From b662966936791fd9834565fd85aee2e254b5ad11 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 10 Jun 2026 16:08:26 +0800 Subject: [PATCH 01/78] docs(rfc): vp migrate upgrade path for existing Vite+ projects Proposes extending vp migrate to repair projects migrated by older Vite+ versions after the @voidzero-dev/vite-plus-test wrapper removal (#1588): state-based detection of stale wrapper aliases, an upgrade-fixups registry, per-package-manager override reconciliation, and a global-CLI preflight to avoid the local-first delegation chicken-and-egg. --- rfcs/migrate-upgrade-path.md | 239 +++++++++++++++++++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 rfcs/migrate-upgrade-path.md diff --git a/rfcs/migrate-upgrade-path.md b/rfcs/migrate-upgrade-path.md new file mode 100644 index 0000000000..887621abcb --- /dev/null +++ b/rfcs/migrate-upgrade-path.md @@ -0,0 +1,239 @@ +# RFC: `vp migrate` Upgrade Path for Existing Vite+ Projects + +- Status: Draft (for discussion) +- Depends on: [#1588 refactor: replace @voidzero-dev/vite-plus-test with upstream vitest](https://github.com/voidzero-dev/vite-plus/pull/1588) +- Related: [migration-command.md](./migration-command.md), [upgrade-command.md](./upgrade-command.md), `docs/guide/upgrade.md` + +## Background + +PR #1588 deletes the bundled `@voidzero-dev/vite-plus-test` wrapper and consumes upstream `vitest` directly. New migrations write a different dependency shape: `vitest` and nine `@vitest/*` internals are pinned to the bundled `VITEST_VERSION` in the package-manager override mechanism, instead of aliasing `vitest` to the wrapper. + +Every project migrated **before** #1588 carries the old shape on disk. Per package manager: + +| Package manager | Location | Stale entry | +| ----------------------------- | ------------------------------------------------------ | ------------------------------------------------------------- | +| pnpm | `pnpm-workspace.yaml` `catalog` (and named `catalogs`) | `vitest: npm:@voidzero-dev/vite-plus-test@latest` (or pinned) | +| pnpm (existing `pnpm` config) | `package.json` `pnpm.overrides` | same alias | +| npm | `package.json` `overrides` | `"vitest": "npm:@voidzero-dev/vite-plus-test@latest"` | +| bun | `package.json` `overrides` / `workspaces.catalog` | same alias | +| yarn | `package.json` `resolutions` | same alias | +| all | dependency fields (`devDependencies` etc.) | `vitest` aliased to the wrapper in some setups | +| all | lockfile | resolved `@voidzero-dev/vite-plus-test` entries | + +Old projects also **lack** entries the new shape requires: + +- The nine `@vitest/*` override/catalog pins (`@vitest/expect`, `runner`, `snapshot`, `spy`, `utils`, `mocker`, `pretty-format`, `coverage-v8`, `coverage-istanbul`). +- The expanded pnpm `peerDependencyRules` (`allowAny` / `allowedVersions` for those packages). +- The pnpm `allowBuilds` entries for browser-provider drivers. + +### What breaks if we do nothing + +The wrapper stays published on npm (existing versions are immutable), so installs do not hard-fail. The failure modes are quieter and worse: + +1. **Permanently stale vitest.** The wrapper receives no further releases. The override forces every `vitest` in the tree, including the one `vite-plus` itself depends on, to the last wrapper version. Users never receive vitest updates or security fixes again, regardless of how often they update `vite-plus`. +2. **Mixed vitest copies.** The unpinned `@vitest/*` internals resolve to the newest 4.x while `vitest` is pinned to the old wrapper. Two physical vitest module graphs is the classic source of mock-hoisting and internal-state bugs. +3. **Peer conflicts.** New `vite-plus` ships `@vitest/browser-*` providers with exact `vitest` peers. The override forces a non-matching version into the tree. +4. **Dead-end update advice.** `docs/guide/upgrade.md` previously told users to run `vp update @voidzero-dev/vite-plus-test`, which now updates to a package that will never move. + +#1588 already adds `pruneLegacyWrapperAliases` / `pruneYamlMapLegacyWrapperAliases` sweeps in `migrator.ts`, but they only run inside the **full migration** path (`rewritePackageJson` / `rewriteConfigs`). When a project already has `vite-plus` as a dependency, `bin.ts` takes the early-return path (`packages/cli/src/migration/bin.ts`, "Early return if already using Vite+") which only offers ESLint, Prettier, git hooks, baseUrl, and node-version migrations. The stale override shape is never touched. That gap is what this RFC closes. + +## Goals + +1. `vp migrate` on a project that already uses Vite+ detects state written by older Vite+ versions and repairs it automatically. +2. The first (and motivating) repair: replace stale `@voidzero-dev/vite-plus-test` aliases with the upstream-vitest shape and add the missing `@vitest/*` pins, across all four package managers, in standalone projects and monorepos. +3. Establish a small, ordered **upgrade-fixups registry** so future breaking changes in the managed dependency shape get the same treatment without redesigning the flow. +4. Idempotent: re-running `vp migrate` on an already-repaired project is a no-op. +5. Conservative: user-authored specs that are not wrapper aliases are preserved (same stance as #1588's prune sweeps). + +## Non-Goals + +- Changing the behavior of `vp migrate` for projects that do not have `vite-plus` yet (the full-migration path already handles stale aliases after #1588). +- A general project codemod system for arbitrary user code. Import rewrites from the original migration are not re-run; `vite-plus/test*` remains the stable public API, so no source files need to change. +- Replacing `vp update` / `vp outdated`. Those remain the way to bump versions day to day; `vp migrate` repairs **shape**, not routine version bumps. + +## Design + +### 1. Detection: state-based, not version-based + +There is no reliable marker recording which Vite+ version performed the original migration, and config files may have been hand-edited since. Detection therefore inspects the actual state: + +- Scan `package.json` (`overrides`, `resolutions`, `pnpm.overrides`, `workspaces.catalog(s)`, dependency fields) and `pnpm-workspace.yaml` (`catalog`, `catalogs`, `overrides`) for specs matching `npm:@voidzero-dev/vite-plus-test` or `npm:@voidzero-dev/vite-plus-test@*` (reuse `isLegacyWrapperSpec` from #1588). +- Independently, detect a **vp-managed override block** (identified by the `vite: npm:@voidzero-dev/vite-plus-core@...` alias) that is missing keys from the current `VITE_PLUS_OVERRIDE_PACKAGES`. This catches projects where someone hand-removed the wrapper alias but still lacks the `@vitest/*` pins. + +Either signal marks the project as needing the fixup. Detection is cheap (file reads, no network, no install). + +### 2. Upgrade-fixups registry + +A new module `packages/cli/src/migration/upgrade-fixups.ts`: + +```ts +interface UpgradeFixup { + /** Stable id, e.g. 'vitest-wrapper-removal' */ + id: string; + /** One-line description shown in the prompt and the summary */ + summary: string; + /** Cheap, read-only check against the workspace */ + detect(workspace: WorkspaceInfo): boolean; + /** Mutates config files; returns what changed for the report */ + apply(workspace: WorkspaceInfo, report: MigrationReport): Promise; +} + +export const UPGRADE_FIXUPS: UpgradeFixup[] = [vitestWrapperRemovalFixup]; +``` + +The already-using-Vite+ path in `bin.ts` runs `detect()` for each registered fixup, prompts once for the batch (see UX below), applies them in order, and triggers a single reinstall if any fixup mutated files. Future breaking changes (for example, if the `vite` alias shape ever changes) append a new entry instead of growing ad-hoc branches. + +### 3. Fixup #1: vitest wrapper removal + +`apply()` reuses the #1588 machinery rather than introducing new rewrite logic: + +1. **Prune wrapper aliases** everywhere they can appear, via `pruneLegacyWrapperAliases` (JSON records: `overrides`, `resolutions`, `pnpm.overrides`, dependency fields, bun `workspaces.catalog(s)`) and `pruneYamlMapLegacyWrapperAliases` (pnpm-workspace.yaml `catalog`, named `catalogs`, `overrides`). `vitest` keys are rewritten to `VITEST_VERSION` so existing `catalog:` references keep resolving; other wrapper-targeted keys are dropped. +2. **Reconcile the managed block to the canonical shape**: for the override mechanism the project already uses, ensure every key in `VITE_PLUS_OVERRIDE_PACKAGES` is present with the canonical value, and extend pnpm `peerDependencyRules` / `allowBuilds` the same way the full migration writes them (reuse the existing per-package-manager writers in `migrator.ts`). Existing keys whose value is a user-authored, non-wrapper spec are left alone and reported as a warning instead of being overwritten. +3. **Walk workspace packages** in monorepos: each package's `package.json` dependency fields get the same prune (mirrors the #1588 sweep at the dependency-field level). + +Before/after, pnpm monorepo (`pnpm-workspace.yaml`): + +```yaml +# before (written by older vp migrate) +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vitest: npm:@voidzero-dev/vite-plus-test@latest + vite-plus: latest +overrides: + vite: 'catalog:' + vitest: 'catalog:' +peerDependencyRules: + allowAny: [vite, vitest] + allowedVersions: { vite: '*', vitest: '*' } + +# after +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vitest: 4.1.7 + '@vitest/expect': 4.1.7 + # ... runner, snapshot, spy, utils, mocker, pretty-format, coverage-v8, coverage-istanbul + vite-plus: latest +allowBuilds: + edgedriver: false + geckodriver: false +overrides: + vite: 'catalog:' + vitest: 'catalog:' + '@vitest/expect': 'catalog:' + # ... same set +peerDependencyRules: + allowAny: [vite, vitest, '@vitest/expect', ...] + allowedVersions: { vite: '*', vitest: '*', '@vitest/expect': '*', ... } +``` + +Before/after, npm/bun standalone (`package.json`): + +```jsonc +// before +"overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "npm:@voidzero-dev/vite-plus-test@latest" +} + +// after +"overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "4.1.7", + "@vitest/expect": "4.1.7" + // ... same set +} +``` + +yarn `resolutions` follows the npm shape. + +### 4. Bumping `vite-plus` itself + +The repaired shape pins `vitest` to the `VITEST_VERSION` baked into the CLI performing the migration. That pin is only correct if the project's `vite-plus` version bundles the same vitest. The fixup therefore also normalizes the `vite-plus` spec: + +- If the spec is a dist-tag (`latest`) or a range satisfied by the CLI's own version, leave it; the reinstall resolves it forward. +- Otherwise (older pinned version), update it to the migrating CLI's version, the same way the full migration pins `vite-plus` today (`catalog:` in monorepos, explicit version standalone). + +This keeps `vite-plus` and the vitest pins in lockstep by construction, because the executing CLI writes both from its own constants. + +### 5. Install and verification + +If any fixup mutated files, run a single `vp install` with `--no-frozen-lockfile` (pnpm/yarn) or `--force` (npm/bun), reusing `handleInstallResult` from #1588 so failures surface as warnings and a non-zero exit code. After install, verify the lockfile contains zero `@voidzero-dev/vite-plus-test` references; if any remain (for example, a transitive dependency the prune could not reach), emit a warning with the offending lockfile keys. + +### 6. Command routing: the chicken-and-egg problem + +`vp migrate` is delegated **local-first** (`crates/vite_global_cli/src/commands/delegate.rs`): if the project has a local `vite-plus`, its (old) migration code runs, which knows nothing about the new shape. Meanwhile the user cannot cleanly get the new local `vite-plus` first, because installing it under the stale overrides produces the mixed-vitest state described above. + +The global `vp` binary is the natural escape hatch: users keep it current via `vp upgrade`, independent of any project. Proposal: + +**Global preflight (recommended).** Before delegating `migrate`, the global CLI runs the cheap stale-state scan itself (or always routes `migrate` for already-Vite+ projects through the global JS CLI). When stale wrapper state is detected, the **global** CLI's migration code executes the fixups, then proceeds with the existing partial migrations. Since the global JS CLI is the same `vite-plus` package at the global version, `VITEST_VERSION` and the writers are automatically consistent. + +Alternative routings are listed under Open Questions. + +### 7. UX + +Interactive: + +``` +$ vp migrate +│ This project already uses Vite+. +│ Detected configuration written by an older Vite+ version: +│ - vitest is aliased to the removed @voidzero-dev/vite-plus-test wrapper +◆ Upgrade the Vite+ dependency setup? +│ Rewrites catalog/overrides to upstream vitest 4.1.7, updates vite-plus, reinstalls. +│ ● Yes / ○ No +``` + +- One prompt for the whole fixup batch, not one per fixup; the bullet list names each detected fixup via its `summary`. +- `--no-interactive` applies the fixups (declining would leave the project broken-by-default; this matches migrate's existing convention of applying safe defaults). Declining interactively prints the manual steps and continues with the other partial migrations. +- The migration summary gains a section, fed by `MigrationReport`: + +``` +Upgraded Vite+ dependency setup + rewrote 2 stale vitest aliases (pnpm-workspace.yaml, packages/app/package.json) + added 9 @vitest/* pins + vite-plus: 0.6.0 -> 0.9.0 +``` + +### 8. Idempotency + +After a successful run, `detect()` returns false for every fixup (no wrapper aliases, no missing keys), so a re-run takes the existing "already using Vite+, happy coding" path. Fixups must be written so that partial failure (e.g. install failed after files were rewritten) is recoverable by simply re-running `vp migrate`. + +## Code Touchpoints + +| Area | Change | +| ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `packages/cli/src/migration/upgrade-fixups.ts` (new) | Fixup interface, registry, vitest-wrapper fixup | +| `packages/cli/src/migration/bin.ts` | Already-Vite+ path: run detection, prompt, apply, fold into the existing single-reinstall logic | +| `packages/cli/src/migration/migrator.ts` | Export/reshape `pruneLegacyWrapperAliases`, `pruneYamlMapLegacyWrapperAliases`, and the per-PM override writers so the fixup can call them outside the full-migration flow | +| `packages/cli/src/migration/report.ts` | `upgradeFixups` entries (id, counts, version bump) | +| `crates/vite_global_cli/src/commands/migrate.rs` (+ `delegate.rs`) | Routing change per the preflight decision | +| `docs/guide/upgrade.md` | New section: upgrading projects migrated by older Vite+ (`vp migrate` repairs the setup) | +| `docs/guide/migrate.md` | Note that running migrate on an existing Vite+ project also repairs older setups | + +## Testing Plan + +- **Unit** (`packages/cli/src/migration/__tests__/upgrade-fixups.spec.ts`): detection and apply for each stale shape: pnpm catalog + named catalogs, `pnpm.overrides` in package.json, npm/bun `overrides`, bun `workspaces.catalog`, yarn `resolutions`, wrapper aliases in dependency fields, pinned wrapper versions (`npm:@voidzero-dev/vite-plus-test@4.0.5`), hand-edited blocks missing only `@vitest/*` keys, user-authored `vitest: ^4.0.0` ranges preserved with warning. +- **Snap tests** (`packages/cli/snap-tests-global/`): new fixtures whose inputs are committed old-shape projects, e.g. `migration-upgrade-stale-vitest-pnpm`, `-npm`, `-yarn`, `-bun`, `migration-upgrade-monorepo-catalog`, plus an idempotency fixture that runs `vp migrate` twice and snapshots the second run's "already using Vite+" output. Fixture inputs must be committed files, not generated by ignored local state. +- **E2E**: take a project migrated by the last pre-#1588 release, run new `vp migrate`, assert `vp test` passes and the lockfile has zero wrapper references. + +## Rollout and Complementary Actions + +1. Land after #1588 merges and ships in the same release if possible, so the first release without the wrapper is also the first that can repair old projects. +2. `npm deprecate @voidzero-dev/vite-plus-test "Merged into vite-plus; run 'vp migrate' to update your project"` so users who never run migrate still get a pointer at install time. +3. Release notes and `docs/guide/upgrade.md` call out the one-command repair: `vp upgrade && vp migrate`. + +## Alternatives Considered + +- **Auto-heal in `vp install`**: detect stale aliases on every install and fix silently. Rejected as the primary mechanism: install should not rewrite config files unprompted, and the migration machinery (prompts, report, per-PM writers) already lives in migrate. A lightweight **warning** in `vp install` pointing at `vp migrate` is proposed as a follow-up (Open Question 2). +- **Hook into `vp update vite-plus`**: reconcile overrides whenever the vite-plus spec is bumped. More magical, splits migration logic across commands, and misses users who edit package.json by hand. The install-time warning covers discovery instead. +- **Version-marker file** (e.g. recording the migrating Vite+ version) to drive upgrade steps by version range. Rejected: state-based detection is robust to hand-edits and requires no new artifact in user repos. +- **Always delegate `migrate` to the global CLI** (drop local-first for this command). Simpler routing than a preflight, and arguably correct since migrate is a toolchain-level operation like `create`, but it changes behavior for users who intentionally pin a local version. Kept as an option in Open Question 1. + +## Open Questions + +1. **Routing**: global preflight scan (recommended) vs. always routing `migrate` through the global CLI for already-Vite+ projects? The preflight keeps local-first semantics for everything else but adds a Rust-side (or pre-delegation JS) scan; always-global is simpler but a behavior change. +2. Should `vp install` (and/or `vp doctor`-style checks, `vp outdated`) **warn** when stale wrapper aliases are present, pointing at `vp migrate`? This is the main discovery mechanism for users who do not think to run migrate again. +3. When the fixup finds a **user-authored `vitest` range** (not a wrapper alias) inside an otherwise vp-managed override block, should we still add the `@vitest/*` pins (risking a mixed tree against their chosen vitest) or skip the whole block with a warning? Current proposal: add nothing, warn, and explain the risk. +4. Should declining the fixup interactively be allowed to proceed with the other partial migrations (current proposal), or should migrate stop early since the project is in a known-broken state? +5. Is bumping the `vite-plus` spec to the migrating CLI's version acceptable in non-interactive mode, or should non-interactive runs require an explicit `--upgrade` flag the first time? (CI running `vp migrate --no-interactive` would otherwise get an unattended dependency bump.) +6. Do we want `vp migrate --check` (detection only, exit code signals drift) for CI, mirroring `vp upgrade --check`? From 8998baf7bfae9ec9dbcc7b8b4fd533f50fceae48 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 18 Jun 2026 09:08:55 +0800 Subject: [PATCH 02/78] docs(rfc): cover real 0.1.24->0.2.0 upgrade failure in vp migrate Rewrite the upgrade-path RFC against merged #1588. Correct the stale pre-merge claims (only vitest is pinned, not nine @vitest/* packages; VITEST_VERSION 4.1.9; age-gate exemption; coverage providers are peer-managed) and document what #1588 already ships (detectVitePlusBootstrapPending / ensureVitePlusBootstrap). Center the real failure found in node-modules/urllib: vp migrate did not upgrade 0.1.24 to 0.2.0. Three compounding blockers, plus coverage skew: - local-first delegation runs the stale 0.1.24 CLI, which writes pnpm-workspace.yaml overrides pinning vite/vitest to ^0.1.24 and the deleted wrapper, actively blocking later upgrades - vp update vite-plus --latest does not reconcile those pins, and the behind core alias (core@^0.1.24) is not caught by the wrapper prune - an empty pkg.pnpm ({}) misroutes the v0.2.0 detector/bootstrap so the real pnpm-workspace.yaml overrides are never repaired - @vitest/coverage-v8 stays at the old version; only a runtime guard warns Add design for routing escalation to the global CLI, behind-alias and empty-pnpm repair, coverage-provider alignment, a urllib-shaped fixture, and open questions to fix the misrouting and the release-notes flow. --- rfcs/migrate-upgrade-path.md | 335 ++++++++++++++++++----------------- 1 file changed, 176 insertions(+), 159 deletions(-) diff --git a/rfcs/migrate-upgrade-path.md b/rfcs/migrate-upgrade-path.md index 887621abcb..f87e466f99 100644 --- a/rfcs/migrate-upgrade-path.md +++ b/rfcs/migrate-upgrade-path.md @@ -1,239 +1,256 @@ # RFC: `vp migrate` Upgrade Path for Existing Vite+ Projects - Status: Draft (for discussion) -- Depends on: [#1588 refactor: replace @voidzero-dev/vite-plus-test with upstream vitest](https://github.com/voidzero-dev/vite-plus/pull/1588) +- Depends on: [#1588 refactor: replace @voidzero-dev/vite-plus-test with upstream vitest](https://github.com/voidzero-dev/vite-plus/pull/1588) (merged, `342fd2f4`) - Related: [migration-command.md](./migration-command.md), [upgrade-command.md](./upgrade-command.md), `docs/guide/upgrade.md` ## Background -PR #1588 deletes the bundled `@voidzero-dev/vite-plus-test` wrapper and consumes upstream `vitest` directly. New migrations write a different dependency shape: `vitest` and nine `@vitest/*` internals are pinned to the bundled `VITEST_VERSION` in the package-manager override mechanism, instead of aliasing `vitest` to the wrapper. +PR #1588 (shipped in v0.2.0) deleted the bundled `@voidzero-dev/vite-plus-test` wrapper and consumes upstream `vitest` directly. The managed dependency shape it writes is: -Every project migrated **before** #1588 carries the old shape on disk. Per package manager: +- `vite` stays aliased to `npm:@voidzero-dev/vite-plus-core@latest` (unchanged). +- `vitest` is pinned to the bundled `VITEST_VERSION` (currently `4.1.9`, in `packages/cli/src/utils/constants.ts`). The `@vitest/*` runtime family (`expect`, `runner`, `snapshot`, `spy`, `utils`, `mocker`, `pretty-format`) are EXACT dependencies of `vitest` itself, so a single `vitest` override cascades one consistent version to the whole tree. They are deliberately NOT pinned individually. +- The package-manager age gate gets `VITEST_AGE_GATE_EXEMPT_PACKAGES = ['vitest', '@vitest/*']` added (pnpm `minimumReleaseAgeExclude` / Yarn `npmPreapprovedPackages`) so the freshly published pinned version is not quarantined. +- Coverage providers (`@vitest/coverage-v8` / `@vitest/coverage-istanbul`) are NOT managed at all: they are peer deps the project installs and versions itself. A runtime guard in `packages/cli/src/define-config.ts` fail-fasts when an installed provider's version skews from the bundled vitest (Vitest otherwise silently runs mixed versions and yields unreliable coverage). -| Package manager | Location | Stale entry | -| ----------------------------- | ------------------------------------------------------ | ------------------------------------------------------------- | -| pnpm | `pnpm-workspace.yaml` `catalog` (and named `catalogs`) | `vitest: npm:@voidzero-dev/vite-plus-test@latest` (or pinned) | -| pnpm (existing `pnpm` config) | `package.json` `pnpm.overrides` | same alias | -| npm | `package.json` `overrides` | `"vitest": "npm:@voidzero-dev/vite-plus-test@latest"` | -| bun | `package.json` `overrides` / `workspaces.catalog` | same alias | -| yarn | `package.json` `resolutions` | same alias | -| all | dependency fields (`devDependencies` etc.) | `vitest` aliased to the wrapper in some setups | -| all | lockfile | resolved `@voidzero-dev/vite-plus-test` entries | +So the canonical v0.2.0 shape, pnpm monorepo (`pnpm-workspace.yaml`): -Old projects also **lack** entries the new shape requires: - -- The nine `@vitest/*` override/catalog pins (`@vitest/expect`, `runner`, `snapshot`, `spy`, `utils`, `mocker`, `pretty-format`, `coverage-v8`, `coverage-istanbul`). -- The expanded pnpm `peerDependencyRules` (`allowAny` / `allowedVersions` for those packages). -- The pnpm `allowBuilds` entries for browser-provider drivers. +```yaml +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vitest: 4.1.9 + vite-plus: latest +overrides: + vite: 'catalog:' + vitest: 'catalog:' +peerDependencyRules: + allowAny: [vite, vitest] + allowedVersions: { vite: '*', vitest: '*' } +minimumReleaseAgeExclude: + - vite-plus + - '@voidzero-dev/*' + # ... oxlint/oxfmt families ... + - vitest + - '@vitest/*' +``` -### What breaks if we do nothing +npm/bun standalone (`package.json`): -The wrapper stays published on npm (existing versions are immutable), so installs do not hard-fail. The failure modes are quieter and worse: +```jsonc +"overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "4.1.9" +} +``` -1. **Permanently stale vitest.** The wrapper receives no further releases. The override forces every `vitest` in the tree, including the one `vite-plus` itself depends on, to the last wrapper version. Users never receive vitest updates or security fixes again, regardless of how often they update `vite-plus`. -2. **Mixed vitest copies.** The unpinned `@vitest/*` internals resolve to the newest 4.x while `vitest` is pinned to the old wrapper. Two physical vitest module graphs is the classic source of mock-hoisting and internal-state bugs. -3. **Peer conflicts.** New `vite-plus` ships `@vitest/browser-*` providers with exact `vitest` peers. The override forces a non-matching version into the tree. -4. **Dead-end update advice.** `docs/guide/upgrade.md` previously told users to run `vp update @voidzero-dev/vite-plus-test`, which now updates to a package that will never move. +### What #1588 already handles -#1588 already adds `pruneLegacyWrapperAliases` / `pruneYamlMapLegacyWrapperAliases` sweeps in `migrator.ts`, but they only run inside the **full migration** path (`rewritePackageJson` / `rewriteConfigs`). When a project already has `vite-plus` as a dependency, `bin.ts` takes the early-return path (`packages/cli/src/migration/bin.ts`, "Early return if already using Vite+") which only offers ESLint, Prettier, git hooks, baseUrl, and node-version migrations. The stale override shape is never touched. That gap is what this RFC closes. +PR #1588 did not stop at the rewrite functions. It also added an "existing Vite+ project" repair path that this RFC originally proposed: -## Goals +- `detectVitePlusBootstrapPending` (`migrator.ts`) inspects, per package manager, whether an already-Vite+ project's override shape is stale, including the case where `vitest` still points at the deleted `@voidzero-dev/vite-plus-test` wrapper (`isSemanticVitePlusOverrideSpec` treats a wrapper alias as NOT satisfied). +- `ensureVitePlusBootstrap` (`migrator.ts`) rewrites overrides/resolutions/catalog/peerDependencyRules to the canonical shape for npm, yarn, bun, and pnpm. +- `bin.ts` wires both into the "already using Vite+" early-return path and triggers one reinstall via `handleInstallResult`. -1. `vp migrate` on a project that already uses Vite+ detects state written by older Vite+ versions and repairs it automatically. -2. The first (and motivating) repair: replace stale `@voidzero-dev/vite-plus-test` aliases with the upstream-vitest shape and add the missing `@vitest/*` pins, across all four package managers, in standalone projects and monorepos. -3. Establish a small, ordered **upgrade-fixups registry** so future breaking changes in the managed dependency shape get the same treatment without redesigning the flow. -4. Idempotent: re-running `vp migrate` on an already-repaired project is a no-op. -5. Conservative: user-authored specs that are not wrapper aliases are preserved (same stance as #1588's prune sweeps). +This is proven by the `migration-already-vite-plus` snap fixture, whose input has `"vitest": "npm:@voidzero-dev/vite-plus-test@latest"` in `overrides` and whose output rewrites it to the bundled `vitest` version, even under `--no-interactive`. -## Non-Goals +### The real gap: upgrading a v0.1.x project (urllib, 0.1.24 -> 0.2.0) -- Changing the behavior of `vp migrate` for projects that do not have `vite-plus` yet (the full-migration path already handles stale aliases after #1588). -- A general project codemod system for arbitrary user code. Import rewrites from the original migration are not re-run; `vite-plus/test*` remains the stable public API, so no source files need to change. -- Replacing `vp update` / `vp outdated`. Those remain the way to bump versions day to day; `vp migrate` repairs **shape**, not routine version bumps. +Running `vp migrate` in a real 0.1.24 project (`node-modules/urllib`) did NOT upgrade the vitest stack. Its `package.json`: -## Design +```jsonc +{ + "devDependencies": { + "@vitest/coverage-v8": "^4.1.8", + "vite": "npm:@voidzero-dev/vite-plus-core@^0.1.24", + "vite-plus": "^0.1.24", + "vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.24", + }, + "overrides": {}, + "pnpm": {}, + "packageManager": "pnpm@11.7.0", +} +``` -### 1. Detection: state-based, not version-based +Three independent root causes, each enough to break the upgrade: -There is no reliable marker recording which Vite+ version performed the original migration, and config files may have been hand-edited since. Detection therefore inspects the actual state: +1. **Routing: the stale local CLI runs (primary cause).** `vp migrate` is delegated **local-first** (`crates/vite_global_cli/src/commands/delegate.rs`). urllib has `vite-plus@0.1.24` installed in `node_modules`, so the global `vp v0.2.0` delegated to the **0.1.24** migrate CLI, which predates #1588 and has no bootstrap/upgrade logic at all. None of the repair above ever executed. This is the chicken-and-egg: the project that most needs the new upgrade code is exactly the project whose installed CLI is too old to contain it. -- Scan `package.json` (`overrides`, `resolutions`, `pnpm.overrides`, `workspaces.catalog(s)`, dependency fields) and `pnpm-workspace.yaml` (`catalog`, `catalogs`, `overrides`) for specs matching `npm:@voidzero-dev/vite-plus-test` or `npm:@voidzero-dev/vite-plus-test@*` (reuse `isLegacyWrapperSpec` from #1588). -- Independently, detect a **vp-managed override block** (identified by the `vite: npm:@voidzero-dev/vite-plus-core@...` alias) that is missing keys from the current `VITE_PLUS_OVERRIDE_PACKAGES`. This catches projects where someone hand-removed the wrapper alias but still lacks the `@vitest/*` pins. +2. **The v0.1.x inline-devDependency-alias shape is not repaired.** v0.1.x migration wrote the aliases directly into `devDependencies` (`vite`/`vitest` aliased to `@voidzero-dev/vite-plus-*@^0.1.24`) with a pinned `vite-plus: ^0.1.24` and empty `overrides`/`pnpm`. Even the v0.2.0 `ensureVitePlusBootstrap` does not fully fix this: + - `ensureVitePlusDependencySpecs` only re-pins `vite-plus` when its spec is `catalog:` or absent. A pinned `^0.1.24` is left untouched, so `vite-plus` resolves to the newest `0.1.x` and never reaches `0.2.0`. + - The inline `vite`/`vitest` alias entries in `devDependencies` are never rewritten, so `vitest` keeps naming the dead `@voidzero-dev/vite-plus-test` wrapper. + - Writing catalog/overrides on top of the surviving inline aliases produces a confusing half-migrated state rather than the canonical shape. -Either signal marks the project as needing the fixup. Detection is cheap (file reads, no network, no install). +3. **Coverage providers are never aligned.** `@vitest/coverage-v8: ^4.1.8` is intentionally outside `VITE_PLUS_OVERRIDE_PACKAGES`. Bootstrap does not touch it and the lockfile keeps `4.1.8`, so it lags the bundled `vitest@4.1.9`. The only feedback is the runtime skew warning/guard in `define-config.ts`, which fires when the user later runs `vp test --coverage`. The migration itself does nothing to bring the provider to `4.1.9`. -### 2. Upgrade-fixups registry +The user-visible symptom is exactly what was reported: after `vp migrate`, `vite-plus` is still `0.1.x` and `@vitest/coverage-v8` is still `4.1.8`, not the expected `4.1.9`. -A new module `packages/cli/src/migration/upgrade-fixups.ts`: +### Why the documented v0.2.0 upgrade flow still fails -```ts -interface UpgradeFixup { - /** Stable id, e.g. 'vitest-wrapper-removal' */ - id: string; - /** One-line description shown in the prompt and the summary */ - summary: string; - /** Cheap, read-only check against the workspace */ - detect(workspace: WorkspaceInfo): boolean; - /** Mutates config files; returns what changed for the report */ - apply(workspace: WorkspaceInfo, report: MigrationReport): Promise; -} +The v0.2.0 release notes document the upgrade as "bump `vite-plus` first, then migrate": -export const UPGRADE_FIXUPS: UpgradeFixup[] = [vitestWrapperRemovalFixup]; +```bash +vp update vite-plus --latest +vp migrate ``` -The already-using-Vite+ path in `bin.ts` runs `detect()` for each registered fixup, prompts once for the batch (see UX below), applies them in order, and triggers a single reinstall if any fixup mutated files. Future breaking changes (for example, if the `vite` alias shape ever changes) append a new entry instead of growing ad-hoc branches. +Following this on urllib still does not upgrade cleanly. The post-run state (directly observed) explains why: -### 3. Fixup #1: vitest wrapper removal +- urllib has a committed `pnpm-workspace.yaml` written by the old 0.1.x CLI that actively **pins** the stack to 0.1.x: -`apply()` reuses the #1588 machinery rather than introducing new rewrite logic: + ```yaml + overrides: + vite: 'npm:@voidzero-dev/vite-plus-core@^0.1.24' # forces core to 0.1.x + vitest: 'npm:@voidzero-dev/vite-plus-test@^0.1.24' # forces the deleted wrapper + ``` -1. **Prune wrapper aliases** everywhere they can appear, via `pruneLegacyWrapperAliases` (JSON records: `overrides`, `resolutions`, `pnpm.overrides`, dependency fields, bun `workspaces.catalog(s)`) and `pruneYamlMapLegacyWrapperAliases` (pnpm-workspace.yaml `catalog`, named `catalogs`, `overrides`). `vitest` keys are rewritten to `VITEST_VERSION` so existing `catalog:` references keep resolving; other wrapper-targeted keys are dropped. -2. **Reconcile the managed block to the canonical shape**: for the override mechanism the project already uses, ensure every key in `VITE_PLUS_OVERRIDE_PACKAGES` is present with the canonical value, and extend pnpm `peerDependencyRules` / `allowBuilds` the same way the full migration writes them (reuse the existing per-package-manager writers in `migrator.ts`). Existing keys whose value is a user-authored, non-wrapper spec are left alone and reported as a warning instead of being overwritten. -3. **Walk workspace packages** in monorepos: each package's `package.json` dependency fields get the same prune (mirrors the #1588 sweep at the dependency-field level). + So the stale CLI does not merely no-op; it has written overrides that block any later upgrade. Installed result: `vite-plus 0.1.24`, `vitest = @voidzero-dev/vite-plus-test 0.1.24`, `@vitest/coverage-v8 4.1.8`. -Before/after, pnpm monorepo (`pnpm-workspace.yaml`): +- `vp update vite-plus --latest` deliberately does NOT re-resolve these aliases/overrides (documented in `docs/guide/upgrade.md`), so the `^0.1.24` pins survive the bump. The `vite` override is a **behind core alias** (`core@^0.1.24`), not the dead wrapper, so #1588's `pruneLegacyWrapperAliases` (which only matches the `@voidzero-dev/vite-plus-test` wrapper) would not even normalize it. -```yaml -# before (written by older vp migrate) -catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: npm:@voidzero-dev/vite-plus-test@latest - vite-plus: latest -overrides: - vite: 'catalog:' - vitest: 'catalog:' -peerDependencyRules: - allowAny: [vite, vitest] - allowedVersions: { vite: '*', vitest: '*' } +- The `vp migrate` step delegates local-first to whatever `vite-plus` is installed; if the update did not actually move the installed CLI past 0.1.x (the override pins fight the bump), migrate re-runs the 0.1.x CLI and rewrites the same old-shape `pnpm-workspace.yaml`. -# after -catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: 4.1.7 - '@vitest/expect': 4.1.7 - # ... runner, snapshot, spy, utils, mocker, pretty-format, coverage-v8, coverage-istanbul - vite-plus: latest -allowBuilds: - edgedriver: false - geckodriver: false -overrides: - vite: 'catalog:' - vitest: 'catalog:' - '@vitest/expect': 'catalog:' - # ... same set -peerDependencyRules: - allowAny: [vite, vitest, '@vitest/expect', ...] - allowedVersions: { vite: '*', vitest: '*', '@vitest/expect': '*', ... } -``` +- Even when the v0.2.0 CLI does run, urllib's `package.json` carries an empty `"pnpm": {}`. Both `detectVitePlusBootstrapPending` and `ensureVitePlusBootstrap` branch on `if (pkg.pnpm)`, and `{}` is truthy, so they inspect `pkg.pnpm.overrides` (empty) and take the `if (!pkg.pnpm)` -> false path that **skips the `pnpm-workspace.yaml` rewrite entirely**. The result is the worst case: a fresh `pnpm.overrides` block is written into `package.json` while the pinning `overrides` in `pnpm-workspace.yaml` are left intact, leaving two conflicting override sources. This is effectively a bug in the #1588 logic: an empty/partial `pkg.pnpm` should be treated as "no package.json pnpm config" so the `pnpm-workspace.yaml` path runs. -Before/after, npm/bun standalone (`package.json`): +So three structural blockers compound: stale-CLI-written pins, `vp update` not reconciling them, and the empty-`pnpm` misrouting that prevents the workspace-yaml repair. Coverage skew remains on top of all three. -```jsonc -// before -"overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "npm:@voidzero-dev/vite-plus-test@latest" -} +## Goals -// after -"overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "4.1.7", - "@vitest/expect": "4.1.7" - // ... same set -} -``` +1. `vp migrate` upgrades a v0.1.x Vite+ project (e.g. `0.1.24`) to the current major (`0.2.0`) end to end: `vite-plus`, the `vite`/`vitest` aliases, and the coverage providers all land on versions consistent with the executing CLI. +2. Fix the routing so the upgrade is performed by a CLI new enough to contain the upgrade logic, instead of silently delegating to the stale local `vite-plus`. +3. Repair the v0.1.x inline-devDependency-alias shape (aliases in `devDependencies`, pinned `vite-plus`, empty `overrides`) into the canonical v0.2.0 shape, in addition to the override-based shape #1588 already handles. +4. Align coverage providers (`@vitest/coverage-v8` / `@vitest/coverage-istanbul`) to the bundled `VITEST_VERSION` during migration, turning the runtime skew warning into a migration-time auto-fix. +5. Idempotent and conservative: a re-run on an upgraded project is a no-op, and user-authored, non-wrapper specs are preserved (same stance as #1588's prune sweeps). -yarn `resolutions` follows the npm shape. +## Non-Goals + +- Changing behavior for projects that do not have `vite-plus` yet; the full-migration path already writes the canonical shape. +- A general project codemod for user source. `vite-plus/test*` remains the stable public API, so no source imports need to change. +- Replacing `vp update` / `vp outdated` for routine version bumps; `vp migrate` repairs shape and performs the cross-major upgrade, not day-to-day patching. +- Pinning the `@vitest/*` runtime family individually. They cascade from the single `vitest` pin and must stay that way (see Background). Coverage providers are the one exception this RFC adds, because they are independently installed peers, not transitive deps of `vitest`. -### 4. Bumping `vite-plus` itself +## Design -The repaired shape pins `vitest` to the `VITEST_VERSION` baked into the CLI performing the migration. That pin is only correct if the project's `vite-plus` version bundles the same vitest. The fixup therefore also normalizes the `vite-plus` spec: +### 1. Routing: never let a stale local CLI silently own the upgrade -- If the spec is a dist-tag (`latest`) or a range satisfied by the CLI's own version, leave it; the reinstall resolves it forward. -- Otherwise (older pinned version), update it to the migrating CLI's version, the same way the full migration pins `vite-plus` today (`catalog:` in monorepos, explicit version standalone). +This is the crux. `vp migrate` must guarantee the migrate logic that runs is at least as new as the global `vp` performing the command. -This keeps `vite-plus` and the vitest pins in lockstep by construction, because the executing CLI writes both from its own constants. +Proposal: a cheap pre-delegation check in the global CLI (`crates/vite_global_cli`). Before `delegate_to_local_cli` for `migrate`: -### 5. Install and verification +- Read the local `vite-plus` version (from `node_modules/vite-plus/package.json`, already resolvable on the delegation path). +- Compare it to the global `vp` version. +- If the local `vite-plus` is older than the global `vp` (cross-major or otherwise behind), do NOT delegate to the local CLI. Run `migrate` from the **global** JS CLI instead (`delegate_to_global_cli`). The global CLI is the same `vite-plus` package at the global version, so `VITE_PLUS_VERSION` / `VITEST_VERSION` / the writers are all consistent and the upgrade targets `0.2.0`. + +This keeps local-first semantics for the normal case (local == global, or local newer) and only escalates when the local CLI is provably too old to perform the upgrade. The global CLI then re-pins `vite-plus` to its own version, so the very next `vp` invocation in the project picks up the upgraded local CLI. + +This is not optional polish: the urllib evidence shows the stale local CLI does not just fail to upgrade, it writes `pnpm-workspace.yaml` overrides that pin `vite`/`vitest` to `^0.1.24` and the deleted wrapper, actively blocking later upgrades. The documented "bump first" flow (`vp update vite-plus --latest && vp migrate`) does not reliably escape this, because `vp update` does not reconcile those pins (by design) and the bump can be fought by the pins themselves. Routing the upgrade to a CLI that is new enough to repair the shape is the only robust fix. + +Alternative (simpler, listed in Open Questions): always route `migrate` through the global CLI, dropping local-first for this one command, on the grounds that migrate is a toolchain-level operation like `create`. + +### 2. Detect the v0.1.x shape (state-based) -If any fixup mutated files, run a single `vp install` with `--no-frozen-lockfile` (pnpm/yarn) or `--force` (npm/bun), reusing `handleInstallResult` from #1588 so failures surface as warnings and a non-zero exit code. After install, verify the lockfile contains zero `@voidzero-dev/vite-plus-test` references; if any remain (for example, a transitive dependency the prune could not reach), emit a warning with the offending lockfile keys. +Extend the existing detection so `detectVitePlusBootstrapPending` (or a sibling) also flags: -### 6. Command routing: the chicken-and-egg problem +- A `vite-plus` dependency spec that is a concrete range/version **older than the executing CLI's major/version** (e.g. `^0.1.24` when the CLI is `0.2.0`), not just `catalog:`/absent. +- `vite`/`vitest` **inline alias** entries in any dependency field pointing at `npm:@voidzero-dev/vite-plus-core@*` or the `@voidzero-dev/vite-plus-test` wrapper, regardless of whether an `overrides`/`catalog` block exists. +- Installed coverage providers whose version does not satisfy the bundled `VITEST_VERSION`. -`vp migrate` is delegated **local-first** (`crates/vite_global_cli/src/commands/delegate.rs`): if the project has a local `vite-plus`, its (old) migration code runs, which knows nothing about the new shape. Meanwhile the user cannot cleanly get the new local `vite-plus` first, because installing it under the stale overrides produces the mixed-vitest state described above. +Detection stays cheap (file reads, plus the already-available installed-version info), no network. + +### 3. Repair the v0.1.x shape + +Extend `ensureVitePlusBootstrap` (or the upgrade fixup it calls) so that, in addition to the override/catalog reconciliation it already does: + +1. **Re-pin `vite-plus`** to the executing CLI's target spec whenever the current spec resolves below the CLI version, not only when it is `catalog:`. For pnpm/bun monorepos this becomes `catalog:` with a `vite-plus: latest` (or the CLI version) catalog entry; for standalone it becomes the explicit version. This is what moves `0.1.24 -> 0.2.0`. +2. **Normalize inline and behind aliases.** Reuse the #1588 prune helpers (`pruneLegacyWrapperAliases`) at the dependency-field level for the dead `vitest: npm:@voidzero-dev/vite-plus-test@*` wrapper, and ADD normalization for **behind core aliases** that the prune does not catch: any `vite: npm:@voidzero-dev/vite-plus-core@` (e.g. `@^0.1.24`) is realigned to `@latest`. Apply this in both `package.json` dependency fields and the override/catalog/`pnpm-workspace.yaml` blocks, then move management into the canonical block so surviving entries match the v0.2.0 shape rather than carrying stale pins. +3. **Reconcile the managed block** to the canonical shape (the part #1588 already does): `overrides`/`resolutions`/`catalog` for `vite` and `vitest`, pnpm `peerDependencyRules`, and the `vitest` / `@vitest/*` age-gate exemptions. User-authored, non-wrapper specs are left alone and reported as a warning instead of being overwritten. +4. **Repair the right pnpm location.** Treat an empty/partial `pkg.pnpm` (e.g. `"pnpm": {}`) as "no package.json pnpm config" so the `pnpm-workspace.yaml` path runs (fixes the misrouting in section Background). When both a `package.json` `pnpm.overrides` and a `pnpm-workspace.yaml` `overrides` exist, reconcile both so the project is not left with two conflicting override sources; prune the stale `^0.1.24`/wrapper pins from whichever location holds them. + +### 4. Align coverage providers + +When migrating, detect `@vitest/coverage-v8` / `@vitest/coverage-istanbul` in any dependency field and rewrite their spec to the bundled `VITEST_VERSION` (e.g. `^4.1.8` -> `4.1.9`), so the installed provider matches the runner and the `define-config.ts` guard stays quiet. This is the migration-time counterpart to that runtime guard: the guard remains the safety net for projects that never re-run migrate, while migrate proactively fixes the version it already knows the correct value for. + +- Only rewrite providers that are already present; never add a coverage provider the project did not have. +- Reuse the same name resolution the runtime guard uses (`@vitest/coverage-`) so the set stays in sync. +- Report each aligned provider in the migration summary. + +### 5. Install and verification -The global `vp` binary is the natural escape hatch: users keep it current via `vp upgrade`, independent of any project. Proposal: +If any repair mutated files, run a single `vp install` with `--no-frozen-lockfile` (pnpm/yarn) or `--force` (npm/bun), reusing `handleInstallResult` so failures surface as warnings and a non-zero exit code (mirrors #1588). After install, verify: -**Global preflight (recommended).** Before delegating `migrate`, the global CLI runs the cheap stale-state scan itself (or always routes `migrate` for already-Vite+ projects through the global JS CLI). When stale wrapper state is detected, the **global** CLI's migration code executes the fixups, then proceeds with the existing partial migrations. Since the global JS CLI is the same `vite-plus` package at the global version, `VITEST_VERSION` and the writers are automatically consistent. +- Zero `@voidzero-dev/vite-plus-test` references remain in the lockfile. +- The resolved `vite-plus`, `vitest`, and any coverage provider are at the expected versions. -Alternative routings are listed under Open Questions. +Emit a warning listing any offending keys if a check fails (e.g. a transitive dep the prune could not reach). -### 7. UX +### 6. UX Interactive: ``` $ vp migrate -│ This project already uses Vite+. -│ Detected configuration written by an older Vite+ version: +│ This project uses an older Vite+ (0.1.24); the global CLI is 0.2.0. +│ Detected setup written by an older Vite+ version: +│ - vite-plus is pinned to 0.1.x │ - vitest is aliased to the removed @voidzero-dev/vite-plus-test wrapper -◆ Upgrade the Vite+ dependency setup? -│ Rewrites catalog/overrides to upstream vitest 4.1.7, updates vite-plus, reinstalls. +│ - @vitest/coverage-v8 (4.1.8) does not match the bundled vitest (4.1.9) +◆ Upgrade this project to Vite+ 0.2.0? +│ Re-pins vite-plus, rewrites the vitest setup to upstream vitest 4.1.9, +│ aligns coverage providers, and reinstalls. │ ● Yes / ○ No ``` -- One prompt for the whole fixup batch, not one per fixup; the bullet list names each detected fixup via its `summary`. -- `--no-interactive` applies the fixups (declining would leave the project broken-by-default; this matches migrate's existing convention of applying safe defaults). Declining interactively prints the manual steps and continues with the other partial migrations. -- The migration summary gains a section, fed by `MigrationReport`: +- One prompt for the whole upgrade, not one per change. +- `--no-interactive` applies the upgrade (declining would leave the project broken-by-default; matches migrate's convention of applying safe defaults). See Open Question 5 for whether an unattended cross-major bump in CI should require an explicit flag. +- Summary section, fed by `MigrationReport`: ``` -Upgraded Vite+ dependency setup - rewrote 2 stale vitest aliases (pnpm-workspace.yaml, packages/app/package.json) - added 9 @vitest/* pins - vite-plus: 0.6.0 -> 0.9.0 +Upgraded Vite+ 0.1.24 -> 0.2.0 + re-pinned vite-plus + rewrote stale vitest wrapper alias -> vitest 4.1.9 + aligned @vitest/coverage-v8 4.1.8 -> 4.1.9 ``` -### 8. Idempotency +### 7. Idempotency -After a successful run, `detect()` returns false for every fixup (no wrapper aliases, no missing keys), so a re-run takes the existing "already using Vite+, happy coding" path. Fixups must be written so that partial failure (e.g. install failed after files were rewritten) is recoverable by simply re-running `vp migrate`. +After a successful upgrade, detection returns false (no wrapper aliases, `vite-plus` at target, providers aligned), so a re-run takes the existing "already using Vite+, happy coding" path. Repairs must be recoverable by re-running `vp migrate` if an install fails after files were rewritten. ## Code Touchpoints -| Area | Change | -| ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `packages/cli/src/migration/upgrade-fixups.ts` (new) | Fixup interface, registry, vitest-wrapper fixup | -| `packages/cli/src/migration/bin.ts` | Already-Vite+ path: run detection, prompt, apply, fold into the existing single-reinstall logic | -| `packages/cli/src/migration/migrator.ts` | Export/reshape `pruneLegacyWrapperAliases`, `pruneYamlMapLegacyWrapperAliases`, and the per-PM override writers so the fixup can call them outside the full-migration flow | -| `packages/cli/src/migration/report.ts` | `upgradeFixups` entries (id, counts, version bump) | -| `crates/vite_global_cli/src/commands/migrate.rs` (+ `delegate.rs`) | Routing change per the preflight decision | -| `docs/guide/upgrade.md` | New section: upgrading projects migrated by older Vite+ (`vp migrate` repairs the setup) | -| `docs/guide/migrate.md` | Note that running migrate on an existing Vite+ project also repairs older setups | +| Area | Change | +| ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `crates/vite_global_cli/src/commands/migrate.rs` (+ `delegate.rs`, version compare helper) | Pre-delegation local-vs-global version check; route `migrate` to the global CLI when the local `vite-plus` is older | +| `packages/cli/src/migration/migrator.ts` | Extend `detectVitePlusBootstrapPending` and `ensureVitePlusBootstrap`: re-pin a behind `vite-plus` (not only `catalog:`), normalize inline/behind `vite`/`vitest` aliases, align coverage providers, treat empty `pkg.pnpm` as "no pnpm config" so the `pnpm-workspace.yaml` path runs, and reconcile both override locations | +| `packages/cli/src/migration/bin.ts` | Surface the version bump and coverage alignment in the existing already-Vite+ path and summary | +| `packages/cli/src/migration/report.ts` | Report fields for version bump and coverage alignment | +| `docs/guide/upgrade.md` | Section: upgrading a v0.1.x project (`vp upgrade && vp migrate`), note that `vp migrate` re-pins and reinstalls | +| `docs/guide/migrate.md` | Note that migrate on an existing Vite+ project performs the cross-version upgrade | ## Testing Plan -- **Unit** (`packages/cli/src/migration/__tests__/upgrade-fixups.spec.ts`): detection and apply for each stale shape: pnpm catalog + named catalogs, `pnpm.overrides` in package.json, npm/bun `overrides`, bun `workspaces.catalog`, yarn `resolutions`, wrapper aliases in dependency fields, pinned wrapper versions (`npm:@voidzero-dev/vite-plus-test@4.0.5`), hand-edited blocks missing only `@vitest/*` keys, user-authored `vitest: ^4.0.0` ranges preserved with warning. -- **Snap tests** (`packages/cli/snap-tests-global/`): new fixtures whose inputs are committed old-shape projects, e.g. `migration-upgrade-stale-vitest-pnpm`, `-npm`, `-yarn`, `-bun`, `migration-upgrade-monorepo-catalog`, plus an idempotency fixture that runs `vp migrate` twice and snapshots the second run's "already using Vite+" output. Fixture inputs must be committed files, not generated by ignored local state. -- **E2E**: take a project migrated by the last pre-#1588 release, run new `vp migrate`, assert `vp test` passes and the lockfile has zero wrapper references. +- **Unit** (`migrator.spec.ts`): the urllib shape (pnpm, inline `vite`/`vitest` aliases in `devDependencies`, pinned `vite-plus: ^0.1.24`, empty `overrides`/`pnpm`, `@vitest/coverage-v8: ^4.1.8`) detected as pending and repaired to: `vite-plus` at target, no wrapper alias, coverage provider at `VITEST_VERSION`. Plus the npm/bun/yarn equivalents and a user-authored non-wrapper `vitest`/coverage range preserved with a warning. +- **Snap tests** (`packages/cli/snap-tests-global/`): a committed `migration-upgrade-v0_1-inline-alias-pnpm` fixture that mirrors urllib EXACTLY (inline `vite`/`vitest` aliases in `devDependencies`, pinned `vite-plus: ^0.1.24`, empty `"pnpm": {}`, AND a committed `pnpm-workspace.yaml` whose `overrides` pin `vite`/`vitest` to `@^0.1.24`/the wrapper, plus `@vitest/coverage-v8: ^4.1.8`). Assert the output has no `^0.1.24`/wrapper pins in either location, a single override source, and aligned coverage. Add standalone npm/yarn/bun variants and an idempotency fixture running `vp migrate` twice. Inputs must be committed files. +- **Routing test** (`crates/vite_global_cli`): with a local `vite-plus` older than the global `vp`, `vp migrate` runs the global migrate path; with local == global it stays local-first. +- **E2E**: a real 0.1.24-shaped project (urllib), run `vp migrate`, assert `vite-plus`, `vitest`, and `@vitest/coverage-v8` resolve to the expected versions and `vp test --coverage` passes with no skew warning. ## Rollout and Complementary Actions -1. Land after #1588 merges and ships in the same release if possible, so the first release without the wrapper is also the first that can repair old projects. -2. `npm deprecate @voidzero-dev/vite-plus-test "Merged into vite-plus; run 'vp migrate' to update your project"` so users who never run migrate still get a pointer at install time. -3. Release notes and `docs/guide/upgrade.md` call out the one-command repair: `vp upgrade && vp migrate`. +1. `npm deprecate @voidzero-dev/vite-plus-test "Merged into vite-plus; run 'vp upgrade && vp migrate' to upgrade your project"` so users who never re-run migrate get a pointer at install time. +2. Release notes and `docs/guide/upgrade.md` document the one-command upgrade: `vp upgrade && vp migrate`. ## Alternatives Considered -- **Auto-heal in `vp install`**: detect stale aliases on every install and fix silently. Rejected as the primary mechanism: install should not rewrite config files unprompted, and the migration machinery (prompts, report, per-PM writers) already lives in migrate. A lightweight **warning** in `vp install` pointing at `vp migrate` is proposed as a follow-up (Open Question 2). -- **Hook into `vp update vite-plus`**: reconcile overrides whenever the vite-plus spec is bumped. More magical, splits migration logic across commands, and misses users who edit package.json by hand. The install-time warning covers discovery instead. -- **Version-marker file** (e.g. recording the migrating Vite+ version) to drive upgrade steps by version range. Rejected: state-based detection is robust to hand-edits and requires no new artifact in user repos. -- **Always delegate `migrate` to the global CLI** (drop local-first for this command). Simpler routing than a preflight, and arguably correct since migrate is a toolchain-level operation like `create`, but it changes behavior for users who intentionally pin a local version. Kept as an option in Open Question 1. +- **Auto-heal in `vp install`**: detect and fix on every install. Rejected as primary mechanism (install should not rewrite config files unprompted), but a lightweight **warning** in `vp install` pointing at `vp migrate` is proposed as a follow-up (Open Question 2). Note this would also have the stale-local-CLI problem unless the warning lives in the global routing layer. +- **Hook into `vp update vite-plus`**: reconcile shape on every bump. Splits migration logic across commands and misses hand edits. Per `docs/guide/upgrade.md`, `vp update` deliberately does not re-resolve the aliases, so this would be a behavior change. +- **Version-marker file** recording the migrating Vite+ version. Rejected: state-based detection is robust to hand-edits and needs no new artifact in user repos. +- **Always delegate `migrate` to the global CLI** (drop local-first for this command). Simpler than the version check; arguably correct since migrate is toolchain-level. Changes behavior for users who intentionally pin a local version. Kept as Open Question 1. ## Open Questions -1. **Routing**: global preflight scan (recommended) vs. always routing `migrate` through the global CLI for already-Vite+ projects? The preflight keeps local-first semantics for everything else but adds a Rust-side (or pre-delegation JS) scan; always-global is simpler but a behavior change. -2. Should `vp install` (and/or `vp doctor`-style checks, `vp outdated`) **warn** when stale wrapper aliases are present, pointing at `vp migrate`? This is the main discovery mechanism for users who do not think to run migrate again. -3. When the fixup finds a **user-authored `vitest` range** (not a wrapper alias) inside an otherwise vp-managed override block, should we still add the `@vitest/*` pins (risking a mixed tree against their chosen vitest) or skip the whole block with a warning? Current proposal: add nothing, warn, and explain the risk. -4. Should declining the fixup interactively be allowed to proceed with the other partial migrations (current proposal), or should migrate stop early since the project is in a known-broken state? -5. Is bumping the `vite-plus` spec to the migrating CLI's version acceptable in non-interactive mode, or should non-interactive runs require an explicit `--upgrade` flag the first time? (CI running `vp migrate --no-interactive` would otherwise get an unattended dependency bump.) -6. Do we want `vp migrate --check` (detection only, exit code signals drift) for CI, mirroring `vp upgrade --check`? +1. **Routing**: pre-delegation local-vs-global version check (recommended) vs. always routing `migrate` through the global CLI? The check preserves local-first for the normal case; always-global is simpler but a behavior change. Either way, what is the comparison rule (any-older, or only cross-major)? +2. Should `vp install` / `vp outdated` **warn** when a stale wrapper alias or a behind `vite-plus` is present, pointing at `vp migrate`? Main discovery path for users who do not re-run migrate. To work for stale-local-CLI projects, the warning must live in the global routing layer. +3. When the project has a **user-authored, non-wrapper `vitest` range** (someone opted out of the managed pin), should migrate still re-pin to `VITEST_VERSION` and align coverage, or skip with a warning? Current proposal: preserve the user's `vitest`, warn, and skip coverage alignment for that project to avoid forcing a mixed tree. +4. Coverage alignment policy: always rewrite to the exact bundled `VITEST_VERSION`, or to a compatible caret range? Exact matches the runtime guard's expectation (the guard wants an exact-version match); a caret could drift again. Current proposal: exact. +5. Should a cross-major bump under `--no-interactive` (CI) be automatic, or require an explicit `--upgrade` flag the first time, so CI does not get an unattended major bump? +6. Do we want `vp migrate --check` (detection only, exit code signals an available upgrade) for CI, mirroring `vp upgrade --check`? +7. The empty-`pkg.pnpm` misrouting (Background) is arguably a standalone bug in #1588 worth fixing immediately, independent of the rest of this RFC. Should it ship as a separate fix first, with a regression test for the `"pnpm": {}` + `pnpm-workspace.yaml` shape? +8. Should the v0.2.0 release notes upgrade flow be corrected? As written (`vp update vite-plus --latest && vp migrate`) it does not reliably upgrade projects with stale pinning overrides; the recommended flow may need to be `vp upgrade` (global) then `vp migrate`, once routing escalates to the global CLI. From 94aa32f6196dc4f0604b61f172058f6946c0e967 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 18 Jun 2026 20:45:46 +0800 Subject: [PATCH 03/78] docs(rfc): align vp migrate upgrade with the v0.2.1 prompt spec Rewrite the upgrade-path RFC to make vp migrate reliably reproduce the manual 'Upgrading from 0.1.x to 0.2.1' prompt, so the release notes' 'do not run vp migrate; not reliable enough yet' disclaimer can be dropped. The prompt corrects a wrong assumption in the prior draft: the upgrade is a usage-based decision, not 'always pin vitest'. - Common case (no direct vitest usage): remove vitest from deps and every resolution mechanism; do not pin. It arrives transitively through vite-plus, so future vp update keeps it correct with no pin to drift. - Direct-usage case (a @vitest/* package listed, e.g. urllib's coverage-v8, or a direct vitest/@vitest import): pin vitest to the bundled 4.1.9 and align every @vitest/* and vitest-browser-* so the tree resolves to a single vitest copy. Also: pin vite-plus and the vite->core alias to the EXACT target version in every workspace package; remove wrapper-only peerDependencyRules / yarn packageExtensions; fix the empty-pnpm misrouting and dual override sources; verify single vitest version; honor git hooks and minimal-edit constraints. --- rfcs/migrate-upgrade-path.md | 276 ++++++++++++++--------------------- 1 file changed, 106 insertions(+), 170 deletions(-) diff --git a/rfcs/migrate-upgrade-path.md b/rfcs/migrate-upgrade-path.md index f87e466f99..29207563db 100644 --- a/rfcs/migrate-upgrade-path.md +++ b/rfcs/migrate-upgrade-path.md @@ -2,60 +2,28 @@ - Status: Draft (for discussion) - Depends on: [#1588 refactor: replace @voidzero-dev/vite-plus-test with upstream vitest](https://github.com/voidzero-dev/vite-plus/pull/1588) (merged, `342fd2f4`) +- Spec source: the ["Upgrading from 0.1.x to 0.2.1 Prompt"](https://github.com/voidzero-dev/vite-plus/releases/tag/v0.2.1) in the v0.2.1 release notes - Related: [migration-command.md](./migration-command.md), [upgrade-command.md](./upgrade-command.md), `docs/guide/upgrade.md` -## Background +## Summary -PR #1588 (shipped in v0.2.0) deleted the bundled `@voidzero-dev/vite-plus-test` wrapper and consumes upstream `vitest` directly. The managed dependency shape it writes is: +The v0.2.1 release notes ship a careful, manual AI-agent prompt for upgrading a project from v0.1.x and explicitly say: -- `vite` stays aliased to `npm:@voidzero-dev/vite-plus-core@latest` (unchanged). -- `vitest` is pinned to the bundled `VITEST_VERSION` (currently `4.1.9`, in `packages/cli/src/utils/constants.ts`). The `@vitest/*` runtime family (`expect`, `runner`, `snapshot`, `spy`, `utils`, `mocker`, `pretty-format`) are EXACT dependencies of `vitest` itself, so a single `vitest` override cascades one consistent version to the whole tree. They are deliberately NOT pinned individually. -- The package-manager age gate gets `VITEST_AGE_GATE_EXEMPT_PACKAGES = ['vitest', '@vitest/*']` added (pnpm `minimumReleaseAgeExclude` / Yarn `npmPreapprovedPackages`) so the freshly published pinned version is not quarantined. -- Coverage providers (`@vitest/coverage-v8` / `@vitest/coverage-istanbul`) are NOT managed at all: they are peer deps the project installs and versions itself. A runtime guard in `packages/cli/src/define-config.ts` fail-fasts when an installed provider's version skews from the bundled vitest (Vitest otherwise silently runs mixed versions and yields unreliable coverage). +> Do not run `vp migrate` for this upgrade; it is not reliable enough yet. Make the changes yourself by editing the project's files, then verify by running the tools. -So the canonical v0.2.0 shape, pnpm monorepo (`pnpm-workspace.yaml`): +That prompt is the authoritative description of the correct end state. This RFC's goal is to make `vp migrate` reliably reproduce that end state so the disclaimer can be removed. The prompt also corrects a key assumption in earlier drafts of this RFC: the upgrade is NOT "always pin `vitest`". It is a usage-based decision that, in the common case, REMOVES `vitest` from the project entirely and lets it arrive transitively through `vite-plus`. -```yaml -catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: 4.1.9 - vite-plus: latest -overrides: - vite: 'catalog:' - vitest: 'catalog:' -peerDependencyRules: - allowAny: [vite, vitest] - allowedVersions: { vite: '*', vitest: '*' } -minimumReleaseAgeExclude: - - vite-plus - - '@voidzero-dev/*' - # ... oxlint/oxfmt families ... - - vitest - - '@vitest/*' -``` - -npm/bun standalone (`package.json`): +## Background -```jsonc -"overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "4.1.9" -} -``` +PR #1588 (shipped in v0.2.0) deleted the bundled `@voidzero-dev/vite-plus-test` wrapper and consumes upstream `vitest` directly. Today `ensureVitePlusBootstrap` (`migrator.ts`) unconditionally writes a managed `vitest` entry (pinned to `VITEST_VERSION`, currently `4.1.9`) into the project's override/catalog block for every already-Vite+ project, alongside the `vite` -> `npm:@voidzero-dev/vite-plus-core@latest` alias. `@vitest/*` runtime internals are NOT pinned (they are exact deps of `vitest`); coverage providers (`@vitest/coverage-v8` / `-istanbul`) are NOT managed and only get a runtime skew guard in `define-config.ts`. ### What #1588 already handles -PR #1588 did not stop at the rewrite functions. It also added an "existing Vite+ project" repair path that this RFC originally proposed: - -- `detectVitePlusBootstrapPending` (`migrator.ts`) inspects, per package manager, whether an already-Vite+ project's override shape is stale, including the case where `vitest` still points at the deleted `@voidzero-dev/vite-plus-test` wrapper (`isSemanticVitePlusOverrideSpec` treats a wrapper alias as NOT satisfied). -- `ensureVitePlusBootstrap` (`migrator.ts`) rewrites overrides/resolutions/catalog/peerDependencyRules to the canonical shape for npm, yarn, bun, and pnpm. -- `bin.ts` wires both into the "already using Vite+" early-return path and triggers one reinstall via `handleInstallResult`. - -This is proven by the `migration-already-vite-plus` snap fixture, whose input has `"vitest": "npm:@voidzero-dev/vite-plus-test@latest"` in `overrides` and whose output rewrites it to the bundled `vitest` version, even under `--no-interactive`. +PR #1588 added an "existing Vite+ project" repair path: `detectVitePlusBootstrapPending` + `ensureVitePlusBootstrap`, wired into the "already using Vite+" branch of `bin.ts` with one reinstall via `handleInstallResult`. It rewrites a stale `vitest: npm:@voidzero-dev/vite-plus-test@*` wrapper alias to the bundled vitest, proven by the `migration-already-vite-plus` snap fixture (even under `--no-interactive`). This is the foundation to build on, but as the prompt and the urllib evidence below show, it does the wrong thing in two ways: it pins `vitest` even when the project does not use it, and it misses several stale shapes. -### The real gap: upgrading a v0.1.x project (urllib, 0.1.24 -> 0.2.0) +### The real gap: upgrading a v0.1.x project (urllib) -Running `vp migrate` in a real 0.1.24 project (`node-modules/urllib`) did NOT upgrade the vitest stack. Its `package.json`: +`vp migrate` on a real 0.1.24 project (`node-modules/urllib`) did NOT upgrade. Its `package.json`: ```jsonc { @@ -63,194 +31,162 @@ Running `vp migrate` in a real 0.1.24 project (`node-modules/urllib`) did NOT up "@vitest/coverage-v8": "^4.1.8", "vite": "npm:@voidzero-dev/vite-plus-core@^0.1.24", "vite-plus": "^0.1.24", - "vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.24", + "vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.24" }, "overrides": {}, "pnpm": {}, - "packageManager": "pnpm@11.7.0", + "packageManager": "pnpm@11.7.0" } ``` -Three independent root causes, each enough to break the upgrade: - -1. **Routing: the stale local CLI runs (primary cause).** `vp migrate` is delegated **local-first** (`crates/vite_global_cli/src/commands/delegate.rs`). urllib has `vite-plus@0.1.24` installed in `node_modules`, so the global `vp v0.2.0` delegated to the **0.1.24** migrate CLI, which predates #1588 and has no bootstrap/upgrade logic at all. None of the repair above ever executed. This is the chicken-and-egg: the project that most needs the new upgrade code is exactly the project whose installed CLI is too old to contain it. - -2. **The v0.1.x inline-devDependency-alias shape is not repaired.** v0.1.x migration wrote the aliases directly into `devDependencies` (`vite`/`vitest` aliased to `@voidzero-dev/vite-plus-*@^0.1.24`) with a pinned `vite-plus: ^0.1.24` and empty `overrides`/`pnpm`. Even the v0.2.0 `ensureVitePlusBootstrap` does not fully fix this: - - `ensureVitePlusDependencySpecs` only re-pins `vite-plus` when its spec is `catalog:` or absent. A pinned `^0.1.24` is left untouched, so `vite-plus` resolves to the newest `0.1.x` and never reaches `0.2.0`. - - The inline `vite`/`vitest` alias entries in `devDependencies` are never rewritten, so `vitest` keeps naming the dead `@voidzero-dev/vite-plus-test` wrapper. - - Writing catalog/overrides on top of the surviving inline aliases produces a confusing half-migrated state rather than the canonical shape. - -3. **Coverage providers are never aligned.** `@vitest/coverage-v8: ^4.1.8` is intentionally outside `VITE_PLUS_OVERRIDE_PACKAGES`. Bootstrap does not touch it and the lockfile keeps `4.1.8`, so it lags the bundled `vitest@4.1.9`. The only feedback is the runtime skew warning/guard in `define-config.ts`, which fires when the user later runs `vp test --coverage`. The migration itself does nothing to bring the provider to `4.1.9`. +plus a committed `pnpm-workspace.yaml` written by the old CLI that actively pins the stack to 0.1.x: -The user-visible symptom is exactly what was reported: after `vp migrate`, `vite-plus` is still `0.1.x` and `@vitest/coverage-v8` is still `4.1.8`, not the expected `4.1.9`. +```yaml +overrides: + vite: 'npm:@voidzero-dev/vite-plus-core@^0.1.24' # forces core to 0.1.x + vitest: 'npm:@voidzero-dev/vite-plus-test@^0.1.24' # forces the deleted wrapper +``` -### Why the documented v0.2.0 upgrade flow still fails +Observed blockers, each sufficient on its own: -The v0.2.0 release notes document the upgrade as "bump `vite-plus` first, then migrate": +1. **Routing: the stale local CLI runs.** `vp migrate` delegates **local-first** (`crates/vite_global_cli/src/commands/delegate.rs`). urllib has `vite-plus@0.1.24` installed, so the global `vp v0.2.x` delegated to the **0.1.24** CLI, which predates #1588 and has no upgrade logic. Worse, that old CLI rewrites the old-shape `pnpm-workspace.yaml`, pinning `vite`/`vitest` to `^0.1.24` and the dead wrapper, which then blocks any later upgrade. The documented `vp update vite-plus --latest && vp migrate` flow does not escape this, because `vp update` deliberately does not reconcile those pins (`docs/guide/upgrade.md`). -```bash -vp update vite-plus --latest -vp migrate -``` +2. **The v0.1.x shapes are not repaired by the v0.2.x bootstrap.** `ensureVitePlusDependencySpecs` only re-pins `vite-plus` when its spec is `catalog:` or absent, so a pinned `^0.1.24` is left untouched and never reaches the target. The inline `vite`/`vitest` aliases in `devDependencies` are never rewritten. The `vite` override is a **behind core alias** (`core@^0.1.24`), not the dead wrapper, so the wrapper-only `pruneLegacyWrapperAliases` does not normalize it. -Following this on urllib still does not upgrade cleanly. The post-run state (directly observed) explains why: +3. **Empty `"pnpm": {}` misroutes the repair.** Both `detectVitePlusBootstrapPending` and `ensureVitePlusBootstrap` branch on `if (pkg.pnpm)`, and `{}` is truthy, so they inspect `pkg.pnpm.overrides` (empty) and take the `if (!pkg.pnpm)` -> false path that **skips the `pnpm-workspace.yaml` rewrite entirely**. A fresh override block lands in `package.json` while the pinning overrides in `pnpm-workspace.yaml` survive, leaving two conflicting override sources. This is effectively a standalone bug in #1588. -- urllib has a committed `pnpm-workspace.yaml` written by the old 0.1.x CLI that actively **pins** the stack to 0.1.x: +### What the v0.2.1 prompt specifies (the correct end state) - ```yaml - overrides: - vite: 'npm:@voidzero-dev/vite-plus-core@^0.1.24' # forces core to 0.1.x - vitest: 'npm:@voidzero-dev/vite-plus-test@^0.1.24' # forces the deleted wrapper - ``` +The prompt encodes the upgrade as these steps (paraphrased; see the release for verbatim text): - So the stale CLI does not merely no-op; it has written overrides that block any later upgrade. Installed result: `vite-plus 0.1.24`, `vitest = @voidzero-dev/vite-plus-test 0.1.24`, `@vitest/coverage-v8 4.1.8`. +1. **Set `vite-plus` to the exact target version (`0.2.1`) and reinstall**, in every workspace package that depends on it. "Changing the spec to `0.2.1` is what moves the lockfile off the old resolution; a reinstall that leaves the spec unchanged would keep the old version." Exact, not a range or `latest`. +2. **Remove the `@voidzero-dev/vite-plus-test` wrapper everywhere** (package.json, lockfile, pnpm-workspace.yaml / .yarnrc.yml catalogs, source imports). Then a **usage-based decision**: + - The project depends on vitest directly ONLY IF a source/test file imports from `vitest` or `@vitest/...`, OR a `@vitest/*` package is in its deps (e.g. a coverage provider). Imports from `vite-plus/test` do NOT count. + - **Common case (no direct usage): remove vitest configuration entirely.** Delete the `vitest` entry from dependencies in whatever form (wrapper alias, `catalog:`, plain version), and remove `vitest` from every resolution mechanism (`overrides`, `resolutions`, pnpm `overrides`/`catalog` in package.json or pnpm-workspace.yaml, any catalog). Do NOT add a pinned `vitest`; it arrives transitively through `vite-plus`. + - **Direct-usage case: pin upstream vitest to the bundled version (`4.1.9`) and align the whole ecosystem.** Set every `@vitest/*` the project lists (`coverage-v8`, `ui`, `browser`, ...) to that same version, and update other integration packages (`vitest-browser-*`) to a compatible release. "Leaving an ecosystem package on an older version pulls in a second copy of vitest, which Vitest rejects at runtime." + - Delete dependency-resolution config that existed only for the wrapper/old vitest: pnpm `peerDependencyRules` (`allowedVersions` / `ignoreMissing`) referencing `vitest` / `@vitest/*` / the wrapper, and yarn `packageExtensions` equivalents. Leave unrelated rules. +3. **Keep the `vite` -> core override, pinned to the exact target**: `vite` -> `npm:@voidzero-dev/vite-plus-core@0.2.1`, in whatever override/resolution/catalog form the project already uses. Core is released in lockstep with `vite-plus`. +4. **Leave `vite-plus/test*` imports unchanged**; only repoint direct `@voidzero-dev/vite-plus-test` imports to `vite-plus/test`. +5. **Reinstall and verify**: no `@voidzero-dev/vite-plus-test` references remain outside `node_modules`; the tree resolves to a **single** `vitest` version (no duplicates); tests pass (native Vitest banner); the `vp check` workflow passes. -- `vp update vite-plus --latest` deliberately does NOT re-resolve these aliases/overrides (documented in `docs/guide/upgrade.md`), so the `^0.1.24` pins survive the bump. The `vite` override is a **behind core alias** (`core@^0.1.24`), not the dead wrapper, so #1588's `pruneLegacyWrapperAliases` (which only matches the `@voidzero-dev/vite-plus-test` wrapper) would not even normalize it. +Constraints: do not bypass git hooks (report pre-existing failures instead); make the smallest set of edits; end with a short summary. -- The `vp migrate` step delegates local-first to whatever `vite-plus` is installed; if the update did not actually move the installed CLI past 0.1.x (the override pins fight the bump), migrate re-runs the 0.1.x CLI and rewrites the same old-shape `pnpm-workspace.yaml`. +Two insights from this change the design: -- Even when the v0.2.0 CLI does run, urllib's `package.json` carries an empty `"pnpm": {}`. Both `detectVitePlusBootstrapPending` and `ensureVitePlusBootstrap` branch on `if (pkg.pnpm)`, and `{}` is truthy, so they inspect `pkg.pnpm.overrides` (empty) and take the `if (!pkg.pnpm)` -> false path that **skips the `pnpm-workspace.yaml` rewrite entirely**. The result is the worst case: a fresh `pnpm.overrides` block is written into `package.json` while the pinning `overrides` in `pnpm-workspace.yaml` are left intact, leaving two conflicting override sources. This is effectively a bug in the #1588 logic: an empty/partial `pkg.pnpm` should be treated as "no package.json pnpm config" so the `pnpm-workspace.yaml` path runs. - -So three structural blockers compound: stale-CLI-written pins, `vp update` not reconciling them, and the empty-`pnpm` misrouting that prevents the workspace-yaml repair. Coverage skew remains on top of all three. +- **The common case is removal, not pinning.** Removing `vitest` (rather than pinning it to an exact version) is what lets future `vp update vite-plus` keep vitest correct automatically: there is no project-level pin to drift. urllib is NOT the common case (it lists `@vitest/coverage-v8`), so it takes the direct-usage branch: pin `vitest` to `4.1.9` and set `@vitest/coverage-v8` to `4.1.9`, which is exactly the version it was missing. +- **Exactness moves the lockfile.** The upgrade must write exact target versions for `vite-plus` and the core alias, in every workspace package, or the lockfile keeps resolving the old version. ## Goals -1. `vp migrate` upgrades a v0.1.x Vite+ project (e.g. `0.1.24`) to the current major (`0.2.0`) end to end: `vite-plus`, the `vite`/`vitest` aliases, and the coverage providers all land on versions consistent with the executing CLI. -2. Fix the routing so the upgrade is performed by a CLI new enough to contain the upgrade logic, instead of silently delegating to the stale local `vite-plus`. -3. Repair the v0.1.x inline-devDependency-alias shape (aliases in `devDependencies`, pinned `vite-plus`, empty `overrides`) into the canonical v0.2.0 shape, in addition to the override-based shape #1588 already handles. -4. Align coverage providers (`@vitest/coverage-v8` / `@vitest/coverage-istanbul`) to the bundled `VITEST_VERSION` during migration, turning the runtime skew warning into a migration-time auto-fix. -5. Idempotent and conservative: a re-run on an upgraded project is a no-op, and user-authored, non-wrapper specs are preserved (same stance as #1588's prune sweeps). +1. `vp migrate` reliably reproduces the v0.2.1 prompt's end state for a v0.1.x project, so the "do not run `vp migrate`" disclaimer can be dropped. +2. Run the upgrade with a CLI new enough to contain this logic (fix the local-first routing that runs a stale 0.1.x CLI). +3. Implement the usage-based vitest decision: remove vitest entirely in the common case; pin + align the ecosystem in the direct-usage case. +4. Pin `vite-plus` and the `vite`->core alias to the exact target version, in every workspace package, so the lockfile moves. +5. Repair all observed stale shapes: inline/behind aliases, the empty-`pnpm` misrouting, dual override sources, and wrapper-only peer config. +6. Verify the end state (no wrapper refs, single vitest version) and respect the prompt's constraints (git hooks, minimal edits, summary). Idempotent on re-run. ## Non-Goals -- Changing behavior for projects that do not have `vite-plus` yet; the full-migration path already writes the canonical shape. -- A general project codemod for user source. `vite-plus/test*` remains the stable public API, so no source imports need to change. -- Replacing `vp update` / `vp outdated` for routine version bumps; `vp migrate` repairs shape and performs the cross-major upgrade, not day-to-day patching. -- Pinning the `@vitest/*` runtime family individually. They cascade from the single `vitest` pin and must stay that way (see Background). Coverage providers are the one exception this RFC adds, because they are independently installed peers, not transitive deps of `vitest`. +- Changing behavior for projects that do not yet use `vite-plus` (the full-migration path already writes the canonical shape). +- Rewriting user source beyond repointing direct `@voidzero-dev/vite-plus-test` imports; `vite-plus/test*` stays the stable public API. +- Pinning the `@vitest/*` runtime internals individually (they cascade from `vitest`). The ecosystem alignment in the direct-usage case targets the packages the project itself lists, not transitive internals. ## Design -### 1. Routing: never let a stale local CLI silently own the upgrade - -This is the crux. `vp migrate` must guarantee the migrate logic that runs is at least as new as the global `vp` performing the command. - -Proposal: a cheap pre-delegation check in the global CLI (`crates/vite_global_cli`). Before `delegate_to_local_cli` for `migrate`: +### 1. Run the right vp (routing) -- Read the local `vite-plus` version (from `node_modules/vite-plus/package.json`, already resolvable on the delegation path). -- Compare it to the global `vp` version. -- If the local `vite-plus` is older than the global `vp` (cross-major or otherwise behind), do NOT delegate to the local CLI. Run `migrate` from the **global** JS CLI instead (`delegate_to_global_cli`). The global CLI is the same `vite-plus` package at the global version, so `VITE_PLUS_VERSION` / `VITEST_VERSION` / the writers are all consistent and the upgrade targets `0.2.0`. +The upgrade logic must execute from a CLI at least as new as the target. The prompt's manual workaround is "after any install, re-resolve vp so you always run the version currently in the project." Automate the same idea: -This keeps local-first semantics for the normal case (local == global, or local newer) and only escalates when the local CLI is provably too old to perform the upgrade. The global CLI then re-pins `vite-plus` to its own version, so the very next `vp` invocation in the project picks up the upgraded local CLI. +- In the global CLI (`crates/vite_global_cli`), before delegating `migrate` local-first, read the local `vite-plus` version and compare to the global `vp`. If local is older, run `migrate` from the **global** JS CLI (`delegate_to_global_cli`) instead of the stale local one. The global CLI's constants (target version, `VITEST_VERSION`) are then self-consistent. +- The upgrade re-pins `vite-plus` to the global version and reinstalls, so the next `vp` in the project resolves to the upgraded local CLI. -This is not optional polish: the urllib evidence shows the stale local CLI does not just fail to upgrade, it writes `pnpm-workspace.yaml` overrides that pin `vite`/`vitest` to `^0.1.24` and the deleted wrapper, actively blocking later upgrades. The documented "bump first" flow (`vp update vite-plus --latest && vp migrate`) does not reliably escape this, because `vp update` does not reconcile those pins (by design) and the bump can be fought by the pins themselves. Routing the upgrade to a CLI that is new enough to repair the shape is the only robust fix. +This is mandatory, not polish: the stale local CLI does not just no-op, it writes pinning overrides that block the upgrade. Simpler alternative (Open Questions): always route `migrate` through the global CLI. -Alternative (simpler, listed in Open Questions): always route `migrate` through the global CLI, dropping local-first for this one command, on the grounds that migrate is a toolchain-level operation like `create`. +### 2. Bump `vite-plus` to the exact target, everywhere, and reinstall -### 2. Detect the v0.1.x shape (state-based) +For every workspace package that depends on `vite-plus`, set the spec to the exact executing-CLI version (e.g. `0.2.1`), not a range or `catalog:`/`latest` placeholder. Extend `ensureVitePlusDependencySpecs` to re-pin a concrete behind spec (`^0.1.24`), not only `catalog:`/absent. Then reinstall with lockfile refresh (`--no-frozen-lockfile` / `--force`) so the lockfile moves off the old resolution. -Extend the existing detection so `detectVitePlusBootstrapPending` (or a sibling) also flags: +### 3. Remove the wrapper and apply the usage-based vitest decision -- A `vite-plus` dependency spec that is a concrete range/version **older than the executing CLI's major/version** (e.g. `^0.1.24` when the CLI is `0.2.0`), not just `catalog:`/absent. -- `vite`/`vitest` **inline alias** entries in any dependency field pointing at `npm:@voidzero-dev/vite-plus-core@*` or the `@voidzero-dev/vite-plus-test` wrapper, regardless of whether an `overrides`/`catalog` block exists. -- Installed coverage providers whose version does not satisfy the bundled `VITEST_VERSION`. +This replaces `ensureVitePlusBootstrap`'s unconditional "write `vitest` into overrides" with the prompt's logic: -Detection stays cheap (file reads, plus the already-available installed-version info), no network. +1. **Detect direct vitest usage**: a source/test file imports from `vitest` or `@vitest/...` (not `vite-plus/test`), OR the project lists any `@vitest/*` package in a dependency field. (Source scan can reuse the migration's existing import walker.) +2. **Common case (no direct usage): purge vitest.** Remove the `vitest` dependency entry in any form, and remove `vitest` from every resolution mechanism (`overrides`, `resolutions`, `pnpm.overrides`, `pnpm-workspace.yaml` `overrides`/`catalog`, bun `workspaces.catalog`, yarn `resolutions`/`.yarnrc.yml` catalog). Add no pin. +3. **Direct-usage case: pin and align.** Set `vitest` (the dependency and/or override the project uses) to `VITEST_VERSION`, and set every `@vitest/*` package the project lists to the same version; bump `vitest-browser-*` and similar integration packages to a compatible release. This subsumes the earlier "coverage provider alignment" goal: `@vitest/coverage-v8: ^4.1.8` -> `4.1.9`. +4. **Behind/inline aliases**: rewrite `vite: npm:@voidzero-dev/vite-plus-core@` to the exact target (`@0.2.1`) wherever it appears, including inline `devDependencies` aliases; reuse `pruneLegacyWrapperAliases` for the dead wrapper and add normalization for behind core aliases. -### 3. Repair the v0.1.x shape +### 4. Pin the `vite` -> core override to the exact target -Extend `ensureVitePlusBootstrap` (or the upgrade fixup it calls) so that, in addition to the override/catalog reconciliation it already does: +Keep the `vite` -> `npm:@voidzero-dev/vite-plus-core@` mapping, set to the exact executing version, in whichever override/resolution/catalog form the project already uses. This is a deliberate change from the current `@latest` convention (see Open Questions) and matches the prompt's lockstep requirement. -1. **Re-pin `vite-plus`** to the executing CLI's target spec whenever the current spec resolves below the CLI version, not only when it is `catalog:`. For pnpm/bun monorepos this becomes `catalog:` with a `vite-plus: latest` (or the CLI version) catalog entry; for standalone it becomes the explicit version. This is what moves `0.1.24 -> 0.2.0`. -2. **Normalize inline and behind aliases.** Reuse the #1588 prune helpers (`pruneLegacyWrapperAliases`) at the dependency-field level for the dead `vitest: npm:@voidzero-dev/vite-plus-test@*` wrapper, and ADD normalization for **behind core aliases** that the prune does not catch: any `vite: npm:@voidzero-dev/vite-plus-core@` (e.g. `@^0.1.24`) is realigned to `@latest`. Apply this in both `package.json` dependency fields and the override/catalog/`pnpm-workspace.yaml` blocks, then move management into the canonical block so surviving entries match the v0.2.0 shape rather than carrying stale pins. -3. **Reconcile the managed block** to the canonical shape (the part #1588 already does): `overrides`/`resolutions`/`catalog` for `vite` and `vitest`, pnpm `peerDependencyRules`, and the `vitest` / `@vitest/*` age-gate exemptions. User-authored, non-wrapper specs are left alone and reported as a warning instead of being overwritten. -4. **Repair the right pnpm location.** Treat an empty/partial `pkg.pnpm` (e.g. `"pnpm": {}`) as "no package.json pnpm config" so the `pnpm-workspace.yaml` path runs (fixes the misrouting in section Background). When both a `package.json` `pnpm.overrides` and a `pnpm-workspace.yaml` `overrides` exist, reconcile both so the project is not left with two conflicting override sources; prune the stale `^0.1.24`/wrapper pins from whichever location holds them. +### 5. Clean wrapper-only resolution config and fix the pnpm location -### 4. Align coverage providers +- Remove pnpm `peerDependencyRules` (`allowAny` / `allowedVersions` / `ignoreMissing`) and yarn `packageExtensions` entries that reference `vitest`, `@vitest/*`, or the wrapper, when they exist only to accommodate the old setup. Leave unrelated rules. +- Treat an empty/partial `pkg.pnpm` (e.g. `"pnpm": {}`) as "no package.json pnpm config" so the `pnpm-workspace.yaml` path runs. When both a `package.json` `pnpm.overrides` and a `pnpm-workspace.yaml` `overrides` exist, reconcile both so the project is not left with two conflicting override sources. -When migrating, detect `@vitest/coverage-v8` / `@vitest/coverage-istanbul` in any dependency field and rewrite their spec to the bundled `VITEST_VERSION` (e.g. `^4.1.8` -> `4.1.9`), so the installed provider matches the runner and the `define-config.ts` guard stays quiet. This is the migration-time counterpart to that runtime guard: the guard remains the safety net for projects that never re-run migrate, while migrate proactively fixes the version it already knows the correct value for. +### 6. Reinstall and verify -- Only rewrite providers that are already present; never add a coverage provider the project did not have. -- Reuse the same name resolution the runtime guard uses (`@vitest/coverage-`) so the set stays in sync. -- Report each aligned provider in the migration summary. +After edits, reinstall once (reusing `handleInstallResult`), then assert the prompt's post-conditions and surface failures as warnings + non-zero exit: -### 5. Install and verification +- No `@voidzero-dev/vite-plus-test` reference anywhere outside `node_modules` (package.json, lockfile, catalogs, sources). +- The dependency tree resolves to a **single** `vitest` version (no duplicate copies). This is the check that catches a missed ecosystem package in the direct-usage branch. +- `vite-plus`, the core alias, and (if present) the aligned `@vitest/*` packages resolve to the expected versions. -If any repair mutated files, run a single `vp install` with `--no-frozen-lockfile` (pnpm/yarn) or `--force` (npm/bun), reusing `handleInstallResult` so failures surface as warnings and a non-zero exit code (mirrors #1588). After install, verify: - -- Zero `@voidzero-dev/vite-plus-test` references remain in the lockfile. -- The resolved `vite-plus`, `vitest`, and any coverage provider are at the expected versions. - -Emit a warning listing any offending keys if a check fails (e.g. a transitive dep the prune could not reach). - -### 6. UX - -Interactive: - -``` -$ vp migrate -│ This project uses an older Vite+ (0.1.24); the global CLI is 0.2.0. -│ Detected setup written by an older Vite+ version: -│ - vite-plus is pinned to 0.1.x -│ - vitest is aliased to the removed @voidzero-dev/vite-plus-test wrapper -│ - @vitest/coverage-v8 (4.1.8) does not match the bundled vitest (4.1.9) -◆ Upgrade this project to Vite+ 0.2.0? -│ Re-pins vite-plus, rewrites the vitest setup to upstream vitest 4.1.9, -│ aligns coverage providers, and reinstalls. -│ ● Yes / ○ No -``` +### 7. Constraints and UX -- One prompt for the whole upgrade, not one per change. -- `--no-interactive` applies the upgrade (declining would leave the project broken-by-default; matches migrate's convention of applying safe defaults). See Open Question 5 for whether an unattended cross-major bump in CI should require an explicit flag. -- Summary section, fed by `MigrationReport`: +Honor the prompt's constraints: do not bypass git hooks (if a pre-existing failure blocks the run, report it rather than forcing through); make the smallest set of edits and do not reformat unrelated files; end with a summary. Interactive run prompts once for the whole upgrade; `--no-interactive` applies it. Summary, fed by `MigrationReport`: ``` -Upgraded Vite+ 0.1.24 -> 0.2.0 - re-pinned vite-plus - rewrote stale vitest wrapper alias -> vitest 4.1.9 - aligned @vitest/coverage-v8 4.1.8 -> 4.1.9 +Upgraded Vite+ 0.1.24 -> 0.2.1 + re-pinned vite-plus and vite->core to 0.2.1 (1 package) + removed @voidzero-dev/vite-plus-test wrapper + project uses vitest directly (@vitest/coverage-v8): pinned vitest 4.1.9, aligned @vitest/coverage-v8 4.1.8 -> 4.1.9 + verified: no wrapper refs, single vitest version ``` -### 7. Idempotency +### 8. Idempotency -After a successful upgrade, detection returns false (no wrapper aliases, `vite-plus` at target, providers aligned), so a re-run takes the existing "already using Vite+, happy coding" path. Repairs must be recoverable by re-running `vp migrate` if an install fails after files were rewritten. +After a successful upgrade, detection returns false (target version pinned, no wrapper, single vitest), so a re-run hits the "already using Vite+, happy coding" path. Repairs must be recoverable by re-running if an install fails after files were rewritten. ## Code Touchpoints -| Area | Change | -| ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `crates/vite_global_cli/src/commands/migrate.rs` (+ `delegate.rs`, version compare helper) | Pre-delegation local-vs-global version check; route `migrate` to the global CLI when the local `vite-plus` is older | -| `packages/cli/src/migration/migrator.ts` | Extend `detectVitePlusBootstrapPending` and `ensureVitePlusBootstrap`: re-pin a behind `vite-plus` (not only `catalog:`), normalize inline/behind `vite`/`vitest` aliases, align coverage providers, treat empty `pkg.pnpm` as "no pnpm config" so the `pnpm-workspace.yaml` path runs, and reconcile both override locations | -| `packages/cli/src/migration/bin.ts` | Surface the version bump and coverage alignment in the existing already-Vite+ path and summary | -| `packages/cli/src/migration/report.ts` | Report fields for version bump and coverage alignment | -| `docs/guide/upgrade.md` | Section: upgrading a v0.1.x project (`vp upgrade && vp migrate`), note that `vp migrate` re-pins and reinstalls | -| `docs/guide/migrate.md` | Note that migrate on an existing Vite+ project performs the cross-version upgrade | +| Area | Change | +| ---- | ------ | +| `crates/vite_global_cli/src/commands/migrate.rs` (+ `delegate.rs`) | Local-vs-global version check; route `migrate` to the global CLI when local `vite-plus` is older | +| `packages/cli/src/migration/migrator.ts` | Replace unconditional vitest pinning with the usage-based decision; exact-version pin of `vite-plus` + core alias for every workspace package; behind/inline alias normalization; empty-`pnpm` fix and dual-source reconciliation; wrapper-only peer-config cleanup | +| `packages/cli/src/migration/detector.ts` | Detect direct vitest usage (source imports + listed `@vitest/*`) | +| `packages/cli/src/migration/bin.ts` | Drive the upgrade in the already-Vite+ path; verify single-vitest post-condition; summary | +| `packages/cli/src/migration/report.ts` | Report version bump, removal-vs-pin decision, ecosystem alignment, verification | +| `docs/guide/upgrade.md` / release notes | Replace the manual prompt + "do not run `vp migrate`" with `vp upgrade && vp migrate` once reliable | ## Testing Plan -- **Unit** (`migrator.spec.ts`): the urllib shape (pnpm, inline `vite`/`vitest` aliases in `devDependencies`, pinned `vite-plus: ^0.1.24`, empty `overrides`/`pnpm`, `@vitest/coverage-v8: ^4.1.8`) detected as pending and repaired to: `vite-plus` at target, no wrapper alias, coverage provider at `VITEST_VERSION`. Plus the npm/bun/yarn equivalents and a user-authored non-wrapper `vitest`/coverage range preserved with a warning. -- **Snap tests** (`packages/cli/snap-tests-global/`): a committed `migration-upgrade-v0_1-inline-alias-pnpm` fixture that mirrors urllib EXACTLY (inline `vite`/`vitest` aliases in `devDependencies`, pinned `vite-plus: ^0.1.24`, empty `"pnpm": {}`, AND a committed `pnpm-workspace.yaml` whose `overrides` pin `vite`/`vitest` to `@^0.1.24`/the wrapper, plus `@vitest/coverage-v8: ^4.1.8`). Assert the output has no `^0.1.24`/wrapper pins in either location, a single override source, and aligned coverage. Add standalone npm/yarn/bun variants and an idempotency fixture running `vp migrate` twice. Inputs must be committed files. -- **Routing test** (`crates/vite_global_cli`): with a local `vite-plus` older than the global `vp`, `vp migrate` runs the global migrate path; with local == global it stays local-first. -- **E2E**: a real 0.1.24-shaped project (urllib), run `vp migrate`, assert `vite-plus`, `vitest`, and `@vitest/coverage-v8` resolve to the expected versions and `vp test --coverage` passes with no skew warning. +- **Unit** (`migrator.spec.ts`): + - urllib shape (pnpm, inline `vite`/`vitest` aliases, pinned `vite-plus: ^0.1.24`, empty `"pnpm": {}`, committed `pnpm-workspace.yaml` pinning to `^0.1.24`/wrapper, `@vitest/coverage-v8: ^4.1.8`) -> direct-usage branch: `vite-plus`/core pinned to target, `vitest` pinned `4.1.9`, `@vitest/coverage-v8` -> `4.1.9`, no wrapper, single override source. + - Common-case shape (uses only `vite-plus/test`, no `@vitest/*` dep): `vitest` removed from deps and all resolution mechanisms, no pin added. + - npm/bun/yarn variants; user-authored non-wrapper `vitest`/coverage range preserved with a warning. +- **Snap tests** (`packages/cli/snap-tests-global/`): committed `migration-upgrade-v0_1-*` fixtures for both branches (direct-usage = urllib mirror, common-case = removal), per package manager, plus an idempotency fixture running `vp migrate` twice. Inputs must be committed files. +- **Routing test** (`crates/vite_global_cli`): local `vite-plus` older than global `vp` runs the global migrate path; equal stays local-first. +- **E2E**: real urllib, run the upgrade, assert no wrapper refs, single `vitest@4.1.9`, `@vitest/coverage-v8@4.1.9`, and `vp run cov` passes with no skew warning. -## Rollout and Complementary Actions +## Rollout -1. `npm deprecate @voidzero-dev/vite-plus-test "Merged into vite-plus; run 'vp upgrade && vp migrate' to upgrade your project"` so users who never re-run migrate get a pointer at install time. -2. Release notes and `docs/guide/upgrade.md` document the one-command upgrade: `vp upgrade && vp migrate`. +1. Land the empty-`pnpm` misrouting fix (Open Question 3) as a standalone bugfix with a regression test, independent of the rest. +2. Ship the full upgrade behavior, then update the v0.2.x release notes / `docs/guide/upgrade.md` to recommend `vp upgrade && vp migrate` and remove the "do not run `vp migrate`" disclaimer. +3. `npm deprecate @voidzero-dev/vite-plus-test "Merged into vite-plus; run 'vp upgrade && vp migrate' to upgrade your project"`. ## Alternatives Considered -- **Auto-heal in `vp install`**: detect and fix on every install. Rejected as primary mechanism (install should not rewrite config files unprompted), but a lightweight **warning** in `vp install` pointing at `vp migrate` is proposed as a follow-up (Open Question 2). Note this would also have the stale-local-CLI problem unless the warning lives in the global routing layer. -- **Hook into `vp update vite-plus`**: reconcile shape on every bump. Splits migration logic across commands and misses hand edits. Per `docs/guide/upgrade.md`, `vp update` deliberately does not re-resolve the aliases, so this would be a behavior change. -- **Version-marker file** recording the migrating Vite+ version. Rejected: state-based detection is robust to hand-edits and needs no new artifact in user repos. -- **Always delegate `migrate` to the global CLI** (drop local-first for this command). Simpler than the version check; arguably correct since migrate is toolchain-level. Changes behavior for users who intentionally pin a local version. Kept as Open Question 1. +- **Keep #1588's always-pin behavior** (write `vitest: VITEST_VERSION` for every project). Rejected: the prompt removes vitest in the common case precisely so future `vp update vite-plus` keeps vitest correct without a project pin to drift. Always-pinning creates per-release maintenance and redundant config. +- **Auto-heal in `vp install`**: rejected as primary mechanism (install should not rewrite config unprompted); a discovery warning pointing at `vp migrate` is a follow-up (Open Question 2). It must live in the global routing layer to reach stale-local-CLI projects. +- **Always delegate `migrate` to the global CLI** (drop local-first for this command). Simpler than the version check; changes behavior for users who pin a local version. Open Question 1. ## Open Questions -1. **Routing**: pre-delegation local-vs-global version check (recommended) vs. always routing `migrate` through the global CLI? The check preserves local-first for the normal case; always-global is simpler but a behavior change. Either way, what is the comparison rule (any-older, or only cross-major)? -2. Should `vp install` / `vp outdated` **warn** when a stale wrapper alias or a behind `vite-plus` is present, pointing at `vp migrate`? Main discovery path for users who do not re-run migrate. To work for stale-local-CLI projects, the warning must live in the global routing layer. -3. When the project has a **user-authored, non-wrapper `vitest` range** (someone opted out of the managed pin), should migrate still re-pin to `VITEST_VERSION` and align coverage, or skip with a warning? Current proposal: preserve the user's `vitest`, warn, and skip coverage alignment for that project to avoid forcing a mixed tree. -4. Coverage alignment policy: always rewrite to the exact bundled `VITEST_VERSION`, or to a compatible caret range? Exact matches the runtime guard's expectation (the guard wants an exact-version match); a caret could drift again. Current proposal: exact. -5. Should a cross-major bump under `--no-interactive` (CI) be automatic, or require an explicit `--upgrade` flag the first time, so CI does not get an unattended major bump? +1. **Routing**: local-vs-global version check (recommended) vs. always routing `migrate` through the global CLI? Comparison rule: any-older or only cross-major? +2. Should `vp install` / `vp outdated` warn when a stale wrapper alias or behind `vite-plus` is present, pointing at `vp migrate`? To reach stale-local-CLI projects the warning must live in the global routing layer. +3. The empty-`pkg.pnpm` misrouting is a standalone #1588 bug. Ship it as a separate fix first, with a regression test for the `"pnpm": {}` + `pnpm-workspace.yaml` shape? +4. **Exact vs `latest`**: the prompt pins `vite-plus` and the core alias to the exact target; the current migrate convention writes `@latest` / `catalog: latest`. Should the upgrade path write exact versions (recommended, guarantees the lockfile moves and matches the prompt), and should normal migrate adopt the same? +5. **Removal default under `--no-interactive`**: removing `vitest` and resolution config is more invasive than pinning. Acceptable unattended in CI, or gated behind an explicit flag the first time? 6. Do we want `vp migrate --check` (detection only, exit code signals an available upgrade) for CI, mirroring `vp upgrade --check`? -7. The empty-`pkg.pnpm` misrouting (Background) is arguably a standalone bug in #1588 worth fixing immediately, independent of the rest of this RFC. Should it ship as a separate fix first, with a regression test for the `"pnpm": {}` + `pnpm-workspace.yaml` shape? -8. Should the v0.2.0 release notes upgrade flow be corrected? As written (`vp update vite-plus --latest && vp migrate`) it does not reliably upgrade projects with stale pinning overrides; the recommended flow may need to be `vp upgrade` (global) then `vp migrate`, once routing escalates to the global CLI. +7. **Direct-usage detection fidelity**: is "any `@vitest/*` listed, or any direct `vitest`/`@vitest` import" sufficient, or do we also need to catch indirect integration packages (`vitest-browser-*`, framework test plugins) that imply vitest usage without a direct import? From 3d30ccc9a4af2eb0ae5148a0c209da830917efc0 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 18 Jun 2026 22:52:46 +0800 Subject: [PATCH 04/78] fix(migrate): make vp migrate upgrade v0.1.x projects to v0.2.x Running vp migrate on a v0.1.x project (e.g. node-modules/urllib) did not upgrade the vitest stack. Four compounding causes, each fixed: - Routing: vp migrate delegates local-first, so a stale local vite-plus (0.1.x, predating the wrapper removal) ran and left the project unmigrated. The global CLI now compares the local vite-plus version to its own and, when local is older, runs migrate from the global CLI so the new upgrade logic executes (delegate_migrate in js_executor). - Empty pnpm field: an empty "pnpm": {} in package.json is truthy, so the bootstrap ignored an existing pnpm-workspace.yaml and left its stale overrides. pnpmConfigLivesInPackageJson routes to the workspace file when one exists so its overrides get reconciled. - Behind vite-plus spec: a pinned ^0.1.24 was left untouched and never moved off 0.1.x. ensureVitePlusDependencySpecs now re-pins a non-protocol-pinned spec to the target so the lockfile moves. - Coverage skew: a listed @vitest/coverage-v8 / -istanbul stayed behind the bundled vitest. It is now aligned to VITEST_VERSION (and the bootstrap detector flags the skew). Adds reproduction tests for each (migrator.spec.ts) and a unit test for the version-comparison routing helper. --- .../vite_global_cli/src/commands/migrate.rs | 11 +- crates/vite_global_cli/src/js_executor.rs | 63 ++++++++++ .../src/migration/__tests__/migrator.spec.ts | 119 ++++++++++++++++++ packages/cli/src/migration/migrator.ts | 114 +++++++++++++++-- 4 files changed, 292 insertions(+), 15 deletions(-) diff --git a/crates/vite_global_cli/src/commands/migrate.rs b/crates/vite_global_cli/src/commands/migrate.rs index a458bbfad4..414b1e2e18 100644 --- a/crates/vite_global_cli/src/commands/migrate.rs +++ b/crates/vite_global_cli/src/commands/migrate.rs @@ -4,11 +4,18 @@ use std::process::ExitStatus; use vite_path::AbsolutePathBuf; -use crate::error::Error; +use crate::{error::Error, js_executor::JsExecutor}; /// Execute the `migrate` command by delegating to local or global vite-plus. +/// +/// Routes through [`JsExecutor::delegate_migrate`], which escalates to the +/// global CLI when the project's local `vite-plus` is older than this global +/// `vp` (the upgrade scenario). Otherwise it keeps local-first semantics. pub async fn execute(cwd: AbsolutePathBuf, args: &[String]) -> Result { - super::delegate::execute(cwd, "migrate", args).await + let mut executor = JsExecutor::new(None); + let mut full_args = vec!["migrate".to_string()]; + full_args.extend(args.iter().cloned()); + executor.delegate_migrate(&cwd, &full_args).await } #[cfg(test)] diff --git a/crates/vite_global_cli/src/js_executor.rs b/crates/vite_global_cli/src/js_executor.rs index 585512d92e..0af79b0399 100644 --- a/crates/vite_global_cli/src/js_executor.rs +++ b/crates/vite_global_cli/src/js_executor.rs @@ -247,6 +247,32 @@ impl JsExecutor { self.run_js_entry_output(project_path, &node_binary, &bin_prefix, args).await } + /// Delegate `migrate`, escalating to the global CLI when the project's local + /// `vite-plus` is older than this global `vp`. A stale local CLI predates the + /// upgrade logic and would otherwise run (and leave the project unmigrated), + /// so the newer global CLI must perform the upgrade; it re-pins `vite-plus`, + /// so the next invocation resolves the upgraded local CLI. When local == global + /// (or local is newer, or none is installed) keep local-first semantics + /// (`delegate_to_local_cli` already falls back to the global bin when no local + /// vite-plus is resolvable). + pub async fn delegate_migrate( + &mut self, + project_path: &AbsolutePath, + args: &[String], + ) -> Result { + let escalate = resolve_local_vite_plus_version(project_path) + .is_some_and(|local| local_vite_plus_is_older(&local, env!("CARGO_PKG_VERSION"))); + if escalate { + tracing::debug!( + "Local vite-plus is older than global vp {}; running migrate from the global CLI", + env!("CARGO_PKG_VERSION") + ); + self.delegate_to_global_cli(project_path, args).await + } else { + self.delegate_to_local_cli(project_path, args).await + } + } + /// Delegate to the global vite-plus CLI entrypoint directly. /// /// Unlike [`delegate_to_local_cli`], this bypasses project-local resolution and always runs @@ -364,6 +390,31 @@ impl JsExecutor { } } +/// Resolve the version of the project-local `vite-plus`, if one is installed. +fn resolve_local_vite_plus_version(project_path: &AbsolutePath) -> Option { + use oxc_resolver::{ResolveOptions, Resolver}; + + let resolver = Resolver::new(ResolveOptions { + condition_names: vec!["import".into(), "node".into()], + ..ResolveOptions::default() + }); + let resolved = resolver.resolve(project_path, "vite-plus/package.json").ok()?; + let content = std::fs::read_to_string(resolved.path()).ok()?; + let value: serde_json::Value = serde_json::from_str(&content).ok()?; + value.get("version")?.as_str().map(str::to_string) +} + +/// True when `local` is a parseable semver strictly older than `global`. +/// +/// Returns false if either version fails to parse (be conservative: never +/// escalate on a version we can't understand). +fn local_vite_plus_is_older(local: &str, global: &str) -> bool { + match (node_semver::Version::parse(local), node_semver::Version::parse(global)) { + (Ok(local_v), Ok(global_v)) => local_v < global_v, + _ => false, + } +} + /// Check whether a project directory has at least one valid version source. /// /// Uses `is_valid_version` (no warning side effects) to avoid duplicate @@ -427,6 +478,18 @@ mod tests { use super::*; + #[test] + fn test_local_vite_plus_is_older() { + // Older local should escalate. + assert!(local_vite_plus_is_older("0.1.24", "0.2.1")); + // Equal versions keep local-first semantics. + assert!(!local_vite_plus_is_older("0.2.1", "0.2.1")); + // Newer local keeps local-first semantics. + assert!(!local_vite_plus_is_older("0.3.0", "0.2.1")); + // Unparseable versions are conservative: never escalate. + assert!(!local_vite_plus_is_older("latest", "0.2.1")); + } + #[test] fn test_js_executor_new() { let executor = JsExecutor::new(None); diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index cae77d6282..337c86d239 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -1464,6 +1464,125 @@ describe('ensureVitePlusBootstrap', () => { expect(workspace.catalog['vite-plus']).toBe('latest'); }); + it('reconciles stale pnpm-workspace.yaml overrides when package.json has an empty pnpm field (urllib shape)', () => { + // urllib 0.1.x shape: an empty `pnpm: {}` in package.json AND a committed + // pnpm-workspace.yaml whose overrides pin vite/vitest to the deleted + // @voidzero-dev/vite-plus-test wrapper. The empty `pnpm: {}` is truthy, so the + // bootstrap used to take the package.json path and IGNORE the workspace.yaml, + // leaving the dead wrapper override in place (and a second, conflicting + // override source in package.json). Because a pnpm-workspace.yaml exists, the + // workspace.yaml is the real config location and must be reconciled. + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'urllib', + devDependencies: { + '@vitest/coverage-v8': '^4.1.8', + vite: 'npm:@voidzero-dev/vite-plus-core@^0.1.24', + 'vite-plus': '^0.1.24', + vitest: 'npm:@voidzero-dev/vite-plus-test@^0.1.24', + }, + pnpm: {}, + devEngines: { + packageManager: { name: 'pnpm', version: '11.7.0', onFail: 'download' }, + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'pnpm-workspace.yaml'), + [ + 'overrides:', + " vite: 'npm:@voidzero-dev/vite-plus-core@^0.1.24'", + " vitest: 'npm:@voidzero-dev/vite-plus-test@^0.1.24'", + 'peerDependencyRules:', + ' allowAny:', + ' - vite', + ' - vitest', + ].join('\n'), + ); + + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); + + // The deleted wrapper alias must no longer survive in the workspace.yaml. + const workspaceRaw = fs.readFileSync(path.join(tmpDir, 'pnpm-workspace.yaml'), 'utf-8'); + expect(workspaceRaw).not.toContain('@voidzero-dev/vite-plus-test'); + + // And the project must not be left pending (no stale wrapper override anywhere). + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); + }); + + it('aligns coverage providers to the bundled vitest version (urllib coverage-v8 symptom)', () => { + // A coverage provider is a project-installed peer that Vitest pins to an + // exact runner version; a skewed copy makes Vitest run mixed versions. The + // upgrade must bump it to the bundled vitest version, not leave it behind. + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { + 'vite-plus': 'latest', + '@vitest/coverage-v8': '^4.1.8', + }, + overrides: { + vite: 'npm:@voidzero-dev/vite-plus-core@latest', + vitest: 'npm:@voidzero-dev/vite-plus-test@latest', + }, + devEngines: { + packageManager: { name: 'npm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(true); + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.npm)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + }; + expect(pkg.devDependencies['@vitest/coverage-v8']).toBe(VITEST_VERSION); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(false); + }); + + it('re-pins a behind vite-plus spec so the upgrade moves off the old version (urllib)', () => { + // urllib pinned vite-plus to a concrete 0.1.x range. A spec that stays at + // ^0.1.24 keeps the lockfile on the old resolution; the upgrade must re-pin + // it to the migrating toolchain target (here the mocked VITE_PLUS_VERSION + // 'latest', materialized as `catalog:` in a pnpm-workspace.yaml project) so + // the reinstall resolves the new version. + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'urllib', + devDependencies: { + 'vite-plus': '^0.1.24', + vite: 'npm:@voidzero-dev/vite-plus-core@^0.1.24', + vitest: 'npm:@voidzero-dev/vite-plus-test@^0.1.24', + }, + pnpm: {}, + devEngines: { + packageManager: { name: 'pnpm', version: '11.7.0', onFail: 'download' }, + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'pnpm-workspace.yaml'), + [ + 'overrides:', + " vite: 'npm:@voidzero-dev/vite-plus-core@^0.1.24'", + " vitest: 'npm:@voidzero-dev/vite-plus-test@^0.1.24'", + ].join('\n'), + ); + + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + }; + // vite-plus must no longer be pinned to the old 0.1.x range. + expect(pkg.devDependencies['vite-plus']).not.toContain('0.1.24'); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); + }); + it('uses a concrete vite-plus version when pnpm config stays in package.json', () => { fs.writeFileSync( path.join(tmpDir, 'package.json'), diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 8d41d2344e..e54b311a2a 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -105,6 +105,13 @@ const PLAYWRIGHT_PROVIDER = '@vitest/browser-playwright'; // forcing pins dropped, while their catalog entries are PRESERVED. const OPT_IN_BROWSER_PROVIDERS = [WEBDRIVERIO_PROVIDER, PLAYWRIGHT_PROVIDER] as const; +// Coverage providers are project-installed peers (NOT bundled by vite-plus). +// Vitest pins each to an exact runner version, and the `define-config.ts` guard +// fail-fasts when an installed provider skews from the bundled vitest (Vitest +// otherwise runs mixed versions and yields unreliable coverage). The upgrade +// aligns any the project lists to the bundled `VITEST_VERSION`. +const VITEST_COVERAGE_PROVIDERS = ['@vitest/coverage-v8', '@vitest/coverage-istanbul'] as const; + // Provider names whose stale pnpm overrides / resolutions are dropped during // migration: everything vite-plus owns (REMOVE_PACKAGES) plus the user-owned // opt-in providers. The provider DEP is preserved, but a leftover @@ -3418,6 +3425,54 @@ function readBunCatalogDependencyResolver(pkg: { fromWorkspaces(catalogSpec, dependencyName) ?? fromPkg(catalogSpec, dependencyName); } +// Decide where a pnpm project keeps its overrides / peer rules. A truthy +// `pkg.pnpm` is not enough: an empty `pnpm: {}` is truthy yet carries no +// config, and when a real `pnpm-workspace.yaml` exists the workspace file is +// the actual config source. Treat the config as living in package.json only +// when `pkg.pnpm` has entries, or when it is present-but-empty AND there is no +// `pnpm-workspace.yaml` to own the config instead. +function pnpmConfigLivesInPackageJson( + pkg: BootstrapPackageJson, + projectPath: string, +): boolean { + if (pkg.pnpm == null) { + return false; + } + return ( + Object.keys(pkg.pnpm).length > 0 || + !fs.existsSync(path.join(projectPath, 'pnpm-workspace.yaml')) + ); +} + +// Pin any coverage provider the project lists to the bundled vitest version. +// Returns true if any spec changed. Providers are plain dependency entries +// (not overrides), so this is package-manager agnostic. +function alignVitestCoverageProviders(pkg: BootstrapPackageJson): boolean { + const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; + let changed = false; + for (const provider of VITEST_COVERAGE_PROVIDERS) { + for (const dependencies of dependencyGroups) { + if (dependencies?.[provider] !== undefined && dependencies[provider] !== VITEST_VERSION) { + dependencies[provider] = VITEST_VERSION; + changed = true; + } + } + } + return changed; +} + +// True when the project lists a coverage provider at a version other than the +// bundled vitest, so the bootstrap should run to realign it. +function vitestCoverageProvidersPending(pkg: BootstrapPackageJson): boolean { + const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; + return VITEST_COVERAGE_PROVIDERS.some((provider) => + dependencyGroups.some( + (dependencies) => + dependencies?.[provider] !== undefined && dependencies[provider] !== VITEST_VERSION, + ), + ); +} + export function detectVitePlusBootstrapPending( projectPath: string, packageManager: PackageManager | undefined, @@ -3436,6 +3491,12 @@ export function detectVitePlusBootstrapPending( return true; } + // A coverage provider skewed from the bundled vitest needs realigning, + // independent of the package manager's override shape. + if (vitestCoverageProvidersPending(pkg)) { + return true; + } + if (packageManager === undefined) { return true; } @@ -3454,11 +3515,11 @@ export function detectVitePlusBootstrapPending( return !overridesSatisfyVitePlus(pkg.overrides, readBunCatalogDependencyResolver(pkg)); } if (packageManager === PackageManager.pnpm) { - if (pkg.pnpm) { + if (pnpmConfigLivesInPackageJson(pkg, projectPath)) { return ( vitePlusDependencyNeedsConcreteVersion(pkg) || - !overridesSatisfyVitePlus(pkg.pnpm.overrides) || - !pnpmPeerDependencyRulesSatisfyVitePlus(pkg.pnpm.peerDependencyRules) + !overridesSatisfyVitePlus(pkg.pnpm?.overrides) || + !pnpmPeerDependencyRulesSatisfyVitePlus(pkg.pnpm?.peerDependencyRules) ); } const resolver = readPnpmWorkspaceCatalogDependencyResolver(projectPath); @@ -3474,13 +3535,31 @@ export function detectVitePlusBootstrapPending( function ensureVitePlusDependencySpecs(pkg: BootstrapPackageJson, version: string): boolean { let changed = false; - if (version !== 'catalog:') { - const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; - for (const dependencies of dependencyGroups) { - if (dependencies?.[VITE_PLUS_NAME]?.startsWith('catalog:')) { - dependencies[VITE_PLUS_NAME] = version; - changed = true; - } + // Re-pin a pre-existing vite-plus spec to the migrating toolchain target so + // the lockfile moves off an old resolution (e.g. `^0.1.24`). Mirrors the + // full-migration rule at `shouldNormalizeExistingVitePlus`/`canonicalVitePlusSpec`: + // only vanilla version ranges are rewritten; deliberate protocol pins + // (workspace:, link:, file:, npm:, github:, git, http) are preserved. + const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; + for (const dependencies of dependencyGroups) { + const spec = dependencies?.[VITE_PLUS_NAME]; + if (spec === undefined || spec === version) { + continue; + } + // Concrete target (e.g. `latest`): also rewrite an existing `catalog:` + // pin onto the concrete version — `isProtocolPinnedSpec` matches + // `catalog:`, so handle it explicitly before the generic plain-range case. + if (version !== 'catalog:' && spec.startsWith('catalog:')) { + dependencies[VITE_PLUS_NAME] = version; + changed = true; + continue; + } + // Plain (non-protocol-pinned) range like `^0.1.24` → rewrite to the target + // (`catalog:` for catalog-supporting projects, otherwise the concrete + // version). Already-`catalog:` / other protocol pins are left untouched. + if (!isProtocolPinnedSpec(spec)) { + dependencies[VITE_PLUS_NAME] = version; + changed = true; } } if (pkg.devDependencies?.[VITE_PLUS_NAME]) { @@ -3571,7 +3650,9 @@ export function ensureVitePlusBootstrap( catalogs?: Record>; } >(packageJsonPath, (pkg) => { - const usePnpmWorkspaceYaml = workspaceInfo.packageManager === PackageManager.pnpm && !pkg.pnpm; + const usePnpmWorkspaceYaml = + workspaceInfo.packageManager === PackageManager.pnpm && + !pnpmConfigLivesInPackageJson(pkg, projectPath); const supportCatalog = !VITE_PLUS_VERSION.startsWith('file:') && (usePnpmWorkspaceYaml || workspaceInfo.packageManager === PackageManager.bun); @@ -3579,6 +3660,7 @@ export function ensureVitePlusBootstrap( pkg, supportCatalog ? 'catalog:' : VITE_PLUS_VERSION, ); + packageJsonChanged = alignVitestCoverageProviders(pkg) || packageJsonChanged; if (workspaceInfo.packageManager === PackageManager.npm) { packageJsonChanged = ensureNpmVitePlusManagedDependencies(pkg) || packageJsonChanged; } @@ -3601,7 +3683,13 @@ export function ensureVitePlusBootstrap( pkg.overrides = ensured.overrides; packageJsonChanged = true; } - } else if (workspaceInfo.packageManager === PackageManager.pnpm && pkg.pnpm) { + } else if ( + workspaceInfo.packageManager === PackageManager.pnpm && + pnpmConfigLivesInPackageJson(pkg, projectPath) + ) { + // `pnpmConfigLivesInPackageJson` guarantees `pkg.pnpm` is present here, + // but it may be an empty object (no pnpm-workspace.yaml case), so seed it. + pkg.pnpm ??= {}; const ensured = ensureOverrideEntries(pkg.pnpm.overrides); if (ensured.changed) { pkg.pnpm.overrides = ensured.overrides; @@ -3616,7 +3704,7 @@ export function ensureVitePlusBootstrap( if (workspaceInfo.packageManager === PackageManager.pnpm) { const pkg = readJsonFile(packageJsonPath) as BootstrapPackageJson; - if (!pkg.pnpm) { + if (!pnpmConfigLivesInPackageJson(pkg, projectPath)) { const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); const before = fs.existsSync(pnpmWorkspaceYamlPath) ? fs.readFileSync(pnpmWorkspaceYamlPath, 'utf-8') From 8eb837d33ab2ee8fdc9ab0b46b77dfa3dfd19a8f Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 18 Jun 2026 23:41:29 +0800 Subject: [PATCH 05/78] feat(migrate): manage vitest only when the project uses it directly Per the v0.2.1 upgrade spec, vite-plus consumes upstream vitest transitively, so a project-level vitest pin should exist only when the project actually uses vitest. Make the managed override set usage-aware: - A project uses vitest directly when it lists a vitest ecosystem dep (@vitest/* or vitest-*), imports vitest/@vitest in source, or runs vitest browser mode. - Common case (no direct usage): remove vitest entirely from deps and every resolution mechanism (overrides, resolutions, pnpm overrides, pnpm-workspace.yaml overrides/catalog, bun catalog, yarn resolutions) and from pnpm peerDependencyRules. It then arrives transitively, so a future vp update vite-plus keeps it correct with no pin to drift. - Direct-usage case: keep vitest pinned to the bundled version and align the coverage providers, as before. vite handling is unchanged (always managed). Removal is gated on VITEST_IS_MANAGED_OVERRIDE so force-override/CI mode never strips a user's own vitest. Also fixes two issues found while reviewing this change: - ensureVitePlusDependencySpecs triggered a tsgolint TS18048 after the earlier re-pin rewrite; narrow the dependency group before assigning. - Folding browser mode into the usage signal keeps an injected direct vitest:catalog: from dangling against a vitest-less catalog. --- .../src/migration/__tests__/migrator.spec.ts | 249 +++++++-- packages/cli/src/migration/migrator.ts | 526 +++++++++++++++--- 2 files changed, 643 insertions(+), 132 deletions(-) diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 337c86d239..f102bdc89d 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -1313,11 +1313,13 @@ describe('ensureVitePlusBootstrap', () => { devEngines: { packageManager: { name: string } }; }; expect(pkg.overrides.vite).toContain('@voidzero-dev/vite-plus-core'); - expect(pkg.overrides.vitest).toBe('4.1.9'); + // Common case (no @vitest/* dep, no vitest source): `vitest` is NOT managed — + // it arrives transitively through vite-plus, so no override is written. + expect(pkg.overrides.vitest).toBeUndefined(); expect(pkg.devEngines.packageManager.name).toBe(PackageManager.npm); }); - it('rewrites the stale vitest wrapper override without pinning the @vitest/* family for npm projects', () => { + it('removes the stale vitest wrapper override for a non-vitest npm project', () => { fs.writeFileSync( path.join(tmpDir, 'package.json'), JSON.stringify({ @@ -1336,16 +1338,16 @@ describe('ensureVitePlusBootstrap', () => { const result = ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.npm)); // The `vite` alias still points at the live `@voidzero-dev/vite-plus-core` - // package, so it satisfies the migration and is left untouched. The `vitest` - // alias points at the DELETED `@voidzero-dev/vite-plus-test` wrapper, so it is - // rewritten to the bundled vitest version. The `@vitest/*` family is NOT pinned: - // it resolves transitively from `vitest`'s own exact deps. + // package, so it satisfies the migration and is left untouched. The project + // does NOT use vitest directly (no @vitest/* dep, no vitest source), so the + // stale `vitest` wrapper override (the DELETED `@voidzero-dev/vite-plus-test`) + // is REMOVED entirely — vitest arrives transitively through vite-plus. expect(result.changed).toBe(true); const pkg = readJson(path.join(tmpDir, 'package.json')) as { overrides: Record; }; expect(pkg.overrides.vite).toBe('npm:@voidzero-dev/vite-plus-core@0.1.0'); - expect(pkg.overrides.vitest).toBe('4.1.9'); + expect(pkg.overrides.vitest).toBeUndefined(); expect(pkg.overrides['@vitest/expect']).toBeUndefined(); expect(pkg.overrides['@vitest/coverage-v8']).toBeUndefined(); expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(false); @@ -1378,7 +1380,9 @@ describe('ensureVitePlusBootstrap', () => { dependencies: Record; }; expect(pkg.devDependencies.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); - expect(pkg.dependencies.vitest).toBe('4.1.9'); + // Common case (no @vitest/* dep, no vitest source): the direct `vitest` dep + // is removed — it arrives transitively through vite-plus. + expect(pkg.dependencies.vitest).toBeUndefined(); }); it('normalizes catalog vite-plus pins for npm projects', () => { @@ -1543,6 +1547,129 @@ describe('ensureVitePlusBootstrap', () => { expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(false); }); + it('removes a stale vitest wrapper override for a common-case npm project (no @vitest/* dep, no vitest source)', () => { + // v0.2.1 spec: vite-plus consumes upstream vitest directly, so a project that + // does NOT use vitest directly must NOT carry a managed `vitest` override — + // it arrives transitively through vite-plus. A pre-existing stale wrapper + // override (`npm:@voidzero-dev/vite-plus-test@*`) is REMOVED entirely while + // the `vite` alias stays. The bootstrap is idempotent: a second detect is + // false. + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { 'vite-plus': 'latest' }, + overrides: { + vite: 'npm:@voidzero-dev/vite-plus-core@latest', + vitest: 'npm:@voidzero-dev/vite-plus-test@latest', + }, + devEngines: { + packageManager: { name: 'npm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(true); + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.npm)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + overrides: Record; + }; + expect(pkg.overrides.vitest).toBeUndefined(); + expect(pkg.overrides.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(false); + }); + + it('keeps vitest managed for a direct-usage npm project (@vitest/coverage-v8) and aligns coverage', () => { + // The project lists `@vitest/coverage-v8`, so it USES vitest directly: the + // managed `vitest` override is kept (re-pinned to the bundled vitest version, + // off the stale wrapper) AND the coverage provider is aligned to that version. + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { + 'vite-plus': 'latest', + '@vitest/coverage-v8': '^4.1.8', + }, + overrides: { + vite: 'npm:@voidzero-dev/vite-plus-core@latest', + vitest: 'npm:@voidzero-dev/vite-plus-test@latest', + }, + devEngines: { + packageManager: { name: 'npm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(true); + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.npm)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + overrides: Record; + }; + // vitest stays managed (the stale wrapper is re-pinned to the bundled version). + expect(pkg.overrides.vitest).toBe(VITEST_VERSION); + // Coverage provider aligned to the same bundled vitest version. + expect(pkg.devDependencies['@vitest/coverage-v8']).toBe(VITEST_VERSION); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(false); + }); + + it('removes managed vitest catalog/override/peer entries from pnpm-workspace.yaml in the common case', () => { + // pnpm-workspace.yaml common-case removal: a project with no @vitest/* dep + // and no vitest source must have every managed `vitest` entry (catalog, + // override, peer rule) stripped from the workspace file so vitest resolves + // transitively through vite-plus. + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { 'vite-plus': 'catalog:' }, + devEngines: { + packageManager: { name: 'pnpm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'pnpm-workspace.yaml'), + [ + 'catalog:', + ' vite: npm:@voidzero-dev/vite-plus-core@latest', + ' vitest: npm:@voidzero-dev/vite-plus-test@latest', + ' vite-plus: latest', + 'overrides:', + " vite: 'catalog:'", + " vitest: 'catalog:'", + 'peerDependencyRules:', + ' allowAny:', + ' - vite', + ' - vitest', + ' allowedVersions:', + " vite: '*'", + " vitest: '*'", + '', + ].join('\n'), + ); + + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(true); + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); + + const workspace = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { + catalog: Record; + overrides: Record; + peerDependencyRules: { allowAny: string[]; allowedVersions: Record }; + }; + // Managed `vitest` is gone from every sink; `vite` stays managed. + expect(workspace.catalog.vitest).toBeUndefined(); + expect(workspace.catalog.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + expect(workspace.overrides.vitest).toBeUndefined(); + expect(workspace.overrides.vite).toBe('catalog:'); + expect(workspace.peerDependencyRules.allowAny).toEqual(['vite']); + expect(workspace.peerDependencyRules.allowedVersions).toEqual({ vite: '*' }); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); + }); + it('re-pins a behind vite-plus spec so the upgrade moves off the old version (urllib)', () => { // urllib pinned vite-plus to a concrete 0.1.x range. A spec that stays at // ^0.1.24 keeps the lockfile on the old resolution; the upgrade must re-pin @@ -1737,7 +1864,9 @@ describe('ensureVitePlusBootstrap', () => { }; expect(yarnrc.nodeLinker).toBe('node-modules'); expect(yarnrc.catalog.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); - expect(yarnrc.catalog.vitest).toBe('4.1.9'); + // Common case (no @vitest/* dep, no vitest source): `vitest` is not managed, + // so no catalog entry is written for it. + expect(yarnrc.catalog.vitest).toBeUndefined(); expect(yarnrc.catalog['vite-plus']).toBe('latest'); }); @@ -1771,13 +1900,19 @@ describe('ensureVitePlusBootstrap', () => { expect(result.changed).toBe(true); expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); + // Common case (no @vitest/* dep, no vitest source): the pre-existing managed + // `vitest` catalog/override/peer entries are REMOVED — only `vite` stays + // managed. vitest arrives transitively through vite-plus. const workspace = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { + catalog: Record; + overrides: Record; peerDependencyRules: { allowAny: string[]; allowedVersions: Record }; }; - expect(workspace.peerDependencyRules.allowAny).toEqual(['vite', 'vitest']); + expect(workspace.catalog.vitest).toBeUndefined(); + expect(workspace.overrides.vitest).toBeUndefined(); + expect(workspace.peerDependencyRules.allowAny).toEqual(['vite']); expect(workspace.peerDependencyRules.allowedVersions).toEqual({ vite: '*', - vitest: '*', }); }); @@ -1989,9 +2124,9 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { const overrides = pnpm.overrides as Record; expect(overrides['some-pkg']).toBe('1.0.0'); expect(overrides.vite).toBeDefined(); - // vitest is pinned via overrides so downstream projects resolve a single - // vitest copy (the one vp-cli ships). - expect(overrides.vitest).toBe('4.1.9'); + // Common case (no @vitest/* dep, no vitest source): `vitest` is not managed, + // so no override is written — it arrives transitively through vite-plus. + expect(overrides.vitest).toBeUndefined(); // peerDependencyRules should be present expect(pnpm.peerDependencyRules).toBeDefined(); @@ -2037,9 +2172,10 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { const yaml = readYaml(path.join(tmpDir, 'pnpm-workspace.yaml')); expect(yaml).toContain("vite: 'catalog:'"); - // vitest is now a managed override key — it resolves through the catalog - // like vite does. - expect(yaml).toContain("vitest: 'catalog:'"); + // Common case (no @vitest/* dep, no vitest source): `vitest` is not managed, + // so no `vitest` override is written — it arrives transitively through + // vite-plus. + expect(yaml).not.toContain('vitest'); }); it('rewrites named catalogs in pnpm-workspace.yaml without adding new entries', () => { @@ -2082,16 +2218,16 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { catalogs: Record>; }; expect(yaml.overrides.vite).toBe('catalog:vite7'); - // vitest is now a managed override key — it is added to overrides as a - // `catalog:` reference, and its catalog entry is rewritten to the pinned - // vitest version vp-cli ships. - expect(yaml.overrides.vitest).toBe('catalog:'); - expect(yaml.catalog.vitest).toBe('4.1.9'); + // Common case (no @vitest/* dep, no vitest source): `vitest` is not managed, + // so no override is added and the pre-existing managed `vitest` catalog + // entries (default + named) are REMOVED — it arrives transitively through + // vite-plus. + expect(yaml.overrides.vitest).toBeUndefined(); + expect(yaml.catalog?.vitest).toBeUndefined(); 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'); - // Named catalog vitest entries are also pinned to the managed override version. - expect(yaml.catalogs.test.vitest).toBe('4.1.9'); + expect(yaml.catalogs.test.vitest).toBeUndefined(); expect(yaml.catalogs.test.tsdown).toBeUndefined(); expect(yaml.catalogs.test['vite-plus']).toBeUndefined(); @@ -2102,11 +2238,9 @@ 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'); - // vitest peer `catalog:` is resolved against the pre-rewrite catalog - // (which still holds the user's `^4.0.0`); only the catalog file itself - // is later rewritten to the pinned vp-cli version. The peer range stays - // as the user wrote it. - expect(pkg.peerDependencies.vitest).toBe('^4.0.0'); + // `vitest` is no longer a managed override key, so the peer entry is left as + // the user wrote it (untouched). + expect(pkg.peerDependencies.vitest).toBe('catalog:'); expect(pkg.peerDependencies).not.toHaveProperty('tsdown'); }); @@ -3177,8 +3311,9 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { catalogs: Record>; }; expect(yaml.overrides.vite).toBe('catalog:vite7'); - // vitest is now injected into overrides as a managed override key. - expect(yaml.overrides.vitest).toBe('catalog:'); + // Common case (no @vitest/* dep, no vitest source): `vitest` is not managed, + // so no `vitest` override is injected. + expect(yaml.overrides.vitest).toBeUndefined(); expect(yaml.overrides.react).toBe('^18.0.0'); expect(yaml.catalogs.vite7.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); @@ -3222,8 +3357,9 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { overrides: Record; }; expect(yaml.overrides.vite).toBe('catalog:'); - // vitest is now a managed override key — added to overrides as catalog: ref. - expect(yaml.overrides.vitest).toBe('catalog:'); + // Common case (no @vitest/* dep, no vitest source): `vitest` is not managed, + // so no `vitest` override is added. + expect(yaml.overrides.vitest).toBeUndefined(); }); it('does not resolve peer dependency catalog specs to migrated aliases', () => { @@ -3255,9 +3391,9 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { peerDependencies: Record; }; expect(pkg.peerDependencies.vite).toBe('*'); - // vitest is now a managed override key — peer dep catalog refs that - // resolve to the override target are coerced to '*'. - expect(pkg.peerDependencies.vitest).toBe('*'); + // `vitest` is no longer a managed override key (common case: no @vitest/* + // dep, no vitest source), so its peer entry is left as the user wrote it. + expect(pkg.peerDependencies.vitest).toBe('catalog:'); }); it('adds vitest only to the monorepo package that uses browser mode', () => { @@ -4442,9 +4578,9 @@ describe('rewriteMonorepo yarn catalog', () => { 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'); - // vitest is now a managed override key — existing catalog entries are - // rewritten to the pinned vp-cli vitest version. - expect(yarnrc.catalogs.test.vitest).toBe('4.1.9'); + // Common case (no @vitest/* dep, no vitest source): `vitest` is not managed, + // so the pre-existing named-catalog `vitest` entry is REMOVED. + expect(yarnrc.catalogs.test.vitest).toBeUndefined(); expect(yarnrc.catalogs.test.oxlint).toBeUndefined(); const pkg = readJson(path.join(tmpDir, 'package.json')) as { @@ -4453,10 +4589,9 @@ describe('rewriteMonorepo yarn catalog', () => { }; expect(pkg.devDependencies.vite).toBe('catalog:vite7'); expect(pkg.peerDependencies.vite).toBe('^7.0.0'); - // vitest peer `catalog:test` is resolved against the pre-rewrite catalog - // (which still holds the user's `^4.0.0`). The peer range stays as the - // user wrote it; only the catalog file itself is later rewritten. - expect(pkg.peerDependencies.vitest).toBe('^4.0.0'); + // `vitest` is no longer managed, so the peer entry is left as the user + // wrote it (untouched). + expect(pkg.peerDependencies.vitest).toBe('catalog:test'); }); }); @@ -4549,9 +4684,9 @@ describe('rewriteMonorepo bun catalog', () => { 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'); - // vitest is now a managed override key — pre-existing catalog entries are - // rewritten to the pinned vp-cli vitest version. - expect(pkg.catalog.vitest).toBe('4.1.9'); + // Common case (no @vitest/* dep, no vitest source): `vitest` is not managed, + // so the pre-existing catalog `vitest` entry is REMOVED. + expect(pkg.catalog.vitest).toBeUndefined(); expect(pkg.catalog.tsdown).toBeUndefined(); expect(pkg.catalog.react).toBe('^19.0.0'); expect(pkg.catalog['vite-plus']).toBeUndefined(); @@ -4611,17 +4746,17 @@ describe('rewriteMonorepo bun catalog', () => { 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(); - // vitest is now a managed override key — existing catalog entries are - // rewritten to the pinned version and `overrides.vitest` is injected - // as a `catalog:` ref so bun resolves it through the catalog. - expect(pkg.catalogs.test.vitest).toBe('4.1.9'); + // Common case (no @vitest/* dep, no vitest source): `vitest` is not managed, + // so the pre-existing named-catalog `vitest` entry is REMOVED and no + // `overrides.vitest` is injected. + expect(pkg.catalogs.test.vitest).toBeUndefined(); expect(pkg.overrides.vite).toBe('catalog:build'); - expect(pkg.overrides.vitest).toBe('catalog:'); + expect(pkg.overrides.vitest).toBeUndefined(); expect(pkg.devDependencies.vite).toBe('catalog:build'); expect(pkg.peerDependencies.vite).toBe('^7.0.0'); - // vitest peer `catalog:test` is resolved against the pre-rewrite catalog - // (which still holds the user's `^4.0.0`). Peer range stays as-is. - expect(pkg.peerDependencies.vitest).toBe('^4.0.0'); + // `vitest` is no longer managed, so the peer entry is left as the user + // wrote it (untouched). + expect(pkg.peerDependencies.vitest).toBe('catalog:test'); }); it('rewrites workspaces named catalogs and writes default catalog beside them', () => { @@ -4656,9 +4791,9 @@ describe('rewriteMonorepo bun catalog', () => { 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(); - // vitest is a managed override key — existing catalog entries are - // rewritten to the pinned vp-cli vitest version. - expect(pkg.workspaces.catalogs.test.vitest).toBe('4.1.9'); + // 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:'); }); diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index e54b311a2a..b26b329735 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -490,6 +490,132 @@ const PUBLIC_PEER_DEPENDENCY_FALLBACKS: Record = { vitest: '*', }; +// The managed override/catalog packages vite-plus writes and the detector +// requires. `vite` is ALWAYS managed (aliased to vite-plus-core). `vitest` is +// managed ONLY when the project uses vitest DIRECTLY — vite-plus consumes +// upstream vitest itself, so a non-vitest project gets it transitively through +// vite-plus and must NOT carry a managed `vitest` pin (which would drift on a +// future `vp update vite-plus`). When `usesVitest` is false the common-case +// removal logic ACTIVELY strips any lingering `vitest` entry. +function managedOverridePackages(usesVitest: boolean): Record { + if (usesVitest) { + return VITE_PLUS_OVERRIDE_PACKAGES; + } + // Drop only `vitest`; every other managed key (e.g. `vite`, and in + // force-override/CI mode the `@voidzero-dev/vite-plus-core` file: alias) stays. + return Object.fromEntries( + Object.entries(VITE_PLUS_OVERRIDE_PACKAGES).filter(([key]) => key !== 'vitest'), + ); +} + +// True iff a dependency field lists a vitest ecosystem package — any name that +// contains `vitest` other than bare `vitest` itself (e.g. `@vitest/coverage-v8`, +// `@vitest/browser-playwright`, `vitest-browser-svelte`). A bare `vitest` +// dependency alone is deliberately NOT a signal — a prior migration may have +// injected it transitively-redundantly, so it must not keep the project pinned +// to a managed `vitest`. This mirrors the `isVitestAdjacent` signal used later +// when deciding to inject a direct `vitest`, so the two stay consistent. +function projectListsVitestEcosystemDep(pkg: { + dependencies?: Record; + devDependencies?: Record; + optionalDependencies?: Record; + peerDependencies?: Record; +}): boolean { + const dependencyGroups = [ + pkg.dependencies, + pkg.devDependencies, + pkg.optionalDependencies, + pkg.peerDependencies, + ]; + return dependencyGroups.some((deps) => + deps ? Object.keys(deps).some((name) => name !== 'vitest' && name.includes('vitest')) : false, + ); +} + +// True iff the project uses vitest DIRECTLY — via a vitest ecosystem dependency +// (see `projectListsVitestEcosystemDep`), a source file referencing vitest (the +// `vitest` substring matches `vitest` / `@vitest/` import specifiers and not +// `vite-plus/test`), or vitest browser mode (whose published `vite-plus/test/browser*` +// shims carry no `vitest` substring but still need vitest resolvable). Drives +// whether the migration keeps `vitest` managed or removes it entirely; the +// browser-mode arm keeps it aligned with the direct-`vitest` injection below so +// an injected `catalog:` spec never dangles against a vitest-less catalog. +function projectUsesVitestDirectly( + projectPath: string, + pkg: { + dependencies?: Record; + optionalDependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + }, +): boolean { + return ( + projectListsVitestEcosystemDep(pkg) || + sourceTreeReferencesAny(projectPath, ['vitest']) || + usesVitestBrowserMode(projectPath) + ); +} + +// Common case (`!usesVitest`): vite-plus consumes upstream vitest itself, so a +// lingering `vitest` entry — a managed pin, a stale `npm:@voidzero-dev/vite-plus-test@*` +// wrapper alias, or a `catalog:` reference — must be REMOVED from every sink so +// it arrives transitively through vite-plus and a future `vp update vite-plus` +// keeps it correct with no pin to drift. The `@vitest/*` family is left +// untouched (those are direct-usage signals handled elsewhere). +// +// The removal only applies when `vitest` is a key vite-plus actually manages in +// the active override config. In force-override / CI mode (`VP_OVERRIDE_PACKAGES` +// with file: tgz aliases) `vitest` is NOT in the override set, so a `vitest` +// entry there is the user's own and must be left untouched. +const VITEST_IS_MANAGED_OVERRIDE = 'vitest' in VITE_PLUS_OVERRIDE_PACKAGES; + +// Remove a managed `vitest` key from a flat string-valued record (dependency +// field, npm/bun overrides, yarn resolutions, pnpm.overrides, a catalog object). +// Only a STRING value is removed: a managed pin, `catalog:` reference, or wrapper +// alias is always a string, whereas a nested object value (npm/bun `overrides`) +// is a user override scoped under `vitest` and must be left intact. Returns true +// iff an entry was removed. +function removeManagedVitestEntry(record: Record | undefined): boolean { + if (VITEST_IS_MANAGED_OVERRIDE && typeof record?.vitest === 'string') { + delete record.vitest; + return true; + } + return false; +} + +// Remove a managed `vitest` scalar key from a YAMLMap (pnpm-workspace.yaml +// `overrides`, `catalog`, and each named `catalogs` entry). +function removeYamlMapVitestEntry(map: unknown): void { + if (!VITEST_IS_MANAGED_OVERRIDE || !(map instanceof YAMLMap)) { + return; + } + const target = map.items.find( + (item) => item.key instanceof Scalar && item.key.value === 'vitest', + )?.key; + if (target) { + map.delete(target); + } +} + +// Remove the managed `vitest` entry from pnpm peerDependencyRules (its +// `allowAny` array entry and `allowedVersions.vitest`), in place. Works on both +// the package.json `pnpm.peerDependencyRules` JSON shape and the same shape read +// back from pnpm-workspace.yaml. +function removeVitestPeerDependencyRule(peerDependencyRules: { + allowAny?: string[]; + allowedVersions?: Record; +}): void { + if (!VITEST_IS_MANAGED_OVERRIDE) { + return; + } + if (Array.isArray(peerDependencyRules.allowAny)) { + peerDependencyRules.allowAny = peerDependencyRules.allowAny.filter((key) => key !== 'vitest'); + } + if (peerDependencyRules.allowedVersions) { + delete peerDependencyRules.allowedVersions.vitest; + } +} + // Plugins Oxlint resolves natively (no JS import). Source: // `LintPluginOptionsSchema` in `node_modules/oxlint/dist/index.d.ts`. // Anything else in the merged `lint.plugins[]` after migration is a @@ -1495,6 +1621,10 @@ export function rewriteStandaloneProject( let shouldRewritePnpmWorkspaceYaml = false; let shouldAddPnpmWorkspaceVitePlusOverride = false; let shouldAllowBrowserProviderBuilds = false; + // Whether the project uses vitest directly (an `@vitest/*` dep or a source + // reference). Computed inside the callback (where `pkg` is available) and + // hoisted so the post-callback pnpm-workspace.yaml writer sees it too. + let usesVitest = false; // Determined inside editJsonFile callback to avoid a redundant file read let usePnpmWorkspaceYaml = false; editJsonFile<{ @@ -1517,6 +1647,8 @@ export function rewriteStandaloneProject( }>(packageJsonPath, (pkg) => { shouldAllowBrowserProviderBuilds = hasOwnWebdriverioDependency(pkg) || usesWebdriverioProvider(projectPath); + usesVitest = projectUsesVitestDirectly(projectPath, pkg); + const managed = managedOverridePackages(usesVitest); // Strip stale `vite-plus-test` wrapper aliases before injecting new overrides // so the deleted wrapper doesn't survive migration in any sink. pruneLegacyWrapperAliases(pkg.resolutions); @@ -1531,15 +1663,21 @@ export function rewriteStandaloneProject( // the bundled-vitest-aligned 4.1.9. (The pnpm sinks are pruned below.) dropRemovePackageOverrideKeys(pkg.resolutions); dropRemovePackageOverrideKeys(pkg.overrides); + // Common case (no direct vitest): strip a lingering managed `vitest` from + // the npm/bun `overrides` and yarn `resolutions` sinks so it isn't re-pinned. + if (!usesVitest) { + removeManagedVitestEntry(pkg.resolutions); + removeManagedVitestEntry(pkg.overrides); + } if (packageManager === PackageManager.yarn) { pkg.resolutions = { ...pkg.resolutions, - ...VITE_PLUS_OVERRIDE_PACKAGES, + ...managed, }; } else if (packageManager === PackageManager.npm || packageManager === PackageManager.bun) { pkg.overrides = { ...pkg.overrides, - ...VITE_PLUS_OVERRIDE_PACKAGES, + ...managed, }; if (packageManager === PackageManager.bun) { // Bun walks transitive peer-deps before resolving overrides; vitest @@ -1562,18 +1700,26 @@ export function rewriteStandaloneProject( shouldRewritePnpmWorkspaceYaml = true; shouldAddPnpmWorkspaceVitePlusOverride = isForceOverrideMode(); } - const overrideKeys = Object.keys(VITE_PLUS_OVERRIDE_PACKAGES); + const overrideKeys = Object.keys(managed); if (!usePnpmWorkspaceYaml) { // Strip selector-shaped overrides (e.g. `parent>@vitest/browser-playwright`) // whose target is a removed package, before re-merging the user's // overrides into the new pnpm config. dropRemovePackageOverrideKeys(pkg.pnpm?.overrides); + // Common case: drop a lingering managed `vitest` override + its peer + // rules before re-merging. + if (!usesVitest) { + removeManagedVitestEntry(pkg.pnpm?.overrides); + if (pkg.pnpm?.peerDependencyRules) { + removeVitestPeerDependencyRule(pkg.pnpm.peerDependencyRules); + } + } // Project already has pnpm config in package.json -- keep using it. pkg.pnpm = { ...pkg.pnpm, overrides: { ...pkg.pnpm?.overrides, - ...VITE_PLUS_OVERRIDE_PACKAGES, + ...managed, ...(isForceOverrideMode() ? { [VITE_PLUS_NAME]: VITE_PLUS_VERSION } : {}), }, peerDependencyRules: { @@ -1623,6 +1769,7 @@ export function rewriteStandaloneProject( catalogDependencyResolver, usesVitestBrowserMode(projectPath), collectProviderSourceModes(projectPath), + usesVitest, ); // ensure vite-plus is in devDependencies @@ -1647,7 +1794,12 @@ export function rewriteStandaloneProject( }); if (shouldRewritePnpmWorkspaceYaml) { - rewritePnpmWorkspaceYaml(projectPath, pnpmMajorVersion, shouldAllowBrowserProviderBuilds); + rewritePnpmWorkspaceYaml( + projectPath, + pnpmMajorVersion, + shouldAllowBrowserProviderBuilds, + usesVitest, + ); } // Move remaining non-Vite pnpm.overrides to pnpm-workspace.yaml @@ -1662,7 +1814,7 @@ export function rewriteStandaloneProject( } if (packageManager === PackageManager.yarn) { - rewriteYarnrcYml(projectPath); + rewriteYarnrcYml(projectPath, usesVitest); } else if (packageManager === PackageManager.bun) { ensureBunfigPeerSuppression(projectPath); } @@ -1709,17 +1861,24 @@ export function rewriteMonorepo( workspaceInfo.rootDir, workspaceInfo.packages, ); + // The SHARED workspace sinks (catalog / overrides / peer rules) keep `vitest` + // managed iff ANY package in the workspace uses vitest directly. + const workspaceUsesVitest = workspaceUsesVitestDirectly( + workspaceInfo.rootDir, + workspaceInfo.packages, + ); // rewrite root workspace if (workspaceInfo.packageManager === PackageManager.pnpm) { rewritePnpmWorkspaceYaml( workspaceInfo.rootDir, pnpmMajorVersion, workspaceShouldAllowBrowserBuilds, + workspaceUsesVitest, ); } else if (workspaceInfo.packageManager === PackageManager.yarn) { - rewriteYarnrcYml(workspaceInfo.rootDir); + rewriteYarnrcYml(workspaceInfo.rootDir, workspaceUsesVitest); } else if (workspaceInfo.packageManager === PackageManager.bun) { - rewriteBunCatalog(workspaceInfo.rootDir); + rewriteBunCatalog(workspaceInfo.rootDir, workspaceUsesVitest); } rewriteRootWorkspacePackageJson( workspaceInfo.rootDir, @@ -1729,6 +1888,7 @@ export function rewriteMonorepo( workspaceInfo.packages, pnpmMajorVersion, workspaceShouldAllowBrowserBuilds, + workspaceUsesVitest, ); // (mergeViteConfigFiles below will sanitize the merged lint config // against this workspace's full package set.) @@ -1840,6 +2000,7 @@ export function rewriteMonorepoProject( catalogDependencyResolver, usesVitestBrowserMode(projectPath), collectProviderSourceModes(projectPath), + projectUsesVitestDirectly(projectPath, pkg), ); // If this SUB-workspace now depends on `vite-plus` and Yarn isolates its // hoisting (via the root `nmHoistingLimits` OR the workspace's own @@ -1884,15 +2045,17 @@ function rewritePnpmWorkspaceYaml( projectPath: string, pnpmMajorVersion: number | undefined, shouldAllowBrowserBuilds: boolean, + usesVitest: boolean, ): void { const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); if (!fs.existsSync(pnpmWorkspaceYamlPath)) { fs.writeFileSync(pnpmWorkspaceYamlPath, ''); } + const managed = managedOverridePackages(usesVitest); editYamlFile(pnpmWorkspaceYamlPath, (doc) => { // catalog - rewriteCatalog(doc); + rewriteCatalog(doc, usesVitest); if (pnpmMajorVersion !== undefined) { applyBuildAllowanceToWorkspaceYaml(doc, pnpmMajorVersion, shouldAllowBrowserBuilds); } @@ -1919,13 +2082,14 @@ function rewritePnpmWorkspaceYaml( } } } - for (const key of Object.keys(VITE_PLUS_OVERRIDE_PACKAGES)) { + // Common case (no direct vitest): actively strip any lingering managed + // `vitest` override so it arrives transitively through vite-plus. + if (!usesVitest) { + removeYamlMapVitestEntry(doc.getIn(['overrides'])); + } + for (const key of Object.keys(managed)) { const currentVersion = getYamlMapScalarStringValue(overrides, key); - const version = getCatalogDependencySpec( - currentVersion, - VITE_PLUS_OVERRIDE_PACKAGES[key], - true, - ); + const version = getCatalogDependencySpec(currentVersion, managed[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" @@ -1944,8 +2108,12 @@ function rewritePnpmWorkspaceYaml( if (!allowAny) { allowAny = new YAMLSeq>(); } + // Common case: drop any lingering managed `vitest` allowAny entry. + if (!usesVitest && VITEST_IS_MANAGED_OVERRIDE) { + allowAny.items = allowAny.items.filter((n) => n.value !== 'vitest'); + } const existing = new Set(allowAny.items.map((n) => n.value)); - for (const key of Object.keys(VITE_PLUS_OVERRIDE_PACKAGES)) { + for (const key of Object.keys(managed)) { if (!existing.has(key)) { allowAny.add(scalarString(key)); } @@ -1960,7 +2128,11 @@ function rewritePnpmWorkspaceYaml( if (!allowedVersions) { allowedVersions = new YAMLMap, Scalar>(); } - for (const key of Object.keys(VITE_PLUS_OVERRIDE_PACKAGES)) { + // Common case: drop any lingering managed `vitest` allowedVersions entry. + if (!usesVitest) { + removeYamlMapVitestEntry(allowedVersions); + } + for (const key of Object.keys(managed)) { // - vite: '*' allowedVersions.set(scalarString(key), scalarString('*')); } @@ -2018,10 +2190,17 @@ function cleanupPnpmOverridesForWorkspaceYaml( // Strip selector-shaped overrides (e.g. `parent>@vitest/browser-playwright`) // whose target is a removed package, before the exact-key sweep below. dropRemovePackageOverrideKeys(pkg.pnpm?.overrides); - // Remove Vite-managed keys from pnpm.overrides + // Remove Vite-managed keys from pnpm.overrides. `vitest` is always swept so a + // lingering managed `vitest` override is dropped in the common case (when it + // is NOT in `overrideKeys` because the project does not use vitest directly) — + // it is deleted but NOT captured as a moved catalog override. + const sweepKeys = + overrideKeys.includes('vitest') || !VITEST_IS_MANAGED_OVERRIDE + ? overrideKeys + : [...overrideKeys, 'vitest']; const catalogOverrides: Record = {}; const overrides = pkg.pnpm?.overrides; - for (const key of [...overrideKeys, ...PROVIDER_OVERRIDE_DROP_NAMES]) { + for (const key of [...sweepKeys, ...PROVIDER_OVERRIDE_DROP_NAMES]) { const value = overrides?.[key]; if (value) { if (overrideKeys.includes(key) && value.startsWith('catalog:')) { @@ -2049,8 +2228,10 @@ function cleanupPnpmOverridesForWorkspaceYaml( remaining = { ...remaining, ...pkg.pnpm.overrides }; } delete pkg.pnpm?.overrides; - // Only remove Vite-managed peerDependencyRules entries, preserve custom ones - cleanupPeerDependencyRules(pkg.pnpm?.peerDependencyRules, overrideKeys); + // Only remove Vite-managed peerDependencyRules entries, preserve custom ones. + // `vitest` is always swept (common case: dropped even though it is not in the + // managed `overrideKeys`). + cleanupPeerDependencyRules(pkg.pnpm?.peerDependencyRules, sweepKeys); if (pkg.pnpm?.peerDependencyRules && Object.keys(pkg.pnpm.peerDependencyRules).length === 0) { delete pkg.pnpm.peerDependencyRules; } @@ -2131,6 +2312,32 @@ function workspaceUsesWebdriverio( return false; } +// Workspace-wide direct-vitest signal for the SHARED sinks a monorepo root +// owns (pnpm-workspace.yaml catalog/overrides/peer rules, .yarnrc.yml catalog, +// bun catalog): `vitest` stays managed there iff ANY package in the workspace — +// the root or any sub-package — uses vitest directly (an `@vitest/*` dep or a +// source reference). See `projectUsesVitestDirectly`. +function workspaceUsesVitestDirectly( + rootDir: string, + packages: WorkspacePackage[] | undefined, +): boolean { + const rootPkg = readPackageJsonIfExists(path.join(rootDir, 'package.json')) ?? {}; + if (projectUsesVitestDirectly(rootDir, rootPkg)) { + return true; + } + if (!packages) { + return false; + } + for (const pkg of packages) { + const packageDir = path.join(rootDir, pkg.path); + const subPkg = readPackageJsonIfExists(path.join(packageDir, 'package.json')) ?? {}; + if (projectUsesVitestDirectly(packageDir, subPkg)) { + return true; + } + } + return false; +} + function readPackageJsonIfExists(packageJsonPath: string): DependencyBag | undefined { if (!fs.existsSync(packageJsonPath)) { return undefined; @@ -2501,7 +2708,7 @@ function applyYarnWorkspaceHoistingFix( } } -function rewriteYarnrcYml(projectPath: string): void { +function rewriteYarnrcYml(projectPath: string, usesVitest: boolean): void { const yarnrcYmlPath = path.join(projectPath, '.yarnrc.yml'); if (!fs.existsSync(yarnrcYmlPath)) { fs.writeFileSync(yarnrcYmlPath, ''); @@ -2532,7 +2739,7 @@ function rewriteYarnrcYml(projectPath: string): void { } doc.setIn(['npmPreapprovedPackages'], npmPreapprovedPackages); // catalog - rewriteCatalog(doc); + rewriteCatalog(doc, usesVitest); }); } @@ -2740,8 +2947,14 @@ function pruneYamlMapLegacyWrapperAliases(map: unknown): void { } } -function rewriteCatalog(doc: YamlDocument): void { - for (const [key, value] of Object.entries(VITE_PLUS_OVERRIDE_PACKAGES)) { +function rewriteCatalog(doc: YamlDocument, usesVitest: boolean): void { + const managed = managedOverridePackages(usesVitest); + // Common case (no direct vitest): remove any lingering managed `vitest` + // catalog entry so it resolves transitively through vite-plus. + if (!usesVitest) { + removeYamlMapVitestEntry(doc.getIn(['catalog'])); + } + for (const [key, value] of Object.entries(managed)) { // 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 // ignore setting catalog if value starts with 'file:' if (value.startsWith('file:')) { @@ -2770,7 +2983,12 @@ function rewriteCatalog(doc: YamlDocument): void { if (typeof catalogName !== 'string' || !(item.value instanceof YAMLMap)) { continue; } - for (const [key, value] of Object.entries(VITE_PLUS_OVERRIDE_PACKAGES)) { + // Common case: strip a lingering managed `vitest` entry from this named + // catalog (existing entries only — named catalogs are never grown here). + if (!usesVitest) { + removeYamlMapVitestEntry(item.value); + } + for (const [key, value] of Object.entries(managed)) { const catalogPath = ['catalogs', catalogName, key]; if (!value.startsWith('file:') && doc.hasIn(catalogPath)) { doc.setIn(catalogPath, scalarString(value)); @@ -2790,8 +3008,18 @@ function rewriteCatalog(doc: YamlDocument): void { } } -function rewriteCatalogObject(catalog: Record, addMissing: boolean): void { - for (const [key, value] of Object.entries(VITE_PLUS_OVERRIDE_PACKAGES)) { +function rewriteCatalogObject( + catalog: Record, + addMissing: boolean, + usesVitest: boolean, +): void { + const managed = managedOverridePackages(usesVitest); + // Common case (no direct vitest): strip a lingering managed `vitest` catalog + // entry so it resolves transitively through vite-plus. + if (!usesVitest) { + removeManagedVitestEntry(catalog); + } + for (const [key, value] of Object.entries(managed)) { if (value.startsWith('file:') || (!addMissing && !(key in catalog))) { continue; } @@ -2805,9 +3033,12 @@ function rewriteCatalogObject(catalog: Record, addMissing: boole } } -function rewriteCatalogsObject(catalogs: Record>): void { +function rewriteCatalogsObject( + catalogs: Record>, + usesVitest: boolean, +): void { for (const catalog of Object.values(catalogs)) { - rewriteCatalogObject(catalog, false); + rewriteCatalogObject(catalog, false, usesVitest); } } @@ -2853,11 +3084,12 @@ function ensureBunfigPeerSuppression(projectPath: string): void { * unlike pnpm which uses pnpm-workspace.yaml. * @see https://bun.sh/docs/pm/catalogs */ -function rewriteBunCatalog(projectPath: string): void { +function rewriteBunCatalog(projectPath: string, usesVitest: boolean): void { const packageJsonPath = path.join(projectPath, 'package.json'); if (!fs.existsSync(packageJsonPath)) { return; } + const managed = managedOverridePackages(usesVitest); editJsonFile<{ workspaces?: NpmWorkspaces; @@ -2875,30 +3107,30 @@ function rewriteBunCatalog(projectPath: string): void { ...(useWorkspacesCatalog ? workspacesObj?.catalog : pkg.catalog), }; - rewriteCatalogObject(catalog, true); + rewriteCatalogObject(catalog, true, usesVitest); pruneLegacyWrapperAliases(catalog); if (useWorkspacesCatalog) { workspacesObj.catalog = catalog; if (pkg.catalog) { - rewriteCatalogObject(pkg.catalog, false); + rewriteCatalogObject(pkg.catalog, false, usesVitest); pruneLegacyWrapperAliases(pkg.catalog); } } else { pkg.catalog = catalog; if (workspacesObj?.catalog) { - rewriteCatalogObject(workspacesObj.catalog, false); + rewriteCatalogObject(workspacesObj.catalog, false, usesVitest); pruneLegacyWrapperAliases(workspacesObj.catalog); } } if (workspacesObj?.catalogs) { - rewriteCatalogsObject(workspacesObj.catalogs); + rewriteCatalogsObject(workspacesObj.catalogs, usesVitest); for (const named of Object.values(workspacesObj.catalogs)) { pruneLegacyWrapperAliases(named); } } if (pkg.catalogs) { - rewriteCatalogsObject(pkg.catalogs); + rewriteCatalogsObject(pkg.catalogs, usesVitest); for (const named of Object.values(pkg.catalogs)) { pruneLegacyWrapperAliases(named); } @@ -2907,7 +3139,13 @@ function rewriteBunCatalog(projectPath: string): void { // bun overrides support catalog: references const overrides: Record = { ...pkg.overrides }; pruneLegacyWrapperAliases(overrides); - for (const [key, value] of Object.entries(VITE_PLUS_OVERRIDE_PACKAGES)) { + // Common case (no direct vitest): strip a lingering managed `vitest` + // override (string-valued only — a nested user override is left intact; + // removeManagedVitestEntry also no-ops when vitest is not a managed key). + if (!usesVitest && typeof overrides.vitest === 'string') { + removeManagedVitestEntry(overrides); + } + for (const [key, value] of Object.entries(managed)) { const current = overrides[key] as unknown; // A nested object value is a user override scoped under this managed key, // not a version pin — leave it intact (getCatalogDependencySpec expects a @@ -2940,11 +3178,16 @@ function rewriteRootWorkspacePackageJson( packages?: WorkspacePackage[], pnpmMajorVersion?: number, shouldAllowBrowserBuilds = false, + // Workspace-wide direct-vitest signal: the root resolution/override sinks are + // shared by every package, so `vitest` stays managed here iff ANY package uses + // vitest directly. + workspaceUsesVitest = true, ): void { const packageJsonPath = path.join(projectPath, 'package.json'); if (!fs.existsSync(packageJsonPath)) { return; } + const managed = managedOverridePackages(workspaceUsesVitest); let remainingPnpmOverrides: Record | undefined; editJsonFile<{ @@ -2978,17 +3221,23 @@ function rewriteRootWorkspacePackageJson( // the bundled-vitest-aligned 4.1.9. (The pnpm sinks are pruned below.) dropRemovePackageOverrideKeys(pkg.resolutions); dropRemovePackageOverrideKeys(pkg.overrides); + // Common case (no workspace-wide direct vitest): strip a lingering managed + // `vitest` from the shared root sinks so it isn't re-pinned. + if (!workspaceUsesVitest) { + removeManagedVitestEntry(pkg.resolutions); + removeManagedVitestEntry(pkg.overrides); + } if (packageManager === PackageManager.yarn) { pkg.resolutions = { ...pkg.resolutions, // FIXME: yarn don't support catalog on resolutions // https://github.com/yarnpkg/berry/issues/6979 - ...VITE_PLUS_OVERRIDE_PACKAGES, + ...managed, }; } else if (packageManager === PackageManager.npm) { pkg.overrides = { ...pkg.overrides, - ...VITE_PLUS_OVERRIDE_PACKAGES, + ...managed, }; } else if (packageManager === PackageManager.bun) { // bun overrides are handled in rewriteBunCatalog() with catalog: references @@ -3006,12 +3255,16 @@ function rewriteRootWorkspacePackageJson( ), }; } else if (packageManager === PackageManager.pnpm) { - const overrideKeys = Object.keys(VITE_PLUS_OVERRIDE_PACKAGES); + const overrideKeys = Object.keys(managed); if (isForceOverrideMode()) { // Strip selector-shaped overrides (e.g. `parent>@vitest/browser-playwright`) // whose target is a removed package, before re-merging the user's // overrides into the new pnpm config. dropRemovePackageOverrideKeys(pkg.pnpm?.overrides); + // Common case: drop a lingering managed `vitest` override before merging. + if (!workspaceUsesVitest) { + removeManagedVitestEntry(pkg.pnpm?.overrides); + } // In force-override mode, keep overrides in package.json pnpm.overrides // because pnpm ignores pnpm-workspace.yaml overrides when pnpm.overrides // exists in package.json (even with unrelated entries like rollup). @@ -3019,7 +3272,7 @@ function rewriteRootWorkspacePackageJson( ...pkg.pnpm, overrides: { ...pkg.pnpm?.overrides, - ...VITE_PLUS_OVERRIDE_PACKAGES, + ...managed, [VITE_PLUS_NAME]: VITE_PLUS_VERSION, }, }; @@ -3279,9 +3532,15 @@ function overrideSpecSatisfiesVitePlus( function overridesSatisfyVitePlus( overrides: Record | undefined, + usesVitest: boolean, catalogDependencyResolver?: CatalogDependencyResolver, ): boolean { - return Object.keys(VITE_PLUS_OVERRIDE_PACKAGES).every((dependencyName) => + // Common case: a lingering managed `vitest` override is NOT satisfied — it + // must be removed, so the bootstrap stays pending until it is. + if (!usesVitest && VITEST_IS_MANAGED_OVERRIDE && overrides?.vitest !== undefined) { + return false; + } + return Object.keys(managedOverridePackages(usesVitest)).every((dependencyName) => overrideSpecSatisfiesVitePlus( dependencyName, overrides?.[dependencyName], @@ -3319,16 +3578,36 @@ function pnpmPeerDependencyRulesSatisfyVitePlus( peerDependencyRules: | { allowAny?: string[]; allowedVersions?: Record } | undefined, + usesVitest: boolean, ): boolean { - const overrideKeys = Object.keys(VITE_PLUS_OVERRIDE_PACKAGES); const allowAny = new Set(peerDependencyRules?.allowAny ?? []); const allowedVersions = peerDependencyRules?.allowedVersions ?? {}; + // Common case: a lingering managed `vitest` peer rule is NOT satisfied. + if ( + !usesVitest && + VITEST_IS_MANAGED_OVERRIDE && + (allowAny.has('vitest') || allowedVersions.vitest !== undefined) + ) { + return false; + } + const overrideKeys = Object.keys(managedOverridePackages(usesVitest)); return overrideKeys.every((key) => allowAny.has(key) && allowedVersions[key] === '*'); } -function npmVitePlusManagedDependenciesPending(pkg: BootstrapPackageJson): boolean { +function npmVitePlusManagedDependenciesPending( + pkg: BootstrapPackageJson, + usesVitest: boolean, +): boolean { const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; - return Object.keys(VITE_PLUS_OVERRIDE_PACKAGES).some((dependencyName) => + // Common case: a lingering managed `vitest` install dep is pending removal. + if ( + !usesVitest && + VITEST_IS_MANAGED_OVERRIDE && + dependencyGroups.some((dependencies) => dependencies?.vitest !== undefined) + ) { + return true; + } + return Object.keys(managedOverridePackages(usesVitest)).some((dependencyName) => dependencyGroups.some( (dependencies) => dependencies?.[dependencyName] !== undefined && @@ -3373,7 +3652,7 @@ function readPnpmWorkspacePeerDependencyRules( return doc?.peerDependencyRules; } -function yarnrcSatisfiesVitePlus(projectPath: string): boolean { +function yarnrcSatisfiesVitePlus(projectPath: string, usesVitest: boolean): boolean { const yarnrcYmlPath = path.join(projectPath, '.yarnrc.yml'); if (!fs.existsSync(yarnrcYmlPath)) { return false; @@ -3385,7 +3664,7 @@ function yarnrcSatisfiesVitePlus(projectPath: string): boolean { return ( !!doc && Object.hasOwn(doc, 'nodeLinker') && - overridesSatisfyVitePlus(doc.catalog) && + overridesSatisfyVitePlus(doc.catalog, usesVitest) && (VITE_PLUS_VERSION.startsWith('file:') || doc.catalog?.[VITE_PLUS_NAME] === VITE_PLUS_VERSION) ); } @@ -3501,32 +3780,47 @@ export function detectVitePlusBootstrapPending( return true; } + // `vitest` is managed only when the project uses it directly; otherwise a + // lingering managed `vitest` entry is treated as pending so the bootstrap + // removes it (and a second detect after removal returns false). + const usesVitest = projectUsesVitestDirectly(projectPath, pkg); + if (packageManager === PackageManager.yarn) { - return !overridesSatisfyVitePlus(pkg.resolutions) || !yarnrcSatisfiesVitePlus(projectPath); + return ( + !overridesSatisfyVitePlus(pkg.resolutions, usesVitest) || + !yarnrcSatisfiesVitePlus(projectPath, usesVitest) + ); } if (packageManager === PackageManager.npm) { return ( vitePlusDependencyNeedsConcreteVersion(pkg) || - !overridesSatisfyVitePlus(pkg.overrides) || - npmVitePlusManagedDependenciesPending(pkg) + !overridesSatisfyVitePlus(pkg.overrides, usesVitest) || + npmVitePlusManagedDependenciesPending(pkg, usesVitest) ); } if (packageManager === PackageManager.bun) { - return !overridesSatisfyVitePlus(pkg.overrides, readBunCatalogDependencyResolver(pkg)); + return !overridesSatisfyVitePlus( + pkg.overrides, + usesVitest, + readBunCatalogDependencyResolver(pkg), + ); } if (packageManager === PackageManager.pnpm) { if (pnpmConfigLivesInPackageJson(pkg, projectPath)) { return ( vitePlusDependencyNeedsConcreteVersion(pkg) || - !overridesSatisfyVitePlus(pkg.pnpm?.overrides) || - !pnpmPeerDependencyRulesSatisfyVitePlus(pkg.pnpm?.peerDependencyRules) + !overridesSatisfyVitePlus(pkg.pnpm?.overrides, usesVitest) || + !pnpmPeerDependencyRulesSatisfyVitePlus(pkg.pnpm?.peerDependencyRules, usesVitest) ); } const resolver = readPnpmWorkspaceCatalogDependencyResolver(projectPath); return ( defaultCatalogVitePlusDependencyPending(pkg, resolver) || - !overridesSatisfyVitePlus(readPnpmWorkspaceOverrides(projectPath), resolver) || - !pnpmPeerDependencyRulesSatisfyVitePlus(readPnpmWorkspacePeerDependencyRules(projectPath)) + !overridesSatisfyVitePlus(readPnpmWorkspaceOverrides(projectPath), usesVitest, resolver) || + !pnpmPeerDependencyRulesSatisfyVitePlus( + readPnpmWorkspacePeerDependencyRules(projectPath), + usesVitest, + ) ); } @@ -3542,7 +3836,10 @@ function ensureVitePlusDependencySpecs(pkg: BootstrapPackageJson, version: strin // (workspace:, link:, file:, npm:, github:, git, http) are preserved. const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; for (const dependencies of dependencyGroups) { - const spec = dependencies?.[VITE_PLUS_NAME]; + if (dependencies === undefined) { + continue; + } + const spec = dependencies[VITE_PLUS_NAME]; if (spec === undefined || spec === version) { continue; } @@ -3574,11 +3871,18 @@ function ensureVitePlusDependencySpecs(pkg: BootstrapPackageJson, version: strin function ensureOverrideEntries( overrides: Record | undefined, + usesVitest: boolean, catalogDependencyResolver?: CatalogDependencyResolver, ): { overrides: Record; changed: boolean } { const next = { ...overrides }; let changed = false; - for (const [dependencyName, overrideSpec] of Object.entries(VITE_PLUS_OVERRIDE_PACKAGES)) { + // Common case: drop a lingering managed `vitest` override. + if (!usesVitest && removeManagedVitestEntry(next)) { + changed = true; + } + for (const [dependencyName, overrideSpec] of Object.entries( + managedOverridePackages(usesVitest), + )) { if ( !overrideSpecSatisfiesVitePlus( dependencyName, @@ -3593,10 +3897,21 @@ function ensureOverrideEntries( return { overrides: next, changed }; } -function ensureNpmVitePlusManagedDependencies(pkg: BootstrapPackageJson): boolean { +function ensureNpmVitePlusManagedDependencies( + pkg: BootstrapPackageJson, + usesVitest: boolean, +): boolean { let changed = false; const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; - for (const [dependencyName, version] of Object.entries(VITE_PLUS_OVERRIDE_PACKAGES)) { + // Common case: strip a lingering managed `vitest` install dep. + if (!usesVitest) { + for (const dependencies of dependencyGroups) { + if (removeManagedVitestEntry(dependencies)) { + changed = true; + } + } + } + for (const [dependencyName, version] of Object.entries(managedOverridePackages(usesVitest))) { for (const dependencies of dependencyGroups) { if ( dependencies?.[dependencyName] !== undefined && @@ -3610,14 +3925,29 @@ function ensureNpmVitePlusManagedDependencies(pkg: BootstrapPackageJson): boolea return changed; } -function ensurePnpmPeerDependencyRules(pkg: BootstrapPackageJson): boolean { - const overrideKeys = Object.keys(VITE_PLUS_OVERRIDE_PACKAGES); +function ensurePnpmPeerDependencyRules(pkg: BootstrapPackageJson, usesVitest: boolean): boolean { + const overrideKeys = Object.keys(managedOverridePackages(usesVitest)); pkg.pnpm ??= {}; + // Common case: drop a lingering managed `vitest` peer rule from the source + // shape before re-deriving the managed rules. + const seed = { ...pkg.pnpm.peerDependencyRules } as { + allowAny?: string[]; + allowedVersions?: Record; + }; + if (!usesVitest && VITEST_IS_MANAGED_OVERRIDE) { + if (Array.isArray(seed.allowAny)) { + seed.allowAny = seed.allowAny.filter((key) => key !== 'vitest'); + } + if (seed.allowedVersions) { + seed.allowedVersions = { ...seed.allowedVersions }; + delete seed.allowedVersions.vitest; + } + } const peerDependencyRules = { - ...pkg.pnpm.peerDependencyRules, - allowAny: [...new Set([...(pkg.pnpm.peerDependencyRules?.allowAny ?? []), ...overrideKeys])], + ...seed, + allowAny: [...new Set([...(seed.allowAny ?? []), ...overrideKeys])], allowedVersions: { - ...pkg.pnpm.peerDependencyRules?.allowedVersions, + ...seed.allowedVersions, ...Object.fromEntries(overrideKeys.map((key) => [key, '*'])), }, }; @@ -3643,6 +3973,16 @@ export function ensureVitePlusBootstrap( return result; } + // Whether the project uses vitest directly (an `@vitest/*` dep or a source + // reference). Read up front so it is available to the post-callback + // pnpm-workspace.yaml / .yarnrc.yml / bun catalog rewrites too. `vitest` stays + // managed only when true; otherwise the bootstrap REMOVES any lingering + // managed `vitest` entry from every sink. + const usesVitest = projectUsesVitestDirectly( + projectPath, + readJsonFile(packageJsonPath) as BootstrapPackageJson, + ); + editJsonFile< BootstrapPackageJson & { workspaces?: NpmWorkspaces; @@ -3662,23 +4002,27 @@ export function ensureVitePlusBootstrap( ); packageJsonChanged = alignVitestCoverageProviders(pkg) || packageJsonChanged; if (workspaceInfo.packageManager === PackageManager.npm) { - packageJsonChanged = ensureNpmVitePlusManagedDependencies(pkg) || packageJsonChanged; + packageJsonChanged = ensureNpmVitePlusManagedDependencies(pkg, usesVitest) || packageJsonChanged; } if (workspaceInfo.packageManager === PackageManager.yarn) { - const ensured = ensureOverrideEntries(pkg.resolutions); + const ensured = ensureOverrideEntries(pkg.resolutions, usesVitest); if (ensured.changed) { pkg.resolutions = ensured.overrides; packageJsonChanged = true; } } else if (workspaceInfo.packageManager === PackageManager.npm) { - const ensured = ensureOverrideEntries(pkg.overrides); + const ensured = ensureOverrideEntries(pkg.overrides, usesVitest); if (ensured.changed) { pkg.overrides = ensured.overrides; packageJsonChanged = true; } } else if (workspaceInfo.packageManager === PackageManager.bun) { - const ensured = ensureOverrideEntries(pkg.overrides, readBunCatalogDependencyResolver(pkg)); + const ensured = ensureOverrideEntries( + pkg.overrides, + usesVitest, + readBunCatalogDependencyResolver(pkg), + ); if (ensured.changed) { pkg.overrides = ensured.overrides; packageJsonChanged = true; @@ -3690,12 +4034,12 @@ export function ensureVitePlusBootstrap( // `pnpmConfigLivesInPackageJson` guarantees `pkg.pnpm` is present here, // but it may be an empty object (no pnpm-workspace.yaml case), so seed it. pkg.pnpm ??= {}; - const ensured = ensureOverrideEntries(pkg.pnpm.overrides); + const ensured = ensureOverrideEntries(pkg.pnpm.overrides, usesVitest); if (ensured.changed) { pkg.pnpm.overrides = ensured.overrides; packageJsonChanged = true; } - packageJsonChanged = ensurePnpmPeerDependencyRules(pkg) || packageJsonChanged; + packageJsonChanged = ensurePnpmPeerDependencyRules(pkg, usesVitest) || packageJsonChanged; } result.packageJson = packageJsonChanged; @@ -3714,16 +4058,20 @@ export function ensureVitePlusBootstrap( defaultCatalogVitePlusDependencyPending(pkg, catalogDependencyResolver) || !overridesSatisfyVitePlus( readPnpmWorkspaceOverrides(projectPath), + usesVitest, catalogDependencyResolver, ) || - !pnpmPeerDependencyRulesSatisfyVitePlus(readPnpmWorkspacePeerDependencyRules(projectPath)) + !pnpmPeerDependencyRulesSatisfyVitePlus( + readPnpmWorkspacePeerDependencyRules(projectPath), + usesVitest, + ) ) { // Bootstrap only completes the catalog / overrides / peer rules for a // project that already uses Vite+. Build-script allowance stays owned // by the full migration paths, so pass an undefined pnpm major to skip // it (mirrors the single-arg call this path used before the signature // grew the build-allowance parameters). - rewritePnpmWorkspaceYaml(projectPath, undefined, false); + rewritePnpmWorkspaceYaml(projectPath, undefined, false, usesVitest); } if (fs.existsSync(pnpmWorkspaceYamlPath)) { ensurePnpmWorkspacePackages(projectPath, workspaceInfo.workspacePatterns); @@ -3738,12 +4086,12 @@ export function ensureVitePlusBootstrap( const before = fs.existsSync(yarnrcYmlPath) ? fs.readFileSync(yarnrcYmlPath, 'utf-8') : undefined; - rewriteYarnrcYml(projectPath); + rewriteYarnrcYml(projectPath, usesVitest); const after = fs.readFileSync(yarnrcYmlPath, 'utf-8'); result.packageManagerConfig = before !== after; } else if (workspaceInfo.packageManager === PackageManager.bun) { const before = fs.readFileSync(packageJsonPath, 'utf-8'); - rewriteBunCatalog(projectPath); + rewriteBunCatalog(projectPath, usesVitest); const after = fs.readFileSync(packageJsonPath, 'utf-8'); result.packageJson = result.packageJson || before !== after; } @@ -4003,6 +4351,12 @@ export function rewritePackageJson( // `@vitest/browser-webdriverio` → true). A provider with no dep declared but // imported in source still gets kept/injected. providerSourceModes?: Partial>, + // Whether the project uses vitest DIRECTLY (an `@vitest/*` dep or a source + // reference). `vitest` is managed (and a managed dep/override pin kept) only + // when true; in the common case (`false`) a lingering managed `vitest` entry + // is REMOVED so it arrives transitively through vite-plus. Defaults to true to + // preserve legacy behavior for callers that don't compute the signal. + usesVitestDirectly = true, ): Record | null { if (pkg.scripts) { const updated = rewriteScripts( @@ -4041,7 +4395,29 @@ export function rewritePackageJson( needVitePlus = true; } } - for (const [key, version] of Object.entries(VITE_PLUS_OVERRIDE_PACKAGES)) { + const managed = managedOverridePackages(usesVitestDirectly); + // Common case (no direct vitest): vite-plus consumes upstream vitest itself, + // so ACTIVELY REMOVE any lingering managed `vitest` dependency (a managed pin, + // a `catalog:` reference, or a stale wrapper alias already normalized above) — + // it arrives transitively through vite-plus and a future `vp update vite-plus` + // keeps it correct with no pin to drift. The `@vitest/*` family and unrelated + // keys are untouched. (Browser-mode / vitest-adjacent projects re-add a direct + // `vitest` below; those are direct-usage signals, so this never strips one a + // surviving consumer needs.) + if (!usesVitestDirectly) { + // Only the INSTALL groups — a `peerDependencies` `vitest` is a declaration + // about consumers (coerced to `*` via PUBLIC_PEER_DEPENDENCY_FALLBACKS), + // not an install pin, so it is left as-is. + for (const { dependencyField, dependencies } of dependencyGroups) { + if (dependencyField === 'peerDependencies') { + continue; + } + if (removeManagedVitestEntry(dependencies)) { + needVitePlus = true; + } + } + } + for (const [key, version] of Object.entries(managed)) { for (const { dependencyField, dependencies } of dependencyGroups) { if (dependencies?.[key]) { dependencies[key] = getCatalogDependencySpec(dependencies[key], version, supportCatalog, { From e82435a5fc9e90780023a54dc05fdda7d6f31443 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 19 Jun 2026 17:28:30 +0800 Subject: [PATCH 06/78] feat(migrate): align the full @vitest/* ecosystem to the bundled vitest Every official @vitest/* package is versioned in lockstep with vitest and carries an exact 'vitest: ' peer (verified against the registry), so any the project lists must match what vite-plus ships or Vitest runs mixed copies. The previous code only aligned the two coverage providers, leaving @vitest/ui and @vitest/web-worker behind. Replace the hardcoded coverage-provider list with a predicate (isAlignableVitestEcosystemPackage): align any @vitest/* dependency to VITEST_VERSION except @vitest/eslint-plugin, which versions on its own line with a 'vitest: *' peer. Third-party integrations (vitest-browser-*) are not @vitest/* and keep their existing handling (range peer, own versioning, kept with a managed vitest + override). Rename alignVitestCoverageProviders -> alignVitestEcosystemPackages and vitestCoverageProvidersPending -> vitestEcosystemPackagesPending. Add a test asserting @vitest/ui and @vitest/web-worker align while @vitest/eslint-plugin is left untouched. --- .../src/migration/__tests__/migrator.spec.ts | 33 ++++++++++ packages/cli/src/migration/migrator.ts | 64 +++++++++++-------- ...e-path.md => migrate-existing-projects.md} | 0 3 files changed, 71 insertions(+), 26 deletions(-) rename rfcs/{migrate-upgrade-path.md => migrate-existing-projects.md} (100%) diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index f102bdc89d..6f47ef7e93 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -1547,6 +1547,39 @@ describe('ensureVitePlusBootstrap', () => { expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(false); }); + it('aligns the full @vitest/* ecosystem (ui, web-worker) but leaves @vitest/eslint-plugin alone', () => { + // Every official @vitest/* package carries an exact `vitest` peer, so each + // must match the bundled vitest. @vitest/eslint-plugin versions on its own + // line (`vitest: *` peer) and must NOT be pinned to the vitest version. + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { + 'vite-plus': 'latest', + '@vitest/ui': '^4.1.0', + '@vitest/web-worker': '^4.1.0', + '@vitest/eslint-plugin': '^1.0.0', + }, + overrides: { + vite: 'npm:@voidzero-dev/vite-plus-core@latest', + }, + devEngines: { + packageManager: { name: 'npm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.npm)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + }; + expect(pkg.devDependencies['@vitest/ui']).toBe(VITEST_VERSION); + expect(pkg.devDependencies['@vitest/web-worker']).toBe(VITEST_VERSION); + expect(pkg.devDependencies['@vitest/eslint-plugin']).toBe('^1.0.0'); + }); + it('removes a stale vitest wrapper override for a common-case npm project (no @vitest/* dep, no vitest source)', () => { // v0.2.1 spec: vite-plus consumes upstream vitest directly, so a project that // does NOT use vitest directly must NOT carry a managed `vitest` override — diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index b26b329735..f861c9f717 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -105,12 +105,19 @@ const PLAYWRIGHT_PROVIDER = '@vitest/browser-playwright'; // forcing pins dropped, while their catalog entries are PRESERVED. const OPT_IN_BROWSER_PROVIDERS = [WEBDRIVERIO_PROVIDER, PLAYWRIGHT_PROVIDER] as const; -// Coverage providers are project-installed peers (NOT bundled by vite-plus). -// Vitest pins each to an exact runner version, and the `define-config.ts` guard -// fail-fasts when an installed provider skews from the bundled vitest (Vitest -// otherwise runs mixed versions and yields unreliable coverage). The upgrade -// aligns any the project lists to the bundled `VITEST_VERSION`. -const VITEST_COVERAGE_PROVIDERS = ['@vitest/coverage-v8', '@vitest/coverage-istanbul'] as const; +// Official `@vitest/*` packages are versioned in lockstep with vitest and carry +// an EXACT `vitest` peer (verified against the registry: `@vitest/coverage-v8`, +// `@vitest/coverage-istanbul`, `@vitest/ui`, `@vitest/web-worker`, the browser +// family, and the runtime internals all pin `vitest: `), so any the +// project lists must match the bundled vitest or Vitest runs mixed copies (the +// `define-config.ts` coverage guard fail-fasts on exactly this skew). +// `@vitest/eslint-plugin` is the exception: it versions on its own line with a +// `vitest: *` peer, so it must NOT be pinned to the vitest version. +const VITEST_ALIGN_EXCLUDED = new Set(['@vitest/eslint-plugin']); + +function isAlignableVitestEcosystemPackage(name: string): boolean { + return name.startsWith('@vitest/') && !VITEST_ALIGN_EXCLUDED.has(name); +} // Provider names whose stale pnpm overrides / resolutions are dropped during // migration: everything vite-plus owns (REMOVE_PACKAGES) plus the user-owned @@ -3723,16 +3730,19 @@ function pnpmConfigLivesInPackageJson( ); } -// Pin any coverage provider the project lists to the bundled vitest version. -// Returns true if any spec changed. Providers are plain dependency entries -// (not overrides), so this is package-manager agnostic. -function alignVitestCoverageProviders(pkg: BootstrapPackageJson): boolean { +// Pin every alignable `@vitest/*` package the project lists to the bundled +// vitest version. Returns true if any spec changed. These are plain dependency +// entries (not overrides), so this is package-manager agnostic. +function alignVitestEcosystemPackages(pkg: BootstrapPackageJson): boolean { const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; let changed = false; - for (const provider of VITEST_COVERAGE_PROVIDERS) { - for (const dependencies of dependencyGroups) { - if (dependencies?.[provider] !== undefined && dependencies[provider] !== VITEST_VERSION) { - dependencies[provider] = VITEST_VERSION; + for (const dependencies of dependencyGroups) { + if (!dependencies) { + continue; + } + for (const name of Object.keys(dependencies)) { + if (isAlignableVitestEcosystemPackage(name) && dependencies[name] !== VITEST_VERSION) { + dependencies[name] = VITEST_VERSION; changed = true; } } @@ -3740,15 +3750,17 @@ function alignVitestCoverageProviders(pkg: BootstrapPackageJson): boolean { return changed; } -// True when the project lists a coverage provider at a version other than the -// bundled vitest, so the bootstrap should run to realign it. -function vitestCoverageProvidersPending(pkg: BootstrapPackageJson): boolean { +// True when the project lists an alignable `@vitest/*` package at a version +// other than the bundled vitest, so the bootstrap should run to realign it. +function vitestEcosystemPackagesPending(pkg: BootstrapPackageJson): boolean { const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; - return VITEST_COVERAGE_PROVIDERS.some((provider) => - dependencyGroups.some( - (dependencies) => - dependencies?.[provider] !== undefined && dependencies[provider] !== VITEST_VERSION, - ), + return dependencyGroups.some((dependencies) => + dependencies + ? Object.keys(dependencies).some( + (name) => + isAlignableVitestEcosystemPackage(name) && dependencies[name] !== VITEST_VERSION, + ) + : false, ); } @@ -3770,9 +3782,9 @@ export function detectVitePlusBootstrapPending( return true; } - // A coverage provider skewed from the bundled vitest needs realigning, - // independent of the package manager's override shape. - if (vitestCoverageProvidersPending(pkg)) { + // A `@vitest/*` ecosystem package skewed from the bundled vitest needs + // realigning, independent of the package manager's override shape. + if (vitestEcosystemPackagesPending(pkg)) { return true; } @@ -4000,7 +4012,7 @@ export function ensureVitePlusBootstrap( pkg, supportCatalog ? 'catalog:' : VITE_PLUS_VERSION, ); - packageJsonChanged = alignVitestCoverageProviders(pkg) || packageJsonChanged; + packageJsonChanged = alignVitestEcosystemPackages(pkg) || packageJsonChanged; if (workspaceInfo.packageManager === PackageManager.npm) { packageJsonChanged = ensureNpmVitePlusManagedDependencies(pkg, usesVitest) || packageJsonChanged; } diff --git a/rfcs/migrate-upgrade-path.md b/rfcs/migrate-existing-projects.md similarity index 100% rename from rfcs/migrate-upgrade-path.md rename to rfcs/migrate-existing-projects.md From 26968b377e6d5e035e0b833180d33533ec30eadf Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 19 Jun 2026 17:28:49 +0800 Subject: [PATCH 07/78] docs(rfc): revise migrate RFC for vitest provisioning and ecosystem rules Reflect the validated design after the urllib (#832-834) and snap-test findings: - vite-plus declares vitest as a dependency, so vitest is provided transitively; by default the migration removes any project-level vitest instead of pinning it. - Exception: keep a managed vitest (devDep + override) when a non-exact vitest peer must be collapsed (third-party vitest-browser-*, browser mode, or a direct vitest source import), verified by migration-vitest-peer-dep. - Align every official @vitest/* (exact peer) to the bundled version; add a verified ecosystem table; exclude @vitest/eslint-plugin. Also lead with the two-command upgrade UX (vp upgrade && vp migrate), present the rules as a table, drop the resolved open questions, and rename the file to migrate-existing-projects.md with a clearer title. --- rfcs/migrate-existing-projects.md | 225 +++++++----------------------- 1 file changed, 54 insertions(+), 171 deletions(-) diff --git a/rfcs/migrate-existing-projects.md b/rfcs/migrate-existing-projects.md index 29207563db..f2b8385864 100644 --- a/rfcs/migrate-existing-projects.md +++ b/rfcs/migrate-existing-projects.md @@ -1,192 +1,75 @@ -# RFC: `vp migrate` Upgrade Path for Existing Vite+ Projects +# RFC: Migrating Existing Vite+ Projects to a New Version -- Status: Draft (for discussion) -- Depends on: [#1588 refactor: replace @voidzero-dev/vite-plus-test with upstream vitest](https://github.com/voidzero-dev/vite-plus/pull/1588) (merged, `342fd2f4`) -- Spec source: the ["Upgrading from 0.1.x to 0.2.1 Prompt"](https://github.com/voidzero-dev/vite-plus/releases/tag/v0.2.1) in the v0.2.1 release notes -- Related: [migration-command.md](./migration-command.md), [upgrade-command.md](./upgrade-command.md), `docs/guide/upgrade.md` +- Status: Partially implemented on `rfc/migrate-upgrade-path` (commits `03689668`, `3e5a5137`); vitest-removal simplification and browser-mode verification pending (see Follow-ups) +- Depends on: [#1588 replace @voidzero-dev/vite-plus-test with upstream vitest](https://github.com/voidzero-dev/vite-plus/pull/1588) (merged, `342fd2f4`) +- Related: `docs/guide/upgrade.md`, [migration-command.md](./migration-command.md), [upgrade-command.md](./upgrade-command.md) -## Summary +## Goal: upgrade in two commands -The v0.2.1 release notes ship a careful, manual AI-agent prompt for upgrading a project from v0.1.x and explicitly say: +Any later Vite+ upgrade is two commands: upgrade the global CLI, then migrate the project. -> Do not run `vp migrate` for this upgrade; it is not reliable enough yet. Make the changes yourself by editing the project's files, then verify by running the tools. - -That prompt is the authoritative description of the correct end state. This RFC's goal is to make `vp migrate` reliably reproduce that end state so the disclaimer can be removed. The prompt also corrects a key assumption in earlier drafts of this RFC: the upgrade is NOT "always pin `vitest`". It is a usage-based decision that, in the common case, REMOVES `vitest` from the project entirely and lets it arrive transitively through `vite-plus`. - -## Background - -PR #1588 (shipped in v0.2.0) deleted the bundled `@voidzero-dev/vite-plus-test` wrapper and consumes upstream `vitest` directly. Today `ensureVitePlusBootstrap` (`migrator.ts`) unconditionally writes a managed `vitest` entry (pinned to `VITEST_VERSION`, currently `4.1.9`) into the project's override/catalog block for every already-Vite+ project, alongside the `vite` -> `npm:@voidzero-dev/vite-plus-core@latest` alias. `@vitest/*` runtime internals are NOT pinned (they are exact deps of `vitest`); coverage providers (`@vitest/coverage-v8` / `-istanbul`) are NOT managed and only get a runtime skew guard in `define-config.ts`. - -### What #1588 already handles - -PR #1588 added an "existing Vite+ project" repair path: `detectVitePlusBootstrapPending` + `ensureVitePlusBootstrap`, wired into the "already using Vite+" branch of `bin.ts` with one reinstall via `handleInstallResult`. It rewrites a stale `vitest: npm:@voidzero-dev/vite-plus-test@*` wrapper alias to the bundled vitest, proven by the `migration-already-vite-plus` snap fixture (even under `--no-interactive`). This is the foundation to build on, but as the prompt and the urllib evidence below show, it does the wrong thing in two ways: it pins `vitest` even when the project does not use it, and it misses several stale shapes. - -### The real gap: upgrading a v0.1.x project (urllib) - -`vp migrate` on a real 0.1.24 project (`node-modules/urllib`) did NOT upgrade. Its `package.json`: - -```jsonc -{ - "devDependencies": { - "@vitest/coverage-v8": "^4.1.8", - "vite": "npm:@voidzero-dev/vite-plus-core@^0.1.24", - "vite-plus": "^0.1.24", - "vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.24" - }, - "overrides": {}, - "pnpm": {}, - "packageManager": "pnpm@11.7.0" -} -``` - -plus a committed `pnpm-workspace.yaml` written by the old CLI that actively pins the stack to 0.1.x: - -```yaml -overrides: - vite: 'npm:@voidzero-dev/vite-plus-core@^0.1.24' # forces core to 0.1.x - vitest: 'npm:@voidzero-dev/vite-plus-test@^0.1.24' # forces the deleted wrapper +```bash +vp upgrade # update the global `vp` binary +vp migrate # bring the project up to the new toolchain ``` -Observed blockers, each sufficient on its own: - -1. **Routing: the stale local CLI runs.** `vp migrate` delegates **local-first** (`crates/vite_global_cli/src/commands/delegate.rs`). urllib has `vite-plus@0.1.24` installed, so the global `vp v0.2.x` delegated to the **0.1.24** CLI, which predates #1588 and has no upgrade logic. Worse, that old CLI rewrites the old-shape `pnpm-workspace.yaml`, pinning `vite`/`vitest` to `^0.1.24` and the dead wrapper, which then blocks any later upgrade. The documented `vp update vite-plus --latest && vp migrate` flow does not escape this, because `vp update` deliberately does not reconcile those pins (`docs/guide/upgrade.md`). - -2. **The v0.1.x shapes are not repaired by the v0.2.x bootstrap.** `ensureVitePlusDependencySpecs` only re-pins `vite-plus` when its spec is `catalog:` or absent, so a pinned `^0.1.24` is left untouched and never reaches the target. The inline `vite`/`vitest` aliases in `devDependencies` are never rewritten. The `vite` override is a **behind core alias** (`core@^0.1.24`), not the dead wrapper, so the wrapper-only `pruneLegacyWrapperAliases` does not normalize it. - -3. **Empty `"pnpm": {}` misroutes the repair.** Both `detectVitePlusBootstrapPending` and `ensureVitePlusBootstrap` branch on `if (pkg.pnpm)`, and `{}` is truthy, so they inspect `pkg.pnpm.overrides` (empty) and take the `if (!pkg.pnpm)` -> false path that **skips the `pnpm-workspace.yaml` rewrite entirely**. A fresh override block lands in `package.json` while the pinning overrides in `pnpm-workspace.yaml` survive, leaving two conflicting override sources. This is effectively a standalone bug in #1588. - -### What the v0.2.1 prompt specifies (the correct end state) - -The prompt encodes the upgrade as these steps (paraphrased; see the release for verbatim text): - -1. **Set `vite-plus` to the exact target version (`0.2.1`) and reinstall**, in every workspace package that depends on it. "Changing the spec to `0.2.1` is what moves the lockfile off the old resolution; a reinstall that leaves the spec unchanged would keep the old version." Exact, not a range or `latest`. -2. **Remove the `@voidzero-dev/vite-plus-test` wrapper everywhere** (package.json, lockfile, pnpm-workspace.yaml / .yarnrc.yml catalogs, source imports). Then a **usage-based decision**: - - The project depends on vitest directly ONLY IF a source/test file imports from `vitest` or `@vitest/...`, OR a `@vitest/*` package is in its deps (e.g. a coverage provider). Imports from `vite-plus/test` do NOT count. - - **Common case (no direct usage): remove vitest configuration entirely.** Delete the `vitest` entry from dependencies in whatever form (wrapper alias, `catalog:`, plain version), and remove `vitest` from every resolution mechanism (`overrides`, `resolutions`, pnpm `overrides`/`catalog` in package.json or pnpm-workspace.yaml, any catalog). Do NOT add a pinned `vitest`; it arrives transitively through `vite-plus`. - - **Direct-usage case: pin upstream vitest to the bundled version (`4.1.9`) and align the whole ecosystem.** Set every `@vitest/*` the project lists (`coverage-v8`, `ui`, `browser`, ...) to that same version, and update other integration packages (`vitest-browser-*`) to a compatible release. "Leaving an ecosystem package on an older version pulls in a second copy of vitest, which Vitest rejects at runtime." - - Delete dependency-resolution config that existed only for the wrapper/old vitest: pnpm `peerDependencyRules` (`allowedVersions` / `ignoreMissing`) referencing `vitest` / `@vitest/*` / the wrapper, and yarn `packageExtensions` equivalents. Leave unrelated rules. -3. **Keep the `vite` -> core override, pinned to the exact target**: `vite` -> `npm:@voidzero-dev/vite-plus-core@0.2.1`, in whatever override/resolution/catalog form the project already uses. Core is released in lockstep with `vite-plus`. -4. **Leave `vite-plus/test*` imports unchanged**; only repoint direct `@voidzero-dev/vite-plus-test` imports to `vite-plus/test`. -5. **Reinstall and verify**: no `@voidzero-dev/vite-plus-test` references remain outside `node_modules`; the tree resolves to a **single** `vitest` version (no duplicates); tests pass (native Vitest banner); the `vp check` workflow passes. - -Constraints: do not bypass git hooks (report pre-existing failures instead); make the smallest set of edits; end with a short summary. - -Two insights from this change the design: - -- **The common case is removal, not pinning.** Removing `vitest` (rather than pinning it to an exact version) is what lets future `vp update vite-plus` keep vitest correct automatically: there is no project-level pin to drift. urllib is NOT the common case (it lists `@vitest/coverage-v8`), so it takes the direct-usage branch: pin `vitest` to `4.1.9` and set `@vitest/coverage-v8` to `4.1.9`, which is exactly the version it was missing. -- **Exactness moves the lockfile.** The upgrade must write exact target versions for `vite-plus` and the core alias, in every workspace package, or the lockfile keeps resolving the old version. +Both are needed, and the order matters. `vp migrate` normally runs the project's **local** `vite-plus`, which on an old project predates the new upgrade logic (and would even rewrite config that pins the project to the old version). So `vp upgrade` first makes a new-enough CLI available, and `vp migrate` then escalates to it (see Routing) and applies the rules below. `vp update vite-plus` alone is not enough: it bumps the dependency but does not reconcile the override/catalog config. -## Goals +`vp migrate` is idempotent: on an already-current project it reports "already using Vite+" and changes nothing. -1. `vp migrate` reliably reproduces the v0.2.1 prompt's end state for a v0.1.x project, so the "do not run `vp migrate`" disclaimer can be dropped. -2. Run the upgrade with a CLI new enough to contain this logic (fix the local-first routing that runs a stale 0.1.x CLI). -3. Implement the usage-based vitest decision: remove vitest entirely in the common case; pin + align the ecosystem in the direct-usage case. -4. Pin `vite-plus` and the `vite`->core alias to the exact target version, in every workspace package, so the lockfile moves. -5. Repair all observed stale shapes: inline/behind aliases, the empty-`pnpm` misrouting, dual override sources, and wrapper-only peer config. -6. Verify the end state (no wrapper refs, single vitest version) and respect the prompt's constraints (git hooks, minimal edits, summary). Idempotent on re-run. +## Migrate rules -## Non-Goals +Run on an existing Vite+ project, in order. The guiding fact for vitest: `vite-plus` declares `vitest` (and the `@vitest/*` runtime family) as dependencies at the bundled version, so a project never needs its own `vitest`. It resolves transitively, and an ecosystem package resolves its exact `vitest` peer against it. Verified on `node-modules/urllib` across pnpm, npm, and yarn (PRs [#832](https://github.com/node-modules/urllib/pull/832) / [#833](https://github.com/node-modules/urllib/pull/833) / [#834](https://github.com/node-modules/urllib/pull/834)): with the direct `vitest` removed, coverage stays green on all three. The complementary forced-single case (a third-party `vitest-browser-svelte` keeps a managed `vitest`) is covered by the `migration-vitest-peer-dep` snap test. -- Changing behavior for projects that do not yet use `vite-plus` (the full-migration path already writes the canonical shape). -- Rewriting user source beyond repointing direct `@voidzero-dev/vite-plus-test` imports; `vite-plus/test*` stays the stable public API. -- Pinning the `@vitest/*` runtime internals individually (they cascade from `vitest`). The ecosystem alignment in the direct-usage case targets the packages the project itself lists, not transitive internals. +| Area | Rule | +| ---- | ---- | +| Routing | If the project's local `vite-plus` is older than the global `vp`, run `migrate` from the global CLI; otherwise keep local-first. | +| `vite-plus` spec | Re-pin a non-protocol-pinned spec (e.g. `^0.1.24`) to the toolchain target (`catalog:` in catalog projects, else the version) so the lockfile moves off the old resolution. Preserve deliberate protocol pins (`workspace:`/`file:`/`link:`/`npm:`/...). | +| `vite` override | Always managed: alias `vite` to `npm:@voidzero-dev/vite-plus-core@latest` in whatever override/resolution/catalog form the project uses; normalize a behind `core@` alias. | +| `vitest` itself (default) | Provided by `vite-plus`, so by default not project-managed: remove any project-level `vitest` from dependency fields, `overrides`/`resolutions`/`pnpm.overrides`, `pnpm-workspace.yaml` `overrides`+`catalog(s)`, bun/yarn catalog, and the `vitest` entry in pnpm `peerDependencyRules`. A future `vp update vite-plus` then keeps it correct with no project pin to drift. | +| `vitest`, forced-single exception | Keep a managed `vitest` (add to `devDependencies` **and** override/pin it to the bundled version) when the project has a **non-exact** `vitest` peer to collapse: a third-party integration on a range peer (`vitest-browser-react` / `-vue` / `-svelte`, ...), vitest browser mode, or a direct `vitest` source import. The override forces the range down to `vite-plus`'s exact version (one copy); the `devDependencies` entry satisfies the peer deterministically. Official `@vitest/*` (exact peer) do NOT trigger this, their exact peer already dedupes to `vite-plus`'s vitest. | +| `vitest` ecosystem packages | Align every official `@vitest/*` package the project lists (`@vitest/coverage-v8`, `@vitest/coverage-istanbul`, `@vitest/ui`, `@vitest/web-worker`, ...) to the bundled `VITEST_VERSION`, since each carries an **exact** `vitest` peer. Exclude `@vitest/eslint-plugin` (separate version line, `vitest: *` peer). Browser packages keep their dedicated handling: `@vitest/browser` / `-preview` are bundled by `vite-plus`; `@vitest/browser-playwright` / `-webdriverio` are opt-in (pinned + framework peer kept). | +| Legacy wrapper | Remove every `@voidzero-dev/vite-plus-test` alias (deps, overrides, catalogs); repoint direct wrapper imports to `vite-plus/test`. `vite-plus/test*` imports are left unchanged (stable public API). | +| pnpm config location | An empty `"pnpm": {}` with an existing `pnpm-workspace.yaml` reconciles the workspace file (instead of writing a second, conflicting override block into `package.json`). | +| Reinstall + verify | One reinstall with lockfile refresh (`--no-frozen-lockfile` / `--force`); a failed install warns and sets a non-zero exit. | -## Design +Force-override/CI mode (`VP_OVERRIDE_PACKAGES`) is respected: when `vitest` is not a managed key there, the project's own `vitest` is never stripped. -### 1. Run the right vp (routing) +**Pending verification:** vitest **browser mode** historically needed a direct `vitest` injected (the "vibe-dashboard" regression). That predates `vite-plus` declaring `vitest`+`@vitest/browser` as dependencies and may now be obsolete, but it is not yet confirmed across package managers, so the browser-mode injection stays until a urllib-style 3-PM check clears it. -The upgrade logic must execute from a CLI at least as new as the target. The prompt's manual workaround is "after any install, re-resolve vp so you always run the version currently in the project." Automate the same idea: +## Vitest ecosystem packages -- In the global CLI (`crates/vite_global_cli`), before delegating `migrate` local-first, read the local `vite-plus` version and compare to the global `vp`. If local is older, run `migrate` from the **global** JS CLI (`delegate_to_global_cli`) instead of the stale local one. The global CLI's constants (target version, `VITEST_VERSION`) are then self-consistent. -- The upgrade re-pins `vite-plus` to the global version and reinstalls, so the next `vp` in the project resolves to the upgraded local CLI. +How each package the `vitest` ecosystem rule covers is handled, verified against the registry at `4.1.9`. The code rule: align any `@vitest/*` the project lists to `VITEST_VERSION`, except `@vitest/eslint-plugin`; the browser packages additionally follow their bundled/opt-in handling. -This is mandatory, not polish: the stale local CLI does not just no-op, it writes pinning overrides that block the upgrade. Simpler alternative (Open Questions): always route `migrate` through the global CLI. +| Package | `vitest` peer | Handling | +| ------- | ------------- | -------- | +| `@vitest/coverage-v8` | `4.1.9` (exact) | align to `VITEST_VERSION` | +| `@vitest/coverage-istanbul` | `4.1.9` | align to `VITEST_VERSION` | +| `@vitest/ui` | `4.1.9` | align to `VITEST_VERSION` | +| `@vitest/web-worker` | `4.1.9` | align to `VITEST_VERSION` | +| `@vitest/browser` | `4.1.9` | removed (bundled by `vite-plus`) | +| `@vitest/browser-preview` | `4.1.9` | removed (bundled by `vite-plus`) | +| `@vitest/browser-playwright` | `4.1.9` + `playwright` | opt-in: pin to `VITEST_VERSION`, keep `playwright` peer | +| `@vitest/browser-webdriverio` | `4.1.9` + `webdriverio` | opt-in: pin to `VITEST_VERSION`, keep `webdriverio` peer | +| `@vitest/expect` `/runner` `/snapshot` `/spy` `/utils` `/mocker` `/pretty-format` | none | transitive deps of `vitest`; `vite-plus` provides them, the project does not list them | +| `@vitest/eslint-plugin` | `*` | left as-is (own version line, e.g. `1.6.x`) | +| `vitest-browser-react` `/-vue` `/-svelte`, ... | `^4` (range) | third-party, own versioning; left at a compatible release, **and** a managed `vitest` is kept (devDep + override) to force a single copy against the range peer | -### 2. Bump `vite-plus` to the exact target, everywhere, and reinstall +## Implementation -For every workspace package that depends on `vite-plus`, set the spec to the exact executing-CLI version (e.g. `0.2.1`), not a range or `catalog:`/`latest` placeholder. Extend `ensureVitePlusDependencySpecs` to re-pin a concrete behind spec (`^0.1.24`), not only `catalog:`/absent. Then reinstall with lockfile refresh (`--no-frozen-lockfile` / `--force`) so the lockfile moves off the old resolution. - -### 3. Remove the wrapper and apply the usage-based vitest decision - -This replaces `ensureVitePlusBootstrap`'s unconditional "write `vitest` into overrides" with the prompt's logic: - -1. **Detect direct vitest usage**: a source/test file imports from `vitest` or `@vitest/...` (not `vite-plus/test`), OR the project lists any `@vitest/*` package in a dependency field. (Source scan can reuse the migration's existing import walker.) -2. **Common case (no direct usage): purge vitest.** Remove the `vitest` dependency entry in any form, and remove `vitest` from every resolution mechanism (`overrides`, `resolutions`, `pnpm.overrides`, `pnpm-workspace.yaml` `overrides`/`catalog`, bun `workspaces.catalog`, yarn `resolutions`/`.yarnrc.yml` catalog). Add no pin. -3. **Direct-usage case: pin and align.** Set `vitest` (the dependency and/or override the project uses) to `VITEST_VERSION`, and set every `@vitest/*` package the project lists to the same version; bump `vitest-browser-*` and similar integration packages to a compatible release. This subsumes the earlier "coverage provider alignment" goal: `@vitest/coverage-v8: ^4.1.8` -> `4.1.9`. -4. **Behind/inline aliases**: rewrite `vite: npm:@voidzero-dev/vite-plus-core@` to the exact target (`@0.2.1`) wherever it appears, including inline `devDependencies` aliases; reuse `pruneLegacyWrapperAliases` for the dead wrapper and add normalization for behind core aliases. - -### 4. Pin the `vite` -> core override to the exact target - -Keep the `vite` -> `npm:@voidzero-dev/vite-plus-core@` mapping, set to the exact executing version, in whichever override/resolution/catalog form the project already uses. This is a deliberate change from the current `@latest` convention (see Open Questions) and matches the prompt's lockstep requirement. - -### 5. Clean wrapper-only resolution config and fix the pnpm location - -- Remove pnpm `peerDependencyRules` (`allowAny` / `allowedVersions` / `ignoreMissing`) and yarn `packageExtensions` entries that reference `vitest`, `@vitest/*`, or the wrapper, when they exist only to accommodate the old setup. Leave unrelated rules. -- Treat an empty/partial `pkg.pnpm` (e.g. `"pnpm": {}`) as "no package.json pnpm config" so the `pnpm-workspace.yaml` path runs. When both a `package.json` `pnpm.overrides` and a `pnpm-workspace.yaml` `overrides` exist, reconcile both so the project is not left with two conflicting override sources. - -### 6. Reinstall and verify - -After edits, reinstall once (reusing `handleInstallResult`), then assert the prompt's post-conditions and surface failures as warnings + non-zero exit: - -- No `@voidzero-dev/vite-plus-test` reference anywhere outside `node_modules` (package.json, lockfile, catalogs, sources). -- The dependency tree resolves to a **single** `vitest` version (no duplicate copies). This is the check that catches a missed ecosystem package in the direct-usage branch. -- `vite-plus`, the core alias, and (if present) the aligned `@vitest/*` packages resolve to the expected versions. - -### 7. Constraints and UX - -Honor the prompt's constraints: do not bypass git hooks (if a pre-existing failure blocks the run, report it rather than forcing through); make the smallest set of edits and do not reformat unrelated files; end with a summary. Interactive run prompts once for the whole upgrade; `--no-interactive` applies it. Summary, fed by `MigrationReport`: - -``` -Upgraded Vite+ 0.1.24 -> 0.2.1 - re-pinned vite-plus and vite->core to 0.2.1 (1 package) - removed @voidzero-dev/vite-plus-test wrapper - project uses vitest directly (@vitest/coverage-v8): pinned vitest 4.1.9, aligned @vitest/coverage-v8 4.1.8 -> 4.1.9 - verified: no wrapper refs, single vitest version -``` +| Area | Change | +| ---- | ------ | +| `crates/vite_global_cli` (`commands/migrate.rs`, `js_executor.rs`) | `delegate_migrate`: compare local `vite-plus` vs global `vp` version; escalate to the global CLI when older. | +| `packages/cli/src/migration/migrator.ts` | Managed override set (`managedOverridePackages`); `vitest` removal across every sink; coverage-provider alignment; behind `vite-plus`/`vite` re-pin; empty-`pnpm` routing fix. | -### 8. Idempotency +Covered by unit tests in `migrator.spec.ts` (vitest removal, coverage alignment, behind re-pin, empty-`pnpm` reconciliation) and a routing test in `vite_global_cli`. -After a successful upgrade, detection returns false (target version pinned, no wrapper, single vitest), so a re-run hits the "already using Vite+, happy coding" path. Repairs must be recoverable by re-running if an install fails after files were rewritten. +Not yet reflected in code: the current implementation still *pins* `vitest` when the project lists a vitest ecosystem package, rather than removing it. The "vitest itself: never project-managed" rule above (validated by the urllib 3-PM PRs) makes that pin unnecessary; collapsing it into unconditional removal is the next code change. -## Code Touchpoints +## Follow-ups (not in this change) -| Area | Change | -| ---- | ------ | -| `crates/vite_global_cli/src/commands/migrate.rs` (+ `delegate.rs`) | Local-vs-global version check; route `migrate` to the global CLI when local `vite-plus` is older | -| `packages/cli/src/migration/migrator.ts` | Replace unconditional vitest pinning with the usage-based decision; exact-version pin of `vite-plus` + core alias for every workspace package; behind/inline alias normalization; empty-`pnpm` fix and dual-source reconciliation; wrapper-only peer-config cleanup | -| `packages/cli/src/migration/detector.ts` | Detect direct vitest usage (source imports + listed `@vitest/*`) | -| `packages/cli/src/migration/bin.ts` | Drive the upgrade in the already-Vite+ path; verify single-vitest post-condition; summary | -| `packages/cli/src/migration/report.ts` | Report version bump, removal-vs-pin decision, ecosystem alignment, verification | -| `docs/guide/upgrade.md` / release notes | Replace the manual prompt + "do not run `vp migrate`" with `vp upgrade && vp migrate` once reliable | - -## Testing Plan - -- **Unit** (`migrator.spec.ts`): - - urllib shape (pnpm, inline `vite`/`vitest` aliases, pinned `vite-plus: ^0.1.24`, empty `"pnpm": {}`, committed `pnpm-workspace.yaml` pinning to `^0.1.24`/wrapper, `@vitest/coverage-v8: ^4.1.8`) -> direct-usage branch: `vite-plus`/core pinned to target, `vitest` pinned `4.1.9`, `@vitest/coverage-v8` -> `4.1.9`, no wrapper, single override source. - - Common-case shape (uses only `vite-plus/test`, no `@vitest/*` dep): `vitest` removed from deps and all resolution mechanisms, no pin added. - - npm/bun/yarn variants; user-authored non-wrapper `vitest`/coverage range preserved with a warning. -- **Snap tests** (`packages/cli/snap-tests-global/`): committed `migration-upgrade-v0_1-*` fixtures for both branches (direct-usage = urllib mirror, common-case = removal), per package manager, plus an idempotency fixture running `vp migrate` twice. Inputs must be committed files. -- **Routing test** (`crates/vite_global_cli`): local `vite-plus` older than global `vp` runs the global migrate path; equal stays local-first. -- **E2E**: real urllib, run the upgrade, assert no wrapper refs, single `vitest@4.1.9`, `@vitest/coverage-v8@4.1.9`, and `vp run cov` passes with no skew warning. - -## Rollout - -1. Land the empty-`pnpm` misrouting fix (Open Question 3) as a standalone bugfix with a regression test, independent of the rest. -2. Ship the full upgrade behavior, then update the v0.2.x release notes / `docs/guide/upgrade.md` to recommend `vp upgrade && vp migrate` and remove the "do not run `vp migrate`" disclaimer. -3. `npm deprecate @voidzero-dev/vite-plus-test "Merged into vite-plus; run 'vp upgrade && vp migrate' to upgrade your project"`. - -## Alternatives Considered - -- **Keep #1588's always-pin behavior** (write `vitest: VITEST_VERSION` for every project). Rejected: the prompt removes vitest in the common case precisely so future `vp update vite-plus` keeps vitest correct without a project pin to drift. Always-pinning creates per-release maintenance and redundant config. -- **Auto-heal in `vp install`**: rejected as primary mechanism (install should not rewrite config unprompted); a discovery warning pointing at `vp migrate` is a follow-up (Open Question 2). It must live in the global routing layer to reach stale-local-CLI projects. -- **Always delegate `migrate` to the global CLI** (drop local-first for this command). Simpler than the version check; changes behavior for users who pin a local version. Open Question 1. - -## Open Questions - -1. **Routing**: local-vs-global version check (recommended) vs. always routing `migrate` through the global CLI? Comparison rule: any-older or only cross-major? -2. Should `vp install` / `vp outdated` warn when a stale wrapper alias or behind `vite-plus` is present, pointing at `vp migrate`? To reach stale-local-CLI projects the warning must live in the global routing layer. -3. The empty-`pkg.pnpm` misrouting is a standalone #1588 bug. Ship it as a separate fix first, with a regression test for the `"pnpm": {}` + `pnpm-workspace.yaml` shape? -4. **Exact vs `latest`**: the prompt pins `vite-plus` and the core alias to the exact target; the current migrate convention writes `@latest` / `catalog: latest`. Should the upgrade path write exact versions (recommended, guarantees the lockfile moves and matches the prompt), and should normal migrate adopt the same? -5. **Removal default under `--no-interactive`**: removing `vitest` and resolution config is more invasive than pinning. Acceptable unattended in CI, or gated behind an explicit flag the first time? -6. Do we want `vp migrate --check` (detection only, exit code signals an available upgrade) for CI, mirroring `vp upgrade --check`? -7. **Direct-usage detection fidelity**: is "any `@vitest/*` listed, or any direct `vitest`/`@vitest` import" sufficient, or do we also need to catch indirect integration packages (`vitest-browser-*`, framework test plugins) that imply vitest usage without a direct import? +- Refine the code so `vitest` is removed even when a vitest ecosystem package is present (keep only the ecosystem-package alignment), per the validated rule. +- Verify vitest browser mode across pnpm/npm/yarn with no direct `vitest`; remove the browser-mode injection if it is obsolete. +- Regenerate `snap-tests-global/migration-*` and add an end-to-end check on a real `0.1.x` project. +- Update `docs/guide/upgrade.md` / the release-notes prompt to the `vp upgrade && vp migrate` flow once shipped, and `npm deprecate @voidzero-dev/vite-plus-test`. +- Optional `vp migrate --check` (detection-only, exit code signals an available upgrade) for CI. From f2b0fdabf846e47762c0b48949f0388888589886 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 19 Jun 2026 20:29:34 +0800 Subject: [PATCH 08/78] fix(migrate): make upgrade provisioning peer-safe --- .../migration-add-git-hooks/snap.txt | 4 - .../migration-agent-claude/snap.txt | 2 +- .../snap.txt | 2 +- .../snap.txt | 2 +- .../migration-already-vite-plus/snap.txt | 3 +- .../snap.txt | 4 - .../migration-baseurl-tsconfig/snap.txt | 4 - .../snap.txt | 4 - .../snap.txt | 4 - .../migration-composed-husky-prepare/snap.txt | 4 - .../migration-env-prefix-lint-staged/snap.txt | 4 - .../migration-eslint-lint-staged/snap.txt | 4 - .../migration-eslint-lintstagedrc/snap.txt | 4 - .../migration-eslint-npx-wrapper/snap.txt | 4 - .../snap.txt | 2 +- .../migration-eslint-rerun-mjs/snap.txt | 2 +- .../migration-eslint-rerun/snap.txt | 2 +- .../migration-eslint/snap.txt | 4 - .../snap.txt | 4 - .../snap.txt | 4 - .../snap.txt | 4 - .../migration-existing-husky/snap.txt | 4 - .../snap.txt | 4 - .../snap.txt | 4 - .../snap.txt | 4 - .../snap.txt | 4 - .../migration-from-tsdown/snap.txt | 4 - .../snap.txt | 4 - .../migration-husky-catalog-version/snap.txt | 4 - .../snap.txt | 4 - .../migration-husky-latest-dist-tag/snap.txt | 4 - .../migration-husky-or-prepare/snap.txt | 4 - .../snap.txt | 4 - .../snap.txt | 4 - .../migration-lazy-plugins-await/snap.txt | 4 - .../migration-lint-staged-in-scripts/snap.txt | 4 - .../migration-lint-staged-merge-fail/snap.txt | 4 - .../migration-lint-staged-ts-config/snap.txt | 4 - .../migration-lintstagedrc-json/snap.txt | 4 - .../snap.txt | 4 - .../snap.txt | 4 - .../snap.txt | 4 - .../migration-merge-vite-config-js/snap.txt | 4 - .../migration-monorepo-bun/snap.txt | 7 +- .../snap.txt | 4 - .../migration-monorepo-pnpm/snap.txt | 7 - .../migration-monorepo-yarn4/snap.txt | 7 +- .../migration-no-agent/snap.txt | 2 +- .../migration-no-git-repo/snap.txt | 4 - .../migration-no-hooks-with-husky/snap.txt | 4 - .../migration-no-hooks/snap.txt | 4 - .../migration-other-hook-tool/snap.txt | 4 - .../snap.txt | 4 - .../migration-oxlintrc-jsonc/snap.txt | 4 - .../snap.txt | 6 +- .../snap.txt | 4 - .../migration-prettier-eslint-combo/snap.txt | 4 - .../snap.txt | 4 - .../migration-prettier-lint-staged/snap.txt | 4 - .../migration-prettier-pkg-json/snap.txt | 4 - .../migration-prettier-rerun/snap.txt | 2 +- .../migration-prettier/snap.txt | 4 - .../migration-skip-vite-dependency/snap.txt | 4 - .../snap.txt | 4 - .../migration-standalone-npm/snap.txt | 4 +- .../migration-standalone-pnpm/snap.txt | 5 - .../migration-subpath/snap.txt | 4 - .../snap.txt | 4 - .../migration-vite-version/snap.txt | 4 - .../src/migration/__tests__/migrator.spec.ts | 200 +++++++- packages/cli/src/migration/bin.ts | 1 + packages/cli/src/migration/migrator.ts | 427 +++++++++++++----- rfcs/migrate-existing-projects.md | 74 +-- 73 files changed, 549 insertions(+), 424 deletions(-) diff --git a/packages/cli/snap-tests-global/migration-add-git-hooks/snap.txt b/packages/cli/snap-tests-global/migration-add-git-hooks/snap.txt index 3ecc5a9256..f59ebbe259 100644 --- a/packages/cli/snap-tests-global/migration-add-git-hooks/snap.txt +++ b/packages/cli/snap-tests-global/migration-add-git-hooks/snap.txt @@ -27,18 +27,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat .vite-hooks/pre-commit # check pre-commit hook vp staged diff --git a/packages/cli/snap-tests-global/migration-agent-claude/snap.txt b/packages/cli/snap-tests-global/migration-agent-claude/snap.txt index 5e0ff8a6ac..13653cb4e3 100644 --- a/packages/cli/snap-tests-global/migration-agent-claude/snap.txt +++ b/packages/cli/snap-tests-global/migration-agent-claude/snap.txt @@ -1,7 +1,7 @@ > vp migrate --agent claude --no-interactive # migration with --agent claude should write CLAUDE.md ◇ Migrated . to Vite+ • Node pnpm -• 2 config updates applied +• 2 config updates applied, 1 file had imports rewritten > cat CLAUDE.md | head -3 # verify CLAUDE.md was created diff --git a/packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-hookspath/snap.txt b/packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-hookspath/snap.txt index a497792e14..f0a1a3747b 100644 --- a/packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-hookspath/snap.txt +++ b/packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-hookspath/snap.txt @@ -15,7 +15,7 @@ }, "devDependencies": { "vite": "^7.0.0", - "vite-plus": "latest" + "vite-plus": "catalog:" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-lint-staged/snap.txt index 81dfa7d245..62d1b178c6 100644 --- a/packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-lint-staged/snap.txt @@ -14,7 +14,7 @@ }, "devDependencies": { "vite": "^7.0.0", - "vite-plus": "latest" + "vite-plus": "catalog:" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-already-vite-plus/snap.txt b/packages/cli/snap-tests-global/migration-already-vite-plus/snap.txt index aa0b62ec9d..d917553c49 100644 --- a/packages/cli/snap-tests-global/migration-already-vite-plus/snap.txt +++ b/packages/cli/snap-tests-global/migration-already-vite-plus/snap.txt @@ -15,8 +15,7 @@ "vite-plus": "latest" }, "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "" + "vite": "npm:@voidzero-dev/vite-plus-core@latest" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-auto-create-vite-config/snap.txt b/packages/cli/snap-tests-global/migration-auto-create-vite-config/snap.txt index e8971d018f..3d80db12e9 100644 --- a/packages/cli/snap-tests-global/migration-auto-create-vite-config/snap.txt +++ b/packages/cli/snap-tests-global/migration-auto-create-vite-config/snap.txt @@ -58,15 +58,11 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-baseurl-tsconfig/snap.txt b/packages/cli/snap-tests-global/migration-baseurl-tsconfig/snap.txt index 6b40797742..ecbe162940 100644 --- a/packages/cli/snap-tests-global/migration-baseurl-tsconfig/snap.txt +++ b/packages/cli/snap-tests-global/migration-baseurl-tsconfig/snap.txt @@ -61,15 +61,11 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-chained-lint-staged-pre-commit/snap.txt b/packages/cli/snap-tests-global/migration-chained-lint-staged-pre-commit/snap.txt index 377a73d062..43f904efba 100644 --- a/packages/cli/snap-tests-global/migration-chained-lint-staged-pre-commit/snap.txt +++ b/packages/cli/snap-tests-global/migration-chained-lint-staged-pre-commit/snap.txt @@ -27,18 +27,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat vite.config.ts # check staged config migrated to vite.config.ts import { defineConfig } from 'vite-plus'; diff --git a/packages/cli/snap-tests-global/migration-composed-husky-custom-dir/snap.txt b/packages/cli/snap-tests-global/migration-composed-husky-custom-dir/snap.txt index 060b656fab..9077f28eb6 100644 --- a/packages/cli/snap-tests-global/migration-composed-husky-custom-dir/snap.txt +++ b/packages/cli/snap-tests-global/migration-composed-husky-custom-dir/snap.txt @@ -27,18 +27,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat .config/husky/pre-commit # pre-commit hook should be in custom dir vp staged diff --git a/packages/cli/snap-tests-global/migration-composed-husky-prepare/snap.txt b/packages/cli/snap-tests-global/migration-composed-husky-prepare/snap.txt index 62670a4322..ee4d3f501a 100644 --- a/packages/cli/snap-tests-global/migration-composed-husky-prepare/snap.txt +++ b/packages/cli/snap-tests-global/migration-composed-husky-prepare/snap.txt @@ -27,15 +27,11 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-env-prefix-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-env-prefix-lint-staged/snap.txt index 1739bfda66..2351276fc8 100644 --- a/packages/cli/snap-tests-global/migration-env-prefix-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-env-prefix-lint-staged/snap.txt @@ -27,18 +27,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat vite.config.ts # check staged config migrated to vite.config.ts import { defineConfig } from 'vite-plus'; diff --git a/packages/cli/snap-tests-global/migration-eslint-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-eslint-lint-staged/snap.txt index 46060a3184..8f84735ba4 100644 --- a/packages/cli/snap-tests-global/migration-eslint-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint-lint-staged/snap.txt @@ -27,18 +27,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat vite.config.ts # check oxlint config and staged config merged into vite.config.ts import { defineConfig } from 'vite-plus'; diff --git a/packages/cli/snap-tests-global/migration-eslint-lintstagedrc/snap.txt b/packages/cli/snap-tests-global/migration-eslint-lintstagedrc/snap.txt index dbeea0338d..4ac2df74b8 100644 --- a/packages/cli/snap-tests-global/migration-eslint-lintstagedrc/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint-lintstagedrc/snap.txt @@ -27,18 +27,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > test ! -f .lintstagedrc.json # check lintstagedrc.json is removed > cat vite.config.ts # check oxlint config merged into vite.config.ts diff --git a/packages/cli/snap-tests-global/migration-eslint-npx-wrapper/snap.txt b/packages/cli/snap-tests-global/migration-eslint-npx-wrapper/snap.txt index eae3f8790e..cfb60af6e8 100644 --- a/packages/cli/snap-tests-global/migration-eslint-npx-wrapper/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint-npx-wrapper/snap.txt @@ -32,17 +32,13 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > test ! -f eslint.config.mjs # check eslint config is removed \ No newline at end of file diff --git a/packages/cli/snap-tests-global/migration-eslint-rerun-dual-config/snap.txt b/packages/cli/snap-tests-global/migration-eslint-rerun-dual-config/snap.txt index 0771255168..751dadc781 100644 --- a/packages/cli/snap-tests-global/migration-eslint-rerun-dual-config/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint-rerun-dual-config/snap.txt @@ -12,7 +12,7 @@ "lint": "vp lint ." }, "devDependencies": { - "vite-plus": "latest" + "vite-plus": "catalog:" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-eslint-rerun-mjs/snap.txt b/packages/cli/snap-tests-global/migration-eslint-rerun-mjs/snap.txt index dc0441dd50..0476a84c93 100644 --- a/packages/cli/snap-tests-global/migration-eslint-rerun-mjs/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint-rerun-mjs/snap.txt @@ -12,7 +12,7 @@ "lint": "vp lint ." }, "devDependencies": { - "vite-plus": "latest" + "vite-plus": "catalog:" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-eslint-rerun/snap.txt b/packages/cli/snap-tests-global/migration-eslint-rerun/snap.txt index fa4bf5b15c..60f25d1c4e 100644 --- a/packages/cli/snap-tests-global/migration-eslint-rerun/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint-rerun/snap.txt @@ -12,7 +12,7 @@ "lint": "vp lint ." }, "devDependencies": { - "vite-plus": "latest" + "vite-plus": "catalog:" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-eslint/snap.txt b/packages/cli/snap-tests-global/migration-eslint/snap.txt index fea606b7f3..a6795c9c48 100644 --- a/packages/cli/snap-tests-global/migration-eslint/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint/snap.txt @@ -30,18 +30,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > test ! -f eslint.config.mjs # check eslint config is removed > cat vite.config.ts # check oxlint config merged into vite.config.ts diff --git a/packages/cli/snap-tests-global/migration-existing-husky-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-existing-husky-lint-staged/snap.txt index d2c1c68a13..5bfc30a07b 100644 --- a/packages/cli/snap-tests-global/migration-existing-husky-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-husky-lint-staged/snap.txt @@ -27,18 +27,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat vite.config.ts # check staged config migrated to vite.config.ts import { defineConfig } from 'vite-plus'; diff --git a/packages/cli/snap-tests-global/migration-existing-husky-v8-hooks/snap.txt b/packages/cli/snap-tests-global/migration-existing-husky-v8-hooks/snap.txt index 5d26bc1549..7d7556c838 100644 --- a/packages/cli/snap-tests-global/migration-existing-husky-v8-hooks/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-husky-v8-hooks/snap.txt @@ -30,18 +30,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat .husky/pre-commit # hook file should be unchanged (still has bootstrap) . "$(dirname -- "$0")/_/husky.sh" diff --git a/packages/cli/snap-tests-global/migration-existing-husky-v8-multi-hooks/snap.txt b/packages/cli/snap-tests-global/migration-existing-husky-v8-multi-hooks/snap.txt index d848a1259c..1bd78cc26e 100644 --- a/packages/cli/snap-tests-global/migration-existing-husky-v8-multi-hooks/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-husky-v8-multi-hooks/snap.txt @@ -30,18 +30,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat .husky/pre-commit # hook file should be unchanged (still has bootstrap) . "$(dirname -- "$0")/_/husky.sh" diff --git a/packages/cli/snap-tests-global/migration-existing-husky/snap.txt b/packages/cli/snap-tests-global/migration-existing-husky/snap.txt index cb5a7637e8..625779fd04 100644 --- a/packages/cli/snap-tests-global/migration-existing-husky/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-husky/snap.txt @@ -27,18 +27,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat .vite-hooks/pre-commit # check pre-commit hook rewritten to vp staged vp staged diff --git a/packages/cli/snap-tests-global/migration-existing-lint-staged-config/snap.txt b/packages/cli/snap-tests-global/migration-existing-lint-staged-config/snap.txt index 940fa1c0aa..8ca67d4068 100644 --- a/packages/cli/snap-tests-global/migration-existing-lint-staged-config/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-lint-staged-config/snap.txt @@ -27,18 +27,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > test ! -f .lintstagedrc.json # check lintstagedrc.json (should be deleted after inlining to vite.config.ts) > cat vite.config.ts # check staged config migrated to vite.config.ts diff --git a/packages/cli/snap-tests-global/migration-existing-pnpm-exec-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-existing-pnpm-exec-lint-staged/snap.txt index e6c009ca2b..1e8305dbd6 100644 --- a/packages/cli/snap-tests-global/migration-existing-pnpm-exec-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-pnpm-exec-lint-staged/snap.txt @@ -27,18 +27,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat vite.config.ts # check staged config migrated to vite.config.ts import { defineConfig } from 'vite-plus'; diff --git a/packages/cli/snap-tests-global/migration-existing-prepare-script/snap.txt b/packages/cli/snap-tests-global/migration-existing-prepare-script/snap.txt index 5a898b0f28..df00607092 100644 --- a/packages/cli/snap-tests-global/migration-existing-prepare-script/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-prepare-script/snap.txt @@ -28,18 +28,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat .vite-hooks/pre-commit # check pre-commit hook vp staged diff --git a/packages/cli/snap-tests-global/migration-from-tsdown-json-config/snap.txt b/packages/cli/snap-tests-global/migration-from-tsdown-json-config/snap.txt index 16087a6ade..dff04522cd 100644 --- a/packages/cli/snap-tests-global/migration-from-tsdown-json-config/snap.txt +++ b/packages/cli/snap-tests-global/migration-from-tsdown-json-config/snap.txt @@ -52,18 +52,14 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > vp migrate --no-interactive # run migration again to check if it is idempotent This project is already using Vite+! Happy coding! diff --git a/packages/cli/snap-tests-global/migration-from-tsdown/snap.txt b/packages/cli/snap-tests-global/migration-from-tsdown/snap.txt index 547d4c1772..1045b7499e 100644 --- a/packages/cli/snap-tests-global/migration-from-tsdown/snap.txt +++ b/packages/cli/snap-tests-global/migration-from-tsdown/snap.txt @@ -54,18 +54,14 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > vp migrate --no-interactive # run migration again to check if it is idempotent This project is already using Vite+! Happy coding! diff --git a/packages/cli/snap-tests-global/migration-hooks-skip-on-existing-hookspath/snap.txt b/packages/cli/snap-tests-global/migration-hooks-skip-on-existing-hookspath/snap.txt index 3a30efa064..5dd710ab9f 100644 --- a/packages/cli/snap-tests-global/migration-hooks-skip-on-existing-hookspath/snap.txt +++ b/packages/cli/snap-tests-global/migration-hooks-skip-on-existing-hookspath/snap.txt @@ -30,18 +30,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > git config --local core.hooksPath # should still be .custom-hooks .custom-hooks diff --git a/packages/cli/snap-tests-global/migration-husky-catalog-version/snap.txt b/packages/cli/snap-tests-global/migration-husky-catalog-version/snap.txt index 72ce13481b..132c4aff73 100644 --- a/packages/cli/snap-tests-global/migration-husky-catalog-version/snap.txt +++ b/packages/cli/snap-tests-global/migration-husky-catalog-version/snap.txt @@ -35,18 +35,14 @@ catalog: husky: ^9.1.7 lint-staged: ^16.2.6 vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat vite.config.ts # check staged config migrated to vite.config.ts import { defineConfig } from 'vite-plus'; diff --git a/packages/cli/snap-tests-global/migration-husky-latest-dist-tag-v9-installed/snap.txt b/packages/cli/snap-tests-global/migration-husky-latest-dist-tag-v9-installed/snap.txt index efd29b79ed..553fb5694d 100644 --- a/packages/cli/snap-tests-global/migration-husky-latest-dist-tag-v9-installed/snap.txt +++ b/packages/cli/snap-tests-global/migration-husky-latest-dist-tag-v9-installed/snap.txt @@ -27,15 +27,11 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-husky-latest-dist-tag/snap.txt b/packages/cli/snap-tests-global/migration-husky-latest-dist-tag/snap.txt index 40ab64b83a..fa4e63bf77 100644 --- a/packages/cli/snap-tests-global/migration-husky-latest-dist-tag/snap.txt +++ b/packages/cli/snap-tests-global/migration-husky-latest-dist-tag/snap.txt @@ -29,15 +29,11 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-husky-or-prepare/snap.txt b/packages/cli/snap-tests-global/migration-husky-or-prepare/snap.txt index 2e91e7579e..a5cec54506 100644 --- a/packages/cli/snap-tests-global/migration-husky-or-prepare/snap.txt +++ b/packages/cli/snap-tests-global/migration-husky-or-prepare/snap.txt @@ -27,15 +27,11 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-husky-semicolon-prepare/snap.txt b/packages/cli/snap-tests-global/migration-husky-semicolon-prepare/snap.txt index 3b7b394d0c..f8da4e6f7a 100644 --- a/packages/cli/snap-tests-global/migration-husky-semicolon-prepare/snap.txt +++ b/packages/cli/snap-tests-global/migration-husky-semicolon-prepare/snap.txt @@ -27,15 +27,11 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-husky-v8-preserves-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-husky-v8-preserves-lint-staged/snap.txt index 20e45a6e33..acac9f1da0 100644 --- a/packages/cli/snap-tests-global/migration-husky-v8-preserves-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-husky-v8-preserves-lint-staged/snap.txt @@ -33,15 +33,11 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-lazy-plugins-await/snap.txt b/packages/cli/snap-tests-global/migration-lazy-plugins-await/snap.txt index 95a4844c9a..bbdf28e64a 100644 --- a/packages/cli/snap-tests-global/migration-lazy-plugins-await/snap.txt +++ b/packages/cli/snap-tests-global/migration-lazy-plugins-await/snap.txt @@ -36,15 +36,11 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-lint-staged-in-scripts/snap.txt b/packages/cli/snap-tests-global/migration-lint-staged-in-scripts/snap.txt index bfa1bb00f7..7960ec688f 100644 --- a/packages/cli/snap-tests-global/migration-lint-staged-in-scripts/snap.txt +++ b/packages/cli/snap-tests-global/migration-lint-staged-in-scripts/snap.txt @@ -28,18 +28,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat vite.config.ts # check staged config migrated to vite.config.ts import { defineConfig } from 'vite-plus'; diff --git a/packages/cli/snap-tests-global/migration-lint-staged-merge-fail/snap.txt b/packages/cli/snap-tests-global/migration-lint-staged-merge-fail/snap.txt index ecdad850c3..360d056826 100644 --- a/packages/cli/snap-tests-global/migration-lint-staged-merge-fail/snap.txt +++ b/packages/cli/snap-tests-global/migration-lint-staged-merge-fail/snap.txt @@ -36,18 +36,14 @@ Please add staged config to vite.config.ts manually, see https://viteplus.dev/gu > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat vite.config.ts # vite config should be unchanged (merge failed) const config = { plugins: [] }; diff --git a/packages/cli/snap-tests-global/migration-lint-staged-ts-config/snap.txt b/packages/cli/snap-tests-global/migration-lint-staged-ts-config/snap.txt index 287d37346a..4d1ecec73f 100644 --- a/packages/cli/snap-tests-global/migration-lint-staged-ts-config/snap.txt +++ b/packages/cli/snap-tests-global/migration-lint-staged-ts-config/snap.txt @@ -31,18 +31,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat lint-staged.config.ts # check TS config is not modified export default { diff --git a/packages/cli/snap-tests-global/migration-lintstagedrc-json/snap.txt b/packages/cli/snap-tests-global/migration-lintstagedrc-json/snap.txt index f7d3924f9c..3a400ae3d2 100644 --- a/packages/cli/snap-tests-global/migration-lintstagedrc-json/snap.txt +++ b/packages/cli/snap-tests-global/migration-lintstagedrc-json/snap.txt @@ -101,18 +101,14 @@ Documentation: https://viteplus.dev/guide/migrate > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat vite.config.ts # check staged config migrated to vite.config.ts import { defineConfig } from 'vite-plus'; diff --git a/packages/cli/snap-tests-global/migration-lintstagedrc-merge-fail/snap.txt b/packages/cli/snap-tests-global/migration-lintstagedrc-merge-fail/snap.txt index 9b0e74b5d9..bd3f0f4a87 100644 --- a/packages/cli/snap-tests-global/migration-lintstagedrc-merge-fail/snap.txt +++ b/packages/cli/snap-tests-global/migration-lintstagedrc-merge-fail/snap.txt @@ -33,18 +33,14 @@ Please add staged config to vite.config.ts manually, see https://viteplus.dev/gu > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat .lintstagedrc.json # config file should be preserved when merge fails { diff --git a/packages/cli/snap-tests-global/migration-lintstagedrc-not-support/snap.txt b/packages/cli/snap-tests-global/migration-lintstagedrc-not-support/snap.txt index 6e94aa1508..2a3455d330 100644 --- a/packages/cli/snap-tests-global/migration-lintstagedrc-not-support/snap.txt +++ b/packages/cli/snap-tests-global/migration-lintstagedrc-not-support/snap.txt @@ -45,15 +45,11 @@ export default { > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-lintstagedrc-staged-exists/snap.txt b/packages/cli/snap-tests-global/migration-lintstagedrc-staged-exists/snap.txt index ba09d0e639..6d03dc48a1 100644 --- a/packages/cli/snap-tests-global/migration-lintstagedrc-staged-exists/snap.txt +++ b/packages/cli/snap-tests-global/migration-lintstagedrc-staged-exists/snap.txt @@ -28,18 +28,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > test -f .lintstagedrc.json && echo 'lintstagedrc.json still exists' || echo 'lintstagedrc.json was deleted' # should still exist lintstagedrc.json still exists diff --git a/packages/cli/snap-tests-global/migration-merge-vite-config-js/snap.txt b/packages/cli/snap-tests-global/migration-merge-vite-config-js/snap.txt index 38385db3b2..f2ba7beff2 100644 --- a/packages/cli/snap-tests-global/migration-merge-vite-config-js/snap.txt +++ b/packages/cli/snap-tests-global/migration-merge-vite-config-js/snap.txt @@ -58,15 +58,11 @@ export default { > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-monorepo-bun/snap.txt b/packages/cli/snap-tests-global/migration-monorepo-bun/snap.txt index 8a36eae8d9..28d20df0c0 100644 --- a/packages/cli/snap-tests-global/migration-monorepo-bun/snap.txt +++ b/packages/cli/snap-tests-global/migration-monorepo-bun/snap.txt @@ -45,7 +45,6 @@ export default defineConfig({ ], "catalog": { "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "", "vite-plus": "latest" } }, @@ -63,13 +62,11 @@ export default defineConfig({ "devDependencies": { "@vitejs/plugin-react": "catalog:", "vite": "catalog:", - "vitest": "catalog:", "vite-plus": "catalog:" }, "packageManager": "bun@", "overrides": { - "vite": "catalog:", - "vitest": "catalog:" + "vite": "catalog:" } } @@ -89,7 +86,6 @@ export default defineConfig({ "devDependencies": { "test-vite-plus-package": "1.0.0", "vite": "catalog:", - "vitest": "catalog:", "vite-plus": "catalog:" } } @@ -107,7 +103,6 @@ export default defineConfig({ }, "devDependencies": { "vite": "catalog:", - "vitest": "catalog:", "vite-plus": "catalog:" } } diff --git a/packages/cli/snap-tests-global/migration-monorepo-pnpm-overrides-dependency-selector/snap.txt b/packages/cli/snap-tests-global/migration-monorepo-pnpm-overrides-dependency-selector/snap.txt index b32ff43659..ec987b96f3 100644 --- a/packages/cli/snap-tests-global/migration-monorepo-pnpm-overrides-dependency-selector/snap.txt +++ b/packages/cli/snap-tests-global/migration-monorepo-pnpm-overrides-dependency-selector/snap.txt @@ -39,22 +39,18 @@ packages: catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: '@vitejs/plugin-react>vite': 'npm:vite@' 'supertest>superagent': vite: 'catalog:' - vitest: 'catalog:' react-click-away-listener>react: peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat packages/app/package.json # check app package.json { diff --git a/packages/cli/snap-tests-global/migration-monorepo-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-monorepo-pnpm/snap.txt index 0b8b6f890d..3e8d89ba2a 100644 --- a/packages/cli/snap-tests-global/migration-monorepo-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-monorepo-pnpm/snap.txt @@ -66,7 +66,6 @@ export default defineConfig({ "devDependencies": { "@vitejs/plugin-react": "catalog:", "vite": "catalog:", - "vitest": "catalog:", "vite-plus": "catalog:" }, "resolutions": { @@ -83,20 +82,16 @@ catalog: testnpm2: ^1.0.0 # test comment here to check if the comment is preserved vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest minimumReleaseAge: 1440 overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' minimumReleaseAgeExclude: - vite-plus - '@voidzero-dev/*' @@ -125,7 +120,6 @@ minimumReleaseAgeExclude: "devDependencies": { "test-vite-plus-package": "1.0.0", "vite": "catalog:", - "vitest": "catalog:", "vite-plus": "catalog:" }, "optionalDependencies": { @@ -146,7 +140,6 @@ minimumReleaseAgeExclude: }, "devDependencies": { "vite": "catalog:", - "vitest": "catalog:", "vite-plus": "catalog:" } } diff --git a/packages/cli/snap-tests-global/migration-monorepo-yarn4/snap.txt b/packages/cli/snap-tests-global/migration-monorepo-yarn4/snap.txt index 6bd15e4900..1ee2f91b8b 100644 --- a/packages/cli/snap-tests-global/migration-monorepo-yarn4/snap.txt +++ b/packages/cli/snap-tests-global/migration-monorepo-yarn4/snap.txt @@ -60,13 +60,11 @@ export default defineConfig({ "devDependencies": { "@vitejs/plugin-react": "catalog:", "vite": "catalog:", - "vitest": "catalog:", "vite-plus": "catalog:" }, "packageManager": "yarn@", "resolutions": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "" + "vite": "npm:@voidzero-dev/vite-plus-core@latest" } } @@ -77,7 +75,6 @@ npmPreapprovedPackages: - '@vitest/*' catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest > cat packages/app/package.json # check app package.json @@ -96,7 +93,6 @@ catalog: "devDependencies": { "test-vite-plus-package": "1.0.0", "vite": "catalog:", - "vitest": "catalog:", "vite-plus": "catalog:" }, "optionalDependencies": { @@ -117,7 +113,6 @@ catalog: }, "devDependencies": { "vite": "catalog:", - "vitest": "catalog:", "vite-plus": "catalog:" } } diff --git a/packages/cli/snap-tests-global/migration-no-agent/snap.txt b/packages/cli/snap-tests-global/migration-no-agent/snap.txt index ca1dc7f635..844536aae3 100644 --- a/packages/cli/snap-tests-global/migration-no-agent/snap.txt +++ b/packages/cli/snap-tests-global/migration-no-agent/snap.txt @@ -1,7 +1,7 @@ > vp migrate --no-agent --no-interactive # migration with --no-agent should skip agent instructions ◇ Migrated . to Vite+ • Node pnpm -• 2 config updates applied +• 2 config updates applied, 1 file had imports rewritten > ls -la | grep -E '(AGENTS|CLAUDE)' || echo 'No agent file created' # verify no agent file was created No agent file created diff --git a/packages/cli/snap-tests-global/migration-no-git-repo/snap.txt b/packages/cli/snap-tests-global/migration-no-git-repo/snap.txt index b7f357bd28..39fbe1bd62 100644 --- a/packages/cli/snap-tests-global/migration-no-git-repo/snap.txt +++ b/packages/cli/snap-tests-global/migration-no-git-repo/snap.txt @@ -25,18 +25,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > test -d .vite-hooks && echo 'hooks dir exists' || echo 'no hooks dir' hooks dir exists diff --git a/packages/cli/snap-tests-global/migration-no-hooks-with-husky/snap.txt b/packages/cli/snap-tests-global/migration-no-hooks-with-husky/snap.txt index ec9d22ab50..7299b0296f 100644 --- a/packages/cli/snap-tests-global/migration-no-hooks-with-husky/snap.txt +++ b/packages/cli/snap-tests-global/migration-no-hooks-with-husky/snap.txt @@ -32,18 +32,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > test -d .husky && echo '.husky directory exists' || echo 'No .husky directory' # verify no .husky directory No .husky directory diff --git a/packages/cli/snap-tests-global/migration-no-hooks/snap.txt b/packages/cli/snap-tests-global/migration-no-hooks/snap.txt index 99b7a3d9fb..f9dcc0b68b 100644 --- a/packages/cli/snap-tests-global/migration-no-hooks/snap.txt +++ b/packages/cli/snap-tests-global/migration-no-hooks/snap.txt @@ -23,18 +23,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > test -d .vite-hooks && echo '.vite-hooks directory exists' || echo 'No .vite-hooks directory' # verify no .vite-hooks directory No .vite-hooks directory diff --git a/packages/cli/snap-tests-global/migration-other-hook-tool/snap.txt b/packages/cli/snap-tests-global/migration-other-hook-tool/snap.txt index f1fa202e1a..f741059b24 100644 --- a/packages/cli/snap-tests-global/migration-other-hook-tool/snap.txt +++ b/packages/cli/snap-tests-global/migration-other-hook-tool/snap.txt @@ -35,15 +35,11 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-oxlintrc-json-with-comments/snap.txt b/packages/cli/snap-tests-global/migration-oxlintrc-json-with-comments/snap.txt index 5603db72d3..744e1994cf 100644 --- a/packages/cli/snap-tests-global/migration-oxlintrc-json-with-comments/snap.txt +++ b/packages/cli/snap-tests-global/migration-oxlintrc-json-with-comments/snap.txt @@ -56,15 +56,11 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-oxlintrc-jsonc/snap.txt b/packages/cli/snap-tests-global/migration-oxlintrc-jsonc/snap.txt index 7eb27b65ed..ebf2188bf3 100644 --- a/packages/cli/snap-tests-global/migration-oxlintrc-jsonc/snap.txt +++ b/packages/cli/snap-tests-global/migration-oxlintrc-jsonc/snap.txt @@ -58,15 +58,11 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-partially-installed-vite-plus/snap.txt b/packages/cli/snap-tests-global/migration-partially-installed-vite-plus/snap.txt index 59f7b0f5ed..1633a2fd74 100644 --- a/packages/cli/snap-tests-global/migration-partially-installed-vite-plus/snap.txt +++ b/packages/cli/snap-tests-global/migration-partially-installed-vite-plus/snap.txt @@ -28,7 +28,7 @@ "globals": "^17.6.0", "typescript": "~6.0.2", "vite": "^8.0.12", - "vite-plus": "^0.1.24" + "vite-plus": "catalog:" }, "devEngines": { "packageManager": { @@ -42,18 +42,14 @@ > cat pnpm-workspace.yaml # pnpm overrides and peerDependencyRules should be configured catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat vite.config.ts # vite imports should be rewritten import { defineConfig } from 'vite-plus' diff --git a/packages/cli/snap-tests-global/migration-partially-migrated-pre-commit/snap.txt b/packages/cli/snap-tests-global/migration-partially-migrated-pre-commit/snap.txt index 3d367ba88e..efae8980d3 100644 --- a/packages/cli/snap-tests-global/migration-partially-migrated-pre-commit/snap.txt +++ b/packages/cli/snap-tests-global/migration-partially-migrated-pre-commit/snap.txt @@ -30,18 +30,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat .husky/pre-commit # hook file should be unchanged (still has bootstrap) . "$(dirname -- "$0")/_/husky.sh" diff --git a/packages/cli/snap-tests-global/migration-prettier-eslint-combo/snap.txt b/packages/cli/snap-tests-global/migration-prettier-eslint-combo/snap.txt index a52152e82e..aab95fdf19 100644 --- a/packages/cli/snap-tests-global/migration-prettier-eslint-combo/snap.txt +++ b/packages/cli/snap-tests-global/migration-prettier-eslint-combo/snap.txt @@ -34,18 +34,14 @@ Prettier configuration detected. Auto-migrating to Oxfmt... > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > test ! -f eslint.config.mjs # check eslint config is removed > test ! -f .prettierrc.json # check prettier config is removed diff --git a/packages/cli/snap-tests-global/migration-prettier-ignore-unknown/snap.txt b/packages/cli/snap-tests-global/migration-prettier-ignore-unknown/snap.txt index efc29708b4..7b49f4cde1 100644 --- a/packages/cli/snap-tests-global/migration-prettier-ignore-unknown/snap.txt +++ b/packages/cli/snap-tests-global/migration-prettier-ignore-unknown/snap.txt @@ -32,17 +32,13 @@ Prettier configuration detected. Auto-migrating to Oxfmt... > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > test ! -f .prettierrc.json # check prettier config is removed \ No newline at end of file diff --git a/packages/cli/snap-tests-global/migration-prettier-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-prettier-lint-staged/snap.txt index 3c99c1aaea..c7ef71ca50 100644 --- a/packages/cli/snap-tests-global/migration-prettier-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-prettier-lint-staged/snap.txt @@ -29,18 +29,14 @@ Prettier configuration detected. Auto-migrating to Oxfmt... > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat vite.config.ts # check oxfmt config and staged config merged into vite.config.ts import { defineConfig } from "vite-plus"; diff --git a/packages/cli/snap-tests-global/migration-prettier-pkg-json/snap.txt b/packages/cli/snap-tests-global/migration-prettier-pkg-json/snap.txt index 14e83cdefa..b199e2a1b1 100644 --- a/packages/cli/snap-tests-global/migration-prettier-pkg-json/snap.txt +++ b/packages/cli/snap-tests-global/migration-prettier-pkg-json/snap.txt @@ -30,18 +30,14 @@ Prettier configuration detected. Auto-migrating to Oxfmt... > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat vite.config.ts # check oxfmt config merged into vite.config.ts with semi/singleQuote settings import { defineConfig } from "vite-plus"; diff --git a/packages/cli/snap-tests-global/migration-prettier-rerun/snap.txt b/packages/cli/snap-tests-global/migration-prettier-rerun/snap.txt index 9bc156a317..df5a602fc3 100644 --- a/packages/cli/snap-tests-global/migration-prettier-rerun/snap.txt +++ b/packages/cli/snap-tests-global/migration-prettier-rerun/snap.txt @@ -14,7 +14,7 @@ Prettier configuration detected. Auto-migrating to Oxfmt... "format": "vp fmt ." }, "devDependencies": { - "vite-plus": "latest" + "vite-plus": "catalog:" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-prettier/snap.txt b/packages/cli/snap-tests-global/migration-prettier/snap.txt index ec5ada2f30..82bc36c019 100644 --- a/packages/cli/snap-tests-global/migration-prettier/snap.txt +++ b/packages/cli/snap-tests-global/migration-prettier/snap.txt @@ -32,18 +32,14 @@ Prettier configuration detected. Auto-migrating to Oxfmt... > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > test ! -f .prettierrc.json # check prettier config is removed > cat vite.config.ts # check oxfmt config merged into vite.config.ts diff --git a/packages/cli/snap-tests-global/migration-skip-vite-dependency/snap.txt b/packages/cli/snap-tests-global/migration-skip-vite-dependency/snap.txt index d74639391b..042f2820f0 100644 --- a/packages/cli/snap-tests-global/migration-skip-vite-dependency/snap.txt +++ b/packages/cli/snap-tests-global/migration-skip-vite-dependency/snap.txt @@ -50,15 +50,11 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' 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 29b077788e..ecce492eef 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 @@ -50,15 +50,11 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-standalone-npm/snap.txt b/packages/cli/snap-tests-global/migration-standalone-npm/snap.txt index 1bcde4d80e..c718e55578 100644 --- a/packages/cli/snap-tests-global/migration-standalone-npm/snap.txt +++ b/packages/cli/snap-tests-global/migration-standalone-npm/snap.txt @@ -9,13 +9,11 @@ "name": "migration-standalone-npm", "devDependencies": { "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "", "vite-plus": "latest" }, "packageManager": "npm@", "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "" + "vite": "npm:@voidzero-dev/vite-plus-core@latest" } } diff --git a/packages/cli/snap-tests-global/migration-standalone-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-standalone-pnpm/snap.txt index 53b010c9be..244b0c7f87 100644 --- a/packages/cli/snap-tests-global/migration-standalone-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-standalone-pnpm/snap.txt @@ -9,7 +9,6 @@ "name": "migration-standalone-pnpm", "devDependencies": { "vite": "catalog:", - "vitest": "catalog:", "vite-plus": "catalog:" }, "packageManager": "pnpm@" @@ -18,15 +17,11 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides, peerDependencyRules, and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-subpath/snap.txt b/packages/cli/snap-tests-global/migration-subpath/snap.txt index d8e7263702..6b2fe92a14 100644 --- a/packages/cli/snap-tests-global/migration-subpath/snap.txt +++ b/packages/cli/snap-tests-global/migration-subpath/snap.txt @@ -45,15 +45,11 @@ core.hooksPath is not set > cat foo/pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-tsconfig-esmoduleinterop/snap.txt b/packages/cli/snap-tests-global/migration-tsconfig-esmoduleinterop/snap.txt index ae26c63e1e..00262e60ed 100644 --- a/packages/cli/snap-tests-global/migration-tsconfig-esmoduleinterop/snap.txt +++ b/packages/cli/snap-tests-global/migration-tsconfig-esmoduleinterop/snap.txt @@ -47,15 +47,11 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-vite-version/snap.txt b/packages/cli/snap-tests-global/migration-vite-version/snap.txt index 9cdfbfdcad..31d179e307 100644 --- a/packages/cli/snap-tests-global/migration-vite-version/snap.txt +++ b/packages/cli/snap-tests-global/migration-vite-version/snap.txt @@ -27,15 +27,11 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 6f47ef7e93..f23e294fff 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -1510,6 +1510,11 @@ describe('ensureVitePlusBootstrap', () => { // The deleted wrapper alias must no longer survive in the workspace.yaml. const workspaceRaw = fs.readFileSync(path.join(tmpDir, 'pnpm-workspace.yaml'), 'utf-8'); expect(workspaceRaw).not.toContain('@voidzero-dev/vite-plus-test'); + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + }; + expect(pkg.devDependencies.vitest).toBe('catalog:'); + expect(JSON.stringify(pkg)).not.toContain('@voidzero-dev/vite-plus-test'); // And the project must not be left pending (no stale wrapper override anywhere). expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); @@ -1544,6 +1549,7 @@ describe('ensureVitePlusBootstrap', () => { devDependencies: Record; }; expect(pkg.devDependencies['@vitest/coverage-v8']).toBe(VITEST_VERSION); + expect(pkg.devDependencies.vitest).toBe(VITEST_VERSION); expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(false); }); @@ -1578,6 +1584,161 @@ describe('ensureVitePlusBootstrap', () => { expect(pkg.devDependencies['@vitest/ui']).toBe(VITEST_VERSION); expect(pkg.devDependencies['@vitest/web-worker']).toBe(VITEST_VERSION); expect(pkg.devDependencies['@vitest/eslint-plugin']).toBe('^1.0.0'); + expect(pkg.devDependencies.vitest).toBe(VITEST_VERSION); + }); + + it('does not treat @vitest/eslint-plugin as runner usage', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { + 'vite-plus': 'latest', + '@vitest/eslint-plugin': '^1.6.0', + '@vitest/utils': '^4.1.8', + vitest: '4.1.8', + }, + overrides: { + vite: 'npm:@voidzero-dev/vite-plus-core@latest', + vitest: '4.1.8', + }, + devEngines: { + packageManager: { name: 'npm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'eslint.config.js'), + "import vitest from '@vitest/eslint-plugin';\nimport { diff } from '@vitest/utils';\nexport default [vitest.configs.recommended, diff];\n", + ); + + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.npm)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + overrides: Record; + }; + expect(pkg.devDependencies['@vitest/eslint-plugin']).toBe('^1.6.0'); + expect(pkg.devDependencies['@vitest/utils']).toBe(VITEST_VERSION); + expect(pkg.devDependencies.vitest).toBeUndefined(); + expect(pkg.overrides.vitest).toBeUndefined(); + }); + + it('reconciles vitest and vite-plus in the workspace package that needs them', () => { + const appDir = path.join(tmpDir, 'packages/app'); + fs.mkdirSync(appDir, { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'root', + private: true, + devDependencies: { 'vite-plus': 'catalog:' }, + devEngines: { + packageManager: { name: 'pnpm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + fs.writeFileSync( + path.join(appDir, 'package.json'), + JSON.stringify({ + name: 'app', + devDependencies: { + 'vite-plus': '^0.1.24', + vitest: 'npm:@voidzero-dev/vite-plus-test@^0.1.24', + '@vitest/ui': '^4.1.8', + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'pnpm-workspace.yaml'), + [ + 'packages:', + ' - packages/*', + 'catalog:', + ' vite-plus: latest', + ' vite: npm:@voidzero-dev/vite-plus-core@latest', + 'overrides:', + " vite: 'catalog:'", + 'peerDependencyRules:', + ' allowAny: [vite]', + ' allowedVersions:', + " vite: '*'", + '', + ].join('\n'), + ); + const workspaceInfo = { + ...makeWorkspaceInfo(tmpDir, PackageManager.pnpm), + isMonorepo: true, + workspacePatterns: ['packages/*'], + packages: [{ name: 'app', path: 'packages/app' }], + }; + + expect( + detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm, workspaceInfo.packages), + ).toBe(true); + ensureVitePlusBootstrap(workspaceInfo); + + const rootPkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + }; + const appPkg = readJson(path.join(appDir, 'package.json')) as { + devDependencies: Record; + }; + expect(rootPkg.devDependencies.vitest).toBeUndefined(); + expect(appPkg.devDependencies['vite-plus']).toBe('catalog:'); + expect(appPkg.devDependencies['@vitest/ui']).toBe(VITEST_VERSION); + expect(appPkg.devDependencies.vitest).toBe('catalog:'); + expect(JSON.stringify(appPkg)).not.toContain('@voidzero-dev/vite-plus-test'); + expect( + detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm, workspaceInfo.packages), + ).toBe(false); + }); + + it('restores an opt-in browser provider used only through a Vite+ shim', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'browser-app', + devDependencies: { 'vite-plus': 'catalog:' }, + devEngines: { + packageManager: { name: 'pnpm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'vite.config.ts'), + [ + "import { defineConfig } from 'vite-plus';", + "import { playwright } from 'vite-plus/test/browser-playwright';", + 'export default defineConfig({ test: { browser: { enabled: true, provider: playwright() } } });', + ].join('\n'), + ); + fs.writeFileSync( + path.join(tmpDir, 'pnpm-workspace.yaml'), + [ + 'catalog:', + ' vite-plus: latest', + ' vite: npm:@voidzero-dev/vite-plus-core@latest', + 'overrides:', + " vite: 'catalog:'", + 'peerDependencyRules:', + ' allowAny: [vite]', + ' allowedVersions:', + " vite: '*'", + '', + ].join('\n'), + ); + + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(true); + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + }; + expect(pkg.devDependencies['@vitest/browser-playwright']).toBe(VITEST_VERSION); + expect(pkg.devDependencies.playwright).toBe('*'); + expect(pkg.devDependencies.vitest).toBe('catalog:'); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); }); it('removes a stale vitest wrapper override for a common-case npm project (no @vitest/* dep, no vitest source)', () => { @@ -2399,11 +2560,9 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { expect(overrides['some-pkg']['@vitest/browser-playwright']).toBe('4.0.0'); }); - it('leaves an already-declared coverage provider untouched (no pin, no override)', () => { - // Coverage providers are vitest PEER deps the project installs and versions - // ITSELF. vite-plus never pins or overrides them: the user owns the provider - // version. (The runtime guard in define-config.ts only fail-fasts on a skew - // at `vp test --coverage` time; it does not rewrite the project's deps.) + it('aligns already-declared coverage providers without adding provider overrides', () => { + // Coverage providers have an exact vitest peer and must match the runner. + // Align their dependency specs directly; no provider override is needed. fs.writeFileSync( path.join(tmpDir, 'package.json'), JSON.stringify({ @@ -2422,9 +2581,8 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { devDependencies: Record; overrides?: Record; }; - // Provider versions are preserved exactly as the user declared them. - expect(pkg.devDependencies['@vitest/coverage-v8']).toBe('^4.0.0'); - expect(pkg.devDependencies['@vitest/coverage-istanbul']).toBe('^4.0.0'); + expect(pkg.devDependencies['@vitest/coverage-v8']).toBe(VITEST_VERSION); + expect(pkg.devDependencies['@vitest/coverage-istanbul']).toBe(VITEST_VERSION); // vitest itself is still pinned to the bundled version. expect(pkg.devDependencies.vitest).toBe(VITEST_VERSION); // …and coverage is never written into the override sink. @@ -2433,6 +2591,32 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { expect(overrides['@vitest/coverage-istanbul']).toBeUndefined(); }); + it('removes direct vitest in the same pass that rewrites ordinary vitest imports', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { vite: '^7.0.0', vitest: '^4.0.0' }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'example.spec.ts'), + "import { expect, it } from 'vitest';\nit('works', () => expect(true).toBe(true));\n", + ); + + rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.npm), true, true); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + overrides: Record; + }; + expect(pkg.devDependencies.vitest).toBeUndefined(); + expect(pkg.overrides.vitest).toBeUndefined(); + expect(fs.readFileSync(path.join(tmpDir, 'example.spec.ts'), 'utf8')).toContain( + "from 'vite-plus/test'", + ); + }); + it('does not add a coverage provider the project never declared', () => { // A project that uses vitest WITHOUT a coverage provider must not have one // injected by the migration — the user installs it only if they need it. diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index 58ea7e75da..49edcb8476 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -1178,6 +1178,7 @@ async function main() { const vitePlusBootstrapPending = detectVitePlusBootstrapPending( workspaceInfoOptional.rootDir, workspaceInfoOptional.packageManager, + workspaceInfoOptional.packages, ); let packageManager: PackageManager | undefined = vitePlusBootstrapPending ? (workspaceInfoOptional.packageManager ?? diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index f861c9f717..c8407ebd52 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -115,6 +115,21 @@ const OPT_IN_BROWSER_PROVIDERS = [WEBDRIVERIO_PROVIDER, PLAYWRIGHT_PROVIDER] as // `vitest: *` peer, so it must NOT be pinned to the vitest version. const VITEST_ALIGN_EXCLUDED = new Set(['@vitest/eslint-plugin']); +// Official packages that do not declare a required `vitest` peer. Keep them +// aligned when a project lists them directly, but do not add a direct vitest +// merely because they are present. +const VITEST_DIRECT_USAGE_EXCLUDED = new Set([ + ...VITEST_ALIGN_EXCLUDED, + '@vitest/expect', + '@vitest/mocker', + '@vitest/pretty-format', + '@vitest/runner', + '@vitest/snapshot', + '@vitest/spy', + '@vitest/utils', + '@vitest/ws-client', +]); + function isAlignableVitestEcosystemPackage(name: string): boolean { return name.startsWith('@vitest/') && !VITEST_ALIGN_EXCLUDED.has(name); } @@ -528,22 +543,27 @@ function projectListsVitestEcosystemDep(pkg: { optionalDependencies?: Record; peerDependencies?: Record; }): boolean { - const dependencyGroups = [ - pkg.dependencies, - pkg.devDependencies, - pkg.optionalDependencies, - pkg.peerDependencies, - ]; + // Peer declarations do not install the package in this project; its consumer + // is responsible for satisfying that package's peers. + const dependencyGroups = [pkg.dependencies, pkg.devDependencies, pkg.optionalDependencies]; return dependencyGroups.some((deps) => - deps ? Object.keys(deps).some((name) => name !== 'vitest' && name.includes('vitest')) : false, + deps + ? Object.keys(deps).some( + (name) => + name !== 'vitest' && + name.includes('vitest') && + // Excluded official packages either have no vitest peer or (for the + // ESLint plugin) only an optional `vitest: *` peer. Neither needs a + // direct install or workspace-wide override. + !VITEST_DIRECT_USAGE_EXCLUDED.has(name), + ) + : false, ); } -// True iff the project uses vitest DIRECTLY — via a vitest ecosystem dependency -// (see `projectListsVitestEcosystemDep`), a source file referencing vitest (the -// `vitest` substring matches `vitest` / `@vitest/` import specifiers and not -// `vite-plus/test`), or vitest browser mode (whose published `vite-plus/test/browser*` -// shims carry no `vitest` substring but still need vitest resolvable). Drives +// True iff the project uses vitest DIRECTLY — via a dependency that is expected +// to have a required vitest peer (see `projectListsVitestEcosystemDep`), an +// upstream `vitest` module specifier, or vitest browser mode. Drives // whether the migration keeps `vitest` managed or removes it entirely; the // browser-mode arm keeps it aligned with the direct-`vitest` injection below so // an injected `catalog:` spec never dangles against a vitest-less catalog. @@ -558,7 +578,7 @@ function projectUsesVitestDirectly( ): boolean { return ( projectListsVitestEcosystemDep(pkg) || - sourceTreeReferencesAny(projectPath, ['vitest']) || + sourceTreeReferencesRetainedVitestModule(projectPath) || usesVitestBrowserMode(projectPath) ); } @@ -1628,8 +1648,8 @@ export function rewriteStandaloneProject( let shouldRewritePnpmWorkspaceYaml = false; let shouldAddPnpmWorkspaceVitePlusOverride = false; let shouldAllowBrowserProviderBuilds = false; - // Whether the project uses vitest directly (an `@vitest/*` dep or a source - // reference). Computed inside the callback (where `pkg` is available) and + // Whether the project uses vitest directly (a required-peer consumer, an + // upstream module reference, or browser mode). Computed inside the callback and // hoisted so the post-callback pnpm-workspace.yaml writer sees it too. let usesVitest = false; // Determined inside editJsonFile callback to avoid a redundant file read @@ -1700,9 +1720,10 @@ export function rewriteStandaloneProject( }; } } else if (packageManager === PackageManager.pnpm) { - // If package.json already has a "pnpm" field, keep using it; - // otherwise use pnpm-workspace.yaml. - usePnpmWorkspaceYaml = !pkg.pnpm; + // Keep overrides in package.json only when it actually owns override/peer + // configuration (or no workspace file exists). An empty/unrelated `pnpm` + // object must not hide stale overrides in pnpm-workspace.yaml. + usePnpmWorkspaceYaml = !pnpmConfigLivesInPackageJson(pkg, projectPath); if (usePnpmWorkspaceYaml) { shouldRewritePnpmWorkspaceYaml = true; shouldAddPnpmWorkspaceVitePlusOverride = isForceOverrideMode(); @@ -2322,8 +2343,8 @@ function workspaceUsesWebdriverio( // Workspace-wide direct-vitest signal for the SHARED sinks a monorepo root // owns (pnpm-workspace.yaml catalog/overrides/peer rules, .yarnrc.yml catalog, // bun catalog): `vitest` stays managed there iff ANY package in the workspace — -// the root or any sub-package — uses vitest directly (an `@vitest/*` dep or a -// source reference). See `projectUsesVitestDirectly`. +// the root or any sub-package — uses vitest directly. See +// `projectUsesVitestDirectly`. function workspaceUsesVitestDirectly( rootDir: string, packages: WorkspacePackage[] | undefined, @@ -3469,6 +3490,7 @@ type BootstrapPackageJson = { resolutions?: Record; devDependencies?: Record; dependencies?: Record; + peerDependencies?: Record; optionalDependencies?: Record; pnpm?: { overrides?: Record; @@ -3476,6 +3498,8 @@ type BootstrapPackageJson = { allowAny?: string[]; allowedVersions?: Record; }; + allowBuilds?: Record; + onlyBuiltDependencies?: string[]; }; packageManager?: string; devEngines?: { packageManager?: unknown; [key: string]: unknown }; @@ -3713,21 +3737,18 @@ function readBunCatalogDependencyResolver(pkg: { // Decide where a pnpm project keeps its overrides / peer rules. A truthy // `pkg.pnpm` is not enough: an empty `pnpm: {}` is truthy yet carries no -// config, and when a real `pnpm-workspace.yaml` exists the workspace file is -// the actual config source. Treat the config as living in package.json only -// when `pkg.pnpm` has entries, or when it is present-but-empty AND there is no -// `pnpm-workspace.yaml` to own the config instead. -function pnpmConfigLivesInPackageJson( - pkg: BootstrapPackageJson, - projectPath: string, -): boolean { +// override/peer config, and when a real `pnpm-workspace.yaml` exists that file +// is the actual source unless package.json explicitly defines one of those +// managed sections. Unrelated keys such as `onlyBuiltDependencies` do not move +// override ownership into package.json. +function pnpmConfigLivesInPackageJson(pkg: BootstrapPackageJson, projectPath: string): boolean { if (pkg.pnpm == null) { return false; } - return ( - Object.keys(pkg.pnpm).length > 0 || - !fs.existsSync(path.join(projectPath, 'pnpm-workspace.yaml')) - ); + if (!fs.existsSync(path.join(projectPath, 'pnpm-workspace.yaml'))) { + return true; + } + return Object.hasOwn(pkg.pnpm, 'overrides') || Object.hasOwn(pkg.pnpm, 'peerDependencyRules'); } // Pin every alignable `@vitest/*` package the project lists to the bundled @@ -3750,23 +3771,140 @@ function alignVitestEcosystemPackages(pkg: BootstrapPackageJson): boolean { return changed; } -// True when the project lists an alignable `@vitest/*` package at a version -// other than the bundled vitest, so the bootstrap should run to realign it. -function vitestEcosystemPackagesPending(pkg: BootstrapPackageJson): boolean { - const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; - return dependencyGroups.some((dependencies) => - dependencies - ? Object.keys(dependencies).some( - (name) => - isAlignableVitestEcosystemPackage(name) && dependencies[name] !== VITEST_VERSION, - ) - : false, - ); +/** + * Reconcile the install dependencies in one package during an existing-Vite+ + * bootstrap. Package-manager overrides are intentionally handled separately at + * the workspace root; this function owns only dependency fields so it can also + * be applied to every workspace package. + */ +function reconcileVitePlusBootstrapPackage( + projectPath: string, + pkg: BootstrapPackageJson, + vitePlusVersion: string, + packageManager: PackageManager, + supportCatalog: boolean, + ensureVitePlus: boolean, +): boolean { + const before = JSON.stringify(pkg); + const usesVitest = projectUsesVitestDirectly(projectPath, pkg); + ensureVitePlusDependencySpecs(pkg, vitePlusVersion, ensureVitePlus); + + const installGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; + const dependencyGroups = [...installGroups, pkg.peerDependencies]; + + // Remove every dependency alias to the deleted wrapper before deciding + // whether this package needs a direct upstream vitest peer provider. + for (const dependencies of dependencyGroups) { + pruneLegacyWrapperAliases(dependencies); + } + + // npm keeps the managed alias directly in package.json. Catalog-based package + // managers leave dependency specs alone and repair their shared override. + if (packageManager === PackageManager.npm) { + for (const dependencies of installGroups) { + if ( + dependencies?.vite !== undefined && + !overrideSpecSatisfiesVitePlus('vite', dependencies.vite) + ) { + dependencies.vite = VITE_PLUS_OVERRIDE_PACKAGES.vite; + } + } + } + + alignVitestEcosystemPackages(pkg); + + const providerSourceModes = collectProviderSourceModes(projectPath); + let usesAnyOptInProvider = false; + for (const provider of OPT_IN_BROWSER_PROVIDERS) { + const usesProvider = + providerSourceModes[provider] || + dependencyGroups.some((dependencies) => dependencies?.[provider] !== undefined); + if (!usesProvider) { + continue; + } + usesAnyOptInProvider = true; + const installGroup = installGroups.find( + (dependencies) => dependencies?.[provider] !== undefined, + ); + if (installGroup) { + installGroup[provider] = VITEST_VERSION; + } else { + pkg.devDependencies ??= {}; + pkg.devDependencies[provider] = VITEST_VERSION; + } + const frameworkPeer = BROWSER_PROVIDER_PEER_DEPS[provider]; + const frameworkPresent = dependencyGroups.some( + (dependencies) => dependencies?.[frameworkPeer] !== undefined, + ); + if (frameworkPeer && !frameworkPresent) { + pkg.devDependencies ??= {}; + pkg.devDependencies[frameworkPeer] = '*'; + } + } + + // The base browser runtime and preview provider are bundled by vite-plus; + // only the heavy framework-specific providers remain project-owned. + for (const bundledPackage of REMOVE_PACKAGES.filter((name) => name.startsWith('@vitest/'))) { + for (const dependencies of installGroups) { + if (dependencies?.[bundledPackage] !== undefined) { + delete dependencies[bundledPackage]; + } + } + } + + if (usesAnyOptInProvider && packageManager === PackageManager.npm) { + const viteAlreadyDirect = installGroups.some( + (dependencies) => dependencies?.vite !== undefined, + ); + if (!viteAlreadyDirect) { + pkg.devDependencies ??= {}; + pkg.devDependencies.vite = VITE_PLUS_OVERRIDE_PACKAGES.vite; + } + } + + if (usesVitest) { + // A direct @vitest/*/integration dependency with a required vitest peer + // cannot use the copy nested under its sibling `vite-plus` dependency under + // Yarn PnP or strict pnpm. Provide the peer from this package and keep it on + // the same exact version as the Vite+ runner. + const existingGroup = installGroups.find((dependencies) => dependencies?.vitest !== undefined); + if (existingGroup) { + existingGroup.vitest = getCatalogDependencySpec( + existingGroup.vitest, + VITEST_VERSION, + supportCatalog, + ); + } else { + pkg.devDependencies ??= {}; + pkg.devDependencies.vitest = getCatalogDependencySpec( + undefined, + VITEST_VERSION, + supportCatalog, + ); + } + } else { + // Bare vitest is not itself a usage signal: older migrations injected it + // into every project. Remove that stale install pin when no remaining peer, + // source import, or browser-mode signal needs it. + for (const dependencies of installGroups) { + removeManagedVitestEntry(dependencies); + } + } + + return before !== JSON.stringify(pkg); +} + +function bootstrapProjectPaths( + rootDir: string, + packages: WorkspacePackage[] | undefined, +): string[] { + return [rootDir, ...(packages ?? []).map((pkg) => path.join(rootDir, pkg.path))]; } export function detectVitePlusBootstrapPending( projectPath: string, packageManager: PackageManager | undefined, + packages?: WorkspacePackage[], ): boolean { const packageJsonPath = path.join(projectPath, 'package.json'); if (!fs.existsSync(packageJsonPath)) { @@ -3782,20 +3920,40 @@ export function detectVitePlusBootstrapPending( return true; } - // A `@vitest/*` ecosystem package skewed from the bundled vitest needs - // realigning, independent of the package manager's override shape. - if (vitestEcosystemPackagesPending(pkg)) { + if (packageManager === undefined) { return true; } - if (packageManager === undefined) { - return true; + const usePnpmWorkspaceYaml = + packageManager === PackageManager.pnpm && !pnpmConfigLivesInPackageJson(pkg, projectPath); + const supportCatalog = + !VITE_PLUS_VERSION.startsWith('file:') && + (usePnpmWorkspaceYaml || packageManager === PackageManager.bun); + const canonicalVitePlusSpec = supportCatalog ? 'catalog:' : VITE_PLUS_VERSION; + for (const [index, packagePath] of bootstrapProjectPaths(projectPath, packages).entries()) { + const childPackageJsonPath = path.join(packagePath, 'package.json'); + if (!fs.existsSync(childPackageJsonPath)) { + continue; + } + const childPkg = readJsonFile(childPackageJsonPath) as BootstrapPackageJson; + const candidate = JSON.parse(JSON.stringify(childPkg)) as BootstrapPackageJson; + if ( + reconcileVitePlusBootstrapPackage( + packagePath, + candidate, + canonicalVitePlusSpec, + packageManager, + supportCatalog, + index === 0, + ) + ) { + return true; + } } - // `vitest` is managed only when the project uses it directly; otherwise a - // lingering managed `vitest` entry is treated as pending so the bootstrap - // removes it (and a second detect after removal returns false). - const usesVitest = projectUsesVitestDirectly(projectPath, pkg); + // Shared override/catalog sinks must keep vitest managed when any package in + // the workspace needs it. The direct dependency itself is localized above. + const usesVitest = workspaceUsesVitestDirectly(projectPath, packages); if (packageManager === PackageManager.yarn) { return ( @@ -3839,7 +3997,11 @@ export function detectVitePlusBootstrapPending( return false; } -function ensureVitePlusDependencySpecs(pkg: BootstrapPackageJson, version: string): boolean { +function ensureVitePlusDependencySpecs( + pkg: BootstrapPackageJson, + version: string, + ensurePresent = true, +): boolean { let changed = false; // Re-pin a pre-existing vite-plus spec to the migrating toolchain target so // the lockfile moves off an old resolution (e.g. `^0.1.24`). Mirrors the @@ -3871,7 +4033,7 @@ function ensureVitePlusDependencySpecs(pkg: BootstrapPackageJson, version: strin changed = true; } } - if (pkg.devDependencies?.[VITE_PLUS_NAME]) { + if (pkg.devDependencies?.[VITE_PLUS_NAME] || !ensurePresent) { return changed; } pkg.devDependencies = { @@ -3909,34 +4071,6 @@ function ensureOverrideEntries( return { overrides: next, changed }; } -function ensureNpmVitePlusManagedDependencies( - pkg: BootstrapPackageJson, - usesVitest: boolean, -): boolean { - let changed = false; - const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; - // Common case: strip a lingering managed `vitest` install dep. - if (!usesVitest) { - for (const dependencies of dependencyGroups) { - if (removeManagedVitestEntry(dependencies)) { - changed = true; - } - } - } - for (const [dependencyName, version] of Object.entries(managedOverridePackages(usesVitest))) { - for (const dependencies of dependencyGroups) { - if ( - dependencies?.[dependencyName] !== undefined && - !overrideSpecSatisfiesVitePlus(dependencyName, dependencies[dependencyName]) - ) { - dependencies[dependencyName] = version; - changed = true; - } - } - } - return changed; -} - function ensurePnpmPeerDependencyRules(pkg: BootstrapPackageJson, usesVitest: boolean): boolean { const overrideKeys = Object.keys(managedOverridePackages(usesVitest)); pkg.pnpm ??= {}; @@ -3985,15 +4119,20 @@ export function ensureVitePlusBootstrap( return result; } - // Whether the project uses vitest directly (an `@vitest/*` dep or a source - // reference). Read up front so it is available to the post-callback - // pnpm-workspace.yaml / .yarnrc.yml / bun catalog rewrites too. `vitest` stays - // managed only when true; otherwise the bootstrap REMOVES any lingering - // managed `vitest` entry from every sink. - const usesVitest = projectUsesVitestDirectly( - projectPath, - readJsonFile(packageJsonPath) as BootstrapPackageJson, - ); + const initialRootPkg = readJsonFile(packageJsonPath) as BootstrapPackageJson; + // Shared override/catalog sinks are workspace-wide, so keep vitest managed + // when any package needs it. Each package's direct vitest dependency is + // reconciled independently below. + const usesVitest = workspaceUsesVitestDirectly(projectPath, workspaceInfo.packages); + const pnpmMajorVersion = pnpmMajor(workspaceInfo.downloadPackageManager.version); + const shouldAllowBrowserBuilds = workspaceUsesWebdriverio(projectPath, workspaceInfo.packages); + const usePnpmWorkspaceYaml = + workspaceInfo.packageManager === PackageManager.pnpm && + !pnpmConfigLivesInPackageJson(initialRootPkg, projectPath); + const supportCatalog = + !VITE_PLUS_VERSION.startsWith('file:') && + (usePnpmWorkspaceYaml || workspaceInfo.packageManager === PackageManager.bun); + const canonicalVitePlusSpec = supportCatalog ? 'catalog:' : VITE_PLUS_VERSION; editJsonFile< BootstrapPackageJson & { @@ -4002,20 +4141,14 @@ export function ensureVitePlusBootstrap( catalogs?: Record>; } >(packageJsonPath, (pkg) => { - const usePnpmWorkspaceYaml = - workspaceInfo.packageManager === PackageManager.pnpm && - !pnpmConfigLivesInPackageJson(pkg, projectPath); - const supportCatalog = - !VITE_PLUS_VERSION.startsWith('file:') && - (usePnpmWorkspaceYaml || workspaceInfo.packageManager === PackageManager.bun); - let packageJsonChanged = ensureVitePlusDependencySpecs( + let packageJsonChanged = reconcileVitePlusBootstrapPackage( + projectPath, pkg, - supportCatalog ? 'catalog:' : VITE_PLUS_VERSION, + canonicalVitePlusSpec, + workspaceInfo.packageManager, + supportCatalog, + true, ); - packageJsonChanged = alignVitestEcosystemPackages(pkg) || packageJsonChanged; - if (workspaceInfo.packageManager === PackageManager.npm) { - packageJsonChanged = ensureNpmVitePlusManagedDependencies(pkg, usesVitest) || packageJsonChanged; - } if (workspaceInfo.packageManager === PackageManager.yarn) { const ensured = ensureOverrideEntries(pkg.resolutions, usesVitest); @@ -4052,12 +4185,40 @@ export function ensureVitePlusBootstrap( packageJsonChanged = true; } packageJsonChanged = ensurePnpmPeerDependencyRules(pkg, usesVitest) || packageJsonChanged; + if (pnpmMajorVersion !== undefined && pkg.pnpm) { + const beforePnpm = JSON.stringify(pkg.pnpm); + applyBuildAllowanceToPackageJsonPnpm(pkg.pnpm, pnpmMajorVersion, shouldAllowBrowserBuilds); + packageJsonChanged = beforePnpm !== JSON.stringify(pkg.pnpm) || packageJsonChanged; + } } result.packageJson = packageJsonChanged; return pkg; }); + // Existing Vite+ monorepos take this bootstrap path instead of the full + // migration, so reconcile every workspace manifest as well as the root. + for (const workspacePackage of workspaceInfo.packages) { + const packagePath = path.join(projectPath, workspacePackage.path); + const childPackageJsonPath = path.join(packagePath, 'package.json'); + if (!fs.existsSync(childPackageJsonPath)) { + continue; + } + let childChanged = false; + editJsonFile(childPackageJsonPath, (pkg) => { + childChanged = reconcileVitePlusBootstrapPackage( + packagePath, + pkg, + canonicalVitePlusSpec, + workspaceInfo.packageManager, + supportCatalog, + false, + ); + return childChanged ? pkg : undefined; + }); + result.packageJson = result.packageJson || childChanged; + } + if (workspaceInfo.packageManager === PackageManager.pnpm) { const pkg = readJsonFile(packageJsonPath) as BootstrapPackageJson; if (!pnpmConfigLivesInPackageJson(pkg, projectPath)) { @@ -4078,12 +4239,12 @@ export function ensureVitePlusBootstrap( usesVitest, ) ) { - // Bootstrap only completes the catalog / overrides / peer rules for a - // project that already uses Vite+. Build-script allowance stays owned - // by the full migration paths, so pass an undefined pnpm major to skip - // it (mirrors the single-arg call this path used before the signature - // grew the build-allowance parameters). - rewritePnpmWorkspaceYaml(projectPath, undefined, false, usesVitest); + rewritePnpmWorkspaceYaml( + projectPath, + pnpmMajorVersion, + shouldAllowBrowserBuilds, + usesVitest, + ); } if (fs.existsSync(pnpmWorkspaceYamlPath)) { ensurePnpmWorkspacePackages(projectPath, workspaceInfo.workspacePatterns); @@ -4277,9 +4438,10 @@ const VITEST_SCAN_SKIP_DIRS = new Set([ * is a separate package that the migration scans on its own pass, so the root * package must not inherit a browser-mode signal from a sub-package. */ -function sourceTreeReferencesAny(projectPath: string, hints: readonly string[]): boolean { - const matchesHint = (content: string): boolean => hints.some((hint) => content.includes(hint)); - +function sourceTreeMatches( + projectPath: string, + matchesContent: (content: string) => boolean, +): boolean { const scanDir = (dir: string, isRoot: boolean): boolean => { let entries: fs.Dirent[]; try { @@ -4303,7 +4465,7 @@ function sourceTreeReferencesAny(projectPath: string, hints: readonly string[]): } } else if (entry.isFile() && VITEST_SCAN_EXTENSIONS.has(path.extname(entry.name))) { try { - if (matchesHint(fs.readFileSync(entryPath, 'utf8'))) { + if (matchesContent(fs.readFileSync(entryPath, 'utf8'))) { return true; } } catch { @@ -4317,6 +4479,23 @@ function sourceTreeReferencesAny(projectPath: string, hints: readonly string[]): return scanDir(projectPath, true); } +function sourceTreeReferencesAny(projectPath: string, hints: readonly string[]): boolean { + return sourceTreeMatches(projectPath, (content) => hints.some((hint) => content.includes(hint))); +} + +// Normal imports from `vitest` are rewritten to `vite-plus/test` later in the +// same migration and therefore do not justify a lasting direct dependency. +// Module augmentations and triple-slash type references deliberately retain the +// upstream module identity, so keep vitest package-local for those surfaces. +function sourceTreeReferencesRetainedVitestModule(projectPath: string): boolean { + return sourceTreeMatches(projectPath, (content) => { + return ( + /\bdeclare\s+module\s+['"]vitest(?:\/[^'"]*)?['"]/.test(content) || + />, - // Whether the project uses vitest DIRECTLY (an `@vitest/*` dep or a source - // reference). `vitest` is managed (and a managed dep/override pin kept) only + // Whether the project uses vitest DIRECTLY (a required-peer consumer, an + // upstream module reference, or browser mode). `vitest` is managed only // when true; in the common case (`false`) a lingering managed `vitest` entry // is REMOVED so it arrives transitively through vite-plus. Defaults to true to // preserve legacy behavior for callers that don't compute the signal. @@ -4442,6 +4621,11 @@ export function rewritePackageJson( } } } + // Optional Vitest packages are published in lockstep with the runner. Keep + // every declared official @vitest/* package on the bundled version during a + // fresh migration too; existing-Vite+ upgrades use the same rule in the + // bootstrap path. + alignVitestEcosystemPackages(pkg); // Force-override mode (ecosystem CI / `vp create` E2E) must re-pin any // pre-existing `vite-plus` range to the local tgz. Otherwise pnpm reads the // published vite-plus metadata for transitive dep resolution (e.g. @@ -4567,18 +4751,19 @@ export function rewritePackageJson( const effectiveBrowserMode = vitestBrowserMode || hasBrowserDepSignal; // Trigger vite-plus install when a project has a vitest-adjacent package // (e.g. `vitest-browser-svelte`) that declares vitest as a peer dep — even - // if the project has no vite/oxlint/tsdown dep to migrate. The peer dep is - // satisfied by the upstream vitest that vite-plus bundles as a direct dep. - // Note: peerDependencies count as "adjacent signal" but NOT as installed. + // if the project has no vite/oxlint/tsdown dep to migrate. Only installed + // dependency groups count; a peer declaration alone installs nothing here. const installableNames = [ ...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.devDependencies ?? {}), ...Object.keys(pkg.optionalDependencies ?? {}), ]; - const adjacentSignals = [...installableNames, ...Object.keys(pkg.peerDependencies ?? {})]; const isVitestAdjacent = !installableNames.includes('vitest') && - adjacentSignals.some((name) => name !== 'vitest' && name.includes('vitest')); + installableNames.some( + (name) => + name !== 'vitest' && name.includes('vitest') && !VITEST_DIRECT_USAGE_EXCLUDED.has(name), + ); // Normalize a pre-existing pinned vite-plus so sub-packages don't drift // from siblings: in catalog-supporting monorepos that's `catalog:`, under // force-override (file:) it's the tgz path. Preserve protocol-prefixed diff --git a/rfcs/migrate-existing-projects.md b/rfcs/migrate-existing-projects.md index f2b8385864..8c606e460d 100644 --- a/rfcs/migrate-existing-projects.md +++ b/rfcs/migrate-existing-projects.md @@ -1,6 +1,6 @@ # RFC: Migrating Existing Vite+ Projects to a New Version -- Status: Partially implemented on `rfc/migrate-upgrade-path` (commits `03689668`, `3e5a5137`); vitest-removal simplification and browser-mode verification pending (see Follow-ups) +- Status: Implemented on `rfc/migrate-upgrade-path`; end-to-end browser-mode verification remains (see Follow-ups) - Depends on: [#1588 replace @voidzero-dev/vite-plus-test with upstream vitest](https://github.com/voidzero-dev/vite-plus/pull/1588) (merged, `342fd2f4`) - Related: `docs/guide/upgrade.md`, [migration-command.md](./migration-command.md), [upgrade-command.md](./upgrade-command.md) @@ -19,57 +19,57 @@ Both are needed, and the order matters. `vp migrate` normally runs the project's ## Migrate rules -Run on an existing Vite+ project, in order. The guiding fact for vitest: `vite-plus` declares `vitest` (and the `@vitest/*` runtime family) as dependencies at the bundled version, so a project never needs its own `vitest`. It resolves transitively, and an ecosystem package resolves its exact `vitest` peer against it. Verified on `node-modules/urllib` across pnpm, npm, and yarn (PRs [#832](https://github.com/node-modules/urllib/pull/832) / [#833](https://github.com/node-modules/urllib/pull/833) / [#834](https://github.com/node-modules/urllib/pull/834)): with the direct `vitest` removed, coverage stays green on all three. The complementary forced-single case (a third-party `vitest-browser-svelte` keeps a managed `vitest`) is covered by the `migration-vitest-peer-dep` snap test. +Run on an existing Vite+ project, in order. The guiding fact for vitest: `vite-plus` declares `vitest` (and the `@vitest/*` runtime family) as dependencies at the bundled version, so ordinary node-mode projects using only `vite-plus/test*` do not need their own `vitest`. A direct package with a required `vitest` peer is different: under Yarn PnP and strict pnpm, the copy nested below the sibling `vite-plus` dependency cannot satisfy that peer. Such a package needs a package-local direct `vitest`, plus a shared override when the package manager supports one. This applies whether the peer range is exact or broad. -| Area | Rule | -| ---- | ---- | -| Routing | If the project's local `vite-plus` is older than the global `vp`, run `migrate` from the global CLI; otherwise keep local-first. | -| `vite-plus` spec | Re-pin a non-protocol-pinned spec (e.g. `^0.1.24`) to the toolchain target (`catalog:` in catalog projects, else the version) so the lockfile moves off the old resolution. Preserve deliberate protocol pins (`workspace:`/`file:`/`link:`/`npm:`/...). | -| `vite` override | Always managed: alias `vite` to `npm:@voidzero-dev/vite-plus-core@latest` in whatever override/resolution/catalog form the project uses; normalize a behind `core@` alias. | -| `vitest` itself (default) | Provided by `vite-plus`, so by default not project-managed: remove any project-level `vitest` from dependency fields, `overrides`/`resolutions`/`pnpm.overrides`, `pnpm-workspace.yaml` `overrides`+`catalog(s)`, bun/yarn catalog, and the `vitest` entry in pnpm `peerDependencyRules`. A future `vp update vite-plus` then keeps it correct with no project pin to drift. | -| `vitest`, forced-single exception | Keep a managed `vitest` (add to `devDependencies` **and** override/pin it to the bundled version) when the project has a **non-exact** `vitest` peer to collapse: a third-party integration on a range peer (`vitest-browser-react` / `-vue` / `-svelte`, ...), vitest browser mode, or a direct `vitest` source import. The override forces the range down to `vite-plus`'s exact version (one copy); the `devDependencies` entry satisfies the peer deterministically. Official `@vitest/*` (exact peer) do NOT trigger this, their exact peer already dedupes to `vite-plus`'s vitest. | -| `vitest` ecosystem packages | Align every official `@vitest/*` package the project lists (`@vitest/coverage-v8`, `@vitest/coverage-istanbul`, `@vitest/ui`, `@vitest/web-worker`, ...) to the bundled `VITEST_VERSION`, since each carries an **exact** `vitest` peer. Exclude `@vitest/eslint-plugin` (separate version line, `vitest: *` peer). Browser packages keep their dedicated handling: `@vitest/browser` / `-preview` are bundled by `vite-plus`; `@vitest/browser-playwright` / `-webdriverio` are opt-in (pinned + framework peer kept). | -| Legacy wrapper | Remove every `@voidzero-dev/vite-plus-test` alias (deps, overrides, catalogs); repoint direct wrapper imports to `vite-plus/test`. `vite-plus/test*` imports are left unchanged (stable public API). | -| pnpm config location | An empty `"pnpm": {}` with an existing `pnpm-workspace.yaml` reconciles the workspace file (instead of writing a second, conflicting override block into `package.json`). | -| Reinstall + verify | One reinstall with lockfile refresh (`--no-frozen-lockfile` / `--force`); a failed install warns and sets a non-zero exit. | +Removing the old direct dependency was exercised on `node-modules/urllib` across pnpm, npm, and yarn (PRs [#832](https://github.com/node-modules/urllib/pull/832) / [#833](https://github.com/node-modules/urllib/pull/833) / [#834](https://github.com/node-modules/urllib/pull/834)). Those node-modules layouts can hoist an exact peer, but that is not portable to strict pnpm or Yarn PnP, so the migration still provisions required peers explicitly. Required-peer handling is covered for official `@vitest/*` packages and the third-party `vitest-browser-svelte` case. + +| Area | Rule | +| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Routing | If the project's local `vite-plus` is older than the global `vp`, run `migrate` from the global CLI; otherwise keep local-first. | +| `vite-plus` spec | Re-pin a non-protocol-pinned spec (e.g. `^0.1.24`) to the toolchain target (`catalog:` in catalog projects, else the version) so the lockfile moves off the old resolution. Preserve deliberate protocol pins (`workspace:`/`file:`/`link:`/`npm:`/...). | +| `vite` override | Always managed: alias `vite` to `npm:@voidzero-dev/vite-plus-core@latest` in whatever override/resolution/catalog form the project uses; normalize a behind `core@` alias. | +| `vitest` itself (default) | Provided by `vite-plus`, so by default not project-managed: remove any project-level `vitest` from dependency fields, `overrides`/`resolutions`/`pnpm.overrides`, `pnpm-workspace.yaml` `overrides`+`catalog(s)`, bun/yarn catalog, and the `vitest` entry in pnpm `peerDependencyRules`. A future `vp update vite-plus` then keeps it correct with no project pin to drift. | +| `vitest`, peer/browser exception | Keep a managed `vitest` in the package that needs it (add to `devDependencies` and pin/override it to the bundled version) when that package directly installs a required-`vitest` peer consumer, uses browser mode, or retains a direct upstream `vitest` module reference. This includes official packages with exact peers (`@vitest/ui`, coverage providers, browser providers) and third-party integrations with range peers (`vitest-browser-react` / `-vue` / `-svelte`, ...). The direct dependency satisfies strict peer resolution; the shared override collapses the workspace to the bundled version. | +| `vitest` ecosystem packages | Align every official `@vitest/*` package the project lists (`@vitest/coverage-v8`, `@vitest/coverage-istanbul`, `@vitest/ui`, `@vitest/web-worker`, ...) to the bundled `VITEST_VERSION`. Exclude `@vitest/eslint-plugin` (separate version line, optional `vitest: *` peer); it neither triggers a `vitest` install nor a shared override. Browser packages keep their dedicated handling: `@vitest/browser` / `-preview` are bundled by `vite-plus`; `@vitest/browser-playwright` / `-webdriverio` are opt-in (pinned + framework peer kept). | +| Workspaces | Reconcile every package manifest, not only the root. Localize the direct `vitest` dependency to packages that need it; keep shared catalogs/overrides only when at least one package needs them. Re-pin existing plain `vite-plus` ranges consistently while preserving deliberate protocol specs. | +| Legacy wrapper | Remove every `@voidzero-dev/vite-plus-test` alias (deps, overrides, catalogs); repoint direct wrapper imports to `vite-plus/test`. `vite-plus/test*` imports are left unchanged (stable public API). | +| pnpm config location | An empty `"pnpm": {}` with an existing `pnpm-workspace.yaml` reconciles the workspace file (instead of writing a second, conflicting override block into `package.json`). | +| Reinstall + verify | One reinstall with lockfile refresh (`--no-frozen-lockfile` / `--force`); a failed install warns and sets a non-zero exit. | Force-override/CI mode (`VP_OVERRIDE_PACKAGES`) is respected: when `vitest` is not a managed key there, the project's own `vitest` is never stripped. -**Pending verification:** vitest **browser mode** historically needed a direct `vitest` injected (the "vibe-dashboard" regression). That predates `vite-plus` declaring `vitest`+`@vitest/browser` as dependencies and may now be obsolete, but it is not yet confirmed across package managers, so the browser-mode injection stays until a urllib-style 3-PM check clears it. +**Pending verification:** vitest **browser mode** historically needed a direct `vitest` injected (the "vibe-dashboard" regression). The upgrade now restores the opt-in provider and framework peer and keeps the package-local `vitest`; retain that behavior until a urllib-style pnpm/npm/yarn check proves any part is redundant. ## Vitest ecosystem packages How each package the `vitest` ecosystem rule covers is handled, verified against the registry at `4.1.9`. The code rule: align any `@vitest/*` the project lists to `VITEST_VERSION`, except `@vitest/eslint-plugin`; the browser packages additionally follow their bundled/opt-in handling. -| Package | `vitest` peer | Handling | -| ------- | ------------- | -------- | -| `@vitest/coverage-v8` | `4.1.9` (exact) | align to `VITEST_VERSION` | -| `@vitest/coverage-istanbul` | `4.1.9` | align to `VITEST_VERSION` | -| `@vitest/ui` | `4.1.9` | align to `VITEST_VERSION` | -| `@vitest/web-worker` | `4.1.9` | align to `VITEST_VERSION` | -| `@vitest/browser` | `4.1.9` | removed (bundled by `vite-plus`) | -| `@vitest/browser-preview` | `4.1.9` | removed (bundled by `vite-plus`) | -| `@vitest/browser-playwright` | `4.1.9` + `playwright` | opt-in: pin to `VITEST_VERSION`, keep `playwright` peer | -| `@vitest/browser-webdriverio` | `4.1.9` + `webdriverio` | opt-in: pin to `VITEST_VERSION`, keep `webdriverio` peer | -| `@vitest/expect` `/runner` `/snapshot` `/spy` `/utils` `/mocker` `/pretty-format` | none | transitive deps of `vitest`; `vite-plus` provides them, the project does not list them | -| `@vitest/eslint-plugin` | `*` | left as-is (own version line, e.g. `1.6.x`) | -| `vitest-browser-react` `/-vue` `/-svelte`, ... | `^4` (range) | third-party, own versioning; left at a compatible release, **and** a managed `vitest` is kept (devDep + override) to force a single copy against the range peer | +| Package | `vitest` peer | Handling | +| ---------------------------------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------- | +| `@vitest/coverage-v8` | `4.1.9` (exact) | align; provide direct `vitest` in the same package | +| `@vitest/coverage-istanbul` | `4.1.9` | align; provide direct `vitest` in the same package | +| `@vitest/ui` | `4.1.9` | align; provide direct `vitest` in the same package | +| `@vitest/web-worker` | `4.1.9` | align; provide direct `vitest` in the same package | +| `@vitest/browser` | `4.1.9` | removed (bundled by `vite-plus`); browser package keeps direct `vitest` | +| `@vitest/browser-preview` | `4.1.9` | removed (bundled by `vite-plus`); browser package keeps direct `vitest` | +| `@vitest/browser-playwright` | `4.1.9` + `playwright` | opt-in: pin to `VITEST_VERSION`, keep `playwright` and direct `vitest` | +| `@vitest/browser-webdriverio` | `4.1.9` + `webdriverio` | opt-in: pin to `VITEST_VERSION`, keep `webdriverio` and direct `vitest` | +| `@vitest/expect` `/runner` `/snapshot` `/spy` `/utils` `/mocker` `/pretty-format` `/ws-client` | none | transitive runtime packages; align if listed, but do not add `vitest` for them alone | +| `@vitest/eslint-plugin` | `*` | left as-is (own version line, e.g. `1.6.x`) | +| `vitest-browser-react` `/-vue` `/-svelte`, ... | `^4` (range) | third-party, own versioning; left at a compatible release, with a package-local `vitest` plus shared override | ## Implementation -| Area | Change | -| ---- | ------ | -| `crates/vite_global_cli` (`commands/migrate.rs`, `js_executor.rs`) | `delegate_migrate`: compare local `vite-plus` vs global `vp` version; escalate to the global CLI when older. | -| `packages/cli/src/migration/migrator.ts` | Managed override set (`managedOverridePackages`); `vitest` removal across every sink; coverage-provider alignment; behind `vite-plus`/`vite` re-pin; empty-`pnpm` routing fix. | - -Covered by unit tests in `migrator.spec.ts` (vitest removal, coverage alignment, behind re-pin, empty-`pnpm` reconciliation) and a routing test in `vite_global_cli`. +| Area | Change | +| ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `crates/vite_global_cli` (`commands/migrate.rs`, `js_executor.rs`) | `delegate_migrate`: compare local `vite-plus` vs global `vp` version; escalate to the global CLI when older. | +| `packages/cli/src/migration/migrator.ts` | Usage-aware managed override set; per-package dependency reconciliation; `vitest` removal across every sink; full `@vitest/*` alignment; browser-provider restoration; behind `vite-plus`/`vite` re-pin; empty/unrelated-`pnpm` routing fix. | -Not yet reflected in code: the current implementation still *pins* `vitest` when the project lists a vitest ecosystem package, rather than removing it. The "vitest itself: never project-managed" rule above (validated by the urllib 3-PM PRs) makes that pin unnecessary; collapsing it into unconditional removal is the next code change. +Covered by unit tests in `migrator.spec.ts` (vitest removal, required-peer provisioning, ecosystem alignment, browser-provider restoration, workspace localization, behind re-pin, empty-`pnpm` reconciliation) and a routing test in `vite_global_cli`. ## Follow-ups (not in this change) -- Refine the code so `vitest` is removed even when a vitest ecosystem package is present (keep only the ecosystem-package alignment), per the validated rule. -- Verify vitest browser mode across pnpm/npm/yarn with no direct `vitest`; remove the browser-mode injection if it is obsolete. -- Regenerate `snap-tests-global/migration-*` and add an end-to-end check on a real `0.1.x` project. +- Verify the browser-mode upgrade across pnpm/npm/yarn; simplify package-local provisioning only if strict peer and optimizer resolution remain correct. +- Add an end-to-end check on a real `0.1.x` project. - Update `docs/guide/upgrade.md` / the release-notes prompt to the `vp upgrade && vp migrate` flow once shipped, and `npm deprecate @voidzero-dev/vite-plus-test`. - Optional `vp migrate --check` (detection-only, exit code signals an available upgrade) for CI. From 744e9d807138a06627389e0aee8e538fdec89562 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 19 Jun 2026 20:57:31 +0800 Subject: [PATCH 09/78] fix(migrate): validate upgrade scenarios in snapshots --- .../snap.txt | 2 +- .../snap.txt | 2 +- .../migration-already-vite-plus/snap.txt | 2 +- .../migration-already-vite-plus/steps.json | 2 +- .../snap.txt | 2 +- .../migration-rewrite-declare-module/snap.txt | 5 +- .../steps.json | 2 +- .../migration-standalone-npm/snap.txt | 4 +- .../migration-standalone-npm/steps.json | 2 +- .../package.json | 14 +++ .../pnpm-workspace.yaml | 10 ++ .../snap.txt | 38 +++++++ .../steps.json | 8 ++ .../vite.config.ts | 11 +++ .../package.json | 13 +++ .../pnpm-workspace.yaml | 10 ++ .../snap.txt | 41 ++++++++ .../steps.json | 8 ++ .../vite.config.ts | 11 +++ .../package.json | 14 +++ .../packages/app/package.json | 8 ++ .../pnpm-workspace.yaml | 12 +++ .../snap.txt | 48 +++++++++ .../steps.json | 9 ++ .../local-vite-plus/dist/bin.js | 2 + .../local-vite-plus/package.json | 4 + .../package.json | 16 +++ .../pnpm-workspace.yaml | 10 ++ .../setup-local.mjs | 5 + .../snap.txt | 34 +++++++ .../steps.json | 9 ++ .../package.json | 18 ++++ .../snap.txt | 22 +++++ .../steps.json | 7 ++ .../package.json | 23 +++++ .../snap.txt | 29 ++++++ .../steps.json | 7 ++ .../.yarnrc.yml | 4 + .../package.json | 17 ++++ .../snap.txt | 35 +++++++ .../steps.json | 8 ++ .../package.json | 21 ++++ .../snap.txt | 25 +++++ .../steps.json | 7 ++ .../example.spec.ts | 5 + .../migration-vitest-import-only/package.json | 10 ++ .../migration-vitest-import-only/snap.txt | 43 ++++++++ .../migration-vitest-import-only/steps.json | 9 ++ .../package.json | 10 ++ .../snap.txt | 37 +++++++ .../steps.json | 10 ++ .../src/migration/__tests__/migrator.spec.ts | 6 +- .../migration/__tests__/npm-reinstall.spec.ts | 84 ++++++++++++++++ packages/cli/src/migration/bin.ts | 10 ++ packages/cli/src/migration/migrator.ts | 41 +++++--- packages/cli/src/migration/npm-reinstall.ts | 98 +++++++++++++++++++ rfcs/migrate-existing-projects.md | 31 ++++-- 57 files changed, 940 insertions(+), 35 deletions(-) create mode 100644 packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/pnpm-workspace.yaml create mode 100644 packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/steps.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/vite.config.ts create mode 100644 packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/pnpm-workspace.yaml create mode 100644 packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/steps.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/vite.config.ts create mode 100644 packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/packages/app/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/pnpm-workspace.yaml create mode 100644 packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/steps.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/local-vite-plus/dist/bin.js create mode 100644 packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/local-vite-plus/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/pnpm-workspace.yaml create mode 100644 packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/setup-local.mjs create mode 100644 packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/steps.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/steps.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/steps.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/.yarnrc.yml create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/steps.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/steps.json create mode 100644 packages/cli/snap-tests-global/migration-vitest-import-only/example.spec.ts create mode 100644 packages/cli/snap-tests-global/migration-vitest-import-only/package.json create mode 100644 packages/cli/snap-tests-global/migration-vitest-import-only/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-vitest-import-only/steps.json create mode 100644 packages/cli/snap-tests-global/migration-vitest-unmanaged-override/package.json create mode 100644 packages/cli/snap-tests-global/migration-vitest-unmanaged-override/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-vitest-unmanaged-override/steps.json create mode 100644 packages/cli/src/migration/__tests__/npm-reinstall.spec.ts create mode 100644 packages/cli/src/migration/npm-reinstall.ts diff --git a/packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-hookspath/snap.txt b/packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-hookspath/snap.txt index f0a1a3747b..0d1216e4f3 100644 --- a/packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-hookspath/snap.txt +++ b/packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-hookspath/snap.txt @@ -14,7 +14,7 @@ "prepare": "vp config" }, "devDependencies": { - "vite": "^7.0.0", + "vite": "catalog:", "vite-plus": "catalog:" }, "devEngines": { diff --git a/packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-lint-staged/snap.txt index 62d1b178c6..dacabcc34b 100644 --- a/packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-lint-staged/snap.txt @@ -13,7 +13,7 @@ "prepare": "vp config" }, "devDependencies": { - "vite": "^7.0.0", + "vite": "catalog:", "vite-plus": "catalog:" }, "devEngines": { diff --git a/packages/cli/snap-tests-global/migration-already-vite-plus/snap.txt b/packages/cli/snap-tests-global/migration-already-vite-plus/snap.txt index d917553c49..ccefd20e87 100644 --- a/packages/cli/snap-tests-global/migration-already-vite-plus/snap.txt +++ b/packages/cli/snap-tests-global/migration-already-vite-plus/snap.txt @@ -1,4 +1,4 @@ -> vp migrate --no-interactive # legacy wrapper-override project: rewrites the stale vitest wrapper override to bundled vitest and completes the missing @vitest/* family pins, no hooks/agent setup defaults +> vp migrate --no-interactive # common existing project removes the stale wrapper override, no hooks/agent setup defaults ◇ Migrated . to Vite+ • Node npm • Package manager settings configured diff --git a/packages/cli/snap-tests-global/migration-already-vite-plus/steps.json b/packages/cli/snap-tests-global/migration-already-vite-plus/steps.json index 85bc820818..5a9a3fbc1a 100644 --- a/packages/cli/snap-tests-global/migration-already-vite-plus/steps.json +++ b/packages/cli/snap-tests-global/migration-already-vite-plus/steps.json @@ -1,6 +1,6 @@ { "commands": [ - "vp migrate --no-interactive # legacy wrapper-override project: rewrites the stale vitest wrapper override to bundled vitest and completes the missing @vitest/* family pins, no hooks/agent setup defaults", + "vp migrate --no-interactive # common existing project removes the stale wrapper override, no hooks/agent setup defaults", "vp migrate --no-interactive --hooks --agent agents # explicit setup should still update existing vite-plus project", "cat package.json # prepare script should be configured for vp config", "test -f AGENTS.md # explicit agent instructions should be written", diff --git a/packages/cli/snap-tests-global/migration-partially-installed-vite-plus/snap.txt b/packages/cli/snap-tests-global/migration-partially-installed-vite-plus/snap.txt index 1633a2fd74..8b8b296ed1 100644 --- a/packages/cli/snap-tests-global/migration-partially-installed-vite-plus/snap.txt +++ b/packages/cli/snap-tests-global/migration-partially-installed-vite-plus/snap.txt @@ -27,7 +27,7 @@ "@vitejs/plugin-react": "^6.0.1", "globals": "^17.6.0", "typescript": "~6.0.2", - "vite": "^8.0.12", + "vite": "catalog:", "vite-plus": "catalog:" }, "devEngines": { diff --git a/packages/cli/snap-tests-global/migration-rewrite-declare-module/snap.txt b/packages/cli/snap-tests-global/migration-rewrite-declare-module/snap.txt index 052b0d5b4f..0dca63041b 100644 --- a/packages/cli/snap-tests-global/migration-rewrite-declare-module/snap.txt +++ b/packages/cli/snap-tests-global/migration-rewrite-declare-module/snap.txt @@ -1,4 +1,4 @@ -> vp migrate --no-interactive # migration should rewrite imports to vite-plus +> vp migrate --no-interactive # retained vitest augmentations should keep a package-local vitest ◇ Migrated . to Vite+ • Node pnpm • 2 config updates applied, 1 file had imports rewritten @@ -39,7 +39,8 @@ declare module 'vitest/config' { "name": "migration-rewrite-declare-module", "devDependencies": { "vite": "catalog:", - "vite-plus": "catalog:" + "vite-plus": "catalog:", + "vitest": "catalog:" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-rewrite-declare-module/steps.json b/packages/cli/snap-tests-global/migration-rewrite-declare-module/steps.json index c55aec0263..52c732fd4d 100644 --- a/packages/cli/snap-tests-global/migration-rewrite-declare-module/steps.json +++ b/packages/cli/snap-tests-global/migration-rewrite-declare-module/steps.json @@ -1,6 +1,6 @@ { "commands": [ - "vp migrate --no-interactive # migration should rewrite imports to vite-plus", + "vp migrate --no-interactive # retained vitest augmentations should keep a package-local vitest", "cat src/index.ts # check src/index.ts", "cat package.json # check package.json", "cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog" diff --git a/packages/cli/snap-tests-global/migration-standalone-npm/snap.txt b/packages/cli/snap-tests-global/migration-standalone-npm/snap.txt index c718e55578..680cd111de 100644 --- a/packages/cli/snap-tests-global/migration-standalone-npm/snap.txt +++ b/packages/cli/snap-tests-global/migration-standalone-npm/snap.txt @@ -17,5 +17,5 @@ } } -[1]> node -e "const lock = require('./package-lock.json'); const vite = lock.packages['node_modules/vite']; if (vite && vite.resolved && vite.resolved.includes('@voidzero-dev/vite-plus-core')) console.log('lockfile has vite override'); else { console.error('vite override not found in lockfile'); process.exit(1); }" # verify lockfile updated with override -vite override not found in lockfile +> node -e "const lock = require('./package-lock.json'); const vite = lock.packages['node_modules/vite']; if (vite && (vite.name === '@voidzero-dev/vite-plus-core' || vite.resolved?.includes('/@voidzero-dev/vite-plus-core/'))) console.log('lockfile has vite override'); else { console.error('vite override not found in lockfile'); process.exit(1); }" # verify lockfile updated with override +lockfile has vite override diff --git a/packages/cli/snap-tests-global/migration-standalone-npm/steps.json b/packages/cli/snap-tests-global/migration-standalone-npm/steps.json index 41f180650f..42f66e7055 100644 --- a/packages/cli/snap-tests-global/migration-standalone-npm/steps.json +++ b/packages/cli/snap-tests-global/migration-standalone-npm/steps.json @@ -8,6 +8,6 @@ "commands": [ "vp migrate --no-interactive --no-hooks # migration should work with npm, add overrides, and update lockfile", "cat package.json # check package.json has overrides field (not pnpm.overrides)", - "node -e \"const lock = require('./package-lock.json'); const vite = lock.packages['node_modules/vite']; if (vite && vite.resolved && vite.resolved.includes('@voidzero-dev/vite-plus-core')) console.log('lockfile has vite override'); else { console.error('vite override not found in lockfile'); process.exit(1); }\" # verify lockfile updated with override" + "node -e \"const lock = require('./package-lock.json'); const vite = lock.packages['node_modules/vite']; if (vite && (vite.name === '@voidzero-dev/vite-plus-core' || vite.resolved?.includes('/@voidzero-dev/vite-plus-core/'))) console.log('lockfile has vite override'); else { console.error('vite override not found in lockfile'); process.exit(1); }\" # verify lockfile updated with override" ] } diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/package.json b/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/package.json new file mode 100644 index 0000000000..5798af5eaa --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/package.json @@ -0,0 +1,14 @@ +{ + "name": "migration-upgrade-browser-source-only-pnpm", + "devDependencies": { + "@vitest/browser": "^4.1.8", + "vite-plus": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "10.33.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/pnpm-workspace.yaml b/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/pnpm-workspace.yaml new file mode 100644 index 0000000000..d9df99abda --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest +overrides: + vite: 'catalog:' +peerDependencyRules: + allowAny: + - vite + allowedVersions: + vite: '*' diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/snap.txt new file mode 100644 index 0000000000..2e020716a1 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/snap.txt @@ -0,0 +1,38 @@ +> vp migrate --no-interactive # source-only browser provider should be restored +◇ Migrated . to Vite+ +• Node pnpm +• Package manager settings configured + +> cat package.json # provider, framework peer, and local vitest should be present +{ + "name": "migration-upgrade-browser-source-only-pnpm", + "devDependencies": { + "vite-plus": "catalog:", + "@vitest/browser-playwright": "", + "playwright": "*", + "vitest": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } +} + +> cat pnpm-workspace.yaml # shared vitest catalog and override should be present +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest + vitest: +overrides: + vite: 'catalog:' + vitest: 'catalog:' +peerDependencyRules: + allowAny: + - vite + - vitest + allowedVersions: + vite: '*' + vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/steps.json new file mode 100644 index 0000000000..74dfa42763 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/steps.json @@ -0,0 +1,8 @@ +{ + "env": {}, + "commands": [ + "vp migrate --no-interactive # source-only browser provider should be restored", + "cat package.json # provider, framework peer, and local vitest should be present", + "cat pnpm-workspace.yaml # shared vitest catalog and override should be present" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/vite.config.ts b/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/vite.config.ts new file mode 100644 index 0000000000..c8728c30c4 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite-plus'; +import { playwright } from 'vite-plus/test/browser-playwright'; + +export default defineConfig({ + test: { + browser: { + enabled: true, + provider: playwright(), + }, + }, +}); diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/package.json b/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/package.json new file mode 100644 index 0000000000..c048b8c6a8 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/package.json @@ -0,0 +1,13 @@ +{ + "name": "migration-upgrade-browser-webdriverio-pnpm", + "devDependencies": { + "vite-plus": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "10.33.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/pnpm-workspace.yaml b/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/pnpm-workspace.yaml new file mode 100644 index 0000000000..d9df99abda --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest +overrides: + vite: 'catalog:' +peerDependencyRules: + allowAny: + - vite + allowedVersions: + vite: '*' diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/snap.txt new file mode 100644 index 0000000000..e5f69418c0 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/snap.txt @@ -0,0 +1,41 @@ +> vp migrate --no-interactive # source-only WebdriverIO provider should be restored +◇ Migrated . to Vite+ +• Node pnpm +• Package manager settings configured + +> cat package.json # provider, webdriverio, and local vitest should be present +{ + "name": "migration-upgrade-browser-webdriverio-pnpm", + "devDependencies": { + "vite-plus": "catalog:", + "@vitest/browser-webdriverio": "", + "webdriverio": "*", + "vitest": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } +} + +> cat pnpm-workspace.yaml # driver builds and shared vitest should be enabled +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest + vitest: +overrides: + vite: 'catalog:' + vitest: 'catalog:' +peerDependencyRules: + allowAny: + - vite + - vitest + allowedVersions: + vite: '*' + vitest: '*' +allowBuilds: + edgedriver: true + geckodriver: true diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/steps.json new file mode 100644 index 0000000000..6ac329801a --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/steps.json @@ -0,0 +1,8 @@ +{ + "env": {}, + "commands": [ + "vp migrate --no-interactive # source-only WebdriverIO provider should be restored", + "cat package.json # provider, webdriverio, and local vitest should be present", + "cat pnpm-workspace.yaml # driver builds and shared vitest should be enabled" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/vite.config.ts b/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/vite.config.ts new file mode 100644 index 0000000000..36f9be16c6 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite-plus'; +import { webdriverio } from 'vite-plus/test/browser-webdriverio'; + +export default defineConfig({ + test: { + browser: { + enabled: true, + provider: webdriverio(), + }, + }, +}); diff --git a/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/package.json b/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/package.json new file mode 100644 index 0000000000..bc93d7cada --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/package.json @@ -0,0 +1,14 @@ +{ + "name": "migration-upgrade-monorepo-vitest-localized-pnpm", + "private": true, + "devDependencies": { + "vite-plus": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "10.33.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/packages/app/package.json b/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/packages/app/package.json new file mode 100644 index 0000000000..84fbcdd3c2 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/packages/app/package.json @@ -0,0 +1,8 @@ +{ + "name": "app", + "devDependencies": { + "@vitest/ui": "^4.1.8", + "vite-plus": "^0.1.24", + "vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.24" + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/pnpm-workspace.yaml b/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/pnpm-workspace.yaml new file mode 100644 index 0000000000..c809535178 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/pnpm-workspace.yaml @@ -0,0 +1,12 @@ +packages: + - packages/* +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest +overrides: + vite: 'catalog:' +peerDependencyRules: + allowAny: + - vite + allowedVersions: + vite: '*' diff --git a/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/snap.txt new file mode 100644 index 0000000000..a790df7831 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/snap.txt @@ -0,0 +1,48 @@ +> vp migrate --no-interactive # existing Vite+ workspace packages should be reconciled +◇ Migrated . to Vite+ +• Node pnpm +• Package manager settings configured + +> cat package.json # root should not gain a direct vitest +{ + "name": "migration-upgrade-monorepo-vitest-localized-pnpm", + "private": true, + "devDependencies": { + "vite-plus": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } +} + +> cat packages/app/package.json # only the peer consumer should gain local vitest +{ + "name": "app", + "devDependencies": { + "@vitest/ui": "", + "vite-plus": "catalog:", + "vitest": "catalog:" + } +} + +> cat pnpm-workspace.yaml # shared vitest config should exist for the consuming package +packages: + - packages/* +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest + vitest: +overrides: + vite: 'catalog:' + vitest: 'catalog:' +peerDependencyRules: + allowAny: + - vite + - vitest + allowedVersions: + vite: '*' + vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/steps.json new file mode 100644 index 0000000000..30299fa416 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/steps.json @@ -0,0 +1,9 @@ +{ + "env": {}, + "commands": [ + "vp migrate --no-interactive # existing Vite+ workspace packages should be reconciled", + "cat package.json # root should not gain a direct vitest", + "cat packages/app/package.json # only the peer consumer should gain local vitest", + "cat pnpm-workspace.yaml # shared vitest config should exist for the consuming package" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/local-vite-plus/dist/bin.js b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/local-vite-plus/dist/bin.js new file mode 100644 index 0000000000..08e3fe0c42 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/local-vite-plus/dist/bin.js @@ -0,0 +1,2 @@ +console.error('stale local vite-plus CLI was executed'); +process.exitCode = 42; diff --git a/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/local-vite-plus/package.json b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/local-vite-plus/package.json new file mode 100644 index 0000000000..c301f35a6f --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/local-vite-plus/package.json @@ -0,0 +1,4 @@ +{ + "name": "vite-plus", + "version": "0.1.24" +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/package.json b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/package.json new file mode 100644 index 0000000000..d65275d403 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/package.json @@ -0,0 +1,16 @@ +{ + "name": "migration-upgrade-stale-local-pnpm", + "devDependencies": { + "vite": "npm:@voidzero-dev/vite-plus-core@^0.1.24", + "vite-plus": "^0.1.24", + "vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.24" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "10.33.0", + "onFail": "download" + } + }, + "pnpm": {} +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/pnpm-workspace.yaml b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/pnpm-workspace.yaml new file mode 100644 index 0000000000..a56e85d300 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +overrides: + vite: npm:@voidzero-dev/vite-plus-core@^0.1.24 + vitest: npm:@voidzero-dev/vite-plus-test@^0.1.24 +peerDependencyRules: + allowAny: + - vite + - vitest + allowedVersions: + vite: '*' + vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/setup-local.mjs b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/setup-local.mjs new file mode 100644 index 0000000000..6bbe95da83 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/setup-local.mjs @@ -0,0 +1,5 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +fs.mkdirSync('node_modules', { recursive: true }); +fs.cpSync('local-vite-plus', path.join('node_modules', 'vite-plus'), { recursive: true }); diff --git a/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/snap.txt new file mode 100644 index 0000000000..009a844efb --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/snap.txt @@ -0,0 +1,34 @@ +> node setup-local.mjs +> vp migrate --no-interactive # newer global CLI must bypass the installed stale local CLI +◇ Migrated . to Vite+ +• Node pnpm +• Package manager settings configured + +> cat package.json # stale wrapper deps and plain vite-plus range should be repaired +{ + "name": "migration-upgrade-stale-local-pnpm", + "devDependencies": { + "vite": "catalog:", + "vite-plus": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + }, + "pnpm": {} +} + +> cat pnpm-workspace.yaml # empty pnpm field must not hide workspace overrides +overrides: + vite: 'catalog:' +peerDependencyRules: + allowAny: + - vite + allowedVersions: + vite: '*' +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest diff --git a/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/steps.json new file mode 100644 index 0000000000..9e51e271ad --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/steps.json @@ -0,0 +1,9 @@ +{ + "env": {}, + "commands": [ + { "command": "node setup-local.mjs", "ignoreOutput": true }, + "vp migrate --no-interactive # newer global CLI must bypass the installed stale local CLI", + "cat package.json # stale wrapper deps and plain vite-plus range should be repaired", + "cat pnpm-workspace.yaml # empty pnpm field must not hide workspace overrides" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/package.json b/packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/package.json new file mode 100644 index 0000000000..79eb0c6816 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/package.json @@ -0,0 +1,18 @@ +{ + "name": "migration-upgrade-vite-plus-protocol-pin-npm", + "devDependencies": { + "vite-plus": "file:../custom-vite-plus", + "vitest": "npm:@voidzero-dev/vite-plus-test@latest" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "npm:@voidzero-dev/vite-plus-test@latest" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "11.16.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/snap.txt new file mode 100644 index 0000000000..2b67a8c5e1 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/snap.txt @@ -0,0 +1,22 @@ +> vp migrate --no-interactive # deliberate vite-plus protocol pin must survive bootstrap +◇ Migrated . to Vite+ +• Node npm +• Package manager settings configured + +> cat package.json # file pin should remain while stale vitest config is removed +{ + "name": "migration-upgrade-vite-plus-protocol-pin-npm", + "devDependencies": { + "vite-plus": "file:../custom-vite-plus" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/steps.json new file mode 100644 index 0000000000..4cf48ccb3d --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/steps.json @@ -0,0 +1,7 @@ +{ + "env": {}, + "commands": [ + "vp migrate --no-interactive # deliberate vite-plus protocol pin must survive bootstrap", + "cat package.json # file pin should remain while stale vitest config is removed" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/package.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/package.json new file mode 100644 index 0000000000..5b659d5fe8 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/package.json @@ -0,0 +1,23 @@ +{ + "name": "migration-upgrade-vitest-exact-peer-npm", + "devDependencies": { + "@vitest/coverage-v8": "^4.1.8", + "@vitest/eslint-plugin": "^1.6.0", + "@vitest/ui": "^4.1.8", + "@vitest/utils": "^4.1.8", + "@vitest/web-worker": "^4.1.8", + "vite-plus": "latest", + "vitest": "npm:@voidzero-dev/vite-plus-test@latest" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "npm:@voidzero-dev/vite-plus-test@latest" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "11.16.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/snap.txt new file mode 100644 index 0000000000..06b21d930c --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/snap.txt @@ -0,0 +1,29 @@ +> vp migrate --no-interactive # exact @vitest peers require a package-local vitest +◇ Migrated . to Vite+ +• Node npm +• Package manager settings configured + +> cat package.json # ecosystem packages and vitest should align to the bundled version +{ + "name": "migration-upgrade-vitest-exact-peer-npm", + "devDependencies": { + "@vitest/coverage-v8": "4.1.9", + "@vitest/eslint-plugin": "^1.6.0", + "@vitest/ui": "", + "@vitest/utils": "", + "@vitest/web-worker": "", + "vite-plus": "latest", + "vitest": "" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/steps.json new file mode 100644 index 0000000000..792fcf8e77 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/steps.json @@ -0,0 +1,7 @@ +{ + "env": {}, + "commands": [ + "vp migrate --no-interactive # exact @vitest peers require a package-local vitest", + "cat package.json # ecosystem packages and vitest should align to the bundled version" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/.yarnrc.yml b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/.yarnrc.yml new file mode 100644 index 0000000000..65d6ec1deb --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/.yarnrc.yml @@ -0,0 +1,4 @@ +nodeLinker: pnp +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/package.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/package.json new file mode 100644 index 0000000000..5f0242dfc9 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/package.json @@ -0,0 +1,17 @@ +{ + "name": "migration-upgrade-vitest-exact-peer-yarn4", + "devDependencies": { + "@vitest/ui": "^4.1.8", + "vite-plus": "catalog:" + }, + "resolutions": { + "vite": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "yarn", + "version": "4.12.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/snap.txt new file mode 100644 index 0000000000..8d0b908ae7 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/snap.txt @@ -0,0 +1,35 @@ +> vp migrate --no-interactive # Yarn PnP exact peer should receive package-local vitest +◇ Migrated . to Vite+ +• Node yarn +• Package manager settings configured + +> cat package.json # direct deps and resolutions should use the managed catalog/version +{ + "name": "migration-upgrade-vitest-exact-peer-yarn4", + "devDependencies": { + "@vitest/ui": "", + "vite-plus": "catalog:", + "vitest": "catalog:" + }, + "resolutions": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "" + }, + "devEngines": { + "packageManager": { + "name": "yarn", + "version": "", + "onFail": "download" + } + } +} + +> cat .yarnrc.yml # shared catalog should include the aligned vitest +nodeLinker: pnp +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest + vitest: +npmPreapprovedPackages: + - vitest + - '@vitest/*' diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/steps.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/steps.json new file mode 100644 index 0000000000..41aa4f3d2c --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/steps.json @@ -0,0 +1,8 @@ +{ + "env": {}, + "commands": [ + "vp migrate --no-interactive # Yarn PnP exact peer should receive package-local vitest", + "cat package.json # direct deps and resolutions should use the managed catalog/version", + "cat .yarnrc.yml # shared catalog should include the aligned vitest" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/package.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/package.json new file mode 100644 index 0000000000..0dccb74e58 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/package.json @@ -0,0 +1,21 @@ +{ + "name": "migration-upgrade-vitest-non-runtime-only-npm", + "devDependencies": { + "@vitest/eslint-plugin": "^1.6.0", + "@vitest/utils": "^4.1.8", + "@vitest/ws-client": "^4.1.8", + "vite-plus": "latest", + "vitest": "4.1.8" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "4.1.8" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "11.16.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/snap.txt new file mode 100644 index 0000000000..c2ec356064 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/snap.txt @@ -0,0 +1,25 @@ +> vp migrate --no-interactive # non-runtime @vitest packages must not keep a vitest pin +◇ Migrated . to Vite+ +• Node npm +• Package manager settings configured + +> cat package.json # internal packages align, eslint plugin stays independent, vitest is removed +{ + "name": "migration-upgrade-vitest-non-runtime-only-npm", + "devDependencies": { + "@vitest/eslint-plugin": "^1.6.0", + "@vitest/utils": "", + "@vitest/ws-client": "", + "vite-plus": "latest" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/steps.json new file mode 100644 index 0000000000..06299da744 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/steps.json @@ -0,0 +1,7 @@ +{ + "env": {}, + "commands": [ + "vp migrate --no-interactive # non-runtime @vitest packages must not keep a vitest pin", + "cat package.json # internal packages align, eslint plugin stays independent, vitest is removed" + ] +} diff --git a/packages/cli/snap-tests-global/migration-vitest-import-only/example.spec.ts b/packages/cli/snap-tests-global/migration-vitest-import-only/example.spec.ts new file mode 100644 index 0000000000..8305afb0b3 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-vitest-import-only/example.spec.ts @@ -0,0 +1,5 @@ +import { expect, it } from 'vitest'; + +it('works', () => { + expect(true).toBe(true); +}); diff --git a/packages/cli/snap-tests-global/migration-vitest-import-only/package.json b/packages/cli/snap-tests-global/migration-vitest-import-only/package.json new file mode 100644 index 0000000000..00414adb22 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-vitest-import-only/package.json @@ -0,0 +1,10 @@ +{ + "name": "migration-vitest-import-only", + "scripts": { + "test": "vitest" + }, + "devDependencies": { + "vite": "^7.0.0", + "vitest": "^4.0.0" + } +} diff --git a/packages/cli/snap-tests-global/migration-vitest-import-only/snap.txt b/packages/cli/snap-tests-global/migration-vitest-import-only/snap.txt new file mode 100644 index 0000000000..e51c39c3e3 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-vitest-import-only/snap.txt @@ -0,0 +1,43 @@ +> vp migrate --no-interactive # ordinary vitest imports should migrate without retaining direct vitest +◇ Migrated . to Vite+ +• Node pnpm +• 2 config updates applied, 1 file had imports rewritten + +> cat package.json # direct dependency and shared pin should be removed +{ + "name": "migration-vitest-import-only", + "scripts": { + "test": "vp test", + "prepare": "vp config" + }, + "devDependencies": { + "vite": "catalog:", + "vite-plus": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } +} + +> cat example.spec.ts # source import should use the Vite+ public surface +import { expect, it } from 'vite-plus/test'; + +it('works', () => { + expect(true).toBe(true); +}); + +> cat pnpm-workspace.yaml +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest +overrides: + vite: 'catalog:' +peerDependencyRules: + allowAny: + - vite + allowedVersions: + vite: '*' diff --git a/packages/cli/snap-tests-global/migration-vitest-import-only/steps.json b/packages/cli/snap-tests-global/migration-vitest-import-only/steps.json new file mode 100644 index 0000000000..5337542640 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-vitest-import-only/steps.json @@ -0,0 +1,9 @@ +{ + "env": {}, + "commands": [ + "vp migrate --no-interactive # ordinary vitest imports should migrate without retaining direct vitest", + "cat package.json # direct dependency and shared pin should be removed", + "cat example.spec.ts # source import should use the Vite+ public surface", + "cat pnpm-workspace.yaml" + ] +} diff --git a/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/package.json b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/package.json new file mode 100644 index 0000000000..6fc60c5d10 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/package.json @@ -0,0 +1,10 @@ +{ + "name": "migration-vitest-unmanaged-override", + "scripts": { + "test": "vitest" + }, + "devDependencies": { + "vite": "^7.0.0", + "vitest": "^4.0.0" + } +} diff --git a/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/snap.txt b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/snap.txt new file mode 100644 index 0000000000..bd5f121f6b --- /dev/null +++ b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/snap.txt @@ -0,0 +1,37 @@ +> vp migrate --no-interactive # vitest omitted from managed overrides must remain user-owned +◇ Migrated . to Vite+ +• Node pnpm +• 2 config updates applied + +> cat package.json # user's vitest dependency should be preserved +{ + "name": "migration-vitest-unmanaged-override", + "scripts": { + "test": "vp test", + "prepare": "vp config" + }, + "devDependencies": { + "vite": "catalog:", + "vitest": "^4.0.0", + "vite-plus": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } +} + +> cat pnpm-workspace.yaml # no vitest catalog or override should be introduced +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest +overrides: + vite: 'catalog:' +peerDependencyRules: + allowAny: + - vite + allowedVersions: + vite: '*' diff --git a/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/steps.json b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/steps.json new file mode 100644 index 0000000000..86631201d7 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/steps.json @@ -0,0 +1,10 @@ +{ + "env": { + "VP_OVERRIDE_PACKAGES": "{\"vite\":\"npm:@voidzero-dev/vite-plus-core@latest\"}" + }, + "commands": [ + "vp migrate --no-interactive # vitest omitted from managed overrides must remain user-owned", + "cat package.json # user's vitest dependency should be preserved", + "cat pnpm-workspace.yaml # no vitest catalog or override should be introduced" + ] +} diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index f23e294fff..089ea6c999 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -2023,7 +2023,7 @@ describe('ensureVitePlusBootstrap', () => { expect(pkg.devDependencies['vite-plus']).toBe('latest'); }); - it('keeps yarn monorepo bootstrap rewrites out of package dependency specs', () => { + it('normalizes yarn monorepo dependency specs through the shared catalog', () => { fs.writeFileSync( path.join(tmpDir, 'package.json'), JSON.stringify({ @@ -2049,8 +2049,8 @@ describe('ensureVitePlusBootstrap', () => { devDependencies: Record; resolutions: Record; }; - expect(pkg.devDependencies.vite).toBe('^7.0.0'); - expect(pkg.devDependencies['vite-plus']).toBe('latest'); + expect(pkg.devDependencies.vite).toBe('catalog:'); + expect(pkg.devDependencies['vite-plus']).toBe('catalog:'); expect(pkg.resolutions.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); const yarnrc = readYamlObject(path.join(tmpDir, '.yarnrc.yml')) as { nodeLinker: string; diff --git a/packages/cli/src/migration/__tests__/npm-reinstall.spec.ts b/packages/cli/src/migration/__tests__/npm-reinstall.spec.ts new file mode 100644 index 0000000000..a25bc2dbb3 --- /dev/null +++ b/packages/cli/src/migration/__tests__/npm-reinstall.spec.ts @@ -0,0 +1,84 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { prepareNpmViteAliasReinstall } from '../npm-reinstall.ts'; + +const tempDirs: string[] = []; + +function createTempDir(): string { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vite-plus-npm-reinstall-')); + tempDirs.push(tempDir); + return tempDir; +} + +function writePackage(packagePath: string, name: string): void { + fs.mkdirSync(packagePath, { recursive: true }); + fs.writeFileSync(path.join(packagePath, 'package.json'), JSON.stringify({ name })); +} + +afterEach(() => { + for (const tempDir of tempDirs.splice(0)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +describe('prepareNpmViteAliasReinstall', () => { + it('prunes stale real-Vite lock entries and installations while preserving the core alias', () => { + const rootDir = createTempDir(); + const staleRootVite = path.join(rootDir, 'node_modules', 'vite'); + const staleNestedVite = path.join(rootDir, 'node_modules', 'consumer', 'node_modules', 'vite'); + const coreVite = path.join(rootDir, 'packages', 'app', 'node_modules', 'vite'); + writePackage(staleRootVite, 'vite'); + writePackage(staleNestedVite, 'vite'); + writePackage(coreVite, '@voidzero-dev/vite-plus-core'); + fs.writeFileSync( + path.join(rootDir, 'package-lock.json'), + JSON.stringify({ + lockfileVersion: 3, + packages: { + '': { name: 'test' }, + 'node_modules/vite': { + version: '7.3.5', + resolved: 'https://registry.npmjs.org/vite/-/vite-7.3.5.tgz', + }, + 'node_modules/consumer/node_modules/vite': { + version: '7.3.5', + resolved: 'https://registry.npmjs.org/vite/-/vite-7.3.5.tgz', + }, + 'packages/app/node_modules/vite': { + name: '@voidzero-dev/vite-plus-core', + version: '0.2.1', + }, + }, + }), + ); + + expect( + prepareNpmViteAliasReinstall(rootDir, [rootDir, path.join(rootDir, 'packages', 'app')]), + ).toBe(true); + + const lock = JSON.parse(fs.readFileSync(path.join(rootDir, 'package-lock.json'), 'utf8')) as { + packages: Record; + }; + expect(lock.packages['node_modules/vite']).toBeUndefined(); + expect(lock.packages['node_modules/consumer/node_modules/vite']).toBeUndefined(); + expect(lock.packages['packages/app/node_modules/vite']).toBeDefined(); + expect(fs.existsSync(staleRootVite)).toBe(false); + expect(fs.existsSync(staleNestedVite)).toBe(false); + expect(fs.existsSync(coreVite)).toBe(true); + }); + + it('removes a stale workspace-local install when no package-lock exists', () => { + const rootDir = createTempDir(); + const workspaceDir = path.join(rootDir, 'packages', 'app'); + const staleVite = path.join(workspaceDir, 'node_modules', 'vite'); + writePackage(staleVite, 'vite'); + + expect(prepareNpmViteAliasReinstall(rootDir, [rootDir, workspaceDir])).toBe(true); + expect(fs.existsSync(staleVite)).toBe(false); + expect(prepareNpmViteAliasReinstall(rootDir, [rootDir, workspaceDir])).toBe(false); + }); +}); diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index 49edcb8476..2731c822f0 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -78,6 +78,7 @@ import { type Framework, type NodeVersionManagerDetection, } from './migrator.ts'; +import { prepareNpmViteAliasReinstall } from './npm-reinstall.ts'; import { addMigrationWarning, createMigrationReport, type MigrationReport } from './report.ts'; async function confirmNodeVersionFileMigration( @@ -1083,6 +1084,9 @@ async function executeMigrationPlan( plan.packageManager === PackageManager.npm || plan.packageManager === PackageManager.bun ? ['--force'] : ['--no-frozen-lockfile']; + if (plan.packageManager === PackageManager.npm) { + prepareNpmViteAliasReinstall(workspaceInfo.rootDir, getWorkspaceProjectPaths(workspaceInfo)); + } updateMigrationProgress('Installing dependencies'); const finalInstallSummary = await runViteInstall( workspaceInfo.rootDir, @@ -1395,6 +1399,12 @@ async function main() { const resolved = await ensureExistingPackageManager(); updateMigrationProgress('Installing dependencies'); const resolvedVersion = resolved?.version ?? packageManagerVersion; + if (packageManager === PackageManager.npm) { + prepareNpmViteAliasReinstall( + workspaceInfoOptional.rootDir, + getWorkspaceProjectPaths(workspaceInfoOptional), + ); + } const installSummary = await runViteInstall( workspaceInfoOptional.rootDir, options.interactive, diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index c8407ebd52..56151a529b 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -1798,6 +1798,7 @@ export function rewriteStandaloneProject( usesVitestBrowserMode(projectPath), collectProviderSourceModes(projectPath), usesVitest, + sourceTreeReferencesRetainedVitestModule(projectPath), ); // ensure vite-plus is in devDependencies @@ -2029,6 +2030,7 @@ export function rewriteMonorepoProject( usesVitestBrowserMode(projectPath), collectProviderSourceModes(projectPath), projectUsesVitestDirectly(projectPath, pkg), + sourceTreeReferencesRetainedVitestModule(projectPath), ); // If this SUB-workspace now depends on `vite-plus` and Yarn isolates its // hoisting (via the root `nmHoistingLimits` OR the workspace's own @@ -3798,16 +3800,16 @@ function reconcileVitePlusBootstrapPackage( pruneLegacyWrapperAliases(dependencies); } - // npm keeps the managed alias directly in package.json. Catalog-based package - // managers leave dependency specs alone and repair their shared override. - if (packageManager === PackageManager.npm) { - for (const dependencies of installGroups) { - if ( - dependencies?.vite !== undefined && - !overrideSpecSatisfiesVitePlus('vite', dependencies.vite) - ) { - dependencies.vite = VITE_PLUS_OVERRIDE_PACKAGES.vite; - } + // Normalize direct Vite install entries as well as the shared override. Keep + // named catalog references intact; plain/behind aliases move to the active + // default catalog or the current core alias. + for (const dependencies of installGroups) { + if (dependencies?.vite !== undefined) { + dependencies.vite = getCatalogDependencySpec( + dependencies.vite, + VITE_PLUS_OVERRIDE_PACKAGES.vite, + supportCatalog, + ); } } @@ -3928,7 +3930,9 @@ export function detectVitePlusBootstrapPending( packageManager === PackageManager.pnpm && !pnpmConfigLivesInPackageJson(pkg, projectPath); const supportCatalog = !VITE_PLUS_VERSION.startsWith('file:') && - (usePnpmWorkspaceYaml || packageManager === PackageManager.bun); + (usePnpmWorkspaceYaml || + packageManager === PackageManager.yarn || + packageManager === PackageManager.bun); const canonicalVitePlusSpec = supportCatalog ? 'catalog:' : VITE_PLUS_VERSION; for (const [index, packagePath] of bootstrapProjectPaths(projectPath, packages).entries()) { const childPackageJsonPath = path.join(packagePath, 'package.json'); @@ -4131,7 +4135,9 @@ export function ensureVitePlusBootstrap( !pnpmConfigLivesInPackageJson(initialRootPkg, projectPath); const supportCatalog = !VITE_PLUS_VERSION.startsWith('file:') && - (usePnpmWorkspaceYaml || workspaceInfo.packageManager === PackageManager.bun); + (usePnpmWorkspaceYaml || + workspaceInfo.packageManager === PackageManager.yarn || + workspaceInfo.packageManager === PackageManager.bun); const canonicalVitePlusSpec = supportCatalog ? 'catalog:' : VITE_PLUS_VERSION; editJsonFile< @@ -4548,6 +4554,10 @@ export function rewritePackageJson( // is REMOVED so it arrives transitively through vite-plus. Defaults to true to // preserve legacy behavior for callers that don't compute the signal. usesVitestDirectly = true, + // Module augmentations/triple-slash references intentionally retain the + // upstream `vitest` identity after import rewriting and therefore require a + // package-local provider under strict dependency layouts. + retainedVitestModule = false, ): Record | null { if (pkg.scripts) { const updated = rewriteScripts( @@ -4795,7 +4805,8 @@ export function rewritePackageJson( // `existingVitePlus` is already truthy here), or a re-migration of a project that // already owns it. The guard below still no-ops when a direct `vitest` already exists, // so a genuine normalize pass of an already-correct project mutates nothing. - const needDirectVitest = needVitePlus || effectiveBrowserMode || isVitestAdjacent; + const needDirectVitest = + needVitePlus || effectiveBrowserMode || isVitestAdjacent || retainedVitestModule; if (needVitePlus || shouldNormalizeExistingVitePlus) { pkg.devDependencies = { ...pkg.devDependencies, @@ -4822,7 +4833,9 @@ export function rewritePackageJson( }; if ( !installableDeps.vitest && - (effectiveBrowserMode || Object.keys(installableDeps).some((name) => name.includes('vitest'))) + (effectiveBrowserMode || + retainedVitestModule || + Object.keys(installableDeps).some((name) => name.includes('vitest'))) ) { pkg.devDependencies ??= {}; pkg.devDependencies.vitest = getCatalogDependencySpec( diff --git a/packages/cli/src/migration/npm-reinstall.ts b/packages/cli/src/migration/npm-reinstall.ts new file mode 100644 index 0000000000..607fd7a652 --- /dev/null +++ b/packages/cli/src/migration/npm-reinstall.ts @@ -0,0 +1,98 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { readJsonFile, writeJsonFile } from '../utils/json.ts'; + +const VITE_PLUS_CORE_PACKAGE = '@voidzero-dev/vite-plus-core'; + +interface NpmLockPackage { + name?: string; + resolved?: string; +} + +interface NpmPackageLock { + packages?: Record; +} + +function isViteInstallPath(packagePath: string): boolean { + return packagePath === 'node_modules/vite' || packagePath.endsWith('/node_modules/vite'); +} + +function isVitePlusCorePackage(pkg: NpmLockPackage | undefined): boolean { + return ( + pkg?.name === VITE_PLUS_CORE_PACKAGE || + pkg?.resolved?.includes('/@voidzero-dev/vite-plus-core/') === true + ); +} + +function removeStaleInstalledVite(packagePath: string): boolean { + const packageJsonPath = path.join(packagePath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return false; + } + + try { + const pkg = readJsonFile(packageJsonPath) as { name?: string }; + if (pkg.name === VITE_PLUS_CORE_PACKAGE) { + return false; + } + } catch { + // A broken package directory also needs to be replaced by the reinstall. + } + + fs.rmSync(packagePath, { recursive: true, force: true }); + return true; +} + +/** + * npm does not replace an already-installed package when its dependency changes + * from `vite` to the `@voidzero-dev/vite-plus-core` npm alias. Even `npm + * install --force` can exit successfully while retaining the real Vite package + * and its stale package-lock entry. Remove only those stale Vite entries before + * the migration's final install so npm resolves the managed alias afresh. + */ +export function prepareNpmViteAliasReinstall( + rootDir: string, + projectPaths: string[] = [rootDir], +): boolean { + const packageLockPath = path.join(rootDir, 'package-lock.json'); + let changed = false; + + if (fs.existsSync(packageLockPath)) { + const packageLock = readJsonFile(packageLockPath) as NpmPackageLock; + let lockChanged = false; + + for (const [packagePath, pkg] of Object.entries(packageLock.packages ?? {})) { + if (!isViteInstallPath(packagePath)) { + continue; + } + + const installPath = path.resolve(rootDir, packagePath); + const relativeInstallPath = path.relative(rootDir, installPath); + if (relativeInstallPath.startsWith('..') || path.isAbsolute(relativeInstallPath)) { + continue; + } + + if (!isVitePlusCorePackage(pkg)) { + delete packageLock.packages?.[packagePath]; + lockChanged = true; + removeStaleInstalledVite(installPath); + } else { + changed = removeStaleInstalledVite(installPath) || changed; + } + } + + if (lockChanged) { + writeJsonFile(packageLockPath, packageLock as unknown as Record); + changed = true; + } + } + + // Also handle installs without a lockfile and workspace-local copies that do + // not have their own package-lock entry. + for (const projectPath of projectPaths) { + changed = removeStaleInstalledVite(path.join(projectPath, 'node_modules', 'vite')) || changed; + } + + return changed; +} diff --git a/rfcs/migrate-existing-projects.md b/rfcs/migrate-existing-projects.md index 8c606e460d..d15177806c 100644 --- a/rfcs/migrate-existing-projects.md +++ b/rfcs/migrate-existing-projects.md @@ -34,7 +34,7 @@ Removing the old direct dependency was exercised on `node-modules/urllib` across | Workspaces | Reconcile every package manifest, not only the root. Localize the direct `vitest` dependency to packages that need it; keep shared catalogs/overrides only when at least one package needs them. Re-pin existing plain `vite-plus` ranges consistently while preserving deliberate protocol specs. | | Legacy wrapper | Remove every `@voidzero-dev/vite-plus-test` alias (deps, overrides, catalogs); repoint direct wrapper imports to `vite-plus/test`. `vite-plus/test*` imports are left unchanged (stable public API). | | pnpm config location | An empty `"pnpm": {}` with an existing `pnpm-workspace.yaml` reconciles the workspace file (instead of writing a second, conflicting override block into `package.json`). | -| Reinstall + verify | One reinstall with lockfile refresh (`--no-frozen-lockfile` / `--force`); a failed install warns and sets a non-zero exit. | +| Reinstall + verify | One reinstall with lockfile refresh (`--no-frozen-lockfile` / `--force`); before npm reinstalls, remove a stale real-`vite` install/lock entry that npm otherwise retains after the dependency becomes the Vite+ core alias. A failed install warns and sets a non-zero exit. | Force-override/CI mode (`VP_OVERRIDE_PACKAGES`) is respected: when `vitest` is not a managed key there, the project's own `vitest` is never stripped. @@ -60,12 +60,29 @@ How each package the `vitest` ecosystem rule covers is handled, verified against ## Implementation -| Area | Change | -| ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `crates/vite_global_cli` (`commands/migrate.rs`, `js_executor.rs`) | `delegate_migrate`: compare local `vite-plus` vs global `vp` version; escalate to the global CLI when older. | -| `packages/cli/src/migration/migrator.ts` | Usage-aware managed override set; per-package dependency reconciliation; `vitest` removal across every sink; full `@vitest/*` alignment; browser-provider restoration; behind `vite-plus`/`vite` re-pin; empty/unrelated-`pnpm` routing fix. | - -Covered by unit tests in `migrator.spec.ts` (vitest removal, required-peer provisioning, ecosystem alignment, browser-provider restoration, workspace localization, behind re-pin, empty-`pnpm` reconciliation) and a routing test in `vite_global_cli`. +| Area | Change | +| ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `crates/vite_global_cli` (`commands/migrate.rs`, `js_executor.rs`) | `delegate_migrate`: compare local `vite-plus` vs global `vp` version; escalate to the global CLI when older. | +| `packages/cli/src/migration/{migrator,npm-reinstall,bin}.ts` | Usage-aware managed override set; per-package dependency reconciliation; `vitest` removal across every sink; full `@vitest/*` alignment; browser-provider restoration; behind `vite-plus`/`vite` re-pin; empty/unrelated-`pnpm` routing fix; stale npm Vite install cleanup. | + +Covered by unit tests in `migrator.spec.ts` (vitest removal, required-peer provisioning, ecosystem alignment, browser-provider restoration, workspace localization, behind re-pin, empty-`pnpm` reconciliation), `npm-reinstall.spec.ts` (stale npm install and lock cleanup), and a routing test in `vite_global_cli`. + +## Snapshot coverage + +| Scenario | Global snap fixture | +| ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| Stale local CLI escalation, plain-range re-pin, stale wrapper removal, empty `pnpm` routing | `migration-upgrade-stale-local-pnpm` | +| Default direct-`vitest` removal and ordinary import rewrite | `migration-already-vite-plus`, `migration-vitest-import-only` | +| Official exact peers under npm and Yarn PnP | `migration-upgrade-vitest-exact-peer-npm`, `migration-upgrade-vitest-exact-peer-yarn4` | +| Third-party range peer | `migration-vitest-peer-dep` | +| Internal `@vitest/*` packages and `@vitest/eslint-plugin` exclusions | `migration-upgrade-vitest-non-runtime-only-npm` | +| Playwright and WebdriverIO browser restoration, including pnpm driver approvals | `migration-upgrade-browser-source-only-pnpm`, `migration-upgrade-browser-webdriverio-pnpm` | +| Package-local Vitest in an existing monorepo with shared root overrides | `migration-upgrade-monorepo-vitest-localized-pnpm` | +| Retained upstream module augmentations | `migration-rewrite-declare-module` | +| Unmanaged/CI override mode preserves user-owned Vitest | `migration-vitest-unmanaged-override` | +| Deliberate protocol-pinned `vite-plus` spec | `migration-upgrade-vite-plus-protocol-pin-npm` | +| Idempotent rerun on an already-current project | `migration-from-tsdown`, `migration-from-tsdown-json-config` | +| Reinstall and lockfile refresh after the alias rewrite | `migration-standalone-npm` | ## Follow-ups (not in this change) From c8b175b9e573236435d16e481b87d09157dbb388 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 21 Jun 2026 17:37:40 +0800 Subject: [PATCH 10/78] test(migrate): update default vitest snapshots --- crates/vite_global_cli/src/js_executor.rs | 2 +- .../snap-tests-global/new-vite-monorepo-bun/snap.txt | 4 +--- .../cli/snap-tests-global/new-vite-monorepo/snap.txt | 4 ---- .../snap-tests/create-approve-builds-bun/snap.txt | 9 +++------ .../create-approve-builds-migrate-pnpm11/snap.txt | 12 ------------ .../snap-tests/create-approve-builds-pnpm11/snap.txt | 12 ------------ .../snap-tests/create-approve-builds-yarn/snap.txt | 6 ++---- .../snap-tests/create-org-bundled-monorepo/snap.txt | 4 ---- 8 files changed, 7 insertions(+), 46 deletions(-) diff --git a/crates/vite_global_cli/src/js_executor.rs b/crates/vite_global_cli/src/js_executor.rs index 0af79b0399..bbf25fa9b6 100644 --- a/crates/vite_global_cli/src/js_executor.rs +++ b/crates/vite_global_cli/src/js_executor.rs @@ -486,7 +486,7 @@ mod tests { assert!(!local_vite_plus_is_older("0.2.1", "0.2.1")); // Newer local keeps local-first semantics. assert!(!local_vite_plus_is_older("0.3.0", "0.2.1")); - // Unparseable versions are conservative: never escalate. + // Unparsable versions are conservative: never escalate. assert!(!local_vite_plus_is_older("latest", "0.2.1")); } diff --git a/packages/cli/snap-tests-global/new-vite-monorepo-bun/snap.txt b/packages/cli/snap-tests-global/new-vite-monorepo-bun/snap.txt index 931bc042ab..4d6f94a18d 100644 --- a/packages/cli/snap-tests-global/new-vite-monorepo-bun/snap.txt +++ b/packages/cli/snap-tests-global/new-vite-monorepo-bun/snap.txt @@ -30,8 +30,7 @@ vite.config.ts "vite-plus": "catalog:" }, "overrides": { - "vite": "catalog:", - "vitest": "catalog:" + "vite": "catalog:" }, "devEngines": { "packageManager": { @@ -45,7 +44,6 @@ vite.config.ts }, "catalog": { "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "", "vite-plus": "latest" } } diff --git a/packages/cli/snap-tests-global/new-vite-monorepo/snap.txt b/packages/cli/snap-tests-global/new-vite-monorepo/snap.txt index 4dc309f2fe..2b23c516ab 100644 --- a/packages/cli/snap-tests-global/new-vite-monorepo/snap.txt +++ b/packages/cli/snap-tests-global/new-vite-monorepo/snap.txt @@ -66,18 +66,14 @@ catalog: "@types/node": ^24 typescript: ^5 vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: "catalog:" - vitest: "catalog:" peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: "*" - vitest: "*" > test -f vite-plus-monorepo/.gitignore && echo '.gitignore exists' || echo 'ERROR: .gitignore missing' # verify gitignore renamed from _gitignore .gitignore exists diff --git a/packages/cli/snap-tests/create-approve-builds-bun/snap.txt b/packages/cli/snap-tests/create-approve-builds-bun/snap.txt index 1a2f29e76d..6ab57ecfe0 100644 --- a/packages/cli/snap-tests/create-approve-builds-bun/snap.txt +++ b/packages/cli/snap-tests/create-approve-builds-bun/snap.txt @@ -20,8 +20,7 @@ "vite-plus": "latest" }, "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "" + "vite": "npm:@voidzero-dev/vite-plus-core@latest" }, "devEngines": { "packageManager": { @@ -61,8 +60,7 @@ These dependencies may not work until built. Run vp pm approve-builds core-js in "vite-plus": "latest" }, "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "" + "vite": "npm:@voidzero-dev/vite-plus-core@latest" }, "devEngines": { "packageManager": { @@ -97,8 +95,7 @@ bun pm trust v () "vite-plus": "latest" }, "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "" + "vite": "npm:@voidzero-dev/vite-plus-core@latest" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests/create-approve-builds-migrate-pnpm11/snap.txt b/packages/cli/snap-tests/create-approve-builds-migrate-pnpm11/snap.txt index ac8879bff3..c456d18458 100644 --- a/packages/cli/snap-tests/create-approve-builds-migrate-pnpm11/snap.txt +++ b/packages/cli/snap-tests/create-approve-builds-migrate-pnpm11/snap.txt @@ -11,18 +11,14 @@ allowBuilds: core-js: true catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: "catalog:" - vitest: "catalog:" peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: "*" - vitest: "*" > node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org:with-build-dep --no-interactive --directory default-app # default run surfaces the gated build with guidance, leaving it unapproved @@ -41,18 +37,14 @@ allowBuilds: core-js: set this to true or false catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: "catalog:" - vitest: "catalog:" peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: "*" - vitest: "*" > cd default-app && vp pm approve-builds core-js # the guidance's `vp pm approve-builds` command approves the gated build .../core-js@/node_modules/core-js postinstall$ node -e "try{require('./postinstall')}catch(e){}" @@ -63,15 +55,11 @@ allowBuilds: core-js: true catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: "catalog:" - vitest: "catalog:" peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: "*" - vitest: "*" diff --git a/packages/cli/snap-tests/create-approve-builds-pnpm11/snap.txt b/packages/cli/snap-tests/create-approve-builds-pnpm11/snap.txt index ae6586d93e..c4f467fee6 100644 --- a/packages/cli/snap-tests/create-approve-builds-pnpm11/snap.txt +++ b/packages/cli/snap-tests/create-approve-builds-pnpm11/snap.txt @@ -9,18 +9,14 @@ allowBuilds: core-js: true catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: "catalog:" - vitest: "catalog:" peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: "*" - vitest: "*" > node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org:with-build-dep --no-interactive --directory default-app # default run surfaces the gated build with guidance, leaving it unapproved @@ -37,18 +33,14 @@ allowBuilds: core-js: set this to true or false catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: "catalog:" - vitest: "catalog:" peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: "*" - vitest: "*" > cd default-app && vp pm approve-builds core-js # the guidance's `vp pm approve-builds` command approves the gated build .../core-js@/node_modules/core-js postinstall$ node -e "try{require('./postinstall')}catch(e){}" @@ -59,15 +51,11 @@ allowBuilds: core-js: true catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: "catalog:" - vitest: "catalog:" peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: "*" - vitest: "*" diff --git a/packages/cli/snap-tests/create-approve-builds-yarn/snap.txt b/packages/cli/snap-tests/create-approve-builds-yarn/snap.txt index 831f12dea0..03d9474be1 100644 --- a/packages/cli/snap-tests/create-approve-builds-yarn/snap.txt +++ b/packages/cli/snap-tests/create-approve-builds-yarn/snap.txt @@ -24,8 +24,7 @@ } }, "resolutions": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "" + "vite": "npm:@voidzero-dev/vite-plus-core@latest" }, "devEngines": { "packageManager": { @@ -61,8 +60,7 @@ These dependencies may not work until built. Enable them in the workspace root p "vite-plus": "latest" }, "resolutions": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "" + "vite": "npm:@voidzero-dev/vite-plus-core@latest" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests/create-org-bundled-monorepo/snap.txt b/packages/cli/snap-tests/create-org-bundled-monorepo/snap.txt index 3fe290bc75..cc31c0c256 100644 --- a/packages/cli/snap-tests/create-org-bundled-monorepo/snap.txt +++ b/packages/cli/snap-tests/create-org-bundled-monorepo/snap.txt @@ -26,18 +26,14 @@ packages: - packages/* catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: "catalog:" - vitest: "catalog:" peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: "*" - vitest: "*" > test -d my-mono/.git && echo 'Git initialized' # git-init prompt covers bundled monorepo path Git initialized From 15ca94580a1312b27543ccacfcf44586b24a28e7 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 21 Jun 2026 19:50:10 +0800 Subject: [PATCH 11/78] fix(migrate): handle peer and override edge cases --- .../package.json | 16 ++ .../pnpm-workspace.yaml | 10 + .../snap.txt | 44 +++++ .../steps.json | 8 + .../package.json | 19 ++ .../snap.txt | 27 +++ .../steps.json | 7 + .../package.json | 16 ++ .../pnpm-workspace.yaml | 13 ++ .../snap.txt | 39 ++++ .../steps.json | 8 + .../env.d.ts | 1 + .../package.json | 14 ++ .../pnpm-workspace.yaml | 10 + .../snap.txt | 43 +++++ .../steps.json | 8 + .../bun-catalog-file-protocol.spec.ts | 4 +- .../src/migration/__tests__/migrator.spec.ts | 178 ++++++++++++++++-- packages/cli/src/migration/migrator.ts | 56 +++++- rfcs/migrate-existing-projects.md | 8 +- 20 files changed, 510 insertions(+), 19 deletions(-) create mode 100644 packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/pnpm-workspace.yaml create mode 100644 packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/steps.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/steps.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/pnpm-workspace.yaml create mode 100644 packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/steps.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/env.d.ts create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/pnpm-workspace.yaml create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/steps.json diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/package.json b/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/package.json new file mode 100644 index 0000000000..c1ec7ab36a --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/package.json @@ -0,0 +1,16 @@ +{ + "name": "migration-upgrade-browser-peer-only-pnpm", + "devDependencies": { + "vite-plus": "catalog:" + }, + "peerDependencies": { + "@vitest/browser-playwright": "^4.0.0" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "10.33.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/pnpm-workspace.yaml b/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/pnpm-workspace.yaml new file mode 100644 index 0000000000..d9df99abda --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest +overrides: + vite: 'catalog:' +peerDependencyRules: + allowAny: + - vite + allowedVersions: + vite: '*' diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/snap.txt new file mode 100644 index 0000000000..4c60c8d885 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/snap.txt @@ -0,0 +1,44 @@ +> vp migrate --no-interactive # peer-only browser provider is promoted with its required peers +◇ Migrated . to Vite+ +• Node pnpm +• Package manager settings configured + +> cat package.json # provider, Playwright, and package-local Vitest are installed +{ + "name": "migration-upgrade-browser-peer-only-pnpm", + "devDependencies": { + "vite-plus": "catalog:", + "@vitest/browser-playwright": "", + "playwright": "*", + "vitest": "catalog:" + }, + "peerDependencies": { + "@vitest/browser-playwright": "^4.0.0" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } +} + +> cat pnpm-workspace.yaml # promoted provider keeps shared Vitest management +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest + vitest: +overrides: + vite: 'catalog:' + vitest: 'catalog:' +peerDependencyRules: + allowAny: + - vite + - vitest + allowedVersions: + vite: '*' + vitest: '*' + +> vp migrate --no-interactive # repaired project should no longer be pending +This project is already using Vite+! Happy coding! diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/steps.json new file mode 100644 index 0000000000..0487af5787 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/steps.json @@ -0,0 +1,8 @@ +{ + "commands": [ + "vp migrate --no-interactive # peer-only browser provider is promoted with its required peers", + "cat package.json # provider, Playwright, and package-local Vitest are installed", + "cat pnpm-workspace.yaml # promoted provider keeps shared Vitest management", + "vp migrate --no-interactive # repaired project should no longer be pending" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/package.json b/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/package.json new file mode 100644 index 0000000000..7d344a220d --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/package.json @@ -0,0 +1,19 @@ +{ + "name": "migration-upgrade-nested-vitest-override-npm", + "devDependencies": { + "vite-plus": "latest" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": { + "@vitest/runner": "4.0.0" + } + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "11.16.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/snap.txt new file mode 100644 index 0000000000..e7a9d733d6 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/snap.txt @@ -0,0 +1,27 @@ +> vp migrate --no-interactive # nested Vitest override is user-owned and not pending removal +This project is already using Vite+! Happy coding! + + +> cat package.json # object-valued override is preserved +{ + "name": "migration-upgrade-nested-vitest-override-npm", + "devDependencies": { + "vite-plus": "latest" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": { + "@vitest/runner": "" + } + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "", + "onFail": "download" + } + } +} + +> vp migrate --no-interactive # nested override must not make migration permanently pending +This project is already using Vite+! Happy coding! diff --git a/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/steps.json new file mode 100644 index 0000000000..d97ed7f2e9 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/steps.json @@ -0,0 +1,7 @@ +{ + "commands": [ + "vp migrate --no-interactive # nested Vitest override is user-owned and not pending removal", + "cat package.json # object-valued override is preserved", + "vp migrate --no-interactive # nested override must not make migration permanently pending" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/package.json b/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/package.json new file mode 100644 index 0000000000..86d9d9cbcc --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/package.json @@ -0,0 +1,16 @@ +{ + "name": "migration-upgrade-peer-vitest-catalog-pnpm", + "devDependencies": { + "vite-plus": "catalog:" + }, + "peerDependencies": { + "vitest": "catalog:test" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "10.33.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/pnpm-workspace.yaml b/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/pnpm-workspace.yaml new file mode 100644 index 0000000000..970868c122 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/pnpm-workspace.yaml @@ -0,0 +1,13 @@ +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest +catalogs: + test: + vitest: ^4.0.0 +overrides: + vite: 'catalog:' +peerDependencyRules: + allowAny: + - vite + allowedVersions: + vite: '*' diff --git a/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/snap.txt new file mode 100644 index 0000000000..d7f208b469 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/snap.txt @@ -0,0 +1,39 @@ +> vp migrate --no-interactive # peer catalog must resolve before managed Vitest catalogs are pruned +◇ Migrated . to Vite+ +• Node pnpm +• Package manager settings configured + +> cat package.json # peer uses its resolved public range without gaining direct Vitest +{ + "name": "migration-upgrade-peer-vitest-catalog-pnpm", + "devDependencies": { + "vite-plus": "catalog:" + }, + "peerDependencies": { + "vitest": "^4.0.0" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } +} + +> cat pnpm-workspace.yaml # unreferenced managed Vitest catalog is removed +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest +catalogs: + test: {} +overrides: + vite: 'catalog:' +peerDependencyRules: + allowAny: + - vite + allowedVersions: + vite: '*' + +> vp migrate --no-interactive # repaired project should no longer be pending +This project is already using Vite+! Happy coding! diff --git a/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/steps.json new file mode 100644 index 0000000000..d51f6f4cfc --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/steps.json @@ -0,0 +1,8 @@ +{ + "commands": [ + "vp migrate --no-interactive # peer catalog must resolve before managed Vitest catalogs are pruned", + "cat package.json # peer uses its resolved public range without gaining direct Vitest", + "cat pnpm-workspace.yaml # unreferenced managed Vitest catalog is removed", + "vp migrate --no-interactive # repaired project should no longer be pending" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/env.d.ts b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/env.d.ts new file mode 100644 index 0000000000..e4fafb12fe --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/package.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/package.json new file mode 100644 index 0000000000..057c1fe203 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/package.json @@ -0,0 +1,14 @@ +{ + "name": "migration-upgrade-vitest-reference-whitespace-pnpm", + "devDependencies": { + "vite": "^7.0.0", + "vitest": "^4.0.0" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "10.33.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/pnpm-workspace.yaml b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/pnpm-workspace.yaml new file mode 100644 index 0000000000..d9df99abda --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest +overrides: + vite: 'catalog:' +peerDependencyRules: + allowAny: + - vite + allowedVersions: + vite: '*' diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/snap.txt new file mode 100644 index 0000000000..42017f4cf2 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/snap.txt @@ -0,0 +1,43 @@ +> vp migrate --no-interactive # TypeScript whitespace in a Vitest type directive is valid +◇ Migrated . to Vite+ +• Node pnpm +• 2 config updates applied, 1 file had imports rewritten + +> cat package.json # directive detection keeps package-local Vitest provisioned +{ + "name": "migration-upgrade-vitest-reference-whitespace-pnpm", + "devDependencies": { + "vite": "catalog:", + "vitest": "catalog:", + "vite-plus": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + }, + "scripts": { + "prepare": "vp config" + } +} + +> cat env.d.ts # directive is rewritten to the Vite+ public type surface +/// + +> cat pnpm-workspace.yaml # directive detection keeps shared Vitest management +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest + vitest: +overrides: + vite: 'catalog:' + vitest: 'catalog:' +peerDependencyRules: + allowAny: + - vite + - vitest + allowedVersions: + vite: '*' + vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/steps.json new file mode 100644 index 0000000000..24700d18cc --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/steps.json @@ -0,0 +1,8 @@ +{ + "commands": [ + "vp migrate --no-interactive # TypeScript whitespace in a Vitest type directive is valid", + "cat package.json # directive detection keeps package-local Vitest provisioned", + "cat env.d.ts # directive is rewritten to the Vite+ public type surface", + "cat pnpm-workspace.yaml # directive detection keeps shared Vitest management" + ] +} 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 d04dbce46c..6fdbc3d704 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 @@ -195,7 +195,9 @@ 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'); + // With no catalog resolver available, use a public fallback rather than + // leaking either a dangling catalog reference or the managed file: path. + 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 089ea6c999..88046c389d 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -1741,6 +1741,162 @@ describe('ensureVitePlusBootstrap', () => { expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); }); + it('resolves a Vitest peer catalog before removing its managed catalog entry', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'peer-library', + devDependencies: { 'vite-plus': 'catalog:' }, + peerDependencies: { vitest: 'catalog:test' }, + devEngines: { + packageManager: { name: 'pnpm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'pnpm-workspace.yaml'), + [ + 'catalog:', + ' vite: npm:@voidzero-dev/vite-plus-core@latest', + ' vite-plus: latest', + 'catalogs:', + ' test:', + ' vitest: ^4.0.0', + 'overrides:', + " vite: 'catalog:'", + 'peerDependencyRules:', + ' allowAny: [vite]', + ' allowedVersions:', + " vite: '*'", + '', + ].join('\n'), + ); + + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(true); + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + peerDependencies: Record; + }; + expect(pkg.peerDependencies.vitest).toBe('^4.0.0'); + expect(pkg.devDependencies.vitest).toBeUndefined(); + const workspace = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { + catalogs: Record>; + }; + expect(workspace.catalogs.test.vitest).toBeUndefined(); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); + }); + + it('keeps Vitest managed when promoting a peer-only browser provider', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'browser-library', + devDependencies: { 'vite-plus': 'catalog:' }, + peerDependencies: { '@vitest/browser-playwright': '^4.0.0' }, + devEngines: { + packageManager: { name: 'pnpm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'pnpm-workspace.yaml'), + [ + 'catalog:', + ' vite: npm:@voidzero-dev/vite-plus-core@latest', + ' vite-plus: latest', + 'overrides:', + " vite: 'catalog:'", + 'peerDependencyRules:', + ' allowAny: [vite]', + ' allowedVersions:', + " vite: '*'", + '', + ].join('\n'), + ); + + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + peerDependencies: Record; + }; + expect(pkg.peerDependencies['@vitest/browser-playwright']).toBe('^4.0.0'); + expect(pkg.devDependencies['@vitest/browser-playwright']).toBe(VITEST_VERSION); + expect(pkg.devDependencies.playwright).toBe('*'); + expect(pkg.devDependencies.vitest).toBe('catalog:'); + const workspace = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { + catalog: Record; + overrides: Record; + }; + expect(workspace.catalog.vitest).toBe(VITEST_VERSION); + expect(workspace.overrides.vitest).toBe('catalog:'); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); + }); + + it('recognizes whitespace in retained Vitest triple-slash directives', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'typed-library', + devDependencies: { 'vite-plus': 'catalog:' }, + devEngines: { + packageManager: { name: 'pnpm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + fs.writeFileSync(path.join(tmpDir, 'env.d.ts'), '/// \n'); + fs.writeFileSync( + path.join(tmpDir, 'pnpm-workspace.yaml'), + [ + 'catalog:', + ' vite: npm:@voidzero-dev/vite-plus-core@latest', + ' vite-plus: latest', + 'overrides:', + " vite: 'catalog:'", + 'peerDependencyRules:', + ' allowAny: [vite]', + ' allowedVersions:', + " vite: '*'", + '', + ].join('\n'), + ); + + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + }; + expect(pkg.devDependencies.vitest).toBe('catalog:'); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); + }); + + it('does not remain pending for an object-valued nested Vitest override', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'nested-override', + devDependencies: { 'vite-plus': 'latest' }, + overrides: { + vite: 'npm:@voidzero-dev/vite-plus-core@latest', + vitest: { '@vitest/runner': '4.0.0' }, + }, + devEngines: { + packageManager: { name: 'npm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(false); + const result = ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.npm)); + expect(result.changed).toBe(false); + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + overrides: Record; + }; + expect(pkg.overrides.vitest).toEqual({ '@vitest/runner': '4.0.0' }); + }); + it('removes a stale vitest wrapper override for a common-case npm project (no @vitest/* dep, no vitest source)', () => { // v0.2.1 spec: vite-plus consumes upstream vitest directly, so a project that // does NOT use vitest directly must NOT carry a managed `vitest` override — @@ -2432,9 +2588,10 @@ 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'); - // `vitest` is no longer a managed override key, so the peer entry is left as - // the user wrote it (untouched). - expect(pkg.peerDependencies.vitest).toBe('catalog:'); + // Peer declarations do not keep the managed catalog alive. Resolve the + // catalog entry to its public range before pruning it so the peer cannot + // dangle after migration. + expect(pkg.peerDependencies.vitest).toBe('^4.0.0'); expect(pkg.peerDependencies).not.toHaveProperty('tsdown'); }); @@ -3043,6 +3200,7 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { // no catalog entry is written for it and it must self-resolve. expect(devDeps).toHaveProperty('@vitest/browser-webdriverio', VITEST_VERSION); expect(devDeps.webdriverio).toBe('*'); + expect(devDeps.vitest).toBe('catalog:'); const yaml = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { allowBuilds: Record; @@ -3233,6 +3391,7 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { allowBuilds: Record; }; expect(yaml.catalog['@vitest/browser-webdriverio']).toBe('4.0.0'); + expect(yaml.catalog.vitest).toBe(VITEST_VERSION); expect(yaml.allowBuilds.edgedriver).toBe(true); expect(yaml.allowBuilds.geckodriver).toBe(true); }); @@ -3608,9 +3767,8 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { peerDependencies: Record; }; expect(pkg.peerDependencies.vite).toBe('*'); - // `vitest` is no longer a managed override key (common case: no @vitest/* - // dep, no vitest source), so its peer entry is left as the user wrote it. - expect(pkg.peerDependencies.vitest).toBe('catalog:'); + // Never expose the deleted wrapper alias as a public peer range. + expect(pkg.peerDependencies.vitest).toBe('*'); }); it('adds vitest only to the monorepo package that uses browser mode', () => { @@ -4806,9 +4964,7 @@ describe('rewriteMonorepo yarn catalog', () => { }; expect(pkg.devDependencies.vite).toBe('catalog:vite7'); expect(pkg.peerDependencies.vite).toBe('^7.0.0'); - // `vitest` is no longer managed, so the peer entry is left as the user - // wrote it (untouched). - expect(pkg.peerDependencies.vitest).toBe('catalog:test'); + expect(pkg.peerDependencies.vitest).toBe('^4.0.0'); }); }); @@ -4971,9 +5127,7 @@ describe('rewriteMonorepo bun catalog', () => { expect(pkg.overrides.vitest).toBeUndefined(); expect(pkg.devDependencies.vite).toBe('catalog:build'); expect(pkg.peerDependencies.vite).toBe('^7.0.0'); - // `vitest` is no longer managed, so the peer entry is left as the user - // wrote it (untouched). - expect(pkg.peerDependencies.vitest).toBe('catalog:test'); + 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 56151a529b..79694b4aa9 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -578,6 +578,13 @@ function projectUsesVitestDirectly( ): boolean { return ( projectListsVitestEcosystemDep(pkg) || + // Browser packages declared only as peers still become direct installs: + // rewritePackageJson/reconcileVitePlusBootstrapPackage promote opt-in + // providers into devDependencies and treat the bundled browser packages as + // browser-mode intent. Account for that promotion before shared + // catalog/override ownership is decided, otherwise the promoted provider's + // exact Vitest peer is left unsatisfied under strict pnpm/Yarn layouts. + VITEST_BROWSER_DEP_NAMES.some((name) => pkg.peerDependencies?.[name] !== undefined) || sourceTreeReferencesRetainedVitestModule(projectPath) || usesVitestBrowserMode(projectPath) ); @@ -2866,6 +2873,33 @@ function ensureDirectViteForPnpm( return true; } +// A peer declaration does not install Vitest and therefore must not keep a +// workspace-wide managed Vitest catalog alive. Resolve its catalog reference to +// the public peer range before that catalog is pruned, so the surviving peer +// never points at a missing default/named catalog entry. +function normalizeVitestPeerCatalogSpec( + peerDependencies: Record | undefined, + catalogDependencyResolver?: CatalogDependencyResolver, +): boolean { + if (!peerDependencies) { + return false; + } + const current = peerDependencies.vitest; + if (!current?.startsWith('catalog:')) { + return false; + } + const normalized = getCatalogDependencySpec(current, VITEST_VERSION, true, { + dependencyField: 'peerDependencies', + dependencyName: 'vitest', + catalogDependencyResolver, + }); + if (normalized === current) { + return false; + } + peerDependencies.vitest = normalized; + return true; +} + function isVitePlusOverrideSpec(value: string): boolean { return ( Object.values(VITE_PLUS_OVERRIDE_PACKAGES).includes(value) || @@ -3570,7 +3604,7 @@ function overridesSatisfyVitePlus( ): boolean { // Common case: a lingering managed `vitest` override is NOT satisfied — it // must be removed, so the bootstrap stays pending until it is. - if (!usesVitest && VITEST_IS_MANAGED_OVERRIDE && overrides?.vitest !== undefined) { + if (!usesVitest && VITEST_IS_MANAGED_OVERRIDE && typeof overrides?.vitest === 'string') { return false; } return Object.keys(managedOverridePackages(usesVitest)).every((dependencyName) => @@ -3786,6 +3820,7 @@ function reconcileVitePlusBootstrapPackage( packageManager: PackageManager, supportCatalog: boolean, ensureVitePlus: boolean, + catalogDependencyResolver?: CatalogDependencyResolver, ): boolean { const before = JSON.stringify(pkg); const usesVitest = projectUsesVitestDirectly(projectPath, pkg); @@ -3814,6 +3849,7 @@ function reconcileVitePlusBootstrapPackage( } alignVitestEcosystemPackages(pkg); + normalizeVitestPeerCatalogSpec(pkg.peerDependencies, catalogDependencyResolver); const providerSourceModes = collectProviderSourceModes(projectPath); let usesAnyOptInProvider = false; @@ -3934,6 +3970,7 @@ export function detectVitePlusBootstrapPending( packageManager === PackageManager.yarn || packageManager === PackageManager.bun); const canonicalVitePlusSpec = supportCatalog ? 'catalog:' : VITE_PLUS_VERSION; + const catalogDependencyResolver = createCatalogDependencyResolver(projectPath, packageManager); for (const [index, packagePath] of bootstrapProjectPaths(projectPath, packages).entries()) { const childPackageJsonPath = path.join(packagePath, 'package.json'); if (!fs.existsSync(childPackageJsonPath)) { @@ -3949,6 +3986,7 @@ export function detectVitePlusBootstrapPending( packageManager, supportCatalog, index === 0, + catalogDependencyResolver, ) ) { return true; @@ -4139,6 +4177,10 @@ export function ensureVitePlusBootstrap( workspaceInfo.packageManager === PackageManager.yarn || workspaceInfo.packageManager === PackageManager.bun); const canonicalVitePlusSpec = supportCatalog ? 'catalog:' : VITE_PLUS_VERSION; + const catalogDependencyResolver = createCatalogDependencyResolver( + projectPath, + workspaceInfo.packageManager, + ); editJsonFile< BootstrapPackageJson & { @@ -4154,6 +4196,7 @@ export function ensureVitePlusBootstrap( workspaceInfo.packageManager, supportCatalog, true, + catalogDependencyResolver, ); if (workspaceInfo.packageManager === PackageManager.yarn) { @@ -4219,6 +4262,7 @@ export function ensureVitePlusBootstrap( workspaceInfo.packageManager, supportCatalog, false, + catalogDependencyResolver, ); return childChanged ? pkg : undefined; }); @@ -4234,6 +4278,7 @@ export function ensureVitePlusBootstrap( : undefined; const catalogDependencyResolver = readPnpmWorkspaceCatalogDependencyResolver(projectPath); if ( + result.packageJson || defaultCatalogVitePlusDependencyPending(pkg, catalogDependencyResolver) || !overridesSatisfyVitePlus( readPnpmWorkspaceOverrides(projectPath), @@ -4497,7 +4542,7 @@ function sourceTreeReferencesRetainedVitestModule(projectPath: string): boolean return sourceTreeMatches(projectPath, (content) => { return ( /\bdeclare\s+module\s+['"]vitest(?:\/[^'"]*)?['"]/.test(content) || - /` alias. | -| `vitest` itself (default) | Provided by `vite-plus`, so by default not project-managed: remove any project-level `vitest` from dependency fields, `overrides`/`resolutions`/`pnpm.overrides`, `pnpm-workspace.yaml` `overrides`+`catalog(s)`, bun/yarn catalog, and the `vitest` entry in pnpm `peerDependencyRules`. A future `vp update vite-plus` then keeps it correct with no project pin to drift. | +| `vitest` itself (default) | Provided by `vite-plus`, so by default not project-managed: remove any project-level `vitest` from dependency fields, string-valued `overrides`/`resolutions`/`pnpm.overrides`, `pnpm-workspace.yaml` `overrides`+`catalog(s)`, bun/yarn catalog, and the `vitest` entry in pnpm `peerDependencyRules`. Resolve a surviving `peerDependencies.vitest` catalog reference to its public range before pruning the catalog. A future `vp update vite-plus` then keeps it correct with no project pin to drift. | | `vitest`, peer/browser exception | Keep a managed `vitest` in the package that needs it (add to `devDependencies` and pin/override it to the bundled version) when that package directly installs a required-`vitest` peer consumer, uses browser mode, or retains a direct upstream `vitest` module reference. This includes official packages with exact peers (`@vitest/ui`, coverage providers, browser providers) and third-party integrations with range peers (`vitest-browser-react` / `-vue` / `-svelte`, ...). The direct dependency satisfies strict peer resolution; the shared override collapses the workspace to the bundled version. | | `vitest` ecosystem packages | Align every official `@vitest/*` package the project lists (`@vitest/coverage-v8`, `@vitest/coverage-istanbul`, `@vitest/ui`, `@vitest/web-worker`, ...) to the bundled `VITEST_VERSION`. Exclude `@vitest/eslint-plugin` (separate version line, optional `vitest: *` peer); it neither triggers a `vitest` install nor a shared override. Browser packages keep their dedicated handling: `@vitest/browser` / `-preview` are bundled by `vite-plus`; `@vitest/browser-playwright` / `-webdriverio` are opt-in (pinned + framework peer kept). | | Workspaces | Reconcile every package manifest, not only the root. Localize the direct `vitest` dependency to packages that need it; keep shared catalogs/overrides only when at least one package needs them. Re-pin existing plain `vite-plus` ranges consistently while preserving deliberate protocol specs. | @@ -36,7 +36,7 @@ Removing the old direct dependency was exercised on `node-modules/urllib` across | pnpm config location | An empty `"pnpm": {}` with an existing `pnpm-workspace.yaml` reconciles the workspace file (instead of writing a second, conflicting override block into `package.json`). | | Reinstall + verify | One reinstall with lockfile refresh (`--no-frozen-lockfile` / `--force`); before npm reinstalls, remove a stale real-`vite` install/lock entry that npm otherwise retains after the dependency becomes the Vite+ core alias. A failed install warns and sets a non-zero exit. | -Force-override/CI mode (`VP_OVERRIDE_PACKAGES`) is respected: when `vitest` is not a managed key there, the project's own `vitest` is never stripped. +Force-override/CI mode (`VP_OVERRIDE_PACKAGES`) is respected: when `vitest` is not a managed key there, the project's own `vitest` is never stripped. Object-valued nested npm/Bun overrides are user-owned scopes rather than managed version pins and are preserved. **Pending verification:** vitest **browser mode** historically needed a direct `vitest` injected (the "vibe-dashboard" regression). The upgrade now restores the opt-in provider and framework peer and keeps the package-local `vitest`; retain that behavior until a urllib-style pnpm/npm/yarn check proves any part is redundant. @@ -83,6 +83,10 @@ Covered by unit tests in `migrator.spec.ts` (vitest removal, required-peer provi | Deliberate protocol-pinned `vite-plus` spec | `migration-upgrade-vite-plus-protocol-pin-npm` | | Idempotent rerun on an already-current project | `migration-from-tsdown`, `migration-from-tsdown-json-config` | | Reinstall and lockfile refresh after the alias rewrite | `migration-standalone-npm` | +| Peer `vitest` catalog references resolve before managed catalog pruning | `migration-upgrade-peer-vitest-catalog-pnpm` | +| Peer-only browser providers are promoted with direct and shared Vitest | `migration-upgrade-browser-peer-only-pnpm` | +| Whitespace-tolerant Vitest type-directive detection | `migration-upgrade-vitest-reference-whitespace-pnpm` | +| Object-valued nested Vitest overrides remain user-owned and idempotent | `migration-upgrade-nested-vitest-override-npm` | ## Follow-ups (not in this change) From 04c57a69d5c7ff3b70b106668200fb17d03ebfb4 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 21 Jun 2026 20:51:26 +0800 Subject: [PATCH 12/78] fix(migrate): cover remaining vitest upgrade cases --- .../example.spec.ts | 3 + .../package.json | 11 ++ .../snap.txt | 38 ++++ .../steps.json | 9 + .../package.json | 19 ++ .../snap.txt | 25 +++ .../steps.json | 6 + .../.fixture/vite-plugin-gherkin/index.js | 1 + .../.fixture/vite-plugin-gherkin/package.json | 10 + .../package.json | 19 ++ .../snap.txt | 29 +++ .../steps.json | 8 + .../snap.txt | 12 +- .../steps.json | 5 +- .../package.json | 18 ++ .../snap.txt | 39 ++++ .../steps.json | 9 + .../tsconfig.json | 5 + .../version.ts | 3 + .../package.json | 3 +- .../snap.txt | 9 +- .../steps.json | 6 +- .../bun-catalog-file-protocol.spec.ts | 15 ++ .../src/migration/__tests__/migrator.spec.ts | 180 ++++++++++++++++-- packages/cli/src/migration/migrator.ts | 139 +++++++++++--- packages/cli/src/utils/package.ts | 91 ++++++++- packages/cli/src/utils/tsconfig.ts | 21 ++ rfcs/migrate-existing-projects.md | 36 ++-- 28 files changed, 693 insertions(+), 76 deletions(-) create mode 100644 packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/example.spec.ts create mode 100644 packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/package.json create mode 100644 packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/steps.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/steps.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/.fixture/vite-plugin-gherkin/index.js create mode 100644 packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/.fixture/vite-plugin-gherkin/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/steps.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/steps.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/tsconfig.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/version.ts diff --git a/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/example.spec.ts b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/example.spec.ts new file mode 100644 index 0000000000..fbd3232594 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/example.spec.ts @@ -0,0 +1,3 @@ +import { expect, it } from 'vitest'; + +it('works', () => expect(true).toBe(true)); diff --git a/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/package.json b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/package.json new file mode 100644 index 0000000000..08ef8b2b7d --- /dev/null +++ b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/package.json @@ -0,0 +1,11 @@ +{ + "name": "migration-standalone-yarn4-idempotent", + "scripts": { + "test": "vitest run" + }, + "devDependencies": { + "vite": "^7.0.0", + "vitest": "^4.0.0" + }, + "packageManager": "yarn@4.12.0" +} diff --git a/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/snap.txt b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/snap.txt new file mode 100644 index 0000000000..48f7e4d4c8 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/snap.txt @@ -0,0 +1,38 @@ +> vp migrate --no-interactive # standalone Yarn writes catalog specs on the first pass +◇ Migrated . to Vite+ +• Node yarn +• 2 config updates applied, 1 file had imports rewritten + +> cat package.json # migrated dependency specs use the Yarn catalog immediately +{ + "name": "migration-standalone-yarn4-idempotent", + "scripts": { + "test": "vp test run", + "prepare": "vp config" + }, + "devDependencies": { + "vite": "catalog:", + "vite-plus": "catalog:" + }, + "packageManager": "yarn@", + "resolutions": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest" + } +} + +> cat .yarnrc.yml # managed catalog entries are available to those specs +nodeLinker: node-modules +npmPreapprovedPackages: + - vitest + - '@vitest/*' +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest + +> cat example.spec.ts # ordinary Vitest imports use the Vite+ public surface +import { expect, it } from 'vite-plus/test'; + +it('works', () => expect(true).toBe(true)); + +> vp migrate --no-interactive # a freshly migrated standalone Yarn project is complete +This project is already using Vite+! Happy coding! diff --git a/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/steps.json b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/steps.json new file mode 100644 index 0000000000..2462490ad8 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/steps.json @@ -0,0 +1,9 @@ +{ + "commands": [ + "vp migrate --no-interactive # standalone Yarn writes catalog specs on the first pass", + "cat package.json # migrated dependency specs use the Yarn catalog immediately", + "cat .yarnrc.yml # managed catalog entries are available to those specs", + "cat example.spec.ts # ordinary Vitest imports use the Vite+ public surface", + "vp migrate --no-interactive # a freshly migrated standalone Yarn project is complete" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/package.json b/packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/package.json new file mode 100644 index 0000000000..971be76cb9 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/package.json @@ -0,0 +1,19 @@ +{ + "name": "migration-upgrade-deprecated-coverage-c8-npm", + "devDependencies": { + "@vitest/coverage-c8": "^0.33.0", + "vite-plus": "latest", + "vitest": "4.1.8" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "4.1.8" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "11.16.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/snap.txt new file mode 100644 index 0000000000..5d1d0d9b1c --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/snap.txt @@ -0,0 +1,25 @@ +> vp migrate --no-interactive # deprecated coverage-c8 has an independent version line +◇ Migrated . to Vite+ +• Node npm +• Package manager settings configured + +> cat package.json # coverage-c8 must not be rewritten to a nonexistent Vitest 4 version +{ + "name": "migration-upgrade-deprecated-coverage-c8-npm", + "devDependencies": { + "@vitest/coverage-c8": "^0.33.0", + "vite-plus": "latest", + "vitest": "" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/steps.json new file mode 100644 index 0000000000..86c4696b8d --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/steps.json @@ -0,0 +1,6 @@ +{ + "commands": [ + "vp migrate --no-interactive # deprecated coverage-c8 has an independent version line", + "cat package.json # coverage-c8 must not be rewritten to a nonexistent Vitest 4 version" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/.fixture/vite-plugin-gherkin/index.js b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/.fixture/vite-plugin-gherkin/index.js new file mode 100644 index 0000000000..f053ebf797 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/.fixture/vite-plugin-gherkin/index.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/.fixture/vite-plugin-gherkin/package.json b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/.fixture/vite-plugin-gherkin/package.json new file mode 100644 index 0000000000..53dde2cc8c --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/.fixture/vite-plugin-gherkin/package.json @@ -0,0 +1,10 @@ +{ + "name": "vite-plugin-gherkin", + "version": "0.2.0", + "exports": { + ".": "./index.js" + }, + "peerDependencies": { + "vitest": "^4.1.0" + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/package.json b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/package.json new file mode 100644 index 0000000000..391a849187 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/package.json @@ -0,0 +1,19 @@ +{ + "name": "migration-upgrade-required-vitest-peer-metadata-npm", + "devDependencies": { + "vite-plugin-gherkin": "0.2.0", + "vite-plus": "latest", + "vitest": "4.1.8" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "4.1.8" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "11.16.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/snap.txt new file mode 100644 index 0000000000..eebc1c025c --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/snap.txt @@ -0,0 +1,29 @@ +> node -e "const fs = require('node:fs'); fs.mkdirSync('node_modules', { recursive: true }); fs.cpSync('.fixture/vite-plugin-gherkin', 'node_modules/vite-plugin-gherkin', { recursive: true })" # simulate installed dependency metadata +> vp migrate --no-interactive # required Vitest peer is detected without a Vitest package name +◇ Migrated . to Vite+ +• Node npm +• Package manager settings configured + +> cat package.json # package-local Vitest and its shared override remain aligned +{ + "name": "migration-upgrade-required-vitest-peer-metadata-npm", + "devDependencies": { + "vite-plugin-gherkin": "0.2.0", + "vite-plus": "latest", + "vitest": "" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "", + "onFail": "download" + } + } +} + +> vp migrate --no-interactive # metadata-based peer provisioning is stable on rerun +This project is already using Vite+! Happy coding! diff --git a/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/steps.json new file mode 100644 index 0000000000..738904c5e0 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/steps.json @@ -0,0 +1,8 @@ +{ + "commands": [ + "node -e \"const fs = require('node:fs'); fs.mkdirSync('node_modules', { recursive: true }); fs.cpSync('.fixture/vite-plugin-gherkin', 'node_modules/vite-plugin-gherkin', { recursive: true })\" # simulate installed dependency metadata", + "vp migrate --no-interactive # required Vitest peer is detected without a Vitest package name", + "cat package.json # package-local Vitest and its shared override remain aligned", + "vp migrate --no-interactive # metadata-based peer provisioning is stable on rerun" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/snap.txt index 42017f4cf2..a51e388a52 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/snap.txt @@ -3,12 +3,11 @@ • Node pnpm • 2 config updates applied, 1 file had imports rewritten -> cat package.json # directive detection keeps package-local Vitest provisioned +> cat package.json # rewritten directive does not retain a redundant Vitest dependency { "name": "migration-upgrade-vitest-reference-whitespace-pnpm", "devDependencies": { "vite": "catalog:", - "vitest": "catalog:", "vite-plus": "catalog:" }, "devEngines": { @@ -26,18 +25,17 @@ > cat env.d.ts # directive is rewritten to the Vite+ public type surface /// -> cat pnpm-workspace.yaml # directive detection keeps shared Vitest management +> cat pnpm-workspace.yaml # rewritten directive does not retain shared Vitest management catalog: vite: npm:@voidzero-dev/vite-plus-core@latest vite-plus: latest - vitest: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' + +> vp migrate --no-interactive # directive rewriting is stable on rerun +This project is already using Vite+! Happy coding! diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/steps.json index 24700d18cc..188941dff5 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/steps.json +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/steps.json @@ -1,8 +1,9 @@ { "commands": [ "vp migrate --no-interactive # TypeScript whitespace in a Vitest type directive is valid", - "cat package.json # directive detection keeps package-local Vitest provisioned", + "cat package.json # rewritten directive does not retain a redundant Vitest dependency", "cat env.d.ts # directive is rewritten to the Vite+ public type surface", - "cat pnpm-workspace.yaml # directive detection keeps shared Vitest management" + "cat pnpm-workspace.yaml # rewritten directive does not retain shared Vitest management", + "vp migrate --no-interactive # directive rewriting is stable on rerun" ] } diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/package.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/package.json new file mode 100644 index 0000000000..26701f311e --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/package.json @@ -0,0 +1,18 @@ +{ + "name": "migration-upgrade-vitest-retained-references-npm", + "devDependencies": { + "vite-plus": "latest", + "vitest": "4.1.8" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "4.1.8" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "11.16.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/snap.txt new file mode 100644 index 0000000000..cce99f7a35 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/snap.txt @@ -0,0 +1,39 @@ +> vp migrate --no-interactive # retained upstream references require package-local Vitest +◇ Migrated . to Vite+ +• Node npm +• Package manager settings configured + +> cat package.json # Vitest dependency and override stay aligned +{ + "name": "migration-upgrade-vitest-retained-references-npm", + "devDependencies": { + "vite-plus": "latest", + "vitest": "" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "", + "onFail": "download" + } + } +} + +> cat tsconfig.json # compilerOptions.types remains an upstream Vitest reference +{ + "compilerOptions": { + "types": ["vitest/globals"] + } +} + +> cat version.ts # vitest/package.json remains intentionally unre-written +import metadata from 'vitest/package.json'; + +console.log(metadata.version); + +> vp migrate --no-interactive # retained references remain stable on rerun +This project is already using Vite+! Happy coding! diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/steps.json new file mode 100644 index 0000000000..2a598938ba --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/steps.json @@ -0,0 +1,9 @@ +{ + "commands": [ + "vp migrate --no-interactive # retained upstream references require package-local Vitest", + "cat package.json # Vitest dependency and override stay aligned", + "cat tsconfig.json # compilerOptions.types remains an upstream Vitest reference", + "cat version.ts # vitest/package.json remains intentionally unre-written", + "vp migrate --no-interactive # retained references remain stable on rerun" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/tsconfig.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/tsconfig.json new file mode 100644 index 0000000000..aa0a8c0310 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "types": ["vitest/globals"] + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/version.ts b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/version.ts new file mode 100644 index 0000000000..3b2e0f0e80 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/version.ts @@ -0,0 +1,3 @@ +import metadata from 'vitest/package.json'; + +console.log(metadata.version); diff --git a/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/package.json b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/package.json index 6fc60c5d10..184290e34f 100644 --- a/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/package.json +++ b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/package.json @@ -4,7 +4,8 @@ "test": "vitest" }, "devDependencies": { + "@vitest/ui": "4.0.13", "vite": "^7.0.0", - "vitest": "^4.0.0" + "vitest": "4.0.13" } } diff --git a/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/snap.txt b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/snap.txt index bd5f121f6b..ad04a2be32 100644 --- a/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/snap.txt +++ b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/snap.txt @@ -3,7 +3,7 @@ • Node pnpm • 2 config updates applied -> cat package.json # user's vitest dependency should be preserved +> cat package.json # user's Vitest and exact-peer UI versions should both be preserved { "name": "migration-vitest-unmanaged-override", "scripts": { @@ -11,8 +11,9 @@ "prepare": "vp config" }, "devDependencies": { + "@vitest/ui": "", "vite": "catalog:", - "vitest": "^4.0.0", + "vitest": "", "vite-plus": "catalog:" }, "devEngines": { @@ -24,6 +25,7 @@ } } +> node -e "const pkg = require('./package.json'); if (pkg.devDependencies.vitest !== '4.0.13' || pkg.devDependencies['@vitest/ui'] !== '4.0.13') process.exit(1)" # exact user-owned versions remain unchanged > cat pnpm-workspace.yaml # no vitest catalog or override should be introduced catalog: vite: npm:@voidzero-dev/vite-plus-core@latest @@ -35,3 +37,6 @@ peerDependencyRules: - vite allowedVersions: vite: '*' + +> vp migrate --no-interactive # unmanaged Vitest ecosystem versions remain stable on rerun +This project is already using Vite+! Happy coding! diff --git a/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/steps.json b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/steps.json index 86631201d7..767e603b45 100644 --- a/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/steps.json +++ b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/steps.json @@ -4,7 +4,9 @@ }, "commands": [ "vp migrate --no-interactive # vitest omitted from managed overrides must remain user-owned", - "cat package.json # user's vitest dependency should be preserved", - "cat pnpm-workspace.yaml # no vitest catalog or override should be introduced" + "cat package.json # user's Vitest and exact-peer UI versions should both be preserved", + "node -e \"const pkg = require('./package.json'); if (pkg.devDependencies.vitest !== '4.0.13' || pkg.devDependencies['@vitest/ui'] !== '4.0.13') process.exit(1)\" # exact user-owned versions remain unchanged", + "cat pnpm-workspace.yaml # no vitest catalog or override should be introduced", + "vp migrate --no-interactive # unmanaged Vitest ecosystem versions remain stable on rerun" ] } 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 6fdbc3d704..0594907345 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 @@ -205,4 +205,19 @@ describe('rewriteMonorepo bun catalog with file: protocol', () => { (pkg as { devDependencies?: Record }).devDependencies?.['vite-plus'], ).toBe('file:/tmp/tgz/vite-plus-0.0.0.tgz'); }); + + it('does not align Vitest ecosystem packages when Vitest is unmanaged', () => { + const pkg = { + devDependencies: { + vite: '^7.0.0', + vitest: '4.0.13', + '@vitest/ui': '4.0.13', + }, + }; + + rewritePackageJson(pkg, PackageManager.npm); + + expect(pkg.devDependencies.vitest).toBe('4.0.13'); + expect(pkg.devDependencies['@vitest/ui']).toBe('4.0.13'); + }); }); diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 88046c389d..76249d30bc 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -1587,6 +1587,130 @@ describe('ensureVitePlusBootstrap', () => { expect(pkg.devDependencies.vitest).toBe(VITEST_VERSION); }); + it('does not align deprecated @vitest/coverage-c8 to a nonexistent Vitest 4 version', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { + 'vite-plus': 'latest', + '@vitest/coverage-c8': '^0.33.0', + }, + overrides: { + vite: 'npm:@voidzero-dev/vite-plus-core@latest', + }, + devEngines: { + packageManager: { name: 'npm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.npm)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + }; + expect(pkg.devDependencies['@vitest/coverage-c8']).toBe('^0.33.0'); + expect(pkg.devDependencies.vitest).toBe(VITEST_VERSION); + }); + + it('detects a required Vitest peer from Yarn PnP dependency metadata', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { + 'vite-plus': 'latest', + 'vite-plugin-gherkin': '0.2.0', + }, + resolutions: { + vite: 'npm:@voidzero-dev/vite-plus-core@latest', + }, + devEngines: { + packageManager: { name: 'yarn', version: '4.12.0', onFail: 'download' }, + }, + }), + ); + const pluginDir = path.join(tmpDir, '.yarn/cache/vite-plugin-gherkin'); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, 'package.json'), + JSON.stringify({ + name: 'vite-plugin-gherkin', + version: '0.2.0', + exports: { '.': './index.js' }, + peerDependencies: { vitest: '^4.1.0' }, + }), + ); + fs.writeFileSync(path.join(pluginDir, 'index.js'), 'module.exports = {};\n'); + fs.writeFileSync( + path.join(tmpDir, '.pnp.cjs'), + [ + "const path = require('node:path');", + 'module.exports = {', + ' resolveToUnqualified(request) {', + " if (request !== 'vite-plugin-gherkin') throw new Error('not found');", + " return path.join(__dirname, '.yarn/cache/vite-plugin-gherkin');", + ' },', + '};', + '', + ].join('\n'), + ); + fs.writeFileSync(path.join(tmpDir, '.yarnrc.yml'), 'nodeLinker: pnp\n'); + + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.yarn)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + resolutions: Record; + }; + expect(pkg.devDependencies.vitest).toBe('catalog:'); + expect(pkg.resolutions.vitest).toBe(VITEST_VERSION); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.yarn)).toBe(false); + }); + + it.each([ + { + name: 'compilerOptions.types', + writeReference: (projectPath: string) => + fs.writeFileSync( + path.join(projectPath, 'tsconfig.json'), + JSON.stringify({ compilerOptions: { types: ['vitest/globals'] } }), + ), + }, + { + name: 'vitest/package.json', + writeReference: (projectPath: string) => + fs.writeFileSync( + path.join(projectPath, 'version.ts'), + "import metadata from 'vitest/package.json';\nconsole.log(metadata.version);\n", + ), + }, + ])('keeps package-local Vitest for retained $name references', ({ writeReference }) => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { 'vite-plus': 'latest' }, + overrides: { vite: 'npm:@voidzero-dev/vite-plus-core@latest' }, + devEngines: { + packageManager: { name: 'npm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + writeReference(tmpDir); + + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.npm)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + overrides: Record; + }; + expect(pkg.devDependencies.vitest).toBe(VITEST_VERSION); + expect(pkg.overrides.vitest).toBe(VITEST_VERSION); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(false); + }); + it('does not treat @vitest/eslint-plugin as runner usage', () => { fs.writeFileSync( path.join(tmpDir, 'package.json'), @@ -1835,15 +1959,12 @@ describe('ensureVitePlusBootstrap', () => { expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); }); - it('recognizes whitespace in retained Vitest triple-slash directives', () => { + it('rewrites whitespace-tolerant Vitest directives without leaving rerun mutations', () => { fs.writeFileSync( path.join(tmpDir, 'package.json'), JSON.stringify({ name: 'typed-library', - devDependencies: { 'vite-plus': 'catalog:' }, - devEngines: { - packageManager: { name: 'pnpm', version: '10.33.0', onFail: 'download' }, - }, + devDependencies: { vite: '^7.0.0', vitest: '^4.0.0' }, }), ); fs.writeFileSync(path.join(tmpDir, 'env.d.ts'), '/// \n'); @@ -1863,13 +1984,21 @@ describe('ensureVitePlusBootstrap', () => { ].join('\n'), ); - ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); + const workspaceInfo = makeWorkspaceInfo(tmpDir, PackageManager.pnpm); + rewriteStandaloneProject(tmpDir, workspaceInfo, true, true); - const pkg = readJson(path.join(tmpDir, 'package.json')) as { - devDependencies: Record; - }; - expect(pkg.devDependencies.vitest).toBe('catalog:'); - expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); + const firstPackageJson = fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf8'); + const firstWorkspace = fs.readFileSync(path.join(tmpDir, 'pnpm-workspace.yaml'), 'utf8'); + const firstDirective = fs.readFileSync(path.join(tmpDir, 'env.d.ts'), 'utf8'); + + expect(firstPackageJson).not.toContain('"vitest"'); + expect(firstWorkspace).not.toContain('vitest:'); + expect(firstDirective).toContain('types = "vite-plus/test"'); + + rewriteStandaloneProject(tmpDir, workspaceInfo, true, true); + expect(fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf8')).toBe(firstPackageJson); + expect(fs.readFileSync(path.join(tmpDir, 'pnpm-workspace.yaml'), 'utf8')).toBe(firstWorkspace); + expect(fs.readFileSync(path.join(tmpDir, 'env.d.ts'), 'utf8')).toBe(firstDirective); }); it('does not remain pending for an object-valued nested Vitest override', () => { @@ -2382,6 +2511,35 @@ describe('ensureVitePlusBootstrap', () => { }; expect(workspace.packages).toEqual(['packages/*']); }); + + it('writes catalog specs during the first standalone Yarn migration', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { vite: '^7.0.0', vitest: '^4.0.0' }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'example.spec.ts'), + "import { expect, it } from 'vitest';\nit('works', () => expect(true).toBe(true));\n", + ); + const workspaceInfo = makeWorkspaceInfo(tmpDir, PackageManager.yarn); + + rewriteStandaloneProject(tmpDir, workspaceInfo, true, true); + + const firstPackageJson = fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf8'); + const firstYarnrc = fs.readFileSync(path.join(tmpDir, '.yarnrc.yml'), 'utf8'); + const pkg = JSON.parse(firstPackageJson) as { devDependencies: Record }; + expect(pkg.devDependencies.vite).toBe('catalog:'); + expect(pkg.devDependencies['vite-plus']).toBe('catalog:'); + expect(pkg.devDependencies.vitest).toBeUndefined(); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.yarn)).toBe(false); + + rewriteStandaloneProject(tmpDir, workspaceInfo, true, true); + expect(fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf8')).toBe(firstPackageJson); + expect(fs.readFileSync(path.join(tmpDir, '.yarnrc.yml'), 'utf8')).toBe(firstYarnrc); + }); }); describe('rewriteStandaloneProject pnpm workspace yaml', () => { diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 79694b4aa9..e90cf304ee 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -44,6 +44,7 @@ import { findTsconfigFiles, hasBaseUrlInTsconfig, hasTypesToRewriteInTsconfig, + hasVitestTypesInTsconfig, removeDeprecatedTsconfigFalseOption, rewriteTypesInTsconfig, } from '../utils/tsconfig.ts'; @@ -111,15 +112,22 @@ const OPT_IN_BROWSER_PROVIDERS = [WEBDRIVERIO_PROVIDER, PLAYWRIGHT_PROVIDER] as // family, and the runtime internals all pin `vitest: `), so any the // project lists must match the bundled vitest or Vitest runs mixed copies (the // `define-config.ts` coverage guard fail-fasts on exactly this skew). -// `@vitest/eslint-plugin` is the exception: it versions on its own line with a -// `vitest: *` peer, so it must NOT be pinned to the vitest version. -const VITEST_ALIGN_EXCLUDED = new Set(['@vitest/eslint-plugin']); +// `@vitest/eslint-plugin` versions on its own line, and deprecated +// `@vitest/coverage-c8` never published on the Vitest 4 line, so neither may be +// pinned to the bundled Vitest version. +const VITEST_ALIGN_EXCLUDED = new Set([ + '@vitest/eslint-plugin', + // Deprecated at 0.33.0 and replaced by @vitest/coverage-v8. It does not + // publish versions on Vitest's current release line, so pinning it to the + // bundled Vitest version creates a dependency spec that does not exist. + '@vitest/coverage-c8', +]); // Official packages that do not declare a required `vitest` peer. Keep them // aligned when a project lists them directly, but do not add a direct vitest // merely because they are present. const VITEST_DIRECT_USAGE_EXCLUDED = new Set([ - ...VITEST_ALIGN_EXCLUDED, + '@vitest/eslint-plugin', '@vitest/expect', '@vitest/mocker', '@vitest/pretty-format', @@ -561,6 +569,49 @@ function projectListsVitestEcosystemDep(pkg: { ); } +// Detect installed dependencies whose package metadata declares a required +// Vitest peer. Package names are not authoritative: integrations such as +// `vite-plugin-gherkin` require Vitest without containing "vitest" in their +// own name. Optional peers do not require package-local provisioning. +function projectListsRequiredVitestPeer( + projectPath: string, + pkg: { + dependencies?: Record; + devDependencies?: Record; + optionalDependencies?: Record; + }, +): boolean { + const dependencyNames = new Set([ + ...Object.keys(pkg.dependencies ?? {}), + ...Object.keys(pkg.devDependencies ?? {}), + ...Object.keys(pkg.optionalDependencies ?? {}), + ]); + dependencyNames.delete('vitest'); + + for (const name of dependencyNames) { + const metadata = detectPackageMetadata(projectPath, name); + if (!metadata) { + continue; + } + try { + const installedPkg = readJsonFile(path.join(metadata.path, 'package.json')) as { + peerDependencies?: Record; + peerDependenciesMeta?: Record; + }; + if ( + typeof installedPkg.peerDependencies?.vitest === 'string' && + installedPkg.peerDependenciesMeta?.vitest?.optional !== true + ) { + return true; + } + } catch { + // Missing or unreadable installed metadata cannot provide a peer signal; + // retain the existing package-name and source-based fallbacks below. + } + } + return false; +} + // True iff the project uses vitest DIRECTLY — via a dependency that is expected // to have a required vitest peer (see `projectListsVitestEcosystemDep`), an // upstream `vitest` module specifier, or vitest browser mode. Drives @@ -575,9 +626,11 @@ function projectUsesVitestDirectly( devDependencies?: Record; peerDependencies?: Record; }, + requiredVitestPeer = projectListsRequiredVitestPeer(projectPath, pkg), ): boolean { return ( projectListsVitestEcosystemDep(pkg) || + requiredVitestPeer || // Browser packages declared only as peers still become direct installs: // rewritePackageJson/reconcileVitePlusBootstrapPackage promote opt-in // providers into devDependencies and treat the bundled browser packages as @@ -1681,7 +1734,8 @@ export function rewriteStandaloneProject( }>(packageJsonPath, (pkg) => { shouldAllowBrowserProviderBuilds = hasOwnWebdriverioDependency(pkg) || usesWebdriverioProvider(projectPath); - usesVitest = projectUsesVitestDirectly(projectPath, pkg); + const requiredVitestPeer = projectListsRequiredVitestPeer(projectPath, pkg); + usesVitest = projectUsesVitestDirectly(projectPath, pkg, requiredVitestPeer); const managed = managedOverridePackages(usesVitest); // Strip stale `vite-plus-test` wrapper aliases before injecting new overrides // so the deleted wrapper doesn't survive migration in any sink. @@ -1796,24 +1850,24 @@ export function rewriteStandaloneProject( } } + const supportCatalog = usePnpmWorkspaceYaml || packageManager === PackageManager.yarn; extractedStagedConfig = rewritePackageJson( pkg, packageManager, - usePnpmWorkspaceYaml, + supportCatalog, skipStagedMigration, catalogDependencyResolver, usesVitestBrowserMode(projectPath), collectProviderSourceModes(projectPath), usesVitest, sourceTreeReferencesRetainedVitestModule(projectPath), + requiredVitestPeer, ); // ensure vite-plus is in devDependencies if (!pkg.devDependencies?.[VITE_PLUS_NAME] || isForceOverrideMode()) { const version = - usePnpmWorkspaceYaml && !VITE_PLUS_VERSION.startsWith('file:') - ? 'catalog:' - : VITE_PLUS_VERSION; + supportCatalog && !VITE_PLUS_VERSION.startsWith('file:') ? 'catalog:' : VITE_PLUS_VERSION; pkg.devDependencies = { ...pkg.devDependencies, [VITE_PLUS_NAME]: version, @@ -2027,6 +2081,7 @@ export function rewriteMonorepoProject( scripts?: Record; installConfig?: { hoistingLimits?: string }; }>(packageJsonPath, (pkg) => { + const requiredVitestPeer = projectListsRequiredVitestPeer(projectPath, pkg); // rewrite scripts in package.json extractedStagedConfig = rewritePackageJson( pkg, @@ -2036,8 +2091,9 @@ export function rewriteMonorepoProject( catalogDependencyResolver, usesVitestBrowserMode(projectPath), collectProviderSourceModes(projectPath), - projectUsesVitestDirectly(projectPath, pkg), + projectUsesVitestDirectly(projectPath, pkg, requiredVitestPeer), sourceTreeReferencesRetainedVitestModule(projectPath), + requiredVitestPeer, ); // If this SUB-workspace now depends on `vite-plus` and Yarn isolates its // hoisting (via the root `nmHoistingLimits` OR the workspace's own @@ -3791,6 +3847,9 @@ function pnpmConfigLivesInPackageJson(pkg: BootstrapPackageJson, projectPath: st // vitest version. Returns true if any spec changed. These are plain dependency // entries (not overrides), so this is package-manager agnostic. function alignVitestEcosystemPackages(pkg: BootstrapPackageJson): boolean { + if (!VITEST_IS_MANAGED_OVERRIDE) { + return false; + } const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; let changed = false; for (const dependencies of dependencyGroups) { @@ -3865,7 +3924,9 @@ function reconcileVitePlusBootstrapPackage( (dependencies) => dependencies?.[provider] !== undefined, ); if (installGroup) { - installGroup[provider] = VITEST_VERSION; + if (VITEST_IS_MANAGED_OVERRIDE) { + installGroup[provider] = VITEST_VERSION; + } } else { pkg.devDependencies ??= {}; pkg.devDependencies[provider] = VITEST_VERSION; @@ -3907,11 +3968,13 @@ function reconcileVitePlusBootstrapPackage( // the same exact version as the Vite+ runner. const existingGroup = installGroups.find((dependencies) => dependencies?.vitest !== undefined); if (existingGroup) { - existingGroup.vitest = getCatalogDependencySpec( - existingGroup.vitest, - VITEST_VERSION, - supportCatalog, - ); + if (VITEST_IS_MANAGED_OVERRIDE) { + existingGroup.vitest = getCatalogDependencySpec( + existingGroup.vitest, + VITEST_VERSION, + supportCatalog, + ); + } } else { pkg.devDependencies ??= {}; pkg.devDependencies.vitest = getCatalogDependencySpec( @@ -4534,17 +4597,21 @@ function sourceTreeReferencesAny(projectPath: string, hints: readonly string[]): return sourceTreeMatches(projectPath, (content) => hints.some((hint) => content.includes(hint))); } -// Normal imports from `vitest` are rewritten to `vite-plus/test` later in the -// same migration and therefore do not justify a lasting direct dependency. -// Module augmentations and triple-slash type references deliberately retain the -// upstream module identity, so keep vitest package-local for those surfaces. +// Normal imports and triple-slash type directives from `vitest` are rewritten +// to `vite-plus/test` later in the same migration and therefore do not justify +// a lasting direct dependency. Module augmentations, `vitest/package.json`, and +// compilerOptions.types entries deliberately retain the upstream package +// identity, so keep Vitest package-local for those surfaces. function sourceTreeReferencesRetainedVitestModule(projectPath: string): boolean { - return sourceTreeMatches(projectPath, (content) => { - return ( - /\bdeclare\s+module\s+['"]vitest(?:\/[^'"]*)?['"]/.test(content) || - / { + return ( + /\bdeclare\s+module\s+['"]vitest(?:\/[^'"]*)?['"]/.test(content) || + content.includes('vitest/package.json') + ); + }) + ); } function usesVitestBrowserMode(projectPath: string): boolean { @@ -4599,10 +4666,13 @@ export function rewritePackageJson( // is REMOVED so it arrives transitively through vite-plus. Defaults to true to // preserve legacy behavior for callers that don't compute the signal. usesVitestDirectly = true, - // Module augmentations/triple-slash references intentionally retain the - // upstream `vitest` identity after import rewriting and therefore require a - // package-local provider under strict dependency layouts. + // Module augmentations, compilerOptions.types, and `vitest/package.json` + // intentionally retain the upstream package identity after import rewriting + // and therefore require a package-local provider under strict layouts. retainedVitestModule = false, + // Installed dependency metadata can reveal required Vitest peers whose + // package names do not include "vitest". + requiredVitestPeer = false, ): Record | null { if (pkg.scripts) { const updated = rewriteScripts( @@ -4763,7 +4833,9 @@ export function rewritePackageJson( (deps) => deps?.[provider] !== undefined, ); if (installGroup) { - installGroup[provider] = VITEST_VERSION; + if (VITEST_IS_MANAGED_OVERRIDE) { + installGroup[provider] = VITEST_VERSION; + } } else { pkg.devDependencies ??= {}; pkg.devDependencies[provider] = VITEST_VERSION; @@ -4854,7 +4926,11 @@ export function rewritePackageJson( // already owns it. The guard below still no-ops when a direct `vitest` already exists, // so a genuine normalize pass of an already-correct project mutates nothing. const needDirectVitest = - needVitePlus || effectiveBrowserMode || isVitestAdjacent || retainedVitestModule; + needVitePlus || + effectiveBrowserMode || + isVitestAdjacent || + retainedVitestModule || + requiredVitestPeer; if (needVitePlus || shouldNormalizeExistingVitePlus) { pkg.devDependencies = { ...pkg.devDependencies, @@ -4883,6 +4959,7 @@ export function rewritePackageJson( !installableDeps.vitest && (effectiveBrowserMode || retainedVitestModule || + requiredVitestPeer || Object.keys(installableDeps).some((name) => name.includes('vitest'))) ) { pkg.devDependencies ??= {}; diff --git a/packages/cli/src/utils/package.ts b/packages/cli/src/utils/package.ts index ef3faccecf..14a8587766 100644 --- a/packages/cli/src/utils/package.ts +++ b/packages/cli/src/utils/package.ts @@ -19,15 +19,97 @@ interface PackageMetadata { path: string; } +function findOwningPackageJson(resolvedPath: string, packageName: string): string | undefined { + let currentDir: string; + try { + currentDir = fs.statSync(resolvedPath).isDirectory() + ? resolvedPath + : path.dirname(resolvedPath); + } catch { + return undefined; + } + while (currentDir !== path.dirname(currentDir)) { + const candidate = path.join(currentDir, 'package.json'); + if (fs.existsSync(candidate)) { + try { + const candidatePkg = JSON.parse(fs.readFileSync(candidate, 'utf8')); + if (candidatePkg.name === packageName) { + return candidate; + } + } catch { + // Keep walking: this may be an unrelated or malformed nested manifest. + } + } + currentDir = path.dirname(currentDir); + } + return undefined; +} + +function resolvePackageJsonWithNode( + require: ReturnType, + packageName: string, +): string | undefined { + try { + return require.resolve(`${packageName}/package.json`); + } catch { + // Packages with an exports map often do not expose `./package.json`. + } + try { + return findOwningPackageJson(require.resolve(packageName), packageName); + } catch { + return undefined; + } +} + +function findPnpApiPath(projectPath: string): string | undefined { + let currentDir = path.resolve(projectPath); + while (currentDir !== path.dirname(currentDir)) { + const candidate = path.join(currentDir, '.pnp.cjs'); + if (fs.existsSync(candidate)) { + return candidate; + } + currentDir = path.dirname(currentDir); + } + return undefined; +} + export function detectPackageMetadata( projectPath: string, packageName: string, ): PackageMetadata | void { + // Create require from the project path so resolution only searches the + // project's dependencies, not the global installation's. + const require = createRequire(path.join(projectPath, 'noop.js')); + let pkgFilePath = resolvePackageJsonWithNode(require, packageName); + if (!pkgFilePath) { + const pnpApiPath = findPnpApiPath(projectPath); + if (!pnpApiPath) { + return; + } + try { + const pnpApi = createRequire(pnpApiPath)(pnpApiPath) as { + resolveToUnqualified: (request: string, issuer: string) => string; + setup?: () => void; + }; + // Activating the generated API makes archive-backed Yarn cache paths + // readable through Node's fs implementation as well. + pnpApi.setup?.(); + const unqualified = pnpApi.resolveToUnqualified( + packageName, + path.join(projectPath, 'noop.js'), + ); + pkgFilePath = findOwningPackageJson(unqualified, packageName); + if (!pkgFilePath) { + pkgFilePath = resolvePackageJsonWithNode(require, packageName); + } + } catch { + return; + } + } + if (!pkgFilePath) { + return; + } try { - // Create require from the project path so resolution only searches - // the project's node_modules, not the global installation's - const require = createRequire(path.join(projectPath, 'noop.js')); - const pkgFilePath = require.resolve(`${packageName}/package.json`); const pkg = JSON.parse(fs.readFileSync(pkgFilePath, 'utf8')); return { name: pkg.name, @@ -35,7 +117,6 @@ export function detectPackageMetadata( path: path.dirname(pkgFilePath), }; } catch { - // ignore MODULE_NOT_FOUND error return; } } diff --git a/packages/cli/src/utils/tsconfig.ts b/packages/cli/src/utils/tsconfig.ts index f421dae252..a842e1360f 100644 --- a/packages/cli/src/utils/tsconfig.ts +++ b/packages/cli/src/utils/tsconfig.ts @@ -192,6 +192,27 @@ export function hasTypesToRewriteInTsconfig(filePath: string): boolean { ); } +export function hasVitestTypesInTsconfig(filePath: string): boolean { + let text: string; + try { + text = fs.readFileSync(filePath, 'utf-8'); + } catch { + return false; + } + + const parsed = parseJsonc(text) as { + compilerOptions?: { types?: unknown[] }; + } | null; + + const types = parsed?.compilerOptions?.types; + return ( + Array.isArray(types) && + types.some((type) => + typeof type === 'string' ? type === 'vitest' || type.startsWith('vitest/') : false, + ) + ); +} + export function rewriteTypesInTsconfig(filePath: string): boolean { let text: string; try { diff --git a/rfcs/migrate-existing-projects.md b/rfcs/migrate-existing-projects.md index 44c73cf339..64d74a4e8e 100644 --- a/rfcs/migrate-existing-projects.md +++ b/rfcs/migrate-existing-projects.md @@ -23,20 +23,20 @@ Run on an existing Vite+ project, in order. The guiding fact for vitest: `vite-p Removing the old direct dependency was exercised on `node-modules/urllib` across pnpm, npm, and yarn (PRs [#832](https://github.com/node-modules/urllib/pull/832) / [#833](https://github.com/node-modules/urllib/pull/833) / [#834](https://github.com/node-modules/urllib/pull/834)). Those node-modules layouts can hoist an exact peer, but that is not portable to strict pnpm or Yarn PnP, so the migration still provisions required peers explicitly. Required-peer handling is covered for official `@vitest/*` packages and the third-party `vitest-browser-svelte` case. -| Area | Rule | -| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Routing | If the project's local `vite-plus` is older than the global `vp`, run `migrate` from the global CLI; otherwise keep local-first. | -| `vite-plus` spec | Re-pin a non-protocol-pinned spec (e.g. `^0.1.24`) to the toolchain target (`catalog:` in catalog projects, else the version) so the lockfile moves off the old resolution. Preserve deliberate protocol pins (`workspace:`/`file:`/`link:`/`npm:`/...). | -| `vite` override | Always managed: alias `vite` to `npm:@voidzero-dev/vite-plus-core@latest` in whatever override/resolution/catalog form the project uses; normalize a behind `core@` alias. | -| `vitest` itself (default) | Provided by `vite-plus`, so by default not project-managed: remove any project-level `vitest` from dependency fields, string-valued `overrides`/`resolutions`/`pnpm.overrides`, `pnpm-workspace.yaml` `overrides`+`catalog(s)`, bun/yarn catalog, and the `vitest` entry in pnpm `peerDependencyRules`. Resolve a surviving `peerDependencies.vitest` catalog reference to its public range before pruning the catalog. A future `vp update vite-plus` then keeps it correct with no project pin to drift. | -| `vitest`, peer/browser exception | Keep a managed `vitest` in the package that needs it (add to `devDependencies` and pin/override it to the bundled version) when that package directly installs a required-`vitest` peer consumer, uses browser mode, or retains a direct upstream `vitest` module reference. This includes official packages with exact peers (`@vitest/ui`, coverage providers, browser providers) and third-party integrations with range peers (`vitest-browser-react` / `-vue` / `-svelte`, ...). The direct dependency satisfies strict peer resolution; the shared override collapses the workspace to the bundled version. | -| `vitest` ecosystem packages | Align every official `@vitest/*` package the project lists (`@vitest/coverage-v8`, `@vitest/coverage-istanbul`, `@vitest/ui`, `@vitest/web-worker`, ...) to the bundled `VITEST_VERSION`. Exclude `@vitest/eslint-plugin` (separate version line, optional `vitest: *` peer); it neither triggers a `vitest` install nor a shared override. Browser packages keep their dedicated handling: `@vitest/browser` / `-preview` are bundled by `vite-plus`; `@vitest/browser-playwright` / `-webdriverio` are opt-in (pinned + framework peer kept). | -| Workspaces | Reconcile every package manifest, not only the root. Localize the direct `vitest` dependency to packages that need it; keep shared catalogs/overrides only when at least one package needs them. Re-pin existing plain `vite-plus` ranges consistently while preserving deliberate protocol specs. | -| Legacy wrapper | Remove every `@voidzero-dev/vite-plus-test` alias (deps, overrides, catalogs); repoint direct wrapper imports to `vite-plus/test`. `vite-plus/test*` imports are left unchanged (stable public API). | -| pnpm config location | An empty `"pnpm": {}` with an existing `pnpm-workspace.yaml` reconciles the workspace file (instead of writing a second, conflicting override block into `package.json`). | -| Reinstall + verify | One reinstall with lockfile refresh (`--no-frozen-lockfile` / `--force`); before npm reinstalls, remove a stale real-`vite` install/lock entry that npm otherwise retains after the dependency becomes the Vite+ core alias. A failed install warns and sets a non-zero exit. | - -Force-override/CI mode (`VP_OVERRIDE_PACKAGES`) is respected: when `vitest` is not a managed key there, the project's own `vitest` is never stripped. Object-valued nested npm/Bun overrides are user-owned scopes rather than managed version pins and are preserved. +| Area | Rule | +| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Routing | If the project's local `vite-plus` is older than the global `vp`, run `migrate` from the global CLI; otherwise keep local-first. | +| `vite-plus` spec | Re-pin a non-protocol-pinned spec (e.g. `^0.1.24`) to the toolchain target (`catalog:` in catalog projects, else the version) so the lockfile moves off the old resolution. Preserve deliberate protocol pins (`workspace:`/`file:`/`link:`/`npm:`/...). | +| `vite` override | Always managed: alias `vite` to `npm:@voidzero-dev/vite-plus-core@latest` in whatever override/resolution/catalog form the project uses; normalize a behind `core@` alias. | +| `vitest` itself (default) | Provided by `vite-plus`, so by default not project-managed: remove any project-level `vitest` from dependency fields, string-valued `overrides`/`resolutions`/`pnpm.overrides`, `pnpm-workspace.yaml` `overrides`+`catalog(s)`, bun/yarn catalog, and the `vitest` entry in pnpm `peerDependencyRules`. Resolve a surviving `peerDependencies.vitest` catalog reference to its public range before pruning the catalog. A future `vp update vite-plus` then keeps it correct with no project pin to drift. | +| `vitest`, peer/browser exception | Keep a managed `vitest` in the package that needs it (add to `devDependencies` and pin/override it to the bundled version) when that package directly installs a required-`vitest` peer consumer, uses browser mode, or retains a direct upstream `vitest` package reference. Required peers are detected from installed package metadata, not package names alone, so integrations such as `vite-plugin-gherkin` are covered. Retained references include module augmentations, `compilerOptions.types`, and the intentionally unre-written `vitest/package.json` export; rewriteable imports and triple-slash directives do not leave a lasting pin. The direct dependency satisfies strict peer resolution; the shared override collapses the workspace to the bundled version. | +| `vitest` ecosystem packages | When Vitest is managed, align current lockstep `@vitest/*` packages the project lists (`@vitest/coverage-v8`, `@vitest/coverage-istanbul`, `@vitest/ui`, `@vitest/web-worker`, ...) to the bundled `VITEST_VERSION`. Exclude `@vitest/eslint-plugin` (separate version line, optional `vitest: *` peer) and deprecated `@vitest/coverage-c8` (last published at `0.33.0`; no Vitest 4 release exists). When `VP_OVERRIDE_PACKAGES` omits Vitest, skip ecosystem alignment so user-owned exact-peer versions stay compatible. Browser packages keep their dedicated handling: `@vitest/browser` / `-preview` are bundled by `vite-plus`; `@vitest/browser-playwright` / `-webdriverio` are opt-in (pinned + framework peer kept). | +| Workspaces | Reconcile every package manifest, not only the root. Localize the direct `vitest` dependency to packages that need it; keep shared catalogs/overrides only when at least one package needs them. Re-pin existing plain `vite-plus` ranges consistently while preserving deliberate protocol specs. | +| Legacy wrapper | Remove every `@voidzero-dev/vite-plus-test` alias (deps, overrides, catalogs); repoint direct wrapper imports to `vite-plus/test`. `vite-plus/test*` imports are left unchanged (stable public API). | +| pnpm config location | An empty `"pnpm": {}` with an existing `pnpm-workspace.yaml` reconciles the workspace file (instead of writing a second, conflicting override block into `package.json`). | +| Reinstall + verify | One reinstall with lockfile refresh (`--no-frozen-lockfile` / `--force`); before npm reinstalls, remove a stale real-`vite` install/lock entry that npm otherwise retains after the dependency becomes the Vite+ core alias. A failed install warns and sets a non-zero exit. | + +Force-override/CI mode (`VP_OVERRIDE_PACKAGES`) is respected: when `vitest` is not a managed key there, the project's own `vitest` is never stripped and its `@vitest/*` ecosystem dependencies are not realigned. Object-valued nested npm/Bun overrides are user-owned scopes rather than managed version pins and are preserved. **Pending verification:** vitest **browser mode** historically needed a direct `vitest` injected (the "vibe-dashboard" regression). The upgrade now restores the opt-in provider and framework peer and keeps the package-local `vitest`; retain that behavior until a urllib-style pnpm/npm/yarn check proves any part is redundant. @@ -56,6 +56,7 @@ How each package the `vitest` ecosystem rule covers is handled, verified against | `@vitest/browser-webdriverio` | `4.1.9` + `webdriverio` | opt-in: pin to `VITEST_VERSION`, keep `webdriverio` and direct `vitest` | | `@vitest/expect` `/runner` `/snapshot` `/spy` `/utils` `/mocker` `/pretty-format` `/ws-client` | none | transitive runtime packages; align if listed, but do not add `vitest` for them alone | | `@vitest/eslint-plugin` | `*` | left as-is (own version line, e.g. `1.6.x`) | +| `@vitest/coverage-c8` | `>=0.30.0 <1` | left as-is (deprecated at `0.33.0`; there is no package version matching Vitest 4) | | `vitest-browser-react` `/-vue` `/-svelte`, ... | `^4` (range) | third-party, own versioning; left at a compatible release, with a package-local `vitest` plus shared override | ## Implementation @@ -85,8 +86,13 @@ Covered by unit tests in `migrator.spec.ts` (vitest removal, required-peer provi | Reinstall and lockfile refresh after the alias rewrite | `migration-standalone-npm` | | Peer `vitest` catalog references resolve before managed catalog pruning | `migration-upgrade-peer-vitest-catalog-pnpm` | | Peer-only browser providers are promoted with direct and shared Vitest | `migration-upgrade-browser-peer-only-pnpm` | -| Whitespace-tolerant Vitest type-directive detection | `migration-upgrade-vitest-reference-whitespace-pnpm` | +| Whitespace-tolerant Vitest directives rewrite without leaving transient pins | `migration-upgrade-vitest-reference-whitespace-pnpm` | | Object-valued nested Vitest overrides remain user-owned and idempotent | `migration-upgrade-nested-vitest-override-npm` | +| Retained `compilerOptions.types` and `vitest/package.json` references keep direct Vitest | `migration-upgrade-vitest-retained-references-npm` | +| Required Vitest peers discovered from installed dependency metadata | `migration-upgrade-required-vitest-peer-metadata-npm` | +| Deprecated `@vitest/coverage-c8` is not assigned a nonexistent Vitest 4 version | `migration-upgrade-deprecated-coverage-c8-npm` | +| Standalone Yarn writes catalog specs in one pass and is idempotent | `migration-standalone-yarn4-idempotent` | +| Unmanaged exact-peer Vitest ecosystem versions remain aligned with user-owned Vitest | `migration-vitest-unmanaged-override` | ## Follow-ups (not in this change) From f443a5b98707d7196041c79f63e2d6a3370bb5c9 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 21 Jun 2026 21:00:01 +0800 Subject: [PATCH 13/78] fix(test): normalize snapshot file endings --- packages/tools/src/snap-test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/tools/src/snap-test.ts b/packages/tools/src/snap-test.ts index b8c0bdde1b..da978ea823 100755 --- a/packages/tools/src/snap-test.ts +++ b/packages/tools/src/snap-test.ts @@ -715,7 +715,10 @@ async function runTestCase( } } - const newSnapContent = newSnap.join('\n'); + // Command output commonly ends with multiple newlines. Preserve one existing + // terminal newline, but collapse extras so rerun snapshots do not gain a + // blank line at EOF on every invocation. + const newSnapContent = newSnap.join('\n').replace(/(?:\r?\n)+$/, '\n'); await fsPromises.writeFile(`${casesDir}/${name}/snap.txt`, newSnapContent); console.log('%s finished in %dms', name, Date.now() - startTime); From 4e3e6e1cc31c5d25e4f959a7a3936e2327e109ef Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 21 Jun 2026 21:06:14 +0800 Subject: [PATCH 14/78] test(migrate): sync idempotency snapshots --- .../migration-standalone-yarn4-idempotent/snap.txt | 1 + .../migration-upgrade-browser-peer-only-pnpm/snap.txt | 1 + .../migration-upgrade-nested-vitest-override-npm/snap.txt | 1 + .../migration-upgrade-peer-vitest-catalog-pnpm/snap.txt | 1 + .../snap.txt | 1 + .../snap.txt | 1 + .../snap.txt | 1 + .../migration-vitest-unmanaged-override/snap.txt | 1 + packages/tools/src/snap-test.ts | 5 +---- 9 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/snap.txt b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/snap.txt index 48f7e4d4c8..a61ab8c68d 100644 --- a/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/snap.txt +++ b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/snap.txt @@ -36,3 +36,4 @@ it('works', () => expect(true).toBe(true)); > vp migrate --no-interactive # a freshly migrated standalone Yarn project is complete This project is already using Vite+! Happy coding! + diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/snap.txt index 4c60c8d885..51b2bb428e 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/snap.txt @@ -42,3 +42,4 @@ peerDependencyRules: > vp migrate --no-interactive # repaired project should no longer be pending This project is already using Vite+! Happy coding! + diff --git a/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/snap.txt index e7a9d733d6..d170208fe3 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/snap.txt @@ -25,3 +25,4 @@ This project is already using Vite+! Happy coding! > vp migrate --no-interactive # nested override must not make migration permanently pending This project is already using Vite+! Happy coding! + diff --git a/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/snap.txt index d7f208b469..61c5a2be7b 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/snap.txt @@ -37,3 +37,4 @@ peerDependencyRules: > vp migrate --no-interactive # repaired project should no longer be pending This project is already using Vite+! Happy coding! + diff --git a/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/snap.txt index eebc1c025c..dafe572187 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/snap.txt @@ -27,3 +27,4 @@ > vp migrate --no-interactive # metadata-based peer provisioning is stable on rerun This project is already using Vite+! Happy coding! + diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/snap.txt index a51e388a52..be3bfa3b44 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/snap.txt @@ -39,3 +39,4 @@ peerDependencyRules: > vp migrate --no-interactive # directive rewriting is stable on rerun This project is already using Vite+! Happy coding! + diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/snap.txt index cce99f7a35..8fc11d08b5 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/snap.txt @@ -37,3 +37,4 @@ console.log(metadata.version); > vp migrate --no-interactive # retained references remain stable on rerun This project is already using Vite+! Happy coding! + diff --git a/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/snap.txt b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/snap.txt index ad04a2be32..0369832eb5 100644 --- a/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/snap.txt +++ b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/snap.txt @@ -40,3 +40,4 @@ peerDependencyRules: > vp migrate --no-interactive # unmanaged Vitest ecosystem versions remain stable on rerun This project is already using Vite+! Happy coding! + diff --git a/packages/tools/src/snap-test.ts b/packages/tools/src/snap-test.ts index da978ea823..b8c0bdde1b 100755 --- a/packages/tools/src/snap-test.ts +++ b/packages/tools/src/snap-test.ts @@ -715,10 +715,7 @@ async function runTestCase( } } - // Command output commonly ends with multiple newlines. Preserve one existing - // terminal newline, but collapse extras so rerun snapshots do not gain a - // blank line at EOF on every invocation. - const newSnapContent = newSnap.join('\n').replace(/(?:\r?\n)+$/, '\n'); + const newSnapContent = newSnap.join('\n'); await fsPromises.writeFile(`${casesDir}/${name}/snap.txt`, newSnapContent); console.log('%s finished in %dms', name, Date.now() - startTime); From bfc958bd2d900ad556063cb33e75cd3356d198d0 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 21 Jun 2026 21:21:59 +0800 Subject: [PATCH 15/78] test(create): update standalone Yarn catalog snapshot --- packages/cli/snap-tests/create-approve-builds-yarn/snap.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/snap-tests/create-approve-builds-yarn/snap.txt b/packages/cli/snap-tests/create-approve-builds-yarn/snap.txt index 03d9474be1..09e34b0952 100644 --- a/packages/cli/snap-tests/create-approve-builds-yarn/snap.txt +++ b/packages/cli/snap-tests/create-approve-builds-yarn/snap.txt @@ -16,7 +16,7 @@ "core-js": "3.39.0" }, "devDependencies": { - "vite-plus": "latest" + "vite-plus": "catalog:" }, "dependenciesMeta": { "core-js": { @@ -57,7 +57,7 @@ These dependencies may not work until built. Enable them in the workspace root p "core-js": "3.39.0" }, "devDependencies": { - "vite-plus": "latest" + "vite-plus": "catalog:" }, "resolutions": { "vite": "npm:@voidzero-dev/vite-plus-core@latest" From e282708de49e804b594750928bd68c8d657d7117 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 23 Jun 2026 13:57:37 +0800 Subject: [PATCH 16/78] fix(migrate): preserve vitest imports for Nuxt tests --- crates/vite_migration/src/import_rewriter.rs | 253 +++++++++++++++--- crates/vite_migration/src/lib.rs | 5 +- packages/cli/binding/index.d.cts | 9 +- packages/cli/binding/src/migration.rs | 23 +- .../.fixture/nuxt-test-utils/package.json | 12 + .../package.json | 14 + .../packages/nuxt/nuxt.spec.ts | 7 + .../packages/nuxt/package.json | 8 + .../packages/unit/package.json | 7 + .../packages/unit/unit.spec.ts | 3 + .../pnpm-workspace.yaml | 10 + .../snap.txt | 61 +++++ .../steps.json | 17 ++ .../.fixture/nuxt-test-utils/package.json | 12 + .../nuxt.spec.ts | 8 + .../package.json | 19 ++ .../snap.txt | 46 ++++ .../steps.json | 15 ++ .../unit.spec.ts | 3 + .../lint-vite-plus-imports-nuxt/package.json | 8 + .../lint-vite-plus-imports-nuxt/snap.txt | 41 +++ .../src/nuxt.spec.ts | 7 + .../src/unit.spec.ts | 3 + .../lint-vite-plus-imports-nuxt/steps.json | 10 + .../vite.config.ts | 10 + .../fixtures/nuxt-test-utils/package.json | 6 + .../cli/src/__tests__/oxlint-plugin.spec.ts | 23 ++ .../src/migration/__tests__/migrator.spec.ts | 109 ++++++++ packages/cli/src/migration/bin.ts | 79 +++++- packages/cli/src/migration/migrator.ts | 161 +++++++++-- packages/cli/src/migration/report.ts | 2 + packages/cli/src/oxlint-plugin.ts | 67 ++++- rfcs/migrate-existing-projects.md | 69 +++-- 33 files changed, 1049 insertions(+), 78 deletions(-) create mode 100644 packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/.fixture/nuxt-test-utils/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/nuxt/nuxt.spec.ts create mode 100644 packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/nuxt/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/unit/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/unit/unit.spec.ts create mode 100644 packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/pnpm-workspace.yaml create mode 100644 packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/steps.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/.fixture/nuxt-test-utils/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/nuxt.spec.ts create mode 100644 packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/steps.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/unit.spec.ts create mode 100644 packages/cli/snap-tests/lint-vite-plus-imports-nuxt/package.json create mode 100644 packages/cli/snap-tests/lint-vite-plus-imports-nuxt/snap.txt create mode 100644 packages/cli/snap-tests/lint-vite-plus-imports-nuxt/src/nuxt.spec.ts create mode 100644 packages/cli/snap-tests/lint-vite-plus-imports-nuxt/src/unit.spec.ts create mode 100644 packages/cli/snap-tests/lint-vite-plus-imports-nuxt/steps.json create mode 100644 packages/cli/snap-tests/lint-vite-plus-imports-nuxt/vite.config.ts create mode 100644 packages/cli/src/__tests__/fixtures/nuxt-test-utils/package.json diff --git a/crates/vite_migration/src/import_rewriter.rs b/crates/vite_migration/src/import_rewriter.rs index d0de5a0840..350f2e2538 100644 --- a/crates/vite_migration/src/import_rewriter.rs +++ b/crates/vite_migration/src/import_rewriter.rs @@ -1575,6 +1575,34 @@ static PARSED_VITEST_RULES: LazyLock>> = LazyLock::n ast_grep::load_rules(REWRITE_VITEST_RULES).expect("failed to parse vitest rewrite rules") }); +const BARE_VITEST_RULE_IDS: [&str; 4] = [ + "rewrite-vitest-import", + "rewrite-vitest-export", + "rewrite-vitest-require", + "rewrite-vitest-dynamic-import", +]; + +fn is_bare_vitest_rule(rule: &RuleConfig) -> bool { + BARE_VITEST_RULE_IDS.contains(&rule.id.as_str()) +} + +static PARSED_BARE_VITEST_RULES: LazyLock>> = LazyLock::new(|| { + ast_grep::load_rules(REWRITE_VITEST_RULES) + .expect("failed to parse vitest rewrite rules") + .into_iter() + .filter(is_bare_vitest_rule) + .collect() +}); + +static PARSED_VITEST_RULES_WITHOUT_BARE: LazyLock>> = + LazyLock::new(|| { + ast_grep::load_rules(REWRITE_VITEST_RULES) + .expect("failed to parse vitest rewrite rules") + .into_iter() + .filter(|rule| !is_bare_vitest_rule(rule)) + .collect() + }); + static PARSED_TSDOWN_RULES: LazyLock>> = LazyLock::new(|| { ast_grep::load_rules(REWRITE_TSDOWN_RULES).expect("failed to parse tsdown rewrite rules") }); @@ -1905,6 +1933,20 @@ struct SkipPackages { skip_tsdown: bool, } +#[derive(Debug, Clone, Copy, Default)] +struct PackageRewriteContext { + skip_packages: SkipPackages, + uses_nuxt_test_utils: bool, +} + +/// Options controlling directory-wide import rewriting. +#[derive(Debug, Clone, Copy, Default)] +pub struct RewriteImportsOptions { + /// Preserve exact bare `vitest` module specifiers in files that directly + /// reference `@nuxt/test-utils`, provided the nearest package declares it. + pub preserve_bare_vitest_in_nuxt_files: bool, +} + impl SkipPackages { /// Check if all packages should be skipped (file can be skipped entirely) const fn all_skipped(&self) -> bool { @@ -1937,15 +1979,15 @@ fn find_nearest_package_json(file_path: &Path, root: &Path) -> Option { /// Parse package.json and check which packages are in peerDependencies or dependencies. /// Returns default (no skipping) if package.json doesn't exist or can't be parsed. -fn get_skip_packages_from_package_json(package_json_path: &Path) -> SkipPackages { +fn get_package_rewrite_context(package_json_path: &Path) -> PackageRewriteContext { let content = match std::fs::read_to_string(package_json_path) { Ok(c) => c, - Err(_) => return SkipPackages::default(), + Err(_) => return PackageRewriteContext::default(), }; let pkg: serde_json::Value = match serde_json::from_str(&content) { Ok(p) => p, - Err(_) => return SkipPackages::default(), + Err(_) => return PackageRewriteContext::default(), }; // Helper to check if a package exists in a dependencies object @@ -1955,16 +1997,29 @@ fn get_skip_packages_from_package_json(package_json_path: &Path) -> SkipPackages .is_some_and(|deps| deps.contains_key(package_name)) }; - // Check both peerDependencies and dependencies - SkipPackages { - skip_vite: has_package("peerDependencies", "vite") || has_package("dependencies", "vite"), - skip_vitest: has_package("peerDependencies", "vitest") - || has_package("dependencies", "vitest"), - skip_tsdown: has_package("peerDependencies", "tsdown") - || has_package("dependencies", "tsdown"), + // Peer and runtime dependencies preserve the existing whole-package skip + // behavior. Nuxt compatibility is narrower and accepts the three install + // groups where @nuxt/test-utils is normally declared. + PackageRewriteContext { + skip_packages: SkipPackages { + skip_vite: has_package("peerDependencies", "vite") + || has_package("dependencies", "vite"), + skip_vitest: has_package("peerDependencies", "vitest") + || has_package("dependencies", "vitest"), + skip_tsdown: has_package("peerDependencies", "tsdown") + || has_package("dependencies", "tsdown"), + }, + uses_nuxt_test_utils: ["dependencies", "devDependencies", "optionalDependencies"] + .into_iter() + .any(|key| has_package(key, "@nuxt/test-utils")), } } +#[cfg(test)] +fn get_skip_packages_from_package_json(package_json_path: &Path) -> SkipPackages { + get_package_rewrite_context(package_json_path).skip_packages +} + /// Result of rewriting imports in a file #[derive(Debug)] struct RewriteResult { @@ -1972,6 +2027,8 @@ struct RewriteResult { pub content: String, /// Whether any changes were made pub updated: bool, + /// Whether an exact bare `vitest` specifier was intentionally preserved. + pub preserved_bare_vitest: bool, } /// Result of rewriting imports in multiple files @@ -1981,6 +2038,8 @@ pub struct BatchRewriteResult { pub modified_files: Vec, /// Files that had no changes pub unchanged_files: Vec, + /// Nuxt test-utils files where exact bare `vitest` imports were preserved. + pub preserved_bare_vitest_files: Vec, /// Files that had errors (path, error message) pub errors: Vec<(PathBuf, String)>, } @@ -2021,47 +2080,60 @@ enum FileResult { /// } /// ``` pub fn rewrite_imports_in_directory(root: &Path) -> Result { + rewrite_imports_in_directory_with_options(root, RewriteImportsOptions::default()) +} + +/// Rewrite imports with file-scoped compatibility options. +pub fn rewrite_imports_in_directory_with_options( + root: &Path, + options: RewriteImportsOptions, +) -> Result { let walk_result = file_walker::find_ts_files(root)?; - // Pre-compute skip_packages for each file (requires mutable cache, done sequentially) - let mut skip_packages_cache: HashMap = HashMap::new(); - let files_with_skip: Vec<(PathBuf, SkipPackages)> = walk_result + // Pre-compute package context for each file (requires mutable cache, done sequentially). + let mut package_context_cache: HashMap = HashMap::new(); + let files_with_context: Vec<(PathBuf, PackageRewriteContext)> = walk_result .files .into_iter() .map(|file_path| { - let skip_packages = + let package_context = if let Some(package_json_path) = find_nearest_package_json(&file_path, root) { - *skip_packages_cache + *package_context_cache .entry(package_json_path.clone()) - .or_insert_with(|| get_skip_packages_from_package_json(&package_json_path)) + .or_insert_with(|| get_package_rewrite_context(&package_json_path)) } else { - SkipPackages::default() + PackageRewriteContext::default() }; - (file_path, skip_packages) + (file_path, package_context) }) .collect(); // Process files in parallel using rayon - let results: Vec<(PathBuf, FileResult)> = files_with_skip + let results: Vec<(PathBuf, FileResult, bool)> = files_with_context .into_par_iter() - .map(|(file_path, skip_packages)| { + .map(|(file_path, package_context)| { + let skip_packages = package_context.skip_packages; if skip_packages.all_skipped() { - return (file_path, FileResult::Unchanged); + return (file_path, FileResult::Unchanged, false); } - match rewrite_import(&file_path, &skip_packages) { + match rewrite_import( + &file_path, + &skip_packages, + options.preserve_bare_vitest_in_nuxt_files && package_context.uses_nuxt_test_utils, + ) { Ok(rewrite_result) => { if rewrite_result.updated { if let Err(e) = std::fs::write(&file_path, &rewrite_result.content) { - (file_path, FileResult::Error(e.to_string())) + (file_path, FileResult::Error(e.to_string()), false) } else { - (file_path, FileResult::Modified) + (file_path, FileResult::Modified, rewrite_result.preserved_bare_vitest) } } else { - (file_path, FileResult::Unchanged) + (file_path, FileResult::Unchanged, rewrite_result.preserved_bare_vitest) } } - Err(e) => (file_path, FileResult::Error(e.to_string())), + Err(e) => (file_path, FileResult::Error(e.to_string()), false), } }) .collect(); @@ -2070,10 +2142,14 @@ pub fn rewrite_imports_in_directory(root: &Path) -> Result batch_result.modified_files.push(file_path), FileResult::Unchanged => batch_result.unchanged_files.push(file_path), @@ -2100,12 +2176,28 @@ pub fn rewrite_imports_in_directory(root: &Path) -> Result Result { +fn rewrite_import( + file_path: &Path, + skip_packages: &SkipPackages, + preserve_bare_vitest_in_nuxt_files: bool, +) -> Result { // Read the file let content = std::fs::read_to_string(file_path)?; // Rewrite the imports - rewrite_import_content(&content, skip_packages) + let preserve_bare_vitest = + preserve_bare_vitest_in_nuxt_files && source_directly_references_nuxt_test_utils(&content); + rewrite_import_content_with_options(&content, skip_packages, preserve_bare_vitest) +} + +fn source_directly_references_nuxt_test_utils(content: &str) -> bool { + static RE_NUXT_TEST_UTILS_REFERENCE: LazyLock = LazyLock::new(|| { + Regex::new( + r#"(?m)(?:\bfrom\s*|\b(?:import|require)\s*\(\s*|\bimport\s*)["']@nuxt/test-utils(?:/[^"']+)?["']"#, + ) + .unwrap() + }); + RE_NUXT_TEST_UTILS_REFERENCE.is_match(content) } /// Fast pre-filter to skip expensive AST parsing for files with no relevant imports. @@ -2128,17 +2220,31 @@ fn content_may_need_rewriting(content: &str, skip_packages: &SkipPackages) -> bo /// /// This is the internal function that performs the actual rewrite using ast-grep. /// Packages that are in peerDependencies or dependencies will be skipped. +#[cfg(test)] fn rewrite_import_content( content: &str, skip_packages: &SkipPackages, +) -> Result { + rewrite_import_content_with_options(content, skip_packages, false) +} + +fn rewrite_import_content_with_options( + content: &str, + skip_packages: &SkipPackages, + preserve_bare_vitest: bool, ) -> Result { // Fast path: skip AST parsing if the file doesn't contain any target strings if !content_may_need_rewriting(content, skip_packages) { - return Ok(RewriteResult { content: content.to_string(), updated: false }); + return Ok(RewriteResult { + content: content.to_string(), + updated: false, + preserved_bare_vitest: false, + }); } let mut new_content = content.to_string(); let mut updated = false; + let mut preserved_bare_vitest = false; // Apply vite rules if not skipped (using pre-parsed rules) if !skip_packages.skip_vite { @@ -2151,7 +2257,15 @@ fn rewrite_import_content( // Apply vitest rules if not skipped (using pre-parsed rules) if !skip_packages.skip_vitest { - let vitest_content = ast_grep::apply_loaded_rules(&new_content, &PARSED_VITEST_RULES); + let vitest_rules = if preserve_bare_vitest { + let bare_rewrite = + ast_grep::apply_loaded_rules(&new_content, &PARSED_BARE_VITEST_RULES); + preserved_bare_vitest = bare_rewrite != new_content; + &*PARSED_VITEST_RULES_WITHOUT_BARE + } else { + &*PARSED_VITEST_RULES + }; + let vitest_content = ast_grep::apply_loaded_rules(&new_content, vitest_rules); if vitest_content != new_content { new_content = vitest_content; updated = true; @@ -2171,7 +2285,7 @@ fn rewrite_import_content( // These cannot be handled by ast-grep because they are parsed as comments. updated |= rewrite_reference_types(&mut new_content, skip_packages); - Ok(RewriteResult { content: new_content, updated }) + Ok(RewriteResult { content: new_content, updated, preserved_bare_vitest }) } #[cfg(test)] @@ -2301,7 +2415,7 @@ export default defineConfig({{ .unwrap(); // Run the rewrite - let result = rewrite_import(&vite_config_path, &SkipPackages::default()).unwrap(); + let result = rewrite_import(&vite_config_path, &SkipPackages::default(), false).unwrap(); assert!(result.updated); assert_eq!( @@ -2778,6 +2892,79 @@ describe('test', () => {});"#, assert!(!utils_content.contains("vite-plus")); } + #[test] + fn test_preserves_only_bare_vitest_in_nuxt_test_utils_files() { + use std::fs; + + let temp = tempdir().unwrap(); + fs::write( + temp.path().join("package.json"), + r#"{ + "devDependencies": { + "@nuxt/test-utils": "4.0.3", + "vitest": "4.1.9" + } +}"#, + ) + .unwrap(); + fs::write( + temp.path().join("nuxt.spec.ts"), + r#"import { vi } from 'vitest'; +export { expect } from 'vitest'; +const runtime = require('vitest'); +const dynamic = import('vitest'); +import { defineConfig } from 'vitest/config'; +import { startVitest } from 'vitest/node'; +import { page } from '@vitest/browser/context'; +import { mockNuxtImport } from '@nuxt/test-utils/runtime';"#, + ) + .unwrap(); + fs::write(temp.path().join("ordinary.spec.ts"), "import { expect } from 'vitest';\n") + .unwrap(); + + let result = rewrite_imports_in_directory_with_options( + temp.path(), + RewriteImportsOptions { preserve_bare_vitest_in_nuxt_files: true }, + ) + .unwrap(); + + assert_eq!(result.preserved_bare_vitest_files, [temp.path().join("nuxt.spec.ts")]); + let nuxt = fs::read_to_string(temp.path().join("nuxt.spec.ts")).unwrap(); + assert!(nuxt.contains("from 'vitest'")); + assert!(nuxt.contains("require('vitest')")); + assert!(nuxt.contains("import('vitest')")); + assert!(nuxt.contains("from 'vite-plus'")); + assert!(nuxt.contains("from 'vite-plus/test/node'")); + assert!(nuxt.contains("from 'vite-plus/test/browser/context'")); + + let ordinary = fs::read_to_string(temp.path().join("ordinary.spec.ts")).unwrap(); + assert!(ordinary.contains("from 'vite-plus/test'")); + } + + #[test] + fn test_nuxt_preservation_requires_declared_test_utils_dependency() { + use std::fs; + + let temp = tempdir().unwrap(); + fs::write(temp.path().join("package.json"), r#"{"devDependencies":{"vitest":"4"}}"#) + .unwrap(); + fs::write( + temp.path().join("nuxt.spec.ts"), + "import { vi } from 'vitest';\nimport { mockNuxtImport } from '@nuxt/test-utils/runtime';\n", + ) + .unwrap(); + + let result = rewrite_imports_in_directory_with_options( + temp.path(), + RewriteImportsOptions { preserve_bare_vitest_in_nuxt_files: true }, + ) + .unwrap(); + + assert!(result.preserved_bare_vitest_files.is_empty()); + let content = fs::read_to_string(temp.path().join("nuxt.spec.ts")).unwrap(); + assert!(content.contains("from 'vite-plus/test'")); + } + #[test] fn test_rewrite_imports_in_directory_empty() { let temp = tempdir().unwrap(); diff --git a/crates/vite_migration/src/lib.rs b/crates/vite_migration/src/lib.rs index 78ab12872f..855f23cd9b 100644 --- a/crates/vite_migration/src/lib.rs +++ b/crates/vite_migration/src/lib.rs @@ -16,7 +16,10 @@ mod script_rewrite; mod vite_config; pub use file_walker::{WalkResult, find_ts_files}; -pub use import_rewriter::{BatchRewriteResult, rewrite_imports_in_directory}; +pub use import_rewriter::{ + BatchRewriteResult, RewriteImportsOptions, rewrite_imports_in_directory, + rewrite_imports_in_directory_with_options, +}; pub use package::{rewrite_eslint, rewrite_prettier, rewrite_scripts}; pub use vite_config::{ MergeResult, has_config_key, merge_json_config, merge_tsdown_config, upsert_json_config, diff --git a/packages/cli/binding/index.d.cts b/packages/cli/binding/index.d.cts index 50de42f8fd..30bc455d31 100644 --- a/packages/cli/binding/index.d.cts +++ b/packages/cli/binding/index.d.cts @@ -3288,6 +3288,8 @@ export interface BatchRewriteError { export interface BatchRewriteResult { /** Files that were modified */ modifiedFiles: Array; + /** Nuxt test-utils files where exact bare `vitest` imports were preserved */ + preservedBareVitestFiles: Array; /** Files that had errors */ errors: Array; } @@ -3520,6 +3522,8 @@ export declare function rewriteEslint(scriptsJson: string): string | null; * # Arguments * * * `root` - The root directory to search for files + * * `preserve_bare_vitest_in_nuxt_files` - Preserve exact bare `vitest` + * specifiers in files that directly reference a declared `@nuxt/test-utils` * * # Returns * @@ -3537,7 +3541,10 @@ export declare function rewriteEslint(scriptsJson: string): string | null; * } * ``` */ -export declare function rewriteImportsInDirectory(root: string): BatchRewriteResult; +export declare function rewriteImportsInDirectory( + root: string, + preserveBareVitestInNuxtFiles?: boolean | undefined | null, +): BatchRewriteResult; /** * Rewrite Prettier scripts: rename `prettier` → `vp fmt` and strip Prettier-only flags. diff --git a/packages/cli/binding/src/migration.rs b/packages/cli/binding/src/migration.rs index 059f8607ee..a702fca944 100644 --- a/packages/cli/binding/src/migration.rs +++ b/packages/cli/binding/src/migration.rs @@ -197,6 +197,8 @@ pub struct BatchRewriteError { pub struct BatchRewriteResult { /// Files that were modified pub modified_files: Vec, + /// Nuxt test-utils files where exact bare `vitest` imports were preserved + pub preserved_bare_vitest_files: Vec, /// Files that had errors pub errors: Vec, } @@ -266,6 +268,8 @@ pub fn wrap_lazy_plugins(vite_config_path: String) -> Result Result Result { - let result = vite_migration::rewrite_imports_in_directory(Path::new(&root)) - .map_err(anyhow::Error::from)?; +pub fn rewrite_imports_in_directory( + root: String, + preserve_bare_vitest_in_nuxt_files: Option, +) -> Result { + let result = vite_migration::rewrite_imports_in_directory_with_options( + Path::new(&root), + vite_migration::RewriteImportsOptions { + preserve_bare_vitest_in_nuxt_files: preserve_bare_vitest_in_nuxt_files.unwrap_or(false), + }, + ) + .map_err(anyhow::Error::from)?; Ok(BatchRewriteResult { modified_files: result @@ -293,6 +305,11 @@ pub fn rewrite_imports_in_directory(root: String) -> Result .iter() .map(|p| p.to_string_lossy().to_string()) .collect(), + preserved_bare_vitest_files: result + .preserved_bare_vitest_files + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect(), errors: result .errors .iter() diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/.fixture/nuxt-test-utils/package.json b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/.fixture/nuxt-test-utils/package.json new file mode 100644 index 0000000000..578baa7ab6 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/.fixture/nuxt-test-utils/package.json @@ -0,0 +1,12 @@ +{ + "name": "@nuxt/test-utils", + "version": "4.0.3", + "peerDependencies": { + "vitest": "^4.0.2" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/package.json b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/package.json new file mode 100644 index 0000000000..66a6731860 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/package.json @@ -0,0 +1,14 @@ +{ + "name": "migration-upgrade-nuxt-test-utils-monorepo", + "private": true, + "devDependencies": { + "vite-plus": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "10.33.2", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/nuxt/nuxt.spec.ts b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/nuxt/nuxt.spec.ts new file mode 100644 index 0000000000..aad9acb752 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/nuxt/nuxt.spec.ts @@ -0,0 +1,7 @@ +import { mockNuxtImport } from '@nuxt/test-utils/runtime'; +import { expect, vi } from 'vitest'; +import { startVitest } from 'vitest/node'; + +mockNuxtImport('useExample', () => vi.fn()); +void expect; +void startVitest; diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/nuxt/package.json b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/nuxt/package.json new file mode 100644 index 0000000000..508cad9200 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/nuxt/package.json @@ -0,0 +1,8 @@ +{ + "name": "nuxt-tests", + "private": true, + "devDependencies": { + "@nuxt/test-utils": "file:../../.fixture/nuxt-test-utils", + "vitest": "catalog:" + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/unit/package.json b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/unit/package.json new file mode 100644 index 0000000000..57a77b0b8e --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/unit/package.json @@ -0,0 +1,7 @@ +{ + "name": "unit-tests", + "private": true, + "devDependencies": { + "vitest": "catalog:" + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/unit/unit.spec.ts b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/unit/unit.spec.ts new file mode 100644 index 0000000000..a5a3f5c5c2 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/unit/unit.spec.ts @@ -0,0 +1,3 @@ +import { expect } from 'vitest'; + +void expect; diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/pnpm-workspace.yaml b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/pnpm-workspace.yaml new file mode 100644 index 0000000000..912c35ad21 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +packages: + - packages/* + +catalog: + vite-plus: latest + vitest: ^4.0.2 + +overrides: + vite: npm:@voidzero-dev/vite-plus-core@latest + vitest: npm:@voidzero-dev/vite-plus-test@latest diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/snap.txt new file mode 100644 index 0000000000..f6823a49b2 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/snap.txt @@ -0,0 +1,61 @@ +> vp migrate --no-interactive # preserve Nuxt imports and localize direct Vitest to the affected workspace +◇ Migrated . to Vite+ +• Node pnpm +• 2 files had imports rewritten +• Kept bare `vitest` imports in 1 file for @nuxt/test-utils compatibility +• Package manager settings configured + +> cat packages/nuxt/package.json # affected workspace keeps direct Vitest +{ + "name": "nuxt-tests", + "private": true, + "devDependencies": { + "@nuxt/test-utils": "file:../../.fixture/nuxt-test-utils", + "vitest": "catalog:" + } +} + +> cat packages/unit/package.json # unrelated workspace drops direct Vitest +{ + "name": "unit-tests", + "private": true, + "devDependencies": {} +} + +> cat pnpm-workspace.yaml # shared Vitest pin remains because one workspace needs it +packages: + - packages/* + +catalog: + vite-plus: latest + vitest: + vite: npm:@voidzero-dev/vite-plus-core@latest + +overrides: + vite: 'catalog:' + vitest: 'catalog:' +peerDependencyRules: + allowAny: + - vite + - vitest + allowedVersions: + vite: '*' + vitest: '*' + +> cat packages/nuxt/nuxt.spec.ts # bare Vitest stays while its subpath migrates +import { mockNuxtImport } from '@nuxt/test-utils/runtime'; +import { expect, vi } from 'vitest'; +import { startVitest } from 'vite-plus/test/node'; + +mockNuxtImport('useExample', () => vi.fn()); +void expect; +void startVitest; + +> cat packages/unit/unit.spec.ts # unrelated bare Vitest migrates +import { expect } from 'vite-plus/test'; + +void expect; + +> vp migrate --no-interactive # workspace result is idempotent +This project is already using Vite+! Happy coding! + diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/steps.json b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/steps.json new file mode 100644 index 0000000000..f87e8c2a72 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/steps.json @@ -0,0 +1,17 @@ +{ + "commands": [ + "vp migrate --no-interactive # preserve Nuxt imports and localize direct Vitest to the affected workspace", + "cat packages/nuxt/package.json # affected workspace keeps direct Vitest", + "cat packages/unit/package.json # unrelated workspace drops direct Vitest", + "cat pnpm-workspace.yaml # shared Vitest pin remains because one workspace needs it", + "cat packages/nuxt/nuxt.spec.ts # bare Vitest stays while its subpath migrates", + "cat packages/unit/unit.spec.ts # unrelated bare Vitest migrates", + "vp migrate --no-interactive # workspace result is idempotent" + ], + "ignoredPlatforms": [ + { + "os": "linux", + "libc": "musl" + } + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/.fixture/nuxt-test-utils/package.json b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/.fixture/nuxt-test-utils/package.json new file mode 100644 index 0000000000..578baa7ab6 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/.fixture/nuxt-test-utils/package.json @@ -0,0 +1,12 @@ +{ + "name": "@nuxt/test-utils", + "version": "4.0.3", + "peerDependencies": { + "vitest": "^4.0.2" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/nuxt.spec.ts b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/nuxt.spec.ts new file mode 100644 index 0000000000..a763129373 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/nuxt.spec.ts @@ -0,0 +1,8 @@ +import { mockNuxtImport } from '@nuxt/test-utils/runtime'; +import { page } from '@vitest/browser/context'; +import { vi } from 'vitest'; +import { defineConfig } from 'vitest/config'; + +mockNuxtImport('useExample', () => vi.fn()); +void page; +void defineConfig; diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/package.json b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/package.json new file mode 100644 index 0000000000..a1741d9a04 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/package.json @@ -0,0 +1,19 @@ +{ + "name": "migration-upgrade-nuxt-test-utils", + "devDependencies": { + "@nuxt/test-utils": "file:.fixture/nuxt-test-utils", + "vite-plus": "latest", + "vitest": "^4.0.2" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "npm:@voidzero-dev/vite-plus-test@latest" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "11.16.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/snap.txt new file mode 100644 index 0000000000..681c907914 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/snap.txt @@ -0,0 +1,46 @@ +> vp migrate --no-interactive # preserve Nuxt-compatible bare imports by default while rewriting other Vitest surfaces +◇ Migrated . to Vite+ +• Node npm +• 2 files had imports rewritten +• Kept bare `vitest` imports in 1 file for @nuxt/test-utils compatibility +• Package manager settings configured + +> cat package.json # direct Vitest and its shared pin remain for the preserved import +{ + "name": "migration-upgrade-nuxt-test-utils", + "devDependencies": { + "@nuxt/test-utils": "file:.fixture/nuxt-test-utils", + "vite-plus": "latest", + "vitest": "" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "", + "onFail": "download" + } + } +} + +> cat nuxt.spec.ts # bare Vitest stays; config and browser subpaths migrate +import { mockNuxtImport } from '@nuxt/test-utils/runtime'; +import { page } from 'vite-plus/test/browser/context'; +import { vi } from 'vitest'; +import { defineConfig } from 'vite-plus'; + +mockNuxtImport('useExample', () => vi.fn()); +void page; +void defineConfig; + +> cat unit.spec.ts # unrelated bare Vitest imports still migrate +import { expect } from 'vite-plus/test'; + +expect(true).toBe(true); + +> vp migrate --no-interactive # the compatibility result is idempotent +This project is already using Vite+! Happy coding! + diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/steps.json b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/steps.json new file mode 100644 index 0000000000..0f54655d5c --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/steps.json @@ -0,0 +1,15 @@ +{ + "commands": [ + "vp migrate --no-interactive # preserve Nuxt-compatible bare imports by default while rewriting other Vitest surfaces", + "cat package.json # direct Vitest and its shared pin remain for the preserved import", + "cat nuxt.spec.ts # bare Vitest stays; config and browser subpaths migrate", + "cat unit.spec.ts # unrelated bare Vitest imports still migrate", + "vp migrate --no-interactive # the compatibility result is idempotent" + ], + "ignoredPlatforms": [ + { + "os": "linux", + "libc": "musl" + } + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/unit.spec.ts b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/unit.spec.ts new file mode 100644 index 0000000000..593056d5d9 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/unit.spec.ts @@ -0,0 +1,3 @@ +import { expect } from 'vitest'; + +expect(true).toBe(true); diff --git a/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/package.json b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/package.json new file mode 100644 index 0000000000..66604e79b7 --- /dev/null +++ b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/package.json @@ -0,0 +1,8 @@ +{ + "name": "lint-vite-plus-imports-nuxt", + "version": "0.0.0", + "private": true, + "devDependencies": { + "@nuxt/test-utils": "^4.0.3" + } +} diff --git a/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/snap.txt b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/snap.txt new file mode 100644 index 0000000000..b8965ab384 --- /dev/null +++ b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/snap.txt @@ -0,0 +1,41 @@ +[1]> vp lint src/nuxt.spec.ts src/unit.spec.ts # only the Nuxt bare import is exempt; subpaths and unrelated files still fail + + × vite-plus(prefer-vite-plus-imports): Use 'vite-plus/test/node' instead of 'vitest/node' in Vite+ projects. + ╭─[src/nuxt.spec.ts:3:29] + 2 │ import { expect, vi } from 'vitest'; + 3 │ import { startVitest } from 'vitest/node'; + · ───────────── + 4 │ + ╰──── + + × vite-plus(prefer-vite-plus-imports): Use 'vite-plus/test' instead of 'vitest' in Vite+ projects. + ╭─[src/unit.spec.ts:1:24] + 1 │ import { expect } from 'vitest'; + · ──────── + 2 │ + ╰──── + +Found 0 warnings and 2 errors. +Finished in ms on 2 files with rules using threads. + +> vp lint --fix src/nuxt.spec.ts src/unit.spec.ts # preserve the compatible bare import while fixing all other Vitest imports +Found 0 warnings and 0 errors. +Finished in ms on 2 files with rules using threads. + +> cat src/nuxt.spec.ts +import { mockNuxtImport } from '@nuxt/test-utils/runtime'; +import { expect, vi } from 'vitest'; +import { startVitest } from 'vite-plus/test/node'; + +mockNuxtImport('useExample', () => vi.fn()); +void expect; +void startVitest; + +> cat src/unit.spec.ts +import { expect } from 'vite-plus/test'; + +void expect; + +> vp lint src/nuxt.spec.ts src/unit.spec.ts # confirm the mixed compatible result is clean +Found 0 warnings and 0 errors. +Finished in ms on 2 files with rules using threads. diff --git a/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/src/nuxt.spec.ts b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/src/nuxt.spec.ts new file mode 100644 index 0000000000..aad9acb752 --- /dev/null +++ b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/src/nuxt.spec.ts @@ -0,0 +1,7 @@ +import { mockNuxtImport } from '@nuxt/test-utils/runtime'; +import { expect, vi } from 'vitest'; +import { startVitest } from 'vitest/node'; + +mockNuxtImport('useExample', () => vi.fn()); +void expect; +void startVitest; diff --git a/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/src/unit.spec.ts b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/src/unit.spec.ts new file mode 100644 index 0000000000..a5a3f5c5c2 --- /dev/null +++ b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/src/unit.spec.ts @@ -0,0 +1,3 @@ +import { expect } from 'vitest'; + +void expect; diff --git a/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/steps.json b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/steps.json new file mode 100644 index 0000000000..6d28422626 --- /dev/null +++ b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/steps.json @@ -0,0 +1,10 @@ +{ + "ignoredPlatforms": [{ "os": "linux", "libc": "musl" }], + "commands": [ + "vp lint src/nuxt.spec.ts src/unit.spec.ts # only the Nuxt bare import is exempt; subpaths and unrelated files still fail", + "vp lint --fix src/nuxt.spec.ts src/unit.spec.ts # preserve the compatible bare import while fixing all other Vitest imports", + "cat src/nuxt.spec.ts", + "cat src/unit.spec.ts", + "vp lint src/nuxt.spec.ts src/unit.spec.ts # confirm the mixed compatible result is clean" + ] +} diff --git a/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/vite.config.ts b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/vite.config.ts new file mode 100644 index 0000000000..ccf62c766b --- /dev/null +++ b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + lint: { + jsPlugins: [{ name: 'vite-plus', specifier: 'vite-plus/oxlint-plugin' }], + rules: { + 'vite-plus/prefer-vite-plus-imports': 'error', + }, + }, +}); diff --git a/packages/cli/src/__tests__/fixtures/nuxt-test-utils/package.json b/packages/cli/src/__tests__/fixtures/nuxt-test-utils/package.json new file mode 100644 index 0000000000..4749977f56 --- /dev/null +++ b/packages/cli/src/__tests__/fixtures/nuxt-test-utils/package.json @@ -0,0 +1,6 @@ +{ + "private": true, + "devDependencies": { + "@nuxt/test-utils": "^4.0.3" + } +} diff --git a/packages/cli/src/__tests__/oxlint-plugin.spec.ts b/packages/cli/src/__tests__/oxlint-plugin.spec.ts index 0e9f4c1b6b..94c7cf148d 100644 --- a/packages/cli/src/__tests__/oxlint-plugin.spec.ts +++ b/packages/cli/src/__tests__/oxlint-plugin.spec.ts @@ -1,3 +1,5 @@ +import path from 'node:path'; + import { RuleTester } from 'oxlint/plugins-dev'; import { describe, expect, it } from 'vitest'; @@ -10,6 +12,11 @@ import { } from '../oxlint-plugin-config.js'; import { preferVitePlusImportsRule, rewriteVitePlusImportSpecifier } from '../oxlint-plugin.js'; +const nuxtTestFilename = path.join( + import.meta.dirname, + 'fixtures/nuxt-test-utils/component.spec.ts', +); + describe('oxlint plugin config defaults', () => { it('adds vite-plus js plugin and lint rule defaults', () => { expect( @@ -147,6 +154,10 @@ new RuleTester({ code: `declare module '@vitest/browser-playwright/context' {}`, filename: 'types.ts', }, + { + code: `import { vi } from 'vitest';\nimport { mockNuxtImport } from '@nuxt/test-utils/runtime';`, + filename: nuxtTestFilename, + }, ], invalid: [ { @@ -211,5 +222,17 @@ new RuleTester({ errors: 2, output: `export * from 'vite-plus/test';\nimport { defineConfig } from 'vite-plus';`, }, + { + code: `import { vi } from 'vitest';\nimport { startVitest } from 'vitest/node';\nimport { mockNuxtImport } from '@nuxt/test-utils/runtime';`, + errors: 1, + filename: nuxtTestFilename, + output: `import { vi } from 'vitest';\nimport { startVitest } from 'vite-plus/test/node';\nimport { mockNuxtImport } from '@nuxt/test-utils/runtime';`, + }, + { + code: `import { vi } from 'vitest';\nimport { mockNuxtImport } from '@nuxt/test-utils/runtime';`, + errors: 1, + filename: path.join(import.meta.dirname, 'ordinary.spec.ts'), + output: `import { vi } from 'vite-plus/test';\nimport { mockNuxtImport } from '@nuxt/test-utils/runtime';`, + }, ], }); diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 76249d30bc..392c6d548c 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -23,6 +23,7 @@ const { rewriteMonorepo, rewriteMonorepoProject, detectPendingCoreMigration, + detectNuxtTestUtilsVitestImportFiles, detectVitePlusBootstrapPending, ensureVitePlusBootstrap, finalizeCoreMigrationForExistingVitePlus, @@ -2932,6 +2933,114 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { ); }); + it.each(['dependencies', 'devDependencies', 'optionalDependencies'] as const)( + 'detects Nuxt-compatible bare Vitest imports from %s without installed metadata', + (dependencyGroup) => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'nuxt-project', + [dependencyGroup]: { '@nuxt/test-utils': '^4.0.3' }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'nuxt.spec.ts'), + "import { vi } from 'vitest';\nimport { mockNuxtImport } from '@nuxt/test-utils/runtime';\n", + ); + fs.writeFileSync(path.join(tmpDir, 'unit.spec.ts'), "import { expect } from 'vitest';\n"); + + expect(detectNuxtTestUtilsVitestImportFiles(tmpDir)).toEqual([ + path.join(tmpDir, 'nuxt.spec.ts'), + ]); + }, + ); + + it('preserves Nuxt bare Vitest imports, keeps direct Vitest, and rewrites subpaths', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'nuxt-project', + devDependencies: { + vite: '^7.0.0', + vitest: '^4.0.0', + '@nuxt/test-utils': '^4.0.3', + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'nuxt.spec.ts'), + [ + "import { vi } from 'vitest';", + "import { defineConfig } from 'vitest/config';", + "import { mockNuxtImport } from '@nuxt/test-utils/runtime';", + '', + ].join('\n'), + ); + fs.writeFileSync(path.join(tmpDir, 'unit.spec.ts'), "import { expect } from 'vitest';\n"); + const report = createMigrationReport(); + + rewriteStandaloneProject( + tmpDir, + makeWorkspaceInfo(tmpDir, PackageManager.npm), + true, + true, + report, + ); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + overrides: Record; + }; + expect(pkg.devDependencies.vitest).toBe(VITEST_VERSION); + expect(pkg.overrides.vitest).toBe(VITEST_VERSION); + const nuxtTest = fs.readFileSync(path.join(tmpDir, 'nuxt.spec.ts'), 'utf8'); + expect(nuxtTest).toContain("from 'vitest'"); + expect(nuxtTest).toContain("from 'vite-plus'"); + expect(fs.readFileSync(path.join(tmpDir, 'unit.spec.ts'), 'utf8')).toContain( + "from 'vite-plus/test'", + ); + expect(report.preservedNuxtVitestImportFileCount).toBe(1); + }); + + it('rewrites Nuxt bare Vitest imports when compatibility preservation is declined', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'nuxt-project', + devDependencies: { + vite: '^7.0.0', + vitest: '^4.0.0', + '@nuxt/test-utils': '^4.0.3', + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'nuxt.spec.ts'), + "import { vi } from 'vitest';\nimport { mockNuxtImport } from '@nuxt/test-utils/runtime';\n", + ); + const report = createMigrationReport(); + + rewriteStandaloneProject( + tmpDir, + makeWorkspaceInfo(tmpDir, PackageManager.npm), + true, + true, + report, + { preserveNuxtVitestImports: false }, + ); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + overrides: Record; + }; + expect(pkg.devDependencies.vitest).toBeUndefined(); + expect(pkg.overrides.vitest).toBeUndefined(); + expect(fs.readFileSync(path.join(tmpDir, 'nuxt.spec.ts'), 'utf8')).toContain( + "from 'vite-plus/test'", + ); + expect(report.preservedNuxtVitestImportFileCount).toBe(0); + }); + it('does not add a coverage provider the project never declared', () => { // A project that uses vitest WITHOUT a coverage provider must not have one // injected by the migration — the user installs it only if they need it. diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index 2731c822f0..c5c9c4db36 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -55,6 +55,7 @@ import { detectFramework, detectIncompatibleEslintIntegration, detectNodeVersionManagerFile, + detectNuxtTestUtilsVitestImportFiles, detectPendingCoreMigration, detectPrettierProject, detectVitePlusBootstrapPending, @@ -347,6 +348,51 @@ interface MigrationPlan extends MigrationSetupPlan { migrateNodeVersionFile: boolean; nodeVersionDetection?: NodeVersionManagerDetection; frameworkShimFrameworks?: Framework[]; + preserveNuxtVitestImports: boolean; + nuxtVitestUnsafeRewrite: boolean; +} + +const NUXT_VITEST_REWRITE_WARNING = + '@nuxt/test-utils compatibility: bare `vitest` imports were rewritten. Files using ' + + '`mockNuxtImport` or `mockComponent` may need manual fixes for duplicate `vi` imports.'; + +async function collectNuxtVitestImportDecision( + rootDir: string, + options: MigrationOptions, + packages?: WorkspacePackage[], +): Promise<{ preserveNuxtVitestImports: boolean; nuxtVitestUnsafeRewrite: boolean }> { + const affectedFiles = detectNuxtTestUtilsVitestImportFiles(rootDir, packages); + if (affectedFiles.length === 0) { + return { preserveNuxtVitestImports: true, nuxtVitestUnsafeRewrite: false }; + } + if (!options.interactive) { + return { preserveNuxtVitestImports: true, nuxtVitestUnsafeRewrite: false }; + } + + prompts.log.step('@nuxt/test-utils detected', { withGuide: true }); + const action = await prompts.select({ + message: 'How should bare `vitest` imports in Nuxt test files be handled?', + options: [ + { + label: 'Keep `vitest` imports (recommended)', + value: 'preserve' as const, + hint: 'Compatible with mockNuxtImport and mockComponent', + }, + { + label: 'Rewrite to `vite-plus/test`', + value: 'rewrite' as const, + hint: 'May require manual fixes for duplicate vi imports', + }, + ], + initialValue: 'preserve' as const, + }); + if (prompts.isCancel(action)) { + cancelAndExit(); + } + return { + preserveNuxtVitestImports: action === 'preserve', + nuxtVitestUnsafeRewrite: action === 'rewrite', + }; } function getFrameworkShimCandidates(rootDir: string, packages?: WorkspacePackage[]): Framework[] { @@ -636,6 +682,8 @@ async function collectMigrationPlan( const packageManager = detectedPackageManager ?? (await selectPackageManager(options.interactive, true)); + const nuxtVitestPlan = await collectNuxtVitestImportDecision(rootDir, options, packages); + // 2. Shared setup/tooling decisions const setupPlan = await collectMigrationSetupPlan(rootDir, packageManager, options, packages); @@ -675,6 +723,7 @@ async function collectMigrationPlan( migrateNodeVersionFile, nodeVersionDetection, frameworkShimFrameworks, + ...nuxtVitestPlan, }; return plan; @@ -789,6 +838,13 @@ function showMigrationSummary(options: { } log(`${styleText('gray', '•')} ${parts.join(', ')}`); } + if (report.preservedNuxtVitestImportFileCount > 0) { + log( + `${styleText('gray', '•')} Kept bare \`vitest\` imports in ${report.preservedNuxtVitestImportFileCount} ${ + report.preservedNuxtVitestImportFileCount === 1 ? 'file' : 'files' + } for @nuxt/test-utils compatibility`, + ); + } if (report.eslintMigrated) { log(`${styleText('gray', '•')} ESLint rules migrated to Oxlint`); } @@ -909,6 +965,9 @@ async function executeMigrationPlan( report: MigrationReport; }> { const report = createMigrationReport(); + if (plan.nuxtVitestUnsafeRewrite) { + addMigrationWarning(report, NUXT_VITEST_REWRITE_WARNING); + } const migrationProgress = interactive ? prompts.spinner({ indicator: 'timer' }) : undefined; let migrationProgressStarted = false; const updateMigrationProgress = (message: string) => { @@ -1029,7 +1088,9 @@ async function executeMigrationPlan( // 7. Rewrite configs updateMigrationProgress('Rewriting configs'); if (workspaceInfo.isMonorepo) { - rewriteMonorepo(workspaceInfo, skipStagedMigration, true, report); + rewriteMonorepo(workspaceInfo, skipStagedMigration, true, report, { + preserveNuxtVitestImports: plan.preserveNuxtVitestImports, + }); } else { rewriteStandaloneProject( workspaceInfo.rootDir, @@ -1037,6 +1098,7 @@ async function executeMigrationPlan( skipStagedMigration, true, report, + { preserveNuxtVitestImports: plan.preserveNuxtVitestImports }, ); } @@ -1175,6 +1237,18 @@ async function main() { } }; + const nuxtVitestPlan = await collectNuxtVitestImportDecision( + workspaceInfoOptional.rootDir, + options, + workspaceInfoOptional.packages, + ); + const nuxtVitestImportOptions = { + preserveNuxtVitestImports: nuxtVitestPlan.preserveNuxtVitestImports, + }; + if (nuxtVitestPlan.nuxtVitestUnsafeRewrite) { + addMigrationWarning(report, NUXT_VITEST_REWRITE_WARNING); + } + const pendingCoreMigration = detectPendingCoreMigration(workspaceInfoOptional); const legacyGitHooksMigrationCandidate = detectLegacyGitHooksMigrationCandidate( workspaceInfoOptional.rootDir, @@ -1183,6 +1257,7 @@ async function main() { workspaceInfoOptional.rootDir, workspaceInfoOptional.packageManager, workspaceInfoOptional.packages, + nuxtVitestImportOptions, ); let packageManager: PackageManager | undefined = vitePlusBootstrapPending ? (workspaceInfoOptional.packageManager ?? @@ -1219,6 +1294,7 @@ async function main() { true, report, pendingCoreMigration, + nuxtVitestImportOptions, ); if ( coreMigrationResult.scripts || @@ -1273,6 +1349,7 @@ async function main() { downloadPackageManager: downloadResult, }, report, + nuxtVitestImportOptions, ); didMigrate = bootstrapResult.changed || didMigrate; needsInstall = bootstrapResult.changed || needsInstall; diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index e90cf304ee..2c62bc699e 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -627,6 +627,7 @@ function projectUsesVitestDirectly( peerDependencies?: Record; }, requiredVitestPeer = projectListsRequiredVitestPeer(projectPath, pkg), + preserveNuxtVitestImports = true, ): boolean { return ( projectListsVitestEcosystemDep(pkg) || @@ -639,6 +640,7 @@ function projectUsesVitestDirectly( // exact Vitest peer is left unsatisfied under strict pnpm/Yarn layouts. VITEST_BROWSER_DEP_NAMES.some((name) => pkg.peerDependencies?.[name] !== undefined) || sourceTreeReferencesRetainedVitestModule(projectPath) || + (preserveNuxtVitestImports && sourceTreeReferencesNuxtVitestImport(projectPath, pkg)) || usesVitestBrowserMode(projectPath) ); } @@ -1688,12 +1690,17 @@ export function addFrameworkShim( * Rewrite standalone project to add vite-plus dependencies * @param projectPath - The path to the project */ +export interface VitestImportMigrationOptions { + preserveNuxtVitestImports?: boolean; +} + export function rewriteStandaloneProject( projectPath: string, workspaceInfo: WorkspaceInfo, skipStagedMigration?: boolean, silent = false, report?: MigrationReport, + importOptions?: VitestImportMigrationOptions, ): void { const packageJsonPath = path.join(projectPath, 'package.json'); if (!fs.existsSync(packageJsonPath)) { @@ -1735,7 +1742,12 @@ export function rewriteStandaloneProject( shouldAllowBrowserProviderBuilds = hasOwnWebdriverioDependency(pkg) || usesWebdriverioProvider(projectPath); const requiredVitestPeer = projectListsRequiredVitestPeer(projectPath, pkg); - usesVitest = projectUsesVitestDirectly(projectPath, pkg, requiredVitestPeer); + usesVitest = projectUsesVitestDirectly( + projectPath, + pkg, + requiredVitestPeer, + importOptions?.preserveNuxtVitestImports !== false, + ); const managed = managedOverridePackages(usesVitest); // Strip stale `vite-plus-test` wrapper aliases before injecting new overrides // so the deleted wrapper doesn't survive migration in any sink. @@ -1926,7 +1938,12 @@ export function rewriteStandaloneProject( injectFmtDefaults(projectPath, silent, report); mergeTsdownConfigFile(projectPath, silent, report); // rewrite imports in all TypeScript/JavaScript files before lazy plugin import merging - rewriteAllImports(projectPath, silent, report); + rewriteAllImports( + projectPath, + silent, + report, + importOptions?.preserveNuxtVitestImports !== false, + ); wrapLazyPluginsInViteConfig(projectPath, silent, report); // set package manager setPackageManager(projectPath, workspaceInfo.downloadPackageManager); @@ -1941,6 +1958,7 @@ export function rewriteMonorepo( skipStagedMigration?: boolean, silent = false, report?: MigrationReport, + importOptions?: VitestImportMigrationOptions, ): void { const catalogDependencyResolver = createCatalogDependencyResolver( workspaceInfo.rootDir, @@ -1956,6 +1974,7 @@ export function rewriteMonorepo( const workspaceUsesVitest = workspaceUsesVitestDirectly( workspaceInfo.rootDir, workspaceInfo.packages, + importOptions?.preserveNuxtVitestImports !== false, ); // rewrite root workspace if (workspaceInfo.packageManager === PackageManager.pnpm) { @@ -1979,6 +1998,7 @@ export function rewriteMonorepo( pnpmMajorVersion, workspaceShouldAllowBrowserBuilds, workspaceUsesVitest, + importOptions, ); // (mergeViteConfigFiles below will sanitize the merged lint config // against this workspace's full package set.) @@ -2005,6 +2025,7 @@ export function rewriteMonorepo( catalogDependencyResolver, workspaceContext, true, + importOptions, ); } @@ -2018,7 +2039,12 @@ export function rewriteMonorepo( injectFmtDefaults(workspaceInfo.rootDir, silent, report); mergeTsdownConfigFile(workspaceInfo.rootDir, silent, report); // rewrite imports in all TypeScript/JavaScript files before lazy plugin import merging - rewriteAllImports(workspaceInfo.rootDir, silent, report); + rewriteAllImports( + workspaceInfo.rootDir, + silent, + report, + importOptions?.preserveNuxtVitestImports !== false, + ); wrapLazyPluginsInViteConfig(workspaceInfo.rootDir, silent, report); for (const pkg of workspaceInfo.packages) { wrapLazyPluginsInViteConfig(path.join(workspaceInfo.rootDir, pkg.path), silent, report); @@ -2045,6 +2071,7 @@ export function rewriteMonorepoProject( catalogDependencyResolver?: CatalogDependencyResolver, workspaceContext?: { rootDir: string; packages: WorkspacePackage[] }, deferLazyPluginWrapping = false, + importOptions?: VitestImportMigrationOptions, ): void { cleanupDeprecatedTsconfigOptions(projectPath, silent, report); rewriteTsconfigTypes(projectPath, silent, report); @@ -2091,7 +2118,12 @@ export function rewriteMonorepoProject( catalogDependencyResolver, usesVitestBrowserMode(projectPath), collectProviderSourceModes(projectPath), - projectUsesVitestDirectly(projectPath, pkg, requiredVitestPeer), + projectUsesVitestDirectly( + projectPath, + pkg, + requiredVitestPeer, + importOptions?.preserveNuxtVitestImports !== false, + ), sourceTreeReferencesRetainedVitestModule(projectPath), requiredVitestPeer, ); @@ -2413,9 +2445,10 @@ function workspaceUsesWebdriverio( function workspaceUsesVitestDirectly( rootDir: string, packages: WorkspacePackage[] | undefined, + preserveNuxtVitestImports = true, ): boolean { const rootPkg = readPackageJsonIfExists(path.join(rootDir, 'package.json')) ?? {}; - if (projectUsesVitestDirectly(rootDir, rootPkg)) { + if (projectUsesVitestDirectly(rootDir, rootPkg, undefined, preserveNuxtVitestImports)) { return true; } if (!packages) { @@ -2424,7 +2457,7 @@ function workspaceUsesVitestDirectly( for (const pkg of packages) { const packageDir = path.join(rootDir, pkg.path); const subPkg = readPackageJsonIfExists(path.join(packageDir, 'package.json')) ?? {}; - if (projectUsesVitestDirectly(packageDir, subPkg)) { + if (projectUsesVitestDirectly(packageDir, subPkg, undefined, preserveNuxtVitestImports)) { return true; } } @@ -3302,6 +3335,7 @@ function rewriteRootWorkspacePackageJson( // shared by every package, so `vitest` stays managed here iff ANY package uses // vitest directly. workspaceUsesVitest = true, + importOptions?: VitestImportMigrationOptions, ): void { const packageJsonPath = path.join(projectPath, 'package.json'); if (!fs.existsSync(packageJsonPath)) { @@ -3449,6 +3483,7 @@ function rewriteRootWorkspacePackageJson( catalogDependencyResolver, packages ? { rootDir: projectPath, packages } : undefined, true, + importOptions, ); } @@ -3551,6 +3586,7 @@ export function finalizeCoreMigrationForExistingVitePlus( silent = false, report?: MigrationReport, pending = detectPendingCoreMigration(workspaceInfo), + importOptions?: VitestImportMigrationOptions, ): CoreMigrationFinalizationResult { const projectPaths = getCoreMigrationProjectPaths(workspaceInfo); const result: CoreMigrationFinalizationResult = { @@ -3572,7 +3608,12 @@ export function finalizeCoreMigrationForExistingVitePlus( } } - result.imports = rewriteAllImports(workspaceInfo.rootDir, silent, report); + result.imports = rewriteAllImports( + workspaceInfo.rootDir, + silent, + report, + importOptions?.preserveNuxtVitestImports !== false, + ); return result; } @@ -3880,9 +3921,15 @@ function reconcileVitePlusBootstrapPackage( supportCatalog: boolean, ensureVitePlus: boolean, catalogDependencyResolver?: CatalogDependencyResolver, + importOptions?: VitestImportMigrationOptions, ): boolean { const before = JSON.stringify(pkg); - const usesVitest = projectUsesVitestDirectly(projectPath, pkg); + const usesVitest = projectUsesVitestDirectly( + projectPath, + pkg, + undefined, + importOptions?.preserveNuxtVitestImports !== false, + ); ensureVitePlusDependencySpecs(pkg, vitePlusVersion, ensureVitePlus); const installGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; @@ -4006,6 +4053,7 @@ export function detectVitePlusBootstrapPending( projectPath: string, packageManager: PackageManager | undefined, packages?: WorkspacePackage[], + importOptions?: VitestImportMigrationOptions, ): boolean { const packageJsonPath = path.join(projectPath, 'package.json'); if (!fs.existsSync(packageJsonPath)) { @@ -4050,6 +4098,7 @@ export function detectVitePlusBootstrapPending( supportCatalog, index === 0, catalogDependencyResolver, + importOptions, ) ) { return true; @@ -4058,7 +4107,11 @@ export function detectVitePlusBootstrapPending( // Shared override/catalog sinks must keep vitest managed when any package in // the workspace needs it. The direct dependency itself is localized above. - const usesVitest = workspaceUsesVitestDirectly(projectPath, packages); + const usesVitest = workspaceUsesVitestDirectly( + projectPath, + packages, + importOptions?.preserveNuxtVitestImports !== false, + ); if (packageManager === PackageManager.yarn) { return ( @@ -4211,6 +4264,7 @@ function ensurePnpmPeerDependencyRules(pkg: BootstrapPackageJson, usesVitest: bo export function ensureVitePlusBootstrap( workspaceInfo: WorkspaceInfo, report?: MigrationReport, + importOptions?: VitestImportMigrationOptions, ): VitePlusBootstrapResult { const projectPath = workspaceInfo.rootDir; const packageJsonPath = path.join(projectPath, 'package.json'); @@ -4228,7 +4282,11 @@ export function ensureVitePlusBootstrap( // Shared override/catalog sinks are workspace-wide, so keep vitest managed // when any package needs it. Each package's direct vitest dependency is // reconciled independently below. - const usesVitest = workspaceUsesVitestDirectly(projectPath, workspaceInfo.packages); + const usesVitest = workspaceUsesVitestDirectly( + projectPath, + workspaceInfo.packages, + importOptions?.preserveNuxtVitestImports !== false, + ); const pnpmMajorVersion = pnpmMajor(workspaceInfo.downloadPackageManager.version); const shouldAllowBrowserBuilds = workspaceUsesWebdriverio(projectPath, workspaceInfo.packages); const usePnpmWorkspaceYaml = @@ -4260,6 +4318,7 @@ export function ensureVitePlusBootstrap( supportCatalog, true, catalogDependencyResolver, + importOptions, ); if (workspaceInfo.packageManager === PackageManager.yarn) { @@ -4326,6 +4385,7 @@ export function ensureVitePlusBootstrap( supportCatalog, false, catalogDependencyResolver, + importOptions, ); return childChanged ? pkg : undefined; }); @@ -4552,10 +4612,12 @@ const VITEST_SCAN_SKIP_DIRS = new Set([ * is a separate package that the migration scans on its own pass, so the root * package must not inherit a browser-mode signal from a sub-package. */ -function sourceTreeMatches( +function sourceTreeMatchingFiles( projectPath: string, matchesContent: (content: string) => boolean, -): boolean { + stopAfterFirst = false, +): string[] { + const matchingFiles: string[] = []; const scanDir = (dir: string, isRoot: boolean): boolean => { let entries: fs.Dirent[]; try { @@ -4580,7 +4642,10 @@ function sourceTreeMatches( } else if (entry.isFile() && VITEST_SCAN_EXTENSIONS.has(path.extname(entry.name))) { try { if (matchesContent(fs.readFileSync(entryPath, 'utf8'))) { - return true; + matchingFiles.push(entryPath); + if (stopAfterFirst) { + return true; + } } } catch { // Unreadable file — ignore and keep scanning. @@ -4590,13 +4655,70 @@ function sourceTreeMatches( return false; }; - return scanDir(projectPath, true); + scanDir(projectPath, true); + return matchingFiles; +} + +function sourceTreeMatches( + projectPath: string, + matchesContent: (content: string) => boolean, +): boolean { + return sourceTreeMatchingFiles(projectPath, matchesContent, true).length > 0; } function sourceTreeReferencesAny(projectPath: string, hints: readonly string[]): boolean { return sourceTreeMatches(projectPath, (content) => hints.some((hint) => content.includes(hint))); } +const BARE_VITEST_MODULE_REFERENCE = + /(?:\bfrom\s*|\b(?:import|require)\s*\(\s*|\bimport\s*)['"]vitest['"]/m; +const NUXT_TEST_UTILS_MODULE_REFERENCE = + /(?:\bfrom\s*|\b(?:import|require)\s*\(\s*|\bimport\s*)['"]@nuxt\/test-utils(?:\/[^'"]+)?['"]/m; + +function hasNuxtTestUtilsDependency(pkg: DependencyBag): boolean { + return [pkg.dependencies, pkg.devDependencies, pkg.optionalDependencies].some( + (dependencies) => dependencies?.['@nuxt/test-utils'] !== undefined, + ); +} + +function sourceReferencesNuxtTestUtilsWithBareVitest(content: string): boolean { + return ( + BARE_VITEST_MODULE_REFERENCE.test(content) && NUXT_TEST_UTILS_MODULE_REFERENCE.test(content) + ); +} + +function sourceTreeReferencesNuxtVitestImport(projectPath: string, pkg: DependencyBag): boolean { + return ( + hasNuxtTestUtilsDependency(pkg) && + sourceTreeMatches(projectPath, sourceReferencesNuxtTestUtilsWithBareVitest) + ); +} + +/** + * Find files eligible for the @nuxt/test-utils bare-vitest compatibility choice. + * Each package is scanned independently so a root dependency does not leak into + * unrelated workspace manifests. + */ +export function detectNuxtTestUtilsVitestImportFiles( + rootDir: string, + packages?: WorkspacePackage[], +): string[] { + const files: string[] = []; + for (const projectPath of [ + rootDir, + ...(packages ?? []).map((pkg) => path.join(rootDir, pkg.path)), + ]) { + const pkg = readPackageJsonIfExists(path.join(projectPath, 'package.json')); + if (!pkg || !hasNuxtTestUtilsDependency(pkg)) { + continue; + } + files.push( + ...sourceTreeMatchingFiles(projectPath, sourceReferencesNuxtTestUtilsWithBareVitest), + ); + } + return [...new Set(files)]; +} + // Normal imports and triple-slash type directives from `vitest` are rewritten // to `vite-plus/test` later in the same migration and therefore do not justify // a lasting direct dependency. Module augmentations, `vitest/package.json`, and @@ -5732,13 +5854,20 @@ function wrapLazyPluginsInViteConfig( * This rewrites vite/vitest imports to @voidzero-dev/vite-plus * @param projectPath - The root directory to search for files */ -function rewriteAllImports(projectPath: string, silent = false, report?: MigrationReport): boolean { - const result = rewriteImportsInDirectory(projectPath); +function rewriteAllImports( + projectPath: string, + silent = false, + report?: MigrationReport, + preserveNuxtVitestImports = true, +): boolean { + const result = rewriteImportsInDirectory(projectPath, preserveNuxtVitestImports); const modified = result.modifiedFiles.length; + const preserved = result.preservedBareVitestFiles.length; const errors = result.errors.length; if (report) { report.rewrittenImportFileCount += modified; + report.preservedNuxtVitestImportFileCount += preserved; report.rewrittenImportErrors.push( ...result.errors.map((error) => ({ path: displayRelative(error.path), diff --git a/packages/cli/src/migration/report.ts b/packages/cli/src/migration/report.ts index 63391ae03a..d2bfe2bfec 100644 --- a/packages/cli/src/migration/report.ts +++ b/packages/cli/src/migration/report.ts @@ -7,6 +7,7 @@ export interface MigrationReport { tsdownImportCount: number; wrappedPluginConfigCount: number; rewrittenImportFileCount: number; + preservedNuxtVitestImportFileCount: number; rewrittenImportErrors: Array<{ path: string; message: string }>; eslintMigrated: boolean; prettierMigrated: boolean; @@ -28,6 +29,7 @@ export function createMigrationReport(): MigrationReport { tsdownImportCount: 0, wrappedPluginConfigCount: 0, rewrittenImportFileCount: 0, + preservedNuxtVitestImportFileCount: 0, rewrittenImportErrors: [], eslintMigrated: false, prettierMigrated: false, diff --git a/packages/cli/src/oxlint-plugin.ts b/packages/cli/src/oxlint-plugin.ts index 25ca9c2983..41a163fd0c 100644 --- a/packages/cli/src/oxlint-plugin.ts +++ b/packages/cli/src/oxlint-plugin.ts @@ -1,3 +1,6 @@ +import fs from 'node:fs'; +import path from 'node:path'; + import { definePlugin, defineRule } from '@oxlint/plugins'; import type { Context, ESTree } from '@oxlint/plugins'; @@ -98,13 +101,57 @@ function quoteSpecifier(literal: ESTree.StringLiteral, replacement: string): str return `${quote}${replacement}${quote}`; } +const NUXT_TEST_UTILS_MODULE_REFERENCE = + /(?:\bfrom\s*|\b(?:import|require)\s*\(\s*|\bimport\s*)['"]@nuxt\/test-utils(?:\/[^'"]+)?['"]/m; +const nuxtTestUtilsPackageCache = new Map(); + +function nearestPackageUsesNuxtTestUtils(filename: string): boolean { + if (!path.isAbsolute(filename)) { + return false; + } + let directory = path.dirname(filename); + while (true) { + const packageJsonPath = path.join(directory, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + const cached = nuxtTestUtilsPackageCache.get(packageJsonPath); + if (cached !== undefined) { + return cached; + } + let usesNuxtTestUtils = false; + try { + const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as { + dependencies?: Record; + devDependencies?: Record; + optionalDependencies?: Record; + }; + usesNuxtTestUtils = [pkg.dependencies, pkg.devDependencies, pkg.optionalDependencies].some( + (dependencies) => dependencies?.['@nuxt/test-utils'] !== undefined, + ); + } catch { + // Invalid or unreadable package metadata cannot opt into the exception. + } + nuxtTestUtilsPackageCache.set(packageJsonPath, usesNuxtTestUtils); + return usesNuxtTestUtils; + } + const parent = path.dirname(directory); + if (parent === directory) { + return false; + } + directory = parent; + } +} + function maybeReportLiteral( context: Context, literal: ESTree.Expression | ESTree.TSModuleDeclaration['id'] | null | undefined, + preserveBareVitest = false, ) { if (!literal || literal.type !== 'Literal' || typeof literal.value !== 'string') { return; } + if (preserveBareVitest && literal.value === 'vitest') { + return; + } const replacement = rewriteVitePlusImportSpecifier(literal.value); if (!replacement) { @@ -138,24 +185,30 @@ export const preferVitePlusImportsRule = defineRule({ }, }, createOnce(context: Context) { + let preserveBareVitest = false; return { + Program() { + preserveBareVitest = + nearestPackageUsesNuxtTestUtils(context.filename) && + NUXT_TEST_UTILS_MODULE_REFERENCE.test(context.sourceCode.text); + }, ImportDeclaration(node) { - maybeReportLiteral(context, node.source); + maybeReportLiteral(context, node.source, preserveBareVitest); }, ExportAllDeclaration(node) { - maybeReportLiteral(context, node.source); + maybeReportLiteral(context, node.source, preserveBareVitest); }, ExportNamedDeclaration(node) { - maybeReportLiteral(context, node.source); + maybeReportLiteral(context, node.source, preserveBareVitest); }, ImportExpression(node) { - maybeReportLiteral(context, node.source); + maybeReportLiteral(context, node.source, preserveBareVitest); }, TSImportType(node) { - maybeReportLiteral(context, node.source); + maybeReportLiteral(context, node.source, preserveBareVitest); }, TSExternalModuleReference(node) { - maybeReportLiteral(context, node.expression); + maybeReportLiteral(context, node.expression, preserveBareVitest); }, TSModuleDeclaration(node) { if (node.global) { @@ -169,7 +222,7 @@ export const preferVitePlusImportsRule = defineRule({ ) { return; } - maybeReportLiteral(context, id); + maybeReportLiteral(context, id, preserveBareVitest); }, }; }, diff --git a/rfcs/migrate-existing-projects.md b/rfcs/migrate-existing-projects.md index 64d74a4e8e..5a2abfdf91 100644 --- a/rfcs/migrate-existing-projects.md +++ b/rfcs/migrate-existing-projects.md @@ -23,21 +23,53 @@ Run on an existing Vite+ project, in order. The guiding fact for vitest: `vite-p Removing the old direct dependency was exercised on `node-modules/urllib` across pnpm, npm, and yarn (PRs [#832](https://github.com/node-modules/urllib/pull/832) / [#833](https://github.com/node-modules/urllib/pull/833) / [#834](https://github.com/node-modules/urllib/pull/834)). Those node-modules layouts can hoist an exact peer, but that is not portable to strict pnpm or Yarn PnP, so the migration still provisions required peers explicitly. Required-peer handling is covered for official `@vitest/*` packages and the third-party `vitest-browser-svelte` case. -| Area | Rule | -| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Routing | If the project's local `vite-plus` is older than the global `vp`, run `migrate` from the global CLI; otherwise keep local-first. | -| `vite-plus` spec | Re-pin a non-protocol-pinned spec (e.g. `^0.1.24`) to the toolchain target (`catalog:` in catalog projects, else the version) so the lockfile moves off the old resolution. Preserve deliberate protocol pins (`workspace:`/`file:`/`link:`/`npm:`/...). | -| `vite` override | Always managed: alias `vite` to `npm:@voidzero-dev/vite-plus-core@latest` in whatever override/resolution/catalog form the project uses; normalize a behind `core@` alias. | -| `vitest` itself (default) | Provided by `vite-plus`, so by default not project-managed: remove any project-level `vitest` from dependency fields, string-valued `overrides`/`resolutions`/`pnpm.overrides`, `pnpm-workspace.yaml` `overrides`+`catalog(s)`, bun/yarn catalog, and the `vitest` entry in pnpm `peerDependencyRules`. Resolve a surviving `peerDependencies.vitest` catalog reference to its public range before pruning the catalog. A future `vp update vite-plus` then keeps it correct with no project pin to drift. | -| `vitest`, peer/browser exception | Keep a managed `vitest` in the package that needs it (add to `devDependencies` and pin/override it to the bundled version) when that package directly installs a required-`vitest` peer consumer, uses browser mode, or retains a direct upstream `vitest` package reference. Required peers are detected from installed package metadata, not package names alone, so integrations such as `vite-plugin-gherkin` are covered. Retained references include module augmentations, `compilerOptions.types`, and the intentionally unre-written `vitest/package.json` export; rewriteable imports and triple-slash directives do not leave a lasting pin. The direct dependency satisfies strict peer resolution; the shared override collapses the workspace to the bundled version. | -| `vitest` ecosystem packages | When Vitest is managed, align current lockstep `@vitest/*` packages the project lists (`@vitest/coverage-v8`, `@vitest/coverage-istanbul`, `@vitest/ui`, `@vitest/web-worker`, ...) to the bundled `VITEST_VERSION`. Exclude `@vitest/eslint-plugin` (separate version line, optional `vitest: *` peer) and deprecated `@vitest/coverage-c8` (last published at `0.33.0`; no Vitest 4 release exists). When `VP_OVERRIDE_PACKAGES` omits Vitest, skip ecosystem alignment so user-owned exact-peer versions stay compatible. Browser packages keep their dedicated handling: `@vitest/browser` / `-preview` are bundled by `vite-plus`; `@vitest/browser-playwright` / `-webdriverio` are opt-in (pinned + framework peer kept). | -| Workspaces | Reconcile every package manifest, not only the root. Localize the direct `vitest` dependency to packages that need it; keep shared catalogs/overrides only when at least one package needs them. Re-pin existing plain `vite-plus` ranges consistently while preserving deliberate protocol specs. | -| Legacy wrapper | Remove every `@voidzero-dev/vite-plus-test` alias (deps, overrides, catalogs); repoint direct wrapper imports to `vite-plus/test`. `vite-plus/test*` imports are left unchanged (stable public API). | -| pnpm config location | An empty `"pnpm": {}` with an existing `pnpm-workspace.yaml` reconciles the workspace file (instead of writing a second, conflicting override block into `package.json`). | -| Reinstall + verify | One reinstall with lockfile refresh (`--no-frozen-lockfile` / `--force`); before npm reinstalls, remove a stale real-`vite` install/lock entry that npm otherwise retains after the dependency becomes the Vite+ core alias. A failed install warns and sets a non-zero exit. | +| Area | Rule | +| ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Routing | If the project's local `vite-plus` is older than the global `vp`, run `migrate` from the global CLI; otherwise keep local-first. | +| `vite-plus` spec | Re-pin a non-protocol-pinned spec (e.g. `^0.1.24`) to the toolchain target (`catalog:` in catalog projects, else the version) so the lockfile moves off the old resolution. Preserve deliberate protocol pins (`workspace:`/`file:`/`link:`/`npm:`/...). | +| `vite` override | Always managed: alias `vite` to `npm:@voidzero-dev/vite-plus-core@latest` in whatever override/resolution/catalog form the project uses; normalize a behind `core@` alias. | +| `vitest` itself (default) | Provided by `vite-plus`, so by default not project-managed: remove any project-level `vitest` from dependency fields, string-valued `overrides`/`resolutions`/`pnpm.overrides`, `pnpm-workspace.yaml` `overrides`+`catalog(s)`, bun/yarn catalog, and the `vitest` entry in pnpm `peerDependencyRules`. Resolve a surviving `peerDependencies.vitest` catalog reference to its public range before pruning the catalog. A future `vp update vite-plus` then keeps it correct with no project pin to drift. | +| `vitest`, peer/browser/Nuxt exception | Keep a managed `vitest` in the package that needs it (add to `devDependencies` and pin/override it to the bundled version) when that package directly installs a required-`vitest` peer consumer, uses browser mode, retains a direct upstream `vitest` package reference, or preserves bare `vitest` imports for `@nuxt/test-utils` compatibility. Required peers are detected from installed package metadata, not package names alone, so integrations such as `vite-plugin-gherkin` are covered. Other retained references include module augmentations, `compilerOptions.types`, and the intentionally unre-written `vitest/package.json` export; rewriteable imports and triple-slash directives do not leave a lasting pin. The direct dependency satisfies strict peer resolution; the shared override collapses the workspace to the bundled version. | +| `vitest` ecosystem packages | When Vitest is managed, align current lockstep `@vitest/*` packages the project lists (`@vitest/coverage-v8`, `@vitest/coverage-istanbul`, `@vitest/ui`, `@vitest/web-worker`, ...) to the bundled `VITEST_VERSION`. Exclude `@vitest/eslint-plugin` (separate version line, optional `vitest: *` peer) and deprecated `@vitest/coverage-c8` (last published at `0.33.0`; no Vitest 4 release exists). When `VP_OVERRIDE_PACKAGES` omits Vitest, skip ecosystem alignment so user-owned exact-peer versions stay compatible. Browser packages keep their dedicated handling: `@vitest/browser` / `-preview` are bundled by `vite-plus`; `@vitest/browser-playwright` / `-webdriverio` are opt-in (pinned + framework peer kept). | +| Workspaces | Reconcile every package manifest, not only the root. Localize the direct `vitest` dependency to packages that need it; keep shared catalogs/overrides only when at least one package needs them. Re-pin existing plain `vite-plus` ranges consistently while preserving deliberate protocol specs. | +| Legacy wrapper | Remove every `@voidzero-dev/vite-plus-test` alias (deps, overrides, catalogs); repoint direct wrapper imports to `vite-plus/test`. `vite-plus/test*` imports are left unchanged (stable public API). | +| pnpm config location | An empty `"pnpm": {}` with an existing `pnpm-workspace.yaml` reconciles the workspace file (instead of writing a second, conflicting override block into `package.json`). | +| Reinstall + verify | One reinstall with lockfile refresh (`--no-frozen-lockfile` / `--force`); before npm reinstalls, remove a stale real-`vite` install/lock entry that npm otherwise retains after the dependency becomes the Vite+ core alias. A failed install warns and sets a non-zero exit. | Force-override/CI mode (`VP_OVERRIDE_PACKAGES`) is respected: when `vitest` is not a managed key there, the project's own `vitest` is never stripped and its `@vitest/*` ecosystem dependencies are not realigned. Object-valued nested npm/Bun overrides are user-owned scopes rather than managed version pins and are preserved. +## `@nuxt/test-utils` compatibility + +`@nuxt/test-utils`'s transform detects an existing `vi` import only when its module specifier is exactly `vitest`. When a test uses `mockNuxtImport` or `mockComponent`, changing that import to `vite-plus/test` makes the transform inject a second `vi` import and can fail compilation with a duplicate identifier. The migration therefore treats bare `vitest` imports in Nuxt test-utils files as a compatibility boundary rather than applying the ordinary rewrite unconditionally. + +Detection and scope: + +1. A package is eligible when its `dependencies`, `devDependencies`, or `optionalDependencies` contains `@nuxt/test-utils`. +2. Within an eligible package, a Nuxt test-utils file is one that directly imports, exports from, requires, or dynamically imports `@nuxt/test-utils` or one of its subpaths. +3. The compatibility choice applies only to the exact bare specifier `vitest` in those files. `vitest/config`, every other `vitest/*` subpath, `@vitest/browser*`, and files unrelated to `@nuxt/test-utils` continue through the normal rewrites. +4. Preserving at least one bare import is retained direct Vitest usage, so that package keeps its package-local `vitest` and the workspace keeps the matching shared pin/catalog entry. +5. `prefer-vite-plus-imports` uses the same file-level exception. Lint and autofix must not undo the migration result. + +If eligible files with bare `vitest` imports exist, interactive migration asks: + +```text +◆ @nuxt/test-utils detected + +◆ How should bare `vitest` imports in Nuxt test files be handled? +│ ● Keep `vitest` imports (recommended) +│ Compatible with `mockNuxtImport` and `mockComponent` +│ ○ Rewrite to `vite-plus/test` +│ May require manual fixes for duplicate `vi` imports +``` + +`--no-interactive` selects the recommended preservation behavior. A preserving migration reports: + +```text +• Kept bare `vitest` imports in 7 files for @nuxt/test-utils compatibility +``` + +The count is the number of files, not import declarations. If the user explicitly selects rewriting, migration emits a compatibility warning identifying the possible duplicate-`vi` follow-up. + **Pending verification:** vitest **browser mode** historically needed a direct `vitest` injected (the "vibe-dashboard" regression). The upgrade now restores the opt-in provider and framework peer and keeps the package-local `vitest`; retain that behavior until a urllib-style pnpm/npm/yarn check proves any part is redundant. ## Vitest ecosystem packages @@ -61,10 +93,12 @@ How each package the `vitest` ecosystem rule covers is handled, verified against ## Implementation -| Area | Change | -| ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `crates/vite_global_cli` (`commands/migrate.rs`, `js_executor.rs`) | `delegate_migrate`: compare local `vite-plus` vs global `vp` version; escalate to the global CLI when older. | -| `packages/cli/src/migration/{migrator,npm-reinstall,bin}.ts` | Usage-aware managed override set; per-package dependency reconciliation; `vitest` removal across every sink; full `@vitest/*` alignment; browser-provider restoration; behind `vite-plus`/`vite` re-pin; empty/unrelated-`pnpm` routing fix; stale npm Vite install cleanup. | +| Area | Change | +| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `crates/vite_global_cli` (`commands/migrate.rs`, `js_executor.rs`) | `delegate_migrate`: compare local `vite-plus` vs global `vp` version; escalate to the global CLI when older. | +| `crates/vite_migration` (`import_rewriter.rs`) | Support a file-scoped Nuxt compatibility mode that preserves only exact bare `vitest` specifiers while continuing all Vitest subpath and browser-provider rewrites; return the preserved-file count for the migration summary. | +| `packages/cli/src/migration/{migrator,npm-reinstall,bin}.ts` | Usage-aware managed override set; per-package dependency reconciliation; `vitest` removal across every sink; full `@vitest/*` alignment; browser-provider restoration; behind `vite-plus`/`vite` re-pin; empty/unrelated-`pnpm` routing fix; stale npm Vite install cleanup; Nuxt dependency/file detection, prompt choice, and retained Vitest provisioning. | +| Oxlint `prefer-vite-plus-imports` rule | Apply the same Nuxt file-level bare-`vitest` exception so diagnostics and autofix preserve the migration's compatible result. | Covered by unit tests in `migrator.spec.ts` (vitest removal, required-peer provisioning, ecosystem alignment, browser-provider restoration, workspace localization, behind re-pin, empty-`pnpm` reconciliation), `npm-reinstall.spec.ts` (stale npm install and lock cleanup), and a routing test in `vite_global_cli`. @@ -93,6 +127,9 @@ Covered by unit tests in `migrator.spec.ts` (vitest removal, required-peer provi | Deprecated `@vitest/coverage-c8` is not assigned a nonexistent Vitest 4 version | `migration-upgrade-deprecated-coverage-c8-npm` | | Standalone Yarn writes catalog specs in one pass and is idempotent | `migration-standalone-yarn4-idempotent` | | Unmanaged exact-peer Vitest ecosystem versions remain aligned with user-owned Vitest | `migration-vitest-unmanaged-override` | +| Nuxt-compatible bare imports are preserved while Vitest subpaths still rewrite | `migration-upgrade-nuxt-test-utils`, `migration-upgrade-nuxt-test-utils-monorepo` | + +The matching Oxlint/autofix behavior is covered by the local `lint-vite-plus-imports-nuxt` snapshot: the Nuxt file's bare import remains exempt while its Vitest subpath and an unrelated file's bare import are both fixed. ## Follow-ups (not in this change) From 774bc8f21d7cab7fa9893a48b762ac9fab8ec667 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 23 Jun 2026 15:09:42 +0800 Subject: [PATCH 17/78] test(ecosystem-ci): update npmx.dev fixture --- ecosystem-ci/repo.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ecosystem-ci/repo.json b/ecosystem-ci/repo.json index 052fb48ee0..dad175cc9b 100644 --- a/ecosystem-ci/repo.json +++ b/ecosystem-ci/repo.json @@ -94,7 +94,7 @@ "npmx.dev": { "repository": "https://github.com/npmx-dev/npmx.dev.git", "branch": "main", - "hash": "230b7c7ddb6bb8551ce797144f0ce0f047ff8d7d", + "hash": "035776c96cf8f089c44e6011264b534b0bcde53c", "forceFreshMigration": true }, "vite-plus-jest-dom-repro": { From 461bbc69a669064f0667d5e6972a2ab4032b21d0 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 23 Jun 2026 15:38:11 +0800 Subject: [PATCH 18/78] test(cli): stabilize Nuxt lint snapshot --- .../cli/snap-tests/lint-vite-plus-imports-nuxt/snap.txt | 6 +++--- .../cli/snap-tests/lint-vite-plus-imports-nuxt/steps.json | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/snap.txt b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/snap.txt index b8965ab384..271607b5e0 100644 --- a/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/snap.txt +++ b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/snap.txt @@ -1,4 +1,4 @@ -[1]> vp lint src/nuxt.spec.ts src/unit.spec.ts # only the Nuxt bare import is exempt; subpaths and unrelated files still fail +[1]> vp lint --threads=1 src/nuxt.spec.ts src/unit.spec.ts # only the Nuxt bare import is exempt; subpaths and unrelated files still fail × vite-plus(prefer-vite-plus-imports): Use 'vite-plus/test/node' instead of 'vitest/node' in Vite+ projects. ╭─[src/nuxt.spec.ts:3:29] @@ -18,7 +18,7 @@ Found 0 warnings and 2 errors. Finished in ms on 2 files with rules using threads. -> vp lint --fix src/nuxt.spec.ts src/unit.spec.ts # preserve the compatible bare import while fixing all other Vitest imports +> vp lint --threads=1 --fix src/nuxt.spec.ts src/unit.spec.ts # preserve the compatible bare import while fixing all other Vitest imports Found 0 warnings and 0 errors. Finished in ms on 2 files with rules using threads. @@ -36,6 +36,6 @@ import { expect } from 'vite-plus/test'; void expect; -> vp lint src/nuxt.spec.ts src/unit.spec.ts # confirm the mixed compatible result is clean +> vp lint --threads=1 src/nuxt.spec.ts src/unit.spec.ts # confirm the mixed compatible result is clean Found 0 warnings and 0 errors. Finished in ms on 2 files with rules using threads. diff --git a/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/steps.json b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/steps.json index 6d28422626..e7e97ca151 100644 --- a/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/steps.json +++ b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/steps.json @@ -1,10 +1,10 @@ { "ignoredPlatforms": [{ "os": "linux", "libc": "musl" }], "commands": [ - "vp lint src/nuxt.spec.ts src/unit.spec.ts # only the Nuxt bare import is exempt; subpaths and unrelated files still fail", - "vp lint --fix src/nuxt.spec.ts src/unit.spec.ts # preserve the compatible bare import while fixing all other Vitest imports", + "vp lint --threads=1 src/nuxt.spec.ts src/unit.spec.ts # only the Nuxt bare import is exempt; subpaths and unrelated files still fail", + "vp lint --threads=1 --fix src/nuxt.spec.ts src/unit.spec.ts # preserve the compatible bare import while fixing all other Vitest imports", "cat src/nuxt.spec.ts", "cat src/unit.spec.ts", - "vp lint src/nuxt.spec.ts src/unit.spec.ts # confirm the mixed compatible result is clean" + "vp lint --threads=1 src/nuxt.spec.ts src/unit.spec.ts # confirm the mixed compatible result is clean" ] } From 92c88a01fb3c44c2f36f09696c7fff51280d7618 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 23 Jun 2026 16:01:32 +0800 Subject: [PATCH 19/78] fix(migrate): preserve Vitest across Nuxt packages --- .github/workflows/e2e-test.yml | 1 + crates/vite_migration/src/import_rewriter.rs | 124 ++++++++++-------- packages/cli/binding/index.d.cts | 10 +- packages/cli/binding/src/migration.rs | 16 +-- .../packages/nuxt/unit.spec.ts | 5 + .../snap.txt | 19 ++- .../steps.json | 7 +- .../snap.txt | 18 +-- .../steps.json | 10 +- .../lint-vite-plus-imports-nuxt/snap.txt | 32 ++--- .../src/unit.spec.ts | 2 + .../lint-vite-plus-imports-nuxt/steps.json | 6 +- .../cli/src/__tests__/oxlint-plugin.spec.ts | 20 ++- .../src/migration/__tests__/migrator.spec.ts | 52 +------- packages/cli/src/migration/bin.ts | 74 +---------- packages/cli/src/migration/migrator.ts | 33 ++--- packages/cli/src/oxlint-plugin.ts | 30 ++--- rfcs/migrate-existing-projects.md | 69 ++++------ 18 files changed, 215 insertions(+), 313 deletions(-) create mode 100644 packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/nuxt/unit.spec.ts diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index e63f1a51f1..3af6cba3fc 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -318,6 +318,7 @@ jobs: # on vi.fn() calls — migration sets rule as "error" in config, --allow can't override vp run lint || true vp run test:types + vp test --project nuxt vp test --project unit - name: vite-plus-jest-dom-repro node-version: 24 diff --git a/crates/vite_migration/src/import_rewriter.rs b/crates/vite_migration/src/import_rewriter.rs index 350f2e2538..7b94ae88f7 100644 --- a/crates/vite_migration/src/import_rewriter.rs +++ b/crates/vite_migration/src/import_rewriter.rs @@ -1586,20 +1586,26 @@ fn is_bare_vitest_rule(rule: &RuleConfig) -> bool { BARE_VITEST_RULE_IDS.contains(&rule.id.as_str()) } -static PARSED_BARE_VITEST_RULES: LazyLock>> = LazyLock::new(|| { +fn is_unscoped_vitest_rule(rule: &RuleConfig) -> bool { + is_bare_vitest_rule(rule) + || rule.id.starts_with("rewrite-vitest-config-") + || rule.id.starts_with("rewrite-vitest-subpath-") +} + +static PARSED_UNSCOPED_VITEST_RULES: LazyLock>> = LazyLock::new(|| { ast_grep::load_rules(REWRITE_VITEST_RULES) .expect("failed to parse vitest rewrite rules") .into_iter() - .filter(is_bare_vitest_rule) + .filter(is_unscoped_vitest_rule) .collect() }); -static PARSED_VITEST_RULES_WITHOUT_BARE: LazyLock>> = +static PARSED_VITEST_RULES_WITHOUT_UNSCOPED: LazyLock>> = LazyLock::new(|| { ast_grep::load_rules(REWRITE_VITEST_RULES) .expect("failed to parse vitest rewrite rules") .into_iter() - .filter(|rule| !is_bare_vitest_rule(rule)) + .filter(|rule| !is_unscoped_vitest_rule(rule)) .collect() }); @@ -1717,7 +1723,11 @@ fn apply_regex_replace(content: &mut String, re: &Regex, replacement: &str) -> b /// to match TypeScript semantics and avoid false positives inside string/template literals. /// Allocates only for preamble lines, leaving the file body untouched. /// Returns whether any changes were made. -fn rewrite_reference_types(content: &mut String, skip_packages: &SkipPackages) -> bool { +fn rewrite_reference_types( + content: &mut String, + skip_packages: &SkipPackages, + preserve_unscoped_vitest: bool, +) -> bool { // Fast path: skip files with no triple-slash reference directives. // Check for "///" which covers all spacing variants (///, /// Files that had no changes pub unchanged_files: Vec, - /// Nuxt test-utils files where exact bare `vitest` imports were preserved. - pub preserved_bare_vitest_files: Vec, + /// Files in Nuxt test-utils packages where upstream `vitest` imports were preserved. + pub preserved_vitest_files: Vec, /// Files that had errors (path, error message) pub errors: Vec<(PathBuf, String)>, } @@ -2083,7 +2099,7 @@ pub fn rewrite_imports_in_directory(root: &Path) -> Result { if rewrite_result.updated { if let Err(e) = std::fs::write(&file_path, &rewrite_result.content) { (file_path, FileResult::Error(e.to_string()), false) } else { - (file_path, FileResult::Modified, rewrite_result.preserved_bare_vitest) + (file_path, FileResult::Modified, rewrite_result.preserved_vitest) } } else { - (file_path, FileResult::Unchanged, rewrite_result.preserved_bare_vitest) + (file_path, FileResult::Unchanged, rewrite_result.preserved_vitest) } } Err(e) => (file_path, FileResult::Error(e.to_string()), false), @@ -2142,13 +2158,13 @@ pub fn rewrite_imports_in_directory_with_options( let mut batch_result = BatchRewriteResult { modified_files: Vec::new(), unchanged_files: Vec::new(), - preserved_bare_vitest_files: Vec::new(), + preserved_vitest_files: Vec::new(), errors: Vec::new(), }; - for (file_path, file_result, preserved_bare_vitest) in results { - if preserved_bare_vitest { - batch_result.preserved_bare_vitest_files.push(file_path.clone()); + for (file_path, file_result, preserved_vitest) in results { + if preserved_vitest { + batch_result.preserved_vitest_files.push(file_path.clone()); } match file_result { FileResult::Modified => batch_result.modified_files.push(file_path), @@ -2179,25 +2195,13 @@ pub fn rewrite_imports_in_directory_with_options( fn rewrite_import( file_path: &Path, skip_packages: &SkipPackages, - preserve_bare_vitest_in_nuxt_files: bool, + preserve_vitest_in_nuxt_package: bool, ) -> Result { // Read the file let content = std::fs::read_to_string(file_path)?; // Rewrite the imports - let preserve_bare_vitest = - preserve_bare_vitest_in_nuxt_files && source_directly_references_nuxt_test_utils(&content); - rewrite_import_content_with_options(&content, skip_packages, preserve_bare_vitest) -} - -fn source_directly_references_nuxt_test_utils(content: &str) -> bool { - static RE_NUXT_TEST_UTILS_REFERENCE: LazyLock = LazyLock::new(|| { - Regex::new( - r#"(?m)(?:\bfrom\s*|\b(?:import|require)\s*\(\s*|\bimport\s*)["']@nuxt/test-utils(?:/[^"']+)?["']"#, - ) - .unwrap() - }); - RE_NUXT_TEST_UTILS_REFERENCE.is_match(content) + rewrite_import_content_with_options(&content, skip_packages, preserve_vitest_in_nuxt_package) } /// Fast pre-filter to skip expensive AST parsing for files with no relevant imports. @@ -2231,20 +2235,20 @@ fn rewrite_import_content( fn rewrite_import_content_with_options( content: &str, skip_packages: &SkipPackages, - preserve_bare_vitest: bool, + preserve_unscoped_vitest: bool, ) -> Result { // Fast path: skip AST parsing if the file doesn't contain any target strings if !content_may_need_rewriting(content, skip_packages) { return Ok(RewriteResult { content: content.to_string(), updated: false, - preserved_bare_vitest: false, + preserved_vitest: false, }); } let mut new_content = content.to_string(); let mut updated = false; - let mut preserved_bare_vitest = false; + let mut preserved_vitest = false; // Apply vite rules if not skipped (using pre-parsed rules) if !skip_packages.skip_vite { @@ -2257,11 +2261,11 @@ fn rewrite_import_content_with_options( // Apply vitest rules if not skipped (using pre-parsed rules) if !skip_packages.skip_vitest { - let vitest_rules = if preserve_bare_vitest { - let bare_rewrite = - ast_grep::apply_loaded_rules(&new_content, &PARSED_BARE_VITEST_RULES); - preserved_bare_vitest = bare_rewrite != new_content; - &*PARSED_VITEST_RULES_WITHOUT_BARE + let vitest_rules = if preserve_unscoped_vitest { + let upstream_rewrite = + ast_grep::apply_loaded_rules(&new_content, &PARSED_UNSCOPED_VITEST_RULES); + preserved_vitest = upstream_rewrite != new_content; + &*PARSED_VITEST_RULES_WITHOUT_UNSCOPED } else { &*PARSED_VITEST_RULES }; @@ -2283,9 +2287,9 @@ fn rewrite_import_content_with_options( // Apply reference type rewriting (/// ) // These cannot be handled by ast-grep because they are parsed as comments. - updated |= rewrite_reference_types(&mut new_content, skip_packages); + updated |= rewrite_reference_types(&mut new_content, skip_packages, preserve_unscoped_vitest); - Ok(RewriteResult { content: new_content, updated, preserved_bare_vitest }) + Ok(RewriteResult { content: new_content, updated, preserved_vitest }) } #[cfg(test)] @@ -2893,7 +2897,7 @@ describe('test', () => {});"#, } #[test] - fn test_preserves_only_bare_vitest_in_nuxt_test_utils_files() { + fn test_preserves_unscoped_vitest_in_nuxt_test_utils_packages() { use std::fs; let temp = tempdir().unwrap(); @@ -2919,26 +2923,32 @@ import { page } from '@vitest/browser/context'; import { mockNuxtImport } from '@nuxt/test-utils/runtime';"#, ) .unwrap(); - fs::write(temp.path().join("ordinary.spec.ts"), "import { expect } from 'vitest';\n") - .unwrap(); + fs::write( + temp.path().join("ordinary.spec.ts"), + "/// \nimport { expect } from 'vitest';\n", + ) + .unwrap(); let result = rewrite_imports_in_directory_with_options( temp.path(), - RewriteImportsOptions { preserve_bare_vitest_in_nuxt_files: true }, + RewriteImportsOptions { preserve_vitest_in_nuxt_packages: true }, ) .unwrap(); - assert_eq!(result.preserved_bare_vitest_files, [temp.path().join("nuxt.spec.ts")]); + assert_eq!(result.preserved_vitest_files.len(), 2); + assert!(result.preserved_vitest_files.contains(&temp.path().join("nuxt.spec.ts"))); + assert!(result.preserved_vitest_files.contains(&temp.path().join("ordinary.spec.ts"))); let nuxt = fs::read_to_string(temp.path().join("nuxt.spec.ts")).unwrap(); assert!(nuxt.contains("from 'vitest'")); assert!(nuxt.contains("require('vitest')")); assert!(nuxt.contains("import('vitest')")); - assert!(nuxt.contains("from 'vite-plus'")); - assert!(nuxt.contains("from 'vite-plus/test/node'")); + assert!(nuxt.contains("from 'vitest/config'")); + assert!(nuxt.contains("from 'vitest/node'")); assert!(nuxt.contains("from 'vite-plus/test/browser/context'")); let ordinary = fs::read_to_string(temp.path().join("ordinary.spec.ts")).unwrap(); - assert!(ordinary.contains("from 'vite-plus/test'")); + assert!(ordinary.contains("from 'vitest'")); + assert!(ordinary.contains("types=\"vitest/globals\"")); } #[test] @@ -2956,11 +2966,11 @@ import { mockNuxtImport } from '@nuxt/test-utils/runtime';"#, let result = rewrite_imports_in_directory_with_options( temp.path(), - RewriteImportsOptions { preserve_bare_vitest_in_nuxt_files: true }, + RewriteImportsOptions { preserve_vitest_in_nuxt_packages: true }, ) .unwrap(); - assert!(result.preserved_bare_vitest_files.is_empty()); + assert!(result.preserved_vitest_files.is_empty()); let content = fs::read_to_string(temp.path().join("nuxt.spec.ts")).unwrap(); assert!(content.contains("from 'vite-plus/test'")); } diff --git a/packages/cli/binding/index.d.cts b/packages/cli/binding/index.d.cts index 30bc455d31..5d1ad7d870 100644 --- a/packages/cli/binding/index.d.cts +++ b/packages/cli/binding/index.d.cts @@ -3288,8 +3288,8 @@ export interface BatchRewriteError { export interface BatchRewriteResult { /** Files that were modified */ modifiedFiles: Array; - /** Nuxt test-utils files where exact bare `vitest` imports were preserved */ - preservedBareVitestFiles: Array; + /** Files in Nuxt test-utils packages where upstream `vitest` imports were preserved */ + preservedVitestFiles: Array; /** Files that had errors */ errors: Array; } @@ -3522,8 +3522,8 @@ export declare function rewriteEslint(scriptsJson: string): string | null; * # Arguments * * * `root` - The root directory to search for files - * * `preserve_bare_vitest_in_nuxt_files` - Preserve exact bare `vitest` - * specifiers in files that directly reference a declared `@nuxt/test-utils` + * * `preserve_vitest_in_nuxt_packages` - Preserve `vitest` and `vitest/*` + * specifiers throughout packages that declare `@nuxt/test-utils` * * # Returns * @@ -3543,7 +3543,7 @@ export declare function rewriteEslint(scriptsJson: string): string | null; */ export declare function rewriteImportsInDirectory( root: string, - preserveBareVitestInNuxtFiles?: boolean | undefined | null, + preserveVitestInNuxtPackages?: boolean | undefined | null, ): BatchRewriteResult; /** diff --git a/packages/cli/binding/src/migration.rs b/packages/cli/binding/src/migration.rs index a702fca944..9306f7b79d 100644 --- a/packages/cli/binding/src/migration.rs +++ b/packages/cli/binding/src/migration.rs @@ -197,8 +197,8 @@ pub struct BatchRewriteError { pub struct BatchRewriteResult { /// Files that were modified pub modified_files: Vec, - /// Nuxt test-utils files where exact bare `vitest` imports were preserved - pub preserved_bare_vitest_files: Vec, + /// Files in Nuxt test-utils packages where upstream `vitest` imports were preserved + pub preserved_vitest_files: Vec, /// Files that had errors pub errors: Vec, } @@ -268,8 +268,8 @@ pub fn wrap_lazy_plugins(vite_config_path: String) -> Result Result, + preserve_vitest_in_nuxt_packages: Option, ) -> Result { let result = vite_migration::rewrite_imports_in_directory_with_options( Path::new(&root), vite_migration::RewriteImportsOptions { - preserve_bare_vitest_in_nuxt_files: preserve_bare_vitest_in_nuxt_files.unwrap_or(false), + preserve_vitest_in_nuxt_packages: preserve_vitest_in_nuxt_packages.unwrap_or(false), }, ) .map_err(anyhow::Error::from)?; @@ -305,8 +305,8 @@ pub fn rewrite_imports_in_directory( .iter() .map(|p| p.to_string_lossy().to_string()) .collect(), - preserved_bare_vitest_files: result - .preserved_bare_vitest_files + preserved_vitest_files: result + .preserved_vitest_files .iter() .map(|p| p.to_string_lossy().to_string()) .collect(), diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/nuxt/unit.spec.ts b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/nuxt/unit.spec.ts new file mode 100644 index 0000000000..3ea9392334 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/nuxt/unit.spec.ts @@ -0,0 +1,5 @@ +import { expect } from 'vitest'; +import { startVitest } from 'vitest/node'; + +void expect; +void startVitest; diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/snap.txt index f6823a49b2..e43f57906d 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/snap.txt @@ -1,8 +1,8 @@ -> vp migrate --no-interactive # preserve Nuxt imports and localize direct Vitest to the affected workspace +> vp migrate --no-interactive # preserve upstream Vitest package-wide and localize it to the affected workspace ◇ Migrated . to Vite+ • Node pnpm -• 2 files had imports rewritten -• Kept bare `vitest` imports in 1 file for @nuxt/test-utils compatibility +• 1 file had imports rewritten +• Kept upstream `vitest` imports in 2 files for @nuxt/test-utils compatibility • Package manager settings configured > cat packages/nuxt/package.json # affected workspace keeps direct Vitest @@ -42,16 +42,23 @@ peerDependencyRules: vite: '*' vitest: '*' -> cat packages/nuxt/nuxt.spec.ts # bare Vitest stays while its subpath migrates +> cat packages/nuxt/nuxt.spec.ts # upstream Vitest and its subpath stay import { mockNuxtImport } from '@nuxt/test-utils/runtime'; import { expect, vi } from 'vitest'; -import { startVitest } from 'vite-plus/test/node'; +import { startVitest } from 'vitest/node'; mockNuxtImport('useExample', () => vi.fn()); void expect; void startVitest; -> cat packages/unit/unit.spec.ts # unrelated bare Vitest migrates +> cat packages/nuxt/unit.spec.ts # files without Nuxt imports still preserve Vitest in the affected package +import { expect } from 'vitest'; +import { startVitest } from 'vitest/node'; + +void expect; +void startVitest; + +> cat packages/unit/unit.spec.ts # an unrelated workspace still migrates Vitest import { expect } from 'vite-plus/test'; void expect; diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/steps.json b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/steps.json index f87e8c2a72..b454ea054b 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/steps.json +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/steps.json @@ -1,11 +1,12 @@ { "commands": [ - "vp migrate --no-interactive # preserve Nuxt imports and localize direct Vitest to the affected workspace", + "vp migrate --no-interactive # preserve upstream Vitest package-wide and localize it to the affected workspace", "cat packages/nuxt/package.json # affected workspace keeps direct Vitest", "cat packages/unit/package.json # unrelated workspace drops direct Vitest", "cat pnpm-workspace.yaml # shared Vitest pin remains because one workspace needs it", - "cat packages/nuxt/nuxt.spec.ts # bare Vitest stays while its subpath migrates", - "cat packages/unit/unit.spec.ts # unrelated bare Vitest migrates", + "cat packages/nuxt/nuxt.spec.ts # upstream Vitest and its subpath stay", + "cat packages/nuxt/unit.spec.ts # files without Nuxt imports still preserve Vitest in the affected package", + "cat packages/unit/unit.spec.ts # an unrelated workspace still migrates Vitest", "vp migrate --no-interactive # workspace result is idempotent" ], "ignoredPlatforms": [ diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/snap.txt index 681c907914..f0fca66ab3 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/snap.txt @@ -1,11 +1,11 @@ -> vp migrate --no-interactive # preserve Nuxt-compatible bare imports by default while rewriting other Vitest surfaces +> vp migrate --no-interactive # preserve upstream Vitest throughout packages that declare @nuxt/test-utils ◇ Migrated . to Vite+ • Node npm -• 2 files had imports rewritten -• Kept bare `vitest` imports in 1 file for @nuxt/test-utils compatibility +• 1 file had imports rewritten +• Kept upstream `vitest` imports in 2 files for @nuxt/test-utils compatibility • Package manager settings configured -> cat package.json # direct Vitest and its shared pin remain for the preserved import +> cat package.json # direct Vitest and its shared pin remain for the package-level exception { "name": "migration-upgrade-nuxt-test-utils", "devDependencies": { @@ -26,21 +26,21 @@ } } -> cat nuxt.spec.ts # bare Vitest stays; config and browser subpaths migrate +> cat nuxt.spec.ts # unscoped Vitest stays while the scoped browser package migrates import { mockNuxtImport } from '@nuxt/test-utils/runtime'; import { page } from 'vite-plus/test/browser/context'; import { vi } from 'vitest'; -import { defineConfig } from 'vite-plus'; +import { defineConfig } from 'vitest/config'; mockNuxtImport('useExample', () => vi.fn()); void page; void defineConfig; -> cat unit.spec.ts # unrelated bare Vitest imports still migrate -import { expect } from 'vite-plus/test'; +> cat unit.spec.ts # an unrelated test file in the same package also keeps upstream Vitest +import { expect } from 'vitest'; expect(true).toBe(true); -> vp migrate --no-interactive # the compatibility result is idempotent +> vp migrate --no-interactive # the package-level compatibility result is idempotent This project is already using Vite+! Happy coding! diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/steps.json b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/steps.json index 0f54655d5c..a3c9b5ae00 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/steps.json +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/steps.json @@ -1,10 +1,10 @@ { "commands": [ - "vp migrate --no-interactive # preserve Nuxt-compatible bare imports by default while rewriting other Vitest surfaces", - "cat package.json # direct Vitest and its shared pin remain for the preserved import", - "cat nuxt.spec.ts # bare Vitest stays; config and browser subpaths migrate", - "cat unit.spec.ts # unrelated bare Vitest imports still migrate", - "vp migrate --no-interactive # the compatibility result is idempotent" + "vp migrate --no-interactive # preserve upstream Vitest throughout packages that declare @nuxt/test-utils", + "cat package.json # direct Vitest and its shared pin remain for the package-level exception", + "cat nuxt.spec.ts # unscoped Vitest stays while the scoped browser package migrates", + "cat unit.spec.ts # an unrelated test file in the same package also keeps upstream Vitest", + "vp migrate --no-interactive # the package-level compatibility result is idempotent" ], "ignoredPlatforms": [ { diff --git a/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/snap.txt b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/snap.txt index 271607b5e0..f98e748c25 100644 --- a/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/snap.txt +++ b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/snap.txt @@ -1,41 +1,35 @@ -[1]> vp lint --threads=1 src/nuxt.spec.ts src/unit.spec.ts # only the Nuxt bare import is exempt; subpaths and unrelated files still fail - - × vite-plus(prefer-vite-plus-imports): Use 'vite-plus/test/node' instead of 'vitest/node' in Vite+ projects. - ╭─[src/nuxt.spec.ts:3:29] - 2 │ import { expect, vi } from 'vitest'; - 3 │ import { startVitest } from 'vitest/node'; - · ───────────── - 4 │ - ╰──── +[1]> vp lint --threads=1 src/nuxt.spec.ts src/unit.spec.ts # all upstream Vitest imports are exempt; the unrelated Vite import still fails - × vite-plus(prefer-vite-plus-imports): Use 'vite-plus/test' instead of 'vitest' in Vite+ projects. - ╭─[src/unit.spec.ts:1:24] - 1 │ import { expect } from 'vitest'; - · ──────── - 2 │ + × vite-plus(prefer-vite-plus-imports): Use 'vite-plus' instead of 'vite' in Vite+ projects. + ╭─[src/unit.spec.ts:1:30] + 1 │ import { defineConfig } from 'vite'; + · ────── + 2 │ import { expect } from 'vitest'; ╰──── -Found 0 warnings and 2 errors. +Found 0 warnings and 1 error. Finished in ms on 2 files with rules using threads. -> vp lint --threads=1 --fix src/nuxt.spec.ts src/unit.spec.ts # preserve the compatible bare import while fixing all other Vitest imports +> vp lint --threads=1 --fix src/nuxt.spec.ts src/unit.spec.ts # fix Vite without changing any upstream Vitest imports Found 0 warnings and 0 errors. Finished in ms on 2 files with rules using threads. > cat src/nuxt.spec.ts import { mockNuxtImport } from '@nuxt/test-utils/runtime'; import { expect, vi } from 'vitest'; -import { startVitest } from 'vite-plus/test/node'; +import { startVitest } from 'vitest/node'; mockNuxtImport('useExample', () => vi.fn()); void expect; void startVitest; > cat src/unit.spec.ts -import { expect } from 'vite-plus/test'; +import { defineConfig } from 'vite-plus'; +import { expect } from 'vitest'; +void defineConfig; void expect; -> vp lint --threads=1 src/nuxt.spec.ts src/unit.spec.ts # confirm the mixed compatible result is clean +> vp lint --threads=1 src/nuxt.spec.ts src/unit.spec.ts # confirm the package-level compatible result is clean Found 0 warnings and 0 errors. Finished in ms on 2 files with rules using threads. diff --git a/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/src/unit.spec.ts b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/src/unit.spec.ts index a5a3f5c5c2..ec1d98893d 100644 --- a/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/src/unit.spec.ts +++ b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/src/unit.spec.ts @@ -1,3 +1,5 @@ +import { defineConfig } from 'vite'; import { expect } from 'vitest'; +void defineConfig; void expect; diff --git a/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/steps.json b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/steps.json index e7e97ca151..454491842b 100644 --- a/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/steps.json +++ b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/steps.json @@ -1,10 +1,10 @@ { "ignoredPlatforms": [{ "os": "linux", "libc": "musl" }], "commands": [ - "vp lint --threads=1 src/nuxt.spec.ts src/unit.spec.ts # only the Nuxt bare import is exempt; subpaths and unrelated files still fail", - "vp lint --threads=1 --fix src/nuxt.spec.ts src/unit.spec.ts # preserve the compatible bare import while fixing all other Vitest imports", + "vp lint --threads=1 src/nuxt.spec.ts src/unit.spec.ts # all upstream Vitest imports are exempt; the unrelated Vite import still fails", + "vp lint --threads=1 --fix src/nuxt.spec.ts src/unit.spec.ts # fix Vite without changing any upstream Vitest imports", "cat src/nuxt.spec.ts", "cat src/unit.spec.ts", - "vp lint --threads=1 src/nuxt.spec.ts src/unit.spec.ts # confirm the mixed compatible result is clean" + "vp lint --threads=1 src/nuxt.spec.ts src/unit.spec.ts # confirm the package-level compatible result is clean" ] } diff --git a/packages/cli/src/__tests__/oxlint-plugin.spec.ts b/packages/cli/src/__tests__/oxlint-plugin.spec.ts index 94c7cf148d..0c66f92072 100644 --- a/packages/cli/src/__tests__/oxlint-plugin.spec.ts +++ b/packages/cli/src/__tests__/oxlint-plugin.spec.ts @@ -16,6 +16,10 @@ const nuxtTestFilename = path.join( import.meta.dirname, 'fixtures/nuxt-test-utils/component.spec.ts', ); +const nuxtUnitTestFilename = path.join( + import.meta.dirname, + 'fixtures/nuxt-test-utils/unit.spec.ts', +); describe('oxlint plugin config defaults', () => { it('adds vite-plus js plugin and lint rule defaults', () => { @@ -158,8 +162,18 @@ new RuleTester({ code: `import { vi } from 'vitest';\nimport { mockNuxtImport } from '@nuxt/test-utils/runtime';`, filename: nuxtTestFilename, }, + { + code: `import { expect } from 'vitest';\nimport { startVitest } from 'vitest/node';\nimport { defineConfig } from 'vitest/config';`, + filename: nuxtUnitTestFilename, + }, ], invalid: [ + { + code: `import { page } from '@vitest/browser/context'`, + errors: 1, + filename: nuxtUnitTestFilename, + output: `import { page } from 'vite-plus/test/browser/context'`, + }, { // `declare module 'vite'` IS rewritten — the vite family doesn't // re-export upstream vite types so augmentation works against either id. @@ -224,9 +238,9 @@ new RuleTester({ }, { code: `import { vi } from 'vitest';\nimport { startVitest } from 'vitest/node';\nimport { mockNuxtImport } from '@nuxt/test-utils/runtime';`, - errors: 1, - filename: nuxtTestFilename, - output: `import { vi } from 'vitest';\nimport { startVitest } from 'vite-plus/test/node';\nimport { mockNuxtImport } from '@nuxt/test-utils/runtime';`, + errors: 2, + filename: path.join(import.meta.dirname, 'ordinary.spec.ts'), + output: `import { vi } from 'vite-plus/test';\nimport { startVitest } from 'vite-plus/test/node';\nimport { mockNuxtImport } from '@nuxt/test-utils/runtime';`, }, { code: `import { vi } from 'vitest';\nimport { mockNuxtImport } from '@nuxt/test-utils/runtime';`, diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 392c6d548c..5aa64a34d7 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -2934,7 +2934,7 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { }); it.each(['dependencies', 'devDependencies', 'optionalDependencies'] as const)( - 'detects Nuxt-compatible bare Vitest imports from %s without installed metadata', + 'detects package-wide upstream Vitest imports from %s without installed metadata', (dependencyGroup) => { fs.writeFileSync( path.join(tmpDir, 'package.json'), @@ -2951,11 +2951,12 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { expect(detectNuxtTestUtilsVitestImportFiles(tmpDir)).toEqual([ path.join(tmpDir, 'nuxt.spec.ts'), + path.join(tmpDir, 'unit.spec.ts'), ]); }, ); - it('preserves Nuxt bare Vitest imports, keeps direct Vitest, and rewrites subpaths', () => { + it('preserves all upstream Vitest imports in a Nuxt test-utils package', () => { fs.writeFileSync( path.join(tmpDir, 'package.json'), JSON.stringify({ @@ -2995,50 +2996,9 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { expect(pkg.overrides.vitest).toBe(VITEST_VERSION); const nuxtTest = fs.readFileSync(path.join(tmpDir, 'nuxt.spec.ts'), 'utf8'); expect(nuxtTest).toContain("from 'vitest'"); - expect(nuxtTest).toContain("from 'vite-plus'"); - expect(fs.readFileSync(path.join(tmpDir, 'unit.spec.ts'), 'utf8')).toContain( - "from 'vite-plus/test'", - ); - expect(report.preservedNuxtVitestImportFileCount).toBe(1); - }); - - it('rewrites Nuxt bare Vitest imports when compatibility preservation is declined', () => { - fs.writeFileSync( - path.join(tmpDir, 'package.json'), - JSON.stringify({ - name: 'nuxt-project', - devDependencies: { - vite: '^7.0.0', - vitest: '^4.0.0', - '@nuxt/test-utils': '^4.0.3', - }, - }), - ); - fs.writeFileSync( - path.join(tmpDir, 'nuxt.spec.ts'), - "import { vi } from 'vitest';\nimport { mockNuxtImport } from '@nuxt/test-utils/runtime';\n", - ); - const report = createMigrationReport(); - - rewriteStandaloneProject( - tmpDir, - makeWorkspaceInfo(tmpDir, PackageManager.npm), - true, - true, - report, - { preserveNuxtVitestImports: false }, - ); - - const pkg = readJson(path.join(tmpDir, 'package.json')) as { - devDependencies: Record; - overrides: Record; - }; - expect(pkg.devDependencies.vitest).toBeUndefined(); - expect(pkg.overrides.vitest).toBeUndefined(); - expect(fs.readFileSync(path.join(tmpDir, 'nuxt.spec.ts'), 'utf8')).toContain( - "from 'vite-plus/test'", - ); - expect(report.preservedNuxtVitestImportFileCount).toBe(0); + expect(nuxtTest).toContain("from 'vitest/config'"); + expect(fs.readFileSync(path.join(tmpDir, 'unit.spec.ts'), 'utf8')).toContain("from 'vitest'"); + expect(report.preservedNuxtVitestImportFileCount).toBe(2); }); it('does not add a coverage provider the project never declared', () => { diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index c5c9c4db36..cc374b35bb 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -55,7 +55,6 @@ import { detectFramework, detectIncompatibleEslintIntegration, detectNodeVersionManagerFile, - detectNuxtTestUtilsVitestImportFiles, detectPendingCoreMigration, detectPrettierProject, detectVitePlusBootstrapPending, @@ -348,51 +347,6 @@ interface MigrationPlan extends MigrationSetupPlan { migrateNodeVersionFile: boolean; nodeVersionDetection?: NodeVersionManagerDetection; frameworkShimFrameworks?: Framework[]; - preserveNuxtVitestImports: boolean; - nuxtVitestUnsafeRewrite: boolean; -} - -const NUXT_VITEST_REWRITE_WARNING = - '@nuxt/test-utils compatibility: bare `vitest` imports were rewritten. Files using ' + - '`mockNuxtImport` or `mockComponent` may need manual fixes for duplicate `vi` imports.'; - -async function collectNuxtVitestImportDecision( - rootDir: string, - options: MigrationOptions, - packages?: WorkspacePackage[], -): Promise<{ preserveNuxtVitestImports: boolean; nuxtVitestUnsafeRewrite: boolean }> { - const affectedFiles = detectNuxtTestUtilsVitestImportFiles(rootDir, packages); - if (affectedFiles.length === 0) { - return { preserveNuxtVitestImports: true, nuxtVitestUnsafeRewrite: false }; - } - if (!options.interactive) { - return { preserveNuxtVitestImports: true, nuxtVitestUnsafeRewrite: false }; - } - - prompts.log.step('@nuxt/test-utils detected', { withGuide: true }); - const action = await prompts.select({ - message: 'How should bare `vitest` imports in Nuxt test files be handled?', - options: [ - { - label: 'Keep `vitest` imports (recommended)', - value: 'preserve' as const, - hint: 'Compatible with mockNuxtImport and mockComponent', - }, - { - label: 'Rewrite to `vite-plus/test`', - value: 'rewrite' as const, - hint: 'May require manual fixes for duplicate vi imports', - }, - ], - initialValue: 'preserve' as const, - }); - if (prompts.isCancel(action)) { - cancelAndExit(); - } - return { - preserveNuxtVitestImports: action === 'preserve', - nuxtVitestUnsafeRewrite: action === 'rewrite', - }; } function getFrameworkShimCandidates(rootDir: string, packages?: WorkspacePackage[]): Framework[] { @@ -682,8 +636,6 @@ async function collectMigrationPlan( const packageManager = detectedPackageManager ?? (await selectPackageManager(options.interactive, true)); - const nuxtVitestPlan = await collectNuxtVitestImportDecision(rootDir, options, packages); - // 2. Shared setup/tooling decisions const setupPlan = await collectMigrationSetupPlan(rootDir, packageManager, options, packages); @@ -723,7 +675,6 @@ async function collectMigrationPlan( migrateNodeVersionFile, nodeVersionDetection, frameworkShimFrameworks, - ...nuxtVitestPlan, }; return plan; @@ -840,7 +791,7 @@ function showMigrationSummary(options: { } if (report.preservedNuxtVitestImportFileCount > 0) { log( - `${styleText('gray', '•')} Kept bare \`vitest\` imports in ${report.preservedNuxtVitestImportFileCount} ${ + `${styleText('gray', '•')} Kept upstream \`vitest\` imports in ${report.preservedNuxtVitestImportFileCount} ${ report.preservedNuxtVitestImportFileCount === 1 ? 'file' : 'files' } for @nuxt/test-utils compatibility`, ); @@ -965,9 +916,6 @@ async function executeMigrationPlan( report: MigrationReport; }> { const report = createMigrationReport(); - if (plan.nuxtVitestUnsafeRewrite) { - addMigrationWarning(report, NUXT_VITEST_REWRITE_WARNING); - } const migrationProgress = interactive ? prompts.spinner({ indicator: 'timer' }) : undefined; let migrationProgressStarted = false; const updateMigrationProgress = (message: string) => { @@ -1088,9 +1036,7 @@ async function executeMigrationPlan( // 7. Rewrite configs updateMigrationProgress('Rewriting configs'); if (workspaceInfo.isMonorepo) { - rewriteMonorepo(workspaceInfo, skipStagedMigration, true, report, { - preserveNuxtVitestImports: plan.preserveNuxtVitestImports, - }); + rewriteMonorepo(workspaceInfo, skipStagedMigration, true, report); } else { rewriteStandaloneProject( workspaceInfo.rootDir, @@ -1098,7 +1044,6 @@ async function executeMigrationPlan( skipStagedMigration, true, report, - { preserveNuxtVitestImports: plan.preserveNuxtVitestImports }, ); } @@ -1237,18 +1182,6 @@ async function main() { } }; - const nuxtVitestPlan = await collectNuxtVitestImportDecision( - workspaceInfoOptional.rootDir, - options, - workspaceInfoOptional.packages, - ); - const nuxtVitestImportOptions = { - preserveNuxtVitestImports: nuxtVitestPlan.preserveNuxtVitestImports, - }; - if (nuxtVitestPlan.nuxtVitestUnsafeRewrite) { - addMigrationWarning(report, NUXT_VITEST_REWRITE_WARNING); - } - const pendingCoreMigration = detectPendingCoreMigration(workspaceInfoOptional); const legacyGitHooksMigrationCandidate = detectLegacyGitHooksMigrationCandidate( workspaceInfoOptional.rootDir, @@ -1257,7 +1190,6 @@ async function main() { workspaceInfoOptional.rootDir, workspaceInfoOptional.packageManager, workspaceInfoOptional.packages, - nuxtVitestImportOptions, ); let packageManager: PackageManager | undefined = vitePlusBootstrapPending ? (workspaceInfoOptional.packageManager ?? @@ -1294,7 +1226,6 @@ async function main() { true, report, pendingCoreMigration, - nuxtVitestImportOptions, ); if ( coreMigrationResult.scripts || @@ -1349,7 +1280,6 @@ async function main() { downloadPackageManager: downloadResult, }, report, - nuxtVitestImportOptions, ); didMigrate = bootstrapResult.changed || didMigrate; needsInstall = bootstrapResult.changed || needsInstall; diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 2c62bc699e..240d022b50 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -614,7 +614,8 @@ function projectListsRequiredVitestPeer( // True iff the project uses vitest DIRECTLY — via a dependency that is expected // to have a required vitest peer (see `projectListsVitestEcosystemDep`), an -// upstream `vitest` module specifier, or vitest browser mode. Drives +// upstream `vitest` module specifier, a package-level @nuxt/test-utils +// compatibility boundary, or vitest browser mode. Drives // whether the migration keeps `vitest` managed or removes it entirely; the // browser-mode arm keeps it aligned with the direct-`vitest` injection below so // an injected `catalog:` spec never dangles against a vitest-less catalog. @@ -640,7 +641,7 @@ function projectUsesVitestDirectly( // exact Vitest peer is left unsatisfied under strict pnpm/Yarn layouts. VITEST_BROWSER_DEP_NAMES.some((name) => pkg.peerDependencies?.[name] !== undefined) || sourceTreeReferencesRetainedVitestModule(projectPath) || - (preserveNuxtVitestImports && sourceTreeReferencesNuxtVitestImport(projectPath, pkg)) || + (preserveNuxtVitestImports && hasNuxtTestUtilsDependency(pkg)) || usesVitestBrowserMode(projectPath) ); } @@ -4670,10 +4671,8 @@ function sourceTreeReferencesAny(projectPath: string, hints: readonly string[]): return sourceTreeMatches(projectPath, (content) => hints.some((hint) => content.includes(hint))); } -const BARE_VITEST_MODULE_REFERENCE = - /(?:\bfrom\s*|\b(?:import|require)\s*\(\s*|\bimport\s*)['"]vitest['"]/m; -const NUXT_TEST_UTILS_MODULE_REFERENCE = - /(?:\bfrom\s*|\b(?:import|require)\s*\(\s*|\bimport\s*)['"]@nuxt\/test-utils(?:\/[^'"]+)?['"]/m; +const UPSTREAM_VITEST_MODULE_REFERENCE = + /(?:\bfrom\s*|\b(?:import|require)\s*\(\s*|\bimport\s*)['"]vitest(?:\/[^'"]+)?['"]/m; function hasNuxtTestUtilsDependency(pkg: DependencyBag): boolean { return [pkg.dependencies, pkg.devDependencies, pkg.optionalDependencies].some( @@ -4681,21 +4680,9 @@ function hasNuxtTestUtilsDependency(pkg: DependencyBag): boolean { ); } -function sourceReferencesNuxtTestUtilsWithBareVitest(content: string): boolean { - return ( - BARE_VITEST_MODULE_REFERENCE.test(content) && NUXT_TEST_UTILS_MODULE_REFERENCE.test(content) - ); -} - -function sourceTreeReferencesNuxtVitestImport(projectPath: string, pkg: DependencyBag): boolean { - return ( - hasNuxtTestUtilsDependency(pkg) && - sourceTreeMatches(projectPath, sourceReferencesNuxtTestUtilsWithBareVitest) - ); -} - /** - * Find files eligible for the @nuxt/test-utils bare-vitest compatibility choice. + * Find files whose upstream Vitest imports are preserved by the + * @nuxt/test-utils package-level compatibility rule. * Each package is scanned independently so a root dependency does not leak into * unrelated workspace manifests. */ @@ -4713,7 +4700,9 @@ export function detectNuxtTestUtilsVitestImportFiles( continue; } files.push( - ...sourceTreeMatchingFiles(projectPath, sourceReferencesNuxtTestUtilsWithBareVitest), + ...sourceTreeMatchingFiles(projectPath, (content) => + UPSTREAM_VITEST_MODULE_REFERENCE.test(content), + ), ); } return [...new Set(files)]; @@ -5862,7 +5851,7 @@ function rewriteAllImports( ): boolean { const result = rewriteImportsInDirectory(projectPath, preserveNuxtVitestImports); const modified = result.modifiedFiles.length; - const preserved = result.preservedBareVitestFiles.length; + const preserved = result.preservedVitestFiles.length; const errors = result.errors.length; if (report) { diff --git a/packages/cli/src/oxlint-plugin.ts b/packages/cli/src/oxlint-plugin.ts index 41a163fd0c..c763f9c235 100644 --- a/packages/cli/src/oxlint-plugin.ts +++ b/packages/cli/src/oxlint-plugin.ts @@ -101,10 +101,12 @@ function quoteSpecifier(literal: ESTree.StringLiteral, replacement: string): str return `${quote}${replacement}${quote}`; } -const NUXT_TEST_UTILS_MODULE_REFERENCE = - /(?:\bfrom\s*|\b(?:import|require)\s*\(\s*|\bimport\s*)['"]@nuxt\/test-utils(?:\/[^'"]+)?['"]/m; const nuxtTestUtilsPackageCache = new Map(); +function isUpstreamVitestSpecifier(specifier: string): boolean { + return specifier === 'vitest' || specifier.startsWith('vitest/'); +} + function nearestPackageUsesNuxtTestUtils(filename: string): boolean { if (!path.isAbsolute(filename)) { return false; @@ -144,12 +146,12 @@ function nearestPackageUsesNuxtTestUtils(filename: string): boolean { function maybeReportLiteral( context: Context, literal: ESTree.Expression | ESTree.TSModuleDeclaration['id'] | null | undefined, - preserveBareVitest = false, + preserveUpstreamVitest = false, ) { if (!literal || literal.type !== 'Literal' || typeof literal.value !== 'string') { return; } - if (preserveBareVitest && literal.value === 'vitest') { + if (preserveUpstreamVitest && isUpstreamVitestSpecifier(literal.value)) { return; } @@ -185,30 +187,28 @@ export const preferVitePlusImportsRule = defineRule({ }, }, createOnce(context: Context) { - let preserveBareVitest = false; + let preserveUpstreamVitest = false; return { Program() { - preserveBareVitest = - nearestPackageUsesNuxtTestUtils(context.filename) && - NUXT_TEST_UTILS_MODULE_REFERENCE.test(context.sourceCode.text); + preserveUpstreamVitest = nearestPackageUsesNuxtTestUtils(context.filename); }, ImportDeclaration(node) { - maybeReportLiteral(context, node.source, preserveBareVitest); + maybeReportLiteral(context, node.source, preserveUpstreamVitest); }, ExportAllDeclaration(node) { - maybeReportLiteral(context, node.source, preserveBareVitest); + maybeReportLiteral(context, node.source, preserveUpstreamVitest); }, ExportNamedDeclaration(node) { - maybeReportLiteral(context, node.source, preserveBareVitest); + maybeReportLiteral(context, node.source, preserveUpstreamVitest); }, ImportExpression(node) { - maybeReportLiteral(context, node.source, preserveBareVitest); + maybeReportLiteral(context, node.source, preserveUpstreamVitest); }, TSImportType(node) { - maybeReportLiteral(context, node.source, preserveBareVitest); + maybeReportLiteral(context, node.source, preserveUpstreamVitest); }, TSExternalModuleReference(node) { - maybeReportLiteral(context, node.expression, preserveBareVitest); + maybeReportLiteral(context, node.expression, preserveUpstreamVitest); }, TSModuleDeclaration(node) { if (node.global) { @@ -222,7 +222,7 @@ export const preferVitePlusImportsRule = defineRule({ ) { return; } - maybeReportLiteral(context, id, preserveBareVitest); + maybeReportLiteral(context, id, preserveUpstreamVitest); }, }; }, diff --git a/rfcs/migrate-existing-projects.md b/rfcs/migrate-existing-projects.md index 5a2abfdf91..55b29ed441 100644 --- a/rfcs/migrate-existing-projects.md +++ b/rfcs/migrate-existing-projects.md @@ -23,52 +23,41 @@ Run on an existing Vite+ project, in order. The guiding fact for vitest: `vite-p Removing the old direct dependency was exercised on `node-modules/urllib` across pnpm, npm, and yarn (PRs [#832](https://github.com/node-modules/urllib/pull/832) / [#833](https://github.com/node-modules/urllib/pull/833) / [#834](https://github.com/node-modules/urllib/pull/834)). Those node-modules layouts can hoist an exact peer, but that is not portable to strict pnpm or Yarn PnP, so the migration still provisions required peers explicitly. Required-peer handling is covered for official `@vitest/*` packages and the third-party `vitest-browser-svelte` case. -| Area | Rule | -| ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Routing | If the project's local `vite-plus` is older than the global `vp`, run `migrate` from the global CLI; otherwise keep local-first. | -| `vite-plus` spec | Re-pin a non-protocol-pinned spec (e.g. `^0.1.24`) to the toolchain target (`catalog:` in catalog projects, else the version) so the lockfile moves off the old resolution. Preserve deliberate protocol pins (`workspace:`/`file:`/`link:`/`npm:`/...). | -| `vite` override | Always managed: alias `vite` to `npm:@voidzero-dev/vite-plus-core@latest` in whatever override/resolution/catalog form the project uses; normalize a behind `core@` alias. | -| `vitest` itself (default) | Provided by `vite-plus`, so by default not project-managed: remove any project-level `vitest` from dependency fields, string-valued `overrides`/`resolutions`/`pnpm.overrides`, `pnpm-workspace.yaml` `overrides`+`catalog(s)`, bun/yarn catalog, and the `vitest` entry in pnpm `peerDependencyRules`. Resolve a surviving `peerDependencies.vitest` catalog reference to its public range before pruning the catalog. A future `vp update vite-plus` then keeps it correct with no project pin to drift. | -| `vitest`, peer/browser/Nuxt exception | Keep a managed `vitest` in the package that needs it (add to `devDependencies` and pin/override it to the bundled version) when that package directly installs a required-`vitest` peer consumer, uses browser mode, retains a direct upstream `vitest` package reference, or preserves bare `vitest` imports for `@nuxt/test-utils` compatibility. Required peers are detected from installed package metadata, not package names alone, so integrations such as `vite-plugin-gherkin` are covered. Other retained references include module augmentations, `compilerOptions.types`, and the intentionally unre-written `vitest/package.json` export; rewriteable imports and triple-slash directives do not leave a lasting pin. The direct dependency satisfies strict peer resolution; the shared override collapses the workspace to the bundled version. | -| `vitest` ecosystem packages | When Vitest is managed, align current lockstep `@vitest/*` packages the project lists (`@vitest/coverage-v8`, `@vitest/coverage-istanbul`, `@vitest/ui`, `@vitest/web-worker`, ...) to the bundled `VITEST_VERSION`. Exclude `@vitest/eslint-plugin` (separate version line, optional `vitest: *` peer) and deprecated `@vitest/coverage-c8` (last published at `0.33.0`; no Vitest 4 release exists). When `VP_OVERRIDE_PACKAGES` omits Vitest, skip ecosystem alignment so user-owned exact-peer versions stay compatible. Browser packages keep their dedicated handling: `@vitest/browser` / `-preview` are bundled by `vite-plus`; `@vitest/browser-playwright` / `-webdriverio` are opt-in (pinned + framework peer kept). | -| Workspaces | Reconcile every package manifest, not only the root. Localize the direct `vitest` dependency to packages that need it; keep shared catalogs/overrides only when at least one package needs them. Re-pin existing plain `vite-plus` ranges consistently while preserving deliberate protocol specs. | -| Legacy wrapper | Remove every `@voidzero-dev/vite-plus-test` alias (deps, overrides, catalogs); repoint direct wrapper imports to `vite-plus/test`. `vite-plus/test*` imports are left unchanged (stable public API). | -| pnpm config location | An empty `"pnpm": {}` with an existing `pnpm-workspace.yaml` reconciles the workspace file (instead of writing a second, conflicting override block into `package.json`). | -| Reinstall + verify | One reinstall with lockfile refresh (`--no-frozen-lockfile` / `--force`); before npm reinstalls, remove a stale real-`vite` install/lock entry that npm otherwise retains after the dependency becomes the Vite+ core alias. A failed install warns and sets a non-zero exit. | +| Area | Rule | +| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Routing | If the project's local `vite-plus` is older than the global `vp`, run `migrate` from the global CLI; otherwise keep local-first. | +| `vite-plus` spec | Re-pin a non-protocol-pinned spec (e.g. `^0.1.24`) to the toolchain target (`catalog:` in catalog projects, else the version) so the lockfile moves off the old resolution. Preserve deliberate protocol pins (`workspace:`/`file:`/`link:`/`npm:`/...). | +| `vite` override | Always managed: alias `vite` to `npm:@voidzero-dev/vite-plus-core@latest` in whatever override/resolution/catalog form the project uses; normalize a behind `core@` alias. | +| `vitest` itself (default) | Provided by `vite-plus`, so by default not project-managed: remove any project-level `vitest` from dependency fields, string-valued `overrides`/`resolutions`/`pnpm.overrides`, `pnpm-workspace.yaml` `overrides`+`catalog(s)`, bun/yarn catalog, and the `vitest` entry in pnpm `peerDependencyRules`. Resolve a surviving `peerDependencies.vitest` catalog reference to its public range before pruning the catalog. A future `vp update vite-plus` then keeps it correct with no project pin to drift. | +| `vitest`, peer/browser/Nuxt exception | Keep a managed `vitest` in the package that needs it (add to `devDependencies` and pin/override it to the bundled version) when that package directly installs a required-`vitest` peer consumer, uses browser mode, retains a direct upstream `vitest` package reference, or declares `@nuxt/test-utils`. Required peers are detected from installed package metadata, not package names alone, so integrations such as `vite-plugin-gherkin` are covered. Other retained references include module augmentations, `compilerOptions.types`, and the intentionally unre-written `vitest/package.json` export. In a Nuxt test-utils package, all `vitest` and `vitest/*` specifiers remain upstream consistently; in other packages, rewriteable imports and triple-slash directives do not leave a lasting pin. The direct dependency satisfies strict peer resolution; the shared override collapses the workspace to the bundled version. | +| `vitest` ecosystem packages | When Vitest is managed, align current lockstep `@vitest/*` packages the project lists (`@vitest/coverage-v8`, `@vitest/coverage-istanbul`, `@vitest/ui`, `@vitest/web-worker`, ...) to the bundled `VITEST_VERSION`. Exclude `@vitest/eslint-plugin` (separate version line, optional `vitest: *` peer) and deprecated `@vitest/coverage-c8` (last published at `0.33.0`; no Vitest 4 release exists). When `VP_OVERRIDE_PACKAGES` omits Vitest, skip ecosystem alignment so user-owned exact-peer versions stay compatible. Browser packages keep their dedicated handling: `@vitest/browser` / `-preview` are bundled by `vite-plus`; `@vitest/browser-playwright` / `-webdriverio` are opt-in (pinned + framework peer kept). | +| Workspaces | Reconcile every package manifest, not only the root. Localize the direct `vitest` dependency to packages that need it; keep shared catalogs/overrides only when at least one package needs them. Re-pin existing plain `vite-plus` ranges consistently while preserving deliberate protocol specs. | +| Legacy wrapper | Remove every `@voidzero-dev/vite-plus-test` alias (deps, overrides, catalogs); repoint direct wrapper imports to `vite-plus/test`. `vite-plus/test*` imports are left unchanged (stable public API). | +| pnpm config location | An empty `"pnpm": {}` with an existing `pnpm-workspace.yaml` reconciles the workspace file (instead of writing a second, conflicting override block into `package.json`). | +| Reinstall + verify | One reinstall with lockfile refresh (`--no-frozen-lockfile` / `--force`); before npm reinstalls, remove a stale real-`vite` install/lock entry that npm otherwise retains after the dependency becomes the Vite+ core alias. A failed install warns and sets a non-zero exit. | Force-override/CI mode (`VP_OVERRIDE_PACKAGES`) is respected: when `vitest` is not a managed key there, the project's own `vitest` is never stripped and its `@vitest/*` ecosystem dependencies are not realigned. Object-valued nested npm/Bun overrides are user-owned scopes rather than managed version pins and are preserved. ## `@nuxt/test-utils` compatibility -`@nuxt/test-utils`'s transform detects an existing `vi` import only when its module specifier is exactly `vitest`. When a test uses `mockNuxtImport` or `mockComponent`, changing that import to `vite-plus/test` makes the transform inject a second `vi` import and can fail compilation with a duplicate identifier. The migration therefore treats bare `vitest` imports in Nuxt test-utils files as a compatibility boundary rather than applying the ordinary rewrite unconditionally. +`@nuxt/test-utils`'s transform detects an existing `vi` import only when its module specifier is exactly `vitest`. When a test uses `mockNuxtImport` or `mockComponent`, changing that import to `vite-plus/test` makes the transform inject a second `vi` import and can fail compilation with a duplicate identifier. Requiring users to know which individual files exercise that transform is brittle, so the migration uses one package-level rule instead. Detection and scope: 1. A package is eligible when its `dependencies`, `devDependencies`, or `optionalDependencies` contains `@nuxt/test-utils`. -2. Within an eligible package, a Nuxt test-utils file is one that directly imports, exports from, requires, or dynamically imports `@nuxt/test-utils` or one of its subpaths. -3. The compatibility choice applies only to the exact bare specifier `vitest` in those files. `vitest/config`, every other `vitest/*` subpath, `@vitest/browser*`, and files unrelated to `@nuxt/test-utils` continue through the normal rewrites. -4. Preserving at least one bare import is retained direct Vitest usage, so that package keeps its package-local `vitest` and the workspace keeps the matching shared pin/catalog entry. -5. `prefer-vite-plus-imports` uses the same file-level exception. Lint and autofix must not undo the migration result. +2. Every `vitest` and `vitest/*` module specifier in that package is preserved, regardless of whether the individual file imports `@nuxt/test-utils`. This includes unit tests and shared test helpers, eliminating mixed import identities within one test suite. +3. Scoped `@vitest/browser*` specifiers keep their existing Vite+ rewrites and provider provisioning because they are separate packages, not the upstream `vitest` package identity protected by this rule. +4. An eligible package keeps its package-local `vitest`, and the workspace keeps the matching shared pin/catalog entry. +5. Workspace scope follows the nearest `package.json`: one Nuxt package does not suppress rewrites in unrelated workspace packages. +6. `prefer-vite-plus-imports` uses the same package-level exception for `vitest` and `vitest/*`. Lint and autofix must not undo the migration result. -If eligible files with bare `vitest` imports exist, interactive migration asks: +This rule is automatic in interactive and non-interactive migrations; there is no per-file prompt. A migration reports: ```text -◆ @nuxt/test-utils detected - -◆ How should bare `vitest` imports in Nuxt test files be handled? -│ ● Keep `vitest` imports (recommended) -│ Compatible with `mockNuxtImport` and `mockComponent` -│ ○ Rewrite to `vite-plus/test` -│ May require manual fixes for duplicate `vi` imports -``` - -`--no-interactive` selects the recommended preservation behavior. A preserving migration reports: - -```text -• Kept bare `vitest` imports in 7 files for @nuxt/test-utils compatibility +• Kept upstream `vitest` imports in 135 files for @nuxt/test-utils compatibility ``` -The count is the number of files, not import declarations. If the user explicitly selects rewriting, migration emits a compatibility warning identifying the possible duplicate-`vi` follow-up. +The count is the number of files, not import declarations. **Pending verification:** vitest **browser mode** historically needed a direct `vitest` injected (the "vibe-dashboard" regression). The upgrade now restores the opt-in provider and framework peer and keeps the package-local `vitest`; retain that behavior until a urllib-style pnpm/npm/yarn check proves any part is redundant. @@ -93,12 +82,12 @@ How each package the `vitest` ecosystem rule covers is handled, verified against ## Implementation -| Area | Change | -| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `crates/vite_global_cli` (`commands/migrate.rs`, `js_executor.rs`) | `delegate_migrate`: compare local `vite-plus` vs global `vp` version; escalate to the global CLI when older. | -| `crates/vite_migration` (`import_rewriter.rs`) | Support a file-scoped Nuxt compatibility mode that preserves only exact bare `vitest` specifiers while continuing all Vitest subpath and browser-provider rewrites; return the preserved-file count for the migration summary. | -| `packages/cli/src/migration/{migrator,npm-reinstall,bin}.ts` | Usage-aware managed override set; per-package dependency reconciliation; `vitest` removal across every sink; full `@vitest/*` alignment; browser-provider restoration; behind `vite-plus`/`vite` re-pin; empty/unrelated-`pnpm` routing fix; stale npm Vite install cleanup; Nuxt dependency/file detection, prompt choice, and retained Vitest provisioning. | -| Oxlint `prefer-vite-plus-imports` rule | Apply the same Nuxt file-level bare-`vitest` exception so diagnostics and autofix preserve the migration's compatible result. | +| Area | Change | +| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `crates/vite_global_cli` (`commands/migrate.rs`, `js_executor.rs`) | `delegate_migrate`: compare local `vite-plus` vs global `vp` version; escalate to the global CLI when older. | +| `crates/vite_migration` (`import_rewriter.rs`) | Support a package-scoped Nuxt compatibility mode that preserves `vitest` and `vitest/*` specifiers throughout packages that declare `@nuxt/test-utils`, while continuing scoped `@vitest/browser*` rewrites; return the preserved-file count for the migration summary. | +| `packages/cli/src/migration/{migrator,npm-reinstall,bin}.ts` | Usage-aware managed override set; per-package dependency reconciliation; `vitest` removal across every sink; full `@vitest/*` alignment; browser-provider restoration; behind `vite-plus`/`vite` re-pin; empty/unrelated-`pnpm` routing fix; stale npm Vite install cleanup; package-level Nuxt dependency detection and retained Vitest provisioning. | +| Oxlint `prefer-vite-plus-imports` rule | Apply the same Nuxt package-level `vitest` / `vitest/*` exception so diagnostics and autofix preserve the migration's compatible result. | Covered by unit tests in `migrator.spec.ts` (vitest removal, required-peer provisioning, ecosystem alignment, browser-provider restoration, workspace localization, behind re-pin, empty-`pnpm` reconciliation), `npm-reinstall.spec.ts` (stale npm install and lock cleanup), and a routing test in `vite_global_cli`. @@ -127,9 +116,9 @@ Covered by unit tests in `migrator.spec.ts` (vitest removal, required-peer provi | Deprecated `@vitest/coverage-c8` is not assigned a nonexistent Vitest 4 version | `migration-upgrade-deprecated-coverage-c8-npm` | | Standalone Yarn writes catalog specs in one pass and is idempotent | `migration-standalone-yarn4-idempotent` | | Unmanaged exact-peer Vitest ecosystem versions remain aligned with user-owned Vitest | `migration-vitest-unmanaged-override` | -| Nuxt-compatible bare imports are preserved while Vitest subpaths still rewrite | `migration-upgrade-nuxt-test-utils`, `migration-upgrade-nuxt-test-utils-monorepo` | +| Nuxt packages preserve all upstream `vitest` imports without affecting sibling packages | `migration-upgrade-nuxt-test-utils`, `migration-upgrade-nuxt-test-utils-monorepo` | -The matching Oxlint/autofix behavior is covered by the local `lint-vite-plus-imports-nuxt` snapshot: the Nuxt file's bare import remains exempt while its Vitest subpath and an unrelated file's bare import are both fixed. +The matching Oxlint/autofix behavior is covered by the local `lint-vite-plus-imports-nuxt` snapshot: all `vitest` imports in the Nuxt package remain exempt, while the rule continues rewriting Vite and scoped browser-package imports. ## Follow-ups (not in this change) From 36065354c10d3df92b61a53619db4de62568cc6a Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 23 Jun 2026 17:06:21 +0800 Subject: [PATCH 20/78] fix(migrate): convert Yarn PnP projects --- .../snap.txt | 7 +- .../steps.json | 2 +- .../snap.txt | 6 +- .../steps.json | 6 +- .../snap.txt | 10 +- .../steps.json | 4 +- .../config/tsconfig.test.json | 5 + .../resolve.cjs | 1 + .../snap.txt | 10 ++ .../steps.json | 2 + .../src/migration/__tests__/migrator.spec.ts | 121 ++++++++++++++++++ packages/cli/src/migration/bin.ts | 65 +++++++++- packages/cli/src/migration/migrator.ts | 99 +++++++++++++- rfcs/migrate-existing-projects.md | 55 ++++---- 14 files changed, 349 insertions(+), 44 deletions(-) create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/config/tsconfig.test.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/resolve.cjs diff --git a/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/snap.txt b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/snap.txt index a61ab8c68d..4f5d245b29 100644 --- a/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/snap.txt +++ b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/snap.txt @@ -1,7 +1,12 @@ -> vp migrate --no-interactive # standalone Yarn writes catalog specs on the first pass +> vp migrate --no-interactive # implicit Yarn Berry PnP converts before the first pass + +⚠ Vite+ does not currently support Yarn Plug'n'Play (PnP). + +✔ Switched Yarn to node-modules mode ◇ Migrated . to Vite+ • Node yarn • 2 config updates applied, 1 file had imports rewritten +• Package manager settings configured > cat package.json # migrated dependency specs use the Yarn catalog immediately { diff --git a/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/steps.json b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/steps.json index 2462490ad8..09ea344a15 100644 --- a/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/steps.json +++ b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/steps.json @@ -1,6 +1,6 @@ { "commands": [ - "vp migrate --no-interactive # standalone Yarn writes catalog specs on the first pass", + "vp migrate --no-interactive # implicit Yarn Berry PnP converts before the first pass", "cat package.json # migrated dependency specs use the Yarn catalog immediately", "cat .yarnrc.yml # managed catalog entries are available to those specs", "cat example.spec.ts # ordinary Vitest imports use the Vite+ public surface", diff --git a/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/snap.txt index dafe572187..fa679f6ff3 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/snap.txt @@ -1,5 +1,4 @@ -> node -e "const fs = require('node:fs'); fs.mkdirSync('node_modules', { recursive: true }); fs.cpSync('.fixture/vite-plugin-gherkin', 'node_modules/vite-plugin-gherkin', { recursive: true })" # simulate installed dependency metadata -> vp migrate --no-interactive # required Vitest peer is detected without a Vitest package name +> vp migrate --no-interactive # clean checkout conservatively preserves existing Vitest ◇ Migrated . to Vite+ • Node npm • Package manager settings configured @@ -25,6 +24,7 @@ } } -> vp migrate --no-interactive # metadata-based peer provisioning is stable on rerun +> node -e "const fs = require('node:fs'); fs.mkdirSync('node_modules', { recursive: true }); fs.cpSync('.fixture/vite-plugin-gherkin', 'node_modules/vite-plugin-gherkin', { recursive: true })" # simulate installed dependency metadata +> vp migrate --no-interactive # metadata confirms the unnamed required Vitest peer This project is already using Vite+! Happy coding! diff --git a/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/steps.json index 738904c5e0..46f3b70402 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/steps.json +++ b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/steps.json @@ -1,8 +1,8 @@ { "commands": [ - "node -e \"const fs = require('node:fs'); fs.mkdirSync('node_modules', { recursive: true }); fs.cpSync('.fixture/vite-plugin-gherkin', 'node_modules/vite-plugin-gherkin', { recursive: true })\" # simulate installed dependency metadata", - "vp migrate --no-interactive # required Vitest peer is detected without a Vitest package name", + "vp migrate --no-interactive # clean checkout conservatively preserves existing Vitest", "cat package.json # package-local Vitest and its shared override remain aligned", - "vp migrate --no-interactive # metadata-based peer provisioning is stable on rerun" + "node -e \"const fs = require('node:fs'); fs.mkdirSync('node_modules', { recursive: true }); fs.cpSync('.fixture/vite-plugin-gherkin', 'node_modules/vite-plugin-gherkin', { recursive: true })\" # simulate installed dependency metadata", + "vp migrate --no-interactive # metadata confirms the unnamed required Vitest peer" ] } diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/snap.txt index 8d0b908ae7..1a2c62b558 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/snap.txt @@ -1,4 +1,8 @@ -> vp migrate --no-interactive # Yarn PnP exact peer should receive package-local vitest +> vp migrate --no-interactive # Yarn PnP converts to node-modules before exact-peer migration + +⚠ Vite+ does not currently support Yarn Plug'n'Play (PnP). + +✔ Switched Yarn to node-modules mode ◇ Migrated . to Vite+ • Node yarn • Package manager settings configured @@ -24,8 +28,8 @@ } } -> cat .yarnrc.yml # shared catalog should include the aligned vitest -nodeLinker: pnp +> cat .yarnrc.yml # linker conversion and aligned Vitest catalog are persisted +nodeLinker: node-modules catalog: vite: npm:@voidzero-dev/vite-plus-core@latest vite-plus: latest diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/steps.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/steps.json index 41aa4f3d2c..2c014edafb 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/steps.json +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/steps.json @@ -1,8 +1,8 @@ { "env": {}, "commands": [ - "vp migrate --no-interactive # Yarn PnP exact peer should receive package-local vitest", + "vp migrate --no-interactive # Yarn PnP converts to node-modules before exact-peer migration", "cat package.json # direct deps and resolutions should use the managed catalog/version", - "cat .yarnrc.yml # shared catalog should include the aligned vitest" + "cat .yarnrc.yml # linker conversion and aligned Vitest catalog are persisted" ] } diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/config/tsconfig.test.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/config/tsconfig.test.json new file mode 100644 index 0000000000..aa0a8c0310 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/config/tsconfig.test.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "types": ["vitest/globals"] + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/resolve.cjs b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/resolve.cjs new file mode 100644 index 0000000000..48997b4070 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/resolve.cjs @@ -0,0 +1 @@ +module.exports = require.resolve('vitest'); diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/snap.txt index 8fc11d08b5..d58ec2197c 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/snap.txt @@ -30,6 +30,16 @@ } } +> cat config/tsconfig.test.json # nested compilerOptions.types is also retained +{ + "compilerOptions": { + "types": ["vitest/globals"] + } +} + +> cat resolve.cjs # require.resolve remains an upstream Vitest reference +module.exports = require.resolve('vitest'); + > cat version.ts # vitest/package.json remains intentionally unre-written import metadata from 'vitest/package.json'; diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/steps.json index 2a598938ba..0f3fbd9146 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/steps.json +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/steps.json @@ -3,6 +3,8 @@ "vp migrate --no-interactive # retained upstream references require package-local Vitest", "cat package.json # Vitest dependency and override stay aligned", "cat tsconfig.json # compilerOptions.types remains an upstream Vitest reference", + "cat config/tsconfig.test.json # nested compilerOptions.types is also retained", + "cat resolve.cjs # require.resolve remains an upstream Vitest reference", "cat version.ts # vitest/package.json remains intentionally unre-written", "vp migrate --no-interactive # retained references remain stable on rerun" ] diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 5aa64a34d7..a426c5ee8a 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -40,9 +40,80 @@ const { detectIncompatibleEslintIntegration, preflightGitHooksSetup, detectLegacyGitHooksMigrationCandidate, + detectYarnPnpMode, + configureYarnNodeModulesMode, setPackageManager, } = await import('../migrator.js'); +describe('Yarn PnP migration preflight', () => { + let tmpDir: string; + const savedEnv: Record = {}; + const isolatedEnv = ['HOME', 'USERPROFILE', 'YARN_NODE_LINKER'] as const; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-test-yarn-pnp-')); + for (const key of isolatedEnv) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + const cleanHome = path.join(tmpDir, '.home'); + fs.mkdirSync(cleanHome); + process.env.HOME = cleanHome; + process.env.USERPROFILE = cleanHome; + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + for (const key of isolatedEnv) { + if (savedEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } + } + }); + + it('detects explicit and implicit Yarn Berry PnP modes', () => { + fs.writeFileSync(path.join(tmpDir, '.yarnrc.yml'), 'nodeLinker: pnp\n'); + expect(detectYarnPnpMode(tmpDir, '4.12.0')).toEqual({ source: 'configuration' }); + + fs.rmSync(path.join(tmpDir, '.yarnrc.yml')); + expect(detectYarnPnpMode(tmpDir, '4.12.0')).toEqual({ source: 'default' }); + expect(detectYarnPnpMode(tmpDir, 'latest')).toEqual({ source: 'default' }); + }); + + it('does not classify Yarn Classic or node-modules configuration as PnP', () => { + expect(detectYarnPnpMode(tmpDir, '1.22.22')).toBeUndefined(); + fs.writeFileSync(path.join(tmpDir, '.yarnrc.yml'), 'nodeLinker: node-modules\n'); + expect(detectYarnPnpMode(tmpDir, '4.12.0')).toBeUndefined(); + }); + + it('honours YARN_NODE_LINKER over project configuration', () => { + fs.writeFileSync(path.join(tmpDir, '.yarnrc.yml'), 'nodeLinker: node-modules\n'); + process.env.YARN_NODE_LINKER = 'pnp'; + expect(detectYarnPnpMode(tmpDir, '4.12.0')).toEqual({ source: 'environment' }); + + process.env.YARN_NODE_LINKER = 'node-modules'; + fs.writeFileSync(path.join(tmpDir, '.yarnrc.yml'), 'nodeLinker: pnp\n'); + expect(detectYarnPnpMode(tmpDir, '4.12.0')).toBeUndefined(); + }); + + it('converts the project rc without discarding other settings and is idempotent', () => { + fs.writeFileSync( + path.join(tmpDir, '.yarnrc.yml'), + 'nodeLinker: pnp\nnmHoistingLimits: workspaces\ncatalog:\n react: ^19.0.0\n', + ); + + expect(configureYarnNodeModulesMode(tmpDir)).toBe(true); + expect(readYamlObject(path.join(tmpDir, '.yarnrc.yml'))).toEqual({ + nodeLinker: 'node-modules', + nmHoistingLimits: 'workspaces', + catalog: { react: '^19.0.0' }, + }); + expect(configureYarnNodeModulesMode(tmpDir)).toBe(false); + }); +}); + describe('rewritePackageJson', () => { it('should rewrite package.json scripts and extract staged config', async () => { const pkg = { @@ -1670,6 +1741,37 @@ describe('ensureVitePlusBootstrap', () => { expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.yarn)).toBe(false); }); + it('preserves existing Vitest when dependency peer metadata is unavailable', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { + 'vite-plus': 'latest', + 'vite-plugin-gherkin': '0.2.0', + vitest: '^4.1.0', + }, + overrides: { + vite: 'npm:@voidzero-dev/vite-plus-core@latest', + vitest: '^4.1.0', + }, + devEngines: { + packageManager: { name: 'npm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.npm)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + overrides: Record; + }; + expect(pkg.devDependencies.vitest).toBe(VITEST_VERSION); + expect(pkg.overrides.vitest).toBe(VITEST_VERSION); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(false); + }); + it.each([ { name: 'compilerOptions.types', @@ -1679,6 +1781,17 @@ describe('ensureVitePlusBootstrap', () => { JSON.stringify({ compilerOptions: { types: ['vitest/globals'] } }), ), }, + { + name: 'nested compilerOptions.types', + writeReference: (projectPath: string) => { + const configDir = path.join(projectPath, 'config'); + fs.mkdirSync(configDir); + fs.writeFileSync( + path.join(configDir, 'tsconfig.test.json'), + JSON.stringify({ compilerOptions: { types: ['vitest/globals'] } }), + ); + }, + }, { name: 'vitest/package.json', writeReference: (projectPath: string) => @@ -1687,6 +1800,14 @@ describe('ensureVitePlusBootstrap', () => { "import metadata from 'vitest/package.json';\nconsole.log(metadata.version);\n", ), }, + { + name: 'require.resolve', + writeReference: (projectPath: string) => + fs.writeFileSync( + path.join(projectPath, 'resolve.cjs'), + "module.exports = require.resolve('vitest');\n", + ), + }, ])('keeps package-local Vitest for retained $name references', ({ writeReference }) => { fs.writeFileSync( path.join(tmpDir, 'package.json'), diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index cc374b35bb..66c6aa12ed 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -58,6 +58,7 @@ import { detectPendingCoreMigration, detectPrettierProject, detectVitePlusBootstrapPending, + detectYarnPnpMode, ensureVitePlusBootstrap, finalizeCoreMigrationForExistingVitePlus, hasFrameworkShim, @@ -68,6 +69,7 @@ import { migrateEslintToOxlint, migrateNodeVersionManagerFile, migratePrettierToOxfmt, + configureYarnNodeModulesMode, preflightGitHooksSetup, rewriteMonorepo, rewriteStandaloneProject, @@ -125,6 +127,47 @@ async function confirmFrameworkShim(framework: Framework, interactive: boolean): return true; } +async function ensureYarnNodeModulesMode( + rootDir: string, + packageManager: PackageManager | undefined, + packageManagerVersion: string, + interactive: boolean, +): Promise { + if (packageManager !== PackageManager.yarn) { + return false; + } + + const pnp = detectYarnPnpMode(rootDir, packageManagerVersion); + if (!pnp) { + return false; + } + + prompts.log.warn(`⚠ Vite+ does not currently support Yarn Plug'n'Play (PnP).`); + if (pnp.source === 'environment') { + cancelAndExit( + 'YARN_NODE_LINKER=pnp overrides project configuration. Set it to node-modules or unset it, then re-run `vp migrate`.', + 1, + ); + } + + if (interactive) { + const confirmed = await prompts.confirm({ + message: 'Switch this project to Yarn node-modules mode and continue?', + initialValue: true, + }); + if (prompts.isCancel(confirmed)) { + cancelAndExit(); + } + if (!confirmed) { + cancelAndExit('Migration cancelled. Vite+ requires Yarn node-modules mode.'); + } + } + + configureYarnNodeModulesMode(rootDir); + prompts.log.success('✔ Switched Yarn to node-modules mode'); + return true; +} + async function fixBaseUrlForWorkspace( workspaceInfo: { rootDir: string; packages?: WorkspacePackage[] }, fixBaseUrl: boolean, @@ -341,6 +384,7 @@ interface MigrationSetupPlan { interface MigrationPlan extends MigrationSetupPlan { packageManager: PackageManager; + yarnPnpConverted: boolean; migratePrettier: boolean; prettierConfigFile?: string; fixBaseUrl: boolean; @@ -629,12 +673,19 @@ function getExistingVitePlusSetupOptions( async function collectMigrationPlan( rootDir: string, detectedPackageManager: PackageManager | undefined, + detectedPackageManagerVersion: string, options: MigrationOptions, packages?: WorkspacePackage[], ): Promise { // 1. Package manager selection const packageManager = detectedPackageManager ?? (await selectPackageManager(options.interactive, true)); + const yarnPnpConverted = await ensureYarnNodeModulesMode( + rootDir, + packageManager, + detectedPackageManager ? detectedPackageManagerVersion : 'latest', + options.interactive, + ); // 2. Shared setup/tooling decisions const setupPlan = await collectMigrationSetupPlan(rootDir, packageManager, options, packages); @@ -668,6 +719,7 @@ async function collectMigrationPlan( const plan: MigrationPlan = { packageManager, + yarnPnpConverted, ...setupPlan, migratePrettier, prettierConfigFile: prettierProject.configFile, @@ -916,6 +968,7 @@ async function executeMigrationPlan( report: MigrationReport; }> { const report = createMigrationReport(); + report.packageManagerBootstrapConfigured = plan.yarnPnpConverted; const migrationProgress = interactive ? prompts.spinner({ indicator: 'timer' }) : undefined; let migrationProgressStarted = false; const updateMigrationProgress = (message: string) => { @@ -1150,10 +1203,17 @@ async function main() { workspaceInfoOptional.rootDir, ) as PackageDependencies | null; if (hasVitePlusDependency(rootPkg) && !isForceOverrideMode()) { - let didMigrate = false; + const yarnPnpConverted = await ensureYarnNodeModulesMode( + workspaceInfoOptional.rootDir, + workspaceInfoOptional.packageManager, + workspaceInfoOptional.packageManagerVersion, + options.interactive, + ); + let didMigrate = yarnPnpConverted; let installDurationMs = 0; let finalInstallOk = true; const report = createMigrationReport(); + report.packageManagerBootstrapConfigured = yarnPnpConverted; const migrationProgress = options.interactive ? prompts.spinner({ indicator: 'timer' }) : undefined; @@ -1268,7 +1328,7 @@ async function main() { workspaceInfoOptional.packages, ); - let needsInstall = false; + let needsInstall = yarnPnpConverted; if (vitePlusBootstrapPending) { const downloadResult = await ensureExistingPackageManager(); if (downloadResult && packageManager) { @@ -1500,6 +1560,7 @@ async function main() { const plan = await collectMigrationPlan( workspaceInfoOptional.rootDir, workspaceInfoOptional.packageManager, + workspaceInfoOptional.packageManagerVersion, options, workspaceInfoOptional.packages, ); diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 240d022b50..18393c3cb8 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -581,16 +581,27 @@ function projectListsRequiredVitestPeer( optionalDependencies?: Record; }, ): boolean { + const installGroups = [pkg.dependencies, pkg.devDependencies, pkg.optionalDependencies]; + const hasExistingVitest = installGroups.some( + (dependencies) => dependencies?.vitest !== undefined, + ); const dependencyNames = new Set([ ...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.devDependencies ?? {}), ...Object.keys(pkg.optionalDependencies ?? {}), ]); dependencyNames.delete('vitest'); + dependencyNames.delete('vite'); + dependencyNames.delete(VITE_PLUS_NAME); + for (const name of VITEST_DIRECT_USAGE_EXCLUDED) { + dependencyNames.delete(name); + } + let metadataUnavailable = false; for (const name of dependencyNames) { const metadata = detectPackageMetadata(projectPath, name); if (!metadata) { + metadataUnavailable = true; continue; } try { @@ -605,11 +616,15 @@ function projectListsRequiredVitestPeer( return true; } } catch { - // Missing or unreadable installed metadata cannot provide a peer signal; - // retain the existing package-name and source-based fallbacks below. + metadataUnavailable = true; } } - return false; + // A clean checkout may not have node_modules/.pnp metadata yet. If the user + // already carries a direct Vitest while any dependency's peer contract is + // unknown, preserve it rather than risk removing the provider for an + // arbitrary integration such as vite-plugin-gherkin. A later migration with + // complete metadata can safely remove a genuinely redundant pin. + return metadataUnavailable && hasExistingVitest; } // True iff the project uses vitest DIRECTLY — via a dependency that is expected @@ -2708,6 +2723,51 @@ function resolveEffectiveYarnConfigValue( return home ? readYarnrcValue(home, key) : undefined; } +export interface YarnPnpDetection { + source: 'environment' | 'configuration' | 'default'; +} + +/** + * Detect Yarn Plug'n'Play using the same precedence Yarn applies to + * `nodeLinker`. Yarn 2+ defaults to PnP when no value is configured, while + * Yarn Classic defaults to node_modules. Unknown/`latest` Yarn versions are + * treated as modern because that is the version `vp` will provision. + */ +export function detectYarnPnpMode( + projectPath: string, + yarnVersion: string, +): YarnPnpDetection | undefined { + const environmentLinker = process.env.YARN_NODE_LINKER?.trim(); + if (environmentLinker) { + return environmentLinker.toLowerCase() === 'pnp' ? { source: 'environment' } : undefined; + } + + const configuredLinker = resolveEffectiveYarnConfigValue( + projectPath, + 'nodeLinker', + 'YARN_NODE_LINKER', + ); + if (configuredLinker) { + return configuredLinker.toLowerCase() === 'pnp' ? { source: 'configuration' } : undefined; + } + + const coercedVersion = semver.coerce(yarnVersion); + return coercedVersion?.major === 1 ? undefined : { source: 'default' }; +} + +/** Set the project-local Yarn linker while preserving every other rc setting. */ +export function configureYarnNodeModulesMode(projectPath: string): boolean { + const yarnrcYmlPath = path.join(projectPath, '.yarnrc.yml'); + const before = fs.existsSync(yarnrcYmlPath) ? fs.readFileSync(yarnrcYmlPath, 'utf8') : undefined; + if (before === undefined) { + fs.writeFileSync(yarnrcYmlPath, ''); + } + editYamlFile(yarnrcYmlPath, (doc) => { + doc.set('nodeLinker', 'node-modules'); + }); + return before !== fs.readFileSync(yarnrcYmlPath, 'utf8'); +} + // True when `dir`'s package.json declares a `workspaces` field — i.e. `dir` is a // workspace (Yarn project) root. `workspaces` may be an array or an object // (`{ packages: [...] }`); both are truthy. @@ -4671,6 +4731,33 @@ function sourceTreeReferencesAny(projectPath: string, hints: readonly string[]): return sourceTreeMatches(projectPath, (content) => hints.some((hint) => content.includes(hint))); } +function findPackageTsconfigFiles(projectPath: string): string[] { + const files: string[] = []; + const scanDir = (dir: string, isRoot: boolean): void => { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + if (!isRoot && entries.some((entry) => entry.isFile() && entry.name === 'package.json')) { + return; + } + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!VITEST_SCAN_SKIP_DIRS.has(entry.name)) { + scanDir(entryPath, false); + } + } else if (entry.isFile() && /^tsconfig(?:\.[\w-]+)?\.json$/i.test(entry.name)) { + files.push(entryPath); + } + } + }; + scanDir(projectPath, true); + return files; +} + const UPSTREAM_VITEST_MODULE_REFERENCE = /(?:\bfrom\s*|\b(?:import|require)\s*\(\s*|\bimport\s*)['"]vitest(?:\/[^'"]+)?['"]/m; @@ -4715,11 +4802,13 @@ export function detectNuxtTestUtilsVitestImportFiles( // identity, so keep Vitest package-local for those surfaces. function sourceTreeReferencesRetainedVitestModule(projectPath: string): boolean { return ( - findTsconfigFiles(projectPath).some(hasVitestTypesInTsconfig) || + findPackageTsconfigFiles(projectPath).some(hasVitestTypesInTsconfig) || sourceTreeMatches(projectPath, (content) => { return ( /\bdeclare\s+module\s+['"]vitest(?:\/[^'"]*)?['"]/.test(content) || - content.includes('vitest/package.json') + content.includes('vitest/package.json') || + /\brequire\.resolve\s*\(\s*['"]vitest(?:\/[^'"]*)?['"]/.test(content) || + /\bimport\.meta\.resolve\s*\(\s*['"]vitest(?:\/[^'"]*)?['"]/.test(content) ); }) ); diff --git a/rfcs/migrate-existing-projects.md b/rfcs/migrate-existing-projects.md index 55b29ed441..c394fc603a 100644 --- a/rfcs/migrate-existing-projects.md +++ b/rfcs/migrate-existing-projects.md @@ -19,22 +19,29 @@ Both are needed, and the order matters. `vp migrate` normally runs the project's ## Migrate rules -Run on an existing Vite+ project, in order. The guiding fact for vitest: `vite-plus` declares `vitest` (and the `@vitest/*` runtime family) as dependencies at the bundled version, so ordinary node-mode projects using only `vite-plus/test*` do not need their own `vitest`. A direct package with a required `vitest` peer is different: under Yarn PnP and strict pnpm, the copy nested below the sibling `vite-plus` dependency cannot satisfy that peer. Such a package needs a package-local direct `vitest`, plus a shared override when the package manager supports one. This applies whether the peer range is exact or broad. - -Removing the old direct dependency was exercised on `node-modules/urllib` across pnpm, npm, and yarn (PRs [#832](https://github.com/node-modules/urllib/pull/832) / [#833](https://github.com/node-modules/urllib/pull/833) / [#834](https://github.com/node-modules/urllib/pull/834)). Those node-modules layouts can hoist an exact peer, but that is not portable to strict pnpm or Yarn PnP, so the migration still provisions required peers explicitly. Required-peer handling is covered for official `@vitest/*` packages and the third-party `vitest-browser-svelte` case. - -| Area | Rule | -| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Routing | If the project's local `vite-plus` is older than the global `vp`, run `migrate` from the global CLI; otherwise keep local-first. | -| `vite-plus` spec | Re-pin a non-protocol-pinned spec (e.g. `^0.1.24`) to the toolchain target (`catalog:` in catalog projects, else the version) so the lockfile moves off the old resolution. Preserve deliberate protocol pins (`workspace:`/`file:`/`link:`/`npm:`/...). | -| `vite` override | Always managed: alias `vite` to `npm:@voidzero-dev/vite-plus-core@latest` in whatever override/resolution/catalog form the project uses; normalize a behind `core@` alias. | -| `vitest` itself (default) | Provided by `vite-plus`, so by default not project-managed: remove any project-level `vitest` from dependency fields, string-valued `overrides`/`resolutions`/`pnpm.overrides`, `pnpm-workspace.yaml` `overrides`+`catalog(s)`, bun/yarn catalog, and the `vitest` entry in pnpm `peerDependencyRules`. Resolve a surviving `peerDependencies.vitest` catalog reference to its public range before pruning the catalog. A future `vp update vite-plus` then keeps it correct with no project pin to drift. | -| `vitest`, peer/browser/Nuxt exception | Keep a managed `vitest` in the package that needs it (add to `devDependencies` and pin/override it to the bundled version) when that package directly installs a required-`vitest` peer consumer, uses browser mode, retains a direct upstream `vitest` package reference, or declares `@nuxt/test-utils`. Required peers are detected from installed package metadata, not package names alone, so integrations such as `vite-plugin-gherkin` are covered. Other retained references include module augmentations, `compilerOptions.types`, and the intentionally unre-written `vitest/package.json` export. In a Nuxt test-utils package, all `vitest` and `vitest/*` specifiers remain upstream consistently; in other packages, rewriteable imports and triple-slash directives do not leave a lasting pin. The direct dependency satisfies strict peer resolution; the shared override collapses the workspace to the bundled version. | -| `vitest` ecosystem packages | When Vitest is managed, align current lockstep `@vitest/*` packages the project lists (`@vitest/coverage-v8`, `@vitest/coverage-istanbul`, `@vitest/ui`, `@vitest/web-worker`, ...) to the bundled `VITEST_VERSION`. Exclude `@vitest/eslint-plugin` (separate version line, optional `vitest: *` peer) and deprecated `@vitest/coverage-c8` (last published at `0.33.0`; no Vitest 4 release exists). When `VP_OVERRIDE_PACKAGES` omits Vitest, skip ecosystem alignment so user-owned exact-peer versions stay compatible. Browser packages keep their dedicated handling: `@vitest/browser` / `-preview` are bundled by `vite-plus`; `@vitest/browser-playwright` / `-webdriverio` are opt-in (pinned + framework peer kept). | -| Workspaces | Reconcile every package manifest, not only the root. Localize the direct `vitest` dependency to packages that need it; keep shared catalogs/overrides only when at least one package needs them. Re-pin existing plain `vite-plus` ranges consistently while preserving deliberate protocol specs. | -| Legacy wrapper | Remove every `@voidzero-dev/vite-plus-test` alias (deps, overrides, catalogs); repoint direct wrapper imports to `vite-plus/test`. `vite-plus/test*` imports are left unchanged (stable public API). | -| pnpm config location | An empty `"pnpm": {}` with an existing `pnpm-workspace.yaml` reconciles the workspace file (instead of writing a second, conflicting override block into `package.json`). | -| Reinstall + verify | One reinstall with lockfile refresh (`--no-frozen-lockfile` / `--force`); before npm reinstalls, remove a stale real-`vite` install/lock entry that npm otherwise retains after the dependency becomes the Vite+ core alias. A failed install warns and sets a non-zero exit. | +Run on an existing Vite+ project, in order. The guiding fact for vitest: `vite-plus` declares `vitest` (and the `@vitest/*` runtime family) as dependencies at the bundled version, so ordinary node-mode projects using only `vite-plus/test*` do not need their own `vitest`. A direct package with a required `vitest` peer is different: under strict dependency layouts, the copy nested below the sibling `vite-plus` dependency cannot satisfy that peer. Such a package needs a package-local direct `vitest`, plus a shared override when the package manager supports one. This applies whether the peer range is exact or broad. + +Removing the old direct dependency was exercised on `node-modules/urllib` across pnpm, npm, and yarn (PRs [#832](https://github.com/node-modules/urllib/pull/832) / [#833](https://github.com/node-modules/urllib/pull/833) / [#834](https://github.com/node-modules/urllib/pull/834)). Those node-modules layouts can hoist an exact peer, but that is not portable to strict pnpm, so the migration still provisions required peers explicitly. Required-peer handling is covered for official `@vitest/*` packages and the third-party `vitest-browser-svelte` case. + +### Yarn Plug'n'Play preflight + +Vite+ does not currently support Yarn Plug'n'Play. Before collecting the other migration decisions or installing dependencies, `vp migrate` resolves the effective Yarn linker from `YARN_NODE_LINKER`, project/ancestor/home `.yarnrc.yml` files, and Yarn's version-dependent default. Explicit `nodeLinker: pnp` and the implicit Yarn 2+ default are both PnP mode. + +When PnP is active, interactive migration prints the incompatibility and asks whether to switch the project to `nodeLinker: node-modules` and continue. Accepting writes the project-root `.yarnrc.yml` without discarding its other settings; declining cancels before the remaining migration mutates the project. `--no-interactive` uses the affirmative default, reports the conversion, and continues. The conversion happens before the initial install so a clean checkout gets physical dependency metadata for required-peer detection. A process-level `YARN_NODE_LINKER=pnp` cannot be persistently repaired in project files, so migration stops with instructions to unset it or change it to `node-modules`. + +| Area | Rule | +| ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Routing | If the project's local `vite-plus` is older than the global `vp`, run `migrate` from the global CLI; otherwise keep local-first. | +| Yarn linker | Vite+ does not currently support Yarn PnP. Detect explicit and implicit PnP before migration, ask to switch to `nodeLinker: node-modules`, and continue only after conversion. Non-interactive migration accepts this conversion by default. | +| `vite-plus` spec | Re-pin a non-protocol-pinned spec (e.g. `^0.1.24`) to the toolchain target (`catalog:` in catalog projects, else the version) so the lockfile moves off the old resolution. Preserve deliberate protocol pins (`workspace:`/`file:`/`link:`/`npm:`/...). | +| `vite` override | Always managed: alias `vite` to `npm:@voidzero-dev/vite-plus-core@latest` in whatever override/resolution/catalog form the project uses; normalize a behind `core@` alias. | +| `vitest` itself (default) | Provided by `vite-plus`, so by default not project-managed: remove any project-level `vitest` from dependency fields, string-valued `overrides`/`resolutions`/`pnpm.overrides`, `pnpm-workspace.yaml` `overrides`+`catalog(s)`, bun/yarn catalog, and the `vitest` entry in pnpm `peerDependencyRules`. Resolve a surviving `peerDependencies.vitest` catalog reference to its public range before pruning the catalog. A future `vp update vite-plus` then keeps it correct with no project pin to drift. | +| `vitest`, peer/browser/Nuxt exception | Keep a managed `vitest` in the package that needs it (add to `devDependencies` and pin/override it to the bundled version) when that package directly installs a required-`vitest` peer consumer, uses browser mode, retains a direct upstream `vitest` package reference, or declares `@nuxt/test-utils`. Required peers are detected from installed package metadata, not package names alone, so integrations such as `vite-plugin-gherkin` are covered. When that metadata is unavailable in a clean checkout, preserve an existing direct Vitest conservatively. Other retained references include module augmentations, nested or root `compilerOptions.types`, `require.resolve` / `import.meta.resolve`, and the intentionally unre-written `vitest/package.json` export. In a Nuxt test-utils package, all `vitest` and `vitest/*` specifiers remain upstream consistently; in other packages, rewriteable imports and triple-slash directives do not leave a lasting pin. The direct dependency satisfies strict peer resolution; the shared override collapses the workspace to the bundled version. | +| `vitest` ecosystem packages | When Vitest is managed, align current lockstep `@vitest/*` packages the project lists (`@vitest/coverage-v8`, `@vitest/coverage-istanbul`, `@vitest/ui`, `@vitest/web-worker`, ...) to the bundled `VITEST_VERSION`. Exclude `@vitest/eslint-plugin` (separate version line, optional `vitest: *` peer) and deprecated `@vitest/coverage-c8` (last published at `0.33.0`; no Vitest 4 release exists). When `VP_OVERRIDE_PACKAGES` omits Vitest, skip ecosystem alignment so user-owned exact-peer versions stay compatible. Browser packages keep their dedicated handling: `@vitest/browser` / `-preview` are bundled by `vite-plus`; `@vitest/browser-playwright` / `-webdriverio` are opt-in (pinned + framework peer kept). | +| Workspaces | Reconcile every package manifest, not only the root. Localize the direct `vitest` dependency to packages that need it; keep shared catalogs/overrides only when at least one package needs them. Re-pin existing plain `vite-plus` ranges consistently while preserving deliberate protocol specs. | +| Legacy wrapper | Remove every `@voidzero-dev/vite-plus-test` alias (deps, overrides, catalogs); repoint direct wrapper imports to `vite-plus/test`. `vite-plus/test*` imports are left unchanged (stable public API). | +| pnpm config location | An empty `"pnpm": {}` with an existing `pnpm-workspace.yaml` reconciles the workspace file (instead of writing a second, conflicting override block into `package.json`). | +| Reinstall + verify | One reinstall with lockfile refresh (`--no-frozen-lockfile` / `--force`); before npm reinstalls, remove a stale real-`vite` install/lock entry that npm otherwise retains after the dependency becomes the Vite+ core alias. A failed install warns and sets a non-zero exit. | Force-override/CI mode (`VP_OVERRIDE_PACKAGES`) is respected: when `vitest` is not a managed key there, the project's own `vitest` is never stripped and its `@vitest/*` ecosystem dependencies are not realigned. Object-valued nested npm/Bun overrides are user-owned scopes rather than managed version pins and are preserved. @@ -82,12 +89,12 @@ How each package the `vitest` ecosystem rule covers is handled, verified against ## Implementation -| Area | Change | -| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `crates/vite_global_cli` (`commands/migrate.rs`, `js_executor.rs`) | `delegate_migrate`: compare local `vite-plus` vs global `vp` version; escalate to the global CLI when older. | -| `crates/vite_migration` (`import_rewriter.rs`) | Support a package-scoped Nuxt compatibility mode that preserves `vitest` and `vitest/*` specifiers throughout packages that declare `@nuxt/test-utils`, while continuing scoped `@vitest/browser*` rewrites; return the preserved-file count for the migration summary. | -| `packages/cli/src/migration/{migrator,npm-reinstall,bin}.ts` | Usage-aware managed override set; per-package dependency reconciliation; `vitest` removal across every sink; full `@vitest/*` alignment; browser-provider restoration; behind `vite-plus`/`vite` re-pin; empty/unrelated-`pnpm` routing fix; stale npm Vite install cleanup; package-level Nuxt dependency detection and retained Vitest provisioning. | -| Oxlint `prefer-vite-plus-imports` rule | Apply the same Nuxt package-level `vitest` / `vitest/*` exception so diagnostics and autofix preserve the migration's compatible result. | +| Area | Change | +| ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `crates/vite_global_cli` (`commands/migrate.rs`, `js_executor.rs`) | `delegate_migrate`: compare local `vite-plus` vs global `vp` version; escalate to the global CLI when older. | +| `crates/vite_migration` (`import_rewriter.rs`) | Support a package-scoped Nuxt compatibility mode that preserves `vitest` and `vitest/*` specifiers throughout packages that declare `@nuxt/test-utils`, while continuing scoped `@vitest/browser*` rewrites; return the preserved-file count for the migration summary. | +| `packages/cli/src/migration/{migrator,npm-reinstall,bin}.ts` | Yarn PnP preflight and `node-modules` conversion; usage-aware managed override set; per-package dependency reconciliation; `vitest` removal across every sink; full `@vitest/*` alignment; browser-provider restoration; behind `vite-plus`/`vite` re-pin; empty/unrelated-`pnpm` routing fix; stale npm Vite install cleanup; package-level Nuxt dependency detection and retained Vitest provisioning. | +| Oxlint `prefer-vite-plus-imports` rule | Apply the same Nuxt package-level `vitest` / `vitest/*` exception so diagnostics and autofix preserve the migration's compatible result. | Covered by unit tests in `migrator.spec.ts` (vitest removal, required-peer provisioning, ecosystem alignment, browser-provider restoration, workspace localization, behind re-pin, empty-`pnpm` reconciliation), `npm-reinstall.spec.ts` (stale npm install and lock cleanup), and a routing test in `vite_global_cli`. @@ -97,7 +104,7 @@ Covered by unit tests in `migrator.spec.ts` (vitest removal, required-peer provi | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | | Stale local CLI escalation, plain-range re-pin, stale wrapper removal, empty `pnpm` routing | `migration-upgrade-stale-local-pnpm` | | Default direct-`vitest` removal and ordinary import rewrite | `migration-already-vite-plus`, `migration-vitest-import-only` | -| Official exact peers under npm and Yarn PnP | `migration-upgrade-vitest-exact-peer-npm`, `migration-upgrade-vitest-exact-peer-yarn4` | +| Official exact peers under npm and Yarn after PnP-to-node-modules conversion | `migration-upgrade-vitest-exact-peer-npm`, `migration-upgrade-vitest-exact-peer-yarn4` | | Third-party range peer | `migration-vitest-peer-dep` | | Internal `@vitest/*` packages and `@vitest/eslint-plugin` exclusions | `migration-upgrade-vitest-non-runtime-only-npm` | | Playwright and WebdriverIO browser restoration, including pnpm driver approvals | `migration-upgrade-browser-source-only-pnpm`, `migration-upgrade-browser-webdriverio-pnpm` | @@ -111,7 +118,7 @@ Covered by unit tests in `migrator.spec.ts` (vitest removal, required-peer provi | Peer-only browser providers are promoted with direct and shared Vitest | `migration-upgrade-browser-peer-only-pnpm` | | Whitespace-tolerant Vitest directives rewrite without leaving transient pins | `migration-upgrade-vitest-reference-whitespace-pnpm` | | Object-valued nested Vitest overrides remain user-owned and idempotent | `migration-upgrade-nested-vitest-override-npm` | -| Retained `compilerOptions.types` and `vitest/package.json` references keep direct Vitest | `migration-upgrade-vitest-retained-references-npm` | +| Retained tsconfig, resolver, and `vitest/package.json` references keep direct Vitest | `migration-upgrade-vitest-retained-references-npm` | | Required Vitest peers discovered from installed dependency metadata | `migration-upgrade-required-vitest-peer-metadata-npm` | | Deprecated `@vitest/coverage-c8` is not assigned a nonexistent Vitest 4 version | `migration-upgrade-deprecated-coverage-c8-npm` | | Standalone Yarn writes catalog specs in one pass and is idempotent | `migration-standalone-yarn4-idempotent` | From b2b6d6e4008eac3e8b391a992ae4e8f69f3a2c11 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 23 Jun 2026 17:14:36 +0800 Subject: [PATCH 21/78] test(ecosystem): install Playwright for npmx.dev --- ecosystem-ci/repo.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ecosystem-ci/repo.json b/ecosystem-ci/repo.json index dad175cc9b..3a5940a04e 100644 --- a/ecosystem-ci/repo.json +++ b/ecosystem-ci/repo.json @@ -95,7 +95,8 @@ "repository": "https://github.com/npmx-dev/npmx.dev.git", "branch": "main", "hash": "035776c96cf8f089c44e6011264b534b0bcde53c", - "forceFreshMigration": true + "forceFreshMigration": true, + "playwright": true }, "vite-plus-jest-dom-repro": { "repository": "https://github.com/why-reproductions-are-required/vite-plus-jest-dom-repro.git", From b814002725e4d705c90e77d013e18eb231b9bcfb Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 23 Jun 2026 17:26:04 +0800 Subject: [PATCH 22/78] test(migrate): cover conservative monorepo retention --- .../migration-monorepo-bun/snap.txt | 7 ++++++- .../migration-monorepo-pnpm/snap.txt | 7 +++++++ .../migration-monorepo-yarn4/snap.txt | 12 +++++++++++- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/cli/snap-tests-global/migration-monorepo-bun/snap.txt b/packages/cli/snap-tests-global/migration-monorepo-bun/snap.txt index 28d20df0c0..8a36eae8d9 100644 --- a/packages/cli/snap-tests-global/migration-monorepo-bun/snap.txt +++ b/packages/cli/snap-tests-global/migration-monorepo-bun/snap.txt @@ -45,6 +45,7 @@ export default defineConfig({ ], "catalog": { "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "", "vite-plus": "latest" } }, @@ -62,11 +63,13 @@ export default defineConfig({ "devDependencies": { "@vitejs/plugin-react": "catalog:", "vite": "catalog:", + "vitest": "catalog:", "vite-plus": "catalog:" }, "packageManager": "bun@", "overrides": { - "vite": "catalog:" + "vite": "catalog:", + "vitest": "catalog:" } } @@ -86,6 +89,7 @@ export default defineConfig({ "devDependencies": { "test-vite-plus-package": "1.0.0", "vite": "catalog:", + "vitest": "catalog:", "vite-plus": "catalog:" } } @@ -103,6 +107,7 @@ export default defineConfig({ }, "devDependencies": { "vite": "catalog:", + "vitest": "catalog:", "vite-plus": "catalog:" } } diff --git a/packages/cli/snap-tests-global/migration-monorepo-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-monorepo-pnpm/snap.txt index 3e8d89ba2a..0b8b6f890d 100644 --- a/packages/cli/snap-tests-global/migration-monorepo-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-monorepo-pnpm/snap.txt @@ -66,6 +66,7 @@ export default defineConfig({ "devDependencies": { "@vitejs/plugin-react": "catalog:", "vite": "catalog:", + "vitest": "catalog:", "vite-plus": "catalog:" }, "resolutions": { @@ -82,16 +83,20 @@ catalog: testnpm2: ^1.0.0 # test comment here to check if the comment is preserved vite: npm:@voidzero-dev/vite-plus-core@latest + vitest: vite-plus: latest minimumReleaseAge: 1440 overrides: vite: 'catalog:' + vitest: 'catalog:' peerDependencyRules: allowAny: - vite + - vitest allowedVersions: vite: '*' + vitest: '*' minimumReleaseAgeExclude: - vite-plus - '@voidzero-dev/*' @@ -120,6 +125,7 @@ minimumReleaseAgeExclude: "devDependencies": { "test-vite-plus-package": "1.0.0", "vite": "catalog:", + "vitest": "catalog:", "vite-plus": "catalog:" }, "optionalDependencies": { @@ -140,6 +146,7 @@ minimumReleaseAgeExclude: }, "devDependencies": { "vite": "catalog:", + "vitest": "catalog:", "vite-plus": "catalog:" } } diff --git a/packages/cli/snap-tests-global/migration-monorepo-yarn4/snap.txt b/packages/cli/snap-tests-global/migration-monorepo-yarn4/snap.txt index 1ee2f91b8b..f31071a5a8 100644 --- a/packages/cli/snap-tests-global/migration-monorepo-yarn4/snap.txt +++ b/packages/cli/snap-tests-global/migration-monorepo-yarn4/snap.txt @@ -1,10 +1,15 @@ > vp migrate --no-interactive # migration should merge vite.config.ts and remove oxlintrc +⚠ Vite+ does not currently support Yarn Plug'n'Play (PnP). + +✔ Switched Yarn to node-modules mode + ✔ Merged .oxlintrc.json into vite.config.ts ◇ Migrated . to Vite+ • Node yarn • 2 config updates applied, 1 file had imports rewritten • Inline Vite plugins wrapped with lazyPlugins for check/lint/fmt +• Package manager settings configured > cat vite.config.ts # check vite.config.ts import react from '@vitejs/plugin-react'; @@ -60,11 +65,13 @@ export default defineConfig({ "devDependencies": { "@vitejs/plugin-react": "catalog:", "vite": "catalog:", + "vitest": "catalog:", "vite-plus": "catalog:" }, "packageManager": "yarn@", "resolutions": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest" + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "" } } @@ -75,6 +82,7 @@ npmPreapprovedPackages: - '@vitest/*' catalog: vite: npm:@voidzero-dev/vite-plus-core@latest + vitest: vite-plus: latest > cat packages/app/package.json # check app package.json @@ -93,6 +101,7 @@ catalog: "devDependencies": { "test-vite-plus-package": "1.0.0", "vite": "catalog:", + "vitest": "catalog:", "vite-plus": "catalog:" }, "optionalDependencies": { @@ -113,6 +122,7 @@ catalog: }, "devDependencies": { "vite": "catalog:", + "vitest": "catalog:", "vite-plus": "catalog:" } } From d7ea7f919a57677ac579383b01518ec198f1aa1d Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 23 Jun 2026 21:44:58 +0800 Subject: [PATCH 23/78] fix(migrate): pin pkg.pr.new targets in test helper --- .github/scripts/test-pkg-pr-new-migrate.sh | 144 ++++++++++++++++++ .../package.json | 13 ++ .../migration-upgrade-pkg-pr-new-npm/snap.txt | 29 ++++ .../steps.json | 15 ++ .../src/migration/__tests__/migrator.spec.ts | 52 ++++++- packages/cli/src/migration/migrator.ts | 19 +-- 6 files changed, 252 insertions(+), 20 deletions(-) create mode 100755 .github/scripts/test-pkg-pr-new-migrate.sh create mode 100644 packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/steps.json diff --git a/.github/scripts/test-pkg-pr-new-migrate.sh b/.github/scripts/test-pkg-pr-new-migrate.sh new file mode 100755 index 0000000000..b35e0e27e8 --- /dev/null +++ b/.github/scripts/test-pkg-pr-new-migrate.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: .github/scripts/test-pkg-pr-new-migrate.sh [migrate-options...] + +Examples: + .github/scripts/test-pkg-pr-new-migrate.sh 1891 /path/to/npmx.dev + .github/scripts/test-pkg-pr-new-migrate.sh 4eb2104c /path/to/project --no-interactive + +Environment variables: + VP_PKG_PR_NEW_HOME Override the isolated global CLI installation directory. + ALLOW_DIRTY=1 Allow migration in a dirty Git worktree. +EOF +} + +if [ "$#" -lt 2 ]; then + usage >&2 + exit 2 +fi + +pr_ref="$1" +project_input="$2" +shift 2 + +case "$pr_ref" in + '' | *[![:alnum:]._-]*) + echo "error: PR or SHA contains unsupported characters: $pr_ref" >&2 + exit 2 + ;; +esac + +if [ ! -d "$project_input" ]; then + echo "error: project directory does not exist: $project_input" >&2 + exit 2 +fi + +project_dir="$(cd "$project_input" && pwd -P)" +if [ ! -f "$project_dir/package.json" ]; then + echo "error: package.json not found in project: $project_dir" >&2 + exit 2 +fi + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +repo_root="$(cd "$script_dir/../.." && pwd -P)" +installer="$repo_root/packages/cli/install.sh" + +if [ ! -f "$installer" ]; then + echo "error: Vite+ installer not found: $installer" >&2 + exit 2 +fi + +is_git_repo=0 +if command -v git >/dev/null 2>&1 && git -C "$project_dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + is_git_repo=1 + if [ "${ALLOW_DIRTY:-0}" != "1" ] && [ -n "$(git -C "$project_dir" status --porcelain)" ]; then + echo "error: project worktree is dirty: $project_dir" >&2 + echo "Commit or stash its changes, or rerun with ALLOW_DIRTY=1." >&2 + exit 2 + fi +fi + +original_home="$HOME" +cache_root="${XDG_CACHE_HOME:-$original_home/.cache}" +pr_home="${VP_PKG_PR_NEW_HOME:-$cache_root/vite-plus/pkg-pr-new/$pr_ref}" +installer_home="$(mktemp -d "${TMPDIR:-/tmp}/vite-plus-pr-installer.XXXXXX")" + +cleanup() { + rm -rf "$installer_home" +} +trap cleanup EXIT + +echo "Installing Vite+ pkg.pr.new build $pr_ref into $pr_home" +HOME="$installer_home" \ + VP_HOME="$pr_home" \ + VP_PR_VERSION="$pr_ref" \ + VP_NODE_MANAGER=no \ + bash "$installer" + +vp_bin="$pr_home/bin/vp" +if [ ! -x "$vp_bin" ]; then + echo "error: installed vp executable not found: $vp_bin" >&2 + exit 1 +fi + +vite_plus_package_json="$pr_home/current/node_modules/vite-plus/package.json" +if [ ! -f "$vite_plus_package_json" ]; then + echo "error: installed vite-plus package not found: $vite_plus_package_json" >&2 + exit 1 +fi + +vitest_version="$(awk -F '"' '$2 == "vitest" { print $4; exit }' "$vite_plus_package_json")" +if [ -z "$vitest_version" ]; then + echo "error: could not determine the bundled Vitest version from $vite_plus_package_json" >&2 + exit 1 +fi + +pkg_pr_new_base="https://pkg.pr.new/voidzero-dev/vite-plus" +vite_plus_spec="$pkg_pr_new_base@$pr_ref" +vite_plus_core_spec="$pkg_pr_new_base/@voidzero-dev/vite-plus-core@$pr_ref" + +export VP_HOME="$pr_home" +export PATH="$VP_HOME/bin:$PATH" +export VP_VERSION="$vite_plus_spec" +export VP_OVERRIDE_PACKAGES="$(printf \ + '{"vite":"%s","@voidzero-dev/vite-plus-core":"%s","vitest":"%s"}' \ + "$vite_plus_core_spec" \ + "$vite_plus_core_spec" \ + "$vitest_version")" +export VP_FORCE_MIGRATE=1 +hash -r + +echo +echo "Using isolated global CLI:" +echo " executable: $vp_bin" +echo " installation: $(readlink "$pr_home/current" 2>/dev/null || echo unknown)" +echo " vite-plus spec: $VP_VERSION" +echo " vite spec: $vite_plus_core_spec" +"$vp_bin" --version + +echo +echo "Running vp migrate in $project_dir" +runner_dir="$installer_home/runner" +mkdir -p "$runner_dir" +set +e +( + # Resolve the CLI from an empty directory so a project-local vite-plus at the + # same semver cannot take precedence over the installed pkg.pr.new build. + cd "$runner_dir" + "$vp_bin" migrate "$project_dir" "$@" +) +migrate_status=$? +set -e + +if [ "$is_git_repo" -eq 1 ]; then + echo + echo "Migration worktree changes:" + git -C "$project_dir" status --short + git -C "$project_dir" diff --stat +fi + +exit "$migrate_status" diff --git a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/package.json b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/package.json new file mode 100644 index 0000000000..a104286713 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/package.json @@ -0,0 +1,13 @@ +{ + "name": "migration-upgrade-pkg-pr-new-npm", + "devDependencies": { + "vite": "npm:@voidzero-dev/vite-plus-core@^0.1.20", + "vite-plus": "^0.1.20", + "vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.20" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@^0.1.20", + "vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.20" + }, + "packageManager": "npm@11.11.1" +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/snap.txt new file mode 100644 index 0000000000..b8dd7dbe73 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/snap.txt @@ -0,0 +1,29 @@ +> vp migrate --no-interactive # pkg.pr.new targets replace every stale managed spec +◇ Migrated . to Vite+ +• Node npm +• 2 config updates applied + +> cat package.json # direct dependencies and npm overrides use the same PR URLs +{ + "name": "migration-upgrade-pkg-pr-new-npm", + "devDependencies": { + "vite": "https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891", + "vite-plus": "https://pkg.pr.new/voidzero-dev/vite-plus@1891" + }, + "overrides": { + "vite": "https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891", + "@voidzero-dev/vite-plus-core": "https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891" + }, + "packageManager": "npm@", + "scripts": { + "prepare": "vp config" + } +} + +> node -e "const p = require('./package.json'); const vp = 'https://pkg.pr.new/voidzero-dev/vite-plus@1891'; const core = 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891'; if (p.devDependencies['vite-plus'] !== vp || p.devDependencies.vite !== core || p.overrides.vite !== core || p.overrides['@voidzero-dev/vite-plus-core'] !== core) process.exit(1)" # pkg.pr.new specs are coherent +> node -e "require('node:fs').copyFileSync('package.json', 'package.after-first-migration.json')" # capture first migration result +> vp migrate --no-interactive # pkg.pr.new migration is idempotent +◇ Migrated . to Vite+ +• Node npm + +> node -e "const fs = require('node:fs'); if (fs.readFileSync('package.json', 'utf8') !== fs.readFileSync('package.after-first-migration.json', 'utf8')) process.exit(1)" # rerun leaves package.json unchanged \ No newline at end of file diff --git a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/steps.json new file mode 100644 index 0000000000..e00e423559 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/steps.json @@ -0,0 +1,15 @@ +{ + "env": { + "VP_FORCE_MIGRATE": "1", + "VP_OVERRIDE_PACKAGES": "{\"vite\":\"https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891\",\"@voidzero-dev/vite-plus-core\":\"https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891\",\"vitest\":\"4.1.9\"}", + "VP_VERSION": "https://pkg.pr.new/voidzero-dev/vite-plus@1891" + }, + "commands": [ + "vp migrate --no-interactive # pkg.pr.new targets replace every stale managed spec", + "cat package.json # direct dependencies and npm overrides use the same PR URLs", + "node -e \"const p = require('./package.json'); const vp = 'https://pkg.pr.new/voidzero-dev/vite-plus@1891'; const core = 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891'; if (p.devDependencies['vite-plus'] !== vp || p.devDependencies.vite !== core || p.overrides.vite !== core || p.overrides['@voidzero-dev/vite-plus-core'] !== core) process.exit(1)\" # pkg.pr.new specs are coherent", + "node -e \"require('node:fs').copyFileSync('package.json', 'package.after-first-migration.json')\" # capture first migration result", + "vp migrate --no-interactive # pkg.pr.new migration is idempotent", + "node -e \"const fs = require('node:fs'); if (fs.readFileSync('package.json', 'utf8') !== fs.readFileSync('package.after-first-migration.json', 'utf8')) process.exit(1)\" # rerun leaves package.json unchanged" + ] +} diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index a426c5ee8a..e3cf9ab3c5 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -1409,22 +1409,62 @@ describe('ensureVitePlusBootstrap', () => { expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(true); const result = ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.npm)); - // The `vite` alias still points at the live `@voidzero-dev/vite-plus-core` - // package, so it satisfies the migration and is left untouched. The project - // does NOT use vitest directly (no @vitest/* dep, no vitest source), so the - // stale `vitest` wrapper override (the DELETED `@voidzero-dev/vite-plus-test`) - // is REMOVED entirely — vitest arrives transitively through vite-plus. + // Both managed aliases must match the active toolchain target. Keeping the + // old core alias while rewriting a direct `vite` dependency causes npm's + // EOVERRIDE error. The project does NOT use vitest directly (no @vitest/* + // dep, no vitest source), so the stale deleted wrapper override is removed. expect(result.changed).toBe(true); const pkg = readJson(path.join(tmpDir, 'package.json')) as { overrides: Record; }; - expect(pkg.overrides.vite).toBe('npm:@voidzero-dev/vite-plus-core@0.1.0'); + expect(pkg.overrides.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); expect(pkg.overrides.vitest).toBeUndefined(); expect(pkg.overrides['@vitest/expect']).toBeUndefined(); expect(pkg.overrides['@vitest/coverage-v8']).toBeUndefined(); expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(false); }); + it('replaces protocol-pinned migration targets in force-override mode', () => { + const savedForceMigrate = process.env.VP_FORCE_MIGRATE; + process.env.VP_FORCE_MIGRATE = '1'; + try { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { + 'vite-plus': 'https://pkg.pr.new/voidzero-dev/vite-plus@old', + vite: 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@old', + }, + overrides: { + vite: 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@old', + }, + devEngines: { + packageManager: { name: 'npm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(true); + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.npm)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + overrides: Record; + }; + expect(pkg.devDependencies['vite-plus']).toBe('latest'); + expect(pkg.devDependencies.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + expect(pkg.overrides.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(false); + } finally { + if (savedForceMigrate === undefined) { + delete process.env.VP_FORCE_MIGRATE; + } else { + process.env.VP_FORCE_MIGRATE = savedForceMigrate; + } + } + }); + it('rewrites direct npm Vite dependencies before adding overrides', () => { fs.writeFileSync( path.join(tmpDir, 'package.json'), diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 18393c3cb8..5ac08d25ca 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -3706,16 +3706,6 @@ export type VitePlusBootstrapResult = { packageManagerField: boolean; }; -function getVitePlusOverridePackageName(dependencyName: string): string | undefined { - if (dependencyName === 'vite') { - return '@voidzero-dev/vite-plus-core'; - } - if (dependencyName === 'vitest') { - return '@voidzero-dev/vite-plus-test'; - } - return undefined; -} - function isSemanticVitePlusOverrideSpec(dependencyName: string, spec: string | undefined): boolean { if (!spec) { return false; @@ -3731,8 +3721,7 @@ function isSemanticVitePlusOverrideSpec(dependencyName: string, spec: string | u if (spec === VITE_PLUS_OVERRIDE_PACKAGES[dependencyName]) { return true; } - const packageName = getVitePlusOverridePackageName(dependencyName); - return packageName !== undefined && spec.includes(packageName); + return false; } function overrideSpecSatisfiesVitePlus( @@ -4246,8 +4235,10 @@ function ensureVitePlusDependencySpecs( } // Plain (non-protocol-pinned) range like `^0.1.24` → rewrite to the target // (`catalog:` for catalog-supporting projects, otherwise the concrete - // version). Already-`catalog:` / other protocol pins are left untouched. - if (!isProtocolPinnedSpec(spec)) { + // version). Already-`catalog:` / other protocol pins are left untouched, + // except in force-override mode where ecosystem/pkg.pr.new validation must + // replace every prior target with the requested artifact. + if (isForceOverrideMode() || !isProtocolPinnedSpec(spec)) { dependencies[VITE_PLUS_NAME] = version; changed = true; } From 63a0861120f742320108b9a554efc8476e6dafd5 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 23 Jun 2026 22:12:33 +0800 Subject: [PATCH 24/78] fix(test): keep pkg.pr.new overrides minimal --- .github/scripts/test-pkg-pr-new-migrate.sh | 3 +-- .../migration-upgrade-pkg-pr-new-npm/snap.txt | 5 ++--- .../migration-upgrade-pkg-pr-new-npm/steps.json | 4 ++-- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/scripts/test-pkg-pr-new-migrate.sh b/.github/scripts/test-pkg-pr-new-migrate.sh index b35e0e27e8..9b4f1a867d 100755 --- a/.github/scripts/test-pkg-pr-new-migrate.sh +++ b/.github/scripts/test-pkg-pr-new-migrate.sh @@ -105,8 +105,7 @@ export VP_HOME="$pr_home" export PATH="$VP_HOME/bin:$PATH" export VP_VERSION="$vite_plus_spec" export VP_OVERRIDE_PACKAGES="$(printf \ - '{"vite":"%s","@voidzero-dev/vite-plus-core":"%s","vitest":"%s"}' \ - "$vite_plus_core_spec" \ + '{"vite":"%s","vitest":"%s"}' \ "$vite_plus_core_spec" \ "$vitest_version")" export VP_FORCE_MIGRATE=1 diff --git a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/snap.txt index b8dd7dbe73..1bc76fd5f3 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/snap.txt @@ -11,8 +11,7 @@ "vite-plus": "https://pkg.pr.new/voidzero-dev/vite-plus@1891" }, "overrides": { - "vite": "https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891", - "@voidzero-dev/vite-plus-core": "https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891" + "vite": "https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891" }, "packageManager": "npm@", "scripts": { @@ -20,7 +19,7 @@ } } -> node -e "const p = require('./package.json'); const vp = 'https://pkg.pr.new/voidzero-dev/vite-plus@1891'; const core = 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891'; if (p.devDependencies['vite-plus'] !== vp || p.devDependencies.vite !== core || p.overrides.vite !== core || p.overrides['@voidzero-dev/vite-plus-core'] !== core) process.exit(1)" # pkg.pr.new specs are coherent +> node -e "const p = require('./package.json'); const vp = 'https://pkg.pr.new/voidzero-dev/vite-plus@1891'; const core = 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891'; if (p.devDependencies['vite-plus'] !== vp || p.devDependencies.vite !== core || p.overrides.vite !== core || p.overrides['@voidzero-dev/vite-plus-core'] !== undefined) process.exit(1)" # pkg.pr.new specs use the minimal override shape > node -e "require('node:fs').copyFileSync('package.json', 'package.after-first-migration.json')" # capture first migration result > vp migrate --no-interactive # pkg.pr.new migration is idempotent ◇ Migrated . to Vite+ diff --git a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/steps.json index e00e423559..5f2a8b74ab 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/steps.json +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/steps.json @@ -1,13 +1,13 @@ { "env": { "VP_FORCE_MIGRATE": "1", - "VP_OVERRIDE_PACKAGES": "{\"vite\":\"https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891\",\"@voidzero-dev/vite-plus-core\":\"https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891\",\"vitest\":\"4.1.9\"}", + "VP_OVERRIDE_PACKAGES": "{\"vite\":\"https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891\",\"vitest\":\"4.1.9\"}", "VP_VERSION": "https://pkg.pr.new/voidzero-dev/vite-plus@1891" }, "commands": [ "vp migrate --no-interactive # pkg.pr.new targets replace every stale managed spec", "cat package.json # direct dependencies and npm overrides use the same PR URLs", - "node -e \"const p = require('./package.json'); const vp = 'https://pkg.pr.new/voidzero-dev/vite-plus@1891'; const core = 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891'; if (p.devDependencies['vite-plus'] !== vp || p.devDependencies.vite !== core || p.overrides.vite !== core || p.overrides['@voidzero-dev/vite-plus-core'] !== core) process.exit(1)\" # pkg.pr.new specs are coherent", + "node -e \"const p = require('./package.json'); const vp = 'https://pkg.pr.new/voidzero-dev/vite-plus@1891'; const core = 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891'; if (p.devDependencies['vite-plus'] !== vp || p.devDependencies.vite !== core || p.overrides.vite !== core || p.overrides['@voidzero-dev/vite-plus-core'] !== undefined) process.exit(1)\" # pkg.pr.new specs use the minimal override shape", "node -e \"require('node:fs').copyFileSync('package.json', 'package.after-first-migration.json')\" # capture first migration result", "vp migrate --no-interactive # pkg.pr.new migration is idempotent", "node -e \"const fs = require('node:fs'); if (fs.readFileSync('package.json', 'utf8') !== fs.readFileSync('package.after-first-migration.json', 'utf8')) process.exit(1)\" # rerun leaves package.json unchanged" From d199e908764241e24f71fd71e9879a4f27dfee01 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 23 Jun 2026 22:50:13 +0800 Subject: [PATCH 25/78] fix(migrate): allow pkg.pr.new pnpm subdependencies --- .github/scripts/test-pkg-pr-new-migrate.sh | 5 ++ .../package.json | 10 ++++ .../pnpm-workspace.yaml | 21 +++++++ .../snap.txt | 57 ++++++++++++++++++ .../steps.json | 17 ++++++ .../src/migration/__tests__/migrator.spec.ts | 60 +++++++++++++++++++ packages/cli/src/migration/migrator.ts | 57 ++++++++++++++++++ 7 files changed, 227 insertions(+) create mode 100644 packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/pnpm-workspace.yaml create mode 100644 packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/steps.json diff --git a/.github/scripts/test-pkg-pr-new-migrate.sh b/.github/scripts/test-pkg-pr-new-migrate.sh index 9b4f1a867d..442aaff0e5 100755 --- a/.github/scripts/test-pkg-pr-new-migrate.sh +++ b/.github/scripts/test-pkg-pr-new-migrate.sh @@ -109,6 +109,11 @@ export VP_OVERRIDE_PACKAGES="$(printf \ "$vite_plus_core_spec" \ "$vitest_version")" export VP_FORCE_MIGRATE=1 +# pkg.pr.new packages depend on URL-resolved platform binaries. pnpm blocks +# those transitive URL dependencies when blockExoticSubdeps is enabled. The +# migration persists the corresponding workspace setting, while this temporary +# override also lets its pre-rewrite install recover a partially migrated tree. +export PNPM_CONFIG_BLOCK_EXOTIC_SUBDEPS=false hash -r echo diff --git a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/package.json b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/package.json new file mode 100644 index 0000000000..541f6d14f1 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/package.json @@ -0,0 +1,10 @@ +{ + "name": "migration-upgrade-pkg-pr-new-pnpm", + "devDependencies": { + "@vitest/coverage-v8": "4.1.6", + "vite": "npm:@voidzero-dev/vite-plus-core@^0.1.20", + "vite-plus": "^0.1.20", + "vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.20" + }, + "packageManager": "pnpm@10.33.2" +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/pnpm-workspace.yaml b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/pnpm-workspace.yaml new file mode 100644 index 0000000000..f7476db5c0 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/pnpm-workspace.yaml @@ -0,0 +1,21 @@ +packages: + - . + +blockExoticSubdeps: true + +catalog: + vite: npm:@voidzero-dev/vite-plus-core@^0.1.20 + vite-plus: ^0.1.20 + vitest: npm:@voidzero-dev/vite-plus-test@^0.1.20 + +overrides: + vite: 'catalog:' + vitest: 'catalog:' + +peerDependencyRules: + allowAny: + - vite + - vitest + allowedVersions: + vite: '*' + vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/snap.txt new file mode 100644 index 0000000000..2d08a0ae0d --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/snap.txt @@ -0,0 +1,57 @@ +> vp migrate --no-interactive # pkg.pr.new pnpm migration allows URL-resolved subdependencies +◇ Migrated . to Vite+ +• Node pnpm +• 2 config updates applied + +> cat package.json # direct dependencies use catalogs aligned to the pkg.pr.new build +{ + "name": "migration-upgrade-pkg-pr-new-pnpm", + "devDependencies": { + "@vitest/coverage-v8": "4.1.9", + "vite": "catalog:", + "vite-plus": "catalog:", + "vitest": "catalog:" + }, + "packageManager": "pnpm@", + "pnpm": { + "overrides": { + "vite": "https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891", + "vitest": "", + "vite-plus": "https://pkg.pr.new/voidzero-dev/vite-plus@1891" + } + }, + "scripts": { + "prepare": "vp config" + } +} + +> cat pnpm-workspace.yaml # pkg.pr.new URLs are pinned and exotic subdependencies are allowed +packages: + - . + +blockExoticSubdeps: false + +catalog: + vite: https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891 + vite-plus: https://pkg.pr.new/voidzero-dev/vite-plus@1891 + vitest: + +overrides: + vite: 'catalog:' + vitest: 'catalog:' + +peerDependencyRules: + allowAny: + - vite + - vitest + allowedVersions: + vite: '*' + vitest: '*' + +> node -e "const fs = require('node:fs'); const y = fs.readFileSync('pnpm-workspace.yaml', 'utf8'); if (!y.includes('blockExoticSubdeps: false') || !y.includes('https://pkg.pr.new/voidzero-dev/vite-plus@1891') || !y.includes('https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891')) process.exit(1)" # pnpm policy and PR targets are persisted +> node -e "const fs = require('node:fs'); fs.copyFileSync('package.json', 'package.after-first-migration.json'); fs.copyFileSync('pnpm-workspace.yaml', 'workspace.after-first-migration.yaml')" # capture first migration result +> vp migrate --no-interactive # pkg.pr.new pnpm migration is idempotent +◇ Migrated . to Vite+ +• Node pnpm + +> node -e "const fs = require('node:fs'); if (fs.readFileSync('package.json', 'utf8') !== fs.readFileSync('package.after-first-migration.json', 'utf8') || fs.readFileSync('pnpm-workspace.yaml', 'utf8') !== fs.readFileSync('workspace.after-first-migration.yaml', 'utf8')) process.exit(1)" # rerun leaves manifests unchanged \ No newline at end of file diff --git a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/steps.json new file mode 100644 index 0000000000..38f0648435 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/steps.json @@ -0,0 +1,17 @@ +{ + "env": { + "PNPM_CONFIG_BLOCK_EXOTIC_SUBDEPS": "false", + "VP_FORCE_MIGRATE": "1", + "VP_OVERRIDE_PACKAGES": "{\"vite\":\"https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891\",\"vitest\":\"4.1.9\"}", + "VP_VERSION": "https://pkg.pr.new/voidzero-dev/vite-plus@1891" + }, + "commands": [ + "vp migrate --no-interactive # pkg.pr.new pnpm migration allows URL-resolved subdependencies", + "cat package.json # direct dependencies use catalogs aligned to the pkg.pr.new build", + "cat pnpm-workspace.yaml # pkg.pr.new URLs are pinned and exotic subdependencies are allowed", + "node -e \"const fs = require('node:fs'); const y = fs.readFileSync('pnpm-workspace.yaml', 'utf8'); if (!y.includes('blockExoticSubdeps: false') || !y.includes('https://pkg.pr.new/voidzero-dev/vite-plus@1891') || !y.includes('https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891')) process.exit(1)\" # pnpm policy and PR targets are persisted", + "node -e \"const fs = require('node:fs'); fs.copyFileSync('package.json', 'package.after-first-migration.json'); fs.copyFileSync('pnpm-workspace.yaml', 'workspace.after-first-migration.yaml')\" # capture first migration result", + "vp migrate --no-interactive # pkg.pr.new pnpm migration is idempotent", + "node -e \"const fs = require('node:fs'); if (fs.readFileSync('package.json', 'utf8') !== fs.readFileSync('package.after-first-migration.json', 'utf8') || fs.readFileSync('pnpm-workspace.yaml', 'utf8') !== fs.readFileSync('workspace.after-first-migration.yaml', 'utf8')) process.exit(1)\" # rerun leaves manifests unchanged" + ] +} diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index e3cf9ab3c5..b85586dc88 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -1539,6 +1539,66 @@ describe('ensureVitePlusBootstrap', () => { expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); }); + it('allows pkg.pr.new transitive URLs in pnpm workspace config and is idempotent', () => { + const savedForceMigrate = process.env.VP_FORCE_MIGRATE; + const savedViteOverride = VITE_PLUS_OVERRIDE_PACKAGES.vite; + const viteOverride = + 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891'; + process.env.VP_FORCE_MIGRATE = '1'; + VITE_PLUS_OVERRIDE_PACKAGES.vite = viteOverride; + try { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { 'vite-plus': 'catalog:' }, + devEngines: { + packageManager: { name: 'pnpm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'pnpm-workspace.yaml'), + [ + 'blockExoticSubdeps: true', + 'catalog:', + ` vite: '${viteOverride}'`, + ' vite-plus: latest', + 'overrides:', + " vite: 'catalog:'", + 'peerDependencyRules:', + ' allowAny:', + ' - vite', + ' allowedVersions:', + " vite: '*'", + ].join('\n'), + ); + + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(true); + const first = ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); + + expect(first.packageManagerConfig).toBe(true); + expect( + ( + readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { + blockExoticSubdeps: boolean; + } + ).blockExoticSubdeps, + ).toBe(false); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); + expect(ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)).changed).toBe( + false, + ); + } finally { + VITE_PLUS_OVERRIDE_PACKAGES.vite = savedViteOverride; + if (savedForceMigrate === undefined) { + delete process.env.VP_FORCE_MIGRATE; + } else { + process.env.VP_FORCE_MIGRATE = savedForceMigrate; + } + } + }); + it('detects missing pnpm workspace catalog entry for vite-plus', () => { fs.writeFileSync( path.join(tmpDir, 'package.json'), diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 5ac08d25ca..34c7a970fa 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -1931,6 +1931,10 @@ export function rewriteStandaloneProject( }); } + if (packageManager === PackageManager.pnpm) { + ensurePnpmWorkspaceExoticSubdepsSetting(projectPath); + } + if (packageManager === PackageManager.yarn) { rewriteYarnrcYml(projectPath, usesVitest); } else if (packageManager === PackageManager.bun) { @@ -2195,6 +2199,8 @@ function rewritePnpmWorkspaceYaml( const managed = managedOverridePackages(usesVitest); editYamlFile(pnpmWorkspaceYamlPath, (doc) => { + ensurePnpmExoticSubdepsSetting(doc); + // catalog rewriteCatalog(doc, usesVitest); if (pnpmMajorVersion !== undefined) { @@ -3866,6 +3872,50 @@ function readPnpmWorkspacePeerDependencyRules( return doc?.peerDependencyRules; } +function forceOverrideUsesExoticPnpmSpec(): boolean { + if (!isForceOverrideMode()) { + return false; + } + return [VITE_PLUS_VERSION, ...Object.values(VITE_PLUS_OVERRIDE_PACKAGES)].some((spec) => + /^(?:file|https?):/.test(spec), + ); +} + +function pnpmWorkspaceExoticSubdepsSettingSatisfied(projectPath: string): boolean { + if (!forceOverrideUsesExoticPnpmSpec()) { + return true; + } + const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); + if (!fs.existsSync(pnpmWorkspaceYamlPath)) { + return false; + } + const doc = readYamlFile(pnpmWorkspaceYamlPath) as { blockExoticSubdeps?: boolean } | null; + return doc?.blockExoticSubdeps === false; +} + +function ensurePnpmExoticSubdepsSetting(doc: YamlDocument): boolean { + if (!forceOverrideUsesExoticPnpmSpec() || doc.get('blockExoticSubdeps') === false) { + return false; + } + doc.set('blockExoticSubdeps', false); + return true; +} + +function ensurePnpmWorkspaceExoticSubdepsSetting(projectPath: string): boolean { + if (!forceOverrideUsesExoticPnpmSpec()) { + return false; + } + const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); + if (!fs.existsSync(pnpmWorkspaceYamlPath)) { + fs.writeFileSync(pnpmWorkspaceYamlPath, ''); + } + let changed = false; + editYamlFile(pnpmWorkspaceYamlPath, (doc) => { + changed = ensurePnpmExoticSubdepsSetting(doc); + }); + return changed; +} + function yarnrcSatisfiesVitePlus(projectPath: string, usesVitest: boolean): boolean { const yarnrcYmlPath = path.join(projectPath, '.yarnrc.yml'); if (!fs.existsSync(yarnrcYmlPath)) { @@ -4184,6 +4234,9 @@ export function detectVitePlusBootstrapPending( ); } if (packageManager === PackageManager.pnpm) { + if (!pnpmWorkspaceExoticSubdepsSettingSatisfied(projectPath)) { + return true; + } if (pnpmConfigLivesInPackageJson(pkg, projectPath)) { return ( vitePlusDependencyNeedsConcreteVersion(pkg) || @@ -4454,6 +4507,7 @@ export function ensureVitePlusBootstrap( const catalogDependencyResolver = readPnpmWorkspaceCatalogDependencyResolver(projectPath); if ( result.packageJson || + !pnpmWorkspaceExoticSubdepsSettingSatisfied(projectPath) || defaultCatalogVitePlusDependencyPending(pkg, catalogDependencyResolver) || !overridesSatisfyVitePlus( readPnpmWorkspaceOverrides(projectPath), @@ -4479,6 +4533,9 @@ export function ensureVitePlusBootstrap( ? fs.readFileSync(pnpmWorkspaceYamlPath, 'utf-8') : undefined; result.packageManagerConfig = before !== after; + } else if (ensurePnpmWorkspaceExoticSubdepsSetting(projectPath)) { + ensurePnpmWorkspacePackages(projectPath, workspaceInfo.workspacePatterns); + result.packageManagerConfig = true; } } else if (workspaceInfo.packageManager === PackageManager.yarn) { const yarnrcYmlPath = path.join(projectPath, '.yarnrc.yml'); From 9f245c9251a380400e347be5fdeafb1cee4fa818 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 24 Jun 2026 09:21:20 +0800 Subject: [PATCH 26/78] fix(test): refresh mutable pkg.pr.new installs --- .github/scripts/test-pkg-pr-new-migrate.sh | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/scripts/test-pkg-pr-new-migrate.sh b/.github/scripts/test-pkg-pr-new-migrate.sh index 442aaff0e5..8419dfe450 100755 --- a/.github/scripts/test-pkg-pr-new-migrate.sh +++ b/.github/scripts/test-pkg-pr-new-migrate.sh @@ -67,6 +67,22 @@ cache_root="${XDG_CACHE_HOME:-$original_home/.cache}" pr_home="${VP_PKG_PR_NEW_HOME:-$cache_root/vite-plus/pkg-pr-new/$pr_ref}" installer_home="$(mktemp -d "${TMPDIR:-/tmp}/vite-plus-pr-installer.XXXXXX")" +# Numeric pkg.pr.new references are mutable PR aliases. The installer reuses a +# version directory named after the reference, so its lockfile can retain the +# checksum from an older publish of the same PR and fail with +# ERR_PNPM_TARBALL_INTEGRITY after the alias is refreshed. Keep the downloaded +# runtime/package-manager cache, but force the wrapper dependency to resolve +# and install again for every PR-alias run. Commit SHA references are immutable +# and can safely retain their installed dependency state. +case "$pr_ref" in + *[!0-9]*) ;; + *) + cached_version_dir="$pr_home/pkg-pr-new-$pr_ref" + rm -rf "$cached_version_dir/node_modules" + rm -f "$cached_version_dir/pnpm-lock.yaml" + ;; +esac + cleanup() { rm -rf "$installer_home" } From 4a0bac7736d43045b4c7291f53bcba69c94a4f3b Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 24 Jun 2026 10:26:33 +0800 Subject: [PATCH 27/78] fix(migrate): preserve Vitest ecosystem catalogs --- .../src/migration/__tests__/migrator.spec.ts | 82 ++++++ packages/cli/src/migration/migrator.ts | 278 +++++++++++++++--- 2 files changed, 319 insertions(+), 41 deletions(-) diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index b85586dc88..246a955efd 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -1759,6 +1759,88 @@ describe('ensureVitePlusBootstrap', () => { expect(pkg.devDependencies.vitest).toBe(VITEST_VERSION); }); + it('prefers existing catalogs for Vitest ecosystem packages and pins unsupported ones', () => { + const appDir = path.join(tmpDir, 'packages/app'); + fs.mkdirSync(appDir, { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'root', + private: true, + devDependencies: { 'vite-plus': 'catalog:' }, + devEngines: { + packageManager: { name: 'pnpm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + fs.writeFileSync( + path.join(appDir, 'package.json'), + JSON.stringify({ + name: 'app', + devDependencies: { + // Reproduce the output from the prior migration: the package was + // hard-pinned even though the default catalog already owned it. + '@vitest/coverage-istanbul': VITEST_VERSION, + '@vitest/ui': 'catalog:test', + '@vitest/web-worker': '^4.1.0', + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'pnpm-workspace.yaml'), + [ + 'packages:', + ' - packages/*', + 'catalog:', + ' vite-plus: latest', + ' vite: npm:@voidzero-dev/vite-plus-core@latest', + ` vitest: ${VITEST_VERSION}`, + " '@vitest/coverage-istanbul': 4.1.4", + 'catalogs:', + ' test:', + " '@vitest/ui': 4.1.4", + 'blockExoticSubdeps: false', + 'overrides:', + " vite: 'catalog:'", + " vitest: 'catalog:'", + 'peerDependencyRules:', + ' allowAny: [vite, vitest]', + ' allowedVersions:', + " vite: '*'", + " vitest: '*'", + '', + ].join('\n'), + ); + const workspaceInfo = { + ...makeWorkspaceInfo(tmpDir, PackageManager.pnpm), + isMonorepo: true, + workspacePatterns: ['packages/*'], + packages: [{ name: 'app', path: 'packages/app' }], + }; + + expect( + detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm, workspaceInfo.packages), + ).toBe(true); + ensureVitePlusBootstrap(workspaceInfo); + + const pkg = readJson(path.join(appDir, 'package.json')) as { + devDependencies: Record; + }; + expect(pkg.devDependencies['@vitest/coverage-istanbul']).toBe('catalog:'); + expect(pkg.devDependencies['@vitest/ui']).toBe('catalog:test'); + expect(pkg.devDependencies['@vitest/web-worker']).toBe(VITEST_VERSION); + + const workspace = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { + catalog: Record; + catalogs: Record>; + }; + expect(workspace.catalog['@vitest/coverage-istanbul']).toBe(VITEST_VERSION); + expect(workspace.catalogs.test['@vitest/ui']).toBe(VITEST_VERSION); + expect( + detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm, workspaceInfo.packages), + ).toBe(false); + }); + it('does not align deprecated @vitest/coverage-c8 to a nonexistent Vitest 4 version', () => { fs.writeFileSync( path.join(tmpDir, 'package.json'), diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 34c7a970fa..2c92eb7920 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -1725,6 +1725,7 @@ export function rewriteStandaloneProject( const packageManager = workspaceInfo.packageManager; const catalogDependencyResolver = createCatalogDependencyResolver(projectPath, packageManager); + const vitestEcosystemPackages = collectVitestEcosystemInstallDependencyNames(projectPath); const pnpmMajorVersion = pnpmMajor(workspaceInfo.downloadPackageManager.version); let extractedStagedConfig: Record | null = null; let remainingPnpmOverrides: Record | undefined; @@ -1917,6 +1918,7 @@ export function rewriteStandaloneProject( pnpmMajorVersion, shouldAllowBrowserProviderBuilds, usesVitest, + vitestEcosystemPackages, ); } @@ -1936,7 +1938,7 @@ export function rewriteStandaloneProject( } if (packageManager === PackageManager.yarn) { - rewriteYarnrcYml(projectPath, usesVitest); + rewriteYarnrcYml(projectPath, usesVitest, vitestEcosystemPackages); } else if (packageManager === PackageManager.bun) { ensureBunfigPeerSuppression(projectPath); } @@ -1996,6 +1998,10 @@ export function rewriteMonorepo( workspaceInfo.packages, importOptions?.preserveNuxtVitestImports !== false, ); + const vitestEcosystemPackages = collectVitestEcosystemInstallDependencyNames( + workspaceInfo.rootDir, + workspaceInfo.packages, + ); // rewrite root workspace if (workspaceInfo.packageManager === PackageManager.pnpm) { rewritePnpmWorkspaceYaml( @@ -2003,11 +2009,12 @@ export function rewriteMonorepo( pnpmMajorVersion, workspaceShouldAllowBrowserBuilds, workspaceUsesVitest, + vitestEcosystemPackages, ); } else if (workspaceInfo.packageManager === PackageManager.yarn) { - rewriteYarnrcYml(workspaceInfo.rootDir, workspaceUsesVitest); + rewriteYarnrcYml(workspaceInfo.rootDir, workspaceUsesVitest, vitestEcosystemPackages); } else if (workspaceInfo.packageManager === PackageManager.bun) { - rewriteBunCatalog(workspaceInfo.rootDir, workspaceUsesVitest); + rewriteBunCatalog(workspaceInfo.rootDir, workspaceUsesVitest, vitestEcosystemPackages); } rewriteRootWorkspacePackageJson( workspaceInfo.rootDir, @@ -2191,6 +2198,7 @@ function rewritePnpmWorkspaceYaml( pnpmMajorVersion: number | undefined, shouldAllowBrowserBuilds: boolean, usesVitest: boolean, + vitestEcosystemPackages: ReadonlySet, ): void { const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); if (!fs.existsSync(pnpmWorkspaceYamlPath)) { @@ -2202,7 +2210,7 @@ function rewritePnpmWorkspaceYaml( ensurePnpmExoticSubdepsSetting(doc); // catalog - rewriteCatalog(doc, usesVitest); + rewriteCatalog(doc, usesVitest, vitestEcosystemPackages); if (pnpmMajorVersion !== undefined) { applyBuildAllowanceToWorkspaceYaml(doc, pnpmMajorVersion, shouldAllowBrowserBuilds); } @@ -2901,7 +2909,11 @@ function applyYarnWorkspaceHoistingFix( } } -function rewriteYarnrcYml(projectPath: string, usesVitest: boolean): void { +function rewriteYarnrcYml( + projectPath: string, + usesVitest: boolean, + vitestEcosystemPackages: ReadonlySet, +): void { const yarnrcYmlPath = path.join(projectPath, '.yarnrc.yml'); if (!fs.existsSync(yarnrcYmlPath)) { fs.writeFileSync(yarnrcYmlPath, ''); @@ -2932,7 +2944,7 @@ function rewriteYarnrcYml(projectPath: string, usesVitest: boolean): void { } doc.setIn(['npmPreapprovedPackages'], npmPreapprovedPackages); // catalog - rewriteCatalog(doc, usesVitest); + rewriteCatalog(doc, usesVitest, vitestEcosystemPackages); }); } @@ -3167,7 +3179,11 @@ function pruneYamlMapLegacyWrapperAliases(map: unknown): void { } } -function rewriteCatalog(doc: YamlDocument, usesVitest: boolean): void { +function rewriteCatalog( + doc: YamlDocument, + usesVitest: boolean, + vitestEcosystemPackages: ReadonlySet, +): void { const managed = managedOverridePackages(usesVitest); // Common case (no direct vitest): remove any lingering managed `vitest` // catalog entry so it resolves transitively through vite-plus. @@ -3193,6 +3209,7 @@ function rewriteCatalog(doc: YamlDocument, usesVitest: boolean): void { } // Drop any entry still pointing at the deleted `vite-plus-test` wrapper. pruneYamlMapLegacyWrapperAliases(doc.getIn(['catalog'])); + rewriteVitestEcosystemYamlCatalog(doc.getIn(['catalog']), vitestEcosystemPackages); const catalogs = doc.getIn(['catalogs']); if (!(catalogs instanceof YAMLMap)) { @@ -3225,6 +3242,26 @@ function rewriteCatalog(doc: YamlDocument, usesVitest: boolean): void { } } pruneYamlMapLegacyWrapperAliases(item.value); + rewriteVitestEcosystemYamlCatalog(item.value, vitestEcosystemPackages); + } +} + +function rewriteVitestEcosystemYamlCatalog( + catalog: unknown, + vitestEcosystemPackages: ReadonlySet, +): void { + if (!VITEST_IS_MANAGED_OVERRIDE || !(catalog instanceof YAMLMap)) { + return; + } + for (const item of catalog.items) { + const name = item.key instanceof Scalar ? item.key.value : undefined; + if ( + typeof name === 'string' && + vitestEcosystemPackages.has(name) && + isAlignableVitestEcosystemPackage(name) + ) { + catalog.set(item.key, scalarString(VITEST_VERSION)); + } } } @@ -3232,6 +3269,7 @@ function rewriteCatalogObject( catalog: Record, addMissing: boolean, usesVitest: boolean, + vitestEcosystemPackages: ReadonlySet, ): void { const managed = managedOverridePackages(usesVitest); // Common case (no direct vitest): strip a lingering managed `vitest` catalog @@ -3251,14 +3289,22 @@ function rewriteCatalogObject( for (const name of REMOVE_PACKAGES) { delete catalog[name]; } + if (VITEST_IS_MANAGED_OVERRIDE) { + for (const name of Object.keys(catalog)) { + if (vitestEcosystemPackages.has(name) && isAlignableVitestEcosystemPackage(name)) { + catalog[name] = VITEST_VERSION; + } + } + } } function rewriteCatalogsObject( catalogs: Record>, usesVitest: boolean, + vitestEcosystemPackages: ReadonlySet, ): void { for (const catalog of Object.values(catalogs)) { - rewriteCatalogObject(catalog, false, usesVitest); + rewriteCatalogObject(catalog, false, usesVitest, vitestEcosystemPackages); } } @@ -3304,7 +3350,11 @@ function ensureBunfigPeerSuppression(projectPath: string): void { * unlike pnpm which uses pnpm-workspace.yaml. * @see https://bun.sh/docs/pm/catalogs */ -function rewriteBunCatalog(projectPath: string, usesVitest: boolean): void { +function rewriteBunCatalog( + projectPath: string, + usesVitest: boolean, + vitestEcosystemPackages: ReadonlySet, +): void { const packageJsonPath = path.join(projectPath, 'package.json'); if (!fs.existsSync(packageJsonPath)) { return; @@ -3327,30 +3377,30 @@ function rewriteBunCatalog(projectPath: string, usesVitest: boolean): void { ...(useWorkspacesCatalog ? workspacesObj?.catalog : pkg.catalog), }; - rewriteCatalogObject(catalog, true, usesVitest); + rewriteCatalogObject(catalog, true, usesVitest, vitestEcosystemPackages); pruneLegacyWrapperAliases(catalog); if (useWorkspacesCatalog) { workspacesObj.catalog = catalog; if (pkg.catalog) { - rewriteCatalogObject(pkg.catalog, false, usesVitest); + rewriteCatalogObject(pkg.catalog, false, usesVitest, vitestEcosystemPackages); pruneLegacyWrapperAliases(pkg.catalog); } } else { pkg.catalog = catalog; if (workspacesObj?.catalog) { - rewriteCatalogObject(workspacesObj.catalog, false, usesVitest); + rewriteCatalogObject(workspacesObj.catalog, false, usesVitest, vitestEcosystemPackages); pruneLegacyWrapperAliases(workspacesObj.catalog); } } if (workspacesObj?.catalogs) { - rewriteCatalogsObject(workspacesObj.catalogs, usesVitest); + rewriteCatalogsObject(workspacesObj.catalogs, usesVitest, vitestEcosystemPackages); for (const named of Object.values(workspacesObj.catalogs)) { pruneLegacyWrapperAliases(named); } } if (pkg.catalogs) { - rewriteCatalogsObject(pkg.catalogs, usesVitest); + rewriteCatalogsObject(pkg.catalogs, usesVitest, vitestEcosystemPackages); for (const named of Object.values(pkg.catalogs)) { pruneLegacyWrapperAliases(named); } @@ -3984,22 +4034,66 @@ function pnpmConfigLivesInPackageJson(pkg: BootstrapPackageJson, projectPath: st return Object.hasOwn(pkg.pnpm, 'overrides') || Object.hasOwn(pkg.pnpm, 'peerDependencyRules'); } -// Pin every alignable `@vitest/*` package the project lists to the bundled -// vitest version. Returns true if any spec changed. These are plain dependency -// entries (not overrides), so this is package-manager agnostic. -function alignVitestEcosystemPackages(pkg: BootstrapPackageJson): boolean { +function getAlignedVitestEcosystemDependencySpec( + current: string, + dependencyName: string, + dependencyField: PackageJsonDependencyField, + packageManager: PackageManager, + supportCatalog: boolean, + catalogDependencyResolver?: CatalogDependencyResolver, +): string { + const catalogSpec = current.startsWith('catalog:') ? current : 'catalog:'; + const catalogSupported = + supportCatalog && catalogDependencyResolver?.(catalogSpec, dependencyName) !== undefined; + return getCatalogDependencySpec(current, VITEST_VERSION, catalogSupported, { + dependencyField, + dependencyName, + packageManager, + catalogDependencyResolver, + }); +} + +// Align every declared official `@vitest/*` package with the bundled Vitest. +// Prefer an existing default or named catalog entry when the package manager +// supports catalogs; otherwise use the concrete bundled version. Returns true +// if any package.json spec changed. Catalog values are reconciled separately by +// the package-manager config writers above. +function alignVitestEcosystemPackages( + pkg: BootstrapPackageJson, + packageManager: PackageManager, + supportCatalog: boolean, + catalogDependencyResolver?: CatalogDependencyResolver, +): boolean { if (!VITEST_IS_MANAGED_OVERRIDE) { return false; } - const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; + const dependencyGroups: Array<{ + dependencyField: PackageJsonDependencyField; + dependencies: Record | undefined; + }> = [ + { dependencyField: 'devDependencies', dependencies: pkg.devDependencies }, + { dependencyField: 'dependencies', dependencies: pkg.dependencies }, + { dependencyField: 'optionalDependencies', dependencies: pkg.optionalDependencies }, + ]; let changed = false; - for (const dependencies of dependencyGroups) { + for (const { dependencyField, dependencies } of dependencyGroups) { if (!dependencies) { continue; } for (const name of Object.keys(dependencies)) { - if (isAlignableVitestEcosystemPackage(name) && dependencies[name] !== VITEST_VERSION) { - dependencies[name] = VITEST_VERSION; + if (!isAlignableVitestEcosystemPackage(name)) { + continue; + } + const aligned = getAlignedVitestEcosystemDependencySpec( + dependencies[name], + name, + dependencyField, + packageManager, + supportCatalog, + catalogDependencyResolver, + ); + if (dependencies[name] !== aligned) { + dependencies[name] = aligned; changed = true; } } @@ -4007,6 +4101,30 @@ function alignVitestEcosystemPackages(pkg: BootstrapPackageJson): boolean { return changed; } +function vitestEcosystemCatalogReferencesPending( + pkg: BootstrapPackageJson, + catalogDependencyResolver?: CatalogDependencyResolver, +): boolean { + if (!VITEST_IS_MANAGED_OVERRIDE || !catalogDependencyResolver) { + return false; + } + for (const dependencies of [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]) { + if (!dependencies) { + continue; + } + for (const [name, spec] of Object.entries(dependencies)) { + if ( + isAlignableVitestEcosystemPackage(name) && + spec.startsWith('catalog:') && + catalogDependencyResolver(spec, name) !== VITEST_VERSION + ) { + return true; + } + } + } + return false; +} + /** * Reconcile the install dependencies in one package during an existing-Vite+ * bootstrap. Package-manager overrides are intentionally handled separately at @@ -4054,7 +4172,7 @@ function reconcileVitePlusBootstrapPackage( } } - alignVitestEcosystemPackages(pkg); + alignVitestEcosystemPackages(pkg, packageManager, supportCatalog, catalogDependencyResolver); normalizeVitestPeerCatalogSpec(pkg.peerDependencies, catalogDependencyResolver); const providerSourceModes = collectProviderSourceModes(projectPath); @@ -4067,12 +4185,24 @@ function reconcileVitePlusBootstrapPackage( continue; } usesAnyOptInProvider = true; - const installGroup = installGroups.find( - (dependencies) => dependencies?.[provider] !== undefined, - ); - if (installGroup) { + const installGroupEntry = [ + { dependencyField: 'devDependencies' as const, dependencies: pkg.devDependencies }, + { dependencyField: 'dependencies' as const, dependencies: pkg.dependencies }, + { + dependencyField: 'optionalDependencies' as const, + dependencies: pkg.optionalDependencies, + }, + ].find(({ dependencies }) => dependencies?.[provider] !== undefined); + if (installGroupEntry?.dependencies) { if (VITEST_IS_MANAGED_OVERRIDE) { - installGroup[provider] = VITEST_VERSION; + installGroupEntry.dependencies[provider] = getAlignedVitestEcosystemDependencySpec( + installGroupEntry.dependencies[provider], + provider, + installGroupEntry.dependencyField, + packageManager, + supportCatalog, + catalogDependencyResolver, + ); } } else { pkg.devDependencies ??= {}; @@ -4149,6 +4279,45 @@ function bootstrapProjectPaths( return [rootDir, ...(packages ?? []).map((pkg) => path.join(rootDir, pkg.path))]; } +function collectVitestEcosystemInstallDependencyNames( + rootDir: string, + packages?: WorkspacePackage[], +): Set { + const names = new Set(); + for (const packagePath of bootstrapProjectPaths(rootDir, packages)) { + const packageJsonPath = path.join(packagePath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + continue; + } + const pkg = readJsonFile(packageJsonPath) as BootstrapPackageJson; + for (const dependencies of [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]) { + for (const name of Object.keys(dependencies ?? {})) { + if (isAlignableVitestEcosystemPackage(name)) { + names.add(name); + } + } + } + } + return names; +} + +function workspaceVitestEcosystemCatalogReferencesPending( + rootDir: string, + packages: WorkspacePackage[] | undefined, + catalogDependencyResolver?: CatalogDependencyResolver, +): boolean { + return bootstrapProjectPaths(rootDir, packages).some((packagePath) => { + const packageJsonPath = path.join(packagePath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return false; + } + return vitestEcosystemCatalogReferencesPending( + readJsonFile(packageJsonPath) as BootstrapPackageJson, + catalogDependencyResolver, + ); + }); +} + export function detectVitePlusBootstrapPending( projectPath: string, packageManager: PackageManager | undefined, @@ -4182,6 +4351,15 @@ export function detectVitePlusBootstrapPending( packageManager === PackageManager.bun); const canonicalVitePlusSpec = supportCatalog ? 'catalog:' : VITE_PLUS_VERSION; const catalogDependencyResolver = createCatalogDependencyResolver(projectPath, packageManager); + if ( + workspaceVitestEcosystemCatalogReferencesPending( + projectPath, + packages, + catalogDependencyResolver, + ) + ) { + return true; + } for (const [index, packagePath] of bootstrapProjectPaths(projectPath, packages).entries()) { const childPackageJsonPath = path.join(packagePath, 'package.json'); if (!fs.existsSync(childPackageJsonPath)) { @@ -4407,6 +4585,15 @@ export function ensureVitePlusBootstrap( projectPath, workspaceInfo.packageManager, ); + const ecosystemCatalogReferencesPending = workspaceVitestEcosystemCatalogReferencesPending( + projectPath, + workspaceInfo.packages, + catalogDependencyResolver, + ); + const vitestEcosystemPackages = collectVitestEcosystemInstallDependencyNames( + projectPath, + workspaceInfo.packages, + ); editJsonFile< BootstrapPackageJson & { @@ -4507,6 +4694,7 @@ export function ensureVitePlusBootstrap( const catalogDependencyResolver = readPnpmWorkspaceCatalogDependencyResolver(projectPath); if ( result.packageJson || + ecosystemCatalogReferencesPending || !pnpmWorkspaceExoticSubdepsSettingSatisfied(projectPath) || defaultCatalogVitePlusDependencyPending(pkg, catalogDependencyResolver) || !overridesSatisfyVitePlus( @@ -4524,6 +4712,7 @@ export function ensureVitePlusBootstrap( pnpmMajorVersion, shouldAllowBrowserBuilds, usesVitest, + vitestEcosystemPackages, ); } if (fs.existsSync(pnpmWorkspaceYamlPath)) { @@ -4542,12 +4731,12 @@ export function ensureVitePlusBootstrap( const before = fs.existsSync(yarnrcYmlPath) ? fs.readFileSync(yarnrcYmlPath, 'utf-8') : undefined; - rewriteYarnrcYml(projectPath, usesVitest); + rewriteYarnrcYml(projectPath, usesVitest, vitestEcosystemPackages); const after = fs.readFileSync(yarnrcYmlPath, 'utf-8'); result.packageManagerConfig = before !== after; } else if (workspaceInfo.packageManager === PackageManager.bun) { const before = fs.readFileSync(packageJsonPath, 'utf-8'); - rewriteBunCatalog(projectPath, usesVitest); + rewriteBunCatalog(projectPath, usesVitest, vitestEcosystemPackages); const after = fs.readFileSync(packageJsonPath, 'utf-8'); result.packageJson = result.packageJson || before !== after; } @@ -5001,7 +5190,7 @@ export function rewritePackageJson( // every declared official @vitest/* package on the bundled version during a // fresh migration too; existing-Vite+ upgrades use the same rule in the // bootstrap path. - alignVitestEcosystemPackages(pkg); + alignVitestEcosystemPackages(pkg, packageManager, supportCatalog, catalogDependencyResolver); // Force-override mode (ecosystem CI / `vp create` E2E) must re-pin any // pre-existing `vite-plus` range to the local tgz. Otherwise pnpm reads the // published vite-plus metadata for transitive dep resolution (e.g. @@ -5057,12 +5246,11 @@ export function rewritePackageJson( // rewritten `vite-plus/test/browser-` import to resolve. Unlike the // rest of the `@vitest/*` family they are deliberately NOT in // VITE_PLUS_OVERRIDE_PACKAGES (so projects not using a provider stay - // untouched), which means the normalization loop above does not pin them. We - // pin each used provider here, to a CONCRETE version (no catalog entry is - // written for an opt-in provider) so it self-resolves and stays aligned with - // the bundled vitest, and we ensure its runtime framework peer - // (`webdriverio` / `playwright`). (`@vitest/browser`/preview stay bundled + - // stripped, handled in the REMOVE_PACKAGES loop above.) + // untouched), which means the normalization loop above does not add them. We + // align each installed provider here using its existing catalog when present, + // or the concrete bundled version otherwise, and ensure its runtime framework + // peer (`webdriverio` / `playwright`). (`@vitest/browser`/preview stay bundled + // + stripped, handled in the REMOVE_PACKAGES loop above.) let usesAnyOptInProvider = false; for (const provider of OPT_IN_BROWSER_PROVIDERS) { const usesProvider = @@ -5077,12 +5265,20 @@ export function rewritePackageJson( // resolve. Normalize an existing install-group declaration to the bundled // vitest version in place (the override loop above no longer pins it); // otherwise — a source-only or peer-only user — inject it into devDeps. - const installGroup = [pkg.dependencies, pkg.devDependencies, pkg.optionalDependencies].find( - (deps) => deps?.[provider] !== undefined, + const installGroupEntry = dependencyGroups.find( + ({ dependencyField, dependencies }) => + dependencyField !== 'peerDependencies' && dependencies?.[provider] !== undefined, ); - if (installGroup) { + if (installGroupEntry?.dependencies) { if (VITEST_IS_MANAGED_OVERRIDE) { - installGroup[provider] = VITEST_VERSION; + installGroupEntry.dependencies[provider] = getAlignedVitestEcosystemDependencySpec( + installGroupEntry.dependencies[provider], + provider, + installGroupEntry.dependencyField, + packageManager, + supportCatalog, + catalogDependencyResolver, + ); } } else { pkg.devDependencies ??= {}; From c88d00d84cfe31b5f8ad2ce6a69536cc9c1d4b63 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 24 Jun 2026 11:51:06 +0800 Subject: [PATCH 28/78] fix(migrate): pin vite-plus toolchain versions --- .../migration-add-git-hooks/snap.txt | 4 +- .../migration-already-vite-plus/snap.txt | 4 +- .../snap.txt | 4 +- .../migration-baseurl-tsconfig/snap.txt | 4 +- .../snap.txt | 4 +- .../snap.txt | 4 +- .../migration-composed-husky-prepare/snap.txt | 4 +- .../migration-env-prefix-lint-staged/snap.txt | 4 +- .../migration-eslint-lint-staged/snap.txt | 4 +- .../migration-eslint-lintstagedrc/snap.txt | 4 +- .../migration-eslint-npx-wrapper/snap.txt | 4 +- .../migration-eslint/snap.txt | 4 +- .../snap.txt | 4 +- .../snap.txt | 4 +- .../snap.txt | 4 +- .../migration-existing-husky/snap.txt | 4 +- .../snap.txt | 4 +- .../snap.txt | 4 +- .../snap.txt | 4 +- .../snap.txt | 4 +- .../migration-from-tsdown/snap.txt | 4 +- .../migration-from-vitest-config/snap.txt | 4 +- .../migration-from-vitest-files/snap.txt | 4 +- .../snap.txt | 4 +- .../migration-husky-catalog-version/snap.txt | 4 +- .../snap.txt | 4 +- .../migration-husky-latest-dist-tag/snap.txt | 4 +- .../migration-husky-or-prepare/snap.txt | 4 +- .../snap.txt | 4 +- .../snap.txt | 4 +- .../migration-lazy-plugins-await/snap.txt | 4 +- .../migration-lint-staged-in-scripts/snap.txt | 4 +- .../migration-lint-staged-merge-fail/snap.txt | 4 +- .../migration-lint-staged-ts-config/snap.txt | 4 +- .../migration-lintstagedrc-json/snap.txt | 4 +- .../snap.txt | 4 +- .../snap.txt | 4 +- .../snap.txt | 4 +- .../migration-merge-vite-config-js/snap.txt | 4 +- .../migration-merge-vite-config-ts/snap.txt | 4 +- .../migration-monorepo-bun/snap.txt | 4 +- .../snap.txt | 4 +- .../migration-monorepo-pnpm/snap.txt | 4 +- .../migration-monorepo-yarn4/snap.txt | 6 +-- .../migration-no-git-repo/snap.txt | 4 +- .../migration-no-hooks-with-husky/snap.txt | 4 +- .../migration-no-hooks/snap.txt | 4 +- .../migration-other-hook-tool/snap.txt | 4 +- .../snap.txt | 4 +- .../migration-oxlintrc-jsonc/snap.txt | 4 +- .../snap.txt | 4 +- .../snap.txt | 4 +- .../migration-prettier-eslint-combo/snap.txt | 4 +- .../snap.txt | 4 +- .../migration-prettier-lint-staged/snap.txt | 4 +- .../migration-prettier-pkg-json/snap.txt | 4 +- .../migration-prettier/snap.txt | 4 +- .../migration-rewrite-declare-module/snap.txt | 4 +- .../migration-skip-vite-dependency/snap.txt | 4 +- .../snap.txt | 4 +- .../migration-standalone-pnpm/snap.txt | 4 +- .../snap.txt | 7 ++-- .../migration-subpath/snap.txt | 4 +- .../snap.txt | 4 +- .../snap.txt | 5 +-- .../snap.txt | 4 +- .../snap.txt | 4 +- .../snap.txt | 4 +- .../snap.txt | 4 +- .../snap.txt | 5 +-- .../snap.txt | 5 +-- .../snap.txt | 5 +-- .../snap.txt | 5 +-- .../snap.txt | 5 +-- .../snap.txt | 4 +- .../snap.txt | 2 +- .../snap.txt | 4 +- .../snap.txt | 6 +-- .../snap.txt | 4 +- .../snap.txt | 5 +-- .../snap.txt | 5 +-- .../migration-vite-version/snap.txt | 4 +- .../migration-vitest-import-only/snap.txt | 4 +- .../migration-vitest-peer-dep/snap.txt | 4 +- .../snap.txt | 3 +- .../new-vite-monorepo-bun/snap.txt | 4 +- .../new-vite-monorepo/snap.txt | 4 +- .../create-approve-builds-bun/snap.txt | 18 ++++----- .../snap.txt | 12 +++--- .../create-approve-builds-pnpm11/snap.txt | 12 +++--- .../create-approve-builds-yarn/snap.txt | 4 +- .../create-org-bundled-monorepo/snap.txt | 4 +- .../src/migration/__tests__/migrator.spec.ts | 9 ++++- .../cli/src/utils/__tests__/constants.spec.ts | 37 +++++++++++++++++++ packages/cli/src/utils/constants.ts | 6 ++- packages/tools/src/utils.ts | 8 ++++ rfcs/migrate-existing-projects.md | 2 +- 97 files changed, 258 insertions(+), 214 deletions(-) create mode 100644 packages/cli/src/utils/__tests__/constants.spec.ts diff --git a/packages/cli/snap-tests-global/migration-add-git-hooks/snap.txt b/packages/cli/snap-tests-global/migration-add-git-hooks/snap.txt index f59ebbe259..6cc0357e2d 100644 --- a/packages/cli/snap-tests-global/migration-add-git-hooks/snap.txt +++ b/packages/cli/snap-tests-global/migration-add-git-hooks/snap.txt @@ -26,8 +26,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-already-vite-plus/snap.txt b/packages/cli/snap-tests-global/migration-already-vite-plus/snap.txt index ccefd20e87..110719bbfb 100644 --- a/packages/cli/snap-tests-global/migration-already-vite-plus/snap.txt +++ b/packages/cli/snap-tests-global/migration-already-vite-plus/snap.txt @@ -12,10 +12,10 @@ { "name": "migration-already-vite-plus", "devDependencies": { - "vite-plus": "latest" + "vite-plus": "" }, "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest" + "vite": "npm:@voidzero-dev/vite-plus-core@" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-auto-create-vite-config/snap.txt b/packages/cli/snap-tests-global/migration-auto-create-vite-config/snap.txt index 3d80db12e9..1a116978b0 100644 --- a/packages/cli/snap-tests-global/migration-auto-create-vite-config/snap.txt +++ b/packages/cli/snap-tests-global/migration-auto-create-vite-config/snap.txt @@ -57,8 +57,8 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-baseurl-tsconfig/snap.txt b/packages/cli/snap-tests-global/migration-baseurl-tsconfig/snap.txt index ecbe162940..9eb8b278c6 100644 --- a/packages/cli/snap-tests-global/migration-baseurl-tsconfig/snap.txt +++ b/packages/cli/snap-tests-global/migration-baseurl-tsconfig/snap.txt @@ -60,8 +60,8 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-chained-lint-staged-pre-commit/snap.txt b/packages/cli/snap-tests-global/migration-chained-lint-staged-pre-commit/snap.txt index 43f904efba..08ac7a6659 100644 --- a/packages/cli/snap-tests-global/migration-chained-lint-staged-pre-commit/snap.txt +++ b/packages/cli/snap-tests-global/migration-chained-lint-staged-pre-commit/snap.txt @@ -26,8 +26,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-composed-husky-custom-dir/snap.txt b/packages/cli/snap-tests-global/migration-composed-husky-custom-dir/snap.txt index 9077f28eb6..6fd81f4a5a 100644 --- a/packages/cli/snap-tests-global/migration-composed-husky-custom-dir/snap.txt +++ b/packages/cli/snap-tests-global/migration-composed-husky-custom-dir/snap.txt @@ -26,8 +26,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-composed-husky-prepare/snap.txt b/packages/cli/snap-tests-global/migration-composed-husky-prepare/snap.txt index ee4d3f501a..b1fec8a9b4 100644 --- a/packages/cli/snap-tests-global/migration-composed-husky-prepare/snap.txt +++ b/packages/cli/snap-tests-global/migration-composed-husky-prepare/snap.txt @@ -26,8 +26,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-env-prefix-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-env-prefix-lint-staged/snap.txt index 2351276fc8..514f67d59b 100644 --- a/packages/cli/snap-tests-global/migration-env-prefix-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-env-prefix-lint-staged/snap.txt @@ -26,8 +26,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-eslint-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-eslint-lint-staged/snap.txt index 8f84735ba4..c43662a1f6 100644 --- a/packages/cli/snap-tests-global/migration-eslint-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint-lint-staged/snap.txt @@ -26,8 +26,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-eslint-lintstagedrc/snap.txt b/packages/cli/snap-tests-global/migration-eslint-lintstagedrc/snap.txt index 4ac2df74b8..6533b25c32 100644 --- a/packages/cli/snap-tests-global/migration-eslint-lintstagedrc/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint-lintstagedrc/snap.txt @@ -26,8 +26,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-eslint-npx-wrapper/snap.txt b/packages/cli/snap-tests-global/migration-eslint-npx-wrapper/snap.txt index cfb60af6e8..527ec413a8 100644 --- a/packages/cli/snap-tests-global/migration-eslint-npx-wrapper/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint-npx-wrapper/snap.txt @@ -31,8 +31,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-eslint/snap.txt b/packages/cli/snap-tests-global/migration-eslint/snap.txt index a6795c9c48..4c46bc311c 100644 --- a/packages/cli/snap-tests-global/migration-eslint/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint/snap.txt @@ -29,8 +29,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-existing-husky-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-existing-husky-lint-staged/snap.txt index 5bfc30a07b..ad7c85413a 100644 --- a/packages/cli/snap-tests-global/migration-existing-husky-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-husky-lint-staged/snap.txt @@ -26,8 +26,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-existing-husky-v8-hooks/snap.txt b/packages/cli/snap-tests-global/migration-existing-husky-v8-hooks/snap.txt index 7d7556c838..95ddbf983b 100644 --- a/packages/cli/snap-tests-global/migration-existing-husky-v8-hooks/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-husky-v8-hooks/snap.txt @@ -29,8 +29,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-existing-husky-v8-multi-hooks/snap.txt b/packages/cli/snap-tests-global/migration-existing-husky-v8-multi-hooks/snap.txt index 1bd78cc26e..50ff3e3a31 100644 --- a/packages/cli/snap-tests-global/migration-existing-husky-v8-multi-hooks/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-husky-v8-multi-hooks/snap.txt @@ -29,8 +29,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-existing-husky/snap.txt b/packages/cli/snap-tests-global/migration-existing-husky/snap.txt index 625779fd04..da674c99f3 100644 --- a/packages/cli/snap-tests-global/migration-existing-husky/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-husky/snap.txt @@ -26,8 +26,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-existing-lint-staged-config/snap.txt b/packages/cli/snap-tests-global/migration-existing-lint-staged-config/snap.txt index 8ca67d4068..71d8b3ca22 100644 --- a/packages/cli/snap-tests-global/migration-existing-lint-staged-config/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-lint-staged-config/snap.txt @@ -26,8 +26,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-existing-pnpm-exec-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-existing-pnpm-exec-lint-staged/snap.txt index 1e8305dbd6..b4d6dd5099 100644 --- a/packages/cli/snap-tests-global/migration-existing-pnpm-exec-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-pnpm-exec-lint-staged/snap.txt @@ -26,8 +26,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-existing-prepare-script/snap.txt b/packages/cli/snap-tests-global/migration-existing-prepare-script/snap.txt index df00607092..2515239baa 100644 --- a/packages/cli/snap-tests-global/migration-existing-prepare-script/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-prepare-script/snap.txt @@ -27,8 +27,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-from-tsdown-json-config/snap.txt b/packages/cli/snap-tests-global/migration-from-tsdown-json-config/snap.txt index dff04522cd..cf858af8ea 100644 --- a/packages/cli/snap-tests-global/migration-from-tsdown-json-config/snap.txt +++ b/packages/cli/snap-tests-global/migration-from-tsdown-json-config/snap.txt @@ -51,8 +51,8 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-from-tsdown/snap.txt b/packages/cli/snap-tests-global/migration-from-tsdown/snap.txt index 1045b7499e..85684a25b1 100644 --- a/packages/cli/snap-tests-global/migration-from-tsdown/snap.txt +++ b/packages/cli/snap-tests-global/migration-from-tsdown/snap.txt @@ -53,8 +53,8 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-from-vitest-config/snap.txt b/packages/cli/snap-tests-global/migration-from-vitest-config/snap.txt index 9a6c718500..d1e01f3cd9 100644 --- a/packages/cli/snap-tests-global/migration-from-vitest-config/snap.txt +++ b/packages/cli/snap-tests-global/migration-from-vitest-config/snap.txt @@ -59,9 +59,9 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest + vite: npm:@voidzero-dev/vite-plus-core@ vitest: - vite-plus: latest + vite-plus: allowBuilds: edgedriver: true geckodriver: true diff --git a/packages/cli/snap-tests-global/migration-from-vitest-files/snap.txt b/packages/cli/snap-tests-global/migration-from-vitest-files/snap.txt index a72ade1a4e..c66403539c 100644 --- a/packages/cli/snap-tests-global/migration-from-vitest-files/snap.txt +++ b/packages/cli/snap-tests-global/migration-from-vitest-files/snap.txt @@ -32,9 +32,9 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest + vite: npm:@voidzero-dev/vite-plus-core@ vitest: - vite-plus: latest + vite-plus: overrides: vite: 'catalog:' vitest: 'catalog:' diff --git a/packages/cli/snap-tests-global/migration-hooks-skip-on-existing-hookspath/snap.txt b/packages/cli/snap-tests-global/migration-hooks-skip-on-existing-hookspath/snap.txt index 5dd710ab9f..758f2bd08c 100644 --- a/packages/cli/snap-tests-global/migration-hooks-skip-on-existing-hookspath/snap.txt +++ b/packages/cli/snap-tests-global/migration-hooks-skip-on-existing-hookspath/snap.txt @@ -29,8 +29,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-husky-catalog-version/snap.txt b/packages/cli/snap-tests-global/migration-husky-catalog-version/snap.txt index 132c4aff73..d898c5e5fd 100644 --- a/packages/cli/snap-tests-global/migration-husky-catalog-version/snap.txt +++ b/packages/cli/snap-tests-global/migration-husky-catalog-version/snap.txt @@ -34,8 +34,8 @@ packages: catalog: husky: ^9.1.7 lint-staged: ^16.2.6 - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-husky-latest-dist-tag-v9-installed/snap.txt b/packages/cli/snap-tests-global/migration-husky-latest-dist-tag-v9-installed/snap.txt index 553fb5694d..395807de91 100644 --- a/packages/cli/snap-tests-global/migration-husky-latest-dist-tag-v9-installed/snap.txt +++ b/packages/cli/snap-tests-global/migration-husky-latest-dist-tag-v9-installed/snap.txt @@ -26,8 +26,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-husky-latest-dist-tag/snap.txt b/packages/cli/snap-tests-global/migration-husky-latest-dist-tag/snap.txt index fa4e63bf77..9c7b70b6a5 100644 --- a/packages/cli/snap-tests-global/migration-husky-latest-dist-tag/snap.txt +++ b/packages/cli/snap-tests-global/migration-husky-latest-dist-tag/snap.txt @@ -28,8 +28,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-husky-or-prepare/snap.txt b/packages/cli/snap-tests-global/migration-husky-or-prepare/snap.txt index a5cec54506..72d9aa6a38 100644 --- a/packages/cli/snap-tests-global/migration-husky-or-prepare/snap.txt +++ b/packages/cli/snap-tests-global/migration-husky-or-prepare/snap.txt @@ -26,8 +26,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-husky-semicolon-prepare/snap.txt b/packages/cli/snap-tests-global/migration-husky-semicolon-prepare/snap.txt index f8da4e6f7a..8502bb4afe 100644 --- a/packages/cli/snap-tests-global/migration-husky-semicolon-prepare/snap.txt +++ b/packages/cli/snap-tests-global/migration-husky-semicolon-prepare/snap.txt @@ -26,8 +26,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-husky-v8-preserves-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-husky-v8-preserves-lint-staged/snap.txt index acac9f1da0..89cc040a7f 100644 --- a/packages/cli/snap-tests-global/migration-husky-v8-preserves-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-husky-v8-preserves-lint-staged/snap.txt @@ -32,8 +32,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-lazy-plugins-await/snap.txt b/packages/cli/snap-tests-global/migration-lazy-plugins-await/snap.txt index bbdf28e64a..84dc9c9e94 100644 --- a/packages/cli/snap-tests-global/migration-lazy-plugins-await/snap.txt +++ b/packages/cli/snap-tests-global/migration-lazy-plugins-await/snap.txt @@ -35,8 +35,8 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-lint-staged-in-scripts/snap.txt b/packages/cli/snap-tests-global/migration-lint-staged-in-scripts/snap.txt index 7960ec688f..1c21e8be94 100644 --- a/packages/cli/snap-tests-global/migration-lint-staged-in-scripts/snap.txt +++ b/packages/cli/snap-tests-global/migration-lint-staged-in-scripts/snap.txt @@ -27,8 +27,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-lint-staged-merge-fail/snap.txt b/packages/cli/snap-tests-global/migration-lint-staged-merge-fail/snap.txt index 360d056826..24d8e4eb9f 100644 --- a/packages/cli/snap-tests-global/migration-lint-staged-merge-fail/snap.txt +++ b/packages/cli/snap-tests-global/migration-lint-staged-merge-fail/snap.txt @@ -35,8 +35,8 @@ Please add staged config to vite.config.ts manually, see https://viteplus.dev/gu > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-lint-staged-ts-config/snap.txt b/packages/cli/snap-tests-global/migration-lint-staged-ts-config/snap.txt index 4d1ecec73f..3fab705ccf 100644 --- a/packages/cli/snap-tests-global/migration-lint-staged-ts-config/snap.txt +++ b/packages/cli/snap-tests-global/migration-lint-staged-ts-config/snap.txt @@ -30,8 +30,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-lintstagedrc-json/snap.txt b/packages/cli/snap-tests-global/migration-lintstagedrc-json/snap.txt index 3a400ae3d2..690ffeca5b 100644 --- a/packages/cli/snap-tests-global/migration-lintstagedrc-json/snap.txt +++ b/packages/cli/snap-tests-global/migration-lintstagedrc-json/snap.txt @@ -100,8 +100,8 @@ Documentation: https://viteplus.dev/guide/migrate > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-lintstagedrc-merge-fail/snap.txt b/packages/cli/snap-tests-global/migration-lintstagedrc-merge-fail/snap.txt index bd3f0f4a87..ddc3c2228e 100644 --- a/packages/cli/snap-tests-global/migration-lintstagedrc-merge-fail/snap.txt +++ b/packages/cli/snap-tests-global/migration-lintstagedrc-merge-fail/snap.txt @@ -32,8 +32,8 @@ Please add staged config to vite.config.ts manually, see https://viteplus.dev/gu > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-lintstagedrc-not-support/snap.txt b/packages/cli/snap-tests-global/migration-lintstagedrc-not-support/snap.txt index 2a3455d330..449aed2d16 100644 --- a/packages/cli/snap-tests-global/migration-lintstagedrc-not-support/snap.txt +++ b/packages/cli/snap-tests-global/migration-lintstagedrc-not-support/snap.txt @@ -44,8 +44,8 @@ export default { > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-lintstagedrc-staged-exists/snap.txt b/packages/cli/snap-tests-global/migration-lintstagedrc-staged-exists/snap.txt index 6d03dc48a1..9f39a98e4a 100644 --- a/packages/cli/snap-tests-global/migration-lintstagedrc-staged-exists/snap.txt +++ b/packages/cli/snap-tests-global/migration-lintstagedrc-staged-exists/snap.txt @@ -27,8 +27,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-merge-vite-config-js/snap.txt b/packages/cli/snap-tests-global/migration-merge-vite-config-js/snap.txt index f2ba7beff2..c41684e788 100644 --- a/packages/cli/snap-tests-global/migration-merge-vite-config-js/snap.txt +++ b/packages/cli/snap-tests-global/migration-merge-vite-config-js/snap.txt @@ -57,8 +57,8 @@ export default { > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-merge-vite-config-ts/snap.txt b/packages/cli/snap-tests-global/migration-merge-vite-config-ts/snap.txt index d63ee649df..f3958fbf7c 100644 --- a/packages/cli/snap-tests-global/migration-merge-vite-config-ts/snap.txt +++ b/packages/cli/snap-tests-global/migration-merge-vite-config-ts/snap.txt @@ -91,9 +91,9 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest + vite: npm:@voidzero-dev/vite-plus-core@ vitest: - vite-plus: latest + vite-plus: overrides: vite: 'catalog:' vitest: 'catalog:' diff --git a/packages/cli/snap-tests-global/migration-monorepo-bun/snap.txt b/packages/cli/snap-tests-global/migration-monorepo-bun/snap.txt index 8a36eae8d9..e9aa907d74 100644 --- a/packages/cli/snap-tests-global/migration-monorepo-bun/snap.txt +++ b/packages/cli/snap-tests-global/migration-monorepo-bun/snap.txt @@ -44,9 +44,9 @@ export default defineConfig({ "packages/*" ], "catalog": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vite": "npm:@voidzero-dev/vite-plus-core@", "vitest": "", - "vite-plus": "latest" + "vite-plus": "" } }, "scripts": { diff --git a/packages/cli/snap-tests-global/migration-monorepo-pnpm-overrides-dependency-selector/snap.txt b/packages/cli/snap-tests-global/migration-monorepo-pnpm-overrides-dependency-selector/snap.txt index ec987b96f3..7f09fd6b80 100644 --- a/packages/cli/snap-tests-global/migration-monorepo-pnpm-overrides-dependency-selector/snap.txt +++ b/packages/cli/snap-tests-global/migration-monorepo-pnpm-overrides-dependency-selector/snap.txt @@ -38,8 +38,8 @@ packages: - packages/* catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: '@vitejs/plugin-react>vite': 'npm:vite@' diff --git a/packages/cli/snap-tests-global/migration-monorepo-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-monorepo-pnpm/snap.txt index 0b8b6f890d..44f79f4a1b 100644 --- a/packages/cli/snap-tests-global/migration-monorepo-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-monorepo-pnpm/snap.txt @@ -82,9 +82,9 @@ packages: catalog: testnpm2: ^1.0.0 # test comment here to check if the comment is preserved - vite: npm:@voidzero-dev/vite-plus-core@latest + vite: npm:@voidzero-dev/vite-plus-core@ vitest: - vite-plus: latest + vite-plus: minimumReleaseAge: 1440 overrides: diff --git a/packages/cli/snap-tests-global/migration-monorepo-yarn4/snap.txt b/packages/cli/snap-tests-global/migration-monorepo-yarn4/snap.txt index f31071a5a8..2e8b54bad1 100644 --- a/packages/cli/snap-tests-global/migration-monorepo-yarn4/snap.txt +++ b/packages/cli/snap-tests-global/migration-monorepo-yarn4/snap.txt @@ -70,7 +70,7 @@ export default defineConfig({ }, "packageManager": "yarn@", "resolutions": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vite": "npm:@voidzero-dev/vite-plus-core@", "vitest": "" } } @@ -81,9 +81,9 @@ npmPreapprovedPackages: - vitest - '@vitest/*' catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest + vite: npm:@voidzero-dev/vite-plus-core@ vitest: - vite-plus: latest + vite-plus: > cat packages/app/package.json # check app package.json { diff --git a/packages/cli/snap-tests-global/migration-no-git-repo/snap.txt b/packages/cli/snap-tests-global/migration-no-git-repo/snap.txt index 39fbe1bd62..2d01e02028 100644 --- a/packages/cli/snap-tests-global/migration-no-git-repo/snap.txt +++ b/packages/cli/snap-tests-global/migration-no-git-repo/snap.txt @@ -24,8 +24,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-no-hooks-with-husky/snap.txt b/packages/cli/snap-tests-global/migration-no-hooks-with-husky/snap.txt index 7299b0296f..cbdf2664ae 100644 --- a/packages/cli/snap-tests-global/migration-no-hooks-with-husky/snap.txt +++ b/packages/cli/snap-tests-global/migration-no-hooks-with-husky/snap.txt @@ -31,8 +31,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-no-hooks/snap.txt b/packages/cli/snap-tests-global/migration-no-hooks/snap.txt index f9dcc0b68b..d00770f2f9 100644 --- a/packages/cli/snap-tests-global/migration-no-hooks/snap.txt +++ b/packages/cli/snap-tests-global/migration-no-hooks/snap.txt @@ -22,8 +22,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-other-hook-tool/snap.txt b/packages/cli/snap-tests-global/migration-other-hook-tool/snap.txt index f741059b24..2a78d94c6e 100644 --- a/packages/cli/snap-tests-global/migration-other-hook-tool/snap.txt +++ b/packages/cli/snap-tests-global/migration-other-hook-tool/snap.txt @@ -34,8 +34,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-oxlintrc-json-with-comments/snap.txt b/packages/cli/snap-tests-global/migration-oxlintrc-json-with-comments/snap.txt index 744e1994cf..bdfe57bdac 100644 --- a/packages/cli/snap-tests-global/migration-oxlintrc-json-with-comments/snap.txt +++ b/packages/cli/snap-tests-global/migration-oxlintrc-json-with-comments/snap.txt @@ -55,8 +55,8 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-oxlintrc-jsonc/snap.txt b/packages/cli/snap-tests-global/migration-oxlintrc-jsonc/snap.txt index ebf2188bf3..2f8b98ca7f 100644 --- a/packages/cli/snap-tests-global/migration-oxlintrc-jsonc/snap.txt +++ b/packages/cli/snap-tests-global/migration-oxlintrc-jsonc/snap.txt @@ -57,8 +57,8 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-partially-installed-vite-plus/snap.txt b/packages/cli/snap-tests-global/migration-partially-installed-vite-plus/snap.txt index 8b8b296ed1..83b5c2b3f1 100644 --- a/packages/cli/snap-tests-global/migration-partially-installed-vite-plus/snap.txt +++ b/packages/cli/snap-tests-global/migration-partially-installed-vite-plus/snap.txt @@ -41,8 +41,8 @@ > cat pnpm-workspace.yaml # pnpm overrides and peerDependencyRules should be configured catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-partially-migrated-pre-commit/snap.txt b/packages/cli/snap-tests-global/migration-partially-migrated-pre-commit/snap.txt index efae8980d3..4c73dac2ae 100644 --- a/packages/cli/snap-tests-global/migration-partially-migrated-pre-commit/snap.txt +++ b/packages/cli/snap-tests-global/migration-partially-migrated-pre-commit/snap.txt @@ -29,8 +29,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-prettier-eslint-combo/snap.txt b/packages/cli/snap-tests-global/migration-prettier-eslint-combo/snap.txt index aab95fdf19..f4dfa42963 100644 --- a/packages/cli/snap-tests-global/migration-prettier-eslint-combo/snap.txt +++ b/packages/cli/snap-tests-global/migration-prettier-eslint-combo/snap.txt @@ -33,8 +33,8 @@ Prettier configuration detected. Auto-migrating to Oxfmt... > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-prettier-ignore-unknown/snap.txt b/packages/cli/snap-tests-global/migration-prettier-ignore-unknown/snap.txt index 7b49f4cde1..7baeaff783 100644 --- a/packages/cli/snap-tests-global/migration-prettier-ignore-unknown/snap.txt +++ b/packages/cli/snap-tests-global/migration-prettier-ignore-unknown/snap.txt @@ -31,8 +31,8 @@ Prettier configuration detected. Auto-migrating to Oxfmt... > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-prettier-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-prettier-lint-staged/snap.txt index c7ef71ca50..d14673abd6 100644 --- a/packages/cli/snap-tests-global/migration-prettier-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-prettier-lint-staged/snap.txt @@ -28,8 +28,8 @@ Prettier configuration detected. Auto-migrating to Oxfmt... > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-prettier-pkg-json/snap.txt b/packages/cli/snap-tests-global/migration-prettier-pkg-json/snap.txt index b199e2a1b1..3353b0422d 100644 --- a/packages/cli/snap-tests-global/migration-prettier-pkg-json/snap.txt +++ b/packages/cli/snap-tests-global/migration-prettier-pkg-json/snap.txt @@ -29,8 +29,8 @@ Prettier configuration detected. Auto-migrating to Oxfmt... > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-prettier/snap.txt b/packages/cli/snap-tests-global/migration-prettier/snap.txt index 82bc36c019..98486f0ebc 100644 --- a/packages/cli/snap-tests-global/migration-prettier/snap.txt +++ b/packages/cli/snap-tests-global/migration-prettier/snap.txt @@ -31,8 +31,8 @@ Prettier configuration detected. Auto-migrating to Oxfmt... > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-rewrite-declare-module/snap.txt b/packages/cli/snap-tests-global/migration-rewrite-declare-module/snap.txt index 0dca63041b..29bfd72c09 100644 --- a/packages/cli/snap-tests-global/migration-rewrite-declare-module/snap.txt +++ b/packages/cli/snap-tests-global/migration-rewrite-declare-module/snap.txt @@ -56,9 +56,9 @@ declare module 'vitest/config' { > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest + vite: npm:@voidzero-dev/vite-plus-core@ vitest: - vite-plus: latest + vite-plus: overrides: vite: 'catalog:' vitest: 'catalog:' diff --git a/packages/cli/snap-tests-global/migration-skip-vite-dependency/snap.txt b/packages/cli/snap-tests-global/migration-skip-vite-dependency/snap.txt index 042f2820f0..5ff6f9ab99 100644 --- a/packages/cli/snap-tests-global/migration-skip-vite-dependency/snap.txt +++ b/packages/cli/snap-tests-global/migration-skip-vite-dependency/snap.txt @@ -49,8 +49,8 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: 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 ecce492eef..2cacaef26b 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 @@ -49,8 +49,8 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-standalone-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-standalone-pnpm/snap.txt index 244b0c7f87..0c6b49172b 100644 --- a/packages/cli/snap-tests-global/migration-standalone-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-standalone-pnpm/snap.txt @@ -16,8 +16,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides, peerDependencyRules, and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/snap.txt b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/snap.txt index 4f5d245b29..432e3c8c74 100644 --- a/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/snap.txt +++ b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/snap.txt @@ -21,7 +21,7 @@ }, "packageManager": "yarn@", "resolutions": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest" + "vite": "npm:@voidzero-dev/vite-plus-core@" } } @@ -31,8 +31,8 @@ npmPreapprovedPackages: - vitest - '@vitest/*' catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: > cat example.spec.ts # ordinary Vitest imports use the Vite+ public surface import { expect, it } from 'vite-plus/test'; @@ -41,4 +41,3 @@ it('works', () => expect(true).toBe(true)); > vp migrate --no-interactive # a freshly migrated standalone Yarn project is complete This project is already using Vite+! Happy coding! - diff --git a/packages/cli/snap-tests-global/migration-subpath/snap.txt b/packages/cli/snap-tests-global/migration-subpath/snap.txt index 6b2fe92a14..b89fab770c 100644 --- a/packages/cli/snap-tests-global/migration-subpath/snap.txt +++ b/packages/cli/snap-tests-global/migration-subpath/snap.txt @@ -44,8 +44,8 @@ core.hooksPath is not set > cat foo/pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-tsconfig-esmoduleinterop/snap.txt b/packages/cli/snap-tests-global/migration-tsconfig-esmoduleinterop/snap.txt index 00262e60ed..5840a8b3a4 100644 --- a/packages/cli/snap-tests-global/migration-tsconfig-esmoduleinterop/snap.txt +++ b/packages/cli/snap-tests-global/migration-tsconfig-esmoduleinterop/snap.txt @@ -46,8 +46,8 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/snap.txt index 51b2bb428e..72a860833a 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/snap.txt @@ -26,8 +26,8 @@ > cat pnpm-workspace.yaml # promoted provider keeps shared Vitest management catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: vitest: overrides: vite: 'catalog:' @@ -42,4 +42,3 @@ peerDependencyRules: > vp migrate --no-interactive # repaired project should no longer be pending This project is already using Vite+! Happy coding! - diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/snap.txt index 2e020716a1..70b04086d7 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/snap.txt @@ -23,8 +23,8 @@ > cat pnpm-workspace.yaml # shared vitest catalog and override should be present catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: vitest: overrides: vite: 'catalog:' diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/snap.txt index e5f69418c0..b4da107e9f 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/snap.txt @@ -23,8 +23,8 @@ > cat pnpm-workspace.yaml # driver builds and shared vitest should be enabled catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: vitest: overrides: vite: 'catalog:' diff --git a/packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/snap.txt index 5d1d0d9b1c..2f2872b2b8 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/snap.txt @@ -8,11 +8,11 @@ "name": "migration-upgrade-deprecated-coverage-c8-npm", "devDependencies": { "@vitest/coverage-c8": "^0.33.0", - "vite-plus": "latest", + "vite-plus": "", "vitest": "" }, "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vite": "npm:@voidzero-dev/vite-plus-core@", "vitest": "" }, "devEngines": { diff --git a/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/snap.txt index a790df7831..614252bdef 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/snap.txt @@ -33,8 +33,8 @@ packages: - packages/* catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: vitest: overrides: vite: 'catalog:' diff --git a/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/snap.txt index d170208fe3..4454394ee9 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/snap.txt @@ -6,10 +6,10 @@ This project is already using Vite+! Happy coding! { "name": "migration-upgrade-nested-vitest-override-npm", "devDependencies": { - "vite-plus": "latest" + "vite-plus": "" }, "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vite": "npm:@voidzero-dev/vite-plus-core@", "vitest": { "@vitest/runner": "" } @@ -25,4 +25,3 @@ This project is already using Vite+! Happy coding! > vp migrate --no-interactive # nested override must not make migration permanently pending This project is already using Vite+! Happy coding! - diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/snap.txt index e43f57906d..b5b7ef9636 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/snap.txt @@ -27,9 +27,9 @@ packages: - packages/* catalog: - vite-plus: latest + vite-plus: vitest: - vite: npm:@voidzero-dev/vite-plus-core@latest + vite: npm:@voidzero-dev/vite-plus-core@ overrides: vite: 'catalog:' @@ -65,4 +65,3 @@ void expect; > vp migrate --no-interactive # workspace result is idempotent This project is already using Vite+! Happy coding! - diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/snap.txt index f0fca66ab3..c322569898 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/snap.txt @@ -10,11 +10,11 @@ "name": "migration-upgrade-nuxt-test-utils", "devDependencies": { "@nuxt/test-utils": "file:.fixture/nuxt-test-utils", - "vite-plus": "latest", + "vite-plus": "", "vitest": "" }, "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vite": "npm:@voidzero-dev/vite-plus-core@", "vitest": "" }, "devEngines": { @@ -43,4 +43,3 @@ expect(true).toBe(true); > vp migrate --no-interactive # the package-level compatibility result is idempotent This project is already using Vite+! Happy coding! - diff --git a/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/snap.txt index 61c5a2be7b..1102051ca6 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/snap.txt @@ -23,8 +23,8 @@ > cat pnpm-workspace.yaml # unreferenced managed Vitest catalog is removed catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: catalogs: test: {} overrides: @@ -37,4 +37,3 @@ peerDependencyRules: > vp migrate --no-interactive # repaired project should no longer be pending This project is already using Vite+! Happy coding! - diff --git a/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/snap.txt index fa679f6ff3..bd06ee6529 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/snap.txt @@ -8,11 +8,11 @@ "name": "migration-upgrade-required-vitest-peer-metadata-npm", "devDependencies": { "vite-plugin-gherkin": "0.2.0", - "vite-plus": "latest", + "vite-plus": "", "vitest": "" }, "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vite": "npm:@voidzero-dev/vite-plus-core@", "vitest": "" }, "devEngines": { @@ -27,4 +27,3 @@ > node -e "const fs = require('node:fs'); fs.mkdirSync('node_modules', { recursive: true }); fs.cpSync('.fixture/vite-plugin-gherkin', 'node_modules/vite-plugin-gherkin', { recursive: true })" # simulate installed dependency metadata > vp migrate --no-interactive # metadata confirms the unnamed required Vitest peer This project is already using Vite+! Happy coding! - diff --git a/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/snap.txt index 009a844efb..a1d7a2f1d7 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/snap.txt @@ -30,5 +30,5 @@ peerDependencyRules: allowedVersions: vite: '*' catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: diff --git a/packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/snap.txt index 2b67a8c5e1..2edd4a9266 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/snap.txt @@ -10,7 +10,7 @@ "vite-plus": "file:../custom-vite-plus" }, "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest" + "vite": "npm:@voidzero-dev/vite-plus-core@" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/snap.txt index 06b21d930c..86fcab4b69 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/snap.txt @@ -12,11 +12,11 @@ "@vitest/ui": "", "@vitest/utils": "", "@vitest/web-worker": "", - "vite-plus": "latest", + "vite-plus": "", "vitest": "" }, "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vite": "npm:@voidzero-dev/vite-plus-core@", "vitest": "" }, "devEngines": { diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/snap.txt index 1a2c62b558..8a24282c5c 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/snap.txt @@ -16,7 +16,7 @@ "vitest": "catalog:" }, "resolutions": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vite": "npm:@voidzero-dev/vite-plus-core@", "vitest": "" }, "devEngines": { @@ -31,8 +31,8 @@ > cat .yarnrc.yml # linker conversion and aligned Vitest catalog are persisted nodeLinker: node-modules catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: vitest: npmPreapprovedPackages: - vitest diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/snap.txt index c2ec356064..4698aff3e8 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/snap.txt @@ -10,10 +10,10 @@ "@vitest/eslint-plugin": "^1.6.0", "@vitest/utils": "", "@vitest/ws-client": "", - "vite-plus": "latest" + "vite-plus": "" }, "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest" + "vite": "npm:@voidzero-dev/vite-plus-core@" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/snap.txt index be3bfa3b44..1b5bce1af1 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/snap.txt @@ -27,8 +27,8 @@ > cat pnpm-workspace.yaml # rewritten directive does not retain shared Vitest management catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: @@ -39,4 +39,3 @@ peerDependencyRules: > vp migrate --no-interactive # directive rewriting is stable on rerun This project is already using Vite+! Happy coding! - diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/snap.txt index d58ec2197c..3269c7bb16 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/snap.txt @@ -7,11 +7,11 @@ { "name": "migration-upgrade-vitest-retained-references-npm", "devDependencies": { - "vite-plus": "latest", + "vite-plus": "", "vitest": "" }, "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vite": "npm:@voidzero-dev/vite-plus-core@", "vitest": "" }, "devEngines": { @@ -47,4 +47,3 @@ console.log(metadata.version); > vp migrate --no-interactive # retained references remain stable on rerun This project is already using Vite+! Happy coding! - diff --git a/packages/cli/snap-tests-global/migration-vite-version/snap.txt b/packages/cli/snap-tests-global/migration-vite-version/snap.txt index 31d179e307..c729e9012e 100644 --- a/packages/cli/snap-tests-global/migration-vite-version/snap.txt +++ b/packages/cli/snap-tests-global/migration-vite-version/snap.txt @@ -26,8 +26,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-vitest-import-only/snap.txt b/packages/cli/snap-tests-global/migration-vitest-import-only/snap.txt index e51c39c3e3..ab2d6feba2 100644 --- a/packages/cli/snap-tests-global/migration-vitest-import-only/snap.txt +++ b/packages/cli/snap-tests-global/migration-vitest-import-only/snap.txt @@ -32,8 +32,8 @@ it('works', () => { > cat pnpm-workspace.yaml catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-vitest-peer-dep/snap.txt b/packages/cli/snap-tests-global/migration-vitest-peer-dep/snap.txt index e6e3ef859a..930fcd96ff 100644 --- a/packages/cli/snap-tests-global/migration-vitest-peer-dep/snap.txt +++ b/packages/cli/snap-tests-global/migration-vitest-peer-dep/snap.txt @@ -29,9 +29,9 @@ > cat pnpm-workspace.yaml catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest + vite: npm:@voidzero-dev/vite-plus-core@ vitest: - vite-plus: latest + vite-plus: overrides: vite: 'catalog:' vitest: 'catalog:' diff --git a/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/snap.txt b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/snap.txt index 0369832eb5..052cde0151 100644 --- a/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/snap.txt +++ b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/snap.txt @@ -29,7 +29,7 @@ > cat pnpm-workspace.yaml # no vitest catalog or override should be introduced catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: @@ -40,4 +40,3 @@ peerDependencyRules: > vp migrate --no-interactive # unmanaged Vitest ecosystem versions remain stable on rerun This project is already using Vite+! Happy coding! - diff --git a/packages/cli/snap-tests-global/new-vite-monorepo-bun/snap.txt b/packages/cli/snap-tests-global/new-vite-monorepo-bun/snap.txt index 4d6f94a18d..e5dff2a89c 100644 --- a/packages/cli/snap-tests-global/new-vite-monorepo-bun/snap.txt +++ b/packages/cli/snap-tests-global/new-vite-monorepo-bun/snap.txt @@ -43,8 +43,8 @@ vite.config.ts "node": ">=22.18.0" }, "catalog": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vite-plus": "latest" + "vite": "npm:@voidzero-dev/vite-plus-core@", + "vite-plus": "" } } diff --git a/packages/cli/snap-tests-global/new-vite-monorepo/snap.txt b/packages/cli/snap-tests-global/new-vite-monorepo/snap.txt index 2b23c516ab..0f3e934d5e 100644 --- a/packages/cli/snap-tests-global/new-vite-monorepo/snap.txt +++ b/packages/cli/snap-tests-global/new-vite-monorepo/snap.txt @@ -65,8 +65,8 @@ catalogMode: prefer catalog: "@types/node": ^24 typescript: ^5 - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: "catalog:" peerDependencyRules: diff --git a/packages/cli/snap-tests/create-approve-builds-bun/snap.txt b/packages/cli/snap-tests/create-approve-builds-bun/snap.txt index 6ab57ecfe0..0a0534d8ea 100644 --- a/packages/cli/snap-tests/create-approve-builds-bun/snap.txt +++ b/packages/cli/snap-tests/create-approve-builds-bun/snap.txt @@ -16,11 +16,11 @@ "core-js": "3.39.0" }, "devDependencies": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vite-plus": "latest" + "vite": "npm:@voidzero-dev/vite-plus-core@", + "vite-plus": "" }, "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest" + "vite": "npm:@voidzero-dev/vite-plus-core@" }, "devEngines": { "packageManager": { @@ -56,11 +56,11 @@ These dependencies may not work until built. Run vp pm approve-builds core-js in "core-js": "3.39.0" }, "devDependencies": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vite-plus": "latest" + "vite": "npm:@voidzero-dev/vite-plus-core@", + "vite-plus": "" }, "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest" + "vite": "npm:@voidzero-dev/vite-plus-core@" }, "devEngines": { "packageManager": { @@ -91,11 +91,11 @@ bun pm trust v () "core-js": "3.39.0" }, "devDependencies": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vite-plus": "latest" + "vite": "npm:@voidzero-dev/vite-plus-core@", + "vite-plus": "" }, "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest" + "vite": "npm:@voidzero-dev/vite-plus-core@" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests/create-approve-builds-migrate-pnpm11/snap.txt b/packages/cli/snap-tests/create-approve-builds-migrate-pnpm11/snap.txt index c456d18458..49097fcdb8 100644 --- a/packages/cli/snap-tests/create-approve-builds-migrate-pnpm11/snap.txt +++ b/packages/cli/snap-tests/create-approve-builds-migrate-pnpm11/snap.txt @@ -10,8 +10,8 @@ Prettier detected in workspace packages but no root config found. Package-level allowBuilds: core-js: true catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: "catalog:" peerDependencyRules: @@ -36,8 +36,8 @@ These dependencies may not work until built. Run vp pm approve-builds in the pro allowBuilds: core-js: set this to true or false catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: "catalog:" peerDependencyRules: @@ -54,8 +54,8 @@ peerDependencyRules: allowBuilds: core-js: true catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: "catalog:" peerDependencyRules: diff --git a/packages/cli/snap-tests/create-approve-builds-pnpm11/snap.txt b/packages/cli/snap-tests/create-approve-builds-pnpm11/snap.txt index c4f467fee6..0596ddd334 100644 --- a/packages/cli/snap-tests/create-approve-builds-pnpm11/snap.txt +++ b/packages/cli/snap-tests/create-approve-builds-pnpm11/snap.txt @@ -8,8 +8,8 @@ allowBuilds: core-js: true catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: "catalog:" peerDependencyRules: @@ -32,8 +32,8 @@ These dependencies may not work until built. Run vp pm approve-builds in the pro allowBuilds: core-js: set this to true or false catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: "catalog:" peerDependencyRules: @@ -50,8 +50,8 @@ peerDependencyRules: allowBuilds: core-js: true catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: "catalog:" peerDependencyRules: diff --git a/packages/cli/snap-tests/create-approve-builds-yarn/snap.txt b/packages/cli/snap-tests/create-approve-builds-yarn/snap.txt index 09e34b0952..497b533e1c 100644 --- a/packages/cli/snap-tests/create-approve-builds-yarn/snap.txt +++ b/packages/cli/snap-tests/create-approve-builds-yarn/snap.txt @@ -24,7 +24,7 @@ } }, "resolutions": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest" + "vite": "npm:@voidzero-dev/vite-plus-core@" }, "devEngines": { "packageManager": { @@ -60,7 +60,7 @@ These dependencies may not work until built. Enable them in the workspace root p "vite-plus": "catalog:" }, "resolutions": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest" + "vite": "npm:@voidzero-dev/vite-plus-core@" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests/create-org-bundled-monorepo/snap.txt b/packages/cli/snap-tests/create-org-bundled-monorepo/snap.txt index cc31c0c256..b256a7ba2b 100644 --- a/packages/cli/snap-tests/create-org-bundled-monorepo/snap.txt +++ b/packages/cli/snap-tests/create-org-bundled-monorepo/snap.txt @@ -25,8 +25,8 @@ packages: - apps/* - packages/* catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: "catalog:" peerDependencyRules: diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 246a955efd..8ae634d196 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -14,7 +14,14 @@ import { createMigrationReport } from '../report.js'; // which would cause snapshot mismatches. vi.mock('../../utils/constants.js', async (importOriginal) => { const mod = await importOriginal(); - return { ...mod, VITE_PLUS_VERSION: 'latest' }; + return { + ...mod, + VITE_PLUS_VERSION: 'latest', + VITE_PLUS_OVERRIDE_PACKAGES: { + ...mod.VITE_PLUS_OVERRIDE_PACKAGES, + vite: 'npm:@voidzero-dev/vite-plus-core@latest', + }, + }; }); const { diff --git a/packages/cli/src/utils/__tests__/constants.spec.ts b/packages/cli/src/utils/__tests__/constants.spec.ts new file mode 100644 index 0000000000..5a2b514ef1 --- /dev/null +++ b/packages/cli/src/utils/__tests__/constants.spec.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import cliPkg from '../../../package.json' with { type: 'json' }; + +describe('Vite+ dependency versions', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it('uses the concrete CLI version for vite-plus and vite-plus-core by default', async () => { + vi.stubEnv('VP_VERSION', ''); + vi.stubEnv('VP_OVERRIDE_PACKAGES', ''); + vi.resetModules(); + + const { VITE_PLUS_OVERRIDE_PACKAGES, VITE_PLUS_VERSION } = await import('../constants.js'); + + expect(VITE_PLUS_VERSION).toBe(cliPkg.version); + expect(VITE_PLUS_OVERRIDE_PACKAGES.vite).toBe( + `npm:@voidzero-dev/vite-plus-core@${cliPkg.version}`, + ); + }); + + it('preserves explicit prerelease overrides', async () => { + const vitePlusUrl = 'https://pkg.pr.new/voidzero-dev/vite-plus@1891'; + const viteCoreUrl = + 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891'; + vi.stubEnv('VP_VERSION', vitePlusUrl); + vi.stubEnv('VP_OVERRIDE_PACKAGES', JSON.stringify({ vite: viteCoreUrl, vitest: '4.1.9' })); + vi.resetModules(); + + const { VITE_PLUS_OVERRIDE_PACKAGES, VITE_PLUS_VERSION } = await import('../constants.js'); + + expect(VITE_PLUS_VERSION).toBe(vitePlusUrl); + expect(VITE_PLUS_OVERRIDE_PACKAGES.vite).toBe(viteCoreUrl); + }); +}); diff --git a/packages/cli/src/utils/constants.ts b/packages/cli/src/utils/constants.ts index 7d10f74588..35df628090 100644 --- a/packages/cli/src/utils/constants.ts +++ b/packages/cli/src/utils/constants.ts @@ -1,14 +1,16 @@ import { createRequire } from 'node:module'; +import cliPkg from '../../package.json' with { type: 'json' }; + export const VITE_PLUS_NAME = 'vite-plus'; -export const VITE_PLUS_VERSION = process.env.VP_VERSION || 'latest'; +export const VITE_PLUS_VERSION = process.env.VP_VERSION || cliPkg.version; export const VITEST_VERSION = '4.1.9'; export const VITE_PLUS_OVERRIDE_PACKAGES: Record = process.env.VP_OVERRIDE_PACKAGES ? JSON.parse(process.env.VP_OVERRIDE_PACKAGES) : { - vite: 'npm:@voidzero-dev/vite-plus-core@latest', + vite: `npm:@voidzero-dev/vite-plus-core@${VITE_PLUS_VERSION}`, // Pin `vitest` only. The `@vitest/*` family (expect, runner, snapshot, spy, // utils, mocker, pretty-format) are EXACT (`4.1.9`) dependencies of `vitest` // itself, so a single `vitest` override cascades one consistent version to diff --git a/packages/tools/src/utils.ts b/packages/tools/src/utils.ts index 328b826061..b1c9626b12 100644 --- a/packages/tools/src/utils.ts +++ b/packages/tools/src/utils.ts @@ -73,6 +73,14 @@ export function replaceUnstableOutput(output: string, cwd?: string) { /("(?:vitest|@vitest\/(?!coverage-)[\w-]+)": ")(?:[4-9]|[1-9]\d+)\.\d+\.\d+(?:-[\w.]+)?(")/g, '$1$2', ) + // Vite+ and its core package are written as exact lockstep versions by + // create/migrate. Mask JSON dependency values so release bumps do not + // create unrelated snapshot churn (YAML values and npm aliases are + // already covered by the generic semver normalization above). + .replaceAll( + /("(?:vite-plus|@voidzero-dev\/vite-plus-core)": ")\d+\.\d+\.\d+(?:-[\w.]+)?(")/g, + '$1$2', + ) // devEngines.packageManager auto-pin writes the exact resolved version // e.g.: `"name": "pnpm",\n "version": "11.5.1"` -> `"version": ""` // (the optional suffix covers prerelease and build metadata: -rc-1, +sha.abc) diff --git a/rfcs/migrate-existing-projects.md b/rfcs/migrate-existing-projects.md index c394fc603a..2d32d616ab 100644 --- a/rfcs/migrate-existing-projects.md +++ b/rfcs/migrate-existing-projects.md @@ -34,7 +34,7 @@ When PnP is active, interactive migration prints the incompatibility and asks wh | Routing | If the project's local `vite-plus` is older than the global `vp`, run `migrate` from the global CLI; otherwise keep local-first. | | Yarn linker | Vite+ does not currently support Yarn PnP. Detect explicit and implicit PnP before migration, ask to switch to `nodeLinker: node-modules`, and continue only after conversion. Non-interactive migration accepts this conversion by default. | | `vite-plus` spec | Re-pin a non-protocol-pinned spec (e.g. `^0.1.24`) to the toolchain target (`catalog:` in catalog projects, else the version) so the lockfile moves off the old resolution. Preserve deliberate protocol pins (`workspace:`/`file:`/`link:`/`npm:`/...). | -| `vite` override | Always managed: alias `vite` to `npm:@voidzero-dev/vite-plus-core@latest` in whatever override/resolution/catalog form the project uses; normalize a behind `core@` alias. | +| `vite` override | Always managed: alias `vite` to the concrete `@voidzero-dev/vite-plus-core` version matching the migrating `vite-plus` release in whatever override/resolution/catalog form the project uses; normalize a behind `core@` alias. | | `vitest` itself (default) | Provided by `vite-plus`, so by default not project-managed: remove any project-level `vitest` from dependency fields, string-valued `overrides`/`resolutions`/`pnpm.overrides`, `pnpm-workspace.yaml` `overrides`+`catalog(s)`, bun/yarn catalog, and the `vitest` entry in pnpm `peerDependencyRules`. Resolve a surviving `peerDependencies.vitest` catalog reference to its public range before pruning the catalog. A future `vp update vite-plus` then keeps it correct with no project pin to drift. | | `vitest`, peer/browser/Nuxt exception | Keep a managed `vitest` in the package that needs it (add to `devDependencies` and pin/override it to the bundled version) when that package directly installs a required-`vitest` peer consumer, uses browser mode, retains a direct upstream `vitest` package reference, or declares `@nuxt/test-utils`. Required peers are detected from installed package metadata, not package names alone, so integrations such as `vite-plugin-gherkin` are covered. When that metadata is unavailable in a clean checkout, preserve an existing direct Vitest conservatively. Other retained references include module augmentations, nested or root `compilerOptions.types`, `require.resolve` / `import.meta.resolve`, and the intentionally unre-written `vitest/package.json` export. In a Nuxt test-utils package, all `vitest` and `vitest/*` specifiers remain upstream consistently; in other packages, rewriteable imports and triple-slash directives do not leave a lasting pin. The direct dependency satisfies strict peer resolution; the shared override collapses the workspace to the bundled version. | | `vitest` ecosystem packages | When Vitest is managed, align current lockstep `@vitest/*` packages the project lists (`@vitest/coverage-v8`, `@vitest/coverage-istanbul`, `@vitest/ui`, `@vitest/web-worker`, ...) to the bundled `VITEST_VERSION`. Exclude `@vitest/eslint-plugin` (separate version line, optional `vitest: *` peer) and deprecated `@vitest/coverage-c8` (last published at `0.33.0`; no Vitest 4 release exists). When `VP_OVERRIDE_PACKAGES` omits Vitest, skip ecosystem alignment so user-owned exact-peer versions stay compatible. Browser packages keep their dedicated handling: `@vitest/browser` / `-preview` are bundled by `vite-plus`; `@vitest/browser-playwright` / `-webdriverio` are opt-in (pinned + framework peer kept). | From 23832e1dad7708bb1fa1d3e346d261892b50e2b0 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 24 Jun 2026 15:20:07 +0800 Subject: [PATCH 29/78] fix(test): reuse unchanged pkg.pr.new install --- .github/scripts/test-pkg-pr-new-migrate.sh | 108 +++++++++++++++------ 1 file changed, 81 insertions(+), 27 deletions(-) diff --git a/.github/scripts/test-pkg-pr-new-migrate.sh b/.github/scripts/test-pkg-pr-new-migrate.sh index 8419dfe450..92df766efd 100755 --- a/.github/scripts/test-pkg-pr-new-migrate.sh +++ b/.github/scripts/test-pkg-pr-new-migrate.sh @@ -66,42 +66,100 @@ original_home="$HOME" cache_root="${XDG_CACHE_HOME:-$original_home/.cache}" pr_home="${VP_PKG_PR_NEW_HOME:-$cache_root/vite-plus/pkg-pr-new/$pr_ref}" installer_home="$(mktemp -d "${TMPDIR:-/tmp}/vite-plus-pr-installer.XXXXXX")" +cached_version_dir="$pr_home/pkg-pr-new-$pr_ref" +vp_bin="$pr_home/bin/vp" +vite_plus_package_json="$pr_home/current/node_modules/vite-plus/package.json" +commit_marker="$cached_version_dir/.pkg-pr-new-commit" +pkg_pr_new_base="https://pkg.pr.new/voidzero-dev/vite-plus" +vite_plus_spec="$pkg_pr_new_base@$pr_ref" +vite_plus_core_spec="$pkg_pr_new_base/@voidzero-dev/vite-plus-core@$pr_ref" -# Numeric pkg.pr.new references are mutable PR aliases. The installer reuses a -# version directory named after the reference, so its lockfile can retain the -# checksum from an older publish of the same PR and fail with -# ERR_PNPM_TARBALL_INTEGRITY after the alias is refreshed. Keep the downloaded -# runtime/package-manager cache, but force the wrapper dependency to resolve -# and install again for every PR-alias run. Commit SHA references are immutable -# and can safely retain their installed dependency state. -case "$pr_ref" in - *[!0-9]*) ;; - *) - cached_version_dir="$pr_home/pkg-pr-new-$pr_ref" - rm -rf "$cached_version_dir/node_modules" - rm -f "$cached_version_dir/pnpm-lock.yaml" - ;; -esac +resolve_pkg_pr_new_commit() { + curl -fsSIL "$vite_plus_spec" | tr -d '\r' | awk -F ': ' ' + tolower($1) == "x-commit-key" { + count = split($2, parts, ":") + print parts[count] + exit + } + ' +} + +read_installed_commit() { + if [ -f "$commit_marker" ]; then + head -n 1 "$commit_marker" + return + fi + + if [ -f "$vite_plus_package_json" ]; then + awk -F '"' ' + $2 == "@voidzero-dev/vite-plus-core" { + value = $4 + sub(/^.*@/, "", value) + print value + exit + } + ' "$vite_plus_package_json" + fi +} + +available_commit="$(resolve_pkg_pr_new_commit || true)" +installed_commit="$(read_installed_commit || true)" +current_target="$(readlink "$pr_home/current" 2>/dev/null || true)" +reuse_install=0 + +if [ -n "$available_commit" ] && + [ "$installed_commit" = "$available_commit" ] && + [ "$current_target" = "pkg-pr-new-$pr_ref" ] && + [ -x "$vp_bin" ] && + [ -f "$vite_plus_package_json" ]; then + reuse_install=1 +fi cleanup() { rm -rf "$installer_home" } trap cleanup EXIT -echo "Installing Vite+ pkg.pr.new build $pr_ref into $pr_home" -HOME="$installer_home" \ - VP_HOME="$pr_home" \ - VP_PR_VERSION="$pr_ref" \ - VP_NODE_MANAGER=no \ - bash "$installer" +if [ "$reuse_install" -eq 1 ]; then + printf '%s\n' "$available_commit" > "$commit_marker" + echo "Reusing installed Vite+ pkg.pr.new build $pr_ref ($available_commit) from $pr_home" +else + if [ -z "$available_commit" ]; then + echo "Could not verify the current pkg.pr.new commit; reinstalling $pr_ref." + elif [ -n "$installed_commit" ]; then + echo "pkg.pr.new build changed: $installed_commit -> $available_commit" + fi + + # Numeric pkg.pr.new references are mutable PR aliases. If the published + # commit changed, the reused lockfile can retain the checksum from the older + # tarball and fail with ERR_PNPM_TARBALL_INTEGRITY. Keep the downloaded + # runtime/package-manager cache, but force the wrapper dependency to resolve + # again. Commit SHA references are immutable and use their own cache path. + case "$pr_ref" in + *[!0-9]*) ;; + *) + rm -rf "$cached_version_dir/node_modules" + rm -f "$cached_version_dir/pnpm-lock.yaml" + ;; + esac + + echo "Installing Vite+ pkg.pr.new build $pr_ref into $pr_home" + HOME="$installer_home" \ + VP_HOME="$pr_home" \ + VP_PR_VERSION="$pr_ref" \ + VP_NODE_MANAGER=no \ + bash "$installer" + + if [ -n "$available_commit" ]; then + printf '%s\n' "$available_commit" > "$commit_marker" + fi +fi -vp_bin="$pr_home/bin/vp" if [ ! -x "$vp_bin" ]; then echo "error: installed vp executable not found: $vp_bin" >&2 exit 1 fi -vite_plus_package_json="$pr_home/current/node_modules/vite-plus/package.json" if [ ! -f "$vite_plus_package_json" ]; then echo "error: installed vite-plus package not found: $vite_plus_package_json" >&2 exit 1 @@ -113,10 +171,6 @@ if [ -z "$vitest_version" ]; then exit 1 fi -pkg_pr_new_base="https://pkg.pr.new/voidzero-dev/vite-plus" -vite_plus_spec="$pkg_pr_new_base@$pr_ref" -vite_plus_core_spec="$pkg_pr_new_base/@voidzero-dev/vite-plus-core@$pr_ref" - export VP_HOME="$pr_home" export PATH="$VP_HOME/bin:$PATH" export VP_VERSION="$vite_plus_spec" From 26343f353374c5ad330564f21a1723958d15efe2 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 24 Jun 2026 15:40:52 +0800 Subject: [PATCH 30/78] fix(test): run pkg.pr.new migration from project root --- .github/scripts/test-pkg-pr-new-migrate.sh | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/.github/scripts/test-pkg-pr-new-migrate.sh b/.github/scripts/test-pkg-pr-new-migrate.sh index 92df766efd..0a0c607f5c 100755 --- a/.github/scripts/test-pkg-pr-new-migrate.sh +++ b/.github/scripts/test-pkg-pr-new-migrate.sh @@ -69,6 +69,7 @@ installer_home="$(mktemp -d "${TMPDIR:-/tmp}/vite-plus-pr-installer.XXXXXX")" cached_version_dir="$pr_home/pkg-pr-new-$pr_ref" vp_bin="$pr_home/bin/vp" vite_plus_package_json="$pr_home/current/node_modules/vite-plus/package.json" +global_cli_entry="$pr_home/current/node_modules/vite-plus/dist/bin.js" commit_marker="$cached_version_dir/.pkg-pr-new-commit" pkg_pr_new_base="https://pkg.pr.new/voidzero-dev/vite-plus" vite_plus_spec="$pkg_pr_new_base@$pr_ref" @@ -111,7 +112,8 @@ if [ -n "$available_commit" ] && [ "$installed_commit" = "$available_commit" ] && [ "$current_target" = "pkg-pr-new-$pr_ref" ] && [ -x "$vp_bin" ] && - [ -f "$vite_plus_package_json" ]; then + [ -f "$vite_plus_package_json" ] && + [ -f "$global_cli_entry" ]; then reuse_install=1 fi @@ -165,6 +167,11 @@ if [ ! -f "$vite_plus_package_json" ]; then exit 1 fi +if [ ! -f "$global_cli_entry" ]; then + echo "error: installed Vite+ CLI entry not found: $global_cli_entry" >&2 + exit 1 +fi + vitest_version="$(awk -F '"' '$2 == "vitest" { print $4; exit }' "$vite_plus_package_json")" if [ -z "$vitest_version" ]; then echo "error: could not determine the bundled Vitest version from $vite_plus_package_json" >&2 @@ -196,14 +203,13 @@ echo " vite spec: $vite_plus_core_spec" echo echo "Running vp migrate in $project_dir" -runner_dir="$installer_home/runner" -mkdir -p "$runner_dir" set +e ( - # Resolve the CLI from an empty directory so a project-local vite-plus at the - # same semver cannot take precedence over the installed pkg.pr.new build. - cd "$runner_dir" - "$vp_bin" migrate "$project_dir" "$@" + # Run the installed JS entry directly so a project-local vite-plus at the + # same semver cannot take precedence. Keep cwd at the project root because + # project config and plugins may resolve dependencies from process.cwd(). + cd "$project_dir" + "$vp_bin" node "$global_cli_entry" migrate "$project_dir" "$@" ) migrate_status=$? set -e From cbc03da18832e52084e1f7a23d7cb63d13098586 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 24 Jun 2026 15:41:00 +0800 Subject: [PATCH 31/78] fix(migrate): isolate config compatibility checks --- .../package.json | 8 +++ .../snap.txt | 23 +++++++ .../steps.json | 6 ++ .../vite.config.ts | 14 ++++ .../migration/__tests__/compat-runner.spec.ts | 60 ++++++++++++++++ packages/cli/src/migration/bin.ts | 19 +----- packages/cli/src/migration/compat-protocol.ts | 1 + packages/cli/src/migration/compat-runner.ts | 68 +++++++++++++++++++ packages/cli/src/migration/compat-worker.ts | 38 +++++++++++ packages/cli/tsdown.config.ts | 1 + 10 files changed, 220 insertions(+), 18 deletions(-) create mode 100644 packages/cli/snap-tests/migration-config-process-crash-isolated/package.json create mode 100644 packages/cli/snap-tests/migration-config-process-crash-isolated/snap.txt create mode 100644 packages/cli/snap-tests/migration-config-process-crash-isolated/steps.json create mode 100644 packages/cli/snap-tests/migration-config-process-crash-isolated/vite.config.ts create mode 100644 packages/cli/src/migration/__tests__/compat-runner.spec.ts create mode 100644 packages/cli/src/migration/compat-protocol.ts create mode 100644 packages/cli/src/migration/compat-runner.ts create mode 100644 packages/cli/src/migration/compat-worker.ts diff --git a/packages/cli/snap-tests/migration-config-process-crash-isolated/package.json b/packages/cli/snap-tests/migration-config-process-crash-isolated/package.json new file mode 100644 index 0000000000..5c6bd19cb9 --- /dev/null +++ b/packages/cli/snap-tests/migration-config-process-crash-isolated/package.json @@ -0,0 +1,8 @@ +{ + "name": "migration-config-process-crash-isolated", + "version": "0.0.0", + "private": true, + "devDependencies": { + "vite": "^8.0.0" + } +} diff --git a/packages/cli/snap-tests/migration-config-process-crash-isolated/snap.txt b/packages/cli/snap-tests/migration-config-process-crash-isolated/snap.txt new file mode 100644 index 0000000000..60cdac381a --- /dev/null +++ b/packages/cli/snap-tests/migration-config-process-crash-isolated/snap.txt @@ -0,0 +1,23 @@ +> vp migrate --no-interactive --no-hooks 2>&1 # project config process handlers must not terminate migration +◇ Migrated . to Vite+ +• Node pnpm +• 1 file had imports rewritten + +> cat vite.config.ts # migration still rewrites the config after its compatibility probe crashes +import { defineConfig } from 'vite-plus'; + +// Models a project plugin that installs a process-level error backstop while +// its config is loaded. Re-throwing from this handler makes Node exit with code +// 7, which used to terminate `vp migrate` during its best-effort compatibility +// check instead of allowing migration to continue. +process.on('uncaughtException', (error) => { + throw error; +}); +queueMicrotask(() => { + throw new Error('simulated project config crash'); +}); + +export default defineConfig({ + fmt: {}, + lint: {"jsPlugins":[{"name":"vite-plus","specifier":"vite-plus/oxlint-plugin"}],"rules":{"vite-plus/prefer-vite-plus-imports":"error"},"options":{"typeAware":true,"typeCheck":true}}, +}); diff --git a/packages/cli/snap-tests/migration-config-process-crash-isolated/steps.json b/packages/cli/snap-tests/migration-config-process-crash-isolated/steps.json new file mode 100644 index 0000000000..e0cef40f52 --- /dev/null +++ b/packages/cli/snap-tests/migration-config-process-crash-isolated/steps.json @@ -0,0 +1,6 @@ +{ + "commands": [ + "vp migrate --no-interactive --no-hooks 2>&1 # project config process handlers must not terminate migration", + "cat vite.config.ts # migration still rewrites the config after its compatibility probe crashes" + ] +} diff --git a/packages/cli/snap-tests/migration-config-process-crash-isolated/vite.config.ts b/packages/cli/snap-tests/migration-config-process-crash-isolated/vite.config.ts new file mode 100644 index 0000000000..ac019508ed --- /dev/null +++ b/packages/cli/snap-tests/migration-config-process-crash-isolated/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite'; + +// Models a project plugin that installs a process-level error backstop while +// its config is loaded. Re-throwing from this handler makes Node exit with code +// 7, which used to terminate `vp migrate` during its best-effort compatibility +// check instead of allowing migration to continue. +process.on('uncaughtException', (error) => { + throw error; +}); +queueMicrotask(() => { + throw new Error('simulated project config crash'); +}); + +export default defineConfig({}); diff --git a/packages/cli/src/migration/__tests__/compat-runner.spec.ts b/packages/cli/src/migration/__tests__/compat-runner.spec.ts new file mode 100644 index 0000000000..5282ad105f --- /dev/null +++ b/packages/cli/src/migration/__tests__/compat-runner.spec.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../utils/command.ts', () => ({ + runCommandSilently: vi.fn(), +})); + +import { runCommandSilently } from '../../utils/command.ts'; +import { checkRolldownCompatibility, ROLLDOWN_COMPAT_RESULT_PREFIX } from '../compat-runner.ts'; +import { createMigrationReport } from '../report.ts'; + +const mockRunCommandSilently = vi.mocked(runCommandSilently); + +describe('checkRolldownCompatibility', () => { + beforeEach(() => { + mockRunCommandSilently.mockReset(); + }); + + it('merges warnings returned by the isolated config worker', async () => { + mockRunCommandSilently.mockResolvedValue({ + exitCode: 0, + stdout: Buffer.from( + `project config output\n${ROLLDOWN_COMPAT_RESULT_PREFIX}${JSON.stringify({ warnings: ['manualChunks warning'] })}\n`, + ), + stderr: Buffer.alloc(0), + }); + const report = createMigrationReport(); + + await checkRolldownCompatibility('/project', report); + + expect(report.warnings).toEqual(['manualChunks warning']); + expect(mockRunCommandSilently).toHaveBeenCalledWith({ + command: process.execPath, + args: [expect.stringMatching(/compat-worker\.js$/), '/project'], + cwd: '/project', + envs: process.env, + }); + }); + + it('skips compatibility checking when project config crashes the worker', async () => { + mockRunCommandSilently.mockResolvedValue({ + exitCode: 7, + stdout: Buffer.from( + `${ROLLDOWN_COMPAT_RESULT_PREFIX}${JSON.stringify({ warnings: ['incomplete result'] })}\n`, + ), + stderr: Buffer.from('project config crashed'), + }); + const report = createMigrationReport(); + + await expect(checkRolldownCompatibility('/project', report)).resolves.toBeUndefined(); + expect(report.warnings).toEqual([]); + }); + + it('skips compatibility checking when the worker cannot start', async () => { + mockRunCommandSilently.mockRejectedValue(new Error('spawn failed')); + const report = createMigrationReport(); + + await expect(checkRolldownCompatibility('/project', report)).resolves.toBeUndefined(); + expect(report.warnings).toEqual([]); + }); +}); diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index 66c6aa12ed..da2203648a 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -45,6 +45,7 @@ import { } from '../utils/tsconfig.ts'; import type { PackageDependencies } from '../utils/types.ts'; import { detectWorkspace } from '../utils/workspace.ts'; +import { checkRolldownCompatibility } from './compat-runner.ts'; import { addFrameworkShim, checkVitestVersion, @@ -885,24 +886,6 @@ function showMigrationSummary(options: { } } -async function checkRolldownCompatibility(rootDir: string, report: MigrationReport): Promise { - try { - const { resolveConfig } = await import('../index.js'); - const { withConfigMetadataResolution } = await import('../define-config.js'); - const { checkManualChunksCompat } = await import('./compat.js'); - // Use 'runner' configLoader to avoid Rolldown bundling the config file, - // which prints UNRESOLVED_IMPORT warnings that cannot be suppressed via logLevel. - // Reads the config only for the manualChunks compat check, so skip the - // user's plugin factory while it resolves. - const config = await withConfigMetadataResolution(() => - resolveConfig({ root: rootDir, logLevel: 'silent', configLoader: 'runner' }, 'build'), - ); - checkManualChunksCompat(config.build?.rollupOptions?.output, report); - } catch { - // Config resolution may fail — skip compatibility check silently - } -} - async function downloadSupportedPackageManager(options: { rootDir: string; packageManager: PackageManager; diff --git a/packages/cli/src/migration/compat-protocol.ts b/packages/cli/src/migration/compat-protocol.ts new file mode 100644 index 0000000000..64f8459db4 --- /dev/null +++ b/packages/cli/src/migration/compat-protocol.ts @@ -0,0 +1 @@ +export const ROLLDOWN_COMPAT_RESULT_PREFIX = 'VITE_PLUS_ROLLDOWN_COMPAT_RESULT='; diff --git a/packages/cli/src/migration/compat-runner.ts b/packages/cli/src/migration/compat-runner.ts new file mode 100644 index 0000000000..62ad62c319 --- /dev/null +++ b/packages/cli/src/migration/compat-runner.ts @@ -0,0 +1,68 @@ +import { fileURLToPath } from 'node:url'; + +import { runCommandSilently } from '../utils/command.ts'; +import { ROLLDOWN_COMPAT_RESULT_PREFIX } from './compat-protocol.ts'; +import { addMigrationWarning, type MigrationReport } from './report.ts'; + +export { ROLLDOWN_COMPAT_RESULT_PREFIX }; + +interface RolldownCompatibilityResult { + warnings: string[]; +} + +function parseRolldownCompatibilityResult(stdout: Buffer): RolldownCompatibilityResult | undefined { + const output = stdout.toString(); + const markerIndex = output.lastIndexOf(ROLLDOWN_COMPAT_RESULT_PREFIX); + if (markerIndex === -1) { + return undefined; + } + + const resultStart = markerIndex + ROLLDOWN_COMPAT_RESULT_PREFIX.length; + const resultEnd = output.indexOf('\n', resultStart); + const serialized = output.slice(resultStart, resultEnd === -1 ? undefined : resultEnd).trim(); + + try { + const result = JSON.parse(serialized) as Partial; + if ( + !Array.isArray(result.warnings) || + !result.warnings.every((item) => typeof item === 'string') + ) { + return undefined; + } + return { warnings: result.warnings }; + } catch { + return undefined; + } +} + +/** + * Resolve a project's Vite config in a child process before checking it for + * Rolldown-incompatible options. Config files execute arbitrary project code; + * isolating them prevents process-level handlers, explicit exits, and + * asynchronous crashes from terminating the migration itself. + */ +export async function checkRolldownCompatibility( + rootDir: string, + report: MigrationReport, +): Promise { + try { + const workerPath = fileURLToPath(new URL('./compat-worker.js', import.meta.url)); + const result = await runCommandSilently({ + command: process.execPath, + args: [workerPath, rootDir], + cwd: rootDir, + envs: process.env, + }); + + if (result.exitCode !== 0) { + return; + } + + const compatibilityResult = parseRolldownCompatibilityResult(result.stdout); + for (const warning of compatibilityResult?.warnings ?? []) { + addMigrationWarning(report, warning); + } + } catch { + // Config resolution is best-effort. Skip failures silently. + } +} diff --git a/packages/cli/src/migration/compat-worker.ts b/packages/cli/src/migration/compat-worker.ts new file mode 100644 index 0000000000..46c101e9a2 --- /dev/null +++ b/packages/cli/src/migration/compat-worker.ts @@ -0,0 +1,38 @@ +import { writeSync } from 'node:fs'; + +import { ROLLDOWN_COMPAT_RESULT_PREFIX } from './compat-protocol.ts'; +import { checkManualChunksCompat } from './compat.ts'; +import { createMigrationReport } from './report.ts'; + +async function main(): Promise { + const rootDir = process.argv[2]; + if (!rootDir) { + return; + } + + try { + const { resolveConfig } = await import('../index.js'); + // Use 'runner' configLoader to avoid Rolldown bundling the config file, + // which prints UNRESOLVED_IMPORT warnings that cannot be suppressed via logLevel. + const config = await resolveConfig( + { root: rootDir, logLevel: 'silent', configLoader: 'runner' }, + 'build', + ); + const report = createMigrationReport(); + checkManualChunksCompat(config.build?.rollupOptions?.output, report); + writeSync( + process.stdout.fd, + `${ROLLDOWN_COMPAT_RESULT_PREFIX}${JSON.stringify({ warnings: report.warnings })}\n`, + ); + } catch { + // Config resolution may fail — skip compatibility checking silently. + } +} + +// Config plugins may leave active handles behind. Once the result has been +// written synchronously, terminate this disposable worker without waiting for +// project-owned cleanup. +main().then( + () => process.exit(0), + () => process.exit(0), +); diff --git a/packages/cli/tsdown.config.ts b/packages/cli/tsdown.config.ts index 944111ecdf..b0cffbfa00 100644 --- a/packages/cli/tsdown.config.ts +++ b/packages/cli/tsdown.config.ts @@ -57,6 +57,7 @@ export default defineConfig([ // Without these, tsdown inlines them into bin.js, breaking on-demand loading. 'create/bin': './src/create/bin.ts', 'migration/bin': './src/migration/bin.ts', + 'migration/compat-worker': './src/migration/compat-worker.ts', version: './src/version.ts', 'config/bin': './src/config/bin.ts', 'staged/bin': './src/staged/bin.ts', From 32925902ffc6be466863729eacb60d3673dd8edd Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 24 Jun 2026 21:18:39 +0800 Subject: [PATCH 32/78] fix(test): pin pkg.pr.new migration builds by commit --- .github/scripts/test-pkg-pr-new-migrate.sh | 86 +++++++++++-------- .../migration-upgrade-pkg-pr-new-npm/snap.txt | 10 +-- .../steps.json | 8 +- .../snap.txt | 10 +-- .../steps.json | 6 +- .../src/migration/__tests__/migrator.spec.ts | 2 +- 6 files changed, 70 insertions(+), 52 deletions(-) diff --git a/.github/scripts/test-pkg-pr-new-migrate.sh b/.github/scripts/test-pkg-pr-new-migrate.sh index 0a0c607f5c..e17a3c52ab 100755 --- a/.github/scripts/test-pkg-pr-new-migrate.sh +++ b/.github/scripts/test-pkg-pr-new-migrate.sh @@ -66,17 +66,11 @@ original_home="$HOME" cache_root="${XDG_CACHE_HOME:-$original_home/.cache}" pr_home="${VP_PKG_PR_NEW_HOME:-$cache_root/vite-plus/pkg-pr-new/$pr_ref}" installer_home="$(mktemp -d "${TMPDIR:-/tmp}/vite-plus-pr-installer.XXXXXX")" -cached_version_dir="$pr_home/pkg-pr-new-$pr_ref" -vp_bin="$pr_home/bin/vp" -vite_plus_package_json="$pr_home/current/node_modules/vite-plus/package.json" -global_cli_entry="$pr_home/current/node_modules/vite-plus/dist/bin.js" -commit_marker="$cached_version_dir/.pkg-pr-new-commit" pkg_pr_new_base="https://pkg.pr.new/voidzero-dev/vite-plus" -vite_plus_spec="$pkg_pr_new_base@$pr_ref" -vite_plus_core_spec="$pkg_pr_new_base/@voidzero-dev/vite-plus-core@$pr_ref" +requested_vite_plus_spec="$pkg_pr_new_base@$pr_ref" resolve_pkg_pr_new_commit() { - curl -fsSIL "$vite_plus_spec" | tr -d '\r' | awk -F ': ' ' + curl -fsSIL "$requested_vite_plus_spec" | tr -d '\r' | awk -F ': ' ' tolower($1) == "x-commit-key" { count = split($2, parts, ":") print parts[count] @@ -85,6 +79,32 @@ resolve_pkg_pr_new_commit() { ' } +available_commit="$(resolve_pkg_pr_new_commit || true)" +case "$available_commit" in + '' | *[!0-9a-fA-F]*) + echo "error: could not resolve an immutable pkg.pr.new commit for $pr_ref" >&2 + exit 1 + ;; +esac +if [ "${#available_commit}" -ne 40 ]; then + echo "error: pkg.pr.new returned an invalid commit for $pr_ref: $available_commit" >&2 + exit 1 +fi + +# PR-number URLs are mutable and pkg.pr.new packages reference their internal +# workspace dependencies by commit SHA. Persisting the PR URL alongside those +# SHA URLs makes package managers install duplicate copies of the same package. +# Resolve once, then use the immutable SHA for the global install and every +# dependency spec written by migration. +resolved_ref="$available_commit" +cached_version_dir="$pr_home/pkg-pr-new-$resolved_ref" +vp_bin="$pr_home/bin/vp" +vite_plus_package_json="$pr_home/current/node_modules/vite-plus/package.json" +global_cli_entry="$pr_home/current/node_modules/vite-plus/dist/bin.js" +commit_marker="$cached_version_dir/.pkg-pr-new-commit" +vite_plus_spec="$pkg_pr_new_base@$resolved_ref" +vite_plus_core_spec="$pkg_pr_new_base/@voidzero-dev/vite-plus-core@$resolved_ref" + read_installed_commit() { if [ -f "$commit_marker" ]; then head -n 1 "$commit_marker" @@ -103,14 +123,12 @@ read_installed_commit() { fi } -available_commit="$(resolve_pkg_pr_new_commit || true)" installed_commit="$(read_installed_commit || true)" current_target="$(readlink "$pr_home/current" 2>/dev/null || true)" reuse_install=0 -if [ -n "$available_commit" ] && - [ "$installed_commit" = "$available_commit" ] && - [ "$current_target" = "pkg-pr-new-$pr_ref" ] && +if [ "$installed_commit" = "$resolved_ref" ] && + [ "$current_target" = "pkg-pr-new-$resolved_ref" ] && [ -x "$vp_bin" ] && [ -f "$vite_plus_package_json" ] && [ -f "$global_cli_entry" ]; then @@ -123,38 +141,36 @@ cleanup() { trap cleanup EXIT if [ "$reuse_install" -eq 1 ]; then - printf '%s\n' "$available_commit" > "$commit_marker" - echo "Reusing installed Vite+ pkg.pr.new build $pr_ref ($available_commit) from $pr_home" + printf '%s\n' "$resolved_ref" > "$commit_marker" + echo "Reusing installed Vite+ pkg.pr.new build $resolved_ref (requested $pr_ref) from $pr_home" else - if [ -z "$available_commit" ]; then - echo "Could not verify the current pkg.pr.new commit; reinstalling $pr_ref." + if [ -n "$installed_commit" ] && [ "$installed_commit" != "$resolved_ref" ]; then + echo "pkg.pr.new build changed: $installed_commit -> $resolved_ref" elif [ -n "$installed_commit" ]; then - echo "pkg.pr.new build changed: $installed_commit -> $available_commit" + echo "Reinstalling pkg.pr.new build $resolved_ref with an immutable cache key" + fi + + # This helper owns a dedicated VP_HOME for each requested PR/ref. Remember + # the previous immutable install so it can be removed only after the new one + # succeeds, while retaining shared runtime and package-manager caches. + previous_target="" + if [ -n "$current_target" ] && [ "$current_target" != "pkg-pr-new-$resolved_ref" ]; then + case "$current_target" in + pkg-pr-new-*) previous_target="$current_target" ;; + esac fi - # Numeric pkg.pr.new references are mutable PR aliases. If the published - # commit changed, the reused lockfile can retain the checksum from the older - # tarball and fail with ERR_PNPM_TARBALL_INTEGRITY. Keep the downloaded - # runtime/package-manager cache, but force the wrapper dependency to resolve - # again. Commit SHA references are immutable and use their own cache path. - case "$pr_ref" in - *[!0-9]*) ;; - *) - rm -rf "$cached_version_dir/node_modules" - rm -f "$cached_version_dir/pnpm-lock.yaml" - ;; - esac - - echo "Installing Vite+ pkg.pr.new build $pr_ref into $pr_home" + echo "Installing Vite+ pkg.pr.new build $resolved_ref (requested $pr_ref) into $pr_home" HOME="$installer_home" \ VP_HOME="$pr_home" \ - VP_PR_VERSION="$pr_ref" \ + VP_PR_VERSION="$resolved_ref" \ VP_NODE_MANAGER=no \ bash "$installer" - if [ -n "$available_commit" ]; then - printf '%s\n' "$available_commit" > "$commit_marker" + if [ -n "$previous_target" ]; then + rm -rf "$pr_home/$previous_target" fi + printf '%s\n' "$resolved_ref" > "$commit_marker" fi if [ ! -x "$vp_bin" ]; then @@ -195,6 +211,8 @@ hash -r echo echo "Using isolated global CLI:" +echo " requested ref: $pr_ref" +echo " resolved commit: $resolved_ref" echo " executable: $vp_bin" echo " installation: $(readlink "$pr_home/current" 2>/dev/null || echo unknown)" echo " vite-plus spec: $VP_VERSION" diff --git a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/snap.txt index 1bc76fd5f3..3a77a693ac 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/snap.txt @@ -3,15 +3,15 @@ • Node npm • 2 config updates applied -> cat package.json # direct dependencies and npm overrides use the same PR URLs +> cat package.json # direct dependencies and npm overrides use the same immutable commit URLs { "name": "migration-upgrade-pkg-pr-new-npm", "devDependencies": { - "vite": "https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891", - "vite-plus": "https://pkg.pr.new/voidzero-dev/vite-plus@1891" + "vite": "https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@0c515e3fbf5c140db35280d700df0bd600838617", + "vite-plus": "https://pkg.pr.new/voidzero-dev/vite-plus@0c515e3fbf5c140db35280d700df0bd600838617" }, "overrides": { - "vite": "https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891" + "vite": "https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@0c515e3fbf5c140db35280d700df0bd600838617" }, "packageManager": "npm@", "scripts": { @@ -19,7 +19,7 @@ } } -> node -e "const p = require('./package.json'); const vp = 'https://pkg.pr.new/voidzero-dev/vite-plus@1891'; const core = 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891'; if (p.devDependencies['vite-plus'] !== vp || p.devDependencies.vite !== core || p.overrides.vite !== core || p.overrides['@voidzero-dev/vite-plus-core'] !== undefined) process.exit(1)" # pkg.pr.new specs use the minimal override shape +> node -e "const p = require('./package.json'); const sha = '0c515e3fbf5c140db35280d700df0bd600838617'; const vp = 'https://pkg.pr.new/voidzero-dev/vite-plus@' + sha; const core = 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@' + sha; if (p.devDependencies['vite-plus'] !== vp || p.devDependencies.vite !== core || p.overrides.vite !== core || p.overrides['@voidzero-dev/vite-plus-core'] !== undefined || JSON.stringify(p).includes('@1891')) process.exit(1)" # pkg.pr.new specs use one immutable commit and the minimal override shape > node -e "require('node:fs').copyFileSync('package.json', 'package.after-first-migration.json')" # capture first migration result > vp migrate --no-interactive # pkg.pr.new migration is idempotent ◇ Migrated . to Vite+ diff --git a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/steps.json index 5f2a8b74ab..112332ff98 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/steps.json +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/steps.json @@ -1,13 +1,13 @@ { "env": { "VP_FORCE_MIGRATE": "1", - "VP_OVERRIDE_PACKAGES": "{\"vite\":\"https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891\",\"vitest\":\"4.1.9\"}", - "VP_VERSION": "https://pkg.pr.new/voidzero-dev/vite-plus@1891" + "VP_OVERRIDE_PACKAGES": "{\"vite\":\"https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@0c515e3fbf5c140db35280d700df0bd600838617\",\"vitest\":\"4.1.9\"}", + "VP_VERSION": "https://pkg.pr.new/voidzero-dev/vite-plus@0c515e3fbf5c140db35280d700df0bd600838617" }, "commands": [ "vp migrate --no-interactive # pkg.pr.new targets replace every stale managed spec", - "cat package.json # direct dependencies and npm overrides use the same PR URLs", - "node -e \"const p = require('./package.json'); const vp = 'https://pkg.pr.new/voidzero-dev/vite-plus@1891'; const core = 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891'; if (p.devDependencies['vite-plus'] !== vp || p.devDependencies.vite !== core || p.overrides.vite !== core || p.overrides['@voidzero-dev/vite-plus-core'] !== undefined) process.exit(1)\" # pkg.pr.new specs use the minimal override shape", + "cat package.json # direct dependencies and npm overrides use the same immutable commit URLs", + "node -e \"const p = require('./package.json'); const sha = '0c515e3fbf5c140db35280d700df0bd600838617'; const vp = 'https://pkg.pr.new/voidzero-dev/vite-plus@' + sha; const core = 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@' + sha; if (p.devDependencies['vite-plus'] !== vp || p.devDependencies.vite !== core || p.overrides.vite !== core || p.overrides['@voidzero-dev/vite-plus-core'] !== undefined || JSON.stringify(p).includes('@1891')) process.exit(1)\" # pkg.pr.new specs use one immutable commit and the minimal override shape", "node -e \"require('node:fs').copyFileSync('package.json', 'package.after-first-migration.json')\" # capture first migration result", "vp migrate --no-interactive # pkg.pr.new migration is idempotent", "node -e \"const fs = require('node:fs'); if (fs.readFileSync('package.json', 'utf8') !== fs.readFileSync('package.after-first-migration.json', 'utf8')) process.exit(1)\" # rerun leaves package.json unchanged" diff --git a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/snap.txt index 2d08a0ae0d..992829edb5 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/snap.txt @@ -15,9 +15,9 @@ "packageManager": "pnpm@", "pnpm": { "overrides": { - "vite": "https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891", + "vite": "https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@0c515e3fbf5c140db35280d700df0bd600838617", "vitest": "", - "vite-plus": "https://pkg.pr.new/voidzero-dev/vite-plus@1891" + "vite-plus": "https://pkg.pr.new/voidzero-dev/vite-plus@0c515e3fbf5c140db35280d700df0bd600838617" } }, "scripts": { @@ -32,8 +32,8 @@ packages: blockExoticSubdeps: false catalog: - vite: https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891 - vite-plus: https://pkg.pr.new/voidzero-dev/vite-plus@1891 + vite: https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@0c515e3fbf5c140db35280d700df0bd600838617 + vite-plus: https://pkg.pr.new/voidzero-dev/vite-plus@0c515e3fbf5c140db35280d700df0bd600838617 vitest: overrides: @@ -48,7 +48,7 @@ peerDependencyRules: vite: '*' vitest: '*' -> node -e "const fs = require('node:fs'); const y = fs.readFileSync('pnpm-workspace.yaml', 'utf8'); if (!y.includes('blockExoticSubdeps: false') || !y.includes('https://pkg.pr.new/voidzero-dev/vite-plus@1891') || !y.includes('https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891')) process.exit(1)" # pnpm policy and PR targets are persisted +> node -e "const fs = require('node:fs'); const y = fs.readFileSync('pnpm-workspace.yaml', 'utf8'); const sha = '0c515e3fbf5c140db35280d700df0bd600838617'; if (!y.includes('blockExoticSubdeps: false') || !y.includes('https://pkg.pr.new/voidzero-dev/vite-plus@' + sha) || !y.includes('https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@' + sha) || y.includes('@1891')) process.exit(1)" # pnpm policy and immutable commit targets are persisted > node -e "const fs = require('node:fs'); fs.copyFileSync('package.json', 'package.after-first-migration.json'); fs.copyFileSync('pnpm-workspace.yaml', 'workspace.after-first-migration.yaml')" # capture first migration result > vp migrate --no-interactive # pkg.pr.new pnpm migration is idempotent ◇ Migrated . to Vite+ diff --git a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/steps.json index 38f0648435..ae70002660 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/steps.json +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/steps.json @@ -2,14 +2,14 @@ "env": { "PNPM_CONFIG_BLOCK_EXOTIC_SUBDEPS": "false", "VP_FORCE_MIGRATE": "1", - "VP_OVERRIDE_PACKAGES": "{\"vite\":\"https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891\",\"vitest\":\"4.1.9\"}", - "VP_VERSION": "https://pkg.pr.new/voidzero-dev/vite-plus@1891" + "VP_OVERRIDE_PACKAGES": "{\"vite\":\"https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@0c515e3fbf5c140db35280d700df0bd600838617\",\"vitest\":\"4.1.9\"}", + "VP_VERSION": "https://pkg.pr.new/voidzero-dev/vite-plus@0c515e3fbf5c140db35280d700df0bd600838617" }, "commands": [ "vp migrate --no-interactive # pkg.pr.new pnpm migration allows URL-resolved subdependencies", "cat package.json # direct dependencies use catalogs aligned to the pkg.pr.new build", "cat pnpm-workspace.yaml # pkg.pr.new URLs are pinned and exotic subdependencies are allowed", - "node -e \"const fs = require('node:fs'); const y = fs.readFileSync('pnpm-workspace.yaml', 'utf8'); if (!y.includes('blockExoticSubdeps: false') || !y.includes('https://pkg.pr.new/voidzero-dev/vite-plus@1891') || !y.includes('https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891')) process.exit(1)\" # pnpm policy and PR targets are persisted", + "node -e \"const fs = require('node:fs'); const y = fs.readFileSync('pnpm-workspace.yaml', 'utf8'); const sha = '0c515e3fbf5c140db35280d700df0bd600838617'; if (!y.includes('blockExoticSubdeps: false') || !y.includes('https://pkg.pr.new/voidzero-dev/vite-plus@' + sha) || !y.includes('https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@' + sha) || y.includes('@1891')) process.exit(1)\" # pnpm policy and immutable commit targets are persisted", "node -e \"const fs = require('node:fs'); fs.copyFileSync('package.json', 'package.after-first-migration.json'); fs.copyFileSync('pnpm-workspace.yaml', 'workspace.after-first-migration.yaml')\" # capture first migration result", "vp migrate --no-interactive # pkg.pr.new pnpm migration is idempotent", "node -e \"const fs = require('node:fs'); if (fs.readFileSync('package.json', 'utf8') !== fs.readFileSync('package.after-first-migration.json', 'utf8') || fs.readFileSync('pnpm-workspace.yaml', 'utf8') !== fs.readFileSync('workspace.after-first-migration.yaml', 'utf8')) process.exit(1)\" # rerun leaves manifests unchanged" diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 8ae634d196..f0dfbde680 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -1550,7 +1550,7 @@ describe('ensureVitePlusBootstrap', () => { const savedForceMigrate = process.env.VP_FORCE_MIGRATE; const savedViteOverride = VITE_PLUS_OVERRIDE_PACKAGES.vite; const viteOverride = - 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891'; + 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@0c515e3fbf5c140db35280d700df0bd600838617'; process.env.VP_FORCE_MIGRATE = '1'; VITE_PLUS_OVERRIDE_PACKAGES.vite = viteOverride; try { From a593aa34ab41ef94f2bd8e48896670adc4da37a3 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 25 Jun 2026 09:46:37 +0800 Subject: [PATCH 33/78] fix(migrate): move pnpm settings to workspace config --- .../snap.txt | 7 +- .../steps.json | 4 +- .../src/migration/__tests__/migrator.spec.ts | 207 +++++++--- packages/cli/src/migration/bin.ts | 1 + packages/cli/src/migration/migrator.ts | 385 +++++++++--------- rfcs/migrate-existing-projects.md | 2 +- 6 files changed, 368 insertions(+), 238 deletions(-) diff --git a/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/snap.txt index a1d7a2f1d7..3367f7a492 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/snap.txt @@ -4,7 +4,7 @@ • Node pnpm • Package manager settings configured -> cat package.json # stale wrapper deps and plain vite-plus range should be repaired +> cat package.json # stale wrapper deps and plain vite-plus range should be repaired; empty pnpm field should be removed { "name": "migration-upgrade-stale-local-pnpm", "devDependencies": { @@ -17,11 +17,10 @@ "version": "", "onFail": "download" } - }, - "pnpm": {} + } } -> cat pnpm-workspace.yaml # empty pnpm field must not hide workspace overrides +> cat pnpm-workspace.yaml # pnpm settings should be consolidated here overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/steps.json index 9e51e271ad..f32489a2b5 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/steps.json +++ b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/steps.json @@ -3,7 +3,7 @@ "commands": [ { "command": "node setup-local.mjs", "ignoreOutput": true }, "vp migrate --no-interactive # newer global CLI must bypass the installed stale local CLI", - "cat package.json # stale wrapper deps and plain vite-plus range should be repaired", - "cat pnpm-workspace.yaml # empty pnpm field must not hide workspace overrides" + "cat package.json # stale wrapper deps and plain vite-plus range should be repaired; empty pnpm field should be removed", + "cat pnpm-workspace.yaml # pnpm settings should be consolidated here" ] } diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index f0dfbde680..dbb2d620a4 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -49,9 +49,23 @@ const { detectLegacyGitHooksMigrationCandidate, detectYarnPnpMode, configureYarnNodeModulesMode, + pnpmSupportsWorkspaceSettings, setPackageManager, } = await import('../migrator.js'); +describe('pnpm workspace settings support', () => { + it.each([ + ['10.5.0', false], + ['10.6.1', false], + ['10.6.2', true], + ['10.33.0', true], + ['11.0.0', true], + ['latest', true], + ])('detects support for pnpm %s', (version, expected) => { + expect(pnpmSupportsWorkspaceSettings(version)).toBe(expected); + }); +}); + describe('Yarn PnP migration preflight', () => { let tmpDir: string; const savedEnv: Record = {}; @@ -2500,7 +2514,7 @@ describe('ensureVitePlusBootstrap', () => { expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); }); - it('uses a concrete vite-plus version when pnpm config stays in package.json', () => { + it('removes an empty pnpm field and creates pnpm-workspace.yaml on pnpm 10.6.2+', () => { fs.writeFileSync( path.join(tmpDir, 'package.json'), JSON.stringify({ @@ -2513,16 +2527,22 @@ describe('ensureVitePlusBootstrap', () => { const result = ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); expect(result.changed).toBe(true); - expect(fs.existsSync(path.join(tmpDir, 'pnpm-workspace.yaml'))).toBe(false); + expect(fs.existsSync(path.join(tmpDir, 'pnpm-workspace.yaml'))).toBe(true); const pkg = readJson(path.join(tmpDir, 'package.json')) as { devDependencies: Record; - pnpm: { overrides: Record }; + pnpm?: unknown; }; - expect(pkg.devDependencies['vite-plus']).toBe('latest'); - expect(pkg.pnpm.overrides.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + expect(pkg.devDependencies['vite-plus']).toBe('catalog:'); + expect(pkg.pnpm).toBeUndefined(); + const workspace = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { + catalog: Record; + overrides: Record; + }; + expect(workspace.catalog['vite-plus']).toBe('latest'); + expect(workspace.overrides.vite).toBe('catalog:'); }); - it('normalizes an existing catalog vite-plus pin when pnpm config stays in package.json', () => { + it('moves existing pnpm settings to pnpm-workspace.yaml', () => { fs.writeFileSync( path.join(tmpDir, 'package.json'), JSON.stringify({ @@ -2536,6 +2556,13 @@ describe('ensureVitePlusBootstrap', () => { vite: 'npm:@voidzero-dev/vite-plus-core@latest', vitest: 'npm:@voidzero-dev/vite-plus-test@latest', }, + onlyBuiltDependencies: ['esbuild'], + packageExtensions: { + 'some-package@*': { peerDependencies: { react: '*' } }, + }, + patchedDependencies: { + 'is-odd@3.0.1': 'patches/is-odd.patch', + }, peerDependencyRules: { allowAny: ['vite', 'vitest'], allowedVersions: { vite: '*', vitest: '*' }, @@ -2548,15 +2575,88 @@ describe('ensureVitePlusBootstrap', () => { const result = ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); expect(result.changed).toBe(true); - expect(fs.existsSync(path.join(tmpDir, 'pnpm-workspace.yaml'))).toBe(false); + expect(fs.existsSync(path.join(tmpDir, 'pnpm-workspace.yaml'))).toBe(true); const pkg = readJson(path.join(tmpDir, 'package.json')) as { devDependencies: Record; + pnpm?: unknown; }; - expect(pkg.devDependencies['vite-plus']).toBe('latest'); + expect(pkg.devDependencies['vite-plus']).toBe('catalog:'); + expect(pkg.pnpm).toBeUndefined(); + const workspace = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { + onlyBuiltDependencies: string[]; + packageExtensions: Record; + patchedDependencies: Record; + peerDependencyRules: { allowAny: string[]; allowedVersions: Record }; + }; + expect(workspace.onlyBuiltDependencies).toEqual(['esbuild']); + expect(workspace.packageExtensions).toEqual({ + 'some-package@*': { peerDependencies: { react: '*' } }, + }); + expect(workspace.patchedDependencies).toEqual({ + 'is-odd@3.0.1': 'patches/is-odd.patch', + }); + expect(workspace.peerDependencyRules.allowAny).toEqual(['vite']); + expect(workspace.peerDependencyRules.allowedVersions).toEqual({ vite: '*' }); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); + }); + + it('keeps pnpm settings in package.json before pnpm 10.6.2', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { 'vite-plus': 'latest' }, + pnpm: { + overrides: { react: '18.3.1' }, + peerDependencyRules: { allowAny: ['react'] }, + }, + }), + ); + const workspaceInfo = makeWorkspaceInfo(tmpDir, PackageManager.pnpm); + workspaceInfo.packageManagerVersion = '10.6.1'; + workspaceInfo.downloadPackageManager.version = '10.6.1'; + + ensureVitePlusBootstrap(workspaceInfo); + + expect(fs.existsSync(path.join(tmpDir, 'pnpm-workspace.yaml'))).toBe(false); + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + pnpm: { + overrides: Record; + peerDependencyRules: { allowAny: string[] }; + }; + }; + expect(pkg.pnpm.overrides.react).toBe('18.3.1'); + expect(pkg.pnpm.overrides.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + expect(pkg.pnpm.peerDependencyRules.allowAny).toEqual(['react', 'vite']); + }); + + it('preserves unknown package.json pnpm keys while moving supported settings', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { 'vite-plus': 'latest' }, + pnpm: { + app: { target: 'desktop' }, + overrides: { react: '18.3.1' }, + }, + }), + ); + + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + pnpm: { app: { target: string }; overrides?: unknown }; + }; + expect(pkg.pnpm).toEqual({ app: { target: 'desktop' } }); + const workspace = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { + overrides: Record; + }; + expect(workspace.overrides.react).toBe('18.3.1'); expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); }); - it('normalizes catalog vite-plus pins outside devDependencies when pnpm config stays in package.json', () => { + it('keeps catalog vite-plus pins outside devDependencies while moving pnpm settings', () => { fs.writeFileSync( path.join(tmpDir, 'package.json'), JSON.stringify({ @@ -2583,19 +2683,21 @@ describe('ensureVitePlusBootstrap', () => { const result = ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); expect(result.changed).toBe(true); - expect(fs.existsSync(path.join(tmpDir, 'pnpm-workspace.yaml'))).toBe(false); + expect(fs.existsSync(path.join(tmpDir, 'pnpm-workspace.yaml'))).toBe(true); const pkg = readJson(path.join(tmpDir, 'package.json')) as { devDependencies: Record; dependencies: Record; optionalDependencies: Record; + pnpm?: unknown; }; - expect(pkg.devDependencies['vite-plus']).toBe('latest'); - expect(pkg.dependencies['vite-plus']).toBe('latest'); - expect(pkg.optionalDependencies['vite-plus']).toBe('latest'); + expect(pkg.devDependencies['vite-plus']).toBe('catalog:'); + expect(pkg.dependencies['vite-plus']).toBe('catalog:'); + expect(pkg.optionalDependencies['vite-plus']).toBe('catalog:'); + expect(pkg.pnpm).toBeUndefined(); expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); }); - it('uses a concrete vite-plus version for pnpm monorepos that keep pnpm config in package.json', () => { + it('uses workspace catalog settings for pnpm 10.6.2+ monorepos', () => { fs.writeFileSync( path.join(tmpDir, 'package.json'), JSON.stringify({ @@ -2612,11 +2714,13 @@ describe('ensureVitePlusBootstrap', () => { }); expect(result.changed).toBe(true); - expect(fs.existsSync(path.join(tmpDir, 'pnpm-workspace.yaml'))).toBe(false); + expect(fs.existsSync(path.join(tmpDir, 'pnpm-workspace.yaml'))).toBe(true); const pkg = readJson(path.join(tmpDir, 'package.json')) as { devDependencies: Record; + pnpm?: unknown; }; - expect(pkg.devDependencies['vite-plus']).toBe('latest'); + expect(pkg.devDependencies['vite-plus']).toBe('catalog:'); + expect(pkg.pnpm).toBeUndefined(); }); it('normalizes yarn monorepo dependency specs through the shared catalog', () => { @@ -2919,7 +3023,7 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { expect(devDeps['vite-plus']).toBe('catalog:'); }); - it('keeps pnpm config in package.json when existing pnpm field present', () => { + it('moves existing pnpm config into pnpm-workspace.yaml on pnpm 10.6.2+', () => { fs.writeFileSync( path.join(tmpDir, 'package.json'), JSON.stringify({ @@ -2933,14 +3037,15 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { ); rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); - // pnpm-workspace.yaml should NOT be created - expect(fs.existsSync(path.join(tmpDir, 'pnpm-workspace.yaml'))).toBe(false); - - // package.json should have pnpm.overrides with both existing and vite overrides + expect(fs.existsSync(path.join(tmpDir, 'pnpm-workspace.yaml'))).toBe(true); const pkg = readJson(path.join(tmpDir, 'package.json')); - const pnpm = pkg.pnpm as Record; - expect(pnpm).toBeDefined(); - const overrides = pnpm.overrides as Record; + expect(pkg.pnpm).toBeUndefined(); + const workspace = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { + overrides: Record; + peerDependencyRules: Record; + onlyBuiltDependencies: string[]; + }; + const overrides = workspace.overrides; expect(overrides['some-pkg']).toBe('1.0.0'); expect(overrides.vite).toBeDefined(); // Common case (no @vitest/* dep, no vitest source): `vitest` is not managed, @@ -2948,14 +3053,11 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { expect(overrides.vitest).toBeUndefined(); // peerDependencyRules should be present - expect(pnpm.peerDependencyRules).toBeDefined(); - // onlyBuiltDependencies should be preserved - expect(pnpm.onlyBuiltDependencies).toEqual(['esbuild']); + expect(workspace.peerDependencyRules).toBeDefined(); + expect(workspace.onlyBuiltDependencies).toEqual(['esbuild']); }); it('preserves custom peerDependencyRules when migrating to pnpm-workspace.yaml', () => { - // Project has peerDependencyRules but no pnpm.overrides -- pnpm field is present - // so it should keep using package.json fs.writeFileSync( path.join(tmpDir, 'package.json'), JSON.stringify({ @@ -2973,8 +3075,11 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); const pkg = readJson(path.join(tmpDir, 'package.json')); - const pnpm = pkg.pnpm as Record; - const rules = pnpm.peerDependencyRules as Record; + expect(pkg.pnpm).toBeUndefined(); + const workspace = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { + peerDependencyRules: Record; + }; + const rules = workspace.peerDependencyRules; // Custom entries preserved, Vite entries merged (vitest is no longer // injected as it's not a managed override key anymore). expect(rules.allowAny).toEqual(expect.arrayContaining(['react', 'vite'])); @@ -3064,8 +3169,8 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { expect(pkg.peerDependencies).not.toHaveProperty('tsdown'); }); - it('drops only global/vite-plus-parent selector-shaped REMOVE_PACKAGES overrides from package.json pnpm.overrides', () => { - // Project keeps its pnpm config in package.json (`pkg.pnpm.overrides`). + it('drops only global/vite-plus-parent selector-shaped REMOVE_PACKAGES overrides after moving pnpm config', () => { + // Project starts with its pnpm config in package.json (`pkg.pnpm.overrides`). // A selector-shaped provider key is stripped only when it would re-pin // vite-plus's OWN provider dep — a versioned global pin or a `vite-plus` // parent. A provider selector scoped under a SPECIFIC non-vite-plus parent @@ -3100,10 +3205,12 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { ); rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); - const pkg = readJson(path.join(tmpDir, 'package.json')) as { - pnpm?: { overrides?: Record }; + const pkg = readJson(path.join(tmpDir, 'package.json')) as { pnpm?: unknown }; + expect(pkg.pnpm).toBeUndefined(); + const workspace = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { + overrides: Record; }; - const overrides = pkg.pnpm?.overrides ?? {}; + const overrides = workspace.overrides; // Playwright-as-TARGET: vite-plus parent and versioned global pin reach // vite-plus's own (now direct-dep) provider — dropped. expect(overrides).not.toHaveProperty('vite-plus>@vitest/browser-playwright'); @@ -5151,7 +5258,9 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { pnpm: { allowBuilds: { edgedriver: false, geckodriver: true }, overrides: {} }, }), ); - rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); + const workspaceInfo = makeWorkspaceInfo(tmpDir, PackageManager.pnpm); + workspaceInfo.downloadPackageManager.version = '10.6.1'; + rewriteStandaloneProject(tmpDir, workspaceInfo, true, true); const pnpm = (readJson(path.join(tmpDir, 'package.json')).pnpm ?? {}) as { allowBuilds?: Record; @@ -5173,7 +5282,9 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { pnpm: { allowBuilds: { edgedriver: false, geckodriver: false }, overrides: {} }, }), ); - rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); + const workspaceInfo = makeWorkspaceInfo(tmpDir, PackageManager.pnpm); + workspaceInfo.downloadPackageManager.version = '10.6.1'; + rewriteStandaloneProject(tmpDir, workspaceInfo, true, true); const pnpm = (readJson(path.join(tmpDir, 'package.json')).pnpm ?? {}) as { allowBuilds?: Record; @@ -5191,7 +5302,9 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { pnpm: { overrides: {} }, }), ); - rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); + const workspaceInfo = makeWorkspaceInfo(tmpDir, PackageManager.pnpm); + workspaceInfo.downloadPackageManager.version = '10.6.1'; + rewriteStandaloneProject(tmpDir, workspaceInfo, true, true); // No webdriverio -> nothing to manage -> no allowBuilds key added to the pnpm sink // (the webdriverio-present case still writes `true` here — see the flip test below). @@ -5210,18 +5323,19 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { }), ); const workspaceInfo = makeWorkspaceInfo(tmpDir, PackageManager.pnpm); + workspaceInfo.packageManagerVersion = '9.15.0'; workspaceInfo.downloadPackageManager.version = '9.15.0'; rewriteStandaloneProject(tmpDir, workspaceInfo, true, true); - const yaml = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { - onlyBuiltDependencies?: string[]; + const pnpm = (readJson(path.join(tmpDir, 'package.json')).pnpm ?? {}) as { + onlyBuiltDependencies: string[]; allowBuilds?: Record; }; - expect(yaml.onlyBuiltDependencies).toEqual( + expect(pnpm.onlyBuiltDependencies).toEqual( expect.arrayContaining(['edgedriver', 'geckodriver']), ); // v10-shape key must not appear on v9 setups - expect(yaml.allowBuilds).toBeUndefined(); + expect(pnpm.allowBuilds).toBeUndefined(); }); it('leaves onlyBuiltDependencies untouched on pnpm v9 when webdriverio is unused', () => { @@ -5230,15 +5344,16 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { JSON.stringify({ name: 'test', devDependencies: { vite: '^7.0.0' } }), ); const workspaceInfo = makeWorkspaceInfo(tmpDir, PackageManager.pnpm); + workspaceInfo.packageManagerVersion = '9.15.0'; workspaceInfo.downloadPackageManager.version = '9.15.0'; rewriteStandaloneProject(tmpDir, workspaceInfo, true, true); - const yaml = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { + const pnpm = (readJson(path.join(tmpDir, 'package.json')).pnpm ?? {}) as { onlyBuiltDependencies?: string[]; allowBuilds?: Record; }; - expect(yaml.onlyBuiltDependencies).toBeUndefined(); - expect(yaml.allowBuilds).toBeUndefined(); + expect(pnpm.onlyBuiltDependencies).toBeUndefined(); + expect(pnpm.allowBuilds).toBeUndefined(); }); it('detects webdriverio in a monorepo sub-package and allows builds at the root', () => { diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index da2203648a..21f64a48fa 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -1233,6 +1233,7 @@ async function main() { workspaceInfoOptional.rootDir, workspaceInfoOptional.packageManager, workspaceInfoOptional.packages, + workspaceInfoOptional.packageManagerVersion, ); let packageManager: PackageManager | undefined = vitePlusBootstrapPending ? (workspaceInfoOptional.packageManager ?? diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 2c92eb7920..c3120ede45 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -1710,6 +1710,110 @@ export interface VitestImportMigrationOptions { preserveNuxtVitestImports?: boolean; } +const PNPM_WORKSPACE_SETTINGS_MIN_VERSION = '10.6.2'; + +type PnpmPeerDependencyRules = { + allowAny?: string[]; + allowedVersions?: Record; + [key: string]: unknown; +}; + +type PnpmPackageJsonSettings = { + overrides?: Record; + peerDependencyRules?: PnpmPeerDependencyRules; + allowBuilds?: Record; + onlyBuiltDependencies?: string[]; + [key: string]: unknown; +}; + +// pnpm 10.5 started reading package.json#pnpm settings from +// pnpm-workspace.yaml, but overrides and peerDependencyRules needed fixes in +// 10.5.1 and 10.6.2 respectively. Use the latter as the atomic migration +// boundary so the complete object can move without splitting its ownership. +export function pnpmSupportsWorkspaceSettings(version: string): boolean { + const coerced = semver.coerce(version); + if (coerced) { + return semver.gte(coerced, PNPM_WORKSPACE_SETTINGS_MIN_VERSION); + } + return version === 'latest' || version === 'next'; +} + +// These are the root package.json#pnpm settings pnpm 10.6.2+ accepts at the +// top level of pnpm-workspace.yaml. Unknown keys may belong to third-party +// tooling and stay in package.json. +const PNPM_WORKSPACE_SETTING_KEYS = [ + 'allowNonAppliedPatches', + 'allowBuilds', + 'allowUnusedPatches', + 'allowedDeprecatedVersions', + 'auditConfig', + 'configDependencies', + 'executionEnv', + 'ignorePatchFailures', + 'ignoredBuiltDependencies', + 'ignoredOptionalDependencies', + 'neverBuiltDependencies', + 'onlyBuiltDependencies', + 'onlyBuiltDependenciesFile', + 'overrides', + 'packageExtensions', + 'patchedDependencies', + 'peerDependencyRules', + 'requiredScripts', + 'supportedArchitectures', + 'updateConfig', +] as const; + +function hasPnpmWorkspaceSettings(pkg: { pnpm?: PnpmPackageJsonSettings }): boolean { + return PNPM_WORKSPACE_SETTING_KEYS.some((key) => Object.hasOwn(pkg.pnpm ?? {}, key)); +} + +function pnpmPackageJsonSettingsPending(pkg: { pnpm?: PnpmPackageJsonSettings }): boolean { + return ( + hasPnpmWorkspaceSettings(pkg) || (pkg.pnpm !== undefined && Object.keys(pkg.pnpm).length === 0) + ); +} + +function takePnpmWorkspaceSettings(pkg: { + pnpm?: PnpmPackageJsonSettings; +}): Record | undefined { + if (!pkg.pnpm) { + return undefined; + } + const settings: Record = {}; + for (const key of PNPM_WORKSPACE_SETTING_KEYS) { + if (!Object.hasOwn(pkg.pnpm, key)) { + continue; + } + settings[key] = pkg.pnpm[key]; + delete pkg.pnpm[key]; + } + if (Object.keys(pkg.pnpm).length === 0) { + delete pkg.pnpm; + } + return Object.keys(settings).length > 0 ? settings : undefined; +} + +function migratePnpmSettingsToWorkspaceYaml( + projectPath: string, + settings: Record | undefined, +): void { + if (!settings || Object.keys(settings).length === 0) { + return; + } + const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); + if (!fs.existsSync(pnpmWorkspaceYamlPath)) { + fs.writeFileSync(pnpmWorkspaceYamlPath, ''); + } + editYamlFile(pnpmWorkspaceYamlPath, (doc) => { + for (const [key, value] of Object.entries(settings)) { + // package.json#pnpm was the effective source before migration. Preserve + // that precedence when the workspace file already defines the same key. + doc.set(key, doc.createNode(value)); + } + }); +} + export function rewriteStandaloneProject( projectPath: string, workspaceInfo: WorkspaceInfo, @@ -1728,7 +1832,7 @@ export function rewriteStandaloneProject( const vitestEcosystemPackages = collectVitestEcosystemInstallDependencyNames(projectPath); const pnpmMajorVersion = pnpmMajor(workspaceInfo.downloadPackageManager.version); let extractedStagedConfig: Record | null = null; - let remainingPnpmOverrides: Record | undefined; + let movedPnpmSettings: Record | undefined; let shouldRewritePnpmWorkspaceYaml = false; let shouldAddPnpmWorkspaceVitePlusOverride = false; let shouldAllowBrowserProviderBuilds = false; @@ -1746,15 +1850,7 @@ export function rewriteStandaloneProject( peerDependencies?: Record; optionalDependencies?: Record; scripts?: Record; - pnpm?: { - overrides?: Record; - peerDependencyRules?: { - allowAny?: string[]; - allowedVersions?: Record; - }; - allowBuilds?: Record; - onlyBuiltDependencies?: string[]; - }; + pnpm?: PnpmPackageJsonSettings; }>(packageJsonPath, (pkg) => { shouldAllowBrowserProviderBuilds = hasOwnWebdriverioDependency(pkg) || usesWebdriverioProvider(projectPath); @@ -1810,10 +1906,9 @@ export function rewriteStandaloneProject( }; } } else if (packageManager === PackageManager.pnpm) { - // Keep overrides in package.json only when it actually owns override/peer - // configuration (or no workspace file exists). An empty/unrelated `pnpm` - // object must not hide stale overrides in pnpm-workspace.yaml. - usePnpmWorkspaceYaml = !pnpmConfigLivesInPackageJson(pkg, projectPath); + usePnpmWorkspaceYaml = pnpmSupportsWorkspaceSettings( + workspaceInfo.downloadPackageManager.version, + ); if (usePnpmWorkspaceYaml) { shouldRewritePnpmWorkspaceYaml = true; shouldAddPnpmWorkspaceVitePlusOverride = isForceOverrideMode(); @@ -1852,7 +1947,7 @@ export function rewriteStandaloneProject( }, }; } else { - remainingPnpmOverrides = cleanupPnpmOverridesForWorkspaceYaml(pkg, overrideKeys); + movedPnpmSettings = takePnpmWorkspaceSettings(pkg); } // remove dependency selectors targeting vite (e.g. "vite-plugin-svgr>vite") for (const key in pkg.pnpm?.overrides) { @@ -1912,6 +2007,8 @@ export function rewriteStandaloneProject( return pkg; }); + migratePnpmSettingsToWorkspaceYaml(projectPath, movedPnpmSettings); + if (shouldRewritePnpmWorkspaceYaml) { rewritePnpmWorkspaceYaml( projectPath, @@ -1922,11 +2019,6 @@ export function rewriteStandaloneProject( ); } - // 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, @@ -1987,6 +2079,9 @@ export function rewriteMonorepo( workspaceInfo.packageManager, ); const pnpmMajorVersion = pnpmMajor(workspaceInfo.downloadPackageManager.version); + const usePnpmWorkspaceSettings = pnpmSupportsWorkspaceSettings( + workspaceInfo.downloadPackageManager.version, + ); const workspaceShouldAllowBrowserBuilds = workspaceUsesWebdriverio( workspaceInfo.rootDir, workspaceInfo.packages, @@ -2003,15 +2098,7 @@ export function rewriteMonorepo( workspaceInfo.packages, ); // rewrite root workspace - if (workspaceInfo.packageManager === PackageManager.pnpm) { - rewritePnpmWorkspaceYaml( - workspaceInfo.rootDir, - pnpmMajorVersion, - workspaceShouldAllowBrowserBuilds, - workspaceUsesVitest, - vitestEcosystemPackages, - ); - } else if (workspaceInfo.packageManager === PackageManager.yarn) { + if (workspaceInfo.packageManager === PackageManager.yarn) { rewriteYarnrcYml(workspaceInfo.rootDir, workspaceUsesVitest, vitestEcosystemPackages); } else if (workspaceInfo.packageManager === PackageManager.bun) { rewriteBunCatalog(workspaceInfo.rootDir, workspaceUsesVitest, vitestEcosystemPackages); @@ -2023,10 +2110,26 @@ export function rewriteMonorepo( catalogDependencyResolver, workspaceInfo.packages, pnpmMajorVersion, + workspaceInfo.downloadPackageManager.version, workspaceShouldAllowBrowserBuilds, workspaceUsesVitest, importOptions, ); + if (workspaceInfo.packageManager === PackageManager.pnpm) { + rewritePnpmWorkspaceYaml( + workspaceInfo.rootDir, + pnpmMajorVersion, + workspaceShouldAllowBrowserBuilds, + workspaceUsesVitest, + vitestEcosystemPackages, + usePnpmWorkspaceSettings, + ); + if (usePnpmWorkspaceSettings && isForceOverrideMode()) { + migratePnpmOverridesToWorkspaceYaml(workspaceInfo.rootDir, { + [VITE_PLUS_NAME]: VITE_PLUS_VERSION, + }); + } + } // (mergeViteConfigFiles below will sanitize the merged lint config // against this workspace's full package set.) @@ -2199,6 +2302,7 @@ function rewritePnpmWorkspaceYaml( shouldAllowBrowserBuilds: boolean, usesVitest: boolean, vitestEcosystemPackages: ReadonlySet, + writeWorkspaceSettings = true, ): void { const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); if (!fs.existsSync(pnpmWorkspaceYamlPath)) { @@ -2207,10 +2311,13 @@ function rewritePnpmWorkspaceYaml( const managed = managedOverridePackages(usesVitest); editYamlFile(pnpmWorkspaceYamlPath, (doc) => { - ensurePnpmExoticSubdepsSetting(doc); - // catalog rewriteCatalog(doc, usesVitest, vitestEcosystemPackages); + if (!writeWorkspaceSettings) { + return; + } + + ensurePnpmExoticSubdepsSetting(doc); if (pnpmMajorVersion !== undefined) { applyBuildAllowanceToWorkspaceYaml(doc, pnpmMajorVersion, shouldAllowBrowserBuilds); } @@ -2328,74 +2435,6 @@ function rewritePnpmWorkspaceYaml( }); } -/** - * Clean up pnpm.overrides and peerDependencyRules from package.json when migrating - * to pnpm-workspace.yaml. Returns any remaining non-Vite overrides that need to be - * moved to pnpm-workspace.yaml. - */ -function cleanupPnpmOverridesForWorkspaceYaml( - pkg: { - pnpm?: { - overrides?: Record; - peerDependencyRules?: { allowAny?: string[]; allowedVersions?: Record }; - }; - }, - overrideKeys: string[], -): Record | undefined { - // Strip selector-shaped overrides (e.g. `parent>@vitest/browser-playwright`) - // whose target is a removed package, before the exact-key sweep below. - dropRemovePackageOverrideKeys(pkg.pnpm?.overrides); - // Remove Vite-managed keys from pnpm.overrides. `vitest` is always swept so a - // lingering managed `vitest` override is dropped in the common case (when it - // is NOT in `overrideKeys` because the project does not use vitest directly) — - // it is deleted but NOT captured as a moved catalog override. - const sweepKeys = - overrideKeys.includes('vitest') || !VITEST_IS_MANAGED_OVERRIDE - ? overrideKeys - : [...overrideKeys, 'vitest']; - const catalogOverrides: Record = {}; - const overrides = pkg.pnpm?.overrides; - for (const key of [...sweepKeys, ...PROVIDER_OVERRIDE_DROP_NAMES]) { - const value = overrides?.[key]; - if (value) { - if (overrideKeys.includes(key) && value.startsWith('catalog:')) { - catalogOverrides[key] = value; - } - delete overrides[key]; - } - } - // Remove dependency selectors targeting vite - for (const key in pkg.pnpm?.overrides) { - if (key.includes('>')) { - const splits = key.split('>'); - if (splits[splits.length - 1].trim() === 'vite') { - delete pkg.pnpm.overrides[key]; - } - } - } - // 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(catalogOverrides).length > 0) { - remaining = { ...catalogOverrides }; - } - if (pkg.pnpm?.overrides && Object.keys(pkg.pnpm.overrides).length > 0) { - remaining = { ...remaining, ...pkg.pnpm.overrides }; - } - delete pkg.pnpm?.overrides; - // Only remove Vite-managed peerDependencyRules entries, preserve custom ones. - // `vitest` is always swept (common case: dropped even though it is not in the - // managed `overrideKeys`). - cleanupPeerDependencyRules(pkg.pnpm?.peerDependencyRules, sweepKeys); - if (pkg.pnpm?.peerDependencyRules && Object.keys(pkg.pnpm.peerDependencyRules).length === 0) { - delete pkg.pnpm.peerDependencyRules; - } - if (pkg.pnpm && Object.keys(pkg.pnpm).length === 0) { - delete pkg.pnpm; - } - return remaining; -} - /** * Move remaining non-Vite pnpm.overrides from package.json to pnpm-workspace.yaml. * pnpm ignores workspace-level overrides when pnpm.overrides exists in package.json, @@ -2597,36 +2636,6 @@ function applyBuildAllowanceToWorkspaceYaml( } } -/** - * Remove only Vite-managed entries from peerDependencyRules, preserving custom ones. - */ -function cleanupPeerDependencyRules( - peerDependencyRules: - | { allowAny?: string[]; allowedVersions?: Record } - | undefined, - overrideKeys: string[], -): void { - if (!peerDependencyRules) { - return; - } - if (Array.isArray(peerDependencyRules.allowAny)) { - peerDependencyRules.allowAny = peerDependencyRules.allowAny.filter( - (key) => !overrideKeys.includes(key), - ); - if (peerDependencyRules.allowAny.length === 0) { - delete peerDependencyRules.allowAny; - } - } - if (peerDependencyRules.allowedVersions) { - for (const key of overrideKeys) { - delete peerDependencyRules.allowedVersions[key]; - } - if (Object.keys(peerDependencyRules.allowedVersions).length === 0) { - delete peerDependencyRules.allowedVersions; - } - } -} - /** * Rewrite .yarnrc.yml to add vite-plus dependencies * @param projectPath - The path to the project @@ -3447,6 +3456,7 @@ function rewriteRootWorkspacePackageJson( // just the root's own `package.json`. packages?: WorkspacePackage[], pnpmMajorVersion?: number, + pnpmVersion?: string, shouldAllowBrowserBuilds = false, // Workspace-wide direct-vitest signal: the root resolution/override sinks are // shared by every package, so `vitest` stays managed here iff ANY package uses @@ -3460,7 +3470,7 @@ function rewriteRootWorkspacePackageJson( } const managed = managedOverridePackages(workspaceUsesVitest); - let remainingPnpmOverrides: Record | undefined; + let movedPnpmSettings: Record | undefined; editJsonFile<{ resolutions?: Record; overrides?: Record; @@ -3468,15 +3478,7 @@ function rewriteRootWorkspacePackageJson( dependencies?: Record; peerDependencies?: Record; optionalDependencies?: Record; - pnpm?: { - overrides?: Record; - peerDependencyRules?: { - allowAny?: string[]; - allowedVersions?: Record; - }; - allowBuilds?: Record; - onlyBuiltDependencies?: string[]; - }; + pnpm?: PnpmPackageJsonSettings; }>(packageJsonPath, (pkg) => { // Strip stale `vite-plus-test` wrapper aliases before injecting new overrides // so the deleted wrapper doesn't survive migration in any sink. @@ -3527,7 +3529,8 @@ function rewriteRootWorkspacePackageJson( }; } else if (packageManager === PackageManager.pnpm) { const overrideKeys = Object.keys(managed); - if (isForceOverrideMode()) { + const usePnpmWorkspaceSettings = pnpmSupportsWorkspaceSettings(pnpmVersion ?? ''); + if (!usePnpmWorkspaceSettings) { // Strip selector-shaped overrides (e.g. `parent>@vitest/browser-playwright`) // whose target is a removed package, before re-merging the user's // overrides into the new pnpm config. @@ -3536,15 +3539,25 @@ function rewriteRootWorkspacePackageJson( if (!workspaceUsesVitest) { removeManagedVitestEntry(pkg.pnpm?.overrides); } - // In force-override mode, keep overrides in package.json pnpm.overrides - // because pnpm ignores pnpm-workspace.yaml overrides when pnpm.overrides - // exists in package.json (even with unrelated entries like rollup). + if (!workspaceUsesVitest && pkg.pnpm?.peerDependencyRules) { + removeVitestPeerDependencyRule(pkg.pnpm.peerDependencyRules); + } pkg.pnpm = { ...pkg.pnpm, overrides: { ...pkg.pnpm?.overrides, ...managed, - [VITE_PLUS_NAME]: VITE_PLUS_VERSION, + ...(isForceOverrideMode() ? { [VITE_PLUS_NAME]: VITE_PLUS_VERSION } : {}), + }, + peerDependencyRules: { + ...pkg.pnpm?.peerDependencyRules, + allowAny: [ + ...new Set([...(pkg.pnpm?.peerDependencyRules?.allowAny ?? []), ...overrideKeys]), + ], + allowedVersions: { + ...pkg.pnpm?.peerDependencyRules?.allowedVersions, + ...Object.fromEntries(overrideKeys.map((key) => [key, '*'])), + }, }, }; } else { @@ -3553,7 +3566,7 @@ function rewriteRootWorkspacePackageJson( delete pkg.resolutions[key]; } } - remainingPnpmOverrides = cleanupPnpmOverridesForWorkspaceYaml(pkg, overrideKeys); + movedPnpmSettings = takePnpmWorkspaceSettings(pkg); } // remove dependency selectors targeting vite (e.g. "vite-plugin-svgr>vite") for (const key in pkg.pnpm?.overrides) { @@ -3583,10 +3596,7 @@ function rewriteRootWorkspacePackageJson( return pkg; }); - // Move remaining non-Vite pnpm.overrides to pnpm-workspace.yaml - if (remainingPnpmOverrides) { - migratePnpmOverridesToWorkspaceYaml(projectPath, remainingPnpmOverrides); - } + migratePnpmSettingsToWorkspaceYaml(projectPath, movedPnpmSettings); // rewrite package.json — `projectPath` IS the workspace root here, so // `workspaceContext.rootDir` matches it; sanitizer resolves @@ -3742,15 +3752,7 @@ type BootstrapPackageJson = { dependencies?: Record; peerDependencies?: Record; optionalDependencies?: Record; - pnpm?: { - overrides?: Record; - peerDependencyRules?: { - allowAny?: string[]; - allowedVersions?: Record; - }; - allowBuilds?: Record; - onlyBuiltDependencies?: string[]; - }; + pnpm?: PnpmPackageJsonSettings; packageManager?: string; devEngines?: { packageManager?: unknown; [key: string]: unknown }; }; @@ -3823,6 +3825,26 @@ function hasPackageManagerPin(pkg: BootstrapPackageJson): boolean { return Boolean(pkg.packageManager || pkg.devEngines?.packageManager); } +function pinnedPackageManagerVersion(pkg: BootstrapPackageJson): string | undefined { + if (typeof pkg.packageManager === 'string') { + const separator = pkg.packageManager.indexOf('@'); + if (separator !== -1) { + return pkg.packageManager.slice(separator + 1); + } + } + const devEngine = pkg.devEngines?.packageManager; + if ( + typeof devEngine === 'object' && + devEngine !== null && + !Array.isArray(devEngine) && + 'version' in devEngine && + typeof devEngine.version === 'string' + ) { + return devEngine.version; + } + return undefined; +} + function vitePlusDependencyNeedsConcreteVersion(pkg: BootstrapPackageJson): boolean { const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; return dependencyGroups.some( @@ -4018,22 +4040,6 @@ function readBunCatalogDependencyResolver(pkg: { fromWorkspaces(catalogSpec, dependencyName) ?? fromPkg(catalogSpec, dependencyName); } -// Decide where a pnpm project keeps its overrides / peer rules. A truthy -// `pkg.pnpm` is not enough: an empty `pnpm: {}` is truthy yet carries no -// override/peer config, and when a real `pnpm-workspace.yaml` exists that file -// is the actual source unless package.json explicitly defines one of those -// managed sections. Unrelated keys such as `onlyBuiltDependencies` do not move -// override ownership into package.json. -function pnpmConfigLivesInPackageJson(pkg: BootstrapPackageJson, projectPath: string): boolean { - if (pkg.pnpm == null) { - return false; - } - if (!fs.existsSync(path.join(projectPath, 'pnpm-workspace.yaml'))) { - return true; - } - return Object.hasOwn(pkg.pnpm, 'overrides') || Object.hasOwn(pkg.pnpm, 'peerDependencyRules'); -} - function getAlignedVitestEcosystemDependencySpec( current: string, dependencyName: string, @@ -4322,6 +4328,7 @@ export function detectVitePlusBootstrapPending( projectPath: string, packageManager: PackageManager | undefined, packages?: WorkspacePackage[], + packageManagerVersion?: string, importOptions?: VitestImportMigrationOptions, ): boolean { const packageJsonPath = path.join(projectPath, 'package.json'); @@ -4342,8 +4349,12 @@ export function detectVitePlusBootstrapPending( return true; } + const pnpmVersion = packageManagerVersion ?? pinnedPackageManagerVersion(pkg) ?? ''; const usePnpmWorkspaceYaml = - packageManager === PackageManager.pnpm && !pnpmConfigLivesInPackageJson(pkg, projectPath); + packageManager === PackageManager.pnpm && pnpmSupportsWorkspaceSettings(pnpmVersion); + if (usePnpmWorkspaceYaml && pnpmPackageJsonSettingsPending(pkg)) { + return true; + } const supportCatalog = !VITE_PLUS_VERSION.startsWith('file:') && (usePnpmWorkspaceYaml || @@ -4415,7 +4426,7 @@ export function detectVitePlusBootstrapPending( if (!pnpmWorkspaceExoticSubdepsSettingSatisfied(projectPath)) { return true; } - if (pnpmConfigLivesInPackageJson(pkg, projectPath)) { + if (!usePnpmWorkspaceYaml) { return ( vitePlusDependencyNeedsConcreteVersion(pkg) || !overridesSatisfyVitePlus(pkg.pnpm?.overrides, usesVitest) || @@ -4561,7 +4572,6 @@ export function ensureVitePlusBootstrap( return result; } - const initialRootPkg = readJsonFile(packageJsonPath) as BootstrapPackageJson; // Shared override/catalog sinks are workspace-wide, so keep vitest managed // when any package needs it. Each package's direct vitest dependency is // reconciled independently below. @@ -4574,7 +4584,7 @@ export function ensureVitePlusBootstrap( const shouldAllowBrowserBuilds = workspaceUsesWebdriverio(projectPath, workspaceInfo.packages); const usePnpmWorkspaceYaml = workspaceInfo.packageManager === PackageManager.pnpm && - !pnpmConfigLivesInPackageJson(initialRootPkg, projectPath); + pnpmSupportsWorkspaceSettings(workspaceInfo.downloadPackageManager.version); const supportCatalog = !VITE_PLUS_VERSION.startsWith('file:') && (usePnpmWorkspaceYaml || @@ -4594,6 +4604,7 @@ export function ensureVitePlusBootstrap( projectPath, workspaceInfo.packages, ); + let movedPnpmSettings: Record | undefined; editJsonFile< BootstrapPackageJson & { @@ -4635,12 +4646,7 @@ export function ensureVitePlusBootstrap( pkg.overrides = ensured.overrides; packageJsonChanged = true; } - } else if ( - workspaceInfo.packageManager === PackageManager.pnpm && - pnpmConfigLivesInPackageJson(pkg, projectPath) - ) { - // `pnpmConfigLivesInPackageJson` guarantees `pkg.pnpm` is present here, - // but it may be an empty object (no pnpm-workspace.yaml case), so seed it. + } else if (workspaceInfo.packageManager === PackageManager.pnpm && !usePnpmWorkspaceYaml) { pkg.pnpm ??= {}; const ensured = ensureOverrideEntries(pkg.pnpm.overrides, usesVitest); if (ensured.changed) { @@ -4653,6 +4659,13 @@ export function ensureVitePlusBootstrap( applyBuildAllowanceToPackageJsonPnpm(pkg.pnpm, pnpmMajorVersion, shouldAllowBrowserBuilds); packageJsonChanged = beforePnpm !== JSON.stringify(pkg.pnpm) || packageJsonChanged; } + } else if (workspaceInfo.packageManager === PackageManager.pnpm) { + const hadPnpmField = pkg.pnpm !== undefined; + movedPnpmSettings = takePnpmWorkspaceSettings(pkg); + packageJsonChanged = + movedPnpmSettings !== undefined || + (hadPnpmField && pkg.pnpm === undefined) || + packageJsonChanged; } result.packageJson = packageJsonChanged; @@ -4686,13 +4699,15 @@ export function ensureVitePlusBootstrap( if (workspaceInfo.packageManager === PackageManager.pnpm) { const pkg = readJsonFile(packageJsonPath) as BootstrapPackageJson; - if (!pnpmConfigLivesInPackageJson(pkg, projectPath)) { + if (usePnpmWorkspaceYaml) { const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); const before = fs.existsSync(pnpmWorkspaceYamlPath) ? fs.readFileSync(pnpmWorkspaceYamlPath, 'utf-8') : undefined; + migratePnpmSettingsToWorkspaceYaml(projectPath, movedPnpmSettings); const catalogDependencyResolver = readPnpmWorkspaceCatalogDependencyResolver(projectPath); if ( + movedPnpmSettings !== undefined || result.packageJson || ecosystemCatalogReferencesPending || !pnpmWorkspaceExoticSubdepsSettingSatisfied(projectPath) || diff --git a/rfcs/migrate-existing-projects.md b/rfcs/migrate-existing-projects.md index 2d32d616ab..f2f8040a7e 100644 --- a/rfcs/migrate-existing-projects.md +++ b/rfcs/migrate-existing-projects.md @@ -40,7 +40,7 @@ When PnP is active, interactive migration prints the incompatibility and asks wh | `vitest` ecosystem packages | When Vitest is managed, align current lockstep `@vitest/*` packages the project lists (`@vitest/coverage-v8`, `@vitest/coverage-istanbul`, `@vitest/ui`, `@vitest/web-worker`, ...) to the bundled `VITEST_VERSION`. Exclude `@vitest/eslint-plugin` (separate version line, optional `vitest: *` peer) and deprecated `@vitest/coverage-c8` (last published at `0.33.0`; no Vitest 4 release exists). When `VP_OVERRIDE_PACKAGES` omits Vitest, skip ecosystem alignment so user-owned exact-peer versions stay compatible. Browser packages keep their dedicated handling: `@vitest/browser` / `-preview` are bundled by `vite-plus`; `@vitest/browser-playwright` / `-webdriverio` are opt-in (pinned + framework peer kept). | | Workspaces | Reconcile every package manifest, not only the root. Localize the direct `vitest` dependency to packages that need it; keep shared catalogs/overrides only when at least one package needs them. Re-pin existing plain `vite-plus` ranges consistently while preserving deliberate protocol specs. | | Legacy wrapper | Remove every `@voidzero-dev/vite-plus-test` alias (deps, overrides, catalogs); repoint direct wrapper imports to `vite-plus/test`. `vite-plus/test*` imports are left unchanged (stable public API). | -| pnpm config location | An empty `"pnpm": {}` with an existing `pnpm-workspace.yaml` reconciles the workspace file (instead of writing a second, conflicting override block into `package.json`). | +| pnpm config location | On pnpm 10.6.2+, move recognized root settings from `package.json#pnpm` into `pnpm-workspace.yaml` and remove the legacy object when empty; retain unknown third-party keys. Older pnpm keeps these settings in `package.json` because complete workspace support, including `peerDependencyRules`, was not reliable before 10.6.2. | | Reinstall + verify | One reinstall with lockfile refresh (`--no-frozen-lockfile` / `--force`); before npm reinstalls, remove a stale real-`vite` install/lock entry that npm otherwise retains after the dependency becomes the Vite+ core alias. A failed install warns and sets a non-zero exit. | Force-override/CI mode (`VP_OVERRIDE_PACKAGES`) is respected: when `vitest` is not a managed key there, the project's own `vitest` is never stripped and its `@vitest/*` ecosystem dependencies are not realigned. Object-valued nested npm/Bun overrides are user-owned scopes rather than managed version pins and are preserved. From 94937cd764a44ee16c71d4886742d79e576e3c72 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 25 Jun 2026 09:46:43 +0800 Subject: [PATCH 34/78] docs(migrate): document user-facing migration rules --- AGENTS.md | 1 + docs/guide/migrate-rules.md | 192 ++++++++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 docs/guide/migrate-rules.md diff --git a/AGENTS.md b/AGENTS.md index d19082c8bd..6231d6a916 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,7 @@ vite-plus/ - **Package-manager commands**: start at `crates/vite_pm_cli/` and `crates/vite_install/`. - **Managed Node runtime / shims**: start at `crates/vite_js_runtime/`. - **Static `vite.config.ts` extraction**: start at `crates/vite_static_config/README.md` and `packages/cli/src/resolve-vite-config.ts`. +- **Migration behavior**: `docs/guide/migrate-rules.md`. - **Bundled toolchain surfaces**: start with `packages/core/BUNDLING.md`, `packages/cli/BUNDLING.md`, and `packages/test/BUNDLING.md`. - **Generated project agent guidance**: `packages/cli/AGENTS.md` and `packages/cli/src/utils/agent.ts`; do not edit these when the task is only to improve root repo guidance. - **Product/repo docs**: root contributor docs live at the repo root and the VitePress site under `docs/` (`docs/guide/`, `docs/config/`); generated agent guidance is separate. diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md new file mode 100644 index 0000000000..5e69ec03be --- /dev/null +++ b/docs/guide/migrate-rules.md @@ -0,0 +1,192 @@ +# Migration Rules + +This guide explains how `vp migrate` updates dependencies, source imports, and +package-manager configuration in existing Vite+ projects. See the +[migration guide](./migrate.md) for the complete command overview. + +## Before You Migrate + +1. Run `vp upgrade` before migrating an existing Vite+ project. A stale local + CLI does not contain the new migration rules; migration delegates to the + global CLI when the local version is older. +2. Upgrade the project to Vite 8+ and Vitest 4.1+ when necessary. +3. Run `vp migrate` from the workspace root. Use `--no-interactive` in + automated environments. +4. Review every changed manifest, package-manager config, source rewrite, and + generated lockfile. +5. Validate with `vp install`, `vp check`, `vp test`, and `vp build`. + +Running the migration again after a successful migration should not produce +another diff. + +## Dependency Versions + +- `vite-plus` is pinned to the concrete version of the CLI running the + migration, not the `latest` dist-tag. +- The `vite` alias must target `@voidzero-dev/vite-plus-core` from the same + Vite+ release. +- A catalog-backed manifest may contain `catalog:` or an existing named catalog + reference. The referenced catalog value must still be updated to the concrete + toolchain target. +- Preserve deliberate protocol pins such as `workspace:`, `file:`, `link:`, + `npm:`, `github:`, Git URLs, and HTTP URLs. +- Reconcile every workspace package, not only the root manifest. Shared + overrides and catalogs stay at the workspace root; direct peer providers + belong in each package that needs them. + +## Dependency Changes + +| Dependency | Migration rule | +| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `vite-plus` | Add it where the package is migrated. Re-pin plain ranges to the current concrete target, directly or through a catalog. Preserve deliberate protocol pins. | +| `vite` | Keep or add a real dependency edge where peer resolution requires one, and rewrite that edge plus the shared override/resolution to the matching `@voidzero-dev/vite-plus-core` target. An override rewrites an edge; it does not create one. | +| `vitest` | Remove it in the common node-mode case because `vite-plus` provides it transitively. Keep or add an exact bundled version only in packages with direct Vitest requirements. | +| `@vitest/*` | Align lockstep packages that the project directly lists to the bundled Vitest version. Prefer the package's existing catalog reference when its catalog owns that package; otherwise write the concrete version. | +| `@voidzero-dev/vite-plus-test` | Remove all dependency, override, resolution, and catalog aliases. Rewrite imports to the current `vite-plus/test*` surface. | + +### Vite and Overrides + +Package-manager overrides do not synthesize dependency edges. This matters most +with pnpm: Vitest has a required `vite` peer, and pnpm can auto-install upstream +Vite when a package that depends on `vite-plus` has no direct `vite` edge. That +creates separate Vite+, Vite, and Vitest peer contexts. Each affected pnpm +workspace package must therefore declare `vite`; the workspace override then +redirects that edge to Vite+ core. + +Do not remove a direct `vite` declaration merely because a root override exists. +Normalize existing plain or stale aliases while retaining named catalog +references. A real edge is also required for Bun's peer resolver, and npm +browser-provider layouts may need a top-level edge so nested Vitest packages can +resolve `vite`. After migration, pnpm users should verify that each affected +workspace package has the required direct edge. + +### When Vitest Is Directly Required + +Keep or add package-local `vitest` at the exact bundled version when any of the +following is true: + +- an installed dependency has a non-optional `vitest` peer, whether exact or a + range; +- the package uses Vitest browser mode or an opt-in browser provider; +- source or TypeScript configuration retains an upstream `vitest` reference; +- the package declares `@nuxt/test-utils`; or +- dependency metadata is unavailable and an existing direct `vitest` might be + satisfying an unknown required peer. + +`vp migrate` checks installed peer metadata, so integrations such as +`vite-plugin-gherkin` are handled even though their names do not contain +`vitest`. + +When a package directly requires Vitest: + +- add `vitest` to that package, not indiscriminately to every workspace package; +- use the existing catalog reference when supported, otherwise use the exact + bundled version; and +- keep a matching workspace override or resolution so the graph uses one + Vitest version. + +A peer declaration alone does not install Vitest. If a surviving +`peerDependencies.vitest` uses a catalog entry that migration will remove, +resolve it to the public peer range first. + +### Vitest Ecosystem Packages + +Official current `@vitest/*` packages generally publish in lockstep with +Vitest. Align packages the project directly installs, including +`@vitest/coverage-v8`, `@vitest/coverage-istanbul`, `@vitest/ui`, and +`@vitest/web-worker`. + +Catalog handling is package-specific: + +- preserve `catalog:` and named `catalog:` dependency references when the + corresponding catalog already defines that package; +- update that catalog entry to the bundled Vitest version; and +- use the concrete bundled version when no catalog owns the package. + +Do not align independently versioned or obsolete packages: + +- `@vitest/eslint-plugin` has its own version line; +- `@vitest/coverage-c8` stopped at an older release and has no Vitest 4 version; + and +- third-party `vitest-*` integrations keep their own compatible package + versions, although their required Vitest peer may require direct provisioning. + +The base `@vitest/browser` runtime and `@vitest/browser-preview` are bundled by +Vite+ and should be removed as direct dependencies. The Playwright and +WebdriverIO providers remain opt-in: keep or add the provider at the bundled +Vitest version and ensure its `playwright` or `webdriverio` peer is installed. + +Object-valued nested npm and Bun overrides are preserved because they are +user-defined scopes rather than scalar version pins. + +## Source Rewrite Rules + +- Rewrite ordinary `vitest` and `vitest/*` imports to `vite-plus/test*`. +- Rewrite scoped browser imports to the corresponding + `vite-plus/test/browser*` exports and provision opt-in providers when needed. +- Leave existing `vite-plus/test*` imports unchanged. +- Do not rewrite `declare module 'vitest'` or + `declare module '@vitest/browser*'`. Module augmentation must retain the + upstream module identity. +- Retained references such as `compilerOptions.types`, `require.resolve`, + `import.meta.resolve`, and `vitest/package.json` require package-local Vitest. +- In a package that declares `@nuxt/test-utils`, preserve every `vitest` and + `vitest/*` module specifier package-wide. Its transform requires the upstream + identity and can otherwise inject a duplicate `vi` import. This exception + does not apply to sibling packages or scoped `@vitest/browser*` imports. + +The `prefer-vite-plus-imports` lint rule follows the same Nuxt exception, so +lint autofix preserves these imports. + +## Package-Manager Rules + +### pnpm + +- pnpm 10.6.2+ uses `pnpm-workspace.yaml` as the single source for supported + root settings. Migration moves recognized `package.json#pnpm` fields there, + including overrides, peer rules, patch settings, package extensions, + architecture and build policy, audit/update configuration, and configuration + dependencies. It removes the `pnpm` object when it becomes empty and preserves + unknown keys that may belong to other tooling. +- Before pnpm 10.6.2, migration retains these settings in + `package.json#pnpm`. General workspace-setting support started in pnpm 10.5.0, + but overrides required 10.5.1 and `peerDependencyRules` required 10.6.2. pnpm + 11 no longer reads the legacy package.json settings. +- Migration keeps dependency references, default and named catalogs, overrides, + and `peerDependencyRules` consistent. +- Each package whose `vite-plus`/Vitest peer context would otherwise install + upstream Vite needs a direct `vite` edge. +- Unrelated selector-shaped and object-valued overrides are preserved. + +### npm + +- Migration normalizes direct aliases before adding the matching override so + npm does not fail with `EOVERRIDE`. +- When changing a real Vite installation to the core alias, remove stale Vite + install and lockfile state before reinstalling. +- Add a top-level `vite` edge for opt-in browser-provider layouts when nested + Vitest packages otherwise cannot resolve it. + +### Yarn + +- Vite+ does not support Plug'n'Play. Detect explicit and implicit PnP before + migration and convert the project to `nodeLinker: node-modules`. Preserve all + unrelated `.yarnrc.yml` settings. `--no-interactive` accepts the conversion; + a process-level `YARN_NODE_LINKER=pnp` must be fixed by the caller. +- Catalog references and user hoisting settings are preserved. +- Migration avoids split Vitest copies under workspace hoisting isolation. It + applies a package-level fix where possible and warns when the isolation + cannot be changed safely. + +### Bun + +- Preserve existing top-level or workspace catalog locations and named catalog + references. +- Mirror the core alias as a direct `vite` dependency so Bun sees the peer + provider before applying overrides. +- Configure missing-peer suppression in `bunfig.toml` when needed, but do not + overwrite an explicit user `peer` setting. + +After updating the manifests and package-manager configuration, migration +reinstalls dependencies once to refresh the lockfile. If installation fails, +migration reports the error and exits with a nonzero status. From 27034b43a0cb695d59697a5c7a1b10baaebc6267 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 25 Jun 2026 10:58:42 +0800 Subject: [PATCH 35/78] test(migrate): update migration snapshots --- .../snap.txt | 7 ++++--- .../migration-rewrite-declare-module/snap.txt | 4 ++-- .../snap-tests-global/migration-standalone-npm/snap.txt | 6 +++--- .../migration-standalone-yarn4-idempotent/snap.txt | 1 + .../migration-upgrade-browser-peer-only-pnpm/snap.txt | 1 + .../migration-upgrade-nested-vitest-override-npm/snap.txt | 6 ++++-- .../migration-upgrade-nuxt-test-utils-monorepo/snap.txt | 1 + .../migration-upgrade-nuxt-test-utils/snap.txt | 1 + .../migration-upgrade-peer-vitest-catalog-pnpm/snap.txt | 1 + .../migration-upgrade-pkg-pr-new-pnpm/snap.txt | 8 +------- .../snap.txt | 1 + .../snap.txt | 1 + .../snap.txt | 1 + .../migration-vitest-unmanaged-override/snap.txt | 1 + 14 files changed, 23 insertions(+), 17 deletions(-) diff --git a/packages/cli/snap-tests-global/migration-monorepo-pnpm-overrides-dependency-selector/snap.txt b/packages/cli/snap-tests-global/migration-monorepo-pnpm-overrides-dependency-selector/snap.txt index 7f09fd6b80..fce06ca981 100644 --- a/packages/cli/snap-tests-global/migration-monorepo-pnpm-overrides-dependency-selector/snap.txt +++ b/packages/cli/snap-tests-global/migration-monorepo-pnpm-overrides-dependency-selector/snap.txt @@ -42,10 +42,11 @@ catalog: vite-plus: overrides: - '@vitejs/plugin-react>vite': 'npm:vite@' - 'supertest>superagent': - vite: 'catalog:' react-click-away-listener>react: + vite: 'catalog:' + vite-plugin-svgr>foo>vite: npm:vite@ + '@vitejs/plugin-react-swc>vite': npm:vite@ + supertest>superagent: peerDependencyRules: allowAny: - vite diff --git a/packages/cli/snap-tests-global/migration-rewrite-declare-module/snap.txt b/packages/cli/snap-tests-global/migration-rewrite-declare-module/snap.txt index 29bfd72c09..86fff5d082 100644 --- a/packages/cli/snap-tests-global/migration-rewrite-declare-module/snap.txt +++ b/packages/cli/snap-tests-global/migration-rewrite-declare-module/snap.txt @@ -39,8 +39,8 @@ declare module 'vitest/config' { "name": "migration-rewrite-declare-module", "devDependencies": { "vite": "catalog:", - "vite-plus": "catalog:", - "vitest": "catalog:" + "vitest": "catalog:", + "vite-plus": "catalog:" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-standalone-npm/snap.txt b/packages/cli/snap-tests-global/migration-standalone-npm/snap.txt index 680cd111de..3d2fc08b26 100644 --- a/packages/cli/snap-tests-global/migration-standalone-npm/snap.txt +++ b/packages/cli/snap-tests-global/migration-standalone-npm/snap.txt @@ -8,12 +8,12 @@ { "name": "migration-standalone-npm", "devDependencies": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vite-plus": "latest" + "vite": "npm:@voidzero-dev/vite-plus-core@", + "vite-plus": "" }, "packageManager": "npm@", "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest" + "vite": "npm:@voidzero-dev/vite-plus-core@" } } diff --git a/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/snap.txt b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/snap.txt index 432e3c8c74..dd4f96ad7b 100644 --- a/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/snap.txt +++ b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/snap.txt @@ -41,3 +41,4 @@ it('works', () => expect(true).toBe(true)); > vp migrate --no-interactive # a freshly migrated standalone Yarn project is complete This project is already using Vite+! Happy coding! + diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/snap.txt index 72a860833a..7e5af10e5e 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/snap.txt @@ -42,3 +42,4 @@ peerDependencyRules: > vp migrate --no-interactive # repaired project should no longer be pending This project is already using Vite+! Happy coding! + diff --git a/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/snap.txt index 4454394ee9..62fd9dd6b8 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/snap.txt @@ -1,6 +1,7 @@ > vp migrate --no-interactive # nested Vitest override is user-owned and not pending removal -This project is already using Vite+! Happy coding! - +◇ Migrated . to Vite+ +• Node npm +• Package manager settings configured > cat package.json # object-valued override is preserved { @@ -25,3 +26,4 @@ This project is already using Vite+! Happy coding! > vp migrate --no-interactive # nested override must not make migration permanently pending This project is already using Vite+! Happy coding! + diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/snap.txt index b5b7ef9636..0098d1f77f 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/snap.txt @@ -65,3 +65,4 @@ void expect; > vp migrate --no-interactive # workspace result is idempotent This project is already using Vite+! Happy coding! + diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/snap.txt index c322569898..7078ddd2c8 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/snap.txt @@ -43,3 +43,4 @@ expect(true).toBe(true); > vp migrate --no-interactive # the package-level compatibility result is idempotent This project is already using Vite+! Happy coding! + diff --git a/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/snap.txt index 1102051ca6..9937c35595 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/snap.txt @@ -37,3 +37,4 @@ peerDependencyRules: > vp migrate --no-interactive # repaired project should no longer be pending This project is already using Vite+! Happy coding! + diff --git a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/snap.txt index 992829edb5..8e4954dc33 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/snap.txt @@ -13,13 +13,6 @@ "vitest": "catalog:" }, "packageManager": "pnpm@", - "pnpm": { - "overrides": { - "vite": "https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@0c515e3fbf5c140db35280d700df0bd600838617", - "vitest": "", - "vite-plus": "https://pkg.pr.new/voidzero-dev/vite-plus@0c515e3fbf5c140db35280d700df0bd600838617" - } - }, "scripts": { "prepare": "vp config" } @@ -39,6 +32,7 @@ catalog: overrides: vite: 'catalog:' vitest: 'catalog:' + vite-plus: https://pkg.pr.new/voidzero-dev/vite-plus@0c515e3fbf5c140db35280d700df0bd600838617 peerDependencyRules: allowAny: diff --git a/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/snap.txt index bd06ee6529..7fc7f09994 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/snap.txt @@ -27,3 +27,4 @@ > node -e "const fs = require('node:fs'); fs.mkdirSync('node_modules', { recursive: true }); fs.cpSync('.fixture/vite-plugin-gherkin', 'node_modules/vite-plugin-gherkin', { recursive: true })" # simulate installed dependency metadata > vp migrate --no-interactive # metadata confirms the unnamed required Vitest peer This project is already using Vite+! Happy coding! + diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/snap.txt index 1b5bce1af1..771edda50e 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/snap.txt @@ -39,3 +39,4 @@ peerDependencyRules: > vp migrate --no-interactive # directive rewriting is stable on rerun This project is already using Vite+! Happy coding! + diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/snap.txt index 3269c7bb16..53707ada64 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/snap.txt @@ -47,3 +47,4 @@ console.log(metadata.version); > vp migrate --no-interactive # retained references remain stable on rerun This project is already using Vite+! Happy coding! + diff --git a/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/snap.txt b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/snap.txt index 052cde0151..0c4eb4176f 100644 --- a/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/snap.txt +++ b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/snap.txt @@ -40,3 +40,4 @@ peerDependencyRules: > vp migrate --no-interactive # unmanaged Vitest ecosystem versions remain stable on rerun This project is already using Vite+! Happy coding! + From ed11ef30b2708b0a6e3659df3a71a5ff1064a90a Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 25 Jun 2026 11:14:25 +0800 Subject: [PATCH 36/78] docs(migrate): clarify pnpm vite dependency rule --- docs/guide/migrate-rules.md | 40 +++++++++++++++++++------------------ docs/guide/migrate.md | 3 +++ 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 5e69ec03be..323f8f55e6 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -36,29 +36,30 @@ another diff. ## Dependency Changes -| Dependency | Migration rule | -| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `vite-plus` | Add it where the package is migrated. Re-pin plain ranges to the current concrete target, directly or through a catalog. Preserve deliberate protocol pins. | -| `vite` | Keep or add a real dependency edge where peer resolution requires one, and rewrite that edge plus the shared override/resolution to the matching `@voidzero-dev/vite-plus-core` target. An override rewrites an edge; it does not create one. | -| `vitest` | Remove it in the common node-mode case because `vite-plus` provides it transitively. Keep or add an exact bundled version only in packages with direct Vitest requirements. | -| `@vitest/*` | Align lockstep packages that the project directly lists to the bundled Vitest version. Prefer the package's existing catalog reference when its catalog owns that package; otherwise write the concrete version. | -| `@voidzero-dev/vite-plus-test` | Remove all dependency, override, resolution, and catalog aliases. Rewrite imports to the current `vite-plus/test*` surface. | +| Dependency | Migration rule | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `vite-plus` | Add it where the package is migrated. Re-pin plain ranges to the current concrete target, directly or through a catalog. Preserve deliberate protocol pins. | +| `vite` | Keep existing declarations. With pnpm, add a direct dev dependency to every package that depends on `vite-plus` and does not already declare `vite`. Point managed edges and the shared override to the matching `@voidzero-dev/vite-plus-core` target. | +| `vitest` | Remove it in the common node-mode case because `vite-plus` provides it transitively. Keep or add an exact bundled version only in packages with direct Vitest requirements. | +| `@vitest/*` | Align lockstep packages that the project directly lists to the bundled Vitest version. Prefer the package's existing catalog reference when its catalog owns that package; otherwise write the concrete version. | +| `@voidzero-dev/vite-plus-test` | Remove all dependency, override, resolution, and catalog aliases. Rewrite imports to the current `vite-plus/test*` surface. | ### Vite and Overrides -Package-manager overrides do not synthesize dependency edges. This matters most -with pnpm: Vitest has a required `vite` peer, and pnpm can auto-install upstream -Vite when a package that depends on `vite-plus` has no direct `vite` edge. That -creates separate Vite+, Vite, and Vitest peer contexts. Each affected pnpm -workspace package must therefore declare `vite`; the workspace override then -redirects that edge to Vite+ core. +Package-manager overrides do not synthesize dependency edges. Under pnpm, every +package that lists `vite-plus` in `dependencies` or `devDependencies` must also +declare `vite`, unless it already has a `vite` entry in `dependencies`, +`devDependencies`, `optionalDependencies`, or `peerDependencies`. Otherwise, +pnpm can auto-install upstream Vite to satisfy Vitest's required `vite` peer, +creating separate Vite+, Vite, and Vitest instances. `vp migrate` adds a missing +`vite` entry to `devDependencies`; the workspace override redirects it to Vite+ +core. Do not remove a direct `vite` declaration merely because a root override exists. Normalize existing plain or stale aliases while retaining named catalog -references. A real edge is also required for Bun's peer resolver, and npm -browser-provider layouts may need a top-level edge so nested Vitest packages can -resolve `vite`. After migration, pnpm users should verify that each affected -workspace package has the required direct edge. +references. The general rule above is specific to pnpm. Bun mirrors its core +alias as a direct dependency for its peer resolver, while npm browser-provider +layouts may need a top-level edge so nested Vitest packages can resolve `vite`. ### When Vitest Is Directly Required @@ -154,8 +155,9 @@ lint autofix preserves these imports. 11 no longer reads the legacy package.json settings. - Migration keeps dependency references, default and named catalogs, overrides, and `peerDependencyRules` consistent. -- Each package whose `vite-plus`/Vitest peer context would otherwise install - upstream Vite needs a direct `vite` edge. +- Each package that lists `vite-plus` in `dependencies` or `devDependencies` + gets a direct `vite` dev dependency unless it already declares `vite` in a + dependency field. - Unrelated selector-shaped and object-valued overrides are preserved. ### npm diff --git a/docs/guide/migrate.md b/docs/guide/migrate.md index c5472a25c8..17d809240c 100644 --- a/docs/guide/migrate.md +++ b/docs/guide/migrate.md @@ -49,6 +49,9 @@ The `migrate` command is designed to move existing projects onto Vite+ quickly. - Can set up commit hooks - Can write agent and editor configuration files +See [Migration Rules](./migrate-rules.md) for the exact dependency, source +rewrite, and package-manager behavior. + Most projects will require further manual adjustments after running `vp migrate`. ## Recommended Workflow From bea36b25cc2e912ca0e8d53e54ccf80c2fe3ddc8 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 25 Jun 2026 20:27:57 +0800 Subject: [PATCH 37/78] fix(migrate): format projects after migration --- docs/guide/migrate-rules.md | 4 +- docs/guide/migrate.md | 1 + .../src/migration/__tests__/format.spec.ts | 47 +++++++++++++++++++ packages/cli/src/migration/bin.ts | 12 +++++ packages/cli/src/migration/format.ts | 44 +++++++++++++++++ packages/cli/src/utils/prompts.ts | 8 ++-- 6 files changed, 111 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/migration/__tests__/format.spec.ts create mode 100644 packages/cli/src/migration/format.ts diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 323f8f55e6..e7029aea30 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -191,4 +191,6 @@ lint autofix preserves these imports. After updating the manifests and package-manager configuration, migration reinstalls dependencies once to refresh the lockfile. If installation fails, -migration reports the error and exits with a nonzero status. +migration reports the error and exits with a nonzero status. After a successful +migration, it runs `vp fmt`. A formatter failure is reported as a warning +so the migration result and manual formatting command remain available. diff --git a/docs/guide/migrate.md b/docs/guide/migrate.md index 17d809240c..c954fd5de3 100644 --- a/docs/guide/migrate.md +++ b/docs/guide/migrate.md @@ -48,6 +48,7 @@ The `migrate` command is designed to move existing projects onto Vite+ quickly. - Updates scripts to the Vite+ command surface - Can set up commit hooks - Can write agent and editor configuration files +- Formats the migrated project See [Migration Rules](./migrate-rules.md) for the exact dependency, source rewrite, and package-manager behavior. diff --git a/packages/cli/src/migration/__tests__/format.spec.ts b/packages/cli/src/migration/__tests__/format.spec.ts new file mode 100644 index 0000000000..7a4f689045 --- /dev/null +++ b/packages/cli/src/migration/__tests__/format.spec.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { formatMigratedProject } from '../format.ts'; +import { createMigrationReport } from '../report.ts'; + +describe('formatMigratedProject', () => { + it('formats the project root', async () => { + const format = vi.fn().mockResolvedValue({ + durationMs: 1, + exitCode: 0, + status: 'formatted', + }); + const report = createMigrationReport(); + + await expect(formatMigratedProject('/project', false, report, format)).resolves.toBe(true); + expect(format).toHaveBeenCalledWith('/project', false, undefined, { + silent: false, + command: process.execPath, + commandArgs: [...process.execArgv, process.argv[1]], + }); + expect(report.warnings).toEqual([]); + }); + + it('reports a formatter nonzero exit without throwing', async () => { + const format = vi.fn().mockResolvedValue({ + durationMs: 1, + exitCode: 1, + status: 'failed', + }); + const report = createMigrationReport(); + + await expect(formatMigratedProject('/project', false, report, format)).resolves.toBe(false); + expect(report.warnings).toEqual([ + 'Automatic formatting failed. Run `vp fmt` manually after migration.', + ]); + }); + + it('reports a formatter exception without throwing', async () => { + const format = vi.fn().mockRejectedValue(new Error('could not load config')); + const report = createMigrationReport(); + + await expect(formatMigratedProject('/project', false, report, format)).resolves.toBe(false); + expect(report.warnings).toEqual([ + 'Automatic formatting failed. Run `vp fmt` manually after migration.', + ]); + }); +}); diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index 21f64a48fa..0c853a0877 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -46,6 +46,7 @@ import { import type { PackageDependencies } from '../utils/types.ts'; import { detectWorkspace } from '../utils/workspace.ts'; import { checkRolldownCompatibility } from './compat-runner.ts'; +import { formatMigratedProject } from './format.ts'; import { addFrameworkShim, checkVitestVersion, @@ -1158,6 +1159,9 @@ async function executeMigrationPlan( workspaceInfo.rootDir, report, ); + if (finalInstallSummary.status === 'installed') { + await formatMigratedProject(workspaceInfo.rootDir, interactive, report); + } return { installDurationMs: initialInstallDurationMs + finalInstallDurationMs, finalInstallOk: finalInstallSummary.status === 'installed', @@ -1195,6 +1199,7 @@ async function main() { let didMigrate = yarnPnpConverted; let installDurationMs = 0; let finalInstallOk = true; + let canFormatMigratedProject = !process.env.VP_SKIP_INSTALL; const report = createMigrationReport(); report.packageManagerBootstrapConfigured = yarnPnpConverted; const migrationProgress = options.interactive @@ -1481,6 +1486,8 @@ async function main() { if (installSummary.status === 'failed') { clearMigrationProgress(); } + finalInstallOk = installSummary.status !== 'failed'; + canFormatMigratedProject = finalInstallOk && canFormatMigratedProject; installDurationMs += handleInstallResult( installSummary, workspaceInfoOptional.rootDir, @@ -1523,6 +1530,11 @@ async function main() { } } + if (didMigrate && finalInstallOk && canFormatMigratedProject) { + clearMigrationProgress(); + await formatMigratedProject(workspaceInfoOptional.rootDir, options.interactive, report); + } + if (didMigrate || report.warnings.length > 0) { clearMigrationProgress(); showMigrationSummary({ diff --git a/packages/cli/src/migration/format.ts b/packages/cli/src/migration/format.ts new file mode 100644 index 0000000000..b5941a0632 --- /dev/null +++ b/packages/cli/src/migration/format.ts @@ -0,0 +1,44 @@ +import { type CommandRunSummary, runViteFmt } from '../utils/prompts.ts'; +import { addMigrationWarning, type MigrationReport } from './report.ts'; + +type FormatRunner = ( + cwd: string, + interactive?: boolean, + paths?: string[], + options?: { silent?: boolean; command?: string; commandArgs?: string[] }, +) => Promise; + +const FORMAT_FAILURE_MESSAGE = + 'Automatic formatting failed. Run `vp fmt` manually after migration.'; + +/** + * Format a successfully migrated project without turning a formatter problem + * into an unhandled migration failure. The formatter already prints its + * stdout/stderr when it exits nonzero; the report keeps the manual follow-up + * visible in the final migration summary. + */ +export async function formatMigratedProject( + projectRoot: string, + interactive: boolean, + report: MigrationReport, + format: FormatRunner = runViteFmt, +): Promise { + try { + const cliEntry = process.argv[1]; + const result = await format(projectRoot, interactive, undefined, { + silent: false, + ...(cliEntry + ? { command: process.execPath, commandArgs: [...process.execArgv, cliEntry] } + : {}), + }); + if (result.status === 'formatted') { + return true; + } + } catch { + // Treat spawn/config failures the same as a formatter nonzero exit. The + // migration changes are still valid and the user can format them manually. + } + + addMigrationWarning(report, FORMAT_FAILURE_MESSAGE); + return false; +} diff --git a/packages/cli/src/utils/prompts.ts b/packages/cli/src/utils/prompts.ts index 02b4356f9c..6e1b0af4cd 100644 --- a/packages/cli/src/utils/prompts.ts +++ b/packages/cli/src/utils/prompts.ts @@ -171,15 +171,15 @@ export async function runViteFmt( cwd: string, interactive?: boolean, paths?: string[], - options?: { silent?: boolean }, + options?: { silent?: boolean; command?: string; commandArgs?: string[] }, ) { const spinner = options?.silent ? getSilentSpinner() : getSpinner(interactive); const startTime = Date.now(); spinner.start(`Formatting code...`); const { exitCode, stderr, stdout } = await runCommandSilently({ - command: process.env.VP_CLI_BIN ?? 'vp', - args: ['fmt', '--write', ...(paths ?? [])], + command: options?.command ?? process.env.VP_CLI_BIN ?? 'vp', + args: [...(options?.commandArgs ?? []), 'fmt', ...(paths ?? [])], cwd, envs: process.env, }); @@ -196,7 +196,7 @@ export async function runViteFmt( prompts.log.info(stdout.toString()); prompts.log.error(stderr.toString()); const relativePaths = (paths ?? []).length > 0 ? ` ${(paths ?? []).join(' ')}` : ''; - prompts.log.info(`You may need to run "vp fmt --write${relativePaths}" manually in ${cwd}`); + prompts.log.info(`You may need to run "vp fmt${relativePaths}" manually in ${cwd}`); return { durationMs: Date.now() - startTime, exitCode, From a9607404d7b2c690b3d85ecd2ca1616fe5180f72 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 25 Jun 2026 20:50:40 +0800 Subject: [PATCH 38/78] fix(migrate): preserve unmigrated Prettier projects --- docs/guide/migrate-rules.md | 5 +++-- .../migration-framework-shim-astro-vue/snap.txt | 8 ++++++-- .../migration-framework-shim-astro/snap.txt | 4 ++++ .../migration-framework-shim-vue/snap.txt | 8 ++++++-- .../migration-standalone-npm/snap.txt | 8 ++++++-- .../migration-standalone-pnpm/snap.txt | 8 ++++++-- .../cli/src/migration/__tests__/format.spec.ts | 16 +++++++++++++++- packages/cli/src/migration/bin.ts | 16 +++++++++++++--- packages/cli/src/migration/format.ts | 11 +++++++++++ 9 files changed, 70 insertions(+), 14 deletions(-) diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index e7029aea30..ddae712c3e 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -192,5 +192,6 @@ lint autofix preserves these imports. After updating the manifests and package-manager configuration, migration reinstalls dependencies once to refresh the lockfile. If installation fails, migration reports the error and exits with a nonzero status. After a successful -migration, it runs `vp fmt`. A formatter failure is reported as a warning -so the migration result and manual formatting command remain available. +migration, it runs `vp fmt` unless the project still uses Prettier. A formatter +failure is reported as a warning so the migration result and manual formatting +command remain available. diff --git a/packages/cli/snap-tests-global/migration-framework-shim-astro-vue/snap.txt b/packages/cli/snap-tests-global/migration-framework-shim-astro-vue/snap.txt index 7ac3da61ef..d994dece5e 100644 --- a/packages/cli/snap-tests-global/migration-framework-shim-astro-vue/snap.txt +++ b/packages/cli/snap-tests-global/migration-framework-shim-astro-vue/snap.txt @@ -1,4 +1,8 @@ > vp migrate --no-interactive --no-hooks # migration should add both Vue and Astro shims + +Formatting code... + +Code formatted ◇ Migrated . to Vite+ • Node pnpm ✓ Dependencies installed in ms @@ -6,8 +10,8 @@ • TypeScript shim added for framework component files > cat src/env.d.ts # check both shims were written -declare module '*.vue' { - import type { DefineComponent } from 'vue'; +declare module "*.vue" { + import type { DefineComponent } from "vue"; const component: DefineComponent<{}, {}, unknown>; export default component; } diff --git a/packages/cli/snap-tests-global/migration-framework-shim-astro/snap.txt b/packages/cli/snap-tests-global/migration-framework-shim-astro/snap.txt index c4f217f21a..6cc21f936f 100644 --- a/packages/cli/snap-tests-global/migration-framework-shim-astro/snap.txt +++ b/packages/cli/snap-tests-global/migration-framework-shim-astro/snap.txt @@ -1,4 +1,8 @@ > vp migrate --no-interactive --no-hooks # migration should add Astro shim when astro dependency is detected + +Formatting code... + +Code formatted ◇ Migrated . to Vite+ • Node pnpm ✓ Dependencies installed in ms diff --git a/packages/cli/snap-tests-global/migration-framework-shim-vue/snap.txt b/packages/cli/snap-tests-global/migration-framework-shim-vue/snap.txt index 78d7399db4..309e6a1bed 100644 --- a/packages/cli/snap-tests-global/migration-framework-shim-vue/snap.txt +++ b/packages/cli/snap-tests-global/migration-framework-shim-vue/snap.txt @@ -1,4 +1,8 @@ > vp migrate --no-interactive --no-hooks # migration should add Vue shim when vue dependency is detected + +Formatting code... + +Code formatted ◇ Migrated . to Vite+ • Node pnpm ✓ Dependencies installed in ms @@ -6,8 +10,8 @@ • TypeScript shim added for framework component files > cat src/env.d.ts # check Vue shim was written -declare module '*.vue' { - import type { DefineComponent } from 'vue'; +declare module "*.vue" { + import type { DefineComponent } from "vue"; const component: DefineComponent<{}, {}, unknown>; export default component; } diff --git a/packages/cli/snap-tests-global/migration-standalone-npm/snap.txt b/packages/cli/snap-tests-global/migration-standalone-npm/snap.txt index 3d2fc08b26..a977650ba6 100644 --- a/packages/cli/snap-tests-global/migration-standalone-npm/snap.txt +++ b/packages/cli/snap-tests-global/migration-standalone-npm/snap.txt @@ -1,4 +1,8 @@ > vp migrate --no-interactive --no-hooks # migration should work with npm, add overrides, and update lockfile + +Formatting code... + +Code formatted ◇ Migrated . to Vite+ • Node npm ✓ Dependencies installed in ms @@ -11,10 +15,10 @@ "vite": "npm:@voidzero-dev/vite-plus-core@", "vite-plus": "" }, - "packageManager": "npm@", "overrides": { "vite": "npm:@voidzero-dev/vite-plus-core@" - } + }, + "packageManager": "npm@" } > node -e "const lock = require('./package-lock.json'); const vite = lock.packages['node_modules/vite']; if (vite && (vite.name === '@voidzero-dev/vite-plus-core' || vite.resolved?.includes('/@voidzero-dev/vite-plus-core/'))) console.log('lockfile has vite override'); else { console.error('vite override not found in lockfile'); process.exit(1); }" # verify lockfile updated with override diff --git a/packages/cli/snap-tests-global/migration-standalone-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-standalone-pnpm/snap.txt index 0c6b49172b..44c5b51097 100644 --- a/packages/cli/snap-tests-global/migration-standalone-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-standalone-pnpm/snap.txt @@ -1,4 +1,8 @@ > vp migrate --no-interactive --no-hooks --package-manager pnpm # migration should work with pnpm, write overrides and peerDependencyRules to pnpm-workspace.yaml + +Formatting code... + +Code formatted ◇ Migrated . to Vite+ • Node pnpm ✓ Dependencies installed in ms @@ -19,9 +23,9 @@ catalog: vite: npm:@voidzero-dev/vite-plus-core@ vite-plus: overrides: - vite: 'catalog:' + vite: "catalog:" peerDependencyRules: allowAny: - vite allowedVersions: - vite: '*' + vite: "*" diff --git a/packages/cli/src/migration/__tests__/format.spec.ts b/packages/cli/src/migration/__tests__/format.spec.ts index 7a4f689045..762c44bf77 100644 --- a/packages/cli/src/migration/__tests__/format.spec.ts +++ b/packages/cli/src/migration/__tests__/format.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { formatMigratedProject } from '../format.ts'; +import { canFormatWithOxfmt, formatMigratedProject } from '../format.ts'; import { createMigrationReport } from '../report.ts'; describe('formatMigratedProject', () => { @@ -45,3 +45,17 @@ describe('formatMigratedProject', () => { ]); }); }); + +describe('canFormatWithOxfmt', () => { + it('formats projects that do not use Prettier', () => { + expect(canFormatWithOxfmt(false, false)).toBe(true); + }); + + it('formats projects after Prettier was migrated', () => { + expect(canFormatWithOxfmt(true, true)).toBe(true); + }); + + it('does not reformat projects that still use Prettier', () => { + expect(canFormatWithOxfmt(true, false)).toBe(false); + }); +}); diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index 0c853a0877..878f2de7e8 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -46,7 +46,7 @@ import { import type { PackageDependencies } from '../utils/types.ts'; import { detectWorkspace } from '../utils/workspace.ts'; import { checkRolldownCompatibility } from './compat-runner.ts'; -import { formatMigratedProject } from './format.ts'; +import { canFormatWithOxfmt, formatMigratedProject } from './format.ts'; import { addFrameworkShim, checkVitestVersion, @@ -388,6 +388,7 @@ interface MigrationPlan extends MigrationSetupPlan { packageManager: PackageManager; yarnPnpConverted: boolean; migratePrettier: boolean; + hasPrettierDependency: boolean; prettierConfigFile?: string; fixBaseUrl: boolean; migrateNodeVersionFile: boolean; @@ -724,6 +725,7 @@ async function collectMigrationPlan( yarnPnpConverted, ...setupPlan, migratePrettier, + hasPrettierDependency: prettierProject.hasDependency, prettierConfigFile: prettierProject.configFile, fixBaseUrl, migrateNodeVersionFile, @@ -1159,7 +1161,10 @@ async function executeMigrationPlan( workspaceInfo.rootDir, report, ); - if (finalInstallSummary.status === 'installed') { + if ( + finalInstallSummary.status === 'installed' && + canFormatWithOxfmt(plan.hasPrettierDependency, plan.migratePrettier) + ) { await formatMigratedProject(workspaceInfo.rootDir, interactive, report); } return { @@ -1530,7 +1535,12 @@ async function main() { } } - if (didMigrate && finalInstallOk && canFormatMigratedProject) { + if ( + didMigrate && + finalInstallOk && + canFormatMigratedProject && + canFormatWithOxfmt(prettierProject.hasDependency, prettierMigrated) + ) { clearMigrationProgress(); await formatMigratedProject(workspaceInfoOptional.rootDir, options.interactive, report); } diff --git a/packages/cli/src/migration/format.ts b/packages/cli/src/migration/format.ts index b5941a0632..afb21db501 100644 --- a/packages/cli/src/migration/format.ts +++ b/packages/cli/src/migration/format.ts @@ -11,6 +11,17 @@ type FormatRunner = ( const FORMAT_FAILURE_MESSAGE = 'Automatic formatting failed. Run `vp fmt` manually after migration.'; +/** + * Do not apply Oxfmt to a project that still uses Prettier. Their formatting + * rules can conflict, especially when Prettier is enforced through ESLint. + */ +export function canFormatWithOxfmt( + hasPrettierDependency: boolean, + prettierMigrated: boolean, +): boolean { + return !hasPrettierDependency || prettierMigrated; +} + /** * Format a successfully migrated project without turning a formatter problem * into an unhandled migration failure. The formatter already prints its From 98fa5c8d1d2a014da7e7a5bfd4b9a222157371f5 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 25 Jun 2026 21:32:40 +0800 Subject: [PATCH 39/78] fix(migrate): detect legacy browser providers --- docs/guide/migrate-rules.md | 9 ++ .../src/migration/__tests__/migrator.spec.ts | 98 +++++++++++++++++++ packages/cli/src/migration/migrator.ts | 15 +++ rfcs/migrate-existing-projects.md | 11 ++- 4 files changed, 132 insertions(+), 1 deletion(-) diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index ddae712c3e..72b04b9009 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -117,12 +117,21 @@ Vite+ and should be removed as direct dependencies. The Playwright and WebdriverIO providers remain opt-in: keep or add the provider at the bundled Vitest version and ensure its `playwright` or `webdriverio` peer is installed. +Migration detects providers before rewriting imports. This includes legacy +projects that aliased `vitest` to `@voidzero-dev/vite-plus-test` and import from +`vitest/browser-`, `vitest/browser/providers/`, or +`vitest/plugins/browser-`. These imports still cause the corresponding +`@vitest/browser-playwright` or `@vitest/browser-webdriverio` dependency and its +framework peer to be installed. + Object-valued nested npm and Bun overrides are preserved because they are user-defined scopes rather than scalar version pins. ## Source Rewrite Rules - Rewrite ordinary `vitest` and `vitest/*` imports to `vite-plus/test*`. +- Detect legacy Playwright and WebdriverIO provider imports before applying that + rewrite so their optional provider dependencies are not lost. - Rewrite scoped browser imports to the corresponding `vite-plus/test/browser*` exports and provision opt-in providers when needed. - Leave existing `vite-plus/test*` imports unchanged. diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index dbb2d620a4..b5d1fc5510 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -3888,6 +3888,104 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { expect(devDeps.playwright).toBe('*'); }); + it.each([ + ['playwright', 'browser-playwright'], + ['playwright', 'browser/providers/playwright'], + ['playwright', 'plugins/browser-playwright'], + ['webdriverio', 'browser-webdriverio'], + ['webdriverio', 'browser/providers/webdriverio'], + ['webdriverio', 'plugins/browser-webdriverio'], + ] as const)( + 'injects the %s provider before rewriting the legacy vitest/%s import', + (provider, subpath) => { + const legacySpecifier = `vitest/${subpath}`; + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { + vite: '^7.0.0', + vitest: 'npm:@voidzero-dev/vite-plus-test@0.1.24', + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'vite.config.ts'), + [ + `import { ${provider} } from '${legacySpecifier}';`, + "import { defineConfig } from 'vite-plus';", + 'export default defineConfig({', + ` test: { browser: { enabled: true, provider: ${provider}() } },`, + '});', + '', + ].join('\n'), + ); + + rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); + + const devDeps = readJson(path.join(tmpDir, 'package.json')).devDependencies as Record< + string, + string + >; + expect(devDeps[`@vitest/browser-${provider}`]).toBe(VITEST_VERSION); + expect(devDeps[provider]).toBe('*'); + expect(devDeps.vitest).toBe('catalog:'); + expect(fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf8')).toContain( + `from 'vite-plus/test/${subpath}'`, + ); + }, + ); + + it('injects the provider before rewriting a legacy provider import at a monorepo root', () => { + // Regression for vue-core: the root manifest is rewritten before imports, + // so the legacy vite-plus-test alias path must be recognized during the + // initial source scan. + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'root', + devDependencies: { + playwright: '^1.56.1', + vite: 'catalog:', + vitest: 'npm:@voidzero-dev/vite-plus-test@0.1.24', + }, + }), + ); + fs.writeFileSync(path.join(tmpDir, 'pnpm-workspace.yaml'), 'packages:\n - packages/*\n'); + fs.writeFileSync( + path.join(tmpDir, 'vite.config.ts'), + [ + "import { playwright } from 'vitest/browser-playwright';", + "import { defineConfig } from 'vite-plus';", + 'export default defineConfig({', + ' test: { browser: { enabled: true, provider: playwright() } },', + '});', + '', + ].join('\n'), + ); + + rewriteMonorepo( + { + ...makeWorkspaceInfo(tmpDir, PackageManager.pnpm), + isMonorepo: true, + workspacePatterns: ['packages/*'], + }, + true, + true, + ); + + const devDeps = readJson(path.join(tmpDir, 'package.json')).devDependencies as Record< + string, + string + >; + expect(devDeps['@vitest/browser-playwright']).toBe(VITEST_VERSION); + expect(devDeps.playwright).toBe('^1.56.1'); + expect(devDeps.vitest).toBe('catalog:'); + expect(fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf8')).toContain( + "from 'vite-plus/test/browser-playwright'", + ); + }); + it('injects the playwright provider on a re-run from the migrated provider-subpath import', () => { // Re-running migration on an ALREADY-migrated project: the import rewriter // maps `@vitest/browser-playwright/provider` to diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index c3120ede45..593789eb95 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -4812,6 +4812,10 @@ export function ensureVitePlusBootstrap( // against every browser-surface `./test/*` export in package.json (those that // re-export `@vitest/browser*` or `vitest/internal/browser`). const VITEST_BROWSER_SPECIFIER_HINTS = [ + // Before v0.2, projects commonly aliased `vitest` to + // `@voidzero-dev/vite-plus-test`, whose browser exports used these paths. + 'vitest/browser', + 'vitest/plugins/browser', '@vitest/browser', 'vite-plus/test/browser', 'vite-plus/test/plugins/browser', @@ -4826,6 +4830,9 @@ const VITEST_BROWSER_SPECIFIER_HINTS = [ // Specifier fragments that signal the WEBDRIVERIO provider specifically. Each // is a prefix, matched as a substring, so subpath imports (`/context`, // `/provider`, …) are covered too: +// - `vitest/browser-webdriverio`, `vitest/browser/providers/webdriverio`, and +// `vitest/plugins/browser-webdriverio` are legacy +// `@voidzero-dev/vite-plus-test` exports reached through the `vitest` alias // - `@vitest/browser-webdriverio` pre-migration (incl. `/provider`, // `/context` subpaths) // - `vite-plus/test/browser-webdriverio` migrated (re-run); covers @@ -4846,6 +4853,9 @@ const VITEST_BROWSER_SPECIFIER_HINTS = [ // it pulls in the (now opt-in) // provider, so it signals usage too. const WEBDRIVERIO_PROVIDER_SPECIFIER_HINTS = [ + 'vitest/browser-webdriverio', + 'vitest/browser/providers/webdriverio', + 'vitest/plugins/browser-webdriverio', '@vitest/browser-webdriverio', 'vite-plus/test/browser-webdriverio', 'vite-plus/test/browser/providers/webdriverio', @@ -4860,6 +4870,11 @@ const WEBDRIVERIO_PROVIDER_SPECIFIER_HINTS = [ // provider via a `vite-plus/test/browser-playwright` shim with no declared dep) // must still have the provider kept/injected for the rewritten import to resolve. const PLAYWRIGHT_PROVIDER_SPECIFIER_HINTS = [ + // Legacy `@voidzero-dev/vite-plus-test` exports reached through the `vitest` + // alias. These must be detected before rewriteAllImports changes the prefix. + 'vitest/browser-playwright', + 'vitest/browser/providers/playwright', + 'vitest/plugins/browser-playwright', '@vitest/browser-playwright', 'vite-plus/test/browser-playwright', 'vite-plus/test/browser/providers/playwright', diff --git a/rfcs/migrate-existing-projects.md b/rfcs/migrate-existing-projects.md index f2f8040a7e..94fb8a5f75 100644 --- a/rfcs/migrate-existing-projects.md +++ b/rfcs/migrate-existing-projects.md @@ -45,6 +45,15 @@ When PnP is active, interactive migration prints the incompatibility and asks wh Force-override/CI mode (`VP_OVERRIDE_PACKAGES`) is respected: when `vitest` is not a managed key there, the project's own `vitest` is never stripped and its `@vitest/*` ecosystem dependencies are not realigned. Object-valued nested npm/Bun overrides are user-owned scopes rather than managed version pins and are preserved. +Legacy browser-provider usage must be detected before source imports are +rewritten. Projects that aliased `vitest` to the removed +`@voidzero-dev/vite-plus-test` package can import Playwright or WebdriverIO from +`vitest/browser-`, `vitest/browser/providers/`, or +`vitest/plugins/browser-`. Migration treats all three forms as opt-in +provider usage, installs the matching `@vitest/browser-` package and +framework peer, and then rewrites the import to the equivalent +`vite-plus/test*` surface. + ## `@nuxt/test-utils` compatibility `@nuxt/test-utils`'s transform detects an existing `vi` import only when its module specifier is exactly `vitest`. When a test uses `mockNuxtImport` or `mockComponent`, changing that import to `vite-plus/test` makes the transform inject a second `vi` import and can fail compilation with a duplicate identifier. Requiring users to know which individual files exercise that transform is brittle, so the migration uses one package-level rule instead. @@ -96,7 +105,7 @@ How each package the `vitest` ecosystem rule covers is handled, verified against | `packages/cli/src/migration/{migrator,npm-reinstall,bin}.ts` | Yarn PnP preflight and `node-modules` conversion; usage-aware managed override set; per-package dependency reconciliation; `vitest` removal across every sink; full `@vitest/*` alignment; browser-provider restoration; behind `vite-plus`/`vite` re-pin; empty/unrelated-`pnpm` routing fix; stale npm Vite install cleanup; package-level Nuxt dependency detection and retained Vitest provisioning. | | Oxlint `prefer-vite-plus-imports` rule | Apply the same Nuxt package-level `vitest` / `vitest/*` exception so diagnostics and autofix preserve the migration's compatible result. | -Covered by unit tests in `migrator.spec.ts` (vitest removal, required-peer provisioning, ecosystem alignment, browser-provider restoration, workspace localization, behind re-pin, empty-`pnpm` reconciliation), `npm-reinstall.spec.ts` (stale npm install and lock cleanup), and a routing test in `vite_global_cli`. +Covered by unit tests in `migrator.spec.ts` (vitest removal, required-peer provisioning, ecosystem alignment, browser-provider restoration including legacy wrapper import paths, workspace localization, behind re-pin, empty-`pnpm` reconciliation), `npm-reinstall.spec.ts` (stale npm install and lock cleanup), and a routing test in `vite_global_cli`. ## Snapshot coverage From 18a6a56d04c0982083b219654fe8f219b12564b2 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 26 Jun 2026 10:30:03 +0800 Subject: [PATCH 40/78] fix(ci): upgrade affected pnpm 11 versions for pkg.pr.new tests --- .../ensure-pkg-pr-new-pnpm-version.mjs | 185 ++++++++++++++++++ .github/scripts/test-pkg-pr-new-migrate.sh | 11 ++ 2 files changed, 196 insertions(+) create mode 100644 .github/scripts/ensure-pkg-pr-new-pnpm-version.mjs diff --git a/.github/scripts/ensure-pkg-pr-new-pnpm-version.mjs b/.github/scripts/ensure-pkg-pr-new-pnpm-version.mjs new file mode 100644 index 0000000000..e97e7b070e --- /dev/null +++ b/.github/scripts/ensure-pkg-pr-new-pnpm-version.mjs @@ -0,0 +1,185 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const SAFE_PNPM_VERSION = '11.9.0'; +const SUPPORTED_PACKAGE_MANAGERS = new Set(['pnpm', 'yarn', 'npm', 'bun']); + +function parseExactVersion(version) { + const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(version); + if (!match) { + return undefined; + } + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease: match[4]?.split('.'), + }; +} + +function compareIdentifiers(left, right) { + const leftNumber = /^\d+$/.test(left) ? Number(left) : undefined; + const rightNumber = /^\d+$/.test(right) ? Number(right) : undefined; + if (leftNumber !== undefined && rightNumber !== undefined) { + return leftNumber - rightNumber; + } + if (leftNumber !== undefined) { + return -1; + } + if (rightNumber !== undefined) { + return 1; + } + return left.localeCompare(right); +} + +function compareVersions(left, right) { + for (const key of ['major', 'minor', 'patch']) { + if (left[key] !== right[key]) { + return left[key] - right[key]; + } + } + if (!left.prerelease && !right.prerelease) { + return 0; + } + if (!left.prerelease) { + return 1; + } + if (!right.prerelease) { + return -1; + } + const length = Math.max(left.prerelease.length, right.prerelease.length); + for (let index = 0; index < length; index++) { + const leftIdentifier = left.prerelease[index]; + const rightIdentifier = right.prerelease[index]; + if (leftIdentifier === undefined) { + return -1; + } + if (rightIdentifier === undefined) { + return 1; + } + const compared = compareIdentifiers(leftIdentifier, rightIdentifier); + if (compared !== 0) { + return compared; + } + } + return 0; +} + +function isAffectedPnpmVersion(version) { + const parsed = parseExactVersion(version); + const lower = parseExactVersion('11.0.0'); + const upper = parseExactVersion(SAFE_PNPM_VERSION); + return ( + parsed !== undefined && + compareVersions(parsed, lower) >= 0 && + compareVersions(parsed, upper) < 0 + ); +} + +function parsePackageManagerSpec(spec) { + const match = /^([^@]+)@(.+)$/.exec(spec); + return match ? { name: match[1], version: match[2] } : undefined; +} + +function devEngineEntries(pkg) { + const value = pkg.devEngines?.packageManager; + if (Array.isArray(value)) { + return value.filter((entry) => entry && typeof entry === 'object'); + } + return value && typeof value === 'object' ? [value] : []; +} + +function selectedDevEngineEntry(pkg) { + return devEngineEntries(pkg).find( + (entry) => typeof entry.name === 'string' && SUPPORTED_PACKAGE_MANAGERS.has(entry.name), + ); +} + +function serializeLike(source, pkg) { + const indentMatch = source.match(/\n([\t ]+)"/); + const indent = indentMatch?.[1].startsWith('\t') ? '\t' : (indentMatch?.[1].length ?? 2); + const newline = source.includes('\r\n') ? '\r\n' : '\n'; + const finalNewline = /\r?\n$/.test(source) ? newline : ''; + return JSON.stringify(pkg, null, indent).replaceAll('\n', newline) + finalNewline; +} + +function replacePackageManagerSpec(source, previousSpec) { + const pattern = /("packageManager"\s*:\s*)("(?:\\.|[^"\\])*")/g; + return source.replace(pattern, (match, prefix, value) => { + if (JSON.parse(value) !== previousSpec) { + return match; + } + return `${prefix}${JSON.stringify(`pnpm@${SAFE_PNPM_VERSION}`)}`; + }); +} + +export function ensureSafePkgPrNewPnpmVersion(source) { + const pkg = JSON.parse(source); + const previousVersions = []; + let packageManagerSpec; + let devEnginesChanged = false; + + if (typeof pkg.packageManager === 'string') { + const parsed = parsePackageManagerSpec(pkg.packageManager); + if (parsed?.name !== 'pnpm' || !isAffectedPnpmVersion(parsed.version)) { + return { changed: false, source, previousVersions }; + } + packageManagerSpec = pkg.packageManager; + previousVersions.push(parsed.version); + pkg.packageManager = `pnpm@${SAFE_PNPM_VERSION}`; + + // Keep exact pnpm devEngines constraints in sync with the authoritative + // packageManager field so the two declarations do not conflict. + for (const entry of devEngineEntries(pkg)) { + if ( + entry.name === 'pnpm' && + typeof entry.version === 'string' && + isAffectedPnpmVersion(entry.version) + ) { + previousVersions.push(entry.version); + entry.version = SAFE_PNPM_VERSION; + devEnginesChanged = true; + } + } + } else { + const selected = selectedDevEngineEntry(pkg); + if ( + selected?.name !== 'pnpm' || + typeof selected.version !== 'string' || + !isAffectedPnpmVersion(selected.version) + ) { + return { changed: false, source, previousVersions }; + } + previousVersions.push(selected.version); + selected.version = SAFE_PNPM_VERSION; + devEnginesChanged = true; + } + + const updatedSource = devEnginesChanged + ? serializeLike(source, pkg) + : replacePackageManagerSpec(source, packageManagerSpec); + return { + changed: true, + source: updatedSource, + previousVersions: [...new Set(previousVersions)], + version: SAFE_PNPM_VERSION, + }; +} + +const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : undefined; +if (invokedPath === import.meta.url) { + const packageJsonPath = process.argv[2]; + if (!packageJsonPath) { + console.error('Usage: ensure-pkg-pr-new-pnpm-version.mjs '); + process.exit(2); + } + const source = fs.readFileSync(packageJsonPath, 'utf8'); + const result = ensureSafePkgPrNewPnpmVersion(source); + if (result.changed) { + fs.writeFileSync(packageJsonPath, result.source); + console.log( + `Updating project pnpm ${result.previousVersions.join(', ')} -> ${result.version} to avoid pkg.pr.new tarball integrity failures`, + ); + } +} diff --git a/.github/scripts/test-pkg-pr-new-migrate.sh b/.github/scripts/test-pkg-pr-new-migrate.sh index e17a3c52ab..7a469d5b51 100755 --- a/.github/scripts/test-pkg-pr-new-migrate.sh +++ b/.github/scripts/test-pkg-pr-new-migrate.sh @@ -46,12 +46,18 @@ fi script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" repo_root="$(cd "$script_dir/../.." && pwd -P)" installer="$repo_root/packages/cli/install.sh" +pnpm_version_helper="$script_dir/ensure-pkg-pr-new-pnpm-version.mjs" if [ ! -f "$installer" ]; then echo "error: Vite+ installer not found: $installer" >&2 exit 2 fi +if [ ! -f "$pnpm_version_helper" ]; then + echo "error: pnpm version helper not found: $pnpm_version_helper" >&2 + exit 2 +fi + is_git_repo=0 if command -v git >/dev/null 2>&1 && git -C "$project_dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then is_git_repo=1 @@ -62,6 +68,11 @@ if command -v git >/dev/null 2>&1 && git -C "$project_dir" rev-parse --is-inside fi fi +# pnpm 11.0.0 through 11.8.x can write pkg.pr.new tarball lock entries without +# integrity metadata, which a later frozen install rejects. Upgrade the +# project's package-manager pin before migration resolves or invokes pnpm. +node "$pnpm_version_helper" "$project_dir/package.json" + original_home="$HOME" cache_root="${XDG_CACHE_HOME:-$original_home/.cache}" pr_home="${VP_PKG_PR_NEW_HOME:-$cache_root/vite-plus/pkg-pr-new/$pr_ref}" From d71857e966552822d1abb6137875e8223726537a Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 26 Jun 2026 10:30:08 +0800 Subject: [PATCH 41/78] fix(migrate): preserve Bun config arrays --- .../migration-bunfig-inline-array/bunfig.toml | 3 +++ .../migration-bunfig-inline-array/package.json | 12 ++++++++++++ .../migration-bunfig-inline-array/snap.txt | 10 ++++++++++ .../migration-bunfig-inline-array/steps.json | 6 ++++++ .../migration-bunfig-missing/package.json | 12 ++++++++++++ .../migration-bunfig-missing/snap.txt | 8 ++++++++ .../migration-bunfig-missing/steps.json | 6 ++++++ .../bunfig.toml | 2 ++ .../package.json | 12 ++++++++++++ .../migration-bunfig-no-install-section/snap.txt | 11 +++++++++++ .../migration-bunfig-no-install-section/steps.json | 6 ++++++ packages/cli/src/migration/migrator.ts | 14 ++++++++++---- 12 files changed, 98 insertions(+), 4 deletions(-) create mode 100644 packages/cli/snap-tests-global/migration-bunfig-inline-array/bunfig.toml create mode 100644 packages/cli/snap-tests-global/migration-bunfig-inline-array/package.json create mode 100644 packages/cli/snap-tests-global/migration-bunfig-inline-array/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-bunfig-inline-array/steps.json create mode 100644 packages/cli/snap-tests-global/migration-bunfig-missing/package.json create mode 100644 packages/cli/snap-tests-global/migration-bunfig-missing/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-bunfig-missing/steps.json create mode 100644 packages/cli/snap-tests-global/migration-bunfig-no-install-section/bunfig.toml create mode 100644 packages/cli/snap-tests-global/migration-bunfig-no-install-section/package.json create mode 100644 packages/cli/snap-tests-global/migration-bunfig-no-install-section/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-bunfig-no-install-section/steps.json diff --git a/packages/cli/snap-tests-global/migration-bunfig-inline-array/bunfig.toml b/packages/cli/snap-tests-global/migration-bunfig-inline-array/bunfig.toml new file mode 100644 index 0000000000..c8f7560e2d --- /dev/null +++ b/packages/cli/snap-tests-global/migration-bunfig-inline-array/bunfig.toml @@ -0,0 +1,3 @@ +[install] +minimumReleaseAge = 259200 +minimumReleaseAgeExcludes = ["@zerobyte/*"] diff --git a/packages/cli/snap-tests-global/migration-bunfig-inline-array/package.json b/packages/cli/snap-tests-global/migration-bunfig-inline-array/package.json new file mode 100644 index 0000000000..586a905041 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-bunfig-inline-array/package.json @@ -0,0 +1,12 @@ +{ + "name": "migration-bunfig-inline-array", + "private": true, + "scripts": { + "test": "vitest" + }, + "devDependencies": { + "vite": "^8.0.16", + "vitest": "^4.1.8" + }, + "packageManager": "bun@1.3.14" +} diff --git a/packages/cli/snap-tests-global/migration-bunfig-inline-array/snap.txt b/packages/cli/snap-tests-global/migration-bunfig-inline-array/snap.txt new file mode 100644 index 0000000000..6265319ed5 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-bunfig-inline-array/snap.txt @@ -0,0 +1,10 @@ +> vp migrate --no-interactive # migration preserves inline arrays in an existing bunfig.toml +◇ Migrated . to Vite+ +• Node bun +• 2 config updates applied + +> cat bunfig.toml # check peer suppression is added without corrupting inline arrays +[install] +peer = false +minimumReleaseAge = 259200 +minimumReleaseAgeExcludes = ["@zerobyte/*"] diff --git a/packages/cli/snap-tests-global/migration-bunfig-inline-array/steps.json b/packages/cli/snap-tests-global/migration-bunfig-inline-array/steps.json new file mode 100644 index 0000000000..b45dc0f8f7 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-bunfig-inline-array/steps.json @@ -0,0 +1,6 @@ +{ + "commands": [ + "vp migrate --no-interactive # migration preserves inline arrays in an existing bunfig.toml", + "cat bunfig.toml # check peer suppression is added without corrupting inline arrays" + ] +} diff --git a/packages/cli/snap-tests-global/migration-bunfig-missing/package.json b/packages/cli/snap-tests-global/migration-bunfig-missing/package.json new file mode 100644 index 0000000000..5b1fe21b37 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-bunfig-missing/package.json @@ -0,0 +1,12 @@ +{ + "name": "migration-bunfig-missing", + "private": true, + "scripts": { + "test": "vitest" + }, + "devDependencies": { + "vite": "^8.0.16", + "vitest": "^4.1.8" + }, + "packageManager": "bun@1.3.14" +} diff --git a/packages/cli/snap-tests-global/migration-bunfig-missing/snap.txt b/packages/cli/snap-tests-global/migration-bunfig-missing/snap.txt new file mode 100644 index 0000000000..1c8cd2cead --- /dev/null +++ b/packages/cli/snap-tests-global/migration-bunfig-missing/snap.txt @@ -0,0 +1,8 @@ +> vp migrate --no-interactive # migration creates a missing bunfig.toml +◇ Migrated . to Vite+ +• Node bun +• 2 config updates applied + +> cat bunfig.toml # check the install section and peer suppression are created +[install] +peer = false diff --git a/packages/cli/snap-tests-global/migration-bunfig-missing/steps.json b/packages/cli/snap-tests-global/migration-bunfig-missing/steps.json new file mode 100644 index 0000000000..e7a5cf67e4 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-bunfig-missing/steps.json @@ -0,0 +1,6 @@ +{ + "commands": [ + "vp migrate --no-interactive # migration creates a missing bunfig.toml", + "cat bunfig.toml # check the install section and peer suppression are created" + ] +} diff --git a/packages/cli/snap-tests-global/migration-bunfig-no-install-section/bunfig.toml b/packages/cli/snap-tests-global/migration-bunfig-no-install-section/bunfig.toml new file mode 100644 index 0000000000..74a344dc24 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-bunfig-no-install-section/bunfig.toml @@ -0,0 +1,2 @@ +[run] +shell = "bun" diff --git a/packages/cli/snap-tests-global/migration-bunfig-no-install-section/package.json b/packages/cli/snap-tests-global/migration-bunfig-no-install-section/package.json new file mode 100644 index 0000000000..bbb48759f4 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-bunfig-no-install-section/package.json @@ -0,0 +1,12 @@ +{ + "name": "migration-bunfig-no-install-section", + "private": true, + "scripts": { + "test": "vitest" + }, + "devDependencies": { + "vite": "^8.0.16", + "vitest": "^4.1.8" + }, + "packageManager": "bun@1.3.14" +} diff --git a/packages/cli/snap-tests-global/migration-bunfig-no-install-section/snap.txt b/packages/cli/snap-tests-global/migration-bunfig-no-install-section/snap.txt new file mode 100644 index 0000000000..d902e983e1 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-bunfig-no-install-section/snap.txt @@ -0,0 +1,11 @@ +> vp migrate --no-interactive # migration adds a missing install section +◇ Migrated . to Vite+ +• Node bun +• 2 config updates applied + +> cat bunfig.toml # check the existing config and new peer suppression are preserved +[run] +shell = "bun" + +[install] +peer = false diff --git a/packages/cli/snap-tests-global/migration-bunfig-no-install-section/steps.json b/packages/cli/snap-tests-global/migration-bunfig-no-install-section/steps.json new file mode 100644 index 0000000000..170f140c81 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-bunfig-no-install-section/steps.json @@ -0,0 +1,6 @@ +{ + "commands": [ + "vp migrate --no-interactive # migration adds a missing install section", + "cat bunfig.toml # check the existing config and new peer suppression are preserved" + ] +} diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 593789eb95..6e92d337b1 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -3345,10 +3345,16 @@ function ensureBunfigPeerSuppression(projectPath: string): void { if (/^\s*peer\s*=\s*(true|false)\s*$/m.test(existing)) { return; } - // Append under existing [install] section, or add a new section. - const installSectionRe = /^\[install\][^[]*/m; - const next = installSectionRe.test(existing) - ? existing.replace(installSectionRe, (section) => `${section.trimEnd()}\npeer = false\n`) + // Insert directly after the existing [install] header. Scanning for the next + // `[` is unsafe because array values such as `minimumReleaseAgeExcludes = []` + // also contain that character. + const installSectionHeaderRe = /^(\s*\[install\][^\S\r\n]*(?:#[^\r\n]*)?)(\r?\n|$)/m; + const next = installSectionHeaderRe.test(existing) + ? existing.replace( + installSectionHeaderRe, + (_match, header: string, newline: string) => + `${header}${newline || '\n'}peer = false${newline || '\n'}`, + ) : `${existing.trimEnd()}\n\n${block}`; fs.writeFileSync(bunfigPath, next); } From b579b819a247a15e545c2685d810c2d73534552e Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 26 Jun 2026 12:22:49 +0800 Subject: [PATCH 42/78] fix(migrate): avoid Bun peer suppression --- .github/scripts/test-pkg-pr-new-migrate.sh | 19 ++++++++ docs/guide/migrate-rules.md | 2 - .../migration-bunfig-inline-array/snap.txt | 3 +- .../migration-bunfig-inline-array/steps.json | 2 +- .../migration-bunfig-missing/snap.txt | 6 +-- .../migration-bunfig-missing/steps.json | 4 +- .../snap.txt | 7 +-- .../steps.json | 4 +- .../bun-catalog-file-protocol.spec.ts | 16 ++----- packages/cli/src/migration/migrator.ts | 46 ------------------- 10 files changed, 33 insertions(+), 76 deletions(-) diff --git a/.github/scripts/test-pkg-pr-new-migrate.sh b/.github/scripts/test-pkg-pr-new-migrate.sh index 7a469d5b51..2072850f9e 100755 --- a/.github/scripts/test-pkg-pr-new-migrate.sh +++ b/.github/scripts/test-pkg-pr-new-migrate.sh @@ -6,6 +6,9 @@ usage() { cat <<'EOF' Usage: .github/scripts/test-pkg-pr-new-migrate.sh [migrate-options...] +This helper does not support Bun projects because pkg.pr.new URL artifacts +cannot preserve npm alias semantics for vite-plus-core. + Examples: .github/scripts/test-pkg-pr-new-migrate.sh 1891 /path/to/npmx.dev .github/scripts/test-pkg-pr-new-migrate.sh 4eb2104c /path/to/project --no-interactive @@ -43,6 +46,22 @@ if [ ! -f "$project_dir/package.json" ]; then exit 2 fi +if [ -f "$project_dir/bun.lock" ] || + [ -f "$project_dir/bun.lockb" ] || + [ -f "$project_dir/bunfig.toml" ] || + node -e ' + const pkg = require(process.argv[1]); + const packageManager = + typeof pkg.packageManager === "string" ? pkg.packageManager.split("@")[0] : undefined; + const devEngine = pkg.devEngines?.packageManager; + const devEngineName = typeof devEngine === "string" ? devEngine : devEngine?.name; + process.exit(packageManager === "bun" || devEngineName === "bun" ? 0 : 1); + ' "$project_dir/package.json"; then + echo "error: Bun projects are not supported by test-pkg-pr-new-migrate.sh." >&2 + echo "pkg.pr.new URL artifacts cannot represent the npm alias used for published vite-plus-core packages." >&2 + exit 2 +fi + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" repo_root="$(cd "$script_dir/../.." && pwd -P)" installer="$repo_root/packages/cli/install.sh" diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 72b04b9009..8c5e71e9a1 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -195,8 +195,6 @@ lint autofix preserves these imports. references. - Mirror the core alias as a direct `vite` dependency so Bun sees the peer provider before applying overrides. -- Configure missing-peer suppression in `bunfig.toml` when needed, but do not - overwrite an explicit user `peer` setting. After updating the manifests and package-manager configuration, migration reinstalls dependencies once to refresh the lockfile. If installation fails, diff --git a/packages/cli/snap-tests-global/migration-bunfig-inline-array/snap.txt b/packages/cli/snap-tests-global/migration-bunfig-inline-array/snap.txt index 6265319ed5..6dd611b66b 100644 --- a/packages/cli/snap-tests-global/migration-bunfig-inline-array/snap.txt +++ b/packages/cli/snap-tests-global/migration-bunfig-inline-array/snap.txt @@ -3,8 +3,7 @@ • Node bun • 2 config updates applied -> cat bunfig.toml # check peer suppression is added without corrupting inline arrays +> cat bunfig.toml # check Bun configuration is unchanged [install] -peer = false minimumReleaseAge = 259200 minimumReleaseAgeExcludes = ["@zerobyte/*"] diff --git a/packages/cli/snap-tests-global/migration-bunfig-inline-array/steps.json b/packages/cli/snap-tests-global/migration-bunfig-inline-array/steps.json index b45dc0f8f7..f0b62921ef 100644 --- a/packages/cli/snap-tests-global/migration-bunfig-inline-array/steps.json +++ b/packages/cli/snap-tests-global/migration-bunfig-inline-array/steps.json @@ -1,6 +1,6 @@ { "commands": [ "vp migrate --no-interactive # migration preserves inline arrays in an existing bunfig.toml", - "cat bunfig.toml # check peer suppression is added without corrupting inline arrays" + "cat bunfig.toml # check Bun configuration is unchanged" ] } diff --git a/packages/cli/snap-tests-global/migration-bunfig-missing/snap.txt b/packages/cli/snap-tests-global/migration-bunfig-missing/snap.txt index 1c8cd2cead..b1e0633677 100644 --- a/packages/cli/snap-tests-global/migration-bunfig-missing/snap.txt +++ b/packages/cli/snap-tests-global/migration-bunfig-missing/snap.txt @@ -1,8 +1,6 @@ -> vp migrate --no-interactive # migration creates a missing bunfig.toml +> vp migrate --no-interactive # migration does not create bunfig.toml ◇ Migrated . to Vite+ • Node bun • 2 config updates applied -> cat bunfig.toml # check the install section and peer suppression are created -[install] -peer = false +> test ! -f bunfig.toml # check Bun configuration remains absent \ No newline at end of file diff --git a/packages/cli/snap-tests-global/migration-bunfig-missing/steps.json b/packages/cli/snap-tests-global/migration-bunfig-missing/steps.json index e7a5cf67e4..d4580d8a44 100644 --- a/packages/cli/snap-tests-global/migration-bunfig-missing/steps.json +++ b/packages/cli/snap-tests-global/migration-bunfig-missing/steps.json @@ -1,6 +1,6 @@ { "commands": [ - "vp migrate --no-interactive # migration creates a missing bunfig.toml", - "cat bunfig.toml # check the install section and peer suppression are created" + "vp migrate --no-interactive # migration does not create bunfig.toml", + "test ! -f bunfig.toml # check Bun configuration remains absent" ] } diff --git a/packages/cli/snap-tests-global/migration-bunfig-no-install-section/snap.txt b/packages/cli/snap-tests-global/migration-bunfig-no-install-section/snap.txt index d902e983e1..a54d454753 100644 --- a/packages/cli/snap-tests-global/migration-bunfig-no-install-section/snap.txt +++ b/packages/cli/snap-tests-global/migration-bunfig-no-install-section/snap.txt @@ -1,11 +1,8 @@ -> vp migrate --no-interactive # migration adds a missing install section +> vp migrate --no-interactive # migration preserves bunfig.toml without an install section ◇ Migrated . to Vite+ • Node bun • 2 config updates applied -> cat bunfig.toml # check the existing config and new peer suppression are preserved +> cat bunfig.toml # check Bun configuration is unchanged [run] shell = "bun" - -[install] -peer = false diff --git a/packages/cli/snap-tests-global/migration-bunfig-no-install-section/steps.json b/packages/cli/snap-tests-global/migration-bunfig-no-install-section/steps.json index 170f140c81..af78bbf93c 100644 --- a/packages/cli/snap-tests-global/migration-bunfig-no-install-section/steps.json +++ b/packages/cli/snap-tests-global/migration-bunfig-no-install-section/steps.json @@ -1,6 +1,6 @@ { "commands": [ - "vp migrate --no-interactive # migration adds a missing install section", - "cat bunfig.toml # check the existing config and new peer suppression are preserved" + "vp migrate --no-interactive # migration preserves bunfig.toml without an install section", + "cat bunfig.toml # check Bun configuration is unchanged" ] } 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 0594907345..93cd6eae33 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 @@ -123,13 +123,7 @@ describe('rewriteMonorepo bun catalog with file: protocol', () => { expect(pkg.devDependencies.vite).toBe('file:/tmp/tgz/voidzero-dev-vite-plus-core-0.0.0.tgz'); }); - it('writes bunfig.toml with `peer = false` so vitest peer-dep on vite does not break install', () => { - // vitest@4.1.9 declares peer vite^6/^7/^8. With overrides.vite pointing at - // file:vite-plus-core@0.0.0 (whose package.json version does not match), - // bun aborts the install. pnpm/yarn/npm tolerate this; bun has no equivalent - // to pnpm's peerDependencyRules and only respects the `[install] peer = false` - // setting in bunfig.toml. The migrator must emit that file or every bun - // user hits `error: vite@^6.0.0 || ^7.0.0 || ^8.0.0 failed to resolve`. + it('does not create bunfig.toml', () => { fs.writeFileSync( path.join(tmpDir, 'package.json'), JSON.stringify({ @@ -140,9 +134,7 @@ describe('rewriteMonorepo bun catalog with file: protocol', () => { ); rewriteMonorepo(makeWorkspaceInfo(tmpDir, PackageManager.bun), true); - const bunfigPath = path.join(tmpDir, 'bunfig.toml'); - expect(fs.existsSync(bunfigPath)).toBe(true); - expect(fs.readFileSync(bunfigPath, 'utf8')).toMatch(/^\[install\][\s\S]*peer\s*=\s*false/m); + expect(fs.existsSync(path.join(tmpDir, 'bunfig.toml'))).toBe(false); }); it('preserves an existing bunfig.toml `peer` setting (does not overwrite user intent)', () => { @@ -161,7 +153,7 @@ describe('rewriteMonorepo bun catalog with file: protocol', () => { expect(fs.readFileSync(path.join(tmpDir, 'bunfig.toml'), 'utf8')).toMatch(/peer\s*=\s*true/); }); - it('appends `peer = false` under an existing [install] section without `peer` setting', () => { + it('preserves an existing bunfig.toml without adding a peer setting', () => { fs.writeFileSync( path.join(tmpDir, 'package.json'), JSON.stringify({ @@ -178,7 +170,7 @@ describe('rewriteMonorepo bun catalog with file: protocol', () => { const bunfig = fs.readFileSync(path.join(tmpDir, 'bunfig.toml'), 'utf8'); expect(bunfig).toMatch(/registry\s*=\s*"https:\/\/registry\.npmjs\.org\/"/); - expect(bunfig).toMatch(/peer\s*=\s*false/); + expect(bunfig).not.toMatch(/peer\s*=/); }); it('does not write file: paths into peer dependencies', () => { diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 6e92d337b1..3dd253c15b 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -2031,8 +2031,6 @@ export function rewriteStandaloneProject( if (packageManager === PackageManager.yarn) { rewriteYarnrcYml(projectPath, usesVitest, vitestEcosystemPackages); - } else if (packageManager === PackageManager.bun) { - ensureBunfigPeerSuppression(projectPath); } // Merge extracted staged config into vite.config.ts, then remove lint-staged from package.json @@ -3317,48 +3315,6 @@ function rewriteCatalogsObject( } } -/** - * Bun rejects vitest@4.1.9's `vite^6/^7/^8` peer-dep when the user's project - * overrides `vite` to `@voidzero-dev/vite-plus-core` (whose package.json version - * does not match those ranges). pnpm/yarn/npm all tolerate this redirect; bun - * does not, and there is no `peerDependencyRules`-style escape hatch — only the - * `[install] peer = false` setting in `bunfig.toml`. - * - * `vite-plus`/`@voidzero-dev/vite-plus-core` already provide the vite surface - * the user needs, so disabling bun's auto-install of *missing* peers is safe in - * this configuration: any vitest peer that's not already pulled in transitively - * (jsdom, happy-dom, etc.) is marked optional upstream anyway. - * - * Writes/merges `bunfig.toml` at `projectPath` so the suppression applies on - * the migration's reinstall AND every subsequent `bun install` the user runs. - */ -function ensureBunfigPeerSuppression(projectPath: string): void { - const bunfigPath = path.join(projectPath, 'bunfig.toml'); - const block = '[install]\npeer = false\n'; - if (!fs.existsSync(bunfigPath)) { - fs.writeFileSync(bunfigPath, block); - return; - } - const existing = fs.readFileSync(bunfigPath, 'utf8'); - // Already configured? Leave the user's setting alone — they may have set - // `peer = true` deliberately for some other reason and we shouldn't override. - if (/^\s*peer\s*=\s*(true|false)\s*$/m.test(existing)) { - return; - } - // Insert directly after the existing [install] header. Scanning for the next - // `[` is unsafe because array values such as `minimumReleaseAgeExcludes = []` - // also contain that character. - const installSectionHeaderRe = /^(\s*\[install\][^\S\r\n]*(?:#[^\r\n]*)?)(\r?\n|$)/m; - const next = installSectionHeaderRe.test(existing) - ? existing.replace( - installSectionHeaderRe, - (_match, header: string, newline: string) => - `${header}${newline || '\n'}peer = false${newline || '\n'}`, - ) - : `${existing.trimEnd()}\n\n${block}`; - fs.writeFileSync(bunfigPath, next); -} - /** * Write catalog entries to root package.json for bun. * Bun stores catalogs in package.json under the `catalog` key, @@ -3444,8 +3400,6 @@ function rewriteBunCatalog( return pkg; }); - - ensureBunfigPeerSuppression(projectPath); } /** From 2a9225a9dd4b45e256a7eae082249e39aba4a704 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 26 Jun 2026 14:09:15 +0800 Subject: [PATCH 43/78] fix(ci): support Bun pkg.pr.new migration tests --- .github/scripts/bun-pkg-pr-new.mjs | 139 +++++++++++++++++++++ .github/scripts/repack-vite-pr.sh | 47 +++++++ .github/scripts/test-pkg-pr-new-migrate.sh | 82 +++++++++--- 3 files changed, 251 insertions(+), 17 deletions(-) create mode 100755 .github/scripts/bun-pkg-pr-new.mjs create mode 100755 .github/scripts/repack-vite-pr.sh diff --git a/.github/scripts/bun-pkg-pr-new.mjs b/.github/scripts/bun-pkg-pr-new.mjs new file mode 100755 index 0000000000..11210029ff --- /dev/null +++ b/.github/scripts/bun-pkg-pr-new.mjs @@ -0,0 +1,139 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; + +function usage() { + console.error(`Usage: + bun-pkg-pr-new.mjs is-bun-project + bun-pkg-pr-new.mjs patch-package + bun-pkg-pr-new.mjs add-core-dependency + bun-pkg-pr-new.mjs normalize-vite-paths `); + process.exit(2); +} + +function readPackageJson(packageJsonPath) { + const text = fs.readFileSync(packageJsonPath, 'utf8'); + return { + indent: text.match(/\n([\t ]+)"/)?.[1] ?? ' ', + pkg: JSON.parse(text), + }; +} + +function writePackageJson(packageJsonPath, pkg, indent) { + fs.writeFileSync(packageJsonPath, `${JSON.stringify(pkg, null, indent)}\n`); +} + +function isBunProject(packageJsonPath) { + const { pkg } = readPackageJson(packageJsonPath); + const packageManager = + typeof pkg.packageManager === 'string' ? pkg.packageManager.split('@')[0] : undefined; + const devEngine = pkg.devEngines?.packageManager; + const devEngineName = typeof devEngine === 'string' ? devEngine : devEngine?.name; + process.exit(packageManager === 'bun' || devEngineName === 'bun' ? 0 : 1); +} + +function patchPackage(packageJsonPath, coreUrl, vitePlusUrl) { + const { pkg } = readPackageJson(packageJsonPath); + const bundledViteVersion = pkg.bundledVersions?.vite; + + pkg.name = 'vite'; + pkg.version = + typeof bundledViteVersion === 'string' && bundledViteVersion.length > 0 + ? bundledViteVersion + : '8.0.0'; + pkg.dependencies = { + ...pkg.dependencies, + '@voidzero-dev/vite-plus-core': coreUrl, + 'vite-plus': vitePlusUrl, + }; + + writePackageJson(packageJsonPath, pkg, ' '); +} + +function addCoreDependency(packageJsonPath, coreSpec) { + const { indent, pkg } = readPackageJson(packageJsonPath); + pkg.devDependencies ??= {}; + pkg.devDependencies['@voidzero-dev/vite-plus-core'] = coreSpec; + writePackageJson(packageJsonPath, pkg, indent); +} + +function normalizeVitePaths(projectDir, tarballPath) { + const absoluteSpec = `file:${tarballPath}`; + const skippedDirectories = new Set([ + '.git', + '.output', + 'build', + 'dist', + 'node_modules', + 'vendor', + ]); + + function rewriteValue(value, relativeSpec) { + if (value === absoluteSpec) { + return relativeSpec; + } + if (Array.isArray(value)) { + return value.map((item) => rewriteValue(item, relativeSpec)); + } + if (value && typeof value === 'object') { + for (const [key, child] of Object.entries(value)) { + value[key] = rewriteValue(child, relativeSpec); + } + } + return value; + } + + function visit(directory) { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + if (!skippedDirectories.has(entry.name)) { + visit(entryPath); + } + continue; + } + if (!entry.isFile() || entry.name !== 'package.json') { + continue; + } + + const text = fs.readFileSync(entryPath, 'utf8'); + if (!text.includes(absoluteSpec)) { + continue; + } + const relativePath = path + .relative(path.dirname(entryPath), tarballPath) + .split(path.sep) + .join('/'); + const relativeSpec = `file:${relativePath.startsWith('.') ? relativePath : `./${relativePath}`}`; + const pkg = rewriteValue(JSON.parse(text), relativeSpec); + const indent = text.match(/\n([\t ]+)"/)?.[1] ?? ' '; + writePackageJson(entryPath, pkg, indent); + } + } + + visit(projectDir); +} + +const [command, ...args] = process.argv.slice(2); + +switch (command) { + case 'is-bun-project': + if (args.length !== 1) usage(); + isBunProject(...args); + break; + case 'patch-package': + if (args.length !== 3) usage(); + patchPackage(...args); + break; + case 'add-core-dependency': + if (args.length !== 2) usage(); + addCoreDependency(...args); + break; + case 'normalize-vite-paths': + if (args.length !== 2) usage(); + normalizeVitePaths(...args); + break; + default: + usage(); +} diff --git a/.github/scripts/repack-vite-pr.sh b/.github/scripts/repack-vite-pr.sh new file mode 100755 index 0000000000..2e9faa3d80 --- /dev/null +++ b/.github/scripts/repack-vite-pr.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash + +set -euo pipefail + +pr_ref="${1:-1891}" +project_input="${2:-$PWD}" + +case "$pr_ref" in + '' | *[![:alnum:]._-]*) + echo "error: PR or commit contains unsupported characters: $pr_ref" >&2 + exit 2 + ;; +esac + +if [ ! -d "$project_input" ]; then + echo "error: project directory does not exist: $project_input" >&2 + exit 2 +fi + +project_dir="$(cd "$project_input" && pwd -P)" +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +output_path="$project_dir/vendor/vite-plus-core-as-vite-$pr_ref.tgz" +core_url="https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@$pr_ref" +vite_plus_url="https://pkg.pr.new/voidzero-dev/vite-plus/vite-plus@$pr_ref" +tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/vite-plus-core-as-vite.XXXXXX")" + +cleanup() { + rm -rf "$tmp_dir" +} +trap cleanup EXIT + +curl -fsSL "$core_url" -o "$tmp_dir/vite-plus-core.tgz" +mkdir -p "$tmp_dir/unpacked" +tar -xzf "$tmp_dir/vite-plus-core.tgz" -C "$tmp_dir/unpacked" + +package_json="$tmp_dir/unpacked/package/package.json" +if [ ! -f "$package_json" ]; then + echo "error: downloaded package does not contain package/package.json" >&2 + exit 1 +fi + +node "$script_dir/bun-pkg-pr-new.mjs" patch-package "$package_json" "$core_url" "$vite_plus_url" + +mkdir -p "$(dirname "$output_path")" +tar -czf "$output_path" -C "$tmp_dir/unpacked" package + +printf '%s\n' "$output_path" diff --git a/.github/scripts/test-pkg-pr-new-migrate.sh b/.github/scripts/test-pkg-pr-new-migrate.sh index 2072850f9e..996846f91b 100755 --- a/.github/scripts/test-pkg-pr-new-migrate.sh +++ b/.github/scripts/test-pkg-pr-new-migrate.sh @@ -6,9 +6,6 @@ usage() { cat <<'EOF' Usage: .github/scripts/test-pkg-pr-new-migrate.sh [migrate-options...] -This helper does not support Bun projects because pkg.pr.new URL artifacts -cannot preserve npm alias semantics for vite-plus-core. - Examples: .github/scripts/test-pkg-pr-new-migrate.sh 1891 /path/to/npmx.dev .github/scripts/test-pkg-pr-new-migrate.sh 4eb2104c /path/to/project --no-interactive @@ -46,23 +43,16 @@ if [ ! -f "$project_dir/package.json" ]; then exit 2 fi +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" + +is_bun_project=0 if [ -f "$project_dir/bun.lock" ] || [ -f "$project_dir/bun.lockb" ] || [ -f "$project_dir/bunfig.toml" ] || - node -e ' - const pkg = require(process.argv[1]); - const packageManager = - typeof pkg.packageManager === "string" ? pkg.packageManager.split("@")[0] : undefined; - const devEngine = pkg.devEngines?.packageManager; - const devEngineName = typeof devEngine === "string" ? devEngine : devEngine?.name; - process.exit(packageManager === "bun" || devEngineName === "bun" ? 0 : 1); - ' "$project_dir/package.json"; then - echo "error: Bun projects are not supported by test-pkg-pr-new-migrate.sh." >&2 - echo "pkg.pr.new URL artifacts cannot represent the npm alias used for published vite-plus-core packages." >&2 - exit 2 + node "$script_dir/bun-pkg-pr-new.mjs" is-bun-project "$project_dir/package.json"; then + is_bun_project=1 fi -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" repo_root="$(cd "$script_dir/../.." && pwd -P)" installer="$repo_root/packages/cli/install.sh" pnpm_version_helper="$script_dir/ensure-pkg-pr-new-pnpm-version.mjs" @@ -134,6 +124,32 @@ global_cli_entry="$pr_home/current/node_modules/vite-plus/dist/bin.js" commit_marker="$cached_version_dir/.pkg-pr-new-commit" vite_plus_spec="$pkg_pr_new_base@$resolved_ref" vite_plus_core_spec="$pkg_pr_new_base/@voidzero-dev/vite-plus-core@$resolved_ref" +vite_override_spec="$vite_plus_core_spec" + +if [ "$is_bun_project" -eq 1 ]; then + bun_repack_script="$script_dir/repack-vite-pr.sh" + if [ ! -f "$bun_repack_script" ]; then + echo "error: Bun pkg.pr.new repack helper not found: $bun_repack_script" >&2 + exit 2 + fi + + generated_tarball_path="$(bash "$bun_repack_script" "$resolved_ref" "$project_dir")" + if [ ! -f "$generated_tarball_path" ]; then + echo "error: Bun repack script did not create its reported tarball: $generated_tarball_path" >&2 + exit 1 + fi + + # Keep the real Core package directly resolvable alongside the repacked + # `vite` alias. Bun otherwise nests it under the local tarball dependency. + node "$script_dir/bun-pkg-pr-new.mjs" \ + add-core-dependency \ + "$project_dir/package.json" \ + "$vite_plus_core_spec" + + # The migrator applies this override to every workspace package. Use an + # absolute file URL so nested package.json files resolve the same tarball. + vite_override_spec="file:$generated_tarball_path" +fi read_installed_commit() { if [ -f "$commit_marker" ]; then @@ -229,7 +245,7 @@ export PATH="$VP_HOME/bin:$PATH" export VP_VERSION="$vite_plus_spec" export VP_OVERRIDE_PACKAGES="$(printf \ '{"vite":"%s","vitest":"%s"}' \ - "$vite_plus_core_spec" \ + "$vite_override_spec" \ "$vitest_version")" export VP_FORCE_MIGRATE=1 # pkg.pr.new packages depend on URL-resolved platform binaries. pnpm blocks @@ -246,9 +262,15 @@ echo " resolved commit: $resolved_ref" echo " executable: $vp_bin" echo " installation: $(readlink "$pr_home/current" 2>/dev/null || echo unknown)" echo " vite-plus spec: $VP_VERSION" -echo " vite spec: $vite_plus_core_spec" +echo " vite spec: $vite_override_spec" "$vp_bin" --version +if [ "$is_bun_project" -eq 1 ] && [ -d "$project_dir/node_modules" ]; then + echo + echo "Removing stale Bun node_modules before migration" + rm -rf "$project_dir/node_modules" +fi + echo echo "Running vp migrate in $project_dir" set +e @@ -262,6 +284,32 @@ set +e migrate_status=$? set -e +if [ "$is_bun_project" -eq 1 ] && [ "$migrate_status" -eq 0 ]; then + # Migration uses one absolute file URL so every workspace can install the + # same tarball. Persist portable specs by rebasing that URL relative to each + # package.json, then refresh Bun's lockfile once with the final paths. + node "$script_dir/bun-pkg-pr-new.mjs" \ + normalize-vite-paths \ + "$project_dir" \ + "$generated_tarball_path" + + echo + echo "Reinstalling Bun dependencies with relative Vite tarball paths" + rm -rf "$project_dir/node_modules" + set +e + ( + cd "$project_dir" + unset VP_OVERRIDE_PACKAGES VP_FORCE_MIGRATE + "$vp_bin" install + ) + bun_install_status=$? + set -e + if [ "$bun_install_status" -ne 0 ]; then + echo "error: dependency installation failed after normalizing Bun file paths" >&2 + migrate_status="$bun_install_status" + fi +fi + if [ "$is_git_repo" -eq 1 ]; then echo echo "Migration worktree changes:" From aa5a0c7b415eefc562c4de0aee87ad5f4cd95c4b Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 26 Jun 2026 14:39:16 +0800 Subject: [PATCH 44/78] fix(ci): satisfy lint in Bun pkg helper --- .github/scripts/bun-pkg-pr-new.mjs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/scripts/bun-pkg-pr-new.mjs b/.github/scripts/bun-pkg-pr-new.mjs index 11210029ff..8ebda89b26 100755 --- a/.github/scripts/bun-pkg-pr-new.mjs +++ b/.github/scripts/bun-pkg-pr-new.mjs @@ -119,19 +119,27 @@ const [command, ...args] = process.argv.slice(2); switch (command) { case 'is-bun-project': - if (args.length !== 1) usage(); + if (args.length !== 1) { + usage(); + } isBunProject(...args); break; case 'patch-package': - if (args.length !== 3) usage(); + if (args.length !== 3) { + usage(); + } patchPackage(...args); break; case 'add-core-dependency': - if (args.length !== 2) usage(); + if (args.length !== 2) { + usage(); + } addCoreDependency(...args); break; case 'normalize-vite-paths': - if (args.length !== 2) usage(); + if (args.length !== 2) { + usage(); + } normalizeVitePaths(...args); break; default: From 3ff95bbc60551d967aea0ee6fe6266994e9bc010 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 26 Jun 2026 14:39:29 +0800 Subject: [PATCH 45/78] fix(migrate): rewrite tools invoked through bunx --- crates/vite_migration/src/eslint.rs | 16 ++ crates/vite_migration/src/package.rs | 58 +++- crates/vite_migration/src/prettier.rs | 18 +- crates/vite_migration/src/script_rewrite.rs | 249 ++++++++++++++++-- docs/guide/migrate-rules.md | 21 ++ .../__snapshots__/migrator.spec.ts.snap | 11 + .../src/migration/__tests__/migrator.spec.ts | 11 + rfcs/migration-command.md | 60 +++-- 8 files changed, 395 insertions(+), 49 deletions(-) diff --git a/crates/vite_migration/src/eslint.rs b/crates/vite_migration/src/eslint.rs index 8756f6bdf8..2dfb8d96c2 100644 --- a/crates/vite_migration/src/eslint.rs +++ b/crates/vite_migration/src/eslint.rs @@ -156,4 +156,20 @@ mod tests { "cross-env NODE_ENV=test CI=true vp lint ." ); } + + #[test] + fn test_rewrite_eslint_bunx() { + assert_eq!( + rewrite_eslint_script("bunx --bun eslint --cache --fix ."), + "bunx --bun vp lint --fix ." + ); + assert_eq!( + rewrite_eslint_script("dotenv -e .env -- bunx --bun eslint --ext .ts ."), + "dotenv -e .env -- bunx --bun vp lint ." + ); + assert_eq!( + rewrite_eslint_script("bunx --bun eslint-plugin-foo"), + "bunx --bun eslint-plugin-foo" + ); + } } diff --git a/crates/vite_migration/src/package.rs b/crates/vite_migration/src/package.rs index 0a8b089173..98e1fe4517 100644 --- a/crates/vite_migration/src/package.rs +++ b/crates/vite_migration/src/package.rs @@ -3,7 +3,10 @@ use ast_grep_language::SupportLang; use serde_json::{Map, Value}; use vite_error::Error; -use crate::{ast_grep, eslint::rewrite_eslint_script, prettier::rewrite_prettier_script}; +use crate::{ + ast_grep, eslint::rewrite_eslint_script, prettier::rewrite_prettier_script, + script_rewrite::rewrite_bunx_commands, +}; // Marker to replace "cross-env " before ast-grep processing // Using a fake env var assignment that won't match our rules @@ -22,8 +25,11 @@ fn rewrite_script(script: &str, rules: &[RuleConfig]) -> String { script.to_string() }; - // Step 2: Process with ast-grep - let result = ast_grep::apply_loaded_rules(&preprocessed, rules); + // Step 2: Rewrite commands behind bunx only when their inner command + // matches an active rule, then process ordinary commands. + let rewritten_bunx = + rewrite_bunx_commands(&preprocessed, |inner| ast_grep::apply_loaded_rules(inner, rules)); + let result = ast_grep::apply_loaded_rules(&rewritten_bunx, rules); // Step 3: Replace cross-env marker back with "cross-env " (only if we replaced it) @@ -172,6 +178,15 @@ rule: regex: '^vitest$' fix: vp test +# lint-staged => vp staged +--- +id: replace-lint-staged +language: bash +rule: + kind: command_name + regex: '^lint-staged$' +fix: vp staged + # tsdown => vp pack --- id: replace-tsdown @@ -276,6 +291,43 @@ fix: vp pack rewrite_script("NODE_ENV=test oxlint --type-aware", &rules), "NODE_ENV=test vp lint --type-aware" ); + // bunx and its flags are preserved while managed commands are rewritten + assert_eq!(rewrite_script("bunx --bun vite build", &rules), "bunx --bun vp build"); + assert_eq!(rewrite_script("bunx --bun vite preview", &rules), "bunx --bun vp preview"); + assert_eq!(rewrite_script("bunx --bun vitest run", &rules), "bunx --bun vp test run"); + assert_eq!( + rewrite_script("bunx --bun oxlint --type-aware", &rules), + "bunx --bun vp lint --type-aware" + ); + assert_eq!( + rewrite_script("bunx --bun oxfmt --check .", &rules), + "bunx --bun vp fmt --check ." + ); + assert_eq!( + rewrite_script("bunx --bun tsdown --watch", &rules), + "bunx --bun vp pack --watch" + ); + assert_eq!(rewrite_script("bunx --bun lint-staged", &rules), "bunx --bun vp staged"); + assert_eq!( + rewrite_script("NODE_ENV=development portless --tailscale run bunx --bun vite", &rules,), + "NODE_ENV=development portless --tailscale run bunx --bun vp dev" + ); + assert_eq!( + rewrite_script("dotenv -e .env.test -- bunx --bun vitest run", &rules), + "dotenv -e .env.test -- bunx --bun vp test run" + ); + // unrelated executor calls and non-launcher arguments stay unchanged + assert_eq!( + rewrite_script("bunx --bun playwright test", &rules), + "bunx --bun playwright test" + ); + assert_eq!(rewrite_script("bunx --bun vp build", &rules), "bunx --bun vp build"); + assert_eq!( + rewrite_script("echo bunx --bun vite build", &rules), + "echo bunx --bun vite build" + ); + assert_eq!(rewrite_script("npx vite build", &rules), "npx vite build"); + assert_eq!(rewrite_script("bun x vite build", &rules), "bun x vite build"); // oxlint commands assert_eq!(rewrite_script("oxlint", &rules), "vp lint"); assert_eq!(rewrite_script("oxlint --type-aware", &rules), "vp lint --type-aware"); diff --git a/crates/vite_migration/src/prettier.rs b/crates/vite_migration/src/prettier.rs index 0651621354..db4ad7b0a1 100644 --- a/crates/vite_migration/src/prettier.rs +++ b/crates/vite_migration/src/prettier.rs @@ -130,7 +130,7 @@ mod tests { "if [ -f .prettierrc ]; then vp fmt .; fi" ); - // npx wrappers unchanged + // non-Bun package executors remain outside this migration rule assert_eq!(rewrite_prettier_script("npx prettier --write ."), "npx prettier --write ."); // already rewritten (no-op) @@ -191,6 +191,22 @@ mod tests { ); } + #[test] + fn test_rewrite_prettier_bunx() { + assert_eq!( + rewrite_prettier_script("bunx --bun prettier --write --single-quote ."), + "bunx --bun vp fmt ." + ); + assert_eq!( + rewrite_prettier_script("dotenv -e .env -- bunx --bun prettier --check ."), + "dotenv -e .env -- bunx --bun vp fmt --check ." + ); + assert_eq!( + rewrite_prettier_script("bunx --bun prettier-plugin-foo"), + "bunx --bun prettier-plugin-foo" + ); + } + #[test] fn test_rewrite_prettier_list_different_to_check() { // --list-different → --check diff --git a/crates/vite_migration/src/script_rewrite.rs b/crates/vite_migration/src/script_rewrite.rs index 7a9110de62..5e391ef174 100644 --- a/crates/vite_migration/src/script_rewrite.rs +++ b/crates/vite_migration/src/script_rewrite.rs @@ -32,6 +32,12 @@ const SHELL_CONTINUATION_KEYWORDS: &[&str] = &["then", "do", "else", "elif", "in /// Rewrite a shell script: find `source_command`, rename to `vp `, /// strip tool-specific flags, and normalize the output. pub fn rewrite_script(script: &str, config: &ScriptRewriteConfig) -> String { + let rewritten_bunx = + rewrite_bunx_commands(script, |inner| rewrite_direct_script(inner, config)); + rewrite_direct_script(&rewritten_bunx, config) +} + +fn rewrite_direct_script(script: &str, config: &ScriptRewriteConfig) -> String { let mut parser = brush_parser::Parser::new( script.as_bytes(), &brush_parser::ParserOptions::default(), @@ -49,42 +55,58 @@ pub fn rewrite_script(script: &str, config: &ScriptRewriteConfig) -> String { } fn rewrite_in_program(program: &mut ast::Program, config: &ScriptRewriteConfig) -> bool { + visit_simple_commands(program, &mut |cmd| rewrite_in_simple_command(cmd, config)) +} + +fn visit_simple_commands( + program: &mut ast::Program, + visitor: &mut impl FnMut(&mut ast::SimpleCommand) -> bool, +) -> bool { let mut changed = false; - for cmd in &mut program.complete_commands { - changed |= rewrite_in_compound_list(cmd, config); + for command in &mut program.complete_commands { + changed |= visit_compound_list(command, visitor); } changed } -fn rewrite_in_compound_list(list: &mut ast::CompoundList, config: &ScriptRewriteConfig) -> bool { +fn visit_compound_list( + list: &mut ast::CompoundList, + visitor: &mut impl FnMut(&mut ast::SimpleCommand) -> bool, +) -> bool { let mut changed = false; for item in &mut list.0 { - changed |= rewrite_in_and_or_list(&mut item.0, config); + changed |= visit_and_or_list(&mut item.0, visitor); } changed } -fn rewrite_in_and_or_list(list: &mut ast::AndOrList, config: &ScriptRewriteConfig) -> bool { - let mut changed = rewrite_in_pipeline(&mut list.first, config); +fn visit_and_or_list( + list: &mut ast::AndOrList, + visitor: &mut impl FnMut(&mut ast::SimpleCommand) -> bool, +) -> bool { + let mut changed = visit_pipeline(&mut list.first, visitor); for and_or in &mut list.additional { match and_or { ast::AndOr::And(p) | ast::AndOr::Or(p) => { - changed |= rewrite_in_pipeline(p, config); + changed |= visit_pipeline(p, visitor); } } } changed } -fn rewrite_in_pipeline(pipeline: &mut ast::Pipeline, config: &ScriptRewriteConfig) -> bool { +fn visit_pipeline( + pipeline: &mut ast::Pipeline, + visitor: &mut impl FnMut(&mut ast::SimpleCommand) -> bool, +) -> bool { let mut changed = false; for cmd in &mut pipeline.seq { match cmd { ast::Command::Simple(simple) => { - changed |= rewrite_in_simple_command(simple, config); + changed |= visitor(simple); } ast::Command::Compound(compound, _redirects) => { - changed |= rewrite_in_compound_command(compound, config); + changed |= visit_compound_command(compound, visitor); } _ => {} } @@ -92,40 +114,40 @@ fn rewrite_in_pipeline(pipeline: &mut ast::Pipeline, config: &ScriptRewriteConfi changed } -fn rewrite_in_compound_command( +fn visit_compound_command( cmd: &mut ast::CompoundCommand, - config: &ScriptRewriteConfig, + visitor: &mut impl FnMut(&mut ast::SimpleCommand) -> bool, ) -> bool { match cmd { - ast::CompoundCommand::BraceGroup(bg) => rewrite_in_compound_list(&mut bg.list, config), - ast::CompoundCommand::Subshell(sub) => rewrite_in_compound_list(&mut sub.list, config), + ast::CompoundCommand::BraceGroup(bg) => visit_compound_list(&mut bg.list, visitor), + ast::CompoundCommand::Subshell(sub) => visit_compound_list(&mut sub.list, visitor), ast::CompoundCommand::IfClause(if_cmd) => { - let mut changed = rewrite_in_compound_list(&mut if_cmd.condition, config); - changed |= rewrite_in_compound_list(&mut if_cmd.then, config); + let mut changed = visit_compound_list(&mut if_cmd.condition, visitor); + changed |= visit_compound_list(&mut if_cmd.then, visitor); if let Some(elses) = &mut if_cmd.elses { for else_clause in elses { if let Some(cond) = &mut else_clause.condition { - changed |= rewrite_in_compound_list(cond, config); + changed |= visit_compound_list(cond, visitor); } - changed |= rewrite_in_compound_list(&mut else_clause.body, config); + changed |= visit_compound_list(&mut else_clause.body, visitor); } } changed } ast::CompoundCommand::WhileClause(wc) | ast::CompoundCommand::UntilClause(wc) => { - let mut changed = rewrite_in_compound_list(&mut wc.0, config); - changed |= rewrite_in_compound_list(&mut wc.1.list, config); + let mut changed = visit_compound_list(&mut wc.0, visitor); + changed |= visit_compound_list(&mut wc.1.list, visitor); changed } - ast::CompoundCommand::ForClause(fc) => rewrite_in_compound_list(&mut fc.body.list, config), + ast::CompoundCommand::ForClause(fc) => visit_compound_list(&mut fc.body.list, visitor), ast::CompoundCommand::ArithmeticForClause(afc) => { - rewrite_in_compound_list(&mut afc.body.list, config) + visit_compound_list(&mut afc.body.list, visitor) } ast::CompoundCommand::CaseClause(cc) => { let mut changed = false; for case_item in &mut cc.cases { if let Some(cmd_list) = &mut case_item.cmd { - changed |= rewrite_in_compound_list(cmd_list, config); + changed |= visit_compound_list(cmd_list, visitor); } } changed @@ -134,6 +156,187 @@ fn rewrite_in_compound_command( } } +#[derive(Clone, Copy)] +enum CommandWordPosition { + Name, + Suffix(usize), +} + +struct CommandWord { + position: CommandWordPosition, + ordinal: usize, + value: String, +} + +struct BunxInvocation { + target_suffix_index: usize, +} + +fn collect_command_words(cmd: &ast::SimpleCommand) -> Vec { + let mut words = Vec::new(); + if let Some(name) = &cmd.word_or_name { + words.push(CommandWord { + position: CommandWordPosition::Name, + ordinal: 0, + value: name.value.clone(), + }); + } + if let Some(suffix) = &cmd.suffix { + for (index, item) in suffix.0.iter().enumerate() { + if let ast::CommandPrefixOrSuffixItem::Word(word) = item { + words.push(CommandWord { + position: CommandWordPosition::Suffix(index), + ordinal: index + 1, + value: word.value.clone(), + }); + } + } + } + words +} + +fn bunx_target(words: &[CommandWord], start: usize) -> Option { + let contiguous = |left: usize, right: usize| { + words.get(left).zip(words.get(right)).is_some_and(|(a, b)| b.ordinal == a.ordinal + 1) + }; + let next = |index: usize| contiguous(index, index + 1).then_some(index + 1); + + if words.get(start)?.value != "bunx" { + return None; + } + let mut target = next(start)?; + + while words.get(target)?.value == "--bun" { + target = next(target)?; + } + Some(target) +} + +fn find_bunx_invocations(cmd: &ast::SimpleCommand) -> Vec { + let words = collect_command_words(cmd); + let mut invocations = Vec::new(); + + for start in 0..words.len() { + let Some(target) = bunx_target(&words, start) else { + continue; + }; + let CommandWordPosition::Suffix(target_suffix_index) = words[target].position else { + continue; + }; + + let allowed_position = match words[start].position { + CommandWordPosition::Name => true, + CommandWordPosition::Suffix(runner_index) => cmd + .suffix + .as_ref() + .and_then(|suffix| runner_index.checked_sub(1).and_then(|i| suffix.0.get(i))) + .is_some_and(|item| { + matches!( + item, + ast::CommandPrefixOrSuffixItem::Word(word) + if matches!(word.value.as_str(), "--" | "run" | "exec") + ) + }), + }; + if allowed_position { + invocations.push(BunxInvocation { target_suffix_index }); + } + } + + invocations +} + +fn parse_single_simple_command(script: &str) -> Option { + let mut parser = brush_parser::Parser::new( + script.as_bytes(), + &brush_parser::ParserOptions::default(), + &brush_parser::SourceInfo::default(), + ); + let mut program = parser.parse_program().ok()?; + if program.complete_commands.len() != 1 { + return None; + } + let mut compound_list = program.complete_commands.pop()?; + if compound_list.0.len() != 1 { + return None; + } + let and_or = compound_list.0.pop()?.0; + if !and_or.additional.is_empty() || and_or.first.seq.len() != 1 { + return None; + } + match and_or.first.seq.into_iter().next()? { + ast::Command::Simple(command) if command.prefix.is_none() => Some(command), + _ => None, + } +} + +fn rewrite_bunx_in_simple_command( + cmd: &mut ast::SimpleCommand, + rewrite_inner: &mut impl FnMut(&str) -> String, +) -> bool { + for invocation in find_bunx_invocations(cmd) { + let Some(suffix) = &cmd.suffix else { + continue; + }; + let Some(ast::CommandPrefixOrSuffixItem::Word(target)) = + suffix.0.get(invocation.target_suffix_index) + else { + continue; + }; + + let inner_command = ast::SimpleCommand { + prefix: None, + word_or_name: Some(target.clone()), + suffix: (invocation.target_suffix_index + 1 < suffix.0.len()).then(|| { + ast::CommandSuffix(suffix.0[invocation.target_suffix_index + 1..].to_vec()) + }), + }; + let original_inner = inner_command.to_string(); + let rewritten_inner = rewrite_inner(&original_inner); + if rewritten_inner == original_inner { + continue; + } + let Some(mut replacement) = parse_single_simple_command(&rewritten_inner) else { + continue; + }; + + let suffix = cmd.suffix.as_mut().expect("executor target is in the suffix"); + let mut replacement_items = Vec::new(); + if let Some(word) = replacement.word_or_name.take() { + replacement_items.push(ast::CommandPrefixOrSuffixItem::Word(word)); + } + if let Some(inner_suffix) = replacement.suffix.take() { + replacement_items.extend(inner_suffix.0); + } + suffix.0.splice(invocation.target_suffix_index.., replacement_items); + return true; + } + false +} + +/// Rewrite commands launched through `bunx`. The runner and its flags are +/// preserved; only an inner command changed by the supplied rewriter is replaced. +pub(crate) fn rewrite_bunx_commands( + script: &str, + mut rewrite_inner: impl FnMut(&str) -> String, +) -> String { + let mut parser = brush_parser::Parser::new( + script.as_bytes(), + &brush_parser::ParserOptions::default(), + &brush_parser::SourceInfo::default(), + ); + let Ok(mut program) = parser.parse_program() else { + return script.to_owned(); + }; + if !visit_simple_commands(&mut program, &mut |cmd| { + rewrite_bunx_in_simple_command(cmd, &mut rewrite_inner) + }) { + return script.to_owned(); + } + + collapse_newlines(&normalize_pipe_spacing(&program.to_string())) +} + fn make_suffix_word(value: &str) -> ast::CommandPrefixOrSuffixItem { ast::CommandPrefixOrSuffixItem::Word(ast::Word { value: value.to_owned(), loc: None }) } diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 8c5e71e9a1..8f921baf8e 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -148,6 +148,27 @@ user-defined scopes rather than scalar version pins. The `prefer-vite-plus-imports` lint rule follows the same Nuxt exception, so lint autofix preserves these imports. +## Package Script Rewrite Rules + +Migration rewrites commands provided by the Vite+ toolchain while preserving +their arguments: `vite` to `vp dev` or the matching `vp` subcommand, `vitest` to +`vp test`, `oxlint` to `vp lint`, `oxfmt` to `vp fmt`, `tsdown` to `vp pack`, and +`lint-staged` to `vp staged`. When their optional migrations run, `eslint` and +`prettier` are similarly rewritten to `vp lint` and `vp fmt`. + +For commands launched through `bunx`, migration preserves `bunx` and its +runner flags and rewrites only the managed command. This also works when +`bunx` follows a command-launcher delimiter such as `run` or `--`: + +| Before | After | +| ------------------------------------------------------- | -------------------------------------------------------- | +| `bunx --bun vite build` | `bunx --bun vp build` | +| `bunx --bun vitest run` | `bunx --bun vp test run` | +| `portless --tailscale run bunx --bun vite` | `portless --tailscale run bunx --bun vp dev` | +| `dotenv -e .env.test -- bunx --bun oxlint --type-aware` | `dotenv -e .env.test -- bunx --bun vp lint --type-aware` | + +Unrelated `bunx` commands and other package-executor forms remain unchanged. + ## Package-Manager Rules ### pnpm diff --git a/packages/cli/src/migration/__tests__/__snapshots__/migrator.spec.ts.snap b/packages/cli/src/migration/__tests__/__snapshots__/migrator.spec.ts.snap index 66f6192f1f..e62998d564 100644 --- a/packages/cli/src/migration/__tests__/__snapshots__/migrator.spec.ts.snap +++ b/packages/cli/src/migration/__tests__/__snapshots__/migrator.spec.ts.snap @@ -86,6 +86,17 @@ exports[`rewritePackageJson > should rewrite package.json scripts and extract st "test_run": "vp test run && vp test --ui", "version": "vp --version", "version_short": "vp -v", + "wrapped_build": "bunx --bun vp build", + "wrapped_dev": "bunx --bun vp dev", + "wrapped_fmt": "bunx --bun vp fmt --check .", + "wrapped_lint": "bunx --bun vp lint --type-aware", + "wrapped_nested_dev": "NODE_ENV=development portless --tailscale run bunx --bun vp dev", + "wrapped_nested_test": "dotenv -e .env.test -- bunx --bun vp test run", + "wrapped_pack": "bunx --bun vp pack --watch", + "wrapped_preview": "bunx --bun vp preview", + "wrapped_staged": "bunx --bun vp staged", + "wrapped_test": "bunx --bun vp test run", + "wrapped_unrelated": "bunx --bun playwright test", }, } `; diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index b5d1fc5510..3768d10dc6 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -166,6 +166,17 @@ describe('rewritePackageJson', () => { dev_profile: 'vite --profile', dev_stats: 'vite --stats', dev_analyze: 'vite --analyze', + wrapped_dev: 'bunx --bun vite', + wrapped_build: 'bunx --bun vite build', + wrapped_preview: 'bunx --bun vite preview', + wrapped_test: 'bunx --bun vitest run', + wrapped_lint: 'bunx --bun oxlint --type-aware', + wrapped_fmt: 'bunx --bun oxfmt --check .', + wrapped_pack: 'bunx --bun tsdown --watch', + wrapped_staged: 'bunx --bun lint-staged', + wrapped_nested_dev: 'NODE_ENV=development portless --tailscale run bunx --bun vite', + wrapped_nested_test: 'dotenv -e .env.test -- bunx --bun vitest run', + wrapped_unrelated: 'bunx --bun playwright test', ready: 'oxlint --fix --type-aware && vitest run && tsdown && oxfmt --fix', ready_env: 'NODE_ENV=test FOO=bar oxlint --fix --type-aware && NODE_ENV=test FOO=bar vitest run && NODE_ENV=test FOO=bar tsdown && NODE_ENV=test FOO=bar oxfmt --fix', diff --git a/rfcs/migration-command.md b/rfcs/migration-command.md index e7a63cafd7..d4a0abe630 100644 --- a/rfcs/migration-command.md +++ b/rfcs/migration-command.md @@ -567,6 +567,20 @@ A successful migration should: 8. ✅ Handle monorepo migrations efficiently 9. ✅ Be safe and transparent about what changes +## Bunx Script Rewriting + +The normal script rules rewrite `vite`, `vitest`, `oxlint`, `oxfmt`, `tsdown`, +and `lint-staged` to their corresponding `vp` commands. When one of these tools +is launched through `bunx`, migration preserves `bunx` and its flags and +rewrites only the inner command. For example, `bunx --bun vite build` becomes +`bunx --bun vp build` and `bunx --bun vitest run` becomes +`bunx --bun vp test run`. + +The same behavior applies to `eslint` and `prettier` when their optional +migrations run. Nested launcher forms such as +`portless --tailscale run bunx --bun vite` are also handled. Other package +executors remain unchanged and can be addressed separately. + ## ESLint Migration When an ESLint flat config (`eslint.config.{js,mjs,cjs,ts,mts,cts}`) and `eslint` dependency are detected, `vp migrate` offers to convert the ESLint configuration to oxlint using [`@oxlint/migrate`](https://www.npmjs.com/package/@oxlint/migrate). @@ -585,15 +599,16 @@ When an ESLint flat config (`eslint.config.{js,mjs,cjs,ts,mts,cts}`) and `eslint **Script Rewriting** (powered by [brush-parser](https://github.com/reubeno/brush) for shell AST parsing): -| Before | After | -| ------------------------------------------ | -------------------------------------------- | -| `eslint .` | `vp lint .` | -| `eslint --cache --ext .ts --fix .` | `vp lint --fix .` | -| `NODE_ENV=test eslint --cache .` | `NODE_ENV=test vp lint .` | -| `cross-env NODE_ENV=test eslint --cache .` | `cross-env NODE_ENV=test vp lint .` | -| `eslint . && vite build` | `vp lint . && vite build` | -| `if [ -f .eslintrc ]; then eslint .; fi` | `if [ -f .eslintrc ]; then vp lint . fi` | -| `npx eslint .` | `npx eslint .` (npx/bunx wrappers preserved) | +| Before | After | +| ------------------------------------------ | ---------------------------------------- | +| `eslint .` | `vp lint .` | +| `eslint --cache --ext .ts --fix .` | `vp lint --fix .` | +| `NODE_ENV=test eslint --cache .` | `NODE_ENV=test vp lint .` | +| `cross-env NODE_ENV=test eslint --cache .` | `cross-env NODE_ENV=test vp lint .` | +| `eslint . && vite build` | `vp lint . && vite build` | +| `if [ -f .eslintrc ]; then eslint .; fi` | `if [ -f .eslintrc ]; then vp lint . fi` | +| `bunx --bun eslint .` | `bunx --bun vp lint .` | +| `npx eslint .` | `npx eslint .` (unchanged) | Stripped ESLint-only flags: `--cache`, `--ext`, `--parser`, `--parser-options`, `--plugin`, `--rulesdir`, `--resolve-plugins-relative-to`, `--output-file`, `--env`, `--no-eslintrc`, `--no-error-on-unmatched-pattern`, `--debug`, `--no-inline-config` @@ -636,19 +651,20 @@ When a Prettier configuration file (`.prettierrc*`, `prettier.config.*`, or `"pr **Script Rewriting** (powered by [brush-parser](https://github.com/reubeno/brush) for shell AST parsing): -| Before | After | -| ------------------------------------------------- | ------------------------------------------------------ | -| `prettier .` | `vp fmt .` | -| `prettier --write .` | `vp fmt .` | -| `prettier --check .` | `vp fmt --check .` | -| `prettier --list-different .` | `vp fmt --check .` | -| `prettier -l .` | `vp fmt --check .` | -| `prettier --write --single-quote --tab-width 4 .` | `vp fmt .` | -| `prettier --config .prettierrc --write .` | `vp fmt .` | -| `prettier --plugin prettier-plugin-tailwindcss .` | `vp fmt .` | -| `cross-env NODE_ENV=test prettier --write .` | `cross-env NODE_ENV=test vp fmt .` | -| `prettier --write . && eslint --fix .` | `vp fmt . && eslint --fix .` | -| `npx prettier --write .` | `npx prettier --write .` (npx/bunx wrappers preserved) | +| Before | After | +| ------------------------------------------------- | ------------------------------------ | +| `prettier .` | `vp fmt .` | +| `prettier --write .` | `vp fmt .` | +| `prettier --check .` | `vp fmt --check .` | +| `prettier --list-different .` | `vp fmt --check .` | +| `prettier -l .` | `vp fmt --check .` | +| `prettier --write --single-quote --tab-width 4 .` | `vp fmt .` | +| `prettier --config .prettierrc --write .` | `vp fmt .` | +| `prettier --plugin prettier-plugin-tailwindcss .` | `vp fmt .` | +| `cross-env NODE_ENV=test prettier --write .` | `cross-env NODE_ENV=test vp fmt .` | +| `prettier --write . && eslint --fix .` | `vp fmt . && eslint --fix .` | +| `bunx --bun prettier --write .` | `bunx --bun vp fmt .` | +| `npx prettier --write .` | `npx prettier --write .` (unchanged) | **Stripped Prettier-only flags**: From 95ea87fcbbcc56dded1d502eee2ac270e2816dbb Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 26 Jun 2026 15:37:25 +0800 Subject: [PATCH 46/78] fix(migrate): limit automatic formatting to changed files --- docs/guide/migrate-rules.md | 8 +- .../migration-eslint-npx-wrapper/snap.txt | 6 +- .../migration-eslint-npx-wrapper/steps.json | 4 +- .../new-vite-monorepo-bun/snap.txt | 1 - .../src/migration/__tests__/format.spec.ts | 57 ++++++++++- packages/cli/src/migration/format.ts | 96 ++++++++++++++++++- rfcs/migration-command.md | 8 ++ 7 files changed, 167 insertions(+), 13 deletions(-) diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 8f921baf8e..2fee8048be 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -220,6 +220,8 @@ Unrelated `bunx` commands and other package-executor forms remain unchanged. After updating the manifests and package-manager configuration, migration reinstalls dependencies once to refresh the lockfile. If installation fails, migration reports the error and exits with a nonzero status. After a successful -migration, it runs `vp fmt` unless the project still uses Prettier. A formatter -failure is reported as a warning so the migration result and manual formatting -command remain available. +migration, it runs `vp fmt` on supported files changed in the Git worktree, +leaving unrelated project files untouched. Non-Git projects retain full-project +formatting. Formatting is skipped while the project still uses Prettier. A +formatter failure is reported as a warning so the migration result and manual +formatting command remain available. diff --git a/packages/cli/snap-tests-global/migration-eslint-npx-wrapper/snap.txt b/packages/cli/snap-tests-global/migration-eslint-npx-wrapper/snap.txt index 527ec413a8..061e1699e2 100644 --- a/packages/cli/snap-tests-global/migration-eslint-npx-wrapper/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint-npx-wrapper/snap.txt @@ -1,10 +1,10 @@ -> vp migrate --no-interactive # migration should rewrite bare eslint but leave npx wrappers unchanged +> vp migrate --no-interactive # migration should rewrite bare and bunx eslint but leave other wrappers unchanged ◇ Migrated . to Vite+ • Node pnpm • 4 config updates applied • ESLint rules migrated to Oxlint -> cat package.json # check eslint removed, bare eslint rewritten, npx/pnpm exec/bunx wrappers unchanged +> cat package.json # check eslint removed, bare and bunx eslint rewritten, npx/pnpm exec unchanged { "name": "migration-eslint-npx-wrapper", "scripts": { @@ -12,7 +12,7 @@ "build": "vp build", "lint": "npx eslint .", "lint:fix": "pnpm exec eslint --fix .", - "lint:bunx": "bunx eslint .", + "lint:bunx": "bunx vp lint .", "lint:bare": "vp lint --fix .", "prepare": "vp config" }, diff --git a/packages/cli/snap-tests-global/migration-eslint-npx-wrapper/steps.json b/packages/cli/snap-tests-global/migration-eslint-npx-wrapper/steps.json index 91955d46b6..41e444e62e 100644 --- a/packages/cli/snap-tests-global/migration-eslint-npx-wrapper/steps.json +++ b/packages/cli/snap-tests-global/migration-eslint-npx-wrapper/steps.json @@ -1,7 +1,7 @@ { "commands": [ - "vp migrate --no-interactive # migration should rewrite bare eslint but leave npx wrappers unchanged", - "cat package.json # check eslint removed, bare eslint rewritten, npx/pnpm exec/bunx wrappers unchanged", + "vp migrate --no-interactive # migration should rewrite bare and bunx eslint but leave other wrappers unchanged", + "cat package.json # check eslint removed, bare and bunx eslint rewritten, npx/pnpm exec unchanged", "cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog", "test ! -f eslint.config.mjs # check eslint config is removed" ] diff --git a/packages/cli/snap-tests-global/new-vite-monorepo-bun/snap.txt b/packages/cli/snap-tests-global/new-vite-monorepo-bun/snap.txt index e5dff2a89c..44df81225e 100644 --- a/packages/cli/snap-tests-global/new-vite-monorepo-bun/snap.txt +++ b/packages/cli/snap-tests-global/new-vite-monorepo-bun/snap.txt @@ -3,7 +3,6 @@ AGENTS.md README.md apps -bunfig.toml package.json packages tsconfig.json diff --git a/packages/cli/src/migration/__tests__/format.spec.ts b/packages/cli/src/migration/__tests__/format.spec.ts index 762c44bf77..1d43346fb3 100644 --- a/packages/cli/src/migration/__tests__/format.spec.ts +++ b/packages/cli/src/migration/__tests__/format.spec.ts @@ -1,6 +1,11 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + import { describe, expect, it, vi } from 'vitest'; -import { canFormatWithOxfmt, formatMigratedProject } from '../format.ts'; +import { canFormatWithOxfmt, collectChangedFormatPaths, formatMigratedProject } from '../format.ts'; import { createMigrationReport } from '../report.ts'; describe('formatMigratedProject', () => { @@ -11,9 +16,12 @@ describe('formatMigratedProject', () => { status: 'formatted', }); const report = createMigrationReport(); + const collectPaths = vi.fn().mockResolvedValue(['package.json', 'vite.config.ts']); - await expect(formatMigratedProject('/project', false, report, format)).resolves.toBe(true); - expect(format).toHaveBeenCalledWith('/project', false, undefined, { + await expect( + formatMigratedProject('/project', false, report, format, collectPaths), + ).resolves.toBe(true); + expect(format).toHaveBeenCalledWith('/project', false, ['package.json', 'vite.config.ts'], { silent: false, command: process.execPath, commandArgs: [...process.execArgv, process.argv[1]], @@ -21,6 +29,18 @@ describe('formatMigratedProject', () => { expect(report.warnings).toEqual([]); }); + it('skips formatting when migration changed no supported files', async () => { + const format = vi.fn(); + const report = createMigrationReport(); + const collectPaths = vi.fn().mockResolvedValue([]); + + await expect( + formatMigratedProject('/project', false, report, format, collectPaths), + ).resolves.toBe(true); + expect(format).not.toHaveBeenCalled(); + expect(report.warnings).toEqual([]); + }); + it('reports a formatter nonzero exit without throwing', async () => { const format = vi.fn().mockResolvedValue({ durationMs: 1, @@ -46,6 +66,37 @@ describe('formatMigratedProject', () => { }); }); +describe('collectChangedFormatPaths', () => { + it('collects supported changed Git paths without formatting unrelated files', async () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-migrate-format-')); + try { + execFileSync('git', ['init'], { cwd: projectRoot, stdio: 'ignore' }); + fs.writeFileSync(path.join(projectRoot, 'package.json'), '{}\n'); + fs.writeFileSync(path.join(projectRoot, 'vite.config.ts'), 'export default {}\n'); + fs.writeFileSync(path.join(projectRoot, 'template.mdx'), '# untouched\n'); + fs.writeFileSync(path.join(projectRoot, 'bun.lock'), 'lockfileVersion = 1\n'); + execFileSync('git', ['add', 'package.json'], { cwd: projectRoot }); + fs.appendFileSync(path.join(projectRoot, 'package.json'), '\n'); + + await expect(collectChangedFormatPaths(projectRoot)).resolves.toEqual([ + 'package.json', + 'vite.config.ts', + ]); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + + it('falls back to full-project formatting outside Git', async () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-migrate-format-no-git-')); + try { + await expect(collectChangedFormatPaths(projectRoot)).resolves.toBeUndefined(); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); +}); + describe('canFormatWithOxfmt', () => { it('formats projects that do not use Prettier', () => { expect(canFormatWithOxfmt(false, false)).toBe(true); diff --git a/packages/cli/src/migration/format.ts b/packages/cli/src/migration/format.ts index afb21db501..6382360e1b 100644 --- a/packages/cli/src/migration/format.ts +++ b/packages/cli/src/migration/format.ts @@ -1,3 +1,7 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { runCommandSilently } from '../utils/command.ts'; import { type CommandRunSummary, runViteFmt } from '../utils/prompts.ts'; import { addMigrationWarning, type MigrationReport } from './report.ts'; @@ -8,9 +12,94 @@ type FormatRunner = ( options?: { silent?: boolean; command?: string; commandArgs?: string[] }, ) => Promise; +type FormatPathCollector = (cwd: string) => Promise; + const FORMAT_FAILURE_MESSAGE = 'Automatic formatting failed. Run `vp fmt` manually after migration.'; +const FORMAT_EXTENSIONS = new Set([ + '.astro', + '.cjs', + '.css', + '.cts', + '.html', + '.js', + '.json', + '.jsonc', + '.jsx', + '.less', + '.md', + '.mjs', + '.mts', + '.scss', + '.svelte', + '.toml', + '.ts', + '.tsx', + '.vue', + '.yaml', + '.yml', +]); + +function parseNullDelimitedPaths(output: Buffer): string[] { + return output.toString().split('\0').filter(Boolean); +} + +function isFormatCandidate(projectRoot: string, relativePath: string): boolean { + const absolutePath = path.join(projectRoot, relativePath); + return ( + fs.existsSync(absolutePath) && + fs.statSync(absolutePath).isFile() && + FORMAT_EXTENSIONS.has(path.extname(relativePath).toLowerCase()) + ); +} + +/** + * Limit automatic formatting to files changed in the current Git worktree. + * This prevents migration from reformatting unrelated source trees while still + * covering manifests, generated config, and rewritten imports. + * + * Return `undefined` outside a Git worktree so non-Git projects retain the + * existing full-project formatting behavior. + */ +export async function collectChangedFormatPaths( + projectRoot: string, +): Promise { + try { + const git = (args: string[]) => + runCommandSilently({ command: 'git', args, cwd: projectRoot, envs: process.env }); + const [unstaged, staged, untracked] = await Promise.all([ + git(['diff', '--name-only', '--relative', '-z', '--diff-filter=ACMRTUXB', '--', '.']), + git([ + 'diff', + '--cached', + '--name-only', + '--relative', + '-z', + '--diff-filter=ACMRTUXB', + '--', + '.', + ]), + git(['ls-files', '--others', '--exclude-standard', '-z', '--', '.']), + ]); + if (unstaged.exitCode !== 0 || staged.exitCode !== 0 || untracked.exitCode !== 0) { + return undefined; + } + + return [ + ...new Set([ + ...parseNullDelimitedPaths(unstaged.stdout), + ...parseNullDelimitedPaths(staged.stdout), + ...parseNullDelimitedPaths(untracked.stdout), + ]), + ] + .filter((file) => isFormatCandidate(projectRoot, file)) + .toSorted(); + } catch { + return undefined; + } +} + /** * Do not apply Oxfmt to a project that still uses Prettier. Their formatting * rules can conflict, especially when Prettier is enforced through ESLint. @@ -33,10 +122,15 @@ export async function formatMigratedProject( interactive: boolean, report: MigrationReport, format: FormatRunner = runViteFmt, + collectPaths: FormatPathCollector = collectChangedFormatPaths, ): Promise { try { + const paths = await collectPaths(projectRoot); + if (paths?.length === 0) { + return true; + } const cliEntry = process.argv[1]; - const result = await format(projectRoot, interactive, undefined, { + const result = await format(projectRoot, interactive, paths, { silent: false, ...(cliEntry ? { command: process.execPath, commandArgs: [...process.execArgv, cliEntry] } diff --git a/rfcs/migration-command.md b/rfcs/migration-command.md index d4a0abe630..7bbd52dd0a 100644 --- a/rfcs/migration-command.md +++ b/rfcs/migration-command.md @@ -581,6 +581,14 @@ migrations run. Nested launcher forms such as `portless --tailscale run bunx --bun vite` are also handled. Other package executors remain unchanged and can be addressed separately. +## Post-Migration Formatting + +After a successful install, migration runs the formatter only on supported +files changed in the Git worktree. This formats manifests, generated config, +and rewritten source without reformatting unrelated files in a large project. +Non-Git projects retain full-project formatting. Projects that still use +Prettier are not formatted automatically. + ## ESLint Migration When an ESLint flat config (`eslint.config.{js,mjs,cjs,ts,mts,cts}`) and `eslint` dependency are detected, `vp migrate` offers to convert the ESLint configuration to oxlint using [`@oxlint/migrate`](https://www.npmjs.com/package/@oxlint/migrate). From 1194a1da8486af12f9dfe866630f9103cb9880de Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 26 Jun 2026 20:39:52 +0800 Subject: [PATCH 47/78] fix(migrate): defer supported formats to oxfmt --- docs/guide/migrate-rules.md | 10 ++-- .../src/migration/__tests__/format.spec.ts | 22 ++++++-- packages/cli/src/migration/format.ts | 50 ++++--------------- rfcs/migration-command.md | 10 ++-- 4 files changed, 40 insertions(+), 52 deletions(-) diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 2fee8048be..ff62137520 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -220,8 +220,8 @@ Unrelated `bunx` commands and other package-executor forms remain unchanged. After updating the manifests and package-manager configuration, migration reinstalls dependencies once to refresh the lockfile. If installation fails, migration reports the error and exits with a nonzero status. After a successful -migration, it runs `vp fmt` on supported files changed in the Git worktree, -leaving unrelated project files untouched. Non-Git projects retain full-project -formatting. Formatting is skipped while the project still uses Prettier. A -formatter failure is reported as a warning so the migration result and manual -formatting command remain available. +migration, it runs `vp fmt` on files changed in the Git worktree, leaving +unrelated project files untouched. Oxfmt selects the supported formats. Non-Git +projects retain full-project formatting. Formatting is skipped while the +project still uses Prettier. A formatter failure is reported as a warning so +the migration result and manual formatting command remain available. diff --git a/packages/cli/src/migration/__tests__/format.spec.ts b/packages/cli/src/migration/__tests__/format.spec.ts index 1d43346fb3..43841ad79a 100644 --- a/packages/cli/src/migration/__tests__/format.spec.ts +++ b/packages/cli/src/migration/__tests__/format.spec.ts @@ -67,18 +67,34 @@ describe('formatMigratedProject', () => { }); describe('collectChangedFormatPaths', () => { - it('collects supported changed Git paths without formatting unrelated files', async () => { + it('collects existing changed Git paths without an extension allowlist', async () => { const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-migrate-format-')); try { execFileSync('git', ['init'], { cwd: projectRoot, stdio: 'ignore' }); fs.writeFileSync(path.join(projectRoot, 'package.json'), '{}\n'); - fs.writeFileSync(path.join(projectRoot, 'vite.config.ts'), 'export default {}\n'); fs.writeFileSync(path.join(projectRoot, 'template.mdx'), '# untouched\n'); fs.writeFileSync(path.join(projectRoot, 'bun.lock'), 'lockfileVersion = 1\n'); - execFileSync('git', ['add', 'package.json'], { cwd: projectRoot }); + execFileSync('git', ['add', '.'], { cwd: projectRoot }); + execFileSync( + 'git', + [ + '-c', + 'user.name=Vite+ Test', + '-c', + 'user.email=test@vite-plus.dev', + 'commit', + '-m', + 'initial', + ], + { cwd: projectRoot, stdio: 'ignore' }, + ); + fs.appendFileSync(path.join(projectRoot, 'package.json'), '\n'); + fs.writeFileSync(path.join(projectRoot, 'vite.config.ts'), 'export default {}\n'); + fs.writeFileSync(path.join(projectRoot, 'future.custom'), 'future format\n'); await expect(collectChangedFormatPaths(projectRoot)).resolves.toEqual([ + 'future.custom', 'package.json', 'vite.config.ts', ]); diff --git a/packages/cli/src/migration/format.ts b/packages/cli/src/migration/format.ts index 6382360e1b..61269bf08e 100644 --- a/packages/cli/src/migration/format.ts +++ b/packages/cli/src/migration/format.ts @@ -17,41 +17,13 @@ type FormatPathCollector = (cwd: string) => Promise; const FORMAT_FAILURE_MESSAGE = 'Automatic formatting failed. Run `vp fmt` manually after migration.'; -const FORMAT_EXTENSIONS = new Set([ - '.astro', - '.cjs', - '.css', - '.cts', - '.html', - '.js', - '.json', - '.jsonc', - '.jsx', - '.less', - '.md', - '.mjs', - '.mts', - '.scss', - '.svelte', - '.toml', - '.ts', - '.tsx', - '.vue', - '.yaml', - '.yml', -]); - function parseNullDelimitedPaths(output: Buffer): string[] { return output.toString().split('\0').filter(Boolean); } -function isFormatCandidate(projectRoot: string, relativePath: string): boolean { +function isExistingFile(projectRoot: string, relativePath: string): boolean { const absolutePath = path.join(projectRoot, relativePath); - return ( - fs.existsSync(absolutePath) && - fs.statSync(absolutePath).isFile() && - FORMAT_EXTENSIONS.has(path.extname(relativePath).toLowerCase()) - ); + return fs.existsSync(absolutePath) && fs.statSync(absolutePath).isFile(); } /** @@ -86,15 +58,15 @@ export async function collectChangedFormatPaths( return undefined; } - return [ - ...new Set([ - ...parseNullDelimitedPaths(unstaged.stdout), - ...parseNullDelimitedPaths(staged.stdout), - ...parseNullDelimitedPaths(untracked.stdout), - ]), - ] - .filter((file) => isFormatCandidate(projectRoot, file)) - .toSorted(); + const changedPaths = new Set([ + ...parseNullDelimitedPaths(unstaged.stdout), + ...parseNullDelimitedPaths(staged.stdout), + ...parseNullDelimitedPaths(untracked.stdout), + ]); + + // Oxfmt owns the supported-file list and skips unknown formats. Passing + // every existing changed file keeps migration aligned as Oxfmt evolves. + return [...changedPaths].filter((file) => isExistingFile(projectRoot, file)).toSorted(); } catch { return undefined; } diff --git a/rfcs/migration-command.md b/rfcs/migration-command.md index 7bbd52dd0a..53473538dd 100644 --- a/rfcs/migration-command.md +++ b/rfcs/migration-command.md @@ -583,11 +583,11 @@ executors remain unchanged and can be addressed separately. ## Post-Migration Formatting -After a successful install, migration runs the formatter only on supported -files changed in the Git worktree. This formats manifests, generated config, -and rewritten source without reformatting unrelated files in a large project. -Non-Git projects retain full-project formatting. Projects that still use -Prettier are not formatted automatically. +After a successful install, migration runs the formatter only on files changed +in the Git worktree. Oxfmt selects the supported formats. This formats +manifests, generated config, and rewritten source without reformatting +unrelated files in a large project. Non-Git projects retain full-project +formatting. Projects that still use Prettier are not formatted automatically. ## ESLint Migration From c8432dc35cc35d1ec1b70194c6fb267fb594ce7c Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 26 Jun 2026 21:12:51 +0800 Subject: [PATCH 48/78] fix(ci): upgrade affected pnpm 10 pkg-pr-new tests --- .../ensure-pkg-pr-new-pnpm-version.mjs | 62 ++++++++++++------- .github/scripts/test-pkg-pr-new-migrate.sh | 8 ++- 2 files changed, 43 insertions(+), 27 deletions(-) diff --git a/.github/scripts/ensure-pkg-pr-new-pnpm-version.mjs b/.github/scripts/ensure-pkg-pr-new-pnpm-version.mjs index e97e7b070e..f5acd420a7 100644 --- a/.github/scripts/ensure-pkg-pr-new-pnpm-version.mjs +++ b/.github/scripts/ensure-pkg-pr-new-pnpm-version.mjs @@ -2,7 +2,8 @@ import fs from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; -const SAFE_PNPM_VERSION = '11.9.0'; +const LATEST_PNPM_10_VERSION = '10.34.4'; +const SAFE_PNPM_11_VERSION = '11.9.0'; const SUPPORTED_PACKAGE_MANAGERS = new Set(['pnpm', 'yarn', 'npm', 'bun']); function parseExactVersion(version) { @@ -66,15 +67,26 @@ function compareVersions(left, right) { return 0; } -function isAffectedPnpmVersion(version) { +function safePnpmVersionFor(version) { const parsed = parseExactVersion(version); - const lower = parseExactVersion('11.0.0'); - const upper = parseExactVersion(SAFE_PNPM_VERSION); - return ( - parsed !== undefined && - compareVersions(parsed, lower) >= 0 && - compareVersions(parsed, upper) < 0 - ); + if (!parsed) { + return undefined; + } + + // pnpm before 10.2.0 rewrites non-semver overrides into peerDependencies, + // causing pkg.pr.new URLs to fail peer-spec validation. Stay on the same + // major and use the latest v10 release containing pnpm/pnpm#9000. + if (parsed.major === 10 && compareVersions(parsed, parseExactVersion('10.2.0')) < 0) { + return LATEST_PNPM_10_VERSION; + } + + const pnpm11Lower = parseExactVersion('11.0.0'); + const pnpm11Upper = parseExactVersion(SAFE_PNPM_11_VERSION); + if (compareVersions(parsed, pnpm11Lower) >= 0 && compareVersions(parsed, pnpm11Upper) < 0) { + return SAFE_PNPM_11_VERSION; + } + + return undefined; } function parsePackageManagerSpec(spec) { @@ -104,13 +116,13 @@ function serializeLike(source, pkg) { return JSON.stringify(pkg, null, indent).replaceAll('\n', newline) + finalNewline; } -function replacePackageManagerSpec(source, previousSpec) { +function replacePackageManagerSpec(source, previousSpec, targetVersion) { const pattern = /("packageManager"\s*:\s*)("(?:\\.|[^"\\])*")/g; return source.replace(pattern, (match, prefix, value) => { if (JSON.parse(value) !== previousSpec) { return match; } - return `${prefix}${JSON.stringify(`pnpm@${SAFE_PNPM_VERSION}`)}`; + return `${prefix}${JSON.stringify(`pnpm@${targetVersion}`)}`; }); } @@ -118,16 +130,18 @@ export function ensureSafePkgPrNewPnpmVersion(source) { const pkg = JSON.parse(source); const previousVersions = []; let packageManagerSpec; + let targetVersion; let devEnginesChanged = false; if (typeof pkg.packageManager === 'string') { const parsed = parsePackageManagerSpec(pkg.packageManager); - if (parsed?.name !== 'pnpm' || !isAffectedPnpmVersion(parsed.version)) { + targetVersion = parsed?.name === 'pnpm' ? safePnpmVersionFor(parsed.version) : undefined; + if (!targetVersion) { return { changed: false, source, previousVersions }; } packageManagerSpec = pkg.packageManager; previousVersions.push(parsed.version); - pkg.packageManager = `pnpm@${SAFE_PNPM_VERSION}`; + pkg.packageManager = `pnpm@${targetVersion}`; // Keep exact pnpm devEngines constraints in sync with the authoritative // packageManager field so the two declarations do not conflict. @@ -135,35 +149,35 @@ export function ensureSafePkgPrNewPnpmVersion(source) { if ( entry.name === 'pnpm' && typeof entry.version === 'string' && - isAffectedPnpmVersion(entry.version) + safePnpmVersionFor(entry.version) ) { previousVersions.push(entry.version); - entry.version = SAFE_PNPM_VERSION; + entry.version = targetVersion; devEnginesChanged = true; } } } else { const selected = selectedDevEngineEntry(pkg); - if ( - selected?.name !== 'pnpm' || - typeof selected.version !== 'string' || - !isAffectedPnpmVersion(selected.version) - ) { + targetVersion = + selected?.name === 'pnpm' && typeof selected.version === 'string' + ? safePnpmVersionFor(selected.version) + : undefined; + if (!targetVersion || selected?.name !== 'pnpm' || typeof selected.version !== 'string') { return { changed: false, source, previousVersions }; } previousVersions.push(selected.version); - selected.version = SAFE_PNPM_VERSION; + selected.version = targetVersion; devEnginesChanged = true; } const updatedSource = devEnginesChanged ? serializeLike(source, pkg) - : replacePackageManagerSpec(source, packageManagerSpec); + : replacePackageManagerSpec(source, packageManagerSpec, targetVersion); return { changed: true, source: updatedSource, previousVersions: [...new Set(previousVersions)], - version: SAFE_PNPM_VERSION, + version: targetVersion, }; } @@ -179,7 +193,7 @@ if (invokedPath === import.meta.url) { if (result.changed) { fs.writeFileSync(packageJsonPath, result.source); console.log( - `Updating project pnpm ${result.previousVersions.join(', ')} -> ${result.version} to avoid pkg.pr.new tarball integrity failures`, + `Updating project pnpm ${result.previousVersions.join(', ')} -> ${result.version} to avoid pkg.pr.new install failures`, ); } } diff --git a/.github/scripts/test-pkg-pr-new-migrate.sh b/.github/scripts/test-pkg-pr-new-migrate.sh index 996846f91b..2dc5314f19 100755 --- a/.github/scripts/test-pkg-pr-new-migrate.sh +++ b/.github/scripts/test-pkg-pr-new-migrate.sh @@ -77,9 +77,11 @@ if command -v git >/dev/null 2>&1 && git -C "$project_dir" rev-parse --is-inside fi fi -# pnpm 11.0.0 through 11.8.x can write pkg.pr.new tarball lock entries without -# integrity metadata, which a later frozen install rejects. Upgrade the -# project's package-manager pin before migration resolves or invokes pnpm. +# pnpm 10 before 10.2.0 rewrites pkg.pr.new URL overrides into workspace peer +# declarations, which then fail peer-spec validation. pnpm 11.0.0 through +# 11.8.x can write pkg.pr.new tarball lock entries without integrity metadata, +# which a later frozen install rejects. Upgrade affected package-manager pins +# before migration resolves or invokes pnpm. node "$pnpm_version_helper" "$project_dir/package.json" original_home="$HOME" From fa3202130079bde6963f9c4e0d37cd577fd40afe Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 26 Jun 2026 21:43:34 +0800 Subject: [PATCH 49/78] fix(migrate): address review feedback --- .github/scripts/bun-pkg-pr-new.mjs | 8 ++- crates/vite_migration/src/eslint.rs | 4 +- crates/vite_migration/src/package.rs | 27 ++++----- crates/vite_migration/src/prettier.rs | 4 +- crates/vite_migration/src/script_rewrite.rs | 21 +++++-- docs/guide/migrate-rules.md | 31 +++++----- .../__snapshots__/migrator.spec.ts.snap | 20 +++---- .../src/migration/__tests__/format.spec.ts | 26 ++++++--- .../src/migration/__tests__/migrator.spec.ts | 8 +++ packages/cli/src/migration/bin.ts | 58 ++++++++++++++----- packages/cli/src/migration/format.ts | 22 +++++-- packages/cli/src/migration/migrator.ts | 8 ++- rfcs/migration-command.md | 21 +++---- 13 files changed, 164 insertions(+), 94 deletions(-) diff --git a/.github/scripts/bun-pkg-pr-new.mjs b/.github/scripts/bun-pkg-pr-new.mjs index 8ebda89b26..cb31d3e705 100755 --- a/.github/scripts/bun-pkg-pr-new.mjs +++ b/.github/scripts/bun-pkg-pr-new.mjs @@ -29,8 +29,12 @@ function isBunProject(packageJsonPath) { const packageManager = typeof pkg.packageManager === 'string' ? pkg.packageManager.split('@')[0] : undefined; const devEngine = pkg.devEngines?.packageManager; - const devEngineName = typeof devEngine === 'string' ? devEngine : devEngine?.name; - process.exit(packageManager === 'bun' || devEngineName === 'bun' ? 0 : 1); + const devEngines = Array.isArray(devEngine) ? devEngine : [devEngine]; + const hasBunDevEngine = devEngines.some((entry) => { + const name = typeof entry === 'string' ? entry : entry?.name; + return name === 'bun'; + }); + process.exit(packageManager === 'bun' || hasBunDevEngine ? 0 : 1); } function patchPackage(packageJsonPath, coreUrl, vitePlusUrl) { diff --git a/crates/vite_migration/src/eslint.rs b/crates/vite_migration/src/eslint.rs index 2dfb8d96c2..59f8dacc4b 100644 --- a/crates/vite_migration/src/eslint.rs +++ b/crates/vite_migration/src/eslint.rs @@ -161,11 +161,11 @@ mod tests { fn test_rewrite_eslint_bunx() { assert_eq!( rewrite_eslint_script("bunx --bun eslint --cache --fix ."), - "bunx --bun vp lint --fix ." + "bunx vp lint --fix ." ); assert_eq!( rewrite_eslint_script("dotenv -e .env -- bunx --bun eslint --ext .ts ."), - "dotenv -e .env -- bunx --bun vp lint ." + "dotenv -e .env -- bunx vp lint ." ); assert_eq!( rewrite_eslint_script("bunx --bun eslint-plugin-foo"), diff --git a/crates/vite_migration/src/package.rs b/crates/vite_migration/src/package.rs index 98e1fe4517..aa570f13c6 100644 --- a/crates/vite_migration/src/package.rs +++ b/crates/vite_migration/src/package.rs @@ -291,36 +291,31 @@ fix: vp pack rewrite_script("NODE_ENV=test oxlint --type-aware", &rules), "NODE_ENV=test vp lint --type-aware" ); - // bunx and its flags are preserved while managed commands are rewritten - assert_eq!(rewrite_script("bunx --bun vite build", &rules), "bunx --bun vp build"); - assert_eq!(rewrite_script("bunx --bun vite preview", &rules), "bunx --bun vp preview"); - assert_eq!(rewrite_script("bunx --bun vitest run", &rules), "bunx --bun vp test run"); + // bunx is preserved, but --bun is removed so vp runs through Node + assert_eq!(rewrite_script("bunx --bun vite build", &rules), "bunx vp build"); + assert_eq!(rewrite_script("bunx --bun vite preview", &rules), "bunx vp preview"); + assert_eq!(rewrite_script("bunx --bun vitest run", &rules), "bunx vp test run"); assert_eq!( rewrite_script("bunx --bun oxlint --type-aware", &rules), - "bunx --bun vp lint --type-aware" + "bunx vp lint --type-aware" ); - assert_eq!( - rewrite_script("bunx --bun oxfmt --check .", &rules), - "bunx --bun vp fmt --check ." - ); - assert_eq!( - rewrite_script("bunx --bun tsdown --watch", &rules), - "bunx --bun vp pack --watch" - ); - assert_eq!(rewrite_script("bunx --bun lint-staged", &rules), "bunx --bun vp staged"); + assert_eq!(rewrite_script("bunx --bun oxfmt --check .", &rules), "bunx vp fmt --check ."); + assert_eq!(rewrite_script("bunx --bun tsdown --watch", &rules), "bunx vp pack --watch"); + assert_eq!(rewrite_script("bunx --bun lint-staged", &rules), "bunx vp staged"); assert_eq!( rewrite_script("NODE_ENV=development portless --tailscale run bunx --bun vite", &rules,), - "NODE_ENV=development portless --tailscale run bunx --bun vp dev" + "NODE_ENV=development portless --tailscale run bunx vp dev" ); assert_eq!( rewrite_script("dotenv -e .env.test -- bunx --bun vitest run", &rules), - "dotenv -e .env.test -- bunx --bun vp test run" + "dotenv -e .env.test -- bunx vp test run" ); // unrelated executor calls and non-launcher arguments stay unchanged assert_eq!( rewrite_script("bunx --bun playwright test", &rules), "bunx --bun playwright test" ); + assert_eq!(rewrite_script("bunx --bun jest", &rules), "bunx --bun jest"); assert_eq!(rewrite_script("bunx --bun vp build", &rules), "bunx --bun vp build"); assert_eq!( rewrite_script("echo bunx --bun vite build", &rules), diff --git a/crates/vite_migration/src/prettier.rs b/crates/vite_migration/src/prettier.rs index db4ad7b0a1..63f4926816 100644 --- a/crates/vite_migration/src/prettier.rs +++ b/crates/vite_migration/src/prettier.rs @@ -195,11 +195,11 @@ mod tests { fn test_rewrite_prettier_bunx() { assert_eq!( rewrite_prettier_script("bunx --bun prettier --write --single-quote ."), - "bunx --bun vp fmt ." + "bunx vp fmt ." ); assert_eq!( rewrite_prettier_script("dotenv -e .env -- bunx --bun prettier --check ."), - "dotenv -e .env -- bunx --bun vp fmt --check ." + "dotenv -e .env -- bunx vp fmt --check ." ); assert_eq!( rewrite_prettier_script("bunx --bun prettier-plugin-foo"), diff --git a/crates/vite_migration/src/script_rewrite.rs b/crates/vite_migration/src/script_rewrite.rs index 5e391ef174..52b842bbd3 100644 --- a/crates/vite_migration/src/script_rewrite.rs +++ b/crates/vite_migration/src/script_rewrite.rs @@ -170,6 +170,7 @@ struct CommandWord { struct BunxInvocation { target_suffix_index: usize, + forced_bun_suffix_indices: Vec, } fn collect_command_words(cmd: &ast::SimpleCommand) -> Vec { @@ -195,7 +196,7 @@ fn collect_command_words(cmd: &ast::SimpleCommand) -> Vec { words } -fn bunx_target(words: &[CommandWord], start: usize) -> Option { +fn bunx_target(words: &[CommandWord], start: usize) -> Option<(usize, Vec)> { let contiguous = |left: usize, right: usize| { words.get(left).zip(words.get(right)).is_some_and(|(a, b)| b.ordinal == a.ordinal + 1) }; @@ -205,11 +206,15 @@ fn bunx_target(words: &[CommandWord], start: usize) -> Option { return None; } let mut target = next(start)?; + let mut forced_bun_suffix_indices = Vec::new(); while words.get(target)?.value == "--bun" { + if let CommandWordPosition::Suffix(index) = words[target].position { + forced_bun_suffix_indices.push(index); + } target = next(target)?; } - Some(target) + Some((target, forced_bun_suffix_indices)) } fn find_bunx_invocations(cmd: &ast::SimpleCommand) -> Vec { @@ -217,7 +222,7 @@ fn find_bunx_invocations(cmd: &ast::SimpleCommand) -> Vec { let mut invocations = Vec::new(); for start in 0..words.len() { - let Some(target) = bunx_target(&words, start) else { + let Some((target, forced_bun_suffix_indices)) = bunx_target(&words, start) else { continue; }; let CommandWordPosition::Suffix(target_suffix_index) = words[target].position else { @@ -239,7 +244,7 @@ fn find_bunx_invocations(cmd: &ast::SimpleCommand) -> Vec { }), }; if allowed_position { - invocations.push(BunxInvocation { target_suffix_index }); + invocations.push(BunxInvocation { target_suffix_index, forced_bun_suffix_indices }); } } @@ -309,13 +314,17 @@ fn rewrite_bunx_in_simple_command( replacement_items.extend(inner_suffix.0); } suffix.0.splice(invocation.target_suffix_index.., replacement_items); + for index in invocation.forced_bun_suffix_indices.iter().rev() { + suffix.0.remove(*index); + } return true; } false } -/// Rewrite commands launched through `bunx`. The runner and its flags are -/// preserved; only an inner command changed by the supplied rewriter is replaced. +/// Rewrite commands launched through `bunx`. The runner is preserved, but its +/// `--bun` flag is removed when the inner command becomes `vp` so Vite+ runs +/// through its Node runtime rather than being forced into Bun. pub(crate) fn rewrite_bunx_commands( script: &str, mut rewrite_inner: impl FnMut(&str) -> String, diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index ff62137520..527d0a5bd2 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -156,16 +156,17 @@ their arguments: `vite` to `vp dev` or the matching `vp` subcommand, `vitest` to `lint-staged` to `vp staged`. When their optional migrations run, `eslint` and `prettier` are similarly rewritten to `vp lint` and `vp fmt`. -For commands launched through `bunx`, migration preserves `bunx` and its -runner flags and rewrites only the managed command. This also works when -`bunx` follows a command-launcher delimiter such as `run` or `--`: - -| Before | After | -| ------------------------------------------------------- | -------------------------------------------------------- | -| `bunx --bun vite build` | `bunx --bun vp build` | -| `bunx --bun vitest run` | `bunx --bun vp test run` | -| `portless --tailscale run bunx --bun vite` | `portless --tailscale run bunx --bun vp dev` | -| `dotenv -e .env.test -- bunx --bun oxlint --type-aware` | `dotenv -e .env.test -- bunx --bun vp lint --type-aware` | +For commands launched through `bunx`, migration preserves `bunx` and rewrites +the managed command. It removes `--bun` from rewritten commands so `vp` runs +through its Node runtime. This also works when `bunx` follows a command-launcher +delimiter such as `run` or `--`: + +| Before | After | +| ------------------------------------------------------- | -------------------------------------------------- | +| `bunx --bun vite build` | `bunx vp build` | +| `bunx --bun vitest run` | `bunx vp test run` | +| `portless --tailscale run bunx --bun vite` | `portless --tailscale run bunx vp dev` | +| `dotenv -e .env.test -- bunx --bun oxlint --type-aware` | `dotenv -e .env.test -- bunx vp lint --type-aware` | Unrelated `bunx` commands and other package-executor forms remain unchanged. @@ -220,8 +221,8 @@ Unrelated `bunx` commands and other package-executor forms remain unchanged. After updating the manifests and package-manager configuration, migration reinstalls dependencies once to refresh the lockfile. If installation fails, migration reports the error and exits with a nonzero status. After a successful -migration, it runs `vp fmt` on files changed in the Git worktree, leaving -unrelated project files untouched. Oxfmt selects the supported formats. Non-Git -projects retain full-project formatting. Formatting is skipped while the -project still uses Prettier. A formatter failure is reported as a warning so -the migration result and manual formatting command remain available. +migration, it runs `vp fmt` on files changed during migration, excluding paths +that were already dirty in the Git worktree. Oxfmt selects the supported +formats. Non-Git projects retain full-project formatting. Formatting is skipped +while the project still uses Prettier. A formatter failure is reported as a +warning so the migration result and manual formatting command remain available. diff --git a/packages/cli/src/migration/__tests__/__snapshots__/migrator.spec.ts.snap b/packages/cli/src/migration/__tests__/__snapshots__/migrator.spec.ts.snap index e62998d564..7e62cab2dc 100644 --- a/packages/cli/src/migration/__tests__/__snapshots__/migrator.spec.ts.snap +++ b/packages/cli/src/migration/__tests__/__snapshots__/migrator.spec.ts.snap @@ -86,16 +86,16 @@ exports[`rewritePackageJson > should rewrite package.json scripts and extract st "test_run": "vp test run && vp test --ui", "version": "vp --version", "version_short": "vp -v", - "wrapped_build": "bunx --bun vp build", - "wrapped_dev": "bunx --bun vp dev", - "wrapped_fmt": "bunx --bun vp fmt --check .", - "wrapped_lint": "bunx --bun vp lint --type-aware", - "wrapped_nested_dev": "NODE_ENV=development portless --tailscale run bunx --bun vp dev", - "wrapped_nested_test": "dotenv -e .env.test -- bunx --bun vp test run", - "wrapped_pack": "bunx --bun vp pack --watch", - "wrapped_preview": "bunx --bun vp preview", - "wrapped_staged": "bunx --bun vp staged", - "wrapped_test": "bunx --bun vp test run", + "wrapped_build": "bunx vp build", + "wrapped_dev": "bunx vp dev", + "wrapped_fmt": "bunx vp fmt --check .", + "wrapped_lint": "bunx vp lint --type-aware", + "wrapped_nested_dev": "NODE_ENV=development portless --tailscale run bunx vp dev", + "wrapped_nested_test": "dotenv -e .env.test -- bunx vp test run", + "wrapped_pack": "bunx vp pack --watch", + "wrapped_preview": "bunx vp preview", + "wrapped_staged": "bunx vp staged", + "wrapped_test": "bunx vp test run", "wrapped_unrelated": "bunx --bun playwright test", }, } diff --git a/packages/cli/src/migration/__tests__/format.spec.ts b/packages/cli/src/migration/__tests__/format.spec.ts index 43841ad79a..df30e13d57 100644 --- a/packages/cli/src/migration/__tests__/format.spec.ts +++ b/packages/cli/src/migration/__tests__/format.spec.ts @@ -17,10 +17,16 @@ describe('formatMigratedProject', () => { }); const report = createMigrationReport(); const collectPaths = vi.fn().mockResolvedValue(['package.json', 'vite.config.ts']); + const excludedPaths = new Set(['notes.md']); await expect( - formatMigratedProject('/project', false, report, format, collectPaths), + formatMigratedProject('/project', false, report, { + format, + collectPaths, + excludedPaths, + }), ).resolves.toBe(true); + expect(collectPaths).toHaveBeenCalledWith('/project', excludedPaths); expect(format).toHaveBeenCalledWith('/project', false, ['package.json', 'vite.config.ts'], { silent: false, command: process.execPath, @@ -35,7 +41,7 @@ describe('formatMigratedProject', () => { const collectPaths = vi.fn().mockResolvedValue([]); await expect( - formatMigratedProject('/project', false, report, format, collectPaths), + formatMigratedProject('/project', false, report, { format, collectPaths }), ).resolves.toBe(true); expect(format).not.toHaveBeenCalled(); expect(report.warnings).toEqual([]); @@ -49,7 +55,7 @@ describe('formatMigratedProject', () => { }); const report = createMigrationReport(); - await expect(formatMigratedProject('/project', false, report, format)).resolves.toBe(false); + await expect(formatMigratedProject('/project', false, report, { format })).resolves.toBe(false); expect(report.warnings).toEqual([ 'Automatic formatting failed. Run `vp fmt` manually after migration.', ]); @@ -59,7 +65,7 @@ describe('formatMigratedProject', () => { const format = vi.fn().mockRejectedValue(new Error('could not load config')); const report = createMigrationReport(); - await expect(formatMigratedProject('/project', false, report, format)).resolves.toBe(false); + await expect(formatMigratedProject('/project', false, report, { format })).resolves.toBe(false); expect(report.warnings).toEqual([ 'Automatic formatting failed. Run `vp fmt` manually after migration.', ]); @@ -89,15 +95,17 @@ describe('collectChangedFormatPaths', () => { { cwd: projectRoot, stdio: 'ignore' }, ); + fs.writeFileSync(path.join(projectRoot, 'notes.md'), '# existing work\n'); + const preExistingPaths = await collectChangedFormatPaths(projectRoot); + expect(preExistingPaths).toEqual(['notes.md']); + fs.appendFileSync(path.join(projectRoot, 'package.json'), '\n'); fs.writeFileSync(path.join(projectRoot, 'vite.config.ts'), 'export default {}\n'); fs.writeFileSync(path.join(projectRoot, 'future.custom'), 'future format\n'); - await expect(collectChangedFormatPaths(projectRoot)).resolves.toEqual([ - 'future.custom', - 'package.json', - 'vite.config.ts', - ]); + await expect( + collectChangedFormatPaths(projectRoot, new Set(preExistingPaths)), + ).resolves.toEqual(['future.custom', 'package.json', 'vite.config.ts']); } finally { fs.rmSync(projectRoot, { recursive: true, force: true }); } diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 3768d10dc6..6ed3296f3c 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -105,6 +105,14 @@ describe('Yarn PnP migration preflight', () => { it('does not classify Yarn Classic or node-modules configuration as PnP', () => { expect(detectYarnPnpMode(tmpDir, '1.22.22')).toBeUndefined(); + fs.writeFileSync(path.join(tmpDir, '.yarnrc.yml'), 'nodeLinker: pnp\n'); + expect(detectYarnPnpMode(tmpDir, '1.22.22')).toBeUndefined(); + + fs.rmSync(path.join(tmpDir, '.yarnrc.yml')); + process.env.YARN_NODE_LINKER = 'pnp'; + expect(detectYarnPnpMode(tmpDir, '1.22.22')).toBeUndefined(); + + delete process.env.YARN_NODE_LINKER; fs.writeFileSync(path.join(tmpDir, '.yarnrc.yml'), 'nodeLinker: node-modules\n'); expect(detectYarnPnpMode(tmpDir, '4.12.0')).toBeUndefined(); }); diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index 878f2de7e8..9a67b39cd1 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -46,7 +46,7 @@ import { import type { PackageDependencies } from '../utils/types.ts'; import { detectWorkspace } from '../utils/workspace.ts'; import { checkRolldownCompatibility } from './compat-runner.ts'; -import { canFormatWithOxfmt, formatMigratedProject } from './format.ts'; +import { canFormatWithOxfmt, collectChangedFormatPaths, formatMigratedProject } from './format.ts'; import { addFrameworkShim, checkVitestVersion, @@ -129,7 +129,7 @@ async function confirmFrameworkShim(framework: Framework, interactive: boolean): return true; } -async function ensureYarnNodeModulesMode( +async function confirmYarnNodeModulesMode( rootDir: string, packageManager: PackageManager | undefined, packageManagerVersion: string, @@ -165,8 +165,6 @@ async function ensureYarnNodeModulesMode( } } - configureYarnNodeModulesMode(rootDir); - prompts.log.success('✔ Switched Yarn to node-modules mode'); return true; } @@ -386,7 +384,7 @@ interface MigrationSetupPlan { interface MigrationPlan extends MigrationSetupPlan { packageManager: PackageManager; - yarnPnpConverted: boolean; + convertYarnPnp: boolean; migratePrettier: boolean; hasPrettierDependency: boolean; prettierConfigFile?: string; @@ -683,7 +681,7 @@ async function collectMigrationPlan( // 1. Package manager selection const packageManager = detectedPackageManager ?? (await selectPackageManager(options.interactive, true)); - const yarnPnpConverted = await ensureYarnNodeModulesMode( + const convertYarnPnp = await confirmYarnNodeModulesMode( rootDir, packageManager, detectedPackageManager ? detectedPackageManagerVersion : 'latest', @@ -722,7 +720,7 @@ async function collectMigrationPlan( const plan: MigrationPlan = { packageManager, - yarnPnpConverted, + convertYarnPnp, ...setupPlan, migratePrettier, hasPrettierDependency: prettierProject.hasDependency, @@ -947,6 +945,7 @@ async function executeMigrationPlan( workspaceInfoOptional: WorkspaceInfoOptional, plan: MigrationPlan, interactive: boolean, + preExistingChangedPaths?: ReadonlySet, ): Promise<{ installDurationMs: number; finalInstallOk: boolean; @@ -954,7 +953,6 @@ async function executeMigrationPlan( report: MigrationReport; }> { const report = createMigrationReport(); - report.packageManagerBootstrapConfigured = plan.yarnPnpConverted; const migrationProgress = interactive ? prompts.spinner({ indicator: 'timer' }) : undefined; let migrationProgressStarted = false; const updateMigrationProgress = (message: string) => { @@ -996,6 +994,14 @@ async function executeMigrationPlan( downloadPackageManager: downloadResult, }; + if (plan.convertYarnPnp) { + updateMigrationProgress('Configuring Yarn node-modules mode'); + report.packageManagerBootstrapConfigured = configureYarnNodeModulesMode(workspaceInfo.rootDir); + if (report.packageManagerBootstrapConfigured) { + prompts.log.success('✔ Switched Yarn to node-modules mode'); + } + } + // 3. Migrate node version manager file → .node-version (independent of vite version) if (plan.migrateNodeVersionFile && plan.nodeVersionDetection) { updateMigrationProgress('Migrating node version file'); @@ -1165,7 +1171,9 @@ async function executeMigrationPlan( finalInstallSummary.status === 'installed' && canFormatWithOxfmt(plan.hasPrettierDependency, plan.migratePrettier) ) { - await formatMigratedProject(workspaceInfo.rootDir, interactive, report); + await formatMigratedProject(workspaceInfo.rootDir, interactive, report, { + excludedPaths: preExistingChangedPaths, + }); } return { installDurationMs: initialInstallDurationMs + finalInstallDurationMs, @@ -1187,6 +1195,8 @@ async function main() { printHeader(); const workspaceInfoOptional = await detectWorkspace(projectPath); + const initialChangedPaths = await collectChangedFormatPaths(workspaceInfoOptional.rootDir); + const preExistingChangedPaths = initialChangedPaths ? new Set(initialChangedPaths) : undefined; const resolvedPackageManager = workspaceInfoOptional.packageManager ?? 'unknown'; // Early return if already using Vite+ (only finalization/setup migrations may be needed) @@ -1195,18 +1205,17 @@ async function main() { workspaceInfoOptional.rootDir, ) as PackageDependencies | null; if (hasVitePlusDependency(rootPkg) && !isForceOverrideMode()) { - const yarnPnpConverted = await ensureYarnNodeModulesMode( + const convertYarnPnp = await confirmYarnNodeModulesMode( workspaceInfoOptional.rootDir, workspaceInfoOptional.packageManager, workspaceInfoOptional.packageManagerVersion, options.interactive, ); - let didMigrate = yarnPnpConverted; + let didMigrate = false; let installDurationMs = 0; let finalInstallOk = true; let canFormatMigratedProject = !process.env.VP_SKIP_INSTALL; const report = createMigrationReport(); - report.packageManagerBootstrapConfigured = yarnPnpConverted; const migrationProgress = options.interactive ? prompts.spinner({ indicator: 'timer' }) : undefined; @@ -1291,6 +1300,7 @@ async function main() { if ( !didMigrate && + !convertYarnPnp && report.warnings.length === 0 && !vitePlusBootstrapPending && !hasExistingVitePlusMigrationCandidates(workspaceInfoOptional, options) @@ -1322,7 +1332,7 @@ async function main() { workspaceInfoOptional.packages, ); - let needsInstall = yarnPnpConverted; + let needsInstall = false; if (vitePlusBootstrapPending) { const downloadResult = await ensureExistingPackageManager(); if (downloadResult && packageManager) { @@ -1420,6 +1430,17 @@ async function main() { } } + if (convertYarnPnp) { + updateMigrationProgress('Configuring Yarn node-modules mode'); + const yarnPnpConverted = configureYarnNodeModulesMode(workspaceInfoOptional.rootDir); + if (yarnPnpConverted) { + prompts.log.success('✔ Switched Yarn to node-modules mode'); + report.packageManagerBootstrapConfigured = true; + didMigrate = true; + needsInstall = true; + } + } + if ( addFrameworkShimsForWorkspace( workspaceInfoOptional.rootDir, @@ -1542,7 +1563,9 @@ async function main() { canFormatWithOxfmt(prettierProject.hasDependency, prettierMigrated) ) { clearMigrationProgress(); - await formatMigratedProject(workspaceInfoOptional.rootDir, options.interactive, report); + await formatMigratedProject(workspaceInfoOptional.rootDir, options.interactive, report, { + excludedPaths: preExistingChangedPaths, + }); } if (didMigrate || report.warnings.length > 0) { @@ -1572,7 +1595,12 @@ async function main() { ); // Phase 2: Execute without prompts - const result = await executeMigrationPlan(workspaceInfoOptional, plan, options.interactive); + const result = await executeMigrationPlan( + workspaceInfoOptional, + plan, + options.interactive, + preExistingChangedPaths, + ); showMigrationSummary({ projectRoot: workspaceInfoOptional.rootDir, packageManager: plan.packageManager, diff --git a/packages/cli/src/migration/format.ts b/packages/cli/src/migration/format.ts index 61269bf08e..cf668f7aa8 100644 --- a/packages/cli/src/migration/format.ts +++ b/packages/cli/src/migration/format.ts @@ -12,7 +12,16 @@ type FormatRunner = ( options?: { silent?: boolean; command?: string; commandArgs?: string[] }, ) => Promise; -type FormatPathCollector = (cwd: string) => Promise; +type FormatPathCollector = ( + cwd: string, + excludedPaths?: ReadonlySet, +) => Promise; + +interface FormatMigratedProjectOptions { + format?: FormatRunner; + collectPaths?: FormatPathCollector; + excludedPaths?: ReadonlySet; +} const FORMAT_FAILURE_MESSAGE = 'Automatic formatting failed. Run `vp fmt` manually after migration.'; @@ -36,6 +45,7 @@ function isExistingFile(projectRoot: string, relativePath: string): boolean { */ export async function collectChangedFormatPaths( projectRoot: string, + excludedPaths?: ReadonlySet, ): Promise { try { const git = (args: string[]) => @@ -66,7 +76,9 @@ export async function collectChangedFormatPaths( // Oxfmt owns the supported-file list and skips unknown formats. Passing // every existing changed file keeps migration aligned as Oxfmt evolves. - return [...changedPaths].filter((file) => isExistingFile(projectRoot, file)).toSorted(); + return [...changedPaths] + .filter((file) => !excludedPaths?.has(file) && isExistingFile(projectRoot, file)) + .toSorted(); } catch { return undefined; } @@ -93,11 +105,11 @@ export async function formatMigratedProject( projectRoot: string, interactive: boolean, report: MigrationReport, - format: FormatRunner = runViteFmt, - collectPaths: FormatPathCollector = collectChangedFormatPaths, + options: FormatMigratedProjectOptions = {}, ): Promise { + const { format = runViteFmt, collectPaths = collectChangedFormatPaths, excludedPaths } = options; try { - const paths = await collectPaths(projectRoot); + const paths = await collectPaths(projectRoot, excludedPaths); if (paths?.length === 0) { return true; } diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 3dd253c15b..dea54f83bd 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -2758,6 +2758,11 @@ export function detectYarnPnpMode( projectPath: string, yarnVersion: string, ): YarnPnpDetection | undefined { + const coercedVersion = semver.coerce(yarnVersion); + if (coercedVersion?.major === 1) { + return undefined; + } + const environmentLinker = process.env.YARN_NODE_LINKER?.trim(); if (environmentLinker) { return environmentLinker.toLowerCase() === 'pnp' ? { source: 'environment' } : undefined; @@ -2772,8 +2777,7 @@ export function detectYarnPnpMode( return configuredLinker.toLowerCase() === 'pnp' ? { source: 'configuration' } : undefined; } - const coercedVersion = semver.coerce(yarnVersion); - return coercedVersion?.major === 1 ? undefined : { source: 'default' }; + return { source: 'default' }; } /** Set the project-local Yarn linker while preserving every other rc setting. */ diff --git a/rfcs/migration-command.md b/rfcs/migration-command.md index 53473538dd..dc0ea6c673 100644 --- a/rfcs/migration-command.md +++ b/rfcs/migration-command.md @@ -571,10 +571,10 @@ A successful migration should: The normal script rules rewrite `vite`, `vitest`, `oxlint`, `oxfmt`, `tsdown`, and `lint-staged` to their corresponding `vp` commands. When one of these tools -is launched through `bunx`, migration preserves `bunx` and its flags and -rewrites only the inner command. For example, `bunx --bun vite build` becomes -`bunx --bun vp build` and `bunx --bun vitest run` becomes -`bunx --bun vp test run`. +is launched through `bunx`, migration preserves `bunx`, removes the `--bun` +runtime override, and rewrites the inner command. For example, +`bunx --bun vite build` becomes `bunx vp build` and +`bunx --bun vitest run` becomes `bunx vp test run`. The same behavior applies to `eslint` and `prettier` when their optional migrations run. Nested launcher forms such as @@ -584,10 +584,11 @@ executors remain unchanged and can be addressed separately. ## Post-Migration Formatting After a successful install, migration runs the formatter only on files changed -in the Git worktree. Oxfmt selects the supported formats. This formats -manifests, generated config, and rewritten source without reformatting -unrelated files in a large project. Non-Git projects retain full-project -formatting. Projects that still use Prettier are not formatted automatically. +during migration, excluding paths that were already dirty in the Git worktree. +Oxfmt selects the supported formats. This formats manifests, generated config, +and rewritten source without reformatting unrelated files in a large project. +Non-Git projects retain full-project formatting. Projects that still use +Prettier are not formatted automatically. ## ESLint Migration @@ -615,7 +616,7 @@ When an ESLint flat config (`eslint.config.{js,mjs,cjs,ts,mts,cts}`) and `eslint | `cross-env NODE_ENV=test eslint --cache .` | `cross-env NODE_ENV=test vp lint .` | | `eslint . && vite build` | `vp lint . && vite build` | | `if [ -f .eslintrc ]; then eslint .; fi` | `if [ -f .eslintrc ]; then vp lint . fi` | -| `bunx --bun eslint .` | `bunx --bun vp lint .` | +| `bunx --bun eslint .` | `bunx vp lint .` | | `npx eslint .` | `npx eslint .` (unchanged) | Stripped ESLint-only flags: `--cache`, `--ext`, `--parser`, `--parser-options`, `--plugin`, `--rulesdir`, `--resolve-plugins-relative-to`, `--output-file`, `--env`, `--no-eslintrc`, `--no-error-on-unmatched-pattern`, `--debug`, `--no-inline-config` @@ -671,7 +672,7 @@ When a Prettier configuration file (`.prettierrc*`, `prettier.config.*`, or `"pr | `prettier --plugin prettier-plugin-tailwindcss .` | `vp fmt .` | | `cross-env NODE_ENV=test prettier --write .` | `cross-env NODE_ENV=test vp fmt .` | | `prettier --write . && eslint --fix .` | `vp fmt . && eslint --fix .` | -| `bunx --bun prettier --write .` | `bunx --bun vp fmt .` | +| `bunx --bun prettier --write .` | `bunx vp fmt .` | | `npx prettier --write .` | `npx prettier --write .` (unchanged) | **Stripped Prettier-only flags**: From b3f6c553fdf8109f8c3dfe382a7711dbb2efc04e Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 27 Jun 2026 00:49:48 +0800 Subject: [PATCH 50/78] fix(migrate): preserve pnpm catalog layouts --- docs/guide/migrate-rules.md | 7 + .../package.json | 14 + .../pnpm-workspace.yaml | 9 + .../snap.txt | 51 ++++ .../steps.json | 17 ++ .../package.json | 14 + .../pnpm-workspace.yaml | 10 + .../snap.txt | 52 ++++ .../steps.json | 17 ++ .../src/migration/__tests__/migrator.spec.ts | 162 ++++++++++- packages/cli/src/migration/migrator.ts | 271 +++++++++++++----- rfcs/migrate-existing-projects.md | 3 + rfcs/migration-command.md | 9 +- 13 files changed, 561 insertions(+), 75 deletions(-) create mode 100644 packages/cli/snap-tests-global/migration-upgrade-pnpm-catalogs-default/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-pnpm-catalogs-default/pnpm-workspace.yaml create mode 100644 packages/cli/snap-tests-global/migration-upgrade-pnpm-catalogs-default/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-upgrade-pnpm-catalogs-default/steps.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-pnpm-named-catalog/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-pnpm-named-catalog/pnpm-workspace.yaml create mode 100644 packages/cli/snap-tests-global/migration-upgrade-pnpm-named-catalog/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-upgrade-pnpm-named-catalog/steps.json diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 527d0a5bd2..17e0d29cf0 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -186,6 +186,13 @@ Unrelated `bunx` commands and other package-executor forms remain unchanged. 11 no longer reads the legacy package.json settings. - Migration keeps dependency references, default and named catalogs, overrides, and `peerDependencyRules` consistent. +- pnpm accepts the logical default catalog as either top-level `catalog` or + `catalogs.default`, but not both. Migration preserves the existing form and + never creates the other form beside it. +- When an existing named catalog already owns `vite-plus`, `vite`, or `vitest`, + migration reuses that managed toolchain catalog for newly added dependencies + and overrides. It creates a top-level default catalog only when no managed or + default catalog can be reused. - Each package that lists `vite-plus` in `dependencies` or `devDependencies` gets a direct `vite` dev dependency unless it already declares `vite` in a dependency field. diff --git a/packages/cli/snap-tests-global/migration-upgrade-pnpm-catalogs-default/package.json b/packages/cli/snap-tests-global/migration-upgrade-pnpm-catalogs-default/package.json new file mode 100644 index 0000000000..7bcb9018a5 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-pnpm-catalogs-default/package.json @@ -0,0 +1,14 @@ +{ + "name": "migration-upgrade-pnpm-catalogs-default", + "devDependencies": { + "vite": "catalog:build", + "vite-plus": "catalog:build" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "10.33.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-pnpm-catalogs-default/pnpm-workspace.yaml b/packages/cli/snap-tests-global/migration-upgrade-pnpm-catalogs-default/pnpm-workspace.yaml new file mode 100644 index 0000000000..960a9d10a0 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-pnpm-catalogs-default/pnpm-workspace.yaml @@ -0,0 +1,9 @@ +packages: + - . + +catalogs: + build: + vite: npm:@voidzero-dev/vite-plus-core@^0.1.20 + vite-plus: ^0.1.20 + default: + rari: ^0.14.12 diff --git a/packages/cli/snap-tests-global/migration-upgrade-pnpm-catalogs-default/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-pnpm-catalogs-default/snap.txt new file mode 100644 index 0000000000..cfa75eb083 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-pnpm-catalogs-default/snap.txt @@ -0,0 +1,51 @@ +> vp migrate --no-interactive # reuse the managed named catalog beside catalogs.default +◇ Migrated . to Vite+ +• Node pnpm +• 2 config updates applied + +> cat package.json # existing catalog:build dependency references are preserved +{ + "name": "migration-upgrade-pnpm-catalogs-default", + "devDependencies": { + "vite": "catalog:build", + "vite-plus": "catalog:build" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + }, + "scripts": { + "prepare": "vp config" + } +} + +> cat pnpm-workspace.yaml # catalogs.default remains the only default catalog definition +packages: + - . + +catalogs: + build: + vite: https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@0c515e3fbf5c140db35280d700df0bd600838617 + vite-plus: https://pkg.pr.new/voidzero-dev/vite-plus@0c515e3fbf5c140db35280d700df0bd600838617 + default: + rari: ^0.14.12 +blockExoticSubdeps: false +overrides: + vite: catalog:build + vite-plus: https://pkg.pr.new/voidzero-dev/vite-plus@0c515e3fbf5c140db35280d700df0bd600838617 +peerDependencyRules: + allowAny: + - vite + allowedVersions: + vite: '*' + +> node -e "const fs = require('node:fs'); const p = require('./package.json'); const y = fs.readFileSync('pnpm-workspace.yaml', 'utf8'); if (p.devDependencies.vite !== 'catalog:build' || p.devDependencies['vite-plus'] !== 'catalog:build' || /^catalog:/m.test(y) || !y.includes(' default:') || !y.includes(' vite: catalog:build')) process.exit(1)" # no duplicate top-level catalog is created +> node -e "const fs = require('node:fs'); fs.copyFileSync('package.json', 'package.after-first-migration.json'); fs.copyFileSync('pnpm-workspace.yaml', 'workspace.after-first-migration.yaml')" # capture first migration result +> vp migrate --no-interactive # catalogs.default migration is idempotent +◇ Migrated . to Vite+ +• Node pnpm + +> node -e "const fs = require('node:fs'); if (fs.readFileSync('package.json', 'utf8') !== fs.readFileSync('package.after-first-migration.json', 'utf8') || fs.readFileSync('pnpm-workspace.yaml', 'utf8') !== fs.readFileSync('workspace.after-first-migration.yaml', 'utf8')) process.exit(1)" # rerun leaves catalog placement unchanged \ No newline at end of file diff --git a/packages/cli/snap-tests-global/migration-upgrade-pnpm-catalogs-default/steps.json b/packages/cli/snap-tests-global/migration-upgrade-pnpm-catalogs-default/steps.json new file mode 100644 index 0000000000..46d2a2fc57 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-pnpm-catalogs-default/steps.json @@ -0,0 +1,17 @@ +{ + "env": { + "PNPM_CONFIG_BLOCK_EXOTIC_SUBDEPS": "false", + "VP_FORCE_MIGRATE": "1", + "VP_OVERRIDE_PACKAGES": "{\"vite\":\"https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@0c515e3fbf5c140db35280d700df0bd600838617\"}", + "VP_VERSION": "https://pkg.pr.new/voidzero-dev/vite-plus@0c515e3fbf5c140db35280d700df0bd600838617" + }, + "commands": [ + "vp migrate --no-interactive # reuse the managed named catalog beside catalogs.default", + "cat package.json # existing catalog:build dependency references are preserved", + "cat pnpm-workspace.yaml # catalogs.default remains the only default catalog definition", + "node -e \"const fs = require('node:fs'); const p = require('./package.json'); const y = fs.readFileSync('pnpm-workspace.yaml', 'utf8'); if (p.devDependencies.vite !== 'catalog:build' || p.devDependencies['vite-plus'] !== 'catalog:build' || /^catalog:/m.test(y) || !y.includes(' default:') || !y.includes(' vite: catalog:build')) process.exit(1)\" # no duplicate top-level catalog is created", + "node -e \"const fs = require('node:fs'); fs.copyFileSync('package.json', 'package.after-first-migration.json'); fs.copyFileSync('pnpm-workspace.yaml', 'workspace.after-first-migration.yaml')\" # capture first migration result", + "vp migrate --no-interactive # catalogs.default migration is idempotent", + "node -e \"const fs = require('node:fs'); if (fs.readFileSync('package.json', 'utf8') !== fs.readFileSync('package.after-first-migration.json', 'utf8') || fs.readFileSync('pnpm-workspace.yaml', 'utf8') !== fs.readFileSync('workspace.after-first-migration.yaml', 'utf8')) process.exit(1)\" # rerun leaves catalog placement unchanged" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-pnpm-named-catalog/package.json b/packages/cli/snap-tests-global/migration-upgrade-pnpm-named-catalog/package.json new file mode 100644 index 0000000000..2f0eab4563 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-pnpm-named-catalog/package.json @@ -0,0 +1,14 @@ +{ + "name": "migration-upgrade-pnpm-named-catalog", + "devDependencies": { + "vite": "catalog:vite-stack", + "vite-plus": "catalog:vite-stack" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "10.33.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-pnpm-named-catalog/pnpm-workspace.yaml b/packages/cli/snap-tests-global/migration-upgrade-pnpm-named-catalog/pnpm-workspace.yaml new file mode 100644 index 0000000000..702377390d --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-pnpm-named-catalog/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +packages: + - . + +catalogs: + repo-tooling: + prettier: 3.8.3 + vite-stack: + vite: npm:@voidzero-dev/vite-plus-core@0.1.21 + vitest: npm:@voidzero-dev/vite-plus-test@0.1.21 + vite-plus: 0.1.21 diff --git a/packages/cli/snap-tests-global/migration-upgrade-pnpm-named-catalog/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-pnpm-named-catalog/snap.txt new file mode 100644 index 0000000000..bc923b4a1e --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-pnpm-named-catalog/snap.txt @@ -0,0 +1,52 @@ +> vp migrate --no-interactive # reuse the existing named-only Vite stack catalog +◇ Migrated . to Vite+ +• Node pnpm +• 2 config updates applied + +> cat package.json # catalog:vite-stack dependency references are preserved +{ + "name": "migration-upgrade-pnpm-named-catalog", + "devDependencies": { + "vite": "catalog:vite-stack", + "vite-plus": "catalog:vite-stack" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + }, + "scripts": { + "prepare": "vp config" + } +} + +> cat pnpm-workspace.yaml # managed versions and overrides stay in vite-stack +packages: + - . + +catalogs: + repo-tooling: + prettier: + vite-stack: + vite: https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@0c515e3fbf5c140db35280d700df0bd600838617 + vitest: + vite-plus: https://pkg.pr.new/voidzero-dev/vite-plus@0c515e3fbf5c140db35280d700df0bd600838617 +blockExoticSubdeps: false +overrides: + vite: catalog:vite-stack + vite-plus: https://pkg.pr.new/voidzero-dev/vite-plus@0c515e3fbf5c140db35280d700df0bd600838617 +peerDependencyRules: + allowAny: + - vite + allowedVersions: + vite: '*' + +> node -e "const fs = require('node:fs'); const p = require('./package.json'); const y = fs.readFileSync('pnpm-workspace.yaml', 'utf8'); if (p.devDependencies.vite !== 'catalog:vite-stack' || p.devDependencies['vite-plus'] !== 'catalog:vite-stack' || /^catalog:/m.test(y) || !y.includes(' vite-stack:') || !y.includes(' vite: catalog:vite-stack')) process.exit(1)" # no default catalog is introduced +> node -e "const fs = require('node:fs'); fs.copyFileSync('package.json', 'package.after-first-migration.json'); fs.copyFileSync('pnpm-workspace.yaml', 'workspace.after-first-migration.yaml')" # capture first migration result +> vp migrate --no-interactive # named-only catalog migration is idempotent +◇ Migrated . to Vite+ +• Node pnpm + +> node -e "const fs = require('node:fs'); if (fs.readFileSync('package.json', 'utf8') !== fs.readFileSync('package.after-first-migration.json', 'utf8') || fs.readFileSync('pnpm-workspace.yaml', 'utf8') !== fs.readFileSync('workspace.after-first-migration.yaml', 'utf8')) process.exit(1)" # rerun leaves catalog placement unchanged \ No newline at end of file diff --git a/packages/cli/snap-tests-global/migration-upgrade-pnpm-named-catalog/steps.json b/packages/cli/snap-tests-global/migration-upgrade-pnpm-named-catalog/steps.json new file mode 100644 index 0000000000..b6689469ea --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-pnpm-named-catalog/steps.json @@ -0,0 +1,17 @@ +{ + "env": { + "PNPM_CONFIG_BLOCK_EXOTIC_SUBDEPS": "false", + "VP_FORCE_MIGRATE": "1", + "VP_OVERRIDE_PACKAGES": "{\"vite\":\"https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@0c515e3fbf5c140db35280d700df0bd600838617\"}", + "VP_VERSION": "https://pkg.pr.new/voidzero-dev/vite-plus@0c515e3fbf5c140db35280d700df0bd600838617" + }, + "commands": [ + "vp migrate --no-interactive # reuse the existing named-only Vite stack catalog", + "cat package.json # catalog:vite-stack dependency references are preserved", + "cat pnpm-workspace.yaml # managed versions and overrides stay in vite-stack", + "node -e \"const fs = require('node:fs'); const p = require('./package.json'); const y = fs.readFileSync('pnpm-workspace.yaml', 'utf8'); if (p.devDependencies.vite !== 'catalog:vite-stack' || p.devDependencies['vite-plus'] !== 'catalog:vite-stack' || /^catalog:/m.test(y) || !y.includes(' vite-stack:') || !y.includes(' vite: catalog:vite-stack')) process.exit(1)\" # no default catalog is introduced", + "node -e \"const fs = require('node:fs'); fs.copyFileSync('package.json', 'package.after-first-migration.json'); fs.copyFileSync('pnpm-workspace.yaml', 'workspace.after-first-migration.yaml')\" # capture first migration result", + "vp migrate --no-interactive # named-only catalog migration is idempotent", + "node -e \"const fs = require('node:fs'); if (fs.readFileSync('package.json', 'utf8') !== fs.readFileSync('package.after-first-migration.json', 'utf8') || fs.readFileSync('pnpm-workspace.yaml', 'utf8') !== fs.readFileSync('workspace.after-first-migration.yaml', 'utf8')) process.exit(1)\" # rerun leaves catalog placement unchanged" + ] +} diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 6ed3296f3c..d93e1cc495 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -3179,7 +3179,7 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { peerDependencies: Record; }; expect(pkg.devDependencies.vite).toBe('catalog:vite7'); - expect(pkg.devDependencies['vite-plus']).toBe('catalog:'); + expect(pkg.devDependencies['vite-plus']).toBe('catalog:vite7'); expect(pkg.peerDependencies.vite).toBe('^7.0.0'); // Peer declarations do not keep the managed catalog alive. Resolve the // catalog entry to its public range before pruning it so the peer cannot @@ -3188,6 +3188,166 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { expect(pkg.peerDependencies).not.toHaveProperty('tsdown'); }); + it('reuses catalogs.default without creating a duplicate top-level catalog', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'rari-shaped-workspace', + devDependencies: { + vite: 'catalog:build', + 'vite-plus': 'catalog:build', + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'pnpm-workspace.yaml'), + [ + 'catalogs:', + ' build:', + ' vite: ^8.0.0', + ' vite-plus: ^0.2.0', + ' default:', + ' rari: ^0.14.12', + '', + ].join('\n'), + ); + + const savedForceMigrate = process.env.VP_FORCE_MIGRATE; + process.env.VP_FORCE_MIGRATE = '1'; + try { + rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); + } finally { + if (savedForceMigrate === undefined) { + delete process.env.VP_FORCE_MIGRATE; + } else { + process.env.VP_FORCE_MIGRATE = savedForceMigrate; + } + } + + const workspace = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { + catalog?: Record; + catalogs: Record>; + overrides: Record; + }; + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + }; + + expect(workspace.catalog).toBeUndefined(); + expect(workspace.catalogs.default).toEqual({ rari: '^0.14.12' }); + expect(workspace.catalogs.build.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + expect(workspace.catalogs.build['vite-plus']).toBe('latest'); + expect(workspace.overrides.vite).toBe('catalog:build'); + expect(pkg.devDependencies.vite).toBe('catalog:build'); + expect(pkg.devDependencies['vite-plus']).toBe('catalog:build'); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); + }); + + it('writes managed dependencies into an active catalogs.default definition', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'default-catalog-workspace', + devDependencies: { + vite: 'catalog:', + 'vite-plus': 'catalog:', + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'pnpm-workspace.yaml'), + [ + 'catalogs:', + ' default:', + ' react: ^19.0.0', + ' vite: ^8.0.0', + ' vite-plus: ^0.2.0', + '', + ].join('\n'), + ); + + const savedForceMigrate = process.env.VP_FORCE_MIGRATE; + process.env.VP_FORCE_MIGRATE = '1'; + try { + rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); + } finally { + if (savedForceMigrate === undefined) { + delete process.env.VP_FORCE_MIGRATE; + } else { + process.env.VP_FORCE_MIGRATE = savedForceMigrate; + } + } + + const workspace = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { + catalog?: Record; + catalogs: Record>; + overrides: Record; + }; + + expect(workspace.catalog).toBeUndefined(); + expect(workspace.catalogs.default.react).toBe('^19.0.0'); + expect(workspace.catalogs.default.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + expect(workspace.catalogs.default['vite-plus']).toBe('latest'); + expect(workspace.overrides.vite).toBe('catalog:'); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); + }); + + it('reuses a named-only Vite stack catalog without creating a default catalog', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'vize-shaped-workspace', + devDependencies: { + vite: 'catalog:vite-stack', + 'vite-plus': 'catalog:vite-stack', + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'pnpm-workspace.yaml'), + [ + 'catalogs:', + ' repo-tooling:', + ' prettier: 3.8.3', + ' vite-stack:', + ' vite: npm:@voidzero-dev/vite-plus-core@0.1.21', + ' vitest: npm:@voidzero-dev/vite-plus-test@0.1.21', + ' vite-plus: 0.1.21', + '', + ].join('\n'), + ); + + const savedForceMigrate = process.env.VP_FORCE_MIGRATE; + process.env.VP_FORCE_MIGRATE = '1'; + try { + rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); + } finally { + if (savedForceMigrate === undefined) { + delete process.env.VP_FORCE_MIGRATE; + } else { + process.env.VP_FORCE_MIGRATE = savedForceMigrate; + } + } + + const workspace = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { + catalog?: Record; + catalogs: Record>; + overrides: Record; + }; + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + }; + + expect(workspace.catalog).toBeUndefined(); + expect(workspace.catalogs['repo-tooling']).toEqual({ prettier: '3.8.3' }); + expect(workspace.catalogs['vite-stack'].vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + expect(workspace.catalogs['vite-stack']['vite-plus']).toBe('latest'); + expect(workspace.overrides.vite).toBe('catalog:vite-stack'); + expect(pkg.devDependencies.vite).toBe('catalog:vite-stack'); + expect(pkg.devDependencies['vite-plus']).toBe('catalog:vite-stack'); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); + }); + it('drops only global/vite-plus-parent selector-shaped REMOVE_PACKAGES overrides after moving pnpm config', () => { // Project starts with its pnpm config in package.json (`pkg.pnpm.overrides`). // A selector-shaped provider key is stripped only when it would re-pin diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index dea54f83bd..13beb4bf15 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -819,10 +819,12 @@ type PackageJsonDependencyField = | 'peerDependencies' | 'optionalDependencies'; -type CatalogDependencyResolver = ( +type CatalogDependencyResolver = (( catalogSpec: string, dependencyName: string, -) => string | undefined; +) => string | undefined) & { + preferredCatalogSpec: string; +}; function warnMigration(message: string, report?: MigrationReport) { addMigrationWarning(report, message); @@ -1990,8 +1992,13 @@ export function rewriteStandaloneProject( // ensure vite-plus is in devDependencies if (!pkg.devDependencies?.[VITE_PLUS_NAME] || isForceOverrideMode()) { + const existingVitePlusSpec = pkg.devDependencies?.[VITE_PLUS_NAME]; const version = - supportCatalog && !VITE_PLUS_VERSION.startsWith('file:') ? 'catalog:' : VITE_PLUS_VERSION; + supportCatalog && !VITE_PLUS_VERSION.startsWith('file:') + ? existingVitePlusSpec?.startsWith('catalog:') + ? existingVitePlusSpec + : (catalogDependencyResolver?.preferredCatalogSpec ?? 'catalog:') + : VITE_PLUS_VERSION; pkg.devDependencies = { ...pkg.devDependencies, [VITE_PLUS_NAME]: version, @@ -2003,6 +2010,7 @@ export function rewriteStandaloneProject( pkg, packageManager, usePnpmWorkspaceYaml && packageManager !== PackageManager.npm, + catalogDependencyResolver, ); return pkg; }); @@ -2310,7 +2318,7 @@ function rewritePnpmWorkspaceYaml( editYamlFile(pnpmWorkspaceYamlPath, (doc) => { // catalog - rewriteCatalog(doc, usesVitest, vitestEcosystemPackages); + const preferredCatalogSpec = rewriteCatalog(doc, usesVitest, vitestEcosystemPackages); if (!writeWorkspaceSettings) { return; } @@ -2349,7 +2357,9 @@ function rewritePnpmWorkspaceYaml( } for (const key of Object.keys(managed)) { const currentVersion = getYamlMapScalarStringValue(overrides, key); - const version = getCatalogDependencySpec(currentVersion, managed[key], true); + const version = getCatalogDependencySpec(currentVersion, managed[key], true, { + preferredCatalogSpec, + }); doc.setIn(['overrides', scalarString(key)], scalarString(version)); } // remove dependency selector from vite, e.g. "vite-plugin-svgr>vite": "npm:vite@7.0.12" @@ -2972,6 +2982,7 @@ function getCatalogDependencySpec( dependencyName?: string; packageManager?: PackageManager; catalogDependencyResolver?: CatalogDependencyResolver; + preferredCatalogSpec?: string; }, ): string { if (options?.dependencyField === 'peerDependencies') { @@ -2993,7 +3004,9 @@ function getCatalogDependencySpec( if (!supportCatalog || version.startsWith('file:')) { return version; } - return currentValue?.startsWith('catalog:') ? currentValue : 'catalog:'; + return currentValue?.startsWith('catalog:') + ? currentValue + : (options?.preferredCatalogSpec ?? 'catalog:'); } /** @@ -3021,6 +3034,7 @@ function ensureDirectViteForPnpm( }, packageManager: PackageManager, supportCatalog: boolean, + catalogDependencyResolver?: CatalogDependencyResolver, ): boolean { const viteOverride = VITE_PLUS_OVERRIDE_PACKAGES.vite; if (packageManager !== PackageManager.pnpm || !viteOverride) { @@ -3041,7 +3055,9 @@ function ensureDirectViteForPnpm( // (file:/npm:) override spec; the extra getCatalogDependencySpec options only // matter for an existing value or a peerDependencies field, neither of which // applies here (we only reach this for a fresh devDependencies entry). - const viteSpec = getCatalogDependencySpec(undefined, viteOverride, supportCatalog); + const viteSpec = getCatalogDependencySpec(undefined, viteOverride, supportCatalog, { + preferredCatalogSpec: catalogDependencyResolver?.preferredCatalogSpec, + }); // Insert `vite` in sorted position rather than appending it: oxfmt sorts // package.json dependencies and `vp migrate` has no later format pass, so an // out-of-order key would fail a follow-up `vp check`. @@ -3129,8 +3145,14 @@ function createCatalogDependencyResolver( workspacesObj?.catalogs, ); const fromPkg = createCatalogDependencyResolverFromCatalogs(pkg.catalog, pkg.catalogs); - return (catalogSpec, dependencyName) => + const resolver = (catalogSpec: string, dependencyName: string) => fromWorkspaces(catalogSpec, dependencyName) ?? fromPkg(catalogSpec, dependencyName); + return Object.assign(resolver, { + preferredCatalogSpec: + workspacesObj?.catalog || workspacesObj?.catalogs + ? fromWorkspaces.preferredCatalogSpec + : fromPkg.preferredCatalogSpec, + }); } return undefined; } @@ -3139,15 +3161,55 @@ function createCatalogDependencyResolverFromCatalogs( catalog: Record | undefined, catalogs: Record> | undefined, ): CatalogDependencyResolver { - return (catalogSpec, dependencyName) => { + const preferredCatalogSpec = selectPreferredCatalogSpec(catalog, catalogs); + const resolver = (catalogSpec: string, dependencyName: string) => { const catalogName = catalogSpec.slice('catalog:'.length); - // pnpm/bun reserve `default` as the name of the top-level `catalog:` map, - // so `catalog:default` resolves there, not a named `catalogs` entry. + // pnpm accepts the default catalog in either `catalog` or + // `catalogs.default`, but rejects a workspace that defines both. Both + // `catalog:` and `catalog:default` resolve through that one logical + // default catalog. if (catalogName && catalogName !== 'default') { return catalogs?.[catalogName]?.[dependencyName]; } - return catalog?.[dependencyName]; + return (catalog ?? catalogs?.default)?.[dependencyName]; }; + return Object.assign(resolver, { preferredCatalogSpec }); +} + +function selectPreferredCatalogSpec( + catalog: Record | undefined, + catalogs: Record> | undefined, +): string { + const candidates: Array<{ spec: string; values: Record }> = []; + if (catalog) { + candidates.push({ spec: 'catalog:', values: catalog }); + } + for (const [name, values] of Object.entries(catalogs ?? {})) { + candidates.push({ + spec: name === 'default' ? 'catalog:' : `catalog:${name}`, + values, + }); + } + + // Keep the managed toolchain together when a project already has a catalog + // for it (for example Vize's `catalogs.vite-stack` and Rari's + // `catalogs.build`). Prefer vite-plus as the strongest signal, followed by + // vite and vitest. Existing dependency references keep their exact catalog + // spec; this choice is for newly injected dependencies and overrides. + for (const dependencyName of [VITE_PLUS_NAME, 'vite', 'vitest']) { + const matching = candidates.find(({ values }) => Object.hasOwn(values, dependencyName)); + if (matching) { + return matching.spec; + } + } + + // Reuse either valid spelling of the default catalog. Do not repurpose an + // unrelated named catalog; when no managed/default catalog exists, create + // the conventional top-level `catalog` instead. + if (catalog || catalogs?.default) { + return 'catalog:'; + } + return 'catalog:'; } function getYamlMapScalarStringValue(map: unknown, key: string): string | undefined { @@ -3194,67 +3256,90 @@ function rewriteCatalog( doc: YamlDocument, usesVitest: boolean, vitestEcosystemPackages: ReadonlySet, +): string { + const parsed = doc.toJS() as { + catalog?: Record; + catalogs?: Record>; + } | null; + const preferredCatalogSpec = selectPreferredCatalogSpec(parsed?.catalog, parsed?.catalogs); + const preferredCatalogName = preferredCatalogSpec.slice('catalog:'.length); + const targetPath: readonly string[] = + preferredCatalogName && preferredCatalogName !== 'default' + ? ['catalogs', preferredCatalogName] + : doc.has('catalog') || !doc.hasIn(['catalogs', 'default']) + ? ['catalog'] + : ['catalogs', 'default']; + + rewriteYamlCatalogAtPath(doc, targetPath, true, usesVitest, vitestEcosystemPackages); + + if (targetPath[0] !== 'catalog') { + rewriteYamlCatalogAtPath(doc, ['catalog'], false, usesVitest, vitestEcosystemPackages); + } + + const catalogs = doc.getIn(['catalogs']); + if (catalogs instanceof YAMLMap) { + for (const item of catalogs.items) { + const catalogName = item.key instanceof Scalar ? item.key.value : undefined; + if ( + typeof catalogName !== 'string' || + !(item.value instanceof YAMLMap) || + (targetPath[0] === 'catalogs' && targetPath[1] === catalogName) + ) { + continue; + } + rewriteYamlCatalogAtPath( + doc, + ['catalogs', catalogName], + false, + usesVitest, + vitestEcosystemPackages, + ); + } + } + + return preferredCatalogSpec; +} + +function rewriteYamlCatalogAtPath( + doc: YamlDocument, + catalogPath: readonly string[], + addMissing: boolean, + usesVitest: boolean, + vitestEcosystemPackages: ReadonlySet, ): void { const managed = managedOverridePackages(usesVitest); + let catalogNode = doc.getIn(catalogPath); + if (!(catalogNode instanceof YAMLMap)) { + if (!addMissing) { + return; + } + catalogNode = new YAMLMap(); + doc.setIn(catalogPath, catalogNode); + } + const catalog = catalogNode as YAMLMap; + // Common case (no direct vitest): remove any lingering managed `vitest` // catalog entry so it resolves transitively through vite-plus. if (!usesVitest) { - removeYamlMapVitestEntry(doc.getIn(['catalog'])); + removeYamlMapVitestEntry(catalog); } for (const [key, value] of Object.entries(managed)) { // 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 // ignore setting catalog if value starts with 'file:' - if (value.startsWith('file:')) { + if (value.startsWith('file:') || (!addMissing && !catalog.has(key))) { continue; } - doc.setIn(['catalog', key], scalarString(value)); + catalog.set(scalarString(key), scalarString(value)); } - if (!VITE_PLUS_VERSION.startsWith('file:')) { - doc.setIn(['catalog', VITE_PLUS_NAME], scalarString(VITE_PLUS_VERSION)); + if (!VITE_PLUS_VERSION.startsWith('file:') && (addMissing || catalog.has(VITE_PLUS_NAME))) { + catalog.set(scalarString(VITE_PLUS_NAME), scalarString(VITE_PLUS_VERSION)); } for (const name of REMOVE_PACKAGES) { - const path = ['catalog', name]; - if (doc.hasIn(path)) { - doc.deleteIn(path); - } + catalog.delete(name); } // Drop any entry still pointing at the deleted `vite-plus-test` wrapper. - pruneYamlMapLegacyWrapperAliases(doc.getIn(['catalog'])); - rewriteVitestEcosystemYamlCatalog(doc.getIn(['catalog']), vitestEcosystemPackages); - - 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; - } - // Common case: strip a lingering managed `vitest` entry from this named - // catalog (existing entries only — named catalogs are never grown here). - if (!usesVitest) { - removeYamlMapVitestEntry(item.value); - } - for (const [key, value] of Object.entries(managed)) { - 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); - } - } - pruneYamlMapLegacyWrapperAliases(item.value); - rewriteVitestEcosystemYamlCatalog(item.value, vitestEcosystemPackages); - } + pruneYamlMapLegacyWrapperAliases(catalog); + rewriteVitestEcosystemYamlCatalog(catalog, vitestEcosystemPackages); } function rewriteVitestEcosystemYamlCatalog( @@ -3553,10 +3638,10 @@ function rewriteRootWorkspacePackageJson( [VITE_PLUS_NAME]: packageManager === PackageManager.npm || VITE_PLUS_VERSION.startsWith('file:') ? VITE_PLUS_VERSION - : 'catalog:', + : (catalogDependencyResolver?.preferredCatalogSpec ?? 'catalog:'), }; } - ensureDirectViteForPnpm(pkg, packageManager, true); + ensureDirectViteForPnpm(pkg, packageManager, true, catalogDependencyResolver); return pkg; }); @@ -3816,14 +3901,14 @@ function vitePlusDependencyNeedsConcreteVersion(pkg: BootstrapPackageJson): bool ); } -function defaultCatalogVitePlusDependencyPending( +function catalogVitePlusDependencyPending( pkg: BootstrapPackageJson, catalogDependencyResolver: CatalogDependencyResolver | undefined, ): boolean { const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; return dependencyGroups.some((dependencies) => { const spec = dependencies?.[VITE_PLUS_NAME]; - if (spec !== 'catalog:' && spec !== 'catalog:default') { + if (!spec?.startsWith('catalog:')) { return false; } return catalogDependencyResolver?.(spec, VITE_PLUS_NAME) !== VITE_PLUS_VERSION; @@ -3960,12 +4045,20 @@ function yarnrcSatisfiesVitePlus(projectPath: string, usesVitest: boolean): bool const doc = readYamlFile(yarnrcYmlPath) as { nodeLinker?: string; catalog?: Record; + catalogs?: Record>; } | null; + const resolver = createCatalogDependencyResolverFromCatalogs(doc?.catalog, doc?.catalogs); + const catalogName = resolver.preferredCatalogSpec.slice('catalog:'.length); + const managedCatalog = + catalogName && catalogName !== 'default' + ? doc?.catalogs?.[catalogName] + : (doc?.catalog ?? doc?.catalogs?.default); return ( !!doc && Object.hasOwn(doc, 'nodeLinker') && - overridesSatisfyVitePlus(doc.catalog, usesVitest) && - (VITE_PLUS_VERSION.startsWith('file:') || doc.catalog?.[VITE_PLUS_NAME] === VITE_PLUS_VERSION) + overridesSatisfyVitePlus(managedCatalog, usesVitest) && + (VITE_PLUS_VERSION.startsWith('file:') || + resolver(resolver.preferredCatalogSpec, VITE_PLUS_NAME) === VITE_PLUS_VERSION) ); } @@ -4000,8 +4093,14 @@ function readBunCatalogDependencyResolver(pkg: { workspacesObj.catalogs, ); const fromPkg = createCatalogDependencyResolverFromCatalogs(pkg.catalog, pkg.catalogs); - return (catalogSpec, dependencyName) => + const resolver = (catalogSpec: string, dependencyName: string) => fromWorkspaces(catalogSpec, dependencyName) ?? fromPkg(catalogSpec, dependencyName); + return Object.assign(resolver, { + preferredCatalogSpec: + workspacesObj.catalog || workspacesObj.catalogs + ? fromWorkspaces.preferredCatalogSpec + : fromPkg.preferredCatalogSpec, + }); } function getAlignedVitestEcosystemDependencySpec( @@ -4020,6 +4119,7 @@ function getAlignedVitestEcosystemDependencySpec( dependencyName, packageManager, catalogDependencyResolver, + preferredCatalogSpec: catalogDependencyResolver?.preferredCatalogSpec, }); } @@ -4138,6 +4238,7 @@ function reconcileVitePlusBootstrapPackage( dependencies.vite, VITE_PLUS_OVERRIDE_PACKAGES.vite, supportCatalog, + { preferredCatalogSpec: catalogDependencyResolver?.preferredCatalogSpec }, ); } } @@ -4220,6 +4321,7 @@ function reconcileVitePlusBootstrapPackage( existingGroup.vitest, VITEST_VERSION, supportCatalog, + { preferredCatalogSpec: catalogDependencyResolver?.preferredCatalogSpec }, ); } } else { @@ -4228,6 +4330,7 @@ function reconcileVitePlusBootstrapPackage( undefined, VITEST_VERSION, supportCatalog, + { preferredCatalogSpec: catalogDependencyResolver?.preferredCatalogSpec }, ); } } else { @@ -4324,8 +4427,10 @@ export function detectVitePlusBootstrapPending( (usePnpmWorkspaceYaml || packageManager === PackageManager.yarn || packageManager === PackageManager.bun); - const canonicalVitePlusSpec = supportCatalog ? 'catalog:' : VITE_PLUS_VERSION; const catalogDependencyResolver = createCatalogDependencyResolver(projectPath, packageManager); + const canonicalVitePlusSpec = supportCatalog + ? (catalogDependencyResolver?.preferredCatalogSpec ?? 'catalog:') + : VITE_PLUS_VERSION; if ( workspaceVitestEcosystemCatalogReferencesPending( projectPath, @@ -4399,7 +4504,7 @@ export function detectVitePlusBootstrapPending( } const resolver = readPnpmWorkspaceCatalogDependencyResolver(projectPath); return ( - defaultCatalogVitePlusDependencyPending(pkg, resolver) || + catalogVitePlusDependencyPending(pkg, resolver) || !overridesSatisfyVitePlus(readPnpmWorkspaceOverrides(projectPath), usesVitest, resolver) || !pnpmPeerDependencyRulesSatisfyVitePlus( readPnpmWorkspacePeerDependencyRules(projectPath), @@ -4431,10 +4536,17 @@ function ensureVitePlusDependencySpecs( if (spec === undefined || spec === version) { continue; } + // Catalog writers update every existing managed entry in place. Keep a + // package's deliberate named/default reference instead of collapsing all + // packages onto the workspace's preferred catalog, including pkg.pr.new + // force-override runs. + if (version.startsWith('catalog:') && spec.startsWith('catalog:')) { + continue; + } // Concrete target (e.g. `latest`): also rewrite an existing `catalog:` // pin onto the concrete version — `isProtocolPinnedSpec` matches // `catalog:`, so handle it explicitly before the generic plain-range case. - if (version !== 'catalog:' && spec.startsWith('catalog:')) { + if (!version.startsWith('catalog:') && spec.startsWith('catalog:')) { dependencies[VITE_PLUS_NAME] = version; changed = true; continue; @@ -4554,11 +4666,13 @@ export function ensureVitePlusBootstrap( (usePnpmWorkspaceYaml || workspaceInfo.packageManager === PackageManager.yarn || workspaceInfo.packageManager === PackageManager.bun); - const canonicalVitePlusSpec = supportCatalog ? 'catalog:' : VITE_PLUS_VERSION; const catalogDependencyResolver = createCatalogDependencyResolver( projectPath, workspaceInfo.packageManager, ); + const canonicalVitePlusSpec = supportCatalog + ? (catalogDependencyResolver?.preferredCatalogSpec ?? 'catalog:') + : VITE_PLUS_VERSION; const ecosystemCatalogReferencesPending = workspaceVitestEcosystemCatalogReferencesPending( projectPath, workspaceInfo.packages, @@ -4675,7 +4789,7 @@ export function ensureVitePlusBootstrap( result.packageJson || ecosystemCatalogReferencesPending || !pnpmWorkspaceExoticSubdepsSettingSatisfied(projectPath) || - defaultCatalogVitePlusDependencyPending(pkg, catalogDependencyResolver) || + catalogVitePlusDependencyPending(pkg, catalogDependencyResolver) || !overridesSatisfyVitePlus( readPnpmWorkspaceOverrides(projectPath), usesVitest, @@ -5172,6 +5286,7 @@ export function rewritePackageJson( dependencyName: key, packageManager, catalogDependencyResolver, + preferredCatalogSpec: catalogDependencyResolver?.preferredCatalogSpec, }); needVitePlus = true; } @@ -5193,7 +5308,16 @@ export function rewritePackageJson( if (isForceOverrideMode()) { for (const { dependencies } of dependencyGroups) { if (dependencies?.[VITE_PLUS_NAME]) { - dependencies[VITE_PLUS_NAME] = VITE_PLUS_VERSION; + // The referenced catalog entry is rewritten to the pkg.pr.new target + // separately. Preserve named/default catalog references so projects + // such as Vize do not gain an unnecessary default catalog. + if ( + !supportCatalog || + VITE_PLUS_VERSION.startsWith('file:') || + !dependencies[VITE_PLUS_NAME].startsWith('catalog:') + ) { + dependencies[VITE_PLUS_NAME] = VITE_PLUS_VERSION; + } needVitePlus = true; } } @@ -5339,7 +5463,9 @@ export function rewritePackageJson( // http(s)://) so deliberate user pins survive; only vanilla version ranges // (e.g. `^0.1.20`, `latest`) are rewritten. const canonicalVitePlusSpec = - supportCatalog && !VITE_PLUS_VERSION.startsWith('file:') ? 'catalog:' : VITE_PLUS_VERSION; + supportCatalog && !VITE_PLUS_VERSION.startsWith('file:') + ? (catalogDependencyResolver?.preferredCatalogSpec ?? 'catalog:') + : VITE_PLUS_VERSION; const existingVitePlus = pkg.devDependencies?.[VITE_PLUS_NAME]; const shouldNormalizeExistingVitePlus = !!existingVitePlus && @@ -5375,7 +5501,7 @@ export function rewritePackageJson( [VITE_PLUS_NAME]: canonicalVitePlusSpec, }; } - ensureDirectViteForPnpm(pkg, packageManager, supportCatalog); + ensureDirectViteForPnpm(pkg, packageManager, supportCatalog, catalogDependencyResolver); // Add `vitest` as a direct devDependency when: // - a remaining dependency likely peer-depends on vitest (e.g. // vitest-browser-svelte), OR @@ -5405,6 +5531,7 @@ export function rewritePackageJson( undefined, VITEST_VERSION, supportCatalog, + { preferredCatalogSpec: catalogDependencyResolver?.preferredCatalogSpec }, ); } } diff --git a/rfcs/migrate-existing-projects.md b/rfcs/migrate-existing-projects.md index 94fb8a5f75..52c3929a4c 100644 --- a/rfcs/migrate-existing-projects.md +++ b/rfcs/migrate-existing-projects.md @@ -38,6 +38,7 @@ When PnP is active, interactive migration prints the incompatibility and asks wh | `vitest` itself (default) | Provided by `vite-plus`, so by default not project-managed: remove any project-level `vitest` from dependency fields, string-valued `overrides`/`resolutions`/`pnpm.overrides`, `pnpm-workspace.yaml` `overrides`+`catalog(s)`, bun/yarn catalog, and the `vitest` entry in pnpm `peerDependencyRules`. Resolve a surviving `peerDependencies.vitest` catalog reference to its public range before pruning the catalog. A future `vp update vite-plus` then keeps it correct with no project pin to drift. | | `vitest`, peer/browser/Nuxt exception | Keep a managed `vitest` in the package that needs it (add to `devDependencies` and pin/override it to the bundled version) when that package directly installs a required-`vitest` peer consumer, uses browser mode, retains a direct upstream `vitest` package reference, or declares `@nuxt/test-utils`. Required peers are detected from installed package metadata, not package names alone, so integrations such as `vite-plugin-gherkin` are covered. When that metadata is unavailable in a clean checkout, preserve an existing direct Vitest conservatively. Other retained references include module augmentations, nested or root `compilerOptions.types`, `require.resolve` / `import.meta.resolve`, and the intentionally unre-written `vitest/package.json` export. In a Nuxt test-utils package, all `vitest` and `vitest/*` specifiers remain upstream consistently; in other packages, rewriteable imports and triple-slash directives do not leave a lasting pin. The direct dependency satisfies strict peer resolution; the shared override collapses the workspace to the bundled version. | | `vitest` ecosystem packages | When Vitest is managed, align current lockstep `@vitest/*` packages the project lists (`@vitest/coverage-v8`, `@vitest/coverage-istanbul`, `@vitest/ui`, `@vitest/web-worker`, ...) to the bundled `VITEST_VERSION`. Exclude `@vitest/eslint-plugin` (separate version line, optional `vitest: *` peer) and deprecated `@vitest/coverage-c8` (last published at `0.33.0`; no Vitest 4 release exists). When `VP_OVERRIDE_PACKAGES` omits Vitest, skip ecosystem alignment so user-owned exact-peer versions stay compatible. Browser packages keep their dedicated handling: `@vitest/browser` / `-preview` are bundled by `vite-plus`; `@vitest/browser-playwright` / `-webdriverio` are opt-in (pinned + framework peer kept). | +| Catalog placement | Preserve the project's active catalog layout. Treat top-level `catalog` and `catalogs.default` as alternative definitions of one logical default and never emit both. Prefer an existing managed named catalog containing `vite-plus`, then `vite`, then `vitest`, for newly injected managed dependencies and overrides. Keep existing named/default dependency references intact, including force-override/pkg.pr.new runs; create a top-level default catalog only when no managed or default catalog can be reused. | | Workspaces | Reconcile every package manifest, not only the root. Localize the direct `vitest` dependency to packages that need it; keep shared catalogs/overrides only when at least one package needs them. Re-pin existing plain `vite-plus` ranges consistently while preserving deliberate protocol specs. | | Legacy wrapper | Remove every `@voidzero-dev/vite-plus-test` alias (deps, overrides, catalogs); repoint direct wrapper imports to `vite-plus/test`. `vite-plus/test*` imports are left unchanged (stable public API). | | pnpm config location | On pnpm 10.6.2+, move recognized root settings from `package.json#pnpm` into `pnpm-workspace.yaml` and remove the legacy object when empty; retain unknown third-party keys. Older pnpm keeps these settings in `package.json` because complete workspace support, including `peerDependencyRules`, was not reliable before 10.6.2. | @@ -131,6 +132,8 @@ Covered by unit tests in `migrator.spec.ts` (vitest removal, required-peer provi | Required Vitest peers discovered from installed dependency metadata | `migration-upgrade-required-vitest-peer-metadata-npm` | | Deprecated `@vitest/coverage-c8` is not assigned a nonexistent Vitest 4 version | `migration-upgrade-deprecated-coverage-c8-npm` | | Standalone Yarn writes catalog specs in one pass and is idempotent | `migration-standalone-yarn4-idempotent` | +| pnpm preserves `catalogs.default` without adding top-level `catalog` | `migration-upgrade-pnpm-catalogs-default` | +| pnpm reuses a named-only managed toolchain catalog during pkg.pr.new migration | `migration-upgrade-pnpm-named-catalog` | | Unmanaged exact-peer Vitest ecosystem versions remain aligned with user-owned Vitest | `migration-vitest-unmanaged-override` | | Nuxt packages preserve all upstream `vitest` imports without affecting sibling packages | `migration-upgrade-nuxt-test-utils`, `migration-upgrade-nuxt-test-utils-monorepo` | diff --git a/rfcs/migration-command.md b/rfcs/migration-command.md index dc0ea6c673..42c83495b7 100644 --- a/rfcs/migration-command.md +++ b/rfcs/migration-command.md @@ -32,7 +32,7 @@ When transitioning to Vite+, projects typically use standalone tools like vite, - ✅ **Dependencies**: vite, vitest, oxlint, oxfmt → vite-plus - ✅ **Overrides**: Force vite → vite-plus (for all dependencies) - - pnpm (no existing `pnpm` config): Writes `overrides`, `peerDependencyRules`, and `catalog` to `pnpm-workspace.yaml` + - pnpm (workspace settings): Writes `overrides` and `peerDependencyRules` to `pnpm-workspace.yaml`; reuses an existing managed/default catalog or creates top-level `catalog` when none exists - pnpm (existing `pnpm` config): Adds `pnpm.overrides` and `pnpm.peerDependencyRules` in `package.json` - npm/bun: Adds `overrides.vite` mapping in `package.json` - yarn: Adds `resolutions.vite` mapping in `package.json` @@ -252,6 +252,11 @@ peerDependencyRules: vitest: '*' ``` +This example shows the fallback top-level default catalog. If the workspace +already uses `catalogs.default`, migration keeps that form. If an existing named +catalog owns the Vite+ toolchain, migration keeps package references and the +managed override on that named catalog instead of introducing a default. + **After (pnpm, existing `pnpm` config) -- `package.json`:** Projects that already have a `pnpm` field in `package.json` (e.g., with `overrides` or `onlyBuiltDependencies`) keep using `package.json` for pnpm config: @@ -497,7 +502,7 @@ export default defineConfig({ ### for pnpm -For monorepo projects and standalone projects without existing `pnpm` config in `package.json`, overrides, peerDependencyRules, and catalog are written to `pnpm-workspace.yaml`. Projects with existing `pnpm` config in `package.json` keep using `package.json`. +For monorepo projects and standalone projects without existing `pnpm` config in `package.json`, overrides and peerDependencyRules are written to `pnpm-workspace.yaml`. Catalog-backed projects reuse their existing managed/default catalog layout; migration creates top-level `catalog` only when no suitable catalog exists. Projects with existing `pnpm` config in `package.json` keep using `package.json`. `pnpm-workspace.yaml` From 7da35efd27f5e5b02504c3963e4d462886aff388 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 27 Jun 2026 00:54:13 +0800 Subject: [PATCH 51/78] test(migrate): use release version for default catalog --- .../snap.txt | 15 +++++---------- .../steps.json | 7 +------ 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/packages/cli/snap-tests-global/migration-upgrade-pnpm-catalogs-default/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-pnpm-catalogs-default/snap.txt index cfa75eb083..9153456e8c 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-pnpm-catalogs-default/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-pnpm-catalogs-default/snap.txt @@ -1,7 +1,7 @@ > vp migrate --no-interactive # reuse the managed named catalog beside catalogs.default ◇ Migrated . to Vite+ • Node pnpm -• 2 config updates applied +• Package manager settings configured > cat package.json # existing catalog:build dependency references are preserved { @@ -16,9 +16,6 @@ "version": "", "onFail": "download" } - }, - "scripts": { - "prepare": "vp config" } } @@ -28,14 +25,12 @@ packages: catalogs: build: - vite: https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@0c515e3fbf5c140db35280d700df0bd600838617 - vite-plus: https://pkg.pr.new/voidzero-dev/vite-plus@0c515e3fbf5c140db35280d700df0bd600838617 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: default: rari: ^0.14.12 -blockExoticSubdeps: false overrides: vite: catalog:build - vite-plus: https://pkg.pr.new/voidzero-dev/vite-plus@0c515e3fbf5c140db35280d700df0bd600838617 peerDependencyRules: allowAny: - vite @@ -45,7 +40,7 @@ peerDependencyRules: > node -e "const fs = require('node:fs'); const p = require('./package.json'); const y = fs.readFileSync('pnpm-workspace.yaml', 'utf8'); if (p.devDependencies.vite !== 'catalog:build' || p.devDependencies['vite-plus'] !== 'catalog:build' || /^catalog:/m.test(y) || !y.includes(' default:') || !y.includes(' vite: catalog:build')) process.exit(1)" # no duplicate top-level catalog is created > node -e "const fs = require('node:fs'); fs.copyFileSync('package.json', 'package.after-first-migration.json'); fs.copyFileSync('pnpm-workspace.yaml', 'workspace.after-first-migration.yaml')" # capture first migration result > vp migrate --no-interactive # catalogs.default migration is idempotent -◇ Migrated . to Vite+ -• Node pnpm +This project is already using Vite+! Happy coding! + > node -e "const fs = require('node:fs'); if (fs.readFileSync('package.json', 'utf8') !== fs.readFileSync('package.after-first-migration.json', 'utf8') || fs.readFileSync('pnpm-workspace.yaml', 'utf8') !== fs.readFileSync('workspace.after-first-migration.yaml', 'utf8')) process.exit(1)" # rerun leaves catalog placement unchanged \ No newline at end of file diff --git a/packages/cli/snap-tests-global/migration-upgrade-pnpm-catalogs-default/steps.json b/packages/cli/snap-tests-global/migration-upgrade-pnpm-catalogs-default/steps.json index 46d2a2fc57..4fc78638a4 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-pnpm-catalogs-default/steps.json +++ b/packages/cli/snap-tests-global/migration-upgrade-pnpm-catalogs-default/steps.json @@ -1,10 +1,5 @@ { - "env": { - "PNPM_CONFIG_BLOCK_EXOTIC_SUBDEPS": "false", - "VP_FORCE_MIGRATE": "1", - "VP_OVERRIDE_PACKAGES": "{\"vite\":\"https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@0c515e3fbf5c140db35280d700df0bd600838617\"}", - "VP_VERSION": "https://pkg.pr.new/voidzero-dev/vite-plus@0c515e3fbf5c140db35280d700df0bd600838617" - }, + "env": {}, "commands": [ "vp migrate --no-interactive # reuse the managed named catalog beside catalogs.default", "cat package.json # existing catalog:build dependency references are preserved", From bd63c0d748b21c03af7edae082a083bce401234f Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 27 Jun 2026 10:08:08 +0800 Subject: [PATCH 52/78] fix(migrate): address remaining review feedback --- .../scripts/create-pkg-pr-new-overrides.mjs | 8 + .github/scripts/test-pkg-pr-new-migrate.sh | 10 +- docs/guide/migrate-rules.md | 11 +- .../snap.txt | 3 +- .../snap.txt | 3 +- .../snap.txt | 3 +- .../src/migration/__tests__/format.spec.ts | 32 +++- .../src/migration/__tests__/migrator.spec.ts | 91 ++++++++--- packages/cli/src/migration/format.ts | 2 +- packages/cli/src/migration/migrator.ts | 144 ++++++++++++++++-- rfcs/migration-command.md | 27 ++-- 11 files changed, 281 insertions(+), 53 deletions(-) create mode 100644 .github/scripts/create-pkg-pr-new-overrides.mjs diff --git a/.github/scripts/create-pkg-pr-new-overrides.mjs b/.github/scripts/create-pkg-pr-new-overrides.mjs new file mode 100644 index 0000000000..ed481569d0 --- /dev/null +++ b/.github/scripts/create-pkg-pr-new-overrides.mjs @@ -0,0 +1,8 @@ +const [vite, vitest] = process.argv.slice(2); + +if (!vite || !vitest) { + console.error('Usage: create-pkg-pr-new-overrides.mjs '); + process.exit(2); +} + +process.stdout.write(JSON.stringify({ vite, vitest })); diff --git a/.github/scripts/test-pkg-pr-new-migrate.sh b/.github/scripts/test-pkg-pr-new-migrate.sh index 2dc5314f19..3e917dfcc7 100755 --- a/.github/scripts/test-pkg-pr-new-migrate.sh +++ b/.github/scripts/test-pkg-pr-new-migrate.sh @@ -56,6 +56,7 @@ fi repo_root="$(cd "$script_dir/../.." && pwd -P)" installer="$repo_root/packages/cli/install.sh" pnpm_version_helper="$script_dir/ensure-pkg-pr-new-pnpm-version.mjs" +override_json_helper="$script_dir/create-pkg-pr-new-overrides.mjs" if [ ! -f "$installer" ]; then echo "error: Vite+ installer not found: $installer" >&2 @@ -67,6 +68,11 @@ if [ ! -f "$pnpm_version_helper" ]; then exit 2 fi +if [ ! -f "$override_json_helper" ]; then + echo "error: pkg.pr.new override helper not found: $override_json_helper" >&2 + exit 2 +fi + is_git_repo=0 if command -v git >/dev/null 2>&1 && git -C "$project_dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then is_git_repo=1 @@ -245,8 +251,8 @@ fi export VP_HOME="$pr_home" export PATH="$VP_HOME/bin:$PATH" export VP_VERSION="$vite_plus_spec" -export VP_OVERRIDE_PACKAGES="$(printf \ - '{"vite":"%s","vitest":"%s"}' \ +export VP_OVERRIDE_PACKAGES="$(node \ + "$override_json_helper" \ "$vite_override_spec" \ "$vitest_version")" export VP_FORCE_MIGRATE=1 diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 17e0d29cf0..49189e0b5b 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -114,8 +114,11 @@ Do not align independently versioned or obsolete packages: The base `@vitest/browser` runtime and `@vitest/browser-preview` are bundled by Vite+ and should be removed as direct dependencies. The Playwright and -WebdriverIO providers remain opt-in: keep or add the provider at the bundled -Vitest version and ensure its `playwright` or `webdriverio` peer is installed. +WebdriverIO providers remain opt-in. Preserve an existing catalog reference when +its catalog owns the provider. When migration injects a provider into a +catalog-capable project, it uses the preferred catalog and adds the provider at +the bundled Vitest version. Otherwise, it writes the concrete bundled version. +Ensure the provider's `playwright` or `webdriverio` peer is also installed. Migration detects providers before rewriting imports. This includes legacy projects that aliased `vitest` to `@voidzero-dev/vite-plus-test` and import from @@ -180,6 +183,10 @@ Unrelated `bunx` commands and other package-executor forms remain unchanged. architecture and build policy, audit/update configuration, and configuration dependencies. It removes the `pnpm` object when it becomes empty and preserves unknown keys that may belong to other tooling. +- When both files define the same migrated pnpm setting, migration recursively + merges object entries and retains unique array entries. Values from + `package.json#pnpm` win at conflicting scalar leaves, while workspace-only + sibling entries are preserved. - Before pnpm 10.6.2, migration retains these settings in `package.json#pnpm`. General workspace-setting support started in pnpm 10.5.0, but overrides required 10.5.1 and `peerDependencyRules` required 10.6.2. pnpm diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/snap.txt index 7e5af10e5e..b6769c1cd5 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/snap.txt @@ -8,7 +8,7 @@ "name": "migration-upgrade-browser-peer-only-pnpm", "devDependencies": { "vite-plus": "catalog:", - "@vitest/browser-playwright": "", + "@vitest/browser-playwright": "catalog:", "playwright": "*", "vitest": "catalog:" }, @@ -29,6 +29,7 @@ catalog: vite: npm:@voidzero-dev/vite-plus-core@ vite-plus: vitest: + '@vitest/browser-playwright': overrides: vite: 'catalog:' vitest: 'catalog:' diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/snap.txt index 70b04086d7..0a7b84d588 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/snap.txt @@ -8,7 +8,7 @@ "name": "migration-upgrade-browser-source-only-pnpm", "devDependencies": { "vite-plus": "catalog:", - "@vitest/browser-playwright": "", + "@vitest/browser-playwright": "catalog:", "playwright": "*", "vitest": "catalog:" }, @@ -26,6 +26,7 @@ catalog: vite: npm:@voidzero-dev/vite-plus-core@ vite-plus: vitest: + '@vitest/browser-playwright': overrides: vite: 'catalog:' vitest: 'catalog:' diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/snap.txt index b4da107e9f..5c7bde5526 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/snap.txt @@ -8,7 +8,7 @@ "name": "migration-upgrade-browser-webdriverio-pnpm", "devDependencies": { "vite-plus": "catalog:", - "@vitest/browser-webdriverio": "", + "@vitest/browser-webdriverio": "catalog:", "webdriverio": "*", "vitest": "catalog:" }, @@ -26,6 +26,7 @@ catalog: vite: npm:@voidzero-dev/vite-plus-core@ vite-plus: vitest: + '@vitest/browser-webdriverio': overrides: vite: 'catalog:' vitest: 'catalog:' diff --git a/packages/cli/src/migration/__tests__/format.spec.ts b/packages/cli/src/migration/__tests__/format.spec.ts index df30e13d57..040cf13787 100644 --- a/packages/cli/src/migration/__tests__/format.spec.ts +++ b/packages/cli/src/migration/__tests__/format.spec.ts @@ -30,11 +30,41 @@ describe('formatMigratedProject', () => { expect(format).toHaveBeenCalledWith('/project', false, ['package.json', 'vite.config.ts'], { silent: false, command: process.execPath, - commandArgs: [...process.execArgv, process.argv[1]], + commandArgs: [...process.execArgv, path.resolve(process.cwd(), process.argv[1])], }); expect(report.warnings).toEqual([]); }); + it('resolves a relative CLI entry before formatting from the project root', async () => { + const originalCliEntry = process.argv[1]; + process.argv[1] = './packages/cli/src/migration/bin.ts'; + try { + const format = vi.fn().mockResolvedValue({ + durationMs: 1, + exitCode: 0, + status: 'formatted', + }); + const report = createMigrationReport(); + + await expect( + formatMigratedProject('/different/project', false, report, { + format, + collectPaths: vi.fn().mockResolvedValue(['package.json']), + }), + ).resolves.toBe(true); + expect(format).toHaveBeenCalledWith('/different/project', false, ['package.json'], { + silent: false, + command: process.execPath, + commandArgs: [ + ...process.execArgv, + path.resolve(process.cwd(), './packages/cli/src/migration/bin.ts'), + ], + }); + } finally { + process.argv[1] = originalCliEntry; + } + }); + it('skips formatting when migration changed no supported files', async () => { const format = vi.fn(); const report = createMigrationReport(); diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index d93e1cc495..44ede2e78f 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -2203,9 +2203,13 @@ describe('ensureVitePlusBootstrap', () => { const pkg = readJson(path.join(tmpDir, 'package.json')) as { devDependencies: Record; }; - expect(pkg.devDependencies['@vitest/browser-playwright']).toBe(VITEST_VERSION); + expect(pkg.devDependencies['@vitest/browser-playwright']).toBe('catalog:'); expect(pkg.devDependencies.playwright).toBe('*'); expect(pkg.devDependencies.vitest).toBe('catalog:'); + const workspace = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { + catalog: Record; + }; + expect(workspace.catalog['@vitest/browser-playwright']).toBe(VITEST_VERSION); expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); }); @@ -2291,13 +2295,14 @@ describe('ensureVitePlusBootstrap', () => { peerDependencies: Record; }; expect(pkg.peerDependencies['@vitest/browser-playwright']).toBe('^4.0.0'); - expect(pkg.devDependencies['@vitest/browser-playwright']).toBe(VITEST_VERSION); + expect(pkg.devDependencies['@vitest/browser-playwright']).toBe('catalog:'); expect(pkg.devDependencies.playwright).toBe('*'); expect(pkg.devDependencies.vitest).toBe('catalog:'); const workspace = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { catalog: Record; overrides: Record; }; + expect(workspace.catalog['@vitest/browser-playwright']).toBe(VITEST_VERSION); expect(workspace.catalog.vitest).toBe(VITEST_VERSION); expect(workspace.overrides.vitest).toBe('catalog:'); expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); @@ -2589,6 +2594,27 @@ describe('ensureVitePlusBootstrap', () => { }, }), ); + fs.writeFileSync( + path.join(tmpDir, 'pnpm-workspace.yaml'), + [ + 'overrides:', + ' react: 18.3.1', + 'onlyBuiltDependencies:', + ' - sharp', + 'packageExtensions:', + " 'other-package@*':", + ' peerDependencies:', + " vue: '*'", + 'patchedDependencies:', + ' is-even@1.0.0: patches/is-even.patch', + 'peerDependencyRules:', + ' allowAny:', + ' - react', + ' allowedVersions:', + " react: '*'", + '', + ].join('\n'), + ); expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(true); const result = ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); @@ -2605,17 +2631,21 @@ describe('ensureVitePlusBootstrap', () => { onlyBuiltDependencies: string[]; packageExtensions: Record; patchedDependencies: Record; + overrides: Record; peerDependencyRules: { allowAny: string[]; allowedVersions: Record }; }; - expect(workspace.onlyBuiltDependencies).toEqual(['esbuild']); + expect(workspace.onlyBuiltDependencies).toEqual(['sharp', 'esbuild']); expect(workspace.packageExtensions).toEqual({ + 'other-package@*': { peerDependencies: { vue: '*' } }, 'some-package@*': { peerDependencies: { react: '*' } }, }); expect(workspace.patchedDependencies).toEqual({ + 'is-even@1.0.0': 'patches/is-even.patch', 'is-odd@3.0.1': 'patches/is-odd.patch', }); - expect(workspace.peerDependencyRules.allowAny).toEqual(['vite']); - expect(workspace.peerDependencyRules.allowedVersions).toEqual({ vite: '*' }); + expect(workspace.overrides.react).toBe('18.3.1'); + expect(workspace.peerDependencyRules.allowAny).toEqual(['react', 'vite']); + expect(workspace.peerDependencyRules.allowedVersions).toEqual({ react: '*', vite: '*' }); expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); }); @@ -4018,16 +4048,17 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { string, string >; - // Opt-in provider is pinned to a CONCRETE bundled vitest version in the - // user's own deps — it is deliberately NOT in VITE_PLUS_OVERRIDE_PACKAGES, so - // no catalog entry is written for it and it must self-resolve. - expect(devDeps).toHaveProperty('@vitest/browser-webdriverio', VITEST_VERSION); + // The injected provider follows the same catalog as the managed Vitest + // dependency, and the catalog owns its concrete bundled version. + expect(devDeps).toHaveProperty('@vitest/browser-webdriverio', 'catalog:'); expect(devDeps.webdriverio).toBe('*'); expect(devDeps.vitest).toBe('catalog:'); const yaml = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { allowBuilds: Record; + catalog: Record; }; + expect(yaml.catalog['@vitest/browser-webdriverio']).toBe(VITEST_VERSION); expect(yaml.allowBuilds.edgedriver).toBe(true); expect(yaml.allowBuilds.geckodriver).toBe(true); }); @@ -4060,11 +4091,12 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { string, string >; - // Opt-in provider pinned to a CONCRETE bundled vitest version in the user's - // own deps — deliberately NOT in VITE_PLUS_OVERRIDE_PACKAGES, so no catalog - // entry is written for it and it must self-resolve. - expect(devDeps).toHaveProperty('@vitest/browser-playwright', VITEST_VERSION); + expect(devDeps).toHaveProperty('@vitest/browser-playwright', 'catalog:'); expect(devDeps.playwright).toBe('*'); + const yaml = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { + catalog: Record; + }; + expect(yaml.catalog['@vitest/browser-playwright']).toBe(VITEST_VERSION); }); it.each([ @@ -4106,9 +4138,13 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { string, string >; - expect(devDeps[`@vitest/browser-${provider}`]).toBe(VITEST_VERSION); + expect(devDeps[`@vitest/browser-${provider}`]).toBe('catalog:'); expect(devDeps[provider]).toBe('*'); expect(devDeps.vitest).toBe('catalog:'); + const workspace = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { + catalog: Record; + }; + expect(workspace.catalog[`@vitest/browser-${provider}`]).toBe(VITEST_VERSION); expect(fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf8')).toContain( `from 'vite-plus/test/${subpath}'`, ); @@ -4157,9 +4193,13 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { string, string >; - expect(devDeps['@vitest/browser-playwright']).toBe(VITEST_VERSION); + expect(devDeps['@vitest/browser-playwright']).toBe('catalog:'); expect(devDeps.playwright).toBe('^1.56.1'); expect(devDeps.vitest).toBe('catalog:'); + const workspace = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { + catalog: Record; + }; + expect(workspace.catalog['@vitest/browser-playwright']).toBe(VITEST_VERSION); expect(fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf8')).toContain( "from 'vite-plus/test/browser-playwright'", ); @@ -4193,8 +4233,12 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { string, string >; - expect(devDeps).toHaveProperty('@vitest/browser-playwright', VITEST_VERSION); + expect(devDeps).toHaveProperty('@vitest/browser-playwright', 'catalog:'); expect(devDeps.playwright).toBe('*'); + const workspace = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { + catalog: Record; + }; + expect(workspace.catalog['@vitest/browser-playwright']).toBe(VITEST_VERSION); }); it('injects the webdriverio provider on a re-run from the migrated provider-subpath import', () => { @@ -4226,11 +4270,13 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { string, string >; - expect(devDeps).toHaveProperty('@vitest/browser-webdriverio', VITEST_VERSION); + expect(devDeps).toHaveProperty('@vitest/browser-webdriverio', 'catalog:'); expect(devDeps.webdriverio).toBe('*'); const yaml = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { allowBuilds: Record; + catalog: Record; }; + expect(yaml.catalog['@vitest/browser-webdriverio']).toBe(VITEST_VERSION); expect(yaml.allowBuilds.edgedriver).toBe(true); expect(yaml.allowBuilds.geckodriver).toBe(true); }); @@ -4261,11 +4307,13 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { string, string >; - expect(devDeps).toHaveProperty('@vitest/browser-webdriverio', VITEST_VERSION); + expect(devDeps).toHaveProperty('@vitest/browser-webdriverio', 'catalog:'); expect(devDeps.webdriverio).toBe('*'); const yaml = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { allowBuilds: Record; + catalog: Record; }; + expect(yaml.catalog['@vitest/browser-webdriverio']).toBe(VITEST_VERSION); expect(yaml.allowBuilds.edgedriver).toBe(true); expect(yaml.allowBuilds.geckodriver).toBe(true); }); @@ -4299,8 +4347,9 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { const pkg = readJson(path.join(tmpDir, 'package.json')); const devDeps = pkg.devDependencies as Record; - // Provider installed in the user's own deps at the bundled vitest version. - expect(devDeps).toHaveProperty('@vitest/browser-webdriverio', VITEST_VERSION); + // Provider installed through the same catalog used by the managed Vitest + // dependency. + expect(devDeps).toHaveProperty('@vitest/browser-webdriverio', 'catalog:'); expect(devDeps.webdriverio).toBe('*'); // Peer-only declaration is left intact and its `catalog:` reference still // resolves because the catalog entry is preserved (NOT deleted). @@ -4311,7 +4360,7 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { catalog: Record; allowBuilds: Record; }; - expect(yaml.catalog['@vitest/browser-webdriverio']).toBe('4.0.0'); + expect(yaml.catalog['@vitest/browser-webdriverio']).toBe(VITEST_VERSION); expect(yaml.catalog.vitest).toBe(VITEST_VERSION); expect(yaml.allowBuilds.edgedriver).toBe(true); expect(yaml.allowBuilds.geckodriver).toBe(true); diff --git a/packages/cli/src/migration/format.ts b/packages/cli/src/migration/format.ts index cf668f7aa8..654aa62455 100644 --- a/packages/cli/src/migration/format.ts +++ b/packages/cli/src/migration/format.ts @@ -113,7 +113,7 @@ export async function formatMigratedProject( if (paths?.length === 0) { return true; } - const cliEntry = process.argv[1]; + const cliEntry = process.argv[1] ? path.resolve(process.cwd(), process.argv[1]) : undefined; const result = await format(projectRoot, interactive, paths, { silent: false, ...(cliEntry diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 13beb4bf15..882fcf275e 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -1796,6 +1796,40 @@ function takePnpmWorkspaceSettings(pkg: { return Object.keys(settings).length > 0 ? settings : undefined; } +function isPlainRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * Preserve workspace-level siblings while moving the effective package.json + * pnpm settings into pnpm-workspace.yaml. Package values win at scalar leaves, + * while objects merge recursively and arrays retain unique entries from both + * locations. + */ +function mergePnpmWorkspaceSetting(existing: unknown, incoming: unknown): unknown { + if (Array.isArray(existing) && Array.isArray(incoming)) { + const seen = new Set(); + return [...existing, ...incoming].filter((value) => { + const key = JSON.stringify(value); + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); + } + if (isPlainRecord(existing) && isPlainRecord(incoming)) { + const merged: Record = { ...existing }; + for (const [key, value] of Object.entries(incoming)) { + merged[key] = Object.hasOwn(existing, key) + ? mergePnpmWorkspaceSetting(existing[key], value) + : value; + } + return merged; + } + return incoming; +} + function migratePnpmSettingsToWorkspaceYaml( projectPath: string, settings: Record | undefined, @@ -1808,10 +1842,12 @@ function migratePnpmSettingsToWorkspaceYaml( fs.writeFileSync(pnpmWorkspaceYamlPath, ''); } editYamlFile(pnpmWorkspaceYamlPath, (doc) => { + const workspace = (doc.toJS() ?? {}) as Record; for (const [key, value] of Object.entries(settings)) { // package.json#pnpm was the effective source before migration. Preserve - // that precedence when the workspace file already defines the same key. - doc.set(key, doc.createNode(value)); + // that precedence at conflicting leaves while retaining workspace-only + // object properties and array entries. + doc.set(key, doc.createNode(mergePnpmWorkspaceSetting(workspace[key], value))); } }); } @@ -1832,6 +1868,7 @@ export function rewriteStandaloneProject( const packageManager = workspaceInfo.packageManager; const catalogDependencyResolver = createCatalogDependencyResolver(projectPath, packageManager); const vitestEcosystemPackages = collectVitestEcosystemInstallDependencyNames(projectPath); + const providerCatalogAdditions = collectInjectedProviderNames(projectPath); const pnpmMajorVersion = pnpmMajor(workspaceInfo.downloadPackageManager.version); let extractedStagedConfig: Record | null = null; let movedPnpmSettings: Record | undefined; @@ -2024,6 +2061,8 @@ export function rewriteStandaloneProject( shouldAllowBrowserProviderBuilds, usesVitest, vitestEcosystemPackages, + true, + providerCatalogAdditions, ); } @@ -2038,7 +2077,7 @@ export function rewriteStandaloneProject( } if (packageManager === PackageManager.yarn) { - rewriteYarnrcYml(projectPath, usesVitest, vitestEcosystemPackages); + rewriteYarnrcYml(projectPath, usesVitest, vitestEcosystemPackages, providerCatalogAdditions); } // Merge extracted staged config into vite.config.ts, then remove lint-staged from package.json @@ -2103,9 +2142,18 @@ export function rewriteMonorepo( workspaceInfo.rootDir, workspaceInfo.packages, ); + const providerCatalogAdditions = collectInjectedProviderNames( + workspaceInfo.rootDir, + workspaceInfo.packages, + ); // rewrite root workspace if (workspaceInfo.packageManager === PackageManager.yarn) { - rewriteYarnrcYml(workspaceInfo.rootDir, workspaceUsesVitest, vitestEcosystemPackages); + rewriteYarnrcYml( + workspaceInfo.rootDir, + workspaceUsesVitest, + vitestEcosystemPackages, + providerCatalogAdditions, + ); } else if (workspaceInfo.packageManager === PackageManager.bun) { rewriteBunCatalog(workspaceInfo.rootDir, workspaceUsesVitest, vitestEcosystemPackages); } @@ -2129,6 +2177,7 @@ export function rewriteMonorepo( workspaceUsesVitest, vitestEcosystemPackages, usePnpmWorkspaceSettings, + providerCatalogAdditions, ); if (usePnpmWorkspaceSettings && isForceOverrideMode()) { migratePnpmOverridesToWorkspaceYaml(workspaceInfo.rootDir, { @@ -2309,6 +2358,7 @@ function rewritePnpmWorkspaceYaml( usesVitest: boolean, vitestEcosystemPackages: ReadonlySet, writeWorkspaceSettings = true, + catalogAdditions: ReadonlySet = new Set(), ): void { const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); if (!fs.existsSync(pnpmWorkspaceYamlPath)) { @@ -2318,7 +2368,12 @@ function rewritePnpmWorkspaceYaml( editYamlFile(pnpmWorkspaceYamlPath, (doc) => { // catalog - const preferredCatalogSpec = rewriteCatalog(doc, usesVitest, vitestEcosystemPackages); + const preferredCatalogSpec = rewriteCatalog( + doc, + usesVitest, + vitestEcosystemPackages, + catalogAdditions, + ); if (!writeWorkspaceSettings) { return; } @@ -2934,6 +2989,7 @@ function rewriteYarnrcYml( projectPath: string, usesVitest: boolean, vitestEcosystemPackages: ReadonlySet, + catalogAdditions: ReadonlySet = new Set(), ): void { const yarnrcYmlPath = path.join(projectPath, '.yarnrc.yml'); if (!fs.existsSync(yarnrcYmlPath)) { @@ -2965,7 +3021,7 @@ function rewriteYarnrcYml( } doc.setIn(['npmPreapprovedPackages'], npmPreapprovedPackages); // catalog - rewriteCatalog(doc, usesVitest, vitestEcosystemPackages); + rewriteCatalog(doc, usesVitest, vitestEcosystemPackages, catalogAdditions); }); } @@ -3256,6 +3312,7 @@ function rewriteCatalog( doc: YamlDocument, usesVitest: boolean, vitestEcosystemPackages: ReadonlySet, + catalogAdditions: ReadonlySet, ): string { const parsed = doc.toJS() as { catalog?: Record; @@ -3270,10 +3327,24 @@ function rewriteCatalog( ? ['catalog'] : ['catalogs', 'default']; - rewriteYamlCatalogAtPath(doc, targetPath, true, usesVitest, vitestEcosystemPackages); + rewriteYamlCatalogAtPath( + doc, + targetPath, + true, + usesVitest, + vitestEcosystemPackages, + catalogAdditions, + ); if (targetPath[0] !== 'catalog') { - rewriteYamlCatalogAtPath(doc, ['catalog'], false, usesVitest, vitestEcosystemPackages); + rewriteYamlCatalogAtPath( + doc, + ['catalog'], + false, + usesVitest, + vitestEcosystemPackages, + catalogAdditions, + ); } const catalogs = doc.getIn(['catalogs']); @@ -3293,6 +3364,7 @@ function rewriteCatalog( false, usesVitest, vitestEcosystemPackages, + catalogAdditions, ); } } @@ -3306,6 +3378,7 @@ function rewriteYamlCatalogAtPath( addMissing: boolean, usesVitest: boolean, vitestEcosystemPackages: ReadonlySet, + catalogAdditions: ReadonlySet, ): void { const managed = managedOverridePackages(usesVitest); let catalogNode = doc.getIn(catalogPath); @@ -3334,6 +3407,13 @@ function rewriteYamlCatalogAtPath( if (!VITE_PLUS_VERSION.startsWith('file:') && (addMissing || catalog.has(VITE_PLUS_NAME))) { catalog.set(scalarString(VITE_PLUS_NAME), scalarString(VITE_PLUS_VERSION)); } + if (addMissing && VITEST_IS_MANAGED_OVERRIDE) { + for (const name of catalogAdditions) { + if (isAlignableVitestEcosystemPackage(name)) { + catalog.set(scalarString(name), scalarString(VITEST_VERSION)); + } + } + } for (const name of REMOVE_PACKAGES) { catalog.delete(name); } @@ -4277,7 +4357,12 @@ function reconcileVitePlusBootstrapPackage( } } else { pkg.devDependencies ??= {}; - pkg.devDependencies[provider] = VITEST_VERSION; + pkg.devDependencies[provider] = getCatalogDependencySpec( + undefined, + VITEST_VERSION, + supportCatalog && packageManager !== PackageManager.bun, + { preferredCatalogSpec: catalogDependencyResolver?.preferredCatalogSpec }, + ); } const frameworkPeer = BROWSER_PROVIDER_PEER_DEPS[provider]; const frameworkPresent = dependencyGroups.some( @@ -4374,6 +4459,32 @@ function collectVitestEcosystemInstallDependencyNames( return names; } +function collectInjectedProviderNames(rootDir: string, packages?: WorkspacePackage[]): Set { + const names = new Set(); + for (const packagePath of bootstrapProjectPaths(rootDir, packages)) { + const packageJsonPath = path.join(packagePath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + continue; + } + const pkg = readJsonFile(packageJsonPath) as BootstrapPackageJson; + const sourceModes = collectProviderSourceModes(packagePath); + const installGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; + const dependencyGroups = [...installGroups, pkg.peerDependencies]; + for (const provider of OPT_IN_BROWSER_PROVIDERS) { + const used = + sourceModes[provider] || + dependencyGroups.some((dependencies) => dependencies?.[provider] !== undefined); + const installed = installGroups.some( + (dependencies) => dependencies?.[provider] !== undefined, + ); + if (used && !installed) { + names.add(provider); + } + } + } + return names; +} + function workspaceVitestEcosystemCatalogReferencesPending( rootDir: string, packages: WorkspacePackage[] | undefined, @@ -4682,6 +4793,10 @@ export function ensureVitePlusBootstrap( projectPath, workspaceInfo.packages, ); + const providerCatalogAdditions = collectInjectedProviderNames( + projectPath, + workspaceInfo.packages, + ); let movedPnpmSettings: Record | undefined; editJsonFile< @@ -4806,6 +4921,8 @@ export function ensureVitePlusBootstrap( shouldAllowBrowserBuilds, usesVitest, vitestEcosystemPackages, + true, + providerCatalogAdditions, ); } if (fs.existsSync(pnpmWorkspaceYamlPath)) { @@ -4824,7 +4941,7 @@ export function ensureVitePlusBootstrap( const before = fs.existsSync(yarnrcYmlPath) ? fs.readFileSync(yarnrcYmlPath, 'utf-8') : undefined; - rewriteYarnrcYml(projectPath, usesVitest, vitestEcosystemPackages); + rewriteYarnrcYml(projectPath, usesVitest, vitestEcosystemPackages, providerCatalogAdditions); const after = fs.readFileSync(yarnrcYmlPath, 'utf-8'); result.packageManagerConfig = before !== after; } else if (workspaceInfo.packageManager === PackageManager.bun) { @@ -5400,7 +5517,12 @@ export function rewritePackageJson( } } else { pkg.devDependencies ??= {}; - pkg.devDependencies[provider] = VITEST_VERSION; + pkg.devDependencies[provider] = getCatalogDependencySpec( + undefined, + VITEST_VERSION, + supportCatalog && packageManager !== PackageManager.bun, + { preferredCatalogSpec: catalogDependencyResolver?.preferredCatalogSpec }, + ); } const peer = BROWSER_PROVIDER_PEER_DEPS[provider]; // 'webdriverio' / 'playwright' const peerPresent = diff --git a/rfcs/migration-command.md b/rfcs/migration-command.md index 42c83495b7..1dcfe16dd2 100644 --- a/rfcs/migration-command.md +++ b/rfcs/migration-command.md @@ -202,15 +202,18 @@ Wrote agent instructions to AGENTS.md "react": "^18.2.0" }, "devDependencies": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vite": "npm:@voidzero-dev/vite-plus-core@", "@vitejs/plugin-react": "^4.2.0" }, "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest" + "vite": "npm:@voidzero-dev/vite-plus-core@" } } ``` +`` is the concrete version bundled with the CLI running the +migration; migration does not persist the mutable `latest` tag. + **After (pnpm, no existing `pnpm` config) -- `package.json`:** ```json @@ -240,8 +243,8 @@ Wrote agent instructions to AGENTS.md ```yaml catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: @@ -265,12 +268,12 @@ Projects that already have a `pnpm` field in `package.json` (e.g., with `overrid { "name": "my-package", "devDependencies": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vite-plus": "latest" + "vite": "npm:@voidzero-dev/vite-plus-core@", + "vite-plus": "" }, "pnpm": { "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest" + "vite": "npm:@voidzero-dev/vite-plus-core@" }, "peerDependencyRules": { "allowAny": ["vite"], @@ -508,8 +511,8 @@ For monorepo projects and standalone projects without existing `pnpm` config in ```yaml catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: @@ -527,10 +530,10 @@ peerDependencyRules: ```json { "devDependencies": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest" + "vite": "npm:@voidzero-dev/vite-plus-core@" }, "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest" + "vite": "npm:@voidzero-dev/vite-plus-core@" } } ``` @@ -541,7 +544,7 @@ peerDependencyRules: ```yaml catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest + vite: npm:@voidzero-dev/vite-plus-core@ ``` `package.json` From c70b63cd31a2a17a51dae8fed3f029e107333232 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 27 Jun 2026 16:53:53 +0800 Subject: [PATCH 53/78] fix(migrate): address upgrade-path review findings - npm-reinstall: guard the package-lock.json parse so a malformed or merge-conflicted lockfile no longer aborts migration mid-write - bun bootstrap: add the direct vite dependency so bun install resolves vitest's vite peer (mirrors the full-migration path) - compat-worker: restore withConfigMetadataResolution so the config compatibility check skips user plugin factories instead of running them (no indefinite hang, no silently dropped warning) - format: only fall back to whole-project formatting outside a git worktree (skip on git errors rather than reformatting everything), and batch the changed-file list to avoid ARG_MAX on large monorepos - oxlint-plugin: invalidate the @nuxt/test-utils cache by package.json mtime so long-lived lint/LSP sessions pick up manifest edits - migrator: drop the dead importOptions wrapper and the unused detectNuxtTestUtilsVitestImportFiles, dedupe redundant source-tree scans and the bun catalog resolver Claude-Session: https://claude.ai/code/session_01DQhS6o1fyQd1yjiee6W8jR --- .../__tests__/format-fallback.spec.ts | 48 ++++ .../src/migration/__tests__/format.spec.ts | 19 ++ .../src/migration/__tests__/migrator.spec.ts | 50 ++-- .../migration/__tests__/npm-reinstall.spec.ts | 16 ++ packages/cli/src/migration/compat-worker.ts | 12 +- packages/cli/src/migration/format.ts | 57 ++++- packages/cli/src/migration/migrator.ts | 222 +++++++----------- packages/cli/src/migration/npm-reinstall.ts | 52 ++-- packages/cli/src/oxlint-plugin.ts | 17 +- 9 files changed, 302 insertions(+), 191 deletions(-) create mode 100644 packages/cli/src/migration/__tests__/format-fallback.spec.ts diff --git a/packages/cli/src/migration/__tests__/format-fallback.spec.ts b/packages/cli/src/migration/__tests__/format-fallback.spec.ts new file mode 100644 index 0000000000..8b1921249c --- /dev/null +++ b/packages/cli/src/migration/__tests__/format-fallback.spec.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../utils/command.ts', () => ({ + runCommandSilently: vi.fn(), +})); + +import { runCommandSilently } from '../../utils/command.ts'; +import { collectChangedFormatPaths } from '../format.ts'; + +const mockRunCommandSilently = vi.mocked(runCommandSilently); + +function result(exitCode: number, stdout = '') { + return { exitCode, stdout: Buffer.from(stdout), stderr: Buffer.alloc(0) }; +} + +describe('collectChangedFormatPaths git fallback', () => { + beforeEach(() => { + mockRunCommandSilently.mockReset(); + }); + + it('falls back to full-project formatting when not inside a Git worktree', async () => { + mockRunCommandSilently.mockImplementation(({ args }) => { + if (args[0] === 'rev-parse') { + return Promise.resolve(result(128, '')); + } + throw new Error(`unexpected git ${args.join(' ')}`); + }); + + await expect(collectChangedFormatPaths('/project')).resolves.toBeUndefined(); + expect(mockRunCommandSilently).toHaveBeenCalledTimes(1); + }); + + it('skips formatting instead of reformatting the whole tree when Git cannot list changes', async () => { + mockRunCommandSilently.mockImplementation(({ args }) => { + if (args[0] === 'rev-parse') { + return Promise.resolve(result(0, 'true\n')); + } + if (args[0] === 'diff' && !args.includes('--cached')) { + // e.g. a locked repo or mid-rebase: the change enumeration errors. + return Promise.resolve(result(128, '')); + } + return Promise.resolve(result(0, '')); + }); + + // Must be [] (skip), not undefined (which would reformat every file). + await expect(collectChangedFormatPaths('/project')).resolves.toEqual([]); + }); +}); diff --git a/packages/cli/src/migration/__tests__/format.spec.ts b/packages/cli/src/migration/__tests__/format.spec.ts index 040cf13787..a4d7796566 100644 --- a/packages/cli/src/migration/__tests__/format.spec.ts +++ b/packages/cli/src/migration/__tests__/format.spec.ts @@ -65,6 +65,25 @@ describe('formatMigratedProject', () => { } }); + it('splits a very large changed-file set across multiple format invocations', async () => { + const format = vi.fn().mockResolvedValue({ durationMs: 1, exitCode: 0, status: 'formatted' }); + const report = createMigrationReport(); + const paths = Array.from( + { length: 5000 }, + (_, index) => `packages/app/src/very/deeply/nested/module-${index}.ts`, + ); + const collectPaths = vi.fn().mockResolvedValue(paths); + + await expect( + formatMigratedProject('/project', false, report, { format, collectPaths }), + ).resolves.toBe(true); + // A single spawn with all 5000 paths would risk E2BIG; it must be batched. + expect(format.mock.calls.length).toBeGreaterThan(1); + // Every path is still formatted exactly once across the batches. + expect(format.mock.calls.flatMap((call) => call[2])).toEqual(paths); + expect(report.warnings).toEqual([]); + }); + it('skips formatting when migration changed no supported files', async () => { const format = vi.fn(); const report = createMigrationReport(); diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 44ede2e78f..9b9b3617ac 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -30,7 +30,6 @@ const { rewriteMonorepo, rewriteMonorepoProject, detectPendingCoreMigration, - detectNuxtTestUtilsVitestImportFiles, detectVitePlusBootstrapPending, ensureVitePlusBootstrap, finalizeCoreMigrationForExistingVitePlus, @@ -1431,6 +1430,32 @@ describe('ensureVitePlusBootstrap', () => { expect(pkg.devEngines.packageManager.name).toBe(PackageManager.npm); }); + it('adds the direct vite dependency for an existing bun Vite+ project so bun resolves the vitest peer', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { 'vite-plus': 'latest', vitest: '4.1.9' }, + overrides: { vite: 'npm:@voidzero-dev/vite-plus-core@0.1.0' }, + devEngines: { + packageManager: { name: 'bun', version: '1.2.0', onFail: 'download' }, + }, + }), + ); + + const result = ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.bun)); + + expect(result.changed).toBe(true); + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + overrides: Record; + }; + // Bun needs `vite` as a direct dependency (pointing at vite-plus-core) for + // vitest's peer to resolve; the bootstrap path previously only wrote the + // override and left `bun install` broken. + expect(pkg.devDependencies.vite).toContain('@voidzero-dev/vite-plus-core'); + }); + it('removes the stale vitest wrapper override for a non-vitest npm project', () => { fs.writeFileSync( path.join(tmpDir, 'package.json'), @@ -3559,29 +3584,6 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { ); }); - it.each(['dependencies', 'devDependencies', 'optionalDependencies'] as const)( - 'detects package-wide upstream Vitest imports from %s without installed metadata', - (dependencyGroup) => { - fs.writeFileSync( - path.join(tmpDir, 'package.json'), - JSON.stringify({ - name: 'nuxt-project', - [dependencyGroup]: { '@nuxt/test-utils': '^4.0.3' }, - }), - ); - fs.writeFileSync( - path.join(tmpDir, 'nuxt.spec.ts'), - "import { vi } from 'vitest';\nimport { mockNuxtImport } from '@nuxt/test-utils/runtime';\n", - ); - fs.writeFileSync(path.join(tmpDir, 'unit.spec.ts'), "import { expect } from 'vitest';\n"); - - expect(detectNuxtTestUtilsVitestImportFiles(tmpDir)).toEqual([ - path.join(tmpDir, 'nuxt.spec.ts'), - path.join(tmpDir, 'unit.spec.ts'), - ]); - }, - ); - it('preserves all upstream Vitest imports in a Nuxt test-utils package', () => { fs.writeFileSync( path.join(tmpDir, 'package.json'), diff --git a/packages/cli/src/migration/__tests__/npm-reinstall.spec.ts b/packages/cli/src/migration/__tests__/npm-reinstall.spec.ts index a25bc2dbb3..d2dbc69986 100644 --- a/packages/cli/src/migration/__tests__/npm-reinstall.spec.ts +++ b/packages/cli/src/migration/__tests__/npm-reinstall.spec.ts @@ -81,4 +81,20 @@ describe('prepareNpmViteAliasReinstall', () => { expect(fs.existsSync(staleVite)).toBe(false); expect(prepareNpmViteAliasReinstall(rootDir, [rootDir, workspaceDir])).toBe(false); }); + + it('does not throw on a malformed package-lock.json and still prunes install trees', () => { + const rootDir = createTempDir(); + const workspaceDir = path.join(rootDir, 'packages', 'app'); + const staleVite = path.join(workspaceDir, 'node_modules', 'vite'); + writePackage(staleVite, 'vite'); + // A merge-conflicted / truncated lockfile (e.g. an interrupted prior + // `npm install`) must not abort the migration with an uncaught SyntaxError. + fs.writeFileSync( + path.join(rootDir, 'package-lock.json'), + '<<<<<<< HEAD\n{ "lockfileVersion": 3 }\n=======\n{}\n>>>>>>> incoming\n', + ); + + expect(() => prepareNpmViteAliasReinstall(rootDir, [rootDir, workspaceDir])).not.toThrow(); + expect(fs.existsSync(staleVite)).toBe(false); + }); }); diff --git a/packages/cli/src/migration/compat-worker.ts b/packages/cli/src/migration/compat-worker.ts index 46c101e9a2..3c832f04c7 100644 --- a/packages/cli/src/migration/compat-worker.ts +++ b/packages/cli/src/migration/compat-worker.ts @@ -12,11 +12,15 @@ async function main(): Promise { try { const { resolveConfig } = await import('../index.js'); + const { withConfigMetadataResolution } = await import('../define-config.js'); // Use 'runner' configLoader to avoid Rolldown bundling the config file, // which prints UNRESOLVED_IMPORT warnings that cannot be suppressed via logLevel. - const config = await resolveConfig( - { root: rootDir, logLevel: 'silent', configLoader: 'runner' }, - 'build', + // Reads the config only for the manualChunks compat check, so skip the user's + // plugin factory (lazyPlugins) while it resolves, otherwise a blocking or + // slow factory would hang this worker and a throwing factory would drop the + // warning silently. + const config = await withConfigMetadataResolution(() => + resolveConfig({ root: rootDir, logLevel: 'silent', configLoader: 'runner' }, 'build'), ); const report = createMigrationReport(); checkManualChunksCompat(config.build?.rollupOptions?.output, report); @@ -25,7 +29,7 @@ async function main(): Promise { `${ROLLDOWN_COMPAT_RESULT_PREFIX}${JSON.stringify({ warnings: report.warnings })}\n`, ); } catch { - // Config resolution may fail — skip compatibility checking silently. + // Config resolution may fail; skip compatibility checking silently. } } diff --git a/packages/cli/src/migration/format.ts b/packages/cli/src/migration/format.ts index 654aa62455..229ac06f3e 100644 --- a/packages/cli/src/migration/format.ts +++ b/packages/cli/src/migration/format.ts @@ -26,6 +26,32 @@ interface FormatMigratedProjectOptions { const FORMAT_FAILURE_MESSAGE = 'Automatic formatting failed. Run `vp fmt` manually after migration.'; +// Keep each `vp fmt <...paths>` argument list well under the OS command-line +// limit (ARG_MAX is ~256KB on macOS). Migrating a large monorepo can rewrite +// thousands of files, so the path list is split into batches to avoid an E2BIG +// spawn failure that would leave the migrated source unformatted. +const MAX_FORMAT_ARG_BYTES = 100_000; + +function chunkPathsByArgLength(paths: string[]): string[][] { + const chunks: string[][] = []; + let current: string[] = []; + let currentBytes = 0; + for (const filePath of paths) { + const bytes = Buffer.byteLength(filePath) + 1; // +1 for the argument separator + if (current.length > 0 && currentBytes + bytes > MAX_FORMAT_ARG_BYTES) { + chunks.push(current); + current = []; + currentBytes = 0; + } + current.push(filePath); + currentBytes += bytes; + } + if (current.length > 0) { + chunks.push(current); + } + return chunks; +} + function parseNullDelimitedPaths(output: Buffer): string[] { return output.toString().split('\0').filter(Boolean); } @@ -50,6 +76,16 @@ export async function collectChangedFormatPaths( try { const git = (args: string[]) => runCommandSilently({ command: 'git', args, cwd: projectRoot, envs: process.env }); + + // Only fall back to whole-project formatting when the project is genuinely + // not a Git worktree. A worktree that exists but cannot enumerate changes + // (locked repo, mid-rebase, unusual config) must NOT trigger a full-tree + // reformat that would bury the migration diff. + const worktree = await git(['rev-parse', '--is-inside-work-tree']); + if (worktree.exitCode !== 0 || worktree.stdout.toString().trim() !== 'true') { + return undefined; + } + const [unstaged, staged, untracked] = await Promise.all([ git(['diff', '--name-only', '--relative', '-z', '--diff-filter=ACMRTUXB', '--', '.']), git([ @@ -65,7 +101,9 @@ export async function collectChangedFormatPaths( git(['ls-files', '--others', '--exclude-standard', '-z', '--', '.']), ]); if (unstaged.exitCode !== 0 || staged.exitCode !== 0 || untracked.exitCode !== 0) { - return undefined; + // Inside a worktree but Git could not list changes; skip targeted + // formatting rather than reformatting the entire project. + return []; } const changedPaths = new Set([ @@ -114,13 +152,24 @@ export async function formatMigratedProject( return true; } const cliEntry = process.argv[1] ? path.resolve(process.cwd(), process.argv[1]) : undefined; - const result = await format(projectRoot, interactive, paths, { + const formatOptions = { silent: false, ...(cliEntry ? { command: process.execPath, commandArgs: [...process.execArgv, cliEntry] } : {}), - }); - if (result.status === 'formatted') { + }; + // `undefined` means "format the whole project" (single invocation); a path + // list is batched so a huge monorepo cannot overflow the command line. + const batches = paths === undefined ? [undefined] : chunkPathsByArgLength(paths); + let allFormatted = true; + for (const batch of batches) { + const result = await format(projectRoot, interactive, batch, formatOptions); + if (result.status !== 'formatted') { + allFormatted = false; + break; + } + } + if (allFormatted) { return true; } } catch { diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 882fcf275e..b1d92ad3d6 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -644,6 +644,11 @@ function projectUsesVitestDirectly( }, requiredVitestPeer = projectListsRequiredVitestPeer(projectPath, pkg), preserveNuxtVitestImports = true, + // Optional precomputed source-tree scan results. Callers that already computed + // these for the same `projectPath` at the same point (no source mutation in + // between) thread them here to avoid re-traversing the source tree. When + // omitted, the scans run lazily as before, preserving short-circuit behavior. + precomputedScans?: { browserMode: boolean; retainedModule: boolean }, ): boolean { return ( projectListsVitestEcosystemDep(pkg) || @@ -655,9 +660,9 @@ function projectUsesVitestDirectly( // catalog/override ownership is decided, otherwise the promoted provider's // exact Vitest peer is left unsatisfied under strict pnpm/Yarn layouts. VITEST_BROWSER_DEP_NAMES.some((name) => pkg.peerDependencies?.[name] !== undefined) || - sourceTreeReferencesRetainedVitestModule(projectPath) || + (precomputedScans?.retainedModule ?? sourceTreeReferencesRetainedVitestModule(projectPath)) || (preserveNuxtVitestImports && hasNuxtTestUtilsDependency(pkg)) || - usesVitestBrowserMode(projectPath) + (precomputedScans?.browserMode ?? usesVitestBrowserMode(projectPath)) ); } @@ -1704,14 +1709,6 @@ export function addFrameworkShim( } } -/** - * Rewrite standalone project to add vite-plus dependencies - * @param projectPath - The path to the project - */ -export interface VitestImportMigrationOptions { - preserveNuxtVitestImports?: boolean; -} - const PNPM_WORKSPACE_SETTINGS_MIN_VERSION = '10.6.2'; type PnpmPeerDependencyRules = { @@ -1857,9 +1854,7 @@ export function rewriteStandaloneProject( workspaceInfo: WorkspaceInfo, skipStagedMigration?: boolean, silent = false, - report?: MigrationReport, - importOptions?: VitestImportMigrationOptions, -): void { + report?: MigrationReport,): void { const packageJsonPath = path.join(projectPath, 'package.json'); if (!fs.existsSync(packageJsonPath)) { return; @@ -1868,7 +1863,19 @@ export function rewriteStandaloneProject( const packageManager = workspaceInfo.packageManager; const catalogDependencyResolver = createCatalogDependencyResolver(projectPath, packageManager); const vitestEcosystemPackages = collectVitestEcosystemInstallDependencyNames(projectPath); - const providerCatalogAdditions = collectInjectedProviderNames(projectPath); + // Source-tree scan signals are computed once here and reused below (and inside + // projectUsesVitestDirectly / collectInjectedProviderNames) so the source tree + // is traversed once each instead of repeatedly. They do not depend on + // package.json contents and no scanned source files are mutated before they + // are consumed, so the values match the previous lazy per-call scans exactly. + const providerSourceModes = collectProviderSourceModes(projectPath); + const browserMode = usesVitestBrowserMode(projectPath); + const retainedVitestModule = sourceTreeReferencesRetainedVitestModule(projectPath); + const providerCatalogAdditions = collectInjectedProviderNames( + projectPath, + undefined, + new Map([[projectPath, providerSourceModes]]), + ); const pnpmMajorVersion = pnpmMajor(workspaceInfo.downloadPackageManager.version); let extractedStagedConfig: Record | null = null; let movedPnpmSettings: Record | undefined; @@ -1898,7 +1905,8 @@ export function rewriteStandaloneProject( projectPath, pkg, requiredVitestPeer, - importOptions?.preserveNuxtVitestImports !== false, + true, + { browserMode, retainedModule: retainedVitestModule }, ); const managed = managedOverridePackages(usesVitest); // Strip stale `vite-plus-test` wrapper aliases before injecting new overrides @@ -2020,10 +2028,10 @@ export function rewriteStandaloneProject( supportCatalog, skipStagedMigration, catalogDependencyResolver, - usesVitestBrowserMode(projectPath), - collectProviderSourceModes(projectPath), + browserMode, + providerSourceModes, usesVitest, - sourceTreeReferencesRetainedVitestModule(projectPath), + retainedVitestModule, requiredVitestPeer, ); @@ -2101,7 +2109,7 @@ export function rewriteStandaloneProject( projectPath, silent, report, - importOptions?.preserveNuxtVitestImports !== false, + true, ); wrapLazyPluginsInViteConfig(projectPath, silent, report); // set package manager @@ -2116,9 +2124,7 @@ export function rewriteMonorepo( workspaceInfo: WorkspaceInfo, skipStagedMigration?: boolean, silent = false, - report?: MigrationReport, - importOptions?: VitestImportMigrationOptions, -): void { + report?: MigrationReport,): void { const catalogDependencyResolver = createCatalogDependencyResolver( workspaceInfo.rootDir, workspaceInfo.packageManager, @@ -2136,7 +2142,7 @@ export function rewriteMonorepo( const workspaceUsesVitest = workspaceUsesVitestDirectly( workspaceInfo.rootDir, workspaceInfo.packages, - importOptions?.preserveNuxtVitestImports !== false, + true, ); const vitestEcosystemPackages = collectVitestEcosystemInstallDependencyNames( workspaceInfo.rootDir, @@ -2166,9 +2172,7 @@ export function rewriteMonorepo( pnpmMajorVersion, workspaceInfo.downloadPackageManager.version, workspaceShouldAllowBrowserBuilds, - workspaceUsesVitest, - importOptions, - ); + workspaceUsesVitest, ); if (workspaceInfo.packageManager === PackageManager.pnpm) { rewritePnpmWorkspaceYaml( workspaceInfo.rootDir, @@ -2209,9 +2213,7 @@ export function rewriteMonorepo( report, catalogDependencyResolver, workspaceContext, - true, - importOptions, - ); + true, ); } if (!skipStagedMigration) { @@ -2228,7 +2230,7 @@ export function rewriteMonorepo( workspaceInfo.rootDir, silent, report, - importOptions?.preserveNuxtVitestImports !== false, + true, ); wrapLazyPluginsInViteConfig(workspaceInfo.rootDir, silent, report); for (const pkg of workspaceInfo.packages) { @@ -2255,9 +2257,7 @@ export function rewriteMonorepoProject( report?: MigrationReport, catalogDependencyResolver?: CatalogDependencyResolver, workspaceContext?: { rootDir: string; packages: WorkspacePackage[] }, - deferLazyPluginWrapping = false, - importOptions?: VitestImportMigrationOptions, -): void { + deferLazyPluginWrapping = false,): void { cleanupDeprecatedTsconfigOptions(projectPath, silent, report); rewriteTsconfigTypes(projectPath, silent, report); mergeViteConfigFiles( @@ -2294,6 +2294,12 @@ export function rewriteMonorepoProject( installConfig?: { hoistingLimits?: string }; }>(packageJsonPath, (pkg) => { const requiredVitestPeer = projectListsRequiredVitestPeer(projectPath, pkg); + // Compute the browser-mode and retained-module source scans once and reuse + // them across rewritePackageJson and projectUsesVitestDirectly: the scans do + // not depend on package.json and nothing mutates the source tree between + // these reads, so this is identical to the previous per-call scans. + const browserMode = usesVitestBrowserMode(projectPath); + const retainedVitestModule = sourceTreeReferencesRetainedVitestModule(projectPath); // rewrite scripts in package.json extractedStagedConfig = rewritePackageJson( pkg, @@ -2301,15 +2307,16 @@ export function rewriteMonorepoProject( true, skipStagedMigration, catalogDependencyResolver, - usesVitestBrowserMode(projectPath), + browserMode, collectProviderSourceModes(projectPath), projectUsesVitestDirectly( projectPath, pkg, requiredVitestPeer, - importOptions?.preserveNuxtVitestImports !== false, + true, + { browserMode, retainedModule: retainedVitestModule }, ), - sourceTreeReferencesRetainedVitestModule(projectPath), + retainedVitestModule, requiredVitestPeer, ); // If this SUB-workspace now depends on `vite-plus` and Yarn isolates its @@ -3194,21 +3201,10 @@ function createCatalogDependencyResolver( catalog?: Record; catalogs?: Record>; }; - const workspacesObj = - pkg.workspaces && !Array.isArray(pkg.workspaces) ? pkg.workspaces : undefined; - const fromWorkspaces = createCatalogDependencyResolverFromCatalogs( - workspacesObj?.catalog, - workspacesObj?.catalogs, - ); - const fromPkg = createCatalogDependencyResolverFromCatalogs(pkg.catalog, pkg.catalogs); - const resolver = (catalogSpec: string, dependencyName: string) => - fromWorkspaces(catalogSpec, dependencyName) ?? fromPkg(catalogSpec, dependencyName); - return Object.assign(resolver, { - preferredCatalogSpec: - workspacesObj?.catalog || workspacesObj?.catalogs - ? fromWorkspaces.preferredCatalogSpec - : fromPkg.preferredCatalogSpec, - }); + // A missing/absent `workspaces.catalog` resolves identically whether the + // fallback is `undefined` (optional chaining) or `{}`, so this shares the + // exact bun catalog resolution used by the in-memory callers. + return readBunCatalogDependencyResolver(pkg); } return undefined; } @@ -3590,9 +3586,7 @@ function rewriteRootWorkspacePackageJson( // Workspace-wide direct-vitest signal: the root resolution/override sinks are // shared by every package, so `vitest` stays managed here iff ANY package uses // vitest directly. - workspaceUsesVitest = true, - importOptions?: VitestImportMigrationOptions, -): void { + workspaceUsesVitest = true,): void { const packageJsonPath = path.join(projectPath, 'package.json'); if (!fs.existsSync(packageJsonPath)) { return; @@ -3738,9 +3732,7 @@ function rewriteRootWorkspacePackageJson( undefined, catalogDependencyResolver, packages ? { rootDir: projectPath, packages } : undefined, - true, - importOptions, - ); + true, ); } const RULES_YAML_PATH = path.join(rulesDir, 'vite-tools.yml'); @@ -3841,9 +3833,7 @@ export function finalizeCoreMigrationForExistingVitePlus( workspaceInfo: CoreMigrationWorkspace, silent = false, report?: MigrationReport, - pending = detectPendingCoreMigration(workspaceInfo), - importOptions?: VitestImportMigrationOptions, -): CoreMigrationFinalizationResult { + pending = detectPendingCoreMigration(workspaceInfo),): CoreMigrationFinalizationResult { const projectPaths = getCoreMigrationProjectPaths(workspaceInfo); const result: CoreMigrationFinalizationResult = { scripts: false, @@ -3868,7 +3858,7 @@ export function finalizeCoreMigrationForExistingVitePlus( workspaceInfo.rootDir, silent, report, - importOptions?.preserveNuxtVitestImports !== false, + true, ); return result; @@ -4288,15 +4278,13 @@ function reconcileVitePlusBootstrapPackage( packageManager: PackageManager, supportCatalog: boolean, ensureVitePlus: boolean, - catalogDependencyResolver?: CatalogDependencyResolver, - importOptions?: VitestImportMigrationOptions, -): boolean { + catalogDependencyResolver?: CatalogDependencyResolver,): boolean { const before = JSON.stringify(pkg); const usesVitest = projectUsesVitestDirectly( projectPath, pkg, undefined, - importOptions?.preserveNuxtVitestImports !== false, + true, ); ensureVitePlusDependencySpecs(pkg, vitePlusVersion, ensureVitePlus); @@ -4394,6 +4382,22 @@ function reconcileVitePlusBootstrapPackage( } } + if (packageManager === PackageManager.bun) { + // Bun resolves vitest's `vite ^6 || ^7 || ^8` peer before applying the + // override that redirects `vite` to vite-plus-core, and aborts with + // "vite@... failed to resolve" unless `vite` is a direct dependency. Mirror + // the full-migration path (rewriteStandaloneProject) so the idempotent + // bootstrap path also produces an installable bun project. The override set + // above still points the direct dep at vite-plus-core. + const viteAlreadyDirect = installGroups.some( + (dependencies) => dependencies?.vite !== undefined, + ); + if (!viteAlreadyDirect) { + pkg.devDependencies ??= {}; + pkg.devDependencies.vite = VITE_PLUS_OVERRIDE_PACKAGES.vite; + } + } + if (usesVitest) { // A direct @vitest/*/integration dependency with a required vitest peer // cannot use the copy nested under its sibling `vite-plus` dependency under @@ -4459,7 +4463,14 @@ function collectVitestEcosystemInstallDependencyNames( return names; } -function collectInjectedProviderNames(rootDir: string, packages?: WorkspacePackage[]): Set { +function collectInjectedProviderNames( + rootDir: string, + packages?: WorkspacePackage[], + // Optional precomputed provider source-scan results keyed by absolute package + // path. Lets a caller that already scanned a path reuse the result instead of + // re-traversing the source tree; unknown paths fall back to a fresh scan. + precomputedSourceModes?: ReadonlyMap>, +): Set { const names = new Set(); for (const packagePath of bootstrapProjectPaths(rootDir, packages)) { const packageJsonPath = path.join(packagePath, 'package.json'); @@ -4467,7 +4478,8 @@ function collectInjectedProviderNames(rootDir: string, packages?: WorkspacePacka continue; } const pkg = readJsonFile(packageJsonPath) as BootstrapPackageJson; - const sourceModes = collectProviderSourceModes(packagePath); + const sourceModes = + precomputedSourceModes?.get(packagePath) ?? collectProviderSourceModes(packagePath); const installGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; const dependencyGroups = [...installGroups, pkg.peerDependencies]; for (const provider of OPT_IN_BROWSER_PROVIDERS) { @@ -4506,9 +4518,7 @@ export function detectVitePlusBootstrapPending( projectPath: string, packageManager: PackageManager | undefined, packages?: WorkspacePackage[], - packageManagerVersion?: string, - importOptions?: VitestImportMigrationOptions, -): boolean { + packageManagerVersion?: string,): boolean { const packageJsonPath = path.join(projectPath, 'package.json'); if (!fs.existsSync(packageJsonPath)) { return false; @@ -4566,9 +4576,7 @@ export function detectVitePlusBootstrapPending( packageManager, supportCatalog, index === 0, - catalogDependencyResolver, - importOptions, - ) + catalogDependencyResolver, ) ) { return true; } @@ -4579,7 +4587,7 @@ export function detectVitePlusBootstrapPending( const usesVitest = workspaceUsesVitestDirectly( projectPath, packages, - importOptions?.preserveNuxtVitestImports !== false, + true, ); if (packageManager === PackageManager.yarn) { @@ -4744,9 +4752,7 @@ function ensurePnpmPeerDependencyRules(pkg: BootstrapPackageJson, usesVitest: bo export function ensureVitePlusBootstrap( workspaceInfo: WorkspaceInfo, - report?: MigrationReport, - importOptions?: VitestImportMigrationOptions, -): VitePlusBootstrapResult { + report?: MigrationReport,): VitePlusBootstrapResult { const projectPath = workspaceInfo.rootDir; const packageJsonPath = path.join(projectPath, 'package.json'); const result: VitePlusBootstrapResult = { @@ -4765,7 +4771,7 @@ export function ensureVitePlusBootstrap( const usesVitest = workspaceUsesVitestDirectly( projectPath, workspaceInfo.packages, - importOptions?.preserveNuxtVitestImports !== false, + true, ); const pnpmMajorVersion = pnpmMajor(workspaceInfo.downloadPackageManager.version); const shouldAllowBrowserBuilds = workspaceUsesWebdriverio(projectPath, workspaceInfo.packages); @@ -4813,9 +4819,7 @@ export function ensureVitePlusBootstrap( workspaceInfo.packageManager, supportCatalog, true, - catalogDependencyResolver, - importOptions, - ); + catalogDependencyResolver, ); if (workspaceInfo.packageManager === PackageManager.yarn) { const ensured = ensureOverrideEntries(pkg.resolutions, usesVitest); @@ -4882,9 +4886,7 @@ export function ensureVitePlusBootstrap( workspaceInfo.packageManager, supportCatalog, false, - catalogDependencyResolver, - importOptions, - ); + catalogDependencyResolver, ); return childChanged ? pkg : undefined; }); result.packageJson = result.packageJson || childChanged; @@ -5135,12 +5137,10 @@ const VITEST_SCAN_SKIP_DIRS = new Set([ * is a separate package that the migration scans on its own pass, so the root * package must not inherit a browser-mode signal from a sub-package. */ -function sourceTreeMatchingFiles( +function sourceTreeMatches( projectPath: string, matchesContent: (content: string) => boolean, - stopAfterFirst = false, -): string[] { - const matchingFiles: string[] = []; +): boolean { const scanDir = (dir: string, isRoot: boolean): boolean => { let entries: fs.Dirent[]; try { @@ -5165,10 +5165,7 @@ function sourceTreeMatchingFiles( } else if (entry.isFile() && VITEST_SCAN_EXTENSIONS.has(path.extname(entry.name))) { try { if (matchesContent(fs.readFileSync(entryPath, 'utf8'))) { - matchingFiles.push(entryPath); - if (stopAfterFirst) { - return true; - } + return true; } } catch { // Unreadable file — ignore and keep scanning. @@ -5178,15 +5175,7 @@ function sourceTreeMatchingFiles( return false; }; - scanDir(projectPath, true); - return matchingFiles; -} - -function sourceTreeMatches( - projectPath: string, - matchesContent: (content: string) => boolean, -): boolean { - return sourceTreeMatchingFiles(projectPath, matchesContent, true).length > 0; + return scanDir(projectPath, true); } function sourceTreeReferencesAny(projectPath: string, hints: readonly string[]): boolean { @@ -5220,43 +5209,12 @@ function findPackageTsconfigFiles(projectPath: string): string[] { return files; } -const UPSTREAM_VITEST_MODULE_REFERENCE = - /(?:\bfrom\s*|\b(?:import|require)\s*\(\s*|\bimport\s*)['"]vitest(?:\/[^'"]+)?['"]/m; - function hasNuxtTestUtilsDependency(pkg: DependencyBag): boolean { return [pkg.dependencies, pkg.devDependencies, pkg.optionalDependencies].some( (dependencies) => dependencies?.['@nuxt/test-utils'] !== undefined, ); } -/** - * Find files whose upstream Vitest imports are preserved by the - * @nuxt/test-utils package-level compatibility rule. - * Each package is scanned independently so a root dependency does not leak into - * unrelated workspace manifests. - */ -export function detectNuxtTestUtilsVitestImportFiles( - rootDir: string, - packages?: WorkspacePackage[], -): string[] { - const files: string[] = []; - for (const projectPath of [ - rootDir, - ...(packages ?? []).map((pkg) => path.join(rootDir, pkg.path)), - ]) { - const pkg = readPackageJsonIfExists(path.join(projectPath, 'package.json')); - if (!pkg || !hasNuxtTestUtilsDependency(pkg)) { - continue; - } - files.push( - ...sourceTreeMatchingFiles(projectPath, (content) => - UPSTREAM_VITEST_MODULE_REFERENCE.test(content), - ), - ); - } - return [...new Set(files)]; -} - // Normal imports and triple-slash type directives from `vitest` are rewritten // to `vite-plus/test` later in the same migration and therefore do not justify // a lasting direct dependency. Module augmentations, `vitest/package.json`, and diff --git a/packages/cli/src/migration/npm-reinstall.ts b/packages/cli/src/migration/npm-reinstall.ts index 607fd7a652..6ae903ae42 100644 --- a/packages/cli/src/migration/npm-reinstall.ts +++ b/packages/cli/src/migration/npm-reinstall.ts @@ -59,32 +59,38 @@ export function prepareNpmViteAliasReinstall( let changed = false; if (fs.existsSync(packageLockPath)) { - const packageLock = readJsonFile(packageLockPath) as NpmPackageLock; - let lockChanged = false; - - for (const [packagePath, pkg] of Object.entries(packageLock.packages ?? {})) { - if (!isViteInstallPath(packagePath)) { - continue; - } - - const installPath = path.resolve(rootDir, packagePath); - const relativeInstallPath = path.relative(rootDir, installPath); - if (relativeInstallPath.startsWith('..') || path.isAbsolute(relativeInstallPath)) { - continue; + try { + const packageLock = readJsonFile(packageLockPath) as NpmPackageLock; + let lockChanged = false; + + for (const [packagePath, pkg] of Object.entries(packageLock.packages ?? {})) { + if (!isViteInstallPath(packagePath)) { + continue; + } + + const installPath = path.resolve(rootDir, packagePath); + const relativeInstallPath = path.relative(rootDir, installPath); + if (relativeInstallPath.startsWith('..') || path.isAbsolute(relativeInstallPath)) { + continue; + } + + if (!isVitePlusCorePackage(pkg)) { + delete packageLock.packages?.[packagePath]; + lockChanged = true; + removeStaleInstalledVite(installPath); + } else { + changed = removeStaleInstalledVite(installPath) || changed; + } } - if (!isVitePlusCorePackage(pkg)) { - delete packageLock.packages?.[packagePath]; - lockChanged = true; - removeStaleInstalledVite(installPath); - } else { - changed = removeStaleInstalledVite(installPath) || changed; + if (lockChanged) { + writeJsonFile(packageLockPath, packageLock as unknown as Record); + changed = true; } - } - - if (lockChanged) { - writeJsonFile(packageLockPath, packageLock as unknown as Record); - changed = true; + } catch { + // A malformed, truncated, or merge-conflicted package-lock.json cannot be + // safely rewritten. Skip lockfile reconciliation instead of aborting the + // migration mid-write; the final `npm install --force` regenerates it. } } diff --git a/packages/cli/src/oxlint-plugin.ts b/packages/cli/src/oxlint-plugin.ts index c763f9c235..02acca2666 100644 --- a/packages/cli/src/oxlint-plugin.ts +++ b/packages/cli/src/oxlint-plugin.ts @@ -101,7 +101,10 @@ function quoteSpecifier(literal: ESTree.StringLiteral, replacement: string): str return `${quote}${replacement}${quote}`; } -const nuxtTestUtilsPackageCache = new Map(); +// Keyed by package.json path and invalidated by its mtime so a long-lived lint +// process (editor/LSP session) re-reads the manifest after the user adds or +// removes `@nuxt/test-utils`, instead of reusing the pre-edit decision forever. +const nuxtTestUtilsPackageCache = new Map(); function isUpstreamVitestSpecifier(specifier: string): boolean { return specifier === 'vitest' || specifier.startsWith('vitest/'); @@ -115,9 +118,15 @@ function nearestPackageUsesNuxtTestUtils(filename: string): boolean { while (true) { const packageJsonPath = path.join(directory, 'package.json'); if (fs.existsSync(packageJsonPath)) { + let mtimeMs = 0; + try { + mtimeMs = fs.statSync(packageJsonPath).mtimeMs; + } catch { + // Unreadable manifest: fall through to a fresh read below. + } const cached = nuxtTestUtilsPackageCache.get(packageJsonPath); - if (cached !== undefined) { - return cached; + if (cached !== undefined && cached.mtimeMs === mtimeMs) { + return cached.usesNuxtTestUtils; } let usesNuxtTestUtils = false; try { @@ -132,7 +141,7 @@ function nearestPackageUsesNuxtTestUtils(filename: string): boolean { } catch { // Invalid or unreadable package metadata cannot opt into the exception. } - nuxtTestUtilsPackageCache.set(packageJsonPath, usesNuxtTestUtils); + nuxtTestUtilsPackageCache.set(packageJsonPath, { mtimeMs, usesNuxtTestUtils }); return usesNuxtTestUtils; } const parent = path.dirname(directory); From 19f44cc6e8e004e03af66f89cf00ed35ef665413 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 27 Jun 2026 17:28:40 +0800 Subject: [PATCH 54/78] ci(migrate): verify pkg.pr.new builds through the registry bridge Resolve pkg.pr.new commit builds as ordinary npm versions (0.0.0-commit.) via the registry bridge instead of mutable pkg.pr.new URLs, so test-pkg-pr-new-migrate.sh installs them like released packages. This drops the Bun tarball repack, file: URL overrides, blockExoticSubdeps, and the override/pnpm-version helpers; the project's package manager is pointed at the bridge with npm_config_registry / YARN_NPM_REGISTRY_SERVER. The global CLI keeps installing from pkg.pr.new, which serves the per-platform binaries the bridge cannot. Register each commit build with the bridge from the pkg.pr.new publish workflow (replacing its GitHub webhook), scoped to same-repo PRs. Claude-Session: https://claude.ai/code/session_01DQhS6o1fyQd1yjiee6W8jR --- .github/scripts/bun-pkg-pr-new.mjs | 151 ------------- .../scripts/create-pkg-pr-new-overrides.mjs | 8 - .../ensure-pkg-pr-new-pnpm-version.mjs | 199 ------------------ .github/scripts/repack-vite-pr.sh | 47 ----- .github/scripts/test-pkg-pr-new-migrate.sh | 159 +++++--------- .github/workflows/publish-to-pkg.pr.new.yml | 20 ++ 6 files changed, 70 insertions(+), 514 deletions(-) delete mode 100755 .github/scripts/bun-pkg-pr-new.mjs delete mode 100644 .github/scripts/create-pkg-pr-new-overrides.mjs delete mode 100644 .github/scripts/ensure-pkg-pr-new-pnpm-version.mjs delete mode 100755 .github/scripts/repack-vite-pr.sh diff --git a/.github/scripts/bun-pkg-pr-new.mjs b/.github/scripts/bun-pkg-pr-new.mjs deleted file mode 100755 index cb31d3e705..0000000000 --- a/.github/scripts/bun-pkg-pr-new.mjs +++ /dev/null @@ -1,151 +0,0 @@ -#!/usr/bin/env node - -import fs from 'node:fs'; -import path from 'node:path'; - -function usage() { - console.error(`Usage: - bun-pkg-pr-new.mjs is-bun-project - bun-pkg-pr-new.mjs patch-package - bun-pkg-pr-new.mjs add-core-dependency - bun-pkg-pr-new.mjs normalize-vite-paths `); - process.exit(2); -} - -function readPackageJson(packageJsonPath) { - const text = fs.readFileSync(packageJsonPath, 'utf8'); - return { - indent: text.match(/\n([\t ]+)"/)?.[1] ?? ' ', - pkg: JSON.parse(text), - }; -} - -function writePackageJson(packageJsonPath, pkg, indent) { - fs.writeFileSync(packageJsonPath, `${JSON.stringify(pkg, null, indent)}\n`); -} - -function isBunProject(packageJsonPath) { - const { pkg } = readPackageJson(packageJsonPath); - const packageManager = - typeof pkg.packageManager === 'string' ? pkg.packageManager.split('@')[0] : undefined; - const devEngine = pkg.devEngines?.packageManager; - const devEngines = Array.isArray(devEngine) ? devEngine : [devEngine]; - const hasBunDevEngine = devEngines.some((entry) => { - const name = typeof entry === 'string' ? entry : entry?.name; - return name === 'bun'; - }); - process.exit(packageManager === 'bun' || hasBunDevEngine ? 0 : 1); -} - -function patchPackage(packageJsonPath, coreUrl, vitePlusUrl) { - const { pkg } = readPackageJson(packageJsonPath); - const bundledViteVersion = pkg.bundledVersions?.vite; - - pkg.name = 'vite'; - pkg.version = - typeof bundledViteVersion === 'string' && bundledViteVersion.length > 0 - ? bundledViteVersion - : '8.0.0'; - pkg.dependencies = { - ...pkg.dependencies, - '@voidzero-dev/vite-plus-core': coreUrl, - 'vite-plus': vitePlusUrl, - }; - - writePackageJson(packageJsonPath, pkg, ' '); -} - -function addCoreDependency(packageJsonPath, coreSpec) { - const { indent, pkg } = readPackageJson(packageJsonPath); - pkg.devDependencies ??= {}; - pkg.devDependencies['@voidzero-dev/vite-plus-core'] = coreSpec; - writePackageJson(packageJsonPath, pkg, indent); -} - -function normalizeVitePaths(projectDir, tarballPath) { - const absoluteSpec = `file:${tarballPath}`; - const skippedDirectories = new Set([ - '.git', - '.output', - 'build', - 'dist', - 'node_modules', - 'vendor', - ]); - - function rewriteValue(value, relativeSpec) { - if (value === absoluteSpec) { - return relativeSpec; - } - if (Array.isArray(value)) { - return value.map((item) => rewriteValue(item, relativeSpec)); - } - if (value && typeof value === 'object') { - for (const [key, child] of Object.entries(value)) { - value[key] = rewriteValue(child, relativeSpec); - } - } - return value; - } - - function visit(directory) { - for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { - const entryPath = path.join(directory, entry.name); - if (entry.isDirectory()) { - if (!skippedDirectories.has(entry.name)) { - visit(entryPath); - } - continue; - } - if (!entry.isFile() || entry.name !== 'package.json') { - continue; - } - - const text = fs.readFileSync(entryPath, 'utf8'); - if (!text.includes(absoluteSpec)) { - continue; - } - const relativePath = path - .relative(path.dirname(entryPath), tarballPath) - .split(path.sep) - .join('/'); - const relativeSpec = `file:${relativePath.startsWith('.') ? relativePath : `./${relativePath}`}`; - const pkg = rewriteValue(JSON.parse(text), relativeSpec); - const indent = text.match(/\n([\t ]+)"/)?.[1] ?? ' '; - writePackageJson(entryPath, pkg, indent); - } - } - - visit(projectDir); -} - -const [command, ...args] = process.argv.slice(2); - -switch (command) { - case 'is-bun-project': - if (args.length !== 1) { - usage(); - } - isBunProject(...args); - break; - case 'patch-package': - if (args.length !== 3) { - usage(); - } - patchPackage(...args); - break; - case 'add-core-dependency': - if (args.length !== 2) { - usage(); - } - addCoreDependency(...args); - break; - case 'normalize-vite-paths': - if (args.length !== 2) { - usage(); - } - normalizeVitePaths(...args); - break; - default: - usage(); -} diff --git a/.github/scripts/create-pkg-pr-new-overrides.mjs b/.github/scripts/create-pkg-pr-new-overrides.mjs deleted file mode 100644 index ed481569d0..0000000000 --- a/.github/scripts/create-pkg-pr-new-overrides.mjs +++ /dev/null @@ -1,8 +0,0 @@ -const [vite, vitest] = process.argv.slice(2); - -if (!vite || !vitest) { - console.error('Usage: create-pkg-pr-new-overrides.mjs '); - process.exit(2); -} - -process.stdout.write(JSON.stringify({ vite, vitest })); diff --git a/.github/scripts/ensure-pkg-pr-new-pnpm-version.mjs b/.github/scripts/ensure-pkg-pr-new-pnpm-version.mjs deleted file mode 100644 index f5acd420a7..0000000000 --- a/.github/scripts/ensure-pkg-pr-new-pnpm-version.mjs +++ /dev/null @@ -1,199 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { pathToFileURL } from 'node:url'; - -const LATEST_PNPM_10_VERSION = '10.34.4'; -const SAFE_PNPM_11_VERSION = '11.9.0'; -const SUPPORTED_PACKAGE_MANAGERS = new Set(['pnpm', 'yarn', 'npm', 'bun']); - -function parseExactVersion(version) { - const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(version); - if (!match) { - return undefined; - } - return { - major: Number(match[1]), - minor: Number(match[2]), - patch: Number(match[3]), - prerelease: match[4]?.split('.'), - }; -} - -function compareIdentifiers(left, right) { - const leftNumber = /^\d+$/.test(left) ? Number(left) : undefined; - const rightNumber = /^\d+$/.test(right) ? Number(right) : undefined; - if (leftNumber !== undefined && rightNumber !== undefined) { - return leftNumber - rightNumber; - } - if (leftNumber !== undefined) { - return -1; - } - if (rightNumber !== undefined) { - return 1; - } - return left.localeCompare(right); -} - -function compareVersions(left, right) { - for (const key of ['major', 'minor', 'patch']) { - if (left[key] !== right[key]) { - return left[key] - right[key]; - } - } - if (!left.prerelease && !right.prerelease) { - return 0; - } - if (!left.prerelease) { - return 1; - } - if (!right.prerelease) { - return -1; - } - const length = Math.max(left.prerelease.length, right.prerelease.length); - for (let index = 0; index < length; index++) { - const leftIdentifier = left.prerelease[index]; - const rightIdentifier = right.prerelease[index]; - if (leftIdentifier === undefined) { - return -1; - } - if (rightIdentifier === undefined) { - return 1; - } - const compared = compareIdentifiers(leftIdentifier, rightIdentifier); - if (compared !== 0) { - return compared; - } - } - return 0; -} - -function safePnpmVersionFor(version) { - const parsed = parseExactVersion(version); - if (!parsed) { - return undefined; - } - - // pnpm before 10.2.0 rewrites non-semver overrides into peerDependencies, - // causing pkg.pr.new URLs to fail peer-spec validation. Stay on the same - // major and use the latest v10 release containing pnpm/pnpm#9000. - if (parsed.major === 10 && compareVersions(parsed, parseExactVersion('10.2.0')) < 0) { - return LATEST_PNPM_10_VERSION; - } - - const pnpm11Lower = parseExactVersion('11.0.0'); - const pnpm11Upper = parseExactVersion(SAFE_PNPM_11_VERSION); - if (compareVersions(parsed, pnpm11Lower) >= 0 && compareVersions(parsed, pnpm11Upper) < 0) { - return SAFE_PNPM_11_VERSION; - } - - return undefined; -} - -function parsePackageManagerSpec(spec) { - const match = /^([^@]+)@(.+)$/.exec(spec); - return match ? { name: match[1], version: match[2] } : undefined; -} - -function devEngineEntries(pkg) { - const value = pkg.devEngines?.packageManager; - if (Array.isArray(value)) { - return value.filter((entry) => entry && typeof entry === 'object'); - } - return value && typeof value === 'object' ? [value] : []; -} - -function selectedDevEngineEntry(pkg) { - return devEngineEntries(pkg).find( - (entry) => typeof entry.name === 'string' && SUPPORTED_PACKAGE_MANAGERS.has(entry.name), - ); -} - -function serializeLike(source, pkg) { - const indentMatch = source.match(/\n([\t ]+)"/); - const indent = indentMatch?.[1].startsWith('\t') ? '\t' : (indentMatch?.[1].length ?? 2); - const newline = source.includes('\r\n') ? '\r\n' : '\n'; - const finalNewline = /\r?\n$/.test(source) ? newline : ''; - return JSON.stringify(pkg, null, indent).replaceAll('\n', newline) + finalNewline; -} - -function replacePackageManagerSpec(source, previousSpec, targetVersion) { - const pattern = /("packageManager"\s*:\s*)("(?:\\.|[^"\\])*")/g; - return source.replace(pattern, (match, prefix, value) => { - if (JSON.parse(value) !== previousSpec) { - return match; - } - return `${prefix}${JSON.stringify(`pnpm@${targetVersion}`)}`; - }); -} - -export function ensureSafePkgPrNewPnpmVersion(source) { - const pkg = JSON.parse(source); - const previousVersions = []; - let packageManagerSpec; - let targetVersion; - let devEnginesChanged = false; - - if (typeof pkg.packageManager === 'string') { - const parsed = parsePackageManagerSpec(pkg.packageManager); - targetVersion = parsed?.name === 'pnpm' ? safePnpmVersionFor(parsed.version) : undefined; - if (!targetVersion) { - return { changed: false, source, previousVersions }; - } - packageManagerSpec = pkg.packageManager; - previousVersions.push(parsed.version); - pkg.packageManager = `pnpm@${targetVersion}`; - - // Keep exact pnpm devEngines constraints in sync with the authoritative - // packageManager field so the two declarations do not conflict. - for (const entry of devEngineEntries(pkg)) { - if ( - entry.name === 'pnpm' && - typeof entry.version === 'string' && - safePnpmVersionFor(entry.version) - ) { - previousVersions.push(entry.version); - entry.version = targetVersion; - devEnginesChanged = true; - } - } - } else { - const selected = selectedDevEngineEntry(pkg); - targetVersion = - selected?.name === 'pnpm' && typeof selected.version === 'string' - ? safePnpmVersionFor(selected.version) - : undefined; - if (!targetVersion || selected?.name !== 'pnpm' || typeof selected.version !== 'string') { - return { changed: false, source, previousVersions }; - } - previousVersions.push(selected.version); - selected.version = targetVersion; - devEnginesChanged = true; - } - - const updatedSource = devEnginesChanged - ? serializeLike(source, pkg) - : replacePackageManagerSpec(source, packageManagerSpec, targetVersion); - return { - changed: true, - source: updatedSource, - previousVersions: [...new Set(previousVersions)], - version: targetVersion, - }; -} - -const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : undefined; -if (invokedPath === import.meta.url) { - const packageJsonPath = process.argv[2]; - if (!packageJsonPath) { - console.error('Usage: ensure-pkg-pr-new-pnpm-version.mjs '); - process.exit(2); - } - const source = fs.readFileSync(packageJsonPath, 'utf8'); - const result = ensureSafePkgPrNewPnpmVersion(source); - if (result.changed) { - fs.writeFileSync(packageJsonPath, result.source); - console.log( - `Updating project pnpm ${result.previousVersions.join(', ')} -> ${result.version} to avoid pkg.pr.new install failures`, - ); - } -} diff --git a/.github/scripts/repack-vite-pr.sh b/.github/scripts/repack-vite-pr.sh deleted file mode 100755 index 2e9faa3d80..0000000000 --- a/.github/scripts/repack-vite-pr.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -pr_ref="${1:-1891}" -project_input="${2:-$PWD}" - -case "$pr_ref" in - '' | *[![:alnum:]._-]*) - echo "error: PR or commit contains unsupported characters: $pr_ref" >&2 - exit 2 - ;; -esac - -if [ ! -d "$project_input" ]; then - echo "error: project directory does not exist: $project_input" >&2 - exit 2 -fi - -project_dir="$(cd "$project_input" && pwd -P)" -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" -output_path="$project_dir/vendor/vite-plus-core-as-vite-$pr_ref.tgz" -core_url="https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@$pr_ref" -vite_plus_url="https://pkg.pr.new/voidzero-dev/vite-plus/vite-plus@$pr_ref" -tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/vite-plus-core-as-vite.XXXXXX")" - -cleanup() { - rm -rf "$tmp_dir" -} -trap cleanup EXIT - -curl -fsSL "$core_url" -o "$tmp_dir/vite-plus-core.tgz" -mkdir -p "$tmp_dir/unpacked" -tar -xzf "$tmp_dir/vite-plus-core.tgz" -C "$tmp_dir/unpacked" - -package_json="$tmp_dir/unpacked/package/package.json" -if [ ! -f "$package_json" ]; then - echo "error: downloaded package does not contain package/package.json" >&2 - exit 1 -fi - -node "$script_dir/bun-pkg-pr-new.mjs" patch-package "$package_json" "$core_url" "$vite_plus_url" - -mkdir -p "$(dirname "$output_path")" -tar -czf "$output_path" -C "$tmp_dir/unpacked" package - -printf '%s\n' "$output_path" diff --git a/.github/scripts/test-pkg-pr-new-migrate.sh b/.github/scripts/test-pkg-pr-new-migrate.sh index 3e917dfcc7..ac8a9a1f7f 100755 --- a/.github/scripts/test-pkg-pr-new-migrate.sh +++ b/.github/scripts/test-pkg-pr-new-migrate.sh @@ -6,6 +6,12 @@ usage() { cat <<'EOF' Usage: .github/scripts/test-pkg-pr-new-migrate.sh [migrate-options...] +Installs an isolated global Vite+ CLI built from a pkg.pr.new commit and runs +`vp migrate` against a local project. The migrated project pins `vite-plus` and +`vite` to the matching commit build, resolved through the pkg.pr.new registry +bridge (https://github.com/fengmk2/pkg-pr-registry-bridge) so they install like +ordinary npm versions (0.0.0-commit.) instead of mutable pkg.pr.new URLs. + Examples: .github/scripts/test-pkg-pr-new-migrate.sh 1891 /path/to/npmx.dev .github/scripts/test-pkg-pr-new-migrate.sh 4eb2104c /path/to/project --no-interactive @@ -44,35 +50,14 @@ if [ ! -f "$project_dir/package.json" ]; then fi script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" - -is_bun_project=0 -if [ -f "$project_dir/bun.lock" ] || - [ -f "$project_dir/bun.lockb" ] || - [ -f "$project_dir/bunfig.toml" ] || - node "$script_dir/bun-pkg-pr-new.mjs" is-bun-project "$project_dir/package.json"; then - is_bun_project=1 -fi - repo_root="$(cd "$script_dir/../.." && pwd -P)" installer="$repo_root/packages/cli/install.sh" -pnpm_version_helper="$script_dir/ensure-pkg-pr-new-pnpm-version.mjs" -override_json_helper="$script_dir/create-pkg-pr-new-overrides.mjs" if [ ! -f "$installer" ]; then echo "error: Vite+ installer not found: $installer" >&2 exit 2 fi -if [ ! -f "$pnpm_version_helper" ]; then - echo "error: pnpm version helper not found: $pnpm_version_helper" >&2 - exit 2 -fi - -if [ ! -f "$override_json_helper" ]; then - echo "error: pkg.pr.new override helper not found: $override_json_helper" >&2 - exit 2 -fi - is_git_repo=0 if command -v git >/dev/null 2>&1 && git -C "$project_dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then is_git_repo=1 @@ -83,20 +68,14 @@ if command -v git >/dev/null 2>&1 && git -C "$project_dir" rev-parse --is-inside fi fi -# pnpm 10 before 10.2.0 rewrites pkg.pr.new URL overrides into workspace peer -# declarations, which then fail peer-spec validation. pnpm 11.0.0 through -# 11.8.x can write pkg.pr.new tarball lock entries without integrity metadata, -# which a later frozen install rejects. Upgrade affected package-manager pins -# before migration resolves or invokes pnpm. -node "$pnpm_version_helper" "$project_dir/package.json" - -original_home="$HOME" -cache_root="${XDG_CACHE_HOME:-$original_home/.cache}" -pr_home="${VP_PKG_PR_NEW_HOME:-$cache_root/vite-plus/pkg-pr-new/$pr_ref}" -installer_home="$(mktemp -d "${TMPDIR:-/tmp}/vite-plus-pr-installer.XXXXXX")" +bridge_registry="https://pkg-pr-registry-bridge.render.vip/" pkg_pr_new_base="https://pkg.pr.new/voidzero-dev/vite-plus" requested_vite_plus_spec="$pkg_pr_new_base@$pr_ref" +# pkg.pr.new commit builds are immutable; PR-number URLs are mutable and the +# registry bridge only mirrors commit builds. Resolve the requested PR or SHA to +# its underlying 40-char commit so the global install and every dependency spec +# share one immutable key. resolve_pkg_pr_new_commit() { curl -fsSIL "$requested_vite_plus_spec" | tr -d '\r' | awk -F ': ' ' tolower($1) == "x-commit-key" { @@ -119,45 +98,33 @@ if [ "${#available_commit}" -ne 40 ]; then exit 1 fi -# PR-number URLs are mutable and pkg.pr.new packages reference their internal -# workspace dependencies by commit SHA. Persisting the PR URL alongside those -# SHA URLs makes package managers install duplicate copies of the same package. -# Resolve once, then use the immutable SHA for the global install and every -# dependency spec written by migration. resolved_ref="$available_commit" +commit_version="0.0.0-commit.$resolved_ref" +vite_core_spec="npm:@voidzero-dev/vite-plus-core@$commit_version" + +# The bridge only serves commit builds it has been told about (registered by the +# pkg.pr.new publish workflow). Fail early with an actionable message instead of +# letting the project install hit an opaque registry miss. +if ! curl -fsS "${bridge_registry}@voidzero-dev/vite-plus-core" 2>/dev/null | + grep -q "0.0.0-commit.$resolved_ref"; then + echo "error: the registry bridge has no build for commit $resolved_ref" >&2 + echo "Ensure the pkg.pr.new publish workflow registered it, or register it manually:" >&2 + echo " curl -fsS -X POST -H \"authorization: Bearer \$PKG_PR_BRIDGE_ADMIN_TOKEN\" \\" >&2 + echo " -H 'content-type: application/json' -d '{\"ref\":\"commit.$resolved_ref\"}' \\" >&2 + echo " ${bridge_registry}-/refs" >&2 + exit 1 +fi + +original_home="$HOME" +cache_root="${XDG_CACHE_HOME:-$original_home/.cache}" +pr_home="${VP_PKG_PR_NEW_HOME:-$cache_root/vite-plus/pkg-pr-new/$pr_ref}" +installer_home="$(mktemp -d "${TMPDIR:-/tmp}/vite-plus-pr-installer.XXXXXX")" + cached_version_dir="$pr_home/pkg-pr-new-$resolved_ref" vp_bin="$pr_home/bin/vp" vite_plus_package_json="$pr_home/current/node_modules/vite-plus/package.json" global_cli_entry="$pr_home/current/node_modules/vite-plus/dist/bin.js" commit_marker="$cached_version_dir/.pkg-pr-new-commit" -vite_plus_spec="$pkg_pr_new_base@$resolved_ref" -vite_plus_core_spec="$pkg_pr_new_base/@voidzero-dev/vite-plus-core@$resolved_ref" -vite_override_spec="$vite_plus_core_spec" - -if [ "$is_bun_project" -eq 1 ]; then - bun_repack_script="$script_dir/repack-vite-pr.sh" - if [ ! -f "$bun_repack_script" ]; then - echo "error: Bun pkg.pr.new repack helper not found: $bun_repack_script" >&2 - exit 2 - fi - - generated_tarball_path="$(bash "$bun_repack_script" "$resolved_ref" "$project_dir")" - if [ ! -f "$generated_tarball_path" ]; then - echo "error: Bun repack script did not create its reported tarball: $generated_tarball_path" >&2 - exit 1 - fi - - # Keep the real Core package directly resolvable alongside the repacked - # `vite` alias. Bun otherwise nests it under the local tarball dependency. - node "$script_dir/bun-pkg-pr-new.mjs" \ - add-core-dependency \ - "$project_dir/package.json" \ - "$vite_plus_core_spec" - - # The migrator applies this override to every workspace package. Use an - # absolute file URL so nested package.json files resolve the same tarball. - vite_override_spec="file:$generated_tarball_path" -fi read_installed_commit() { if [ -f "$commit_marker" ]; then @@ -214,6 +181,9 @@ else esac fi + # The global CLI ships per-platform binaries that the bridge cannot serve + # through npm's tarball path, so install it straight from pkg.pr.new by its + # immutable commit. echo "Installing Vite+ pkg.pr.new build $resolved_ref (requested $pr_ref) into $pr_home" HOME="$installer_home" \ VP_HOME="$pr_home" \ @@ -250,17 +220,19 @@ fi export VP_HOME="$pr_home" export PATH="$VP_HOME/bin:$PATH" -export VP_VERSION="$vite_plus_spec" -export VP_OVERRIDE_PACKAGES="$(node \ - "$override_json_helper" \ - "$vite_override_spec" \ - "$vitest_version")" +# vite-plus and vite (-> vite-plus-core) become ordinary npm versions. The +# values are constrained (commit SHA, semver) so the override JSON needs no +# escaping. +export VP_VERSION="$commit_version" +export VP_OVERRIDE_PACKAGES="{\"vite\":\"$vite_core_spec\",\"vitest\":\"$vitest_version\"}" export VP_FORCE_MIGRATE=1 -# pkg.pr.new packages depend on URL-resolved platform binaries. pnpm blocks -# those transitive URL dependencies when blockExoticSubdeps is enabled. The -# migration persists the corresponding workspace setting, while this temporary -# override also lets its pre-rewrite install recover a partially migrated tree. -export PNPM_CONFIG_BLOCK_EXOTIC_SUBDEPS=false +# Point every package manager at the registry bridge. It serves the vite-plus / +# vite-plus-core / per-platform CLI commit builds and proxies everything else to +# npmjs, so the project resolves the commit versions like any released package. +# npm_config_registry covers npm, pnpm, Yarn Classic and Bun; +# YARN_NPM_REGISTRY_SERVER covers Yarn Berry. +export npm_config_registry="$bridge_registry" +export YARN_NPM_REGISTRY_SERVER="$bridge_registry" hash -r echo @@ -269,16 +241,11 @@ echo " requested ref: $pr_ref" echo " resolved commit: $resolved_ref" echo " executable: $vp_bin" echo " installation: $(readlink "$pr_home/current" 2>/dev/null || echo unknown)" -echo " vite-plus spec: $VP_VERSION" -echo " vite spec: $vite_override_spec" +echo " registry bridge: $bridge_registry" +echo " vite-plus spec: $commit_version" +echo " vite spec: $vite_core_spec" "$vp_bin" --version -if [ "$is_bun_project" -eq 1 ] && [ -d "$project_dir/node_modules" ]; then - echo - echo "Removing stale Bun node_modules before migration" - rm -rf "$project_dir/node_modules" -fi - echo echo "Running vp migrate in $project_dir" set +e @@ -292,32 +259,6 @@ set +e migrate_status=$? set -e -if [ "$is_bun_project" -eq 1 ] && [ "$migrate_status" -eq 0 ]; then - # Migration uses one absolute file URL so every workspace can install the - # same tarball. Persist portable specs by rebasing that URL relative to each - # package.json, then refresh Bun's lockfile once with the final paths. - node "$script_dir/bun-pkg-pr-new.mjs" \ - normalize-vite-paths \ - "$project_dir" \ - "$generated_tarball_path" - - echo - echo "Reinstalling Bun dependencies with relative Vite tarball paths" - rm -rf "$project_dir/node_modules" - set +e - ( - cd "$project_dir" - unset VP_OVERRIDE_PACKAGES VP_FORCE_MIGRATE - "$vp_bin" install - ) - bun_install_status=$? - set -e - if [ "$bun_install_status" -ne 0 ]; then - echo "error: dependency installation failed after normalizing Bun file paths" >&2 - migrate_status="$bun_install_status" - fi -fi - if [ "$is_git_repo" -eq 1 ]; then echo echo "Migration worktree changes:" diff --git a/.github/workflows/publish-to-pkg.pr.new.yml b/.github/workflows/publish-to-pkg.pr.new.yml index 79279257c2..f6834436c4 100644 --- a/.github/workflows/publish-to-pkg.pr.new.yml +++ b/.github/workflows/publish-to-pkg.pr.new.yml @@ -137,3 +137,23 @@ jobs: './packages/cli' \ './packages/core' \ './packages/prompts' + + # Register this commit build with the pkg.pr.new registry bridge so it can + # be installed as the npm version 0.0.0-commit. + # (https://github.com/fengmk2/pkg-pr-registry-bridge). This CI step + # replaces the bridge's GitHub webhook. pkg-pr-new publishes under the PR + # head commit, so register that SHA (not the merge commit github.sha). + # Restricted to same-repo PRs because fork PRs do not receive the admin + # token secret; never fails the publish if the bridge is unreachable. + - name: Register commit build with the registry bridge + if: github.event.pull_request.head.repo.full_name == github.repository + continue-on-error: true + env: + PKG_PR_BRIDGE_ADMIN_TOKEN: ${{ secrets.PKG_PR_BRIDGE_ADMIN_TOKEN }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + curl -fsS -X POST \ + -H "authorization: Bearer ${PKG_PR_BRIDGE_ADMIN_TOKEN}" \ + -H 'content-type: application/json' \ + -d "{\"ref\":\"commit.${HEAD_SHA}\"}" \ + https://pkg-pr-registry-bridge.render.vip/-/refs From 1cde19ef3e2d7728ad950a4a0a227306f62b6761 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 27 Jun 2026 17:54:56 +0800 Subject: [PATCH 55/78] chore(migrate): fix oxfmt formatting and refresh global migration snaps Run oxfmt on migrator.ts / oxlint-plugin.ts, which drifted from the formatter after the importOptions removal and the @nuxt/test-utils cache change. Regenerate the two global migration snapshots (migration-from-vitest-config and migration-monorepo-pnpm-overrides-dependency-selector) so they reflect the current vitest-provider catalog alignment and pnpm-overrides merge behavior; both were left stale by an earlier commit. Claude-Session: https://claude.ai/code/session_01DQhS6o1fyQd1yjiee6W8jR --- .../migration-from-vitest-config/snap.txt | 3 +- .../snap.txt | 4 +- packages/cli/src/migration/migrator.ts | 104 +++++++----------- packages/cli/src/oxlint-plugin.ts | 5 +- 4 files changed, 50 insertions(+), 66 deletions(-) diff --git a/packages/cli/snap-tests-global/migration-from-vitest-config/snap.txt b/packages/cli/snap-tests-global/migration-from-vitest-config/snap.txt index d1e01f3cd9..13486d64ea 100644 --- a/packages/cli/snap-tests-global/migration-from-vitest-config/snap.txt +++ b/packages/cli/snap-tests-global/migration-from-vitest-config/snap.txt @@ -43,7 +43,7 @@ export default defineConfig({ "@vitest/browser-playwright": "", "vite": "catalog:", "vitest": "catalog:", - "@vitest/browser-webdriverio": "", + "@vitest/browser-webdriverio": "catalog:", "webdriverio": "*", "playwright": "*", "vite-plus": "catalog:" @@ -62,6 +62,7 @@ catalog: vite: npm:@voidzero-dev/vite-plus-core@ vitest: vite-plus: + '@vitest/browser-webdriverio': allowBuilds: edgedriver: true geckodriver: true diff --git a/packages/cli/snap-tests-global/migration-monorepo-pnpm-overrides-dependency-selector/snap.txt b/packages/cli/snap-tests-global/migration-monorepo-pnpm-overrides-dependency-selector/snap.txt index fce06ca981..0579fdcc2f 100644 --- a/packages/cli/snap-tests-global/migration-monorepo-pnpm-overrides-dependency-selector/snap.txt +++ b/packages/cli/snap-tests-global/migration-monorepo-pnpm-overrides-dependency-selector/snap.txt @@ -42,11 +42,11 @@ catalog: vite-plus: overrides: + '@vitejs/plugin-react>vite': npm:vite@ + supertest>superagent: react-click-away-listener>react: vite: 'catalog:' - vite-plugin-svgr>foo>vite: npm:vite@ '@vitejs/plugin-react-swc>vite': npm:vite@ - supertest>superagent: peerDependencyRules: allowAny: - vite diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index b1d92ad3d6..d5e9a66ab4 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -1854,7 +1854,8 @@ export function rewriteStandaloneProject( workspaceInfo: WorkspaceInfo, skipStagedMigration?: boolean, silent = false, - report?: MigrationReport,): void { + report?: MigrationReport, +): void { const packageJsonPath = path.join(projectPath, 'package.json'); if (!fs.existsSync(packageJsonPath)) { return; @@ -1901,13 +1902,10 @@ export function rewriteStandaloneProject( shouldAllowBrowserProviderBuilds = hasOwnWebdriverioDependency(pkg) || usesWebdriverioProvider(projectPath); const requiredVitestPeer = projectListsRequiredVitestPeer(projectPath, pkg); - usesVitest = projectUsesVitestDirectly( - projectPath, - pkg, - requiredVitestPeer, - true, - { browserMode, retainedModule: retainedVitestModule }, - ); + usesVitest = projectUsesVitestDirectly(projectPath, pkg, requiredVitestPeer, true, { + browserMode, + retainedModule: retainedVitestModule, + }); const managed = managedOverridePackages(usesVitest); // Strip stale `vite-plus-test` wrapper aliases before injecting new overrides // so the deleted wrapper doesn't survive migration in any sink. @@ -2105,12 +2103,7 @@ export function rewriteStandaloneProject( injectFmtDefaults(projectPath, silent, report); mergeTsdownConfigFile(projectPath, silent, report); // rewrite imports in all TypeScript/JavaScript files before lazy plugin import merging - rewriteAllImports( - projectPath, - silent, - report, - true, - ); + rewriteAllImports(projectPath, silent, report, true); wrapLazyPluginsInViteConfig(projectPath, silent, report); // set package manager setPackageManager(projectPath, workspaceInfo.downloadPackageManager); @@ -2124,7 +2117,8 @@ export function rewriteMonorepo( workspaceInfo: WorkspaceInfo, skipStagedMigration?: boolean, silent = false, - report?: MigrationReport,): void { + report?: MigrationReport, +): void { const catalogDependencyResolver = createCatalogDependencyResolver( workspaceInfo.rootDir, workspaceInfo.packageManager, @@ -2172,7 +2166,8 @@ export function rewriteMonorepo( pnpmMajorVersion, workspaceInfo.downloadPackageManager.version, workspaceShouldAllowBrowserBuilds, - workspaceUsesVitest, ); + workspaceUsesVitest, + ); if (workspaceInfo.packageManager === PackageManager.pnpm) { rewritePnpmWorkspaceYaml( workspaceInfo.rootDir, @@ -2213,7 +2208,8 @@ export function rewriteMonorepo( report, catalogDependencyResolver, workspaceContext, - true, ); + true, + ); } if (!skipStagedMigration) { @@ -2226,12 +2222,7 @@ export function rewriteMonorepo( injectFmtDefaults(workspaceInfo.rootDir, silent, report); mergeTsdownConfigFile(workspaceInfo.rootDir, silent, report); // rewrite imports in all TypeScript/JavaScript files before lazy plugin import merging - rewriteAllImports( - workspaceInfo.rootDir, - silent, - report, - true, - ); + rewriteAllImports(workspaceInfo.rootDir, silent, report, true); wrapLazyPluginsInViteConfig(workspaceInfo.rootDir, silent, report); for (const pkg of workspaceInfo.packages) { wrapLazyPluginsInViteConfig(path.join(workspaceInfo.rootDir, pkg.path), silent, report); @@ -2257,7 +2248,8 @@ export function rewriteMonorepoProject( report?: MigrationReport, catalogDependencyResolver?: CatalogDependencyResolver, workspaceContext?: { rootDir: string; packages: WorkspacePackage[] }, - deferLazyPluginWrapping = false,): void { + deferLazyPluginWrapping = false, +): void { cleanupDeprecatedTsconfigOptions(projectPath, silent, report); rewriteTsconfigTypes(projectPath, silent, report); mergeViteConfigFiles( @@ -2309,13 +2301,10 @@ export function rewriteMonorepoProject( catalogDependencyResolver, browserMode, collectProviderSourceModes(projectPath), - projectUsesVitestDirectly( - projectPath, - pkg, - requiredVitestPeer, - true, - { browserMode, retainedModule: retainedVitestModule }, - ), + projectUsesVitestDirectly(projectPath, pkg, requiredVitestPeer, true, { + browserMode, + retainedModule: retainedVitestModule, + }), retainedVitestModule, requiredVitestPeer, ); @@ -3586,7 +3575,8 @@ function rewriteRootWorkspacePackageJson( // Workspace-wide direct-vitest signal: the root resolution/override sinks are // shared by every package, so `vitest` stays managed here iff ANY package uses // vitest directly. - workspaceUsesVitest = true,): void { + workspaceUsesVitest = true, +): void { const packageJsonPath = path.join(projectPath, 'package.json'); if (!fs.existsSync(packageJsonPath)) { return; @@ -3732,7 +3722,8 @@ function rewriteRootWorkspacePackageJson( undefined, catalogDependencyResolver, packages ? { rootDir: projectPath, packages } : undefined, - true, ); + true, + ); } const RULES_YAML_PATH = path.join(rulesDir, 'vite-tools.yml'); @@ -3833,7 +3824,8 @@ export function finalizeCoreMigrationForExistingVitePlus( workspaceInfo: CoreMigrationWorkspace, silent = false, report?: MigrationReport, - pending = detectPendingCoreMigration(workspaceInfo),): CoreMigrationFinalizationResult { + pending = detectPendingCoreMigration(workspaceInfo), +): CoreMigrationFinalizationResult { const projectPaths = getCoreMigrationProjectPaths(workspaceInfo); const result: CoreMigrationFinalizationResult = { scripts: false, @@ -3854,12 +3846,7 @@ export function finalizeCoreMigrationForExistingVitePlus( } } - result.imports = rewriteAllImports( - workspaceInfo.rootDir, - silent, - report, - true, - ); + result.imports = rewriteAllImports(workspaceInfo.rootDir, silent, report, true); return result; } @@ -4278,14 +4265,10 @@ function reconcileVitePlusBootstrapPackage( packageManager: PackageManager, supportCatalog: boolean, ensureVitePlus: boolean, - catalogDependencyResolver?: CatalogDependencyResolver,): boolean { + catalogDependencyResolver?: CatalogDependencyResolver, +): boolean { const before = JSON.stringify(pkg); - const usesVitest = projectUsesVitestDirectly( - projectPath, - pkg, - undefined, - true, - ); + const usesVitest = projectUsesVitestDirectly(projectPath, pkg, undefined, true); ensureVitePlusDependencySpecs(pkg, vitePlusVersion, ensureVitePlus); const installGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; @@ -4518,7 +4501,8 @@ export function detectVitePlusBootstrapPending( projectPath: string, packageManager: PackageManager | undefined, packages?: WorkspacePackage[], - packageManagerVersion?: string,): boolean { + packageManagerVersion?: string, +): boolean { const packageJsonPath = path.join(projectPath, 'package.json'); if (!fs.existsSync(packageJsonPath)) { return false; @@ -4576,7 +4560,8 @@ export function detectVitePlusBootstrapPending( packageManager, supportCatalog, index === 0, - catalogDependencyResolver, ) + catalogDependencyResolver, + ) ) { return true; } @@ -4584,11 +4569,7 @@ export function detectVitePlusBootstrapPending( // Shared override/catalog sinks must keep vitest managed when any package in // the workspace needs it. The direct dependency itself is localized above. - const usesVitest = workspaceUsesVitestDirectly( - projectPath, - packages, - true, - ); + const usesVitest = workspaceUsesVitestDirectly(projectPath, packages, true); if (packageManager === PackageManager.yarn) { return ( @@ -4752,7 +4733,8 @@ function ensurePnpmPeerDependencyRules(pkg: BootstrapPackageJson, usesVitest: bo export function ensureVitePlusBootstrap( workspaceInfo: WorkspaceInfo, - report?: MigrationReport,): VitePlusBootstrapResult { + report?: MigrationReport, +): VitePlusBootstrapResult { const projectPath = workspaceInfo.rootDir; const packageJsonPath = path.join(projectPath, 'package.json'); const result: VitePlusBootstrapResult = { @@ -4768,11 +4750,7 @@ export function ensureVitePlusBootstrap( // Shared override/catalog sinks are workspace-wide, so keep vitest managed // when any package needs it. Each package's direct vitest dependency is // reconciled independently below. - const usesVitest = workspaceUsesVitestDirectly( - projectPath, - workspaceInfo.packages, - true, - ); + const usesVitest = workspaceUsesVitestDirectly(projectPath, workspaceInfo.packages, true); const pnpmMajorVersion = pnpmMajor(workspaceInfo.downloadPackageManager.version); const shouldAllowBrowserBuilds = workspaceUsesWebdriverio(projectPath, workspaceInfo.packages); const usePnpmWorkspaceYaml = @@ -4819,7 +4797,8 @@ export function ensureVitePlusBootstrap( workspaceInfo.packageManager, supportCatalog, true, - catalogDependencyResolver, ); + catalogDependencyResolver, + ); if (workspaceInfo.packageManager === PackageManager.yarn) { const ensured = ensureOverrideEntries(pkg.resolutions, usesVitest); @@ -4886,7 +4865,8 @@ export function ensureVitePlusBootstrap( workspaceInfo.packageManager, supportCatalog, false, - catalogDependencyResolver, ); + catalogDependencyResolver, + ); return childChanged ? pkg : undefined; }); result.packageJson = result.packageJson || childChanged; diff --git a/packages/cli/src/oxlint-plugin.ts b/packages/cli/src/oxlint-plugin.ts index 02acca2666..01c4b80fd7 100644 --- a/packages/cli/src/oxlint-plugin.ts +++ b/packages/cli/src/oxlint-plugin.ts @@ -104,7 +104,10 @@ function quoteSpecifier(literal: ESTree.StringLiteral, replacement: string): str // Keyed by package.json path and invalidated by its mtime so a long-lived lint // process (editor/LSP session) re-reads the manifest after the user adds or // removes `@nuxt/test-utils`, instead of reusing the pre-edit decision forever. -const nuxtTestUtilsPackageCache = new Map(); +const nuxtTestUtilsPackageCache = new Map< + string, + { mtimeMs: number; usesNuxtTestUtils: boolean } +>(); function isUpstreamVitestSpecifier(specifier: string): boolean { return specifier === 'vitest' || specifier.startsWith('vitest/'); From d13148f3995fa0ab5d64dfa5fde5ecf5286d5324 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 27 Jun 2026 19:47:07 +0800 Subject: [PATCH 56/78] ci(migrate): persist bridge registry into project .npmrc and .yarnrc.yml The pkg.pr.new helper set the bridge registry only via environment variables, which pnpm ignores for resolution (it reads .npmrc), so the migration install fetched the commit version from registry.npmjs.org and failed with ERR_PNPM_NO_MATCHING_VERSION. Write the bridge registry into the project's .npmrc (npm/pnpm/Yarn Classic/Bun) and, for Yarn Berry projects, .yarnrc.yml (npmRegistryServer), so the migrated project resolves the commit builds both during the run and in its own CI. Both writes are idempotent and left in place; the env vars remain as a local-run fallback. Claude-Session: https://claude.ai/code/session_01DQhS6o1fyQd1yjiee6W8jR --- .github/scripts/test-pkg-pr-new-migrate.sh | 48 +++++++++++++++++++++- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/.github/scripts/test-pkg-pr-new-migrate.sh b/.github/scripts/test-pkg-pr-new-migrate.sh index ac8a9a1f7f..5fe3a1e05b 100755 --- a/.github/scripts/test-pkg-pr-new-migrate.sh +++ b/.github/scripts/test-pkg-pr-new-migrate.sh @@ -12,6 +12,10 @@ Installs an isolated global Vite+ CLI built from a pkg.pr.new commit and runs bridge (https://github.com/fengmk2/pkg-pr-registry-bridge) so they install like ordinary npm versions (0.0.0-commit.) instead of mutable pkg.pr.new URLs. +Persists the bridge registry into the project's `.npmrc` (npm/pnpm/Yarn +Classic/Bun) and, for Yarn Berry projects, `.yarnrc.yml`, so the migrated +project resolves the commit versions both during this run and in its own CI. + Examples: .github/scripts/test-pkg-pr-new-migrate.sh 1891 /path/to/npmx.dev .github/scripts/test-pkg-pr-new-migrate.sh 4eb2104c /path/to/project --no-interactive @@ -229,10 +233,49 @@ export VP_FORCE_MIGRATE=1 # Point every package manager at the registry bridge. It serves the vite-plus / # vite-plus-core / per-platform CLI commit builds and proxies everything else to # npmjs, so the project resolves the commit versions like any released package. -# npm_config_registry covers npm, pnpm, Yarn Classic and Bun; -# YARN_NPM_REGISTRY_SERVER covers Yarn Berry. +# Yarn Berry only honors YARN_NPM_REGISTRY_SERVER; Bun honors npm_config_registry. export npm_config_registry="$bridge_registry" export YARN_NPM_REGISTRY_SERVER="$bridge_registry" + +# Persist the bridge registry into the project's own config files so the +# migrated project installs the commit builds in ITS OWN CI too, not just during +# this run (the env vars above are not persisted). pnpm in particular resolves +# from .npmrc, not npm_config_registry, and without this fetches the commit +# version from registry.npmjs.org and fails with ERR_PNPM_NO_MATCHING_VERSION. +registry_marker="# pkg.pr.new registry bridge (added by test-pkg-pr-new-migrate.sh)" + +# .npmrc is read by npm, pnpm, Yarn Classic and Bun. +project_npmrc="$project_dir/.npmrc" +if ! grep -qsF "$registry_marker" "$project_npmrc"; then + if [ -s "$project_npmrc" ]; then + printf '\n' >> "$project_npmrc" + fi + printf '%s\nregistry=%s\n' "$registry_marker" "$bridge_registry" >> "$project_npmrc" +fi + +# Yarn Berry ignores .npmrc and reads .yarnrc.yml instead. The migration's own +# .yarnrc.yml rewrite preserves unrelated keys, so npmRegistryServer survives. +project_yarnrc="$project_dir/.yarnrc.yml" +is_yarn_berry=0 +if [ -f "$project_yarnrc" ] || + { [ -f "$project_dir/yarn.lock" ] && grep -q '^__metadata:' "$project_dir/yarn.lock" 2>/dev/null; } || + grep -qE '"packageManager"[[:space:]]*:[[:space:]]*"yarn@([2-9]|[1-9][0-9])' "$project_dir/package.json" 2>/dev/null; then + is_yarn_berry=1 +fi +if [ "$is_yarn_berry" -eq 1 ]; then + if grep -qsE '^npmRegistryServer:' "$project_yarnrc"; then + # Override an existing default-registry setting in place. + sed -i.pkg-pr-new.bak -E \ + "s|^npmRegistryServer:.*|npmRegistryServer: \"$bridge_registry\"|" "$project_yarnrc" + rm -f "$project_yarnrc.pkg-pr-new.bak" + elif ! grep -qsF "$registry_marker" "$project_yarnrc"; then + if [ -s "$project_yarnrc" ]; then + printf '\n' >> "$project_yarnrc" + fi + printf '%s\nnpmRegistryServer: "%s"\n' "$registry_marker" "$bridge_registry" >> "$project_yarnrc" + fi +fi + hash -r echo @@ -242,6 +285,7 @@ echo " resolved commit: $resolved_ref" echo " executable: $vp_bin" echo " installation: $(readlink "$pr_home/current" 2>/dev/null || echo unknown)" echo " registry bridge: $bridge_registry" +echo " project .npmrc: $project_npmrc" echo " vite-plus spec: $commit_version" echo " vite spec: $vite_core_spec" "$vp_bin" --version From 7d73cd9a7849e40956e70a495a4575128cb20195 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 27 Jun 2026 20:34:33 +0800 Subject: [PATCH 57/78] fix(migrate): do not duplicate vite-plus into devDependencies When a project declares vite-plus in `dependencies` (instead of the conventional `devDependencies`), migration appended a second vite-plus entry to `devDependencies`, leaving it in both groups with a conflicting spec. Every "ensure vite-plus is present" check looked only at `devDependencies`; the firing site for the full-migration path was `rewritePackageJson`, whose `existingVitePlus` ignored `dependencies`. Treat vite-plus as already present when it lives in `dependencies` or `devDependencies` (a `hasDirectVitePlusInstallEntry` helper), and re-pin / normalize the existing entry in place rather than adding a cross-group duplicate. `optionalDependencies` is intentionally excluded so an optional-only entry still gets a guaranteed devDependencies entry. Force-override still re-pins a pre-existing devDependencies entry. Adds a reproducing unit test, the migration-vite-plus-in-dependencies-pnpm snap test, and updates the bootstrap-path tests that codified the old duplicate behavior. Claude-Session: https://claude.ai/code/session_01DQhS6o1fyQd1yjiee6W8jR --- .../package.json | 13 +++++ .../snap.txt | 31 ++++++++++ .../steps.json | 7 +++ .../src/migration/__tests__/migrator.spec.ts | 40 +++++++++++-- packages/cli/src/migration/migrator.ts | 57 ++++++++++++++++--- 5 files changed, 134 insertions(+), 14 deletions(-) create mode 100644 packages/cli/snap-tests/migration-vite-plus-in-dependencies-pnpm/package.json create mode 100644 packages/cli/snap-tests/migration-vite-plus-in-dependencies-pnpm/snap.txt create mode 100644 packages/cli/snap-tests/migration-vite-plus-in-dependencies-pnpm/steps.json diff --git a/packages/cli/snap-tests/migration-vite-plus-in-dependencies-pnpm/package.json b/packages/cli/snap-tests/migration-vite-plus-in-dependencies-pnpm/package.json new file mode 100644 index 0000000000..c389894675 --- /dev/null +++ b/packages/cli/snap-tests/migration-vite-plus-in-dependencies-pnpm/package.json @@ -0,0 +1,13 @@ +{ + "name": "migration-vite-plus-in-dependencies-pnpm", + "dependencies": { + "vite-plus": "0.1.20" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "10.33.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests/migration-vite-plus-in-dependencies-pnpm/snap.txt b/packages/cli/snap-tests/migration-vite-plus-in-dependencies-pnpm/snap.txt new file mode 100644 index 0000000000..d4c7ebc7b3 --- /dev/null +++ b/packages/cli/snap-tests/migration-vite-plus-in-dependencies-pnpm/snap.txt @@ -0,0 +1,31 @@ +> vp migrate --no-interactive --no-hooks # vite-plus declared in dependencies must NOT be duplicated into devDependencies +◇ Migrated . to Vite+ +• Node pnpm +• Package manager settings configured + +> cat package.json # vite-plus stays in dependencies (normalized to catalog:); no duplicate devDependencies entry +{ + "name": "migration-vite-plus-in-dependencies-pnpm", + "dependencies": { + "vite-plus": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } +} + +> cat pnpm-workspace.yaml # the catalog carries the managed vite-plus version that the dependencies catalog: ref resolves to +catalog: + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: +overrides: + vite: 'catalog:' +peerDependencyRules: + allowAny: + - vite + allowedVersions: + vite: '*' diff --git a/packages/cli/snap-tests/migration-vite-plus-in-dependencies-pnpm/steps.json b/packages/cli/snap-tests/migration-vite-plus-in-dependencies-pnpm/steps.json new file mode 100644 index 0000000000..61dc44390d --- /dev/null +++ b/packages/cli/snap-tests/migration-vite-plus-in-dependencies-pnpm/steps.json @@ -0,0 +1,7 @@ +{ + "commands": [ + "vp migrate --no-interactive --no-hooks # vite-plus declared in dependencies must NOT be duplicated into devDependencies", + "cat package.json # vite-plus stays in dependencies (normalized to catalog:); no duplicate devDependencies entry", + "cat pnpm-workspace.yaml # the catalog carries the managed vite-plus version that the dependencies catalog: ref resolves to" + ] +} diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 9b9b3617ac..0afcd3c58f 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -2578,10 +2578,14 @@ describe('ensureVitePlusBootstrap', () => { expect(result.changed).toBe(true); expect(fs.existsSync(path.join(tmpDir, 'pnpm-workspace.yaml'))).toBe(true); const pkg = readJson(path.join(tmpDir, 'package.json')) as { - devDependencies: Record; + dependencies: Record; + devDependencies?: Record; pnpm?: unknown; }; - expect(pkg.devDependencies['vite-plus']).toBe('catalog:'); + // vite-plus was declared in `dependencies`, so it is normalized in place + // (to `catalog:`) and not duplicated into `devDependencies`. + expect(pkg.dependencies['vite-plus']).toBe('catalog:'); + expect(pkg.devDependencies?.['vite-plus']).toBeUndefined(); expect(pkg.pnpm).toBeUndefined(); const workspace = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { catalog: Record; @@ -2759,12 +2763,14 @@ describe('ensureVitePlusBootstrap', () => { expect(result.changed).toBe(true); expect(fs.existsSync(path.join(tmpDir, 'pnpm-workspace.yaml'))).toBe(true); const pkg = readJson(path.join(tmpDir, 'package.json')) as { - devDependencies: Record; + devDependencies?: Record; dependencies: Record; optionalDependencies: Record; pnpm?: unknown; }; - expect(pkg.devDependencies['vite-plus']).toBe('catalog:'); + // vite-plus already lives in `dependencies` (and `optionalDependencies`), so + // it is kept in place and not duplicated into `devDependencies`. + expect(pkg.devDependencies?.['vite-plus']).toBeUndefined(); expect(pkg.dependencies['vite-plus']).toBe('catalog:'); expect(pkg.optionalDependencies['vite-plus']).toBe('catalog:'); expect(pkg.pnpm).toBeUndefined(); @@ -2790,10 +2796,14 @@ describe('ensureVitePlusBootstrap', () => { expect(result.changed).toBe(true); expect(fs.existsSync(path.join(tmpDir, 'pnpm-workspace.yaml'))).toBe(true); const pkg = readJson(path.join(tmpDir, 'package.json')) as { - devDependencies: Record; + dependencies: Record; + devDependencies?: Record; pnpm?: unknown; }; - expect(pkg.devDependencies['vite-plus']).toBe('catalog:'); + // vite-plus was declared in `dependencies`, so it is normalized in place + // (to `catalog:`) and not duplicated into `devDependencies`. + expect(pkg.dependencies['vite-plus']).toBe('catalog:'); + expect(pkg.devDependencies?.['vite-plus']).toBeUndefined(); expect(pkg.pnpm).toBeUndefined(); }); @@ -3097,6 +3107,24 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { expect(devDeps['vite-plus']).toBe('catalog:'); }); + it('does not duplicate vite-plus into devDependencies when it already lives in dependencies', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + dependencies: { 'vite-plus': '0.1.20' }, + devDependencies: { vite: '^7.0.0' }, + }), + ); + rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + dependencies?: Record; + devDependencies?: Record; + }; + expect(pkg.devDependencies?.['vite-plus']).toBeUndefined(); + expect(pkg.dependencies?.['vite-plus']).toBeDefined(); + }); + it('moves existing pnpm config into pnpm-workspace.yaml on pnpm 10.6.2+', () => { fs.writeFileSync( path.join(tmpDir, 'package.json'), diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index d5e9a66ab4..5bbee1ece4 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -2033,8 +2033,13 @@ export function rewriteStandaloneProject( requiredVitestPeer, ); - // ensure vite-plus is in devDependencies - if (!pkg.devDependencies?.[VITE_PLUS_NAME] || isForceOverrideMode()) { + // ensure vite-plus is in devDependencies — but only when it isn't already a + // direct dependency/devDependency, so a project that declares vite-plus in + // `dependencies` is not duplicated into `devDependencies`. Force-override + // still re-pins a pre-existing devDependencies entry in place. + const forceRepinExistingDevEntry = + isForceOverrideMode() && pkg.devDependencies?.[VITE_PLUS_NAME] !== undefined; + if (!hasDirectVitePlusInstallEntry(pkg) || forceRepinExistingDevEntry) { const existingVitePlusSpec = pkg.devDependencies?.[VITE_PLUS_NAME]; const version = supportCatalog && !VITE_PLUS_VERSION.startsWith('file:') @@ -3695,8 +3700,9 @@ function rewriteRootWorkspacePackageJson( } } - // ensure vite-plus is in devDependencies - if (!pkg.devDependencies?.[VITE_PLUS_NAME]) { + // ensure vite-plus is in devDependencies — skip when it already lives in + // `dependencies` or `devDependencies` so it isn't duplicated across groups. + if (!hasDirectVitePlusInstallEntry(pkg)) { pkg.devDependencies = { ...pkg.devDependencies, [VITE_PLUS_NAME]: @@ -4513,7 +4519,10 @@ export function detectVitePlusBootstrapPending( catalogs?: Record>; }; - if (!pkg.devDependencies?.[VITE_PLUS_NAME] || !hasPackageManagerPin(pkg)) { + // vite-plus counts as installed when it's a direct dependency/devDependency, + // so a project that declares it in `dependencies` isn't reported as pending a + // (duplicate) devDependencies entry. + if (!hasDirectVitePlusInstallEntry(pkg) || !hasPackageManagerPin(pkg)) { return true; } @@ -4616,6 +4625,20 @@ export function detectVitePlusBootstrapPending( return false; } +// vite-plus counts as already installed when it lives directly in +// `dependencies` OR `devDependencies`. `optionalDependencies` is deliberately +// excluded: an optional-only entry may be skipped at install time, so the +// package should still receive a guaranteed `devDependencies` entry. +function hasDirectVitePlusInstallEntry(pkg: { + dependencies?: Record; + devDependencies?: Record; +}): boolean { + return ( + pkg.dependencies?.[VITE_PLUS_NAME] !== undefined || + pkg.devDependencies?.[VITE_PLUS_NAME] !== undefined + ); +} + function ensureVitePlusDependencySpecs( pkg: BootstrapPackageJson, version: string, @@ -4661,7 +4684,7 @@ function ensureVitePlusDependencySpecs( changed = true; } } - if (pkg.devDependencies?.[VITE_PLUS_NAME] || !ensurePresent) { + if (hasDirectVitePlusInstallEntry(pkg) || !ensurePresent) { return changed; } pkg.devDependencies = { @@ -5526,7 +5549,17 @@ export function rewritePackageJson( supportCatalog && !VITE_PLUS_VERSION.startsWith('file:') ? (catalogDependencyResolver?.preferredCatalogSpec ?? 'catalog:') : VITE_PLUS_VERSION; - const existingVitePlus = pkg.devDependencies?.[VITE_PLUS_NAME]; + // Treat vite-plus as present when it lives in either `devDependencies` or + // `dependencies` (devDeps wins when both exist). Re-pin/normalize happens in + // whichever group already owns it so a `dependencies` entry is never + // duplicated into `devDependencies`. + const existingVitePlusGroup = + pkg.devDependencies?.[VITE_PLUS_NAME] !== undefined + ? pkg.devDependencies + : pkg.dependencies?.[VITE_PLUS_NAME] !== undefined + ? pkg.dependencies + : undefined; + const existingVitePlus = existingVitePlusGroup?.[VITE_PLUS_NAME]; const shouldNormalizeExistingVitePlus = !!existingVitePlus && supportCatalog && @@ -5555,7 +5588,15 @@ export function rewritePackageJson( isVitestAdjacent || retainedVitestModule || requiredVitestPeer; - if (needVitePlus || shouldNormalizeExistingVitePlus) { + if (existingVitePlusGroup) { + // Already present in `dependencies` or `devDependencies`: re-pin in place + // (only vanilla ranges are normalized; protocol pins are preserved) and + // never add a cross-group duplicate. + if (shouldNormalizeExistingVitePlus) { + existingVitePlusGroup[VITE_PLUS_NAME] = canonicalVitePlusSpec; + } + } else if (needVitePlus) { + // Absent from both groups: add it to `devDependencies` as before. pkg.devDependencies = { ...pkg.devDependencies, [VITE_PLUS_NAME]: canonicalVitePlusSpec, From 094397cbad89c5dd04b16c3502c6cde02f1dafa1 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 27 Jun 2026 21:24:52 +0800 Subject: [PATCH 58/78] refactor(migrate): split migrator.ts into category modules migrator.ts was ~7,300 lines. Move its 244 declarations verbatim into 16 category modules under migration/migrator/ (eslint, prettier, catalog, vitest-ecosystem, vite-plus-bootstrap, package-json, vite-config, yarn, source-scan, git-hooks, orchestrators, ...). migrator.ts is now a barrel of `export *` re-exports, so the external importers keep importing from ./migrator.ts unchanged. Cross-module function helpers are imported from the barrel (safe because they are only referenced inside function bodies at runtime); shared constants/types live in shared.ts and are imported directly from it to avoid a load-time cycle. Pure code move, no behavior change: tsc clean, vp check clean, 323 migration unit tests unchanged. Adds migrator/README.md documenting the structure and the rules for adding modules. Claude-Session: https://claude.ai/code/session_01DQhS6o1fyQd1yjiee6W8jR --- packages/cli/src/migration/migrator.ts | 7313 +---------------- packages/cli/src/migration/migrator/README.md | 123 + .../cli/src/migration/migrator/catalog.ts | 1247 +++ .../migration/migrator/core-finalization.ts | 138 + packages/cli/src/migration/migrator/eslint.ts | 894 ++ .../src/migration/migrator/framework-shim.ts | 101 + .../cli/src/migration/migrator/git-hooks.ts | 517 ++ .../src/migration/migrator/orchestrators.ts | 563 ++ .../src/migration/migrator/package-json.ts | 412 + .../cli/src/migration/migrator/prettier.ts | 338 + packages/cli/src/migration/migrator/setup.ts | 191 + packages/cli/src/migration/migrator/shared.ts | 220 + .../cli/src/migration/migrator/source-scan.ts | 338 + .../cli/src/migration/migrator/tsconfig.ts | 61 + .../cli/src/migration/migrator/vite-config.ts | 531 ++ .../migration/migrator/vite-plus-bootstrap.ts | 951 +++ .../migration/migrator/vitest-ecosystem.ts | 760 ++ packages/cli/src/migration/migrator/yarn.ts | 403 + 18 files changed, 7804 insertions(+), 7297 deletions(-) create mode 100644 packages/cli/src/migration/migrator/README.md create mode 100644 packages/cli/src/migration/migrator/catalog.ts create mode 100644 packages/cli/src/migration/migrator/core-finalization.ts create mode 100644 packages/cli/src/migration/migrator/eslint.ts create mode 100644 packages/cli/src/migration/migrator/framework-shim.ts create mode 100644 packages/cli/src/migration/migrator/git-hooks.ts create mode 100644 packages/cli/src/migration/migrator/orchestrators.ts create mode 100644 packages/cli/src/migration/migrator/package-json.ts create mode 100644 packages/cli/src/migration/migrator/prettier.ts create mode 100644 packages/cli/src/migration/migrator/setup.ts create mode 100644 packages/cli/src/migration/migrator/shared.ts create mode 100644 packages/cli/src/migration/migrator/source-scan.ts create mode 100644 packages/cli/src/migration/migrator/tsconfig.ts create mode 100644 packages/cli/src/migration/migrator/vite-config.ts create mode 100644 packages/cli/src/migration/migrator/vite-plus-bootstrap.ts create mode 100644 packages/cli/src/migration/migrator/vitest-ecosystem.ts create mode 100644 packages/cli/src/migration/migrator/yarn.ts diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 5bbee1ece4..fa4c23f13d 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -1,7297 +1,16 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { styleText } from 'node:util'; - -import * as prompts from '@voidzero-dev/vite-plus-prompts'; -import spawn from 'cross-spawn'; -import type { OxlintConfig } from 'oxlint'; -import semver from 'semver'; -import { Scalar, YAMLMap, YAMLSeq } from 'yaml'; - -import { - hasConfigKey, - mergeJsonConfig, - mergeTsdownConfig, - rewriteEslint, - rewritePrettier, - rewriteScripts, - rewriteImportsInDirectory, - wrapLazyPlugins, - type DownloadPackageManagerResult, -} from '../../binding/index.js'; -import { - createDefaultVitePlusLintConfig, - ensureVitePlusImportRuleDefaults, -} from '../oxlint-plugin-config.ts'; -import { PackageManager, type WorkspaceInfo, type WorkspacePackage } from '../types/index.ts'; -import { runCommandSilently } from '../utils/command.ts'; -import { - BASEURL_TSCONFIG_WARNING, - VITE_PLUS_NAME, - VITE_PLUS_OVERRIDE_PACKAGES, - VITE_PLUS_VERSION, - VITEST_AGE_GATE_EXEMPT_PACKAGES, - VITEST_VERSION, - isForceOverrideMode, -} from '../utils/constants.ts'; -import { editJsonFile, isJsonFile, readJsonFile } from '../utils/json.ts'; -import { detectPackageMetadata } from '../utils/package.ts'; -import { displayRelative, rulesDir } from '../utils/path.ts'; -import { cancelAndExit } from '../utils/prompts.ts'; -import { getSpinner } from '../utils/spinner.ts'; -import { - findTsconfigFiles, - hasBaseUrlInTsconfig, - hasTypesToRewriteInTsconfig, - hasVitestTypesInTsconfig, - removeDeprecatedTsconfigFalseOption, - rewriteTypesInTsconfig, -} from '../utils/tsconfig.ts'; -import type { NpmWorkspaces } from '../utils/workspace.ts'; -import { editYamlFile, readYamlFile, scalarString, type YamlDocument } from '../utils/yaml.ts'; -import { - PRETTIER_CONFIG_FILES, - PRETTIER_PACKAGE_JSON_CONFIG, - detectConfigs, - type ConfigFiles, -} from './detector.ts'; -import { addManualStep, addMigrationWarning, type MigrationReport } from './report.ts'; - -// All known lint-staged config file names. -// JSON-parseable ones come first so rewriteLintStagedConfigFile can rewrite them. -const LINT_STAGED_JSON_CONFIG_FILES = ['.lintstagedrc.json', '.lintstagedrc'] as const; -const LINT_STAGED_OTHER_CONFIG_FILES = [ - '.lintstagedrc.yaml', - '.lintstagedrc.yml', - '.lintstagedrc.mjs', - 'lint-staged.config.mjs', - '.lintstagedrc.cjs', - 'lint-staged.config.cjs', - '.lintstagedrc.js', - 'lint-staged.config.js', - '.lintstagedrc.ts', - 'lint-staged.config.ts', - '.lintstagedrc.mts', - 'lint-staged.config.mts', - '.lintstagedrc.cts', - 'lint-staged.config.cts', -] as const; -const LINT_STAGED_ALL_CONFIG_FILES = [ - ...LINT_STAGED_JSON_CONFIG_FILES, - ...LINT_STAGED_OTHER_CONFIG_FILES, -] as const; - -// packages that are replaced with vite-plus -const REMOVE_PACKAGES = [ - 'oxlint', - 'oxlint-tsgolint', - 'oxfmt', - 'tsdown', - '@vitest/browser', - '@vitest/browser-preview', -] as const; - -// The opt-in browser providers. Unlike `@vitest/browser`/preview these are NOT -// bundled by vite-plus or stripped from users (so they stay out of -// REMOVE_PACKAGES); each drags a heavy non-optional framework peer -// (`playwright` / `webdriverio`) that non-browser consumers must not be forced -// to install. The migration keeps a provider the user actually targets in their -// own deps, pinned to the bundled vitest version. -const WEBDRIVERIO_PROVIDER = '@vitest/browser-webdriverio'; -const PLAYWRIGHT_PROVIDER = '@vitest/browser-playwright'; - -// All opt-in browser providers handled identically by the migration: kept in -// the user's deps (pinned to the bundled vitest), framework peer ensured, stale -// forcing pins dropped, while their catalog entries are PRESERVED. -const OPT_IN_BROWSER_PROVIDERS = [WEBDRIVERIO_PROVIDER, PLAYWRIGHT_PROVIDER] as const; - -// Official `@vitest/*` packages are versioned in lockstep with vitest and carry -// an EXACT `vitest` peer (verified against the registry: `@vitest/coverage-v8`, -// `@vitest/coverage-istanbul`, `@vitest/ui`, `@vitest/web-worker`, the browser -// family, and the runtime internals all pin `vitest: `), so any the -// project lists must match the bundled vitest or Vitest runs mixed copies (the -// `define-config.ts` coverage guard fail-fasts on exactly this skew). -// `@vitest/eslint-plugin` versions on its own line, and deprecated -// `@vitest/coverage-c8` never published on the Vitest 4 line, so neither may be -// pinned to the bundled Vitest version. -const VITEST_ALIGN_EXCLUDED = new Set([ - '@vitest/eslint-plugin', - // Deprecated at 0.33.0 and replaced by @vitest/coverage-v8. It does not - // publish versions on Vitest's current release line, so pinning it to the - // bundled Vitest version creates a dependency spec that does not exist. - '@vitest/coverage-c8', -]); - -// Official packages that do not declare a required `vitest` peer. Keep them -// aligned when a project lists them directly, but do not add a direct vitest -// merely because they are present. -const VITEST_DIRECT_USAGE_EXCLUDED = new Set([ - '@vitest/eslint-plugin', - '@vitest/expect', - '@vitest/mocker', - '@vitest/pretty-format', - '@vitest/runner', - '@vitest/snapshot', - '@vitest/spy', - '@vitest/utils', - '@vitest/ws-client', -]); - -function isAlignableVitestEcosystemPackage(name: string): boolean { - return name.startsWith('@vitest/') && !VITEST_ALIGN_EXCLUDED.has(name); -} - -// Provider names whose stale pnpm overrides / resolutions are dropped during -// migration: everything vite-plus owns (REMOVE_PACKAGES) plus the user-owned -// opt-in providers. The provider DEP is preserved, but a leftover -// override/resolution pin to another version would WIN over the direct dep and -// misalign the provider against the bundled vitest — so the stale forcing pin is -// dropped while the dependency itself stays installed. NOTE: catalog deletion -// uses REMOVE_PACKAGES (not this set) on purpose — a catalog entry is only a -// version *definition*, and deleting it could dangle a surviving `catalog:` -// reference (e.g. in peerDependencies) and break install. -const PROVIDER_OVERRIDE_DROP_NAMES = [...REMOVE_PACKAGES, ...OPT_IN_BROWSER_PROVIDERS] as const; - -// Extract the package name an override/resolution key *targets* — i.e. the -// package whose version would be forced. This mirrors the grammar of the real -// package-manager parsers (verified against `@yarnpkg/parsers` parseResolution): -// - bare (`pkg`, `@scope/pkg`) -// - versioned (`pkg@1`, `@scope/pkg@1`) -// - pnpm parent selectors (`parent>pkg`, chained `a@1>b>@scope/pkg`) -// - yarn `from/target` selectors (`parent/pkg`, `parent/@scope/pkg`, -// `parent@1/pkg`, glob `**/pkg`) -// For a yarn `from/target` selector the forced package is the TRAILING -// descriptor, not the parent: `@scope/pkg@4/child` targets `child`, and an -// npm-alias key like `@scope/pkg@npm:@other/fork@1` is parsed by yarn as -// `from=@scope/pkg@npm:@other`, `descriptor=fork@1` — so the target is `fork`, -// NOT `@scope/pkg`. Taking the trailing descriptor is exactly that. (Yarn -// *rejects* keys whose range embeds a slash, e.g. `pkg@patch:…/…` or git/URL -// ranges, so those never reach us as valid keys and need no special handling.) -// Scoped names keep their leading `@` and internal `/`. -function extractOverrideTargetName(key: string): string { - // pnpm parent selector `parent>child` (incl. chains `a>b>child`): the forced - // package is the deepest child. pnpm splits at a `>` whose preceding char is - // NOT space, `|`, or `@` — this is pnpm's own delimiter rule (DELIMITER_REGEX - // = /[^ |@]>/ in @pnpm/parse-overrides) — so a semver comparator range such as - // `pkg@>=4`, `pkg@>4`, or `>1 || >2` is NOT mistaken for a parent selector. - // Peel parent levels until none remain, keeping the trailing child. - let target = key.trim(); - for (let delim = target.search(/[^ |@]>/); delim !== -1; delim = target.search(/[^ |@]>/)) { - target = target.slice(delim + 2).trim(); - } - if (!target) { - return target; - } - // yarn `from/target` selector: drop leading parent/glob segments, keeping the - // trailing package descriptor (and a scoped name's own `/`). - if (target.includes('/')) { - const segments = target.split('/'); - const last = segments[segments.length - 1]; - const scope = segments[segments.length - 2]; - target = scope?.startsWith('@') ? `${scope}/${last}` : last; - } - // Strip a trailing version/range suffix. The version `@` follows the name - // (after the `/` for a scoped name); the leading scope `@` is never a version - // separator. - const nameStart = target.startsWith('@') ? target.indexOf('/') + 1 : 0; - const versionAt = target.indexOf('@', nameStart); - if (versionAt > 0) { - target = target.slice(0, versionAt); - } - return target; -} - -// True iff a pnpm.overrides key's target (after stripping selector and -// version suffixes) is a provider whose stale pin must be dropped (see -// PROVIDER_OVERRIDE_DROP_NAMES). Shared by the JSON-object and YAMLMap -// variants below. -function isRemovePackageOverrideKey(key: string): boolean { - return (PROVIDER_OVERRIDE_DROP_NAMES as readonly string[]).includes( - extractOverrideTargetName(key), - ); -} - -// Strip a trailing `@version`/range from a selector segment and keep its scope. -// Mirrors the version-suffix peeling in `extractOverrideTargetName`: the version -// `@` follows the name (after the `/` of a scoped name); the leading scope `@` -// is never a version separator. -function stripSegmentVersion(segment: string): string { - const nameStart = segment.startsWith('@') ? segment.indexOf('/') + 1 : 0; - const versionAt = segment.indexOf('@', nameStart); - return versionAt > 0 ? segment.slice(0, versionAt) : segment; -} - -// True iff a single parent-NAME glob segment matches the given literal package -// name. `*` matches any run of characters; all other glob/regex metacharacters -// are escaped. Used for the concrete ancestor segments of a selector. -function parentGlobMatchesName(glob: string, name: string): boolean { - const pattern = glob - .split('*') - .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&')) - .join('.*'); - return new RegExp(`^${pattern}$`).test(name); -} - -// True iff an ancestor segment (literal or glob) matches the given package name. -function ancestorSegmentMatches(segment: string, name: string): boolean { - return segment.includes('*') ? parentGlobMatchesName(segment, name) : segment === name; -} - -// Provider names that sit on vite-plus's OWN dependency path and can therefore -// appear as ANCESTORS of a pin that still constrains vite-plus's provider -// subtree: pnpm/yarn parent selectors are not root-anchored, so a chain like -// `@vitest/browser-preview>@vitest/browser` forces the provider's child -// everywhere that provider appears — including under vite-plus's own direct -// provider dep. Only the vite-plus-supplied `@vitest/browser*` members of -// REMOVE_PACKAGES qualify; the user-owned opt-in providers (webdriverio, -// playwright) are deliberately NOT included — vite-plus no longer ships them, so -// a `@vitest/browser-playwright>…` chain constrains the user's own provider -// subtree, not vite-plus's (see the ACCEPTED EDGE note below). -const OWNED_PROVIDER_ANCESTOR_NAMES = (REMOVE_PACKAGES as readonly string[]).filter((name) => - name.startsWith('@vitest/'), -); - -// True iff a selector's PARENT chain reaches vite-plus's OWN direct provider dep. -// The subtree migration protects is ` → vite-plus → @vitest/provider → …`; -// since vite-plus is a direct dependency of the project, a parent chain reaches -// that subtree iff it glob-matches a path along it: -// - `**` segments match zero-or-more ancestors, so they are ignored here; -// - the FIRST remaining concrete ancestor may glob-match `vite-plus` -// (`vite-plus`, `vite-*`, `*`); -// - every OTHER concrete ancestor must glob-match a vite-plus-owned provider -// (`@vitest/browser*`), because un-anchored selectors such as -// `@vitest/browser-playwright>@vitest/browser` still constrain the -// provider's children under vite-plus. -// Any chain carrying a SPECIFIC unrelated ancestor (`some-parent>vite-plus`, -// `some-parent/**`, `some-parent/vite-*`, `some-app>@vitest/browser-playwright`) -// constrains a different subtree and does NOT touch the root vite-plus provider, -// so it is preserved. A chain of only `**` (`**`, `**/**`) is global and matches. -function parentChainReachesVitePlus(segments: string[]): boolean { - const concrete = segments.filter((segment) => segment !== '**'); - let index = 0; - if (concrete.length > 0 && ancestorSegmentMatches(concrete[0], VITE_PLUS_NAME)) { - index = 1; - } - for (; index < concrete.length; index += 1) { - const segment = concrete[index]; - if (!OWNED_PROVIDER_ANCESTOR_NAMES.some((name) => ancestorSegmentMatches(segment, name))) { - return false; - } - } - return true; -} - -// Extract the ordered PARENT chain of an override/resolution key — the ancestor -// segments above the forced TARGET — or `null` when the key has no parent -// selector (a bare/versioned global pin). Each segment's own `@version`/range is -// stripped and scoped names (`@scope/name`) are kept whole; glob segments (`**`, -// `vite-*`) are preserved verbatim for `parentChainReachesVitePlus`. -// -// Mirrors `extractOverrideTargetName`'s grammar so target and parent stay -// consistent (see that function for the full delimiter rationale): -// - pnpm `a>b>child`: every `>`-separated prefix is a parent level (`a`, `b`); -// pnpm has no globs, so a chain of length > 1 always carries a specific -// ancestor. -// - yarn `from/descriptor`: the descriptor is the trailing 1 (unscoped) or 2 -// (scoped) segments; the remaining leading `/`-segments are the `from` chain, -// with scoped ancestors (`@scope/name`) rejoined. -// - bare/versioned names (`pkg`, `@scope/pkg`, `pkg@4`) have NO parent → `null`. -function extractOverrideParentSegments(key: string): string[] | null { - let rest = key.trim(); - // Peel every pnpm `>` parent level. pnpm splits at a `>` whose preceding char - // is NOT space, `|`, or `@` (its DELIMITER_REGEX), so semver comparators like - // `pkg@>=4` are not mistaken for a parent selector. - const pnpmParents: string[] = []; - for (let delim = rest.search(/[^ |@]>/); delim !== -1; delim = rest.search(/[^ |@]>/)) { - pnpmParents.push(stripSegmentVersion(rest.slice(0, delim + 1).trim())); - rest = rest.slice(delim + 2).trim(); - } - if (pnpmParents.length > 0) { - return pnpmParents; - } - // No pnpm parent — check for a yarn `from/descriptor` selector. `rest` is the - // child (target) descriptor; only a `/` beyond a single scoped name leaves a - // leading `from` (parent) chain. - if (!rest.includes('/')) { - return null; - } - const segments = rest.split('/'); - // The trailing descriptor occupies the last 2 segments when it is a scoped - // name (second-to-last segment starts with `@`), else the last 1. - const descriptorIsScoped = segments[segments.length - 2]?.startsWith('@') ?? false; - const descriptorSegmentCount = descriptorIsScoped ? 2 : 1; - const rawParents = segments.slice(0, segments.length - descriptorSegmentCount); - if (rawParents.length === 0) { - // The whole key was a bare scoped name (`@scope/pkg`) — no parent selector. - return null; - } - // Rejoin scoped ancestors (`@scope` + `name`) and strip each segment's version. - const parents: string[] = []; - for (let i = 0; i < rawParents.length; i += 1) { - const segment = rawParents[i]; - if (segment.startsWith('@') && i + 1 < rawParents.length) { - parents.push(stripSegmentVersion(`${segment}/${rawParents[i + 1]}`)); - i += 1; - } else { - parents.push(stripSegmentVersion(segment)); - } - } - return parents; -} - -// True iff a provider override/resolution key (target ∈ -// PROVIDER_OVERRIDE_DROP_NAMES) should be dropped because the pin would affect -// vite-plus's OWN direct provider dep. The pin reaches that dep iff its parent -// selector is: -// 1. ABSENT — bare/versioned global pin (`@vitest/browser-playwright`, -// `@vitest/browser-playwright@4`). -// 2. a chain that glob-matches a path along the vite-plus provider subtree: a -// pure glob (`**/...`, `*/...`), a name glob matching vite-plus -// (`vite-*/...`), the literal `vite-plus` (`vite-plus>...`, `vite-plus/...`), -// `**`-padded variants (`**/vite-plus/...`), or a chain whose remaining -// ancestors are vite-plus-owned providers — un-anchored selectors such as -// `@vitest/browser-preview>@vitest/browser` or nested npm -// `{ "@vitest/browser-preview": { "@vitest/browser": … } }` still force -// the provider's children under vite-plus. See -// `parentChainReachesVitePlus`. -// A selector carrying a SPECIFIC unrelated ancestor anywhere in its chain -// (`some-app>@vitest/...`, `some-parent/@vitest/...`, `a>vite-plus>@vitest/...`, -// `some-parent/**/@vitest/...`, `some-parent/vite-*/@vitest/...`) or a mere -// wildcard RANGE on a specific parent (`parent@*/...`) only constrains that -// parent's subtree and is preserved. The parent chain comes from the KEY STRING -// for flat pnpm/yarn selectors; for npm/bun NESTED objects it is accumulated from -// the enclosing keys by `dropRemovePackageOverrideKeys` and passed in via -// `ancestorChain`, so a nested `{ a: { vite-plus: { provider } } }` is treated -// exactly like the flat `a>vite-plus>provider` (both preserved). -// -// ACCEPTED EDGE: reachability is judged from `vite-plus` only. A pnpm selector -// whose parent is the project's OWN (root/workspace) package name — which keeps -// an opt-in provider as a direct dep after migration, e.g. -// `my-app>@vitest/browser-webdriverio` or `my-app>@vitest/browser-playwright` — -// is therefore preserved even though it could re-pin that direct dep. Likewise a -// chain parented by an opt-in provider itself (`@vitest/browser-playwright>…`) -// constrains the USER's provider subtree, not vite-plus's, so it is preserved -// (the opt-in providers are excluded from OWNED_PROVIDER_ANCESTOR_NAMES). -// Dropping these would require threading importer names through this pass; per -// PR #1588 this is left as a known, visible (the pin stays in the manifest) -// limitation rather than risk over-deleting genuinely unrelated transitive -// selectors (the behavior the posted P2 review asked us to keep). -function providerKeyReachesVitePlus(key: string, ancestorChain: string[]): boolean { - if (!isRemovePackageOverrideKey(key)) { - return false; - } - const keyParents = extractOverrideParentSegments(key) ?? []; - return parentChainReachesVitePlus([...ancestorChain, ...keyParents]); -} - -// Flat-selector entry point (no enclosing object nesting): used by the -// pnpm-workspace YAML sweep, where each key carries its whole parent chain. -function shouldDropProviderOverrideKey(key: string): boolean { - return providerKeyReachesVitePlus(key, []); -} - -// The ancestor segments a key contributes when the recursion descends into its -// object value: the key's own embedded selector parents followed by its target -// package name (version-stripped). For a plain npm/bun nested key (`a`) this is -// just `[a]`, so the accumulated chain mirrors a flat pnpm/yarn parent chain. -function childChainContribution(key: string): string[] { - const parents = extractOverrideParentSegments(key) ?? []; - return [...parents, extractOverrideTargetName(key)]; -} - -// Drop override keys whose target is a drop-listed provider AND whose pin would -// reach vite-plus's OWN direct provider dep — the edge ` → vite-plus → -// @vitest/provider`. Covers bare, versioned, global-glob and `vite-plus`-parent -// shapes that exact-key matching would miss. A pin scoped under a SPECIFIC -// non-vite-plus parent (pnpm `some-app>@vitest/...`, yarn `some-parent/@vitest/...`, -// or the npm/bun nested `{ "some-pkg": { "@vitest/...": "x" } }`) only constrains -// that parent's subtree and is PRESERVED. -// -// The decision is uniform across sinks: a provider pin is dropped iff its FULL -// ancestor chain reaches the root vite-plus edge (see `parentChainReachesVitePlus`). -// For flat pnpm/yarn selectors the whole chain lives in the KEY STRING; for npm/bun -// nested objects it is accumulated here from the enclosing object keys -// (`ancestorChain`) — so `{ "a": { "vite-plus": { provider } } }` is treated like -// the flat `a>vite-plus>provider` (both PRESERVED: vite-plus sits under `a`, not at -// the root). A long-form provider override (`{ "@vitest/browser-playwright": { ".": -// "x", "other": "y" } }`) has its own version pin (`.`) dropped while unrelated -// children (`other`) are kept. A parent we EMPTY by dropping its last pin is pruned -// so no meaningless `{}` is left; user-authored empties and untouched maps are kept. -// (pnpm/yarn override values are flat strings, so the recursion is inert for those -// sinks.) Returns whether any key/pin was removed. -function dropRemovePackageOverrideKeys( - overrides: Record | undefined, - ancestorChain: string[] = [], -): boolean { - if (!overrides) { - return false; - } - let removed = false; - for (const key of Object.keys(overrides)) { - const value = overrides[key]; - const child = - value !== null && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : undefined; - if (providerKeyReachesVitePlus(key, ancestorChain)) { - if (child) { - // Long-form provider override: drop the provider's own version pin (`.`) - // but keep any unrelated child overrides scoped under it; still descend - // (with the provider appended to the chain) for any deeper root pin. - let changed = false; - if ('.' in child) { - delete child['.']; - changed = true; - } - if ( - dropRemovePackageOverrideKeys(child, [...ancestorChain, ...childChainContribution(key)]) - ) { - changed = true; - } - if (Object.keys(child).length === 0) { - delete overrides[key]; - changed = true; - } - if (changed) { - removed = true; - } - } else { - delete overrides[key]; - removed = true; - } - continue; - } - if (child) { - // Not a root-vite-plus provider pin here: descend with the chain extended by - // this key so a deeper pin sees its full ancestor path; prune the parent only - // if the descent emptied it. - if ( - dropRemovePackageOverrideKeys(child, [...ancestorChain, ...childChainContribution(key)]) - ) { - removed = true; - if (Object.keys(child).length === 0) { - delete overrides[key]; - } - } - } - } - return removed; -} - -// When a browser provider package is removed, its runtime peer dependency -// must be preserved in devDependencies so browser tests continue to work. -const BROWSER_PROVIDER_PEER_DEPS: Record = { - '@vitest/browser-playwright': 'playwright', - '@vitest/browser-webdriverio': 'webdriverio', -}; - -// Transitive packages with postinstall scripts that vite-plus's deps drag in -// via `@vitest/browser-webdriverio` → `webdriverio` → `@wdio/utils`. pnpm v10 -// refuses to run these without explicit approval, so `vp migrate` records the -// allow/deny decision up front: deny by default (the user isn't using -// webdriverio), allow when the user actually depends on webdriverio. -const BROWSER_PROVIDER_POSTINSTALL_PACKAGES = ['edgedriver', 'geckodriver'] as const; - -// Webdriverio is the runtime peer that drags `edgedriver` / `geckodriver` in. -const WEBDRIVERIO_PEER_DEP = 'webdriverio'; - -// Dependencies whose presence before migration signals the user will end up -// with webdriverio after migration. `@vitest/browser-webdriverio` is the opt-in -// provider vite-plus keeps in the user's deps (pinned to the bundled vitest) -// and `webdriverio` is its runtime peer (added via `BROWSER_PROVIDER_PEER_DEPS`); -// either one means the edgedriver/geckodriver postinstalls must be allowed. -const WEBDRIVERIO_ALLOW_SIGNAL_DEPS = [WEBDRIVERIO_PEER_DEP, WEBDRIVERIO_PROVIDER] as const; - -// Browser-provider package names that, when present in the user's deps -// before migration, signal vitest browser mode even if no source file -// imports them. This covers config-only browser-mode setups (e.g. -// `test.browser.provider: 'playwright'` in `vite.config.ts`) where the -// provider package is declared in `devDependencies` but never `import`ed. -const VITEST_BROWSER_DEP_NAMES = [ - '@vitest/browser', - '@vitest/browser-preview', - '@vitest/browser-playwright', - '@vitest/browser-webdriverio', -] as const; - -const PUBLIC_PEER_DEPENDENCY_FALLBACKS: Record = { - vite: '*', - vitest: '*', -}; - -// The managed override/catalog packages vite-plus writes and the detector -// requires. `vite` is ALWAYS managed (aliased to vite-plus-core). `vitest` is -// managed ONLY when the project uses vitest DIRECTLY — vite-plus consumes -// upstream vitest itself, so a non-vitest project gets it transitively through -// vite-plus and must NOT carry a managed `vitest` pin (which would drift on a -// future `vp update vite-plus`). When `usesVitest` is false the common-case -// removal logic ACTIVELY strips any lingering `vitest` entry. -function managedOverridePackages(usesVitest: boolean): Record { - if (usesVitest) { - return VITE_PLUS_OVERRIDE_PACKAGES; - } - // Drop only `vitest`; every other managed key (e.g. `vite`, and in - // force-override/CI mode the `@voidzero-dev/vite-plus-core` file: alias) stays. - return Object.fromEntries( - Object.entries(VITE_PLUS_OVERRIDE_PACKAGES).filter(([key]) => key !== 'vitest'), - ); -} - -// True iff a dependency field lists a vitest ecosystem package — any name that -// contains `vitest` other than bare `vitest` itself (e.g. `@vitest/coverage-v8`, -// `@vitest/browser-playwright`, `vitest-browser-svelte`). A bare `vitest` -// dependency alone is deliberately NOT a signal — a prior migration may have -// injected it transitively-redundantly, so it must not keep the project pinned -// to a managed `vitest`. This mirrors the `isVitestAdjacent` signal used later -// when deciding to inject a direct `vitest`, so the two stay consistent. -function projectListsVitestEcosystemDep(pkg: { - dependencies?: Record; - devDependencies?: Record; - optionalDependencies?: Record; - peerDependencies?: Record; -}): boolean { - // Peer declarations do not install the package in this project; its consumer - // is responsible for satisfying that package's peers. - const dependencyGroups = [pkg.dependencies, pkg.devDependencies, pkg.optionalDependencies]; - return dependencyGroups.some((deps) => - deps - ? Object.keys(deps).some( - (name) => - name !== 'vitest' && - name.includes('vitest') && - // Excluded official packages either have no vitest peer or (for the - // ESLint plugin) only an optional `vitest: *` peer. Neither needs a - // direct install or workspace-wide override. - !VITEST_DIRECT_USAGE_EXCLUDED.has(name), - ) - : false, - ); -} - -// Detect installed dependencies whose package metadata declares a required -// Vitest peer. Package names are not authoritative: integrations such as -// `vite-plugin-gherkin` require Vitest without containing "vitest" in their -// own name. Optional peers do not require package-local provisioning. -function projectListsRequiredVitestPeer( - projectPath: string, - pkg: { - dependencies?: Record; - devDependencies?: Record; - optionalDependencies?: Record; - }, -): boolean { - const installGroups = [pkg.dependencies, pkg.devDependencies, pkg.optionalDependencies]; - const hasExistingVitest = installGroups.some( - (dependencies) => dependencies?.vitest !== undefined, - ); - const dependencyNames = new Set([ - ...Object.keys(pkg.dependencies ?? {}), - ...Object.keys(pkg.devDependencies ?? {}), - ...Object.keys(pkg.optionalDependencies ?? {}), - ]); - dependencyNames.delete('vitest'); - dependencyNames.delete('vite'); - dependencyNames.delete(VITE_PLUS_NAME); - for (const name of VITEST_DIRECT_USAGE_EXCLUDED) { - dependencyNames.delete(name); - } - let metadataUnavailable = false; - - for (const name of dependencyNames) { - const metadata = detectPackageMetadata(projectPath, name); - if (!metadata) { - metadataUnavailable = true; - continue; - } - try { - const installedPkg = readJsonFile(path.join(metadata.path, 'package.json')) as { - peerDependencies?: Record; - peerDependenciesMeta?: Record; - }; - if ( - typeof installedPkg.peerDependencies?.vitest === 'string' && - installedPkg.peerDependenciesMeta?.vitest?.optional !== true - ) { - return true; - } - } catch { - metadataUnavailable = true; - } - } - // A clean checkout may not have node_modules/.pnp metadata yet. If the user - // already carries a direct Vitest while any dependency's peer contract is - // unknown, preserve it rather than risk removing the provider for an - // arbitrary integration such as vite-plugin-gherkin. A later migration with - // complete metadata can safely remove a genuinely redundant pin. - return metadataUnavailable && hasExistingVitest; -} - -// True iff the project uses vitest DIRECTLY — via a dependency that is expected -// to have a required vitest peer (see `projectListsVitestEcosystemDep`), an -// upstream `vitest` module specifier, a package-level @nuxt/test-utils -// compatibility boundary, or vitest browser mode. Drives -// whether the migration keeps `vitest` managed or removes it entirely; the -// browser-mode arm keeps it aligned with the direct-`vitest` injection below so -// an injected `catalog:` spec never dangles against a vitest-less catalog. -function projectUsesVitestDirectly( - projectPath: string, - pkg: { - dependencies?: Record; - optionalDependencies?: Record; - devDependencies?: Record; - peerDependencies?: Record; - }, - requiredVitestPeer = projectListsRequiredVitestPeer(projectPath, pkg), - preserveNuxtVitestImports = true, - // Optional precomputed source-tree scan results. Callers that already computed - // these for the same `projectPath` at the same point (no source mutation in - // between) thread them here to avoid re-traversing the source tree. When - // omitted, the scans run lazily as before, preserving short-circuit behavior. - precomputedScans?: { browserMode: boolean; retainedModule: boolean }, -): boolean { - return ( - projectListsVitestEcosystemDep(pkg) || - requiredVitestPeer || - // Browser packages declared only as peers still become direct installs: - // rewritePackageJson/reconcileVitePlusBootstrapPackage promote opt-in - // providers into devDependencies and treat the bundled browser packages as - // browser-mode intent. Account for that promotion before shared - // catalog/override ownership is decided, otherwise the promoted provider's - // exact Vitest peer is left unsatisfied under strict pnpm/Yarn layouts. - VITEST_BROWSER_DEP_NAMES.some((name) => pkg.peerDependencies?.[name] !== undefined) || - (precomputedScans?.retainedModule ?? sourceTreeReferencesRetainedVitestModule(projectPath)) || - (preserveNuxtVitestImports && hasNuxtTestUtilsDependency(pkg)) || - (precomputedScans?.browserMode ?? usesVitestBrowserMode(projectPath)) - ); -} - -// Common case (`!usesVitest`): vite-plus consumes upstream vitest itself, so a -// lingering `vitest` entry — a managed pin, a stale `npm:@voidzero-dev/vite-plus-test@*` -// wrapper alias, or a `catalog:` reference — must be REMOVED from every sink so -// it arrives transitively through vite-plus and a future `vp update vite-plus` -// keeps it correct with no pin to drift. The `@vitest/*` family is left -// untouched (those are direct-usage signals handled elsewhere). -// -// The removal only applies when `vitest` is a key vite-plus actually manages in -// the active override config. In force-override / CI mode (`VP_OVERRIDE_PACKAGES` -// with file: tgz aliases) `vitest` is NOT in the override set, so a `vitest` -// entry there is the user's own and must be left untouched. -const VITEST_IS_MANAGED_OVERRIDE = 'vitest' in VITE_PLUS_OVERRIDE_PACKAGES; - -// Remove a managed `vitest` key from a flat string-valued record (dependency -// field, npm/bun overrides, yarn resolutions, pnpm.overrides, a catalog object). -// Only a STRING value is removed: a managed pin, `catalog:` reference, or wrapper -// alias is always a string, whereas a nested object value (npm/bun `overrides`) -// is a user override scoped under `vitest` and must be left intact. Returns true -// iff an entry was removed. -function removeManagedVitestEntry(record: Record | undefined): boolean { - if (VITEST_IS_MANAGED_OVERRIDE && typeof record?.vitest === 'string') { - delete record.vitest; - return true; - } - return false; -} - -// Remove a managed `vitest` scalar key from a YAMLMap (pnpm-workspace.yaml -// `overrides`, `catalog`, and each named `catalogs` entry). -function removeYamlMapVitestEntry(map: unknown): void { - if (!VITEST_IS_MANAGED_OVERRIDE || !(map instanceof YAMLMap)) { - return; - } - const target = map.items.find( - (item) => item.key instanceof Scalar && item.key.value === 'vitest', - )?.key; - if (target) { - map.delete(target); - } -} - -// Remove the managed `vitest` entry from pnpm peerDependencyRules (its -// `allowAny` array entry and `allowedVersions.vitest`), in place. Works on both -// the package.json `pnpm.peerDependencyRules` JSON shape and the same shape read -// back from pnpm-workspace.yaml. -function removeVitestPeerDependencyRule(peerDependencyRules: { - allowAny?: string[]; - allowedVersions?: Record; -}): void { - if (!VITEST_IS_MANAGED_OVERRIDE) { - return; - } - if (Array.isArray(peerDependencyRules.allowAny)) { - peerDependencyRules.allowAny = peerDependencyRules.allowAny.filter((key) => key !== 'vitest'); - } - if (peerDependencyRules.allowedVersions) { - delete peerDependencyRules.allowedVersions.vitest; - } -} - -// Plugins Oxlint resolves natively (no JS import). Source: -// `LintPluginOptionsSchema` in `node_modules/oxlint/dist/index.d.ts`. -// Anything else in the merged `lint.plugins[]` after migration is a -// reference left over from `@oxlint/migrate` that won't resolve at lint -// time. -const OXLINT_NATIVE_PLUGINS = new Set([ - 'eslint', - 'react', - 'unicorn', - 'typescript', - 'oxc', - 'import', - 'jsdoc', - 'jest', - 'vitest', - 'jsx-a11y', - 'nextjs', - 'react-perf', - 'promise', - 'node', - 'vue', -]); - -// Legacy wrapper package names that may appear as the target of override -// aliases left over from earlier vite-plus migrations. `@voidzero-dev/vite-plus-test` -// was deleted; any catalog/override entry still pointing at it is stale. -const LEGACY_WRAPPER_PACKAGE_NAMES = ['@voidzero-dev/vite-plus-test'] as const; - -// Fallback specs used when normalizing a stale wrapper alias. Real user -// ranges (e.g. `vitest: ^3.0.0`) are preserved — only the wrapper alias is -// rewritten. For `vitest`, we substitute the vitest version vite-plus -// bundles so any `catalog:` reference the user still has resolves cleanly. -const LEGACY_WRAPPER_FALLBACK_VERSIONS: Record = { - vitest: VITEST_VERSION, -}; - -function isLegacyWrapperSpec(value: unknown): boolean { - // A wrapper spec is always a flat string range; npm/bun `overrides` may hold - // nested object values, which can never themselves be a wrapper alias (the - // recursion in `pruneLegacyWrapperAliases` descends into those). - if (typeof value !== 'string' || !value) { - return false; - } - for (const name of LEGACY_WRAPPER_PACKAGE_NAMES) { - if (value === `npm:${name}` || value.startsWith(`npm:${name}@`)) { - return true; - } - } - return false; -} - -/** - * Rewrite or remove keys whose value points at a deleted vite-plus wrapper. - * When a fallback exists for the key (e.g. `vitest`), the value is replaced - * so existing `catalog:` references continue to resolve. Otherwise the key - * is dropped entirely. Returns true iff any entry was changed. - * - * npm/bun `overrides` may nest an object of scoped overrides under a parent - * key (e.g. `{ "some-parent": { "vitest": "npm:@voidzero-dev/vite-plus-test@latest" } }`), - * so object values are recursed into; a parent emptied by pruning is dropped so - * no `{}` is left behind. Flat maps (pnpm `overrides`, yarn `resolutions`, - * catalogs) hold only string values, where the recursion is inert. - */ -function pruneLegacyWrapperAliases(record: Record | undefined): boolean { - if (!record) { - return false; - } - let mutated = false; - for (const key of Object.keys(record)) { - const value = record[key]; - if (value !== null && typeof value === 'object' && !Array.isArray(value)) { - if (pruneLegacyWrapperAliases(value as Record)) { - mutated = true; - if (Object.keys(value as Record).length === 0) { - delete record[key]; - } - } - continue; - } - if (isLegacyWrapperSpec(value)) { - const fallback = LEGACY_WRAPPER_FALLBACK_VERSIONS[key]; - if (fallback !== undefined) { - record[key] = fallback; - } else { - delete record[key]; - } - mutated = true; - } - } - return mutated; -} - -type PackageJsonDependencyField = - | 'devDependencies' - | 'dependencies' - | 'peerDependencies' - | 'optionalDependencies'; - -type CatalogDependencyResolver = (( - catalogSpec: string, - dependencyName: string, -) => string | undefined) & { - preferredCatalogSpec: string; -}; - -function warnMigration(message: string, report?: MigrationReport) { - addMigrationWarning(report, message); - if (!report) { - prompts.log.warn(message); - } -} - -function infoMigration(message: string, report?: MigrationReport) { - addManualStep(report, message); - if (!report) { - prompts.log.info(message); - } -} - -export function checkViteVersion(projectPath: string): boolean { - return checkPackageVersion(projectPath, 'vite', '7.0.0'); -} - -export function checkVitestVersion(projectPath: string): boolean { - return checkPackageVersion(projectPath, 'vitest', '4.0.0'); -} - -/** - * Check the package version is supported by auto migration - * @param projectPath - The path to the project - * @param name - The name of the package - * @param minVersion - The minimum version of the package - * @returns true if the package version is supported by auto migration - */ -function checkPackageVersion(projectPath: string, name: string, minVersion: string): boolean { - const metadata = detectPackageMetadata(projectPath, name); - if (!metadata || metadata.name !== name) { - return true; - } - if (semver.satisfies(metadata.version, `<${minVersion}`)) { - const packageJsonFilePath = path.join(projectPath, 'package.json'); - prompts.log.error( - `✘ ${name}@${metadata.version} in ${displayRelative(packageJsonFilePath)} is not supported by auto migration`, - ); - prompts.log.info(`Please upgrade ${name} to version >=${minVersion} first`); - return false; - } - return true; -} - -export function detectEslintProject( - projectPath: string, - packages?: WorkspacePackage[], -): { - hasDependency: boolean; - configFile?: string; - legacyConfigFile?: string; -} { - const packageJsonPath = path.join(projectPath, 'package.json'); - if (!fs.existsSync(packageJsonPath)) { - return { hasDependency: false }; - } - const pkg = readJsonFile(packageJsonPath) as { - devDependencies?: Record; - dependencies?: Record; - }; - let hasDependency = !!(pkg.devDependencies?.eslint || pkg.dependencies?.eslint); - const configs = detectConfigs(projectPath); - let configFile = configs.eslintConfig; - const legacyConfigFile = configs.eslintLegacyConfig; - - // If root doesn't have eslint dependency, check workspace packages - if (!hasDependency && packages) { - for (const wp of packages) { - const pkgJsonPath = path.join(projectPath, wp.path, 'package.json'); - if (!fs.existsSync(pkgJsonPath)) { - continue; - } - const wpPkg = readJsonFile(pkgJsonPath) as { - devDependencies?: Record; - dependencies?: Record; - }; - if (wpPkg.devDependencies?.eslint || wpPkg.dependencies?.eslint) { - hasDependency = true; - break; - } - } - } - - return { hasDependency, configFile, legacyConfigFile }; -} - -/** - * Run a `vp dlx @oxlint/migrate` step with graceful error handling. - * Returns true on success, false on failure (spawn error or non-zero exit). - */ -async function runOxlintMigrateStep( - vpBin: string, - cwd: string, - migratePackage: string, - args: string[], - spinner: ReturnType, - failMessage: string, - manualHint: string, -): Promise { - try { - const result = await runCommandSilently({ - command: vpBin, - args: ['dlx', migratePackage, ...args], - cwd, - envs: process.env, - }); - if (result.exitCode !== 0) { - spinner.stop(failMessage); - const stderr = result.stderr.toString().trim(); - if (stderr) { - prompts.log.warn(`⚠ ${stderr}`); - } - prompts.log.info(manualHint); - return false; - } - return true; - } catch { - spinner.stop(failMessage); - prompts.log.info(manualHint); - return false; - } -} - -export async function migrateEslintToOxlint( - projectPath: string, - interactive: boolean, - eslintConfigFile?: string, - packages?: WorkspacePackage[], - options?: { silent?: boolean; report?: MigrationReport }, -): Promise { - const vpBin = process.env.VP_CLI_BIN ?? 'vp'; - const spinner = options?.silent - ? { - start: () => {}, - stop: () => {}, - pause: () => {}, - resume: () => {}, - cancel: () => {}, - error: () => {}, - clear: () => {}, - message: () => {}, - isCancelled: false, - } - : getSpinner(interactive); - - // Steps 1-2: Only run @oxlint/migrate if there's an eslint config at root - if (eslintConfigFile) { - // Pin @oxlint/migrate to the bundled oxlint version. - // @ts-expect-error — resolved at runtime from dist/ → dist/versions.js - const { versions } = await import('../versions.js'); - const migratePackage = `@oxlint/migrate@${versions.oxlint}`; - const migrateArgs = [ - '--merge', - ...(!hasBaseUrlInTsconfig(projectPath) ? ['--type-aware'] : []), - '--with-nursery', - '--details', - ]; - - // Step 1: Generate .oxlintrc.json from ESLint config - spinner.start('Migrating ESLint config to Oxlint...'); - const migrateOk = await runOxlintMigrateStep( - vpBin, - projectPath, - migratePackage, - migrateArgs, - spinner, - 'ESLint migration failed', - `You can run \`vp dlx ${migratePackage} ${migrateArgs.join(' ')}\` manually later`, - ); - if (!migrateOk) { - return false; - } - spinner.stop('ESLint config migrated to .oxlintrc.json'); - - // Step 2: Replace eslint-disable comments with oxlint-disable - spinner.start('Replacing ESLint comments with Oxlint equivalents...'); - const replaceOk = await runOxlintMigrateStep( - vpBin, - projectPath, - migratePackage, - ['--replace-eslint-comments'], - spinner, - 'ESLint comment replacement failed', - `You can run \`vp dlx ${migratePackage} --replace-eslint-comments\` manually later`, - ); - if (replaceOk) { - spinner.stop('ESLint comments replaced'); - } - // Continue with cleanup regardless — .oxlintrc.json was generated successfully - } - - if (options?.report) { - options.report.eslintMigrated = true; - } - - // Read the generated `.oxlintrc.json` to find any packages it references - // in `lint.jsPlugins`. Those packages need to stay in `package.json` so - // Oxlint can actually `import()` them at lint time — without this carve-out, - // the next step would strip them via `isEslintEcosystemDep` and we'd - // immediately invalidate the config we just generated. Local-path - // specifiers (`./X`, `../X`, `/X`) are skipped — they're paths, not - // package names, and have no `package.json` entry to preserve. - const preserveJsPlugins = collectJsPluginPackageNames(projectPath); - - // Step 3-5: Cleanup runs uniformly across the root and every workspace - // package — delete eslint config files, scrub ESLint-ecosystem deps from - // package.json, and rewrite eslint references in any local lint-staged - // config. A monorepo running `vp migrate` is treated as adopted as a - // whole; there's no per-package opt-out today. If a workspace package - // publishes a shared ESLint preset that you want to keep intact, exclude - // it from your `pnpm-workspace.yaml` / `workspaces` before running - // `vp migrate`, then add it back afterwards. - const cleanupTargets = [ - projectPath, - ...(packages ?? []).map((p) => path.join(projectPath, p.path)), - ]; - for (const target of cleanupTargets) { - if (!fs.existsSync(path.join(target, 'package.json'))) { - continue; - } - deleteEslintConfigFiles(target, options?.report, options?.silent); - rewriteEslintPackageJson(path.join(target, 'package.json'), preserveJsPlugins); - rewriteEslintLintStagedConfigFiles(target, options?.report); - } - - return true; -} - -/** - * Read `/.oxlintrc.json` (if any) and collect the package - * names referenced via `lint.jsPlugins[]` string entries. Object-form - * entries (`{ name, specifier }`) and local-path specifiers (`./X`, - * `../X`, `/X`) are excluded — neither maps to a `package.json` entry - * we'd accidentally strip. - */ -function collectJsPluginPackageNames(projectPath: string): Set { - const out = new Set(); - const oxlintConfigPath = path.join(projectPath, '.oxlintrc.json'); - if (!fs.existsSync(oxlintConfigPath)) { - return out; - } - let config: OxlintConfig; - try { - config = readJsonFile(oxlintConfigPath, true) as OxlintConfig; - } catch { - return out; - } - const collectFrom = (jsPlugins: OxlintConfig['jsPlugins']): void => { - for (const entry of jsPlugins ?? []) { - if (typeof entry !== 'string') { - continue; - } - if (entry.startsWith('./') || entry.startsWith('../') || entry.startsWith('/')) { - continue; - } - out.add(entry); - } - }; - collectFrom(config.jsPlugins); - if (Array.isArray(config.overrides)) { - for (const override of config.overrides) { - collectFrom(override.jsPlugins); - } - } - return out; -} - -function deleteEslintConfigFiles(basePath: string, report?: MigrationReport, silent = false): void { - const configs = detectConfigs(basePath); - for (const file of [configs.eslintConfig, configs.eslintLegacyConfig]) { - if (file) { - const configPath = path.join(basePath, file); - if (fs.existsSync(configPath)) { - fs.unlinkSync(configPath); - if (report) { - report.removedConfigCount++; - } - if (!silent) { - prompts.log.success(`✔ Removed ${displayRelative(configPath)}`); - } - } - } - } -} - -// Bare names of packages whose sole purpose is to support ESLint. Removed -// at root cleanup. Reusable AST libraries published under -// `@typescript-eslint/*` (`utils`, `typescript-estree`, `scope-manager`, -// `types`) are deliberately absent so codemods and doc generators that -// import them directly keep working after migration. -const ESLINT_ECOSYSTEM_NAMES = new Set([ - 'eslint', - 'typescript-eslint', - 'eslintrc', - 'eslint-utils', - 'eslint-visitor-keys', - 'eslint-scope', - 'eslint-define-config', - 'eslint-doc-generator', - // ESLint-only typescript-eslint entry points: - '@typescript-eslint/eslint-plugin', - '@typescript-eslint/parser', - '@typescript-eslint/rule-tester', - // Note: framework-ESLint integration modules (e.g. `@nuxt/eslint`) - // are NOT listed here. They short-circuit the entire ESLint - // migration via `INCOMPATIBLE_ESLINT_INTEGRATIONS`, so this list is - // never consulted for them. Keeping them out avoids duplicating the - // "what to do about Nuxt" decision in two places. -]); - -// Flat name prefixes that mark an ESLint-only package. -const ESLINT_ECOSYSTEM_PREFIXES = ['eslint-plugin-', 'eslint-config-', 'eslint-formatter-']; - -// Scopes whose every package is part of the ESLint ecosystem. -// @eslint/* — official ESLint scope (e.g. @eslint/js, @eslint/eslintrc) -// @eslint-community/* — community-maintained ESLint dependencies -// @angular-eslint/* — Angular's ESLint integration family -const ESLINT_ECOSYSTEM_SCOPES = ['@eslint/', '@eslint-community/', '@angular-eslint/']; - -/** - * Decide whether a dependency entry should be removed alongside `eslint` - * itself. The set is intentionally broad: anything whose only purpose is - * to extend, configure, format, or wire ESLint becomes dead weight after - * migration. `@types/` packages are checked symmetrically with `` - * so type-only counterparts of removed runtime packages also go. - */ -function isEslintEcosystemDep(name: string): boolean { - const stripped = name.startsWith('@types/') ? name.slice('@types/'.length) : name; - if (ESLINT_ECOSYSTEM_NAMES.has(stripped)) { - return true; - } - if (ESLINT_ECOSYSTEM_PREFIXES.some((p) => stripped.startsWith(p))) { - return true; - } - if (ESLINT_ECOSYSTEM_SCOPES.some((s) => stripped.startsWith(s))) { - return true; - } - // Scoped plugins/configs/formatters, e.g.: - // @vue/eslint-config-typescript - // @stylistic/eslint-plugin-ts - // @vitest/eslint-plugin - if (/^@[^/]+\/eslint-(plugin|config|formatter)(-.+)?$/.test(stripped)) { - return true; - } - return false; -} - -/** - * Rewrite a project's `package.json` after ESLint has been migrated to - * Oxlint: drop every ESLint-ecosystem dependency (see - * `isEslintEcosystemDep`), strip empty containers, and rewrite eslint - * tokens in scripts / lint-staged. Applied uniformly to the root and to - * every workspace package — the migration treats the whole workspace as - * in scope for adoption, so a half-cleanup at the workspace level would - * be inconsistent with the rest of the flow (which already replaces - * vite-related overrides and adds vite-plus across all packages). - * - * `preserveJsPlugins` names packages that `@oxlint/migrate` referenced - * via `lint.jsPlugins` and that Oxlint will need to `import()` at lint - * time. They override `isEslintEcosystemDep` so the generated config - * isn't immediately invalidated by the cleanup step. - */ -export function rewriteEslintPackageJson( - packageJsonPath: string, - preserveJsPlugins: ReadonlySet = new Set(), -): void { - editJsonFile<{ - devDependencies?: Record; - dependencies?: Record; - peerDependencies?: Record; - optionalDependencies?: Record; - scripts?: Record; - 'lint-staged'?: Record; - }>(packageJsonPath, (pkg) => { - let changed = false; - for (const field of [ - 'devDependencies', - 'dependencies', - 'peerDependencies', - 'optionalDependencies', - ] as const) { - const deps = pkg[field]; - if (!deps) { - continue; - } - let removedAny = false; - for (const name of Object.keys(deps)) { - if (preserveJsPlugins.has(name)) { - continue; - } - if (isEslintEcosystemDep(name)) { - delete deps[name]; - changed = true; - removedAny = true; - } - } - // Drop the field entirely if our cleanup emptied it — avoid - // leaving `"devDependencies": {}` noise in the output. - if (removedAny && Object.keys(deps).length === 0) { - delete pkg[field]; - } - } - if (pkg.scripts) { - const updated = rewriteEslint(JSON.stringify(pkg.scripts)); - if (updated) { - pkg.scripts = JSON.parse(updated); - changed = true; - } - } - if (pkg['lint-staged']) { - const updated = rewriteEslint(JSON.stringify(pkg['lint-staged'])); - if (updated) { - pkg['lint-staged'] = JSON.parse(updated); - changed = true; - } - } - return changed ? pkg : undefined; - }); -} - -/** - * Rewrite tool references in lint-staged config files (JSON ones are rewritten, - * non-JSON ones get a warning). - */ -function rewriteToolLintStagedConfigFiles( - projectPath: string, - rewriteFn: (json: string) => string | null, - toolName: string, - report?: MigrationReport, -): void { - for (const filename of LINT_STAGED_JSON_CONFIG_FILES) { - const configPath = path.join(projectPath, filename); - if (!fs.existsSync(configPath)) { - continue; - } - if (filename === '.lintstagedrc' && !isJsonFile(configPath)) { - warnMigration( - `${displayRelative(configPath)} is not JSON — please update ${toolName} references manually`, - report, - ); - continue; - } - editJsonFile>(configPath, (config) => { - const updated = rewriteFn(JSON.stringify(config)); - if (updated) { - return JSON.parse(updated); - } - return undefined; - }); - } - for (const filename of LINT_STAGED_OTHER_CONFIG_FILES) { - const configPath = path.join(projectPath, filename); - if (!fs.existsSync(configPath)) { - continue; - } - warnMigration( - `${displayRelative(configPath)} — please update ${toolName} references manually`, - report, - ); - } -} - -function rewriteEslintLintStagedConfigFiles(projectPath: string, report?: MigrationReport): void { - rewriteToolLintStagedConfigFiles(projectPath, rewriteEslint, 'eslint', report); -} - -export function detectPrettierProject( - projectPath: string, - packages?: WorkspacePackage[], -): { - hasDependency: boolean; - configFile?: string; -} { - const packageJsonPath = path.join(projectPath, 'package.json'); - if (!fs.existsSync(packageJsonPath)) { - return { hasDependency: false }; - } - const pkg = readJsonFile(packageJsonPath) as { - devDependencies?: Record; - dependencies?: Record; - }; - let hasDependency = !!(pkg.devDependencies?.prettier || pkg.dependencies?.prettier); - const configs = detectConfigs(projectPath); - const configFile = configs.prettierConfig; - - // If root doesn't have prettier dependency, check workspace packages - if (!hasDependency && packages) { - for (const wp of packages) { - const pkgJsonPath = path.join(projectPath, wp.path, 'package.json'); - if (!fs.existsSync(pkgJsonPath)) { - continue; - } - const wpPkg = readJsonFile(pkgJsonPath) as { - devDependencies?: Record; - dependencies?: Record; - }; - if (wpPkg.devDependencies?.prettier || wpPkg.dependencies?.prettier) { - hasDependency = true; - break; - } - } - } - - return { hasDependency, configFile }; -} - -/** - * Run `vp fmt --migrate=prettier` step with graceful error handling. - * Returns true on success, false on failure. - */ -async function runPrettierMigrateStep( - vpBin: string, - cwd: string, - spinner: ReturnType, - failMessage: string, - manualHint: string, -): Promise { - try { - const result = await runCommandSilently({ - command: vpBin, - args: ['fmt', '--migrate=prettier'], - cwd, - envs: process.env, - }); - if (result.exitCode !== 0) { - spinner.stop(failMessage); - const stderr = result.stderr.toString().trim(); - if (stderr) { - prompts.log.warn(`⚠ ${stderr}`); - } - prompts.log.info(manualHint); - return false; - } - return true; - } catch { - spinner.stop(failMessage); - prompts.log.info(manualHint); - return false; - } -} - -export async function migratePrettierToOxfmt( - projectPath: string, - interactive: boolean, - prettierConfigFile?: string, - packages?: WorkspacePackage[], - options?: { silent?: boolean; report?: MigrationReport }, -): Promise { - const vpBin = process.env.VP_CLI_BIN ?? 'vp'; - const spinner = options?.silent - ? { - start: () => {}, - stop: () => {}, - pause: () => {}, - resume: () => {}, - cancel: () => {}, - error: () => {}, - clear: () => {}, - message: () => {}, - isCancelled: false, - } - : getSpinner(interactive); - - // Step 1: Generate .oxfmtrc.json from Prettier config - if (prettierConfigFile) { - let tempPrettierConfig: string | undefined; - - // If config is in package.json, extract it to a temporary .prettierrc.json - // so that `vp fmt --migrate=prettier` can read it - if (prettierConfigFile === PRETTIER_PACKAGE_JSON_CONFIG) { - const packageJsonPath = path.join(projectPath, 'package.json'); - const pkg = readJsonFile(packageJsonPath) as { prettier?: unknown }; - if (pkg.prettier) { - tempPrettierConfig = path.join(projectPath, '.prettierrc.json'); - fs.writeFileSync(tempPrettierConfig, JSON.stringify(pkg.prettier, null, 2)); - } else { - // Config disappeared between detection and migration — nothing to migrate - return true; - } - } - - try { - spinner.start('Migrating Prettier config to Oxfmt...'); - const migrateOk = await runPrettierMigrateStep( - vpBin, - projectPath, - spinner, - 'Prettier migration failed', - 'You can run `vp fmt --migrate=prettier` manually later', - ); - if (!migrateOk) { - return false; - } - spinner.stop('Prettier config migrated to .oxfmtrc.json'); - } finally { - if (tempPrettierConfig) { - try { - fs.unlinkSync(tempPrettierConfig); - } catch {} - } - } - } - - if (options?.report) { - options.report.prettierMigrated = true; - } - - // Step 2: Delete all prettier config files at root - deletePrettierConfigFiles(projectPath, options?.report, options?.silent); - - // Step 3: Remove prettier dependency and rewrite prettier scripts (root) - rewritePrettierPackageJson(path.join(projectPath, 'package.json')); - - // Step 3b: Rewrite prettier scripts in workspace packages - if (packages) { - for (const pkg of packages) { - rewritePrettierPackageJson(path.join(projectPath, pkg.path, 'package.json')); - } - } - - // Step 4: Rewrite prettier references in lint-staged config files - rewritePrettierLintStagedConfigFiles(projectPath, options?.report); - - // Step 5: Warn about .prettierignore if it exists - const prettierIgnorePath = path.join(projectPath, '.prettierignore'); - if (fs.existsSync(prettierIgnorePath)) { - warnMigration( - `${displayRelative(prettierIgnorePath)} found — Oxfmt supports .prettierignore, but using the \`ignorePatterns\` option is recommended.`, - options?.report, - ); - } - - return true; -} - -function deletePrettierConfigFiles( - basePath: string, - report?: MigrationReport, - silent = false, -): void { - // Delete detected prettier config file (like deleteEslintConfigFiles uses detectConfigs) - const configs = detectConfigs(basePath); - if (configs.prettierConfig && configs.prettierConfig !== PRETTIER_PACKAGE_JSON_CONFIG) { - const configPath = path.join(basePath, configs.prettierConfig); - if (fs.existsSync(configPath)) { - fs.unlinkSync(configPath); - if (report) { - report.removedConfigCount++; - } - if (!silent) { - prompts.log.success(`✔ Removed ${displayRelative(configPath)}`); - } - } - } - // Also clean up any stale prettier config files that detectConfigs didn't pick - // (prettier only uses one config, but users may have leftover files) - for (const file of PRETTIER_CONFIG_FILES) { - if (file === configs.prettierConfig) { - continue; // already handled above - } - const configPath = path.join(basePath, file); - if (fs.existsSync(configPath)) { - fs.unlinkSync(configPath); - if (report) { - report.removedConfigCount++; - } - if (!silent) { - prompts.log.success(`✔ Removed ${displayRelative(configPath)}`); - } - } - } - // Remove "prettier" key from package.json if present - editJsonFile<{ prettier?: unknown }>(path.join(basePath, 'package.json'), (pkg) => { - if (pkg.prettier) { - delete pkg.prettier; - return pkg; - } - return undefined; - }); -} - -function rewritePrettierPackageJson(packageJsonPath: string): void { - if (!fs.existsSync(packageJsonPath)) { - return; - } - editJsonFile<{ - devDependencies?: Record; - dependencies?: Record; - scripts?: Record; - 'lint-staged'?: Record; - }>(packageJsonPath, (pkg) => { - let changed = false; - // Remove prettier and prettier-plugin-* dependencies - if (pkg.devDependencies) { - for (const dep of Object.keys(pkg.devDependencies)) { - if (dep === 'prettier' || dep.startsWith('prettier-plugin-')) { - delete pkg.devDependencies[dep]; - changed = true; - } - } - } - if (pkg.dependencies) { - for (const dep of Object.keys(pkg.dependencies)) { - if (dep === 'prettier' || dep.startsWith('prettier-plugin-')) { - delete pkg.dependencies[dep]; - changed = true; - } - } - } - if (pkg.scripts) { - const updated = rewritePrettier(JSON.stringify(pkg.scripts)); - if (updated) { - pkg.scripts = JSON.parse(updated); - changed = true; - } - } - if (pkg['lint-staged']) { - const updated = rewritePrettier(JSON.stringify(pkg['lint-staged'])); - if (updated) { - pkg['lint-staged'] = JSON.parse(updated); - changed = true; - } - } - return changed ? pkg : undefined; - }); -} - -function rewritePrettierLintStagedConfigFiles(projectPath: string, report?: MigrationReport): void { - rewriteToolLintStagedConfigFiles(projectPath, rewritePrettier, 'prettier', report); -} - -function cleanupDeprecatedTsconfigOptions( - projectPath: string, - silent = false, - report?: MigrationReport, -): void { - const deprecatedOptions = ['esModuleInterop', 'allowSyntheticDefaultImports']; - const files = findTsconfigFiles(projectPath); - for (const filePath of files) { - for (const name of deprecatedOptions) { - if (removeDeprecatedTsconfigFalseOption(filePath, name)) { - if (report) { - report.removedConfigCount++; - } - if (!silent) { - prompts.log.success(`✔ Removed ${name}: false from ${displayRelative(filePath)}`); - } - warnMigration( - `Removed \`"${name}": false\` from ${displayRelative(filePath)} — this option has been deprecated. See https://github.com/oxc-project/tsgolint/issues/351, https://github.com/microsoft/TypeScript/issues/62529`, - report, - ); - } - } - } -} - -function rewriteTsconfigTypes( - projectPath: string, - silent = false, - report?: MigrationReport, -): boolean { - let changed = false; - const files = findTsconfigFiles(projectPath); - for (const filePath of files) { - if (rewriteTypesInTsconfig(filePath)) { - changed = true; - if (report) { - report.removedConfigCount++; - } - if (!silent) { - prompts.log.success(`✔ Rewrote types in ${displayRelative(filePath)}`); - } - } - } - return changed; -} - -function hasTsconfigTypesToRewrite(projectPath: string): boolean { - return findTsconfigFiles(projectPath).some((filePath) => hasTypesToRewriteInTsconfig(filePath)); -} - -// .svelte files are handled by @sveltejs/vite-plugin-svelte (transpilation) -// and svelte-check / Svelte Language Server (type checking). -// Module resolution for `.svelte` imports is typically set up by the -// project template (e.g. src/vite-env.d.ts in Vite svelte-ts, or -// auto-generated tsconfig in SvelteKit) rather than this file. -// https://svelte.dev/docs/svelte/typescript -export type Framework = 'vue' | 'astro'; - -const FRAMEWORK_SHIMS: Record = { - // https://vuejs.org/guide/typescript/overview#volar-takeover-mode - vue: [ - "declare module '*.vue' {", - " import type { DefineComponent } from 'vue';", - ' const component: DefineComponent<{}, {}, unknown>;', - ' export default component;', - '}', - ].join('\n'), - // astro/client is the pre-v4.14 form; v4.14+ prefers `/// ` - // but .astro/types.d.ts is generated at build time and may not exist yet after migration. - // astro/client remains valid and is still used in official Astro integrations. - // https://docs.astro.build/en/guides/typescript/#extending-global-types - astro: '/// ', -}; - -export function detectFramework(projectPath: string): Framework[] { - const packageJsonPath = path.join(projectPath, 'package.json'); - if (!fs.existsSync(packageJsonPath)) { - return []; - } - const pkg = readJsonFile(packageJsonPath) as { - dependencies?: Record; - devDependencies?: Record; - }; - const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }; - return (['vue', 'astro'] as const).filter((framework) => !!allDeps[framework]); -} - -function getEnvDtsPath(projectPath: string): string { - const srcEnvDts = path.join(projectPath, 'src', 'env.d.ts'); - const rootEnvDts = path.join(projectPath, 'env.d.ts'); - for (const candidate of [srcEnvDts, rootEnvDts]) { - if (fs.existsSync(candidate)) { - return candidate; - } - } - return fs.existsSync(path.join(projectPath, 'src')) ? srcEnvDts : rootEnvDts; -} - -export function hasFrameworkShim(projectPath: string, framework: Framework): boolean { - const dirsToScan = [projectPath, path.join(projectPath, 'src')]; - for (const dir of dirsToScan) { - if (!fs.existsSync(dir)) { - continue; - } - let entries: string[]; - try { - entries = fs.readdirSync(dir); - } catch { - continue; - } - for (const entry of entries) { - if (!entry.endsWith('.d.ts')) { - continue; - } - const content = fs.readFileSync(path.join(dir, entry), 'utf-8'); - if (framework === 'astro') { - if (content.includes('astro/client')) { - return true; - } - } else if (content.includes(`'*.${framework}'`) || content.includes(`"*.${framework}"`)) { - return true; - } - } - } - return false; -} - -export function addFrameworkShim( - projectPath: string, - framework: Framework, - report?: MigrationReport, -): void { - const envDtsPath = getEnvDtsPath(projectPath); - const shim = FRAMEWORK_SHIMS[framework]; - if (fs.existsSync(envDtsPath)) { - const existing = fs.readFileSync(envDtsPath, 'utf-8'); - fs.writeFileSync(envDtsPath, `${existing.trimEnd()}\n\n${shim}\n`, 'utf-8'); - } else { - fs.mkdirSync(path.dirname(envDtsPath), { recursive: true }); - fs.writeFileSync(envDtsPath, `${shim}\n`, 'utf-8'); - } - if (report) { - report.frameworkShimAdded = true; - } -} - -const PNPM_WORKSPACE_SETTINGS_MIN_VERSION = '10.6.2'; - -type PnpmPeerDependencyRules = { - allowAny?: string[]; - allowedVersions?: Record; - [key: string]: unknown; -}; - -type PnpmPackageJsonSettings = { - overrides?: Record; - peerDependencyRules?: PnpmPeerDependencyRules; - allowBuilds?: Record; - onlyBuiltDependencies?: string[]; - [key: string]: unknown; -}; - -// pnpm 10.5 started reading package.json#pnpm settings from -// pnpm-workspace.yaml, but overrides and peerDependencyRules needed fixes in -// 10.5.1 and 10.6.2 respectively. Use the latter as the atomic migration -// boundary so the complete object can move without splitting its ownership. -export function pnpmSupportsWorkspaceSettings(version: string): boolean { - const coerced = semver.coerce(version); - if (coerced) { - return semver.gte(coerced, PNPM_WORKSPACE_SETTINGS_MIN_VERSION); - } - return version === 'latest' || version === 'next'; -} - -// These are the root package.json#pnpm settings pnpm 10.6.2+ accepts at the -// top level of pnpm-workspace.yaml. Unknown keys may belong to third-party -// tooling and stay in package.json. -const PNPM_WORKSPACE_SETTING_KEYS = [ - 'allowNonAppliedPatches', - 'allowBuilds', - 'allowUnusedPatches', - 'allowedDeprecatedVersions', - 'auditConfig', - 'configDependencies', - 'executionEnv', - 'ignorePatchFailures', - 'ignoredBuiltDependencies', - 'ignoredOptionalDependencies', - 'neverBuiltDependencies', - 'onlyBuiltDependencies', - 'onlyBuiltDependenciesFile', - 'overrides', - 'packageExtensions', - 'patchedDependencies', - 'peerDependencyRules', - 'requiredScripts', - 'supportedArchitectures', - 'updateConfig', -] as const; - -function hasPnpmWorkspaceSettings(pkg: { pnpm?: PnpmPackageJsonSettings }): boolean { - return PNPM_WORKSPACE_SETTING_KEYS.some((key) => Object.hasOwn(pkg.pnpm ?? {}, key)); -} - -function pnpmPackageJsonSettingsPending(pkg: { pnpm?: PnpmPackageJsonSettings }): boolean { - return ( - hasPnpmWorkspaceSettings(pkg) || (pkg.pnpm !== undefined && Object.keys(pkg.pnpm).length === 0) - ); -} - -function takePnpmWorkspaceSettings(pkg: { - pnpm?: PnpmPackageJsonSettings; -}): Record | undefined { - if (!pkg.pnpm) { - return undefined; - } - const settings: Record = {}; - for (const key of PNPM_WORKSPACE_SETTING_KEYS) { - if (!Object.hasOwn(pkg.pnpm, key)) { - continue; - } - settings[key] = pkg.pnpm[key]; - delete pkg.pnpm[key]; - } - if (Object.keys(pkg.pnpm).length === 0) { - delete pkg.pnpm; - } - return Object.keys(settings).length > 0 ? settings : undefined; -} - -function isPlainRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - -/** - * Preserve workspace-level siblings while moving the effective package.json - * pnpm settings into pnpm-workspace.yaml. Package values win at scalar leaves, - * while objects merge recursively and arrays retain unique entries from both - * locations. - */ -function mergePnpmWorkspaceSetting(existing: unknown, incoming: unknown): unknown { - if (Array.isArray(existing) && Array.isArray(incoming)) { - const seen = new Set(); - return [...existing, ...incoming].filter((value) => { - const key = JSON.stringify(value); - if (seen.has(key)) { - return false; - } - seen.add(key); - return true; - }); - } - if (isPlainRecord(existing) && isPlainRecord(incoming)) { - const merged: Record = { ...existing }; - for (const [key, value] of Object.entries(incoming)) { - merged[key] = Object.hasOwn(existing, key) - ? mergePnpmWorkspaceSetting(existing[key], value) - : value; - } - return merged; - } - return incoming; -} - -function migratePnpmSettingsToWorkspaceYaml( - projectPath: string, - settings: Record | undefined, -): void { - if (!settings || Object.keys(settings).length === 0) { - return; - } - const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); - if (!fs.existsSync(pnpmWorkspaceYamlPath)) { - fs.writeFileSync(pnpmWorkspaceYamlPath, ''); - } - editYamlFile(pnpmWorkspaceYamlPath, (doc) => { - const workspace = (doc.toJS() ?? {}) as Record; - for (const [key, value] of Object.entries(settings)) { - // package.json#pnpm was the effective source before migration. Preserve - // that precedence at conflicting leaves while retaining workspace-only - // object properties and array entries. - doc.set(key, doc.createNode(mergePnpmWorkspaceSetting(workspace[key], value))); - } - }); -} - -export function rewriteStandaloneProject( - projectPath: string, - workspaceInfo: WorkspaceInfo, - skipStagedMigration?: boolean, - silent = false, - report?: MigrationReport, -): void { - const packageJsonPath = path.join(projectPath, 'package.json'); - if (!fs.existsSync(packageJsonPath)) { - return; - } - - const packageManager = workspaceInfo.packageManager; - const catalogDependencyResolver = createCatalogDependencyResolver(projectPath, packageManager); - const vitestEcosystemPackages = collectVitestEcosystemInstallDependencyNames(projectPath); - // Source-tree scan signals are computed once here and reused below (and inside - // projectUsesVitestDirectly / collectInjectedProviderNames) so the source tree - // is traversed once each instead of repeatedly. They do not depend on - // package.json contents and no scanned source files are mutated before they - // are consumed, so the values match the previous lazy per-call scans exactly. - const providerSourceModes = collectProviderSourceModes(projectPath); - const browserMode = usesVitestBrowserMode(projectPath); - const retainedVitestModule = sourceTreeReferencesRetainedVitestModule(projectPath); - const providerCatalogAdditions = collectInjectedProviderNames( - projectPath, - undefined, - new Map([[projectPath, providerSourceModes]]), - ); - const pnpmMajorVersion = pnpmMajor(workspaceInfo.downloadPackageManager.version); - let extractedStagedConfig: Record | null = null; - let movedPnpmSettings: Record | undefined; - let shouldRewritePnpmWorkspaceYaml = false; - let shouldAddPnpmWorkspaceVitePlusOverride = false; - let shouldAllowBrowserProviderBuilds = false; - // Whether the project uses vitest directly (a required-peer consumer, an - // upstream module reference, or browser mode). Computed inside the callback and - // hoisted so the post-callback pnpm-workspace.yaml writer sees it too. - let usesVitest = false; - // Determined inside editJsonFile callback to avoid a redundant file read - let usePnpmWorkspaceYaml = false; - editJsonFile<{ - overrides?: Record; - resolutions?: Record; - devDependencies?: Record; - dependencies?: Record; - peerDependencies?: Record; - optionalDependencies?: Record; - scripts?: Record; - pnpm?: PnpmPackageJsonSettings; - }>(packageJsonPath, (pkg) => { - shouldAllowBrowserProviderBuilds = - hasOwnWebdriverioDependency(pkg) || usesWebdriverioProvider(projectPath); - const requiredVitestPeer = projectListsRequiredVitestPeer(projectPath, pkg); - usesVitest = projectUsesVitestDirectly(projectPath, pkg, requiredVitestPeer, true, { - browserMode, - retainedModule: retainedVitestModule, - }); - const managed = managedOverridePackages(usesVitest); - // Strip stale `vite-plus-test` wrapper aliases before injecting new overrides - // so the deleted wrapper doesn't survive migration in any sink. - pruneLegacyWrapperAliases(pkg.resolutions); - pruneLegacyWrapperAliases(pkg.overrides); - pruneLegacyWrapperAliases(pkg.pnpm?.overrides); - // Drop stale provider overrides/resolutions (REMOVE_PACKAGES + the now - // user-owned opt-in providers, webdriverio/playwright) from the npm/bun - // `overrides` and yarn `resolutions` sinks before re-merging managed - // overrides. A leftover pin would conflict with the migrated direct - // `@vitest/browser-webdriverio` / `@vitest/browser-playwright` dep — npm - // hard-fails with EOVERRIDE, and yarn/bun would force the stale version over - // the bundled-vitest-aligned 4.1.9. (The pnpm sinks are pruned below.) - dropRemovePackageOverrideKeys(pkg.resolutions); - dropRemovePackageOverrideKeys(pkg.overrides); - // Common case (no direct vitest): strip a lingering managed `vitest` from - // the npm/bun `overrides` and yarn `resolutions` sinks so it isn't re-pinned. - if (!usesVitest) { - removeManagedVitestEntry(pkg.resolutions); - removeManagedVitestEntry(pkg.overrides); - } - if (packageManager === PackageManager.yarn) { - pkg.resolutions = { - ...pkg.resolutions, - ...managed, - }; - } else if (packageManager === PackageManager.npm || packageManager === PackageManager.bun) { - pkg.overrides = { - ...pkg.overrides, - ...managed, - }; - if (packageManager === PackageManager.bun) { - // Bun walks transitive peer-deps before resolving overrides; vitest - // 4.1.9 declares peer `vite ^6 || ^7 || ^8` and aborts with - // "vite@... failed to resolve" if `vite` isn't a direct dep somewhere - // in the tree, even when the override would redirect it. Mirror the - // override as a devDep so bun's resolver sees `vite` immediately; - // the override above still points it at vite-plus-core. - // See https://github.com/oven-sh/bun/issues/8406. - pkg.devDependencies = { - ...pkg.devDependencies, - vite: VITE_PLUS_OVERRIDE_PACKAGES.vite, - }; - } - } else if (packageManager === PackageManager.pnpm) { - usePnpmWorkspaceYaml = pnpmSupportsWorkspaceSettings( - workspaceInfo.downloadPackageManager.version, - ); - if (usePnpmWorkspaceYaml) { - shouldRewritePnpmWorkspaceYaml = true; - shouldAddPnpmWorkspaceVitePlusOverride = isForceOverrideMode(); - } - const overrideKeys = Object.keys(managed); - if (!usePnpmWorkspaceYaml) { - // Strip selector-shaped overrides (e.g. `parent>@vitest/browser-playwright`) - // whose target is a removed package, before re-merging the user's - // overrides into the new pnpm config. - dropRemovePackageOverrideKeys(pkg.pnpm?.overrides); - // Common case: drop a lingering managed `vitest` override + its peer - // rules before re-merging. - if (!usesVitest) { - removeManagedVitestEntry(pkg.pnpm?.overrides); - if (pkg.pnpm?.peerDependencyRules) { - removeVitestPeerDependencyRule(pkg.pnpm.peerDependencyRules); - } - } - // Project already has pnpm config in package.json -- keep using it. - pkg.pnpm = { - ...pkg.pnpm, - overrides: { - ...pkg.pnpm?.overrides, - ...managed, - ...(isForceOverrideMode() ? { [VITE_PLUS_NAME]: VITE_PLUS_VERSION } : {}), - }, - peerDependencyRules: { - ...pkg.pnpm?.peerDependencyRules, - allowAny: [ - ...new Set([...(pkg.pnpm?.peerDependencyRules?.allowAny ?? []), ...overrideKeys]), - ], - allowedVersions: { - ...pkg.pnpm?.peerDependencyRules?.allowedVersions, - ...Object.fromEntries(overrideKeys.map((key) => [key, '*'])), - }, - }, - }; - } else { - movedPnpmSettings = takePnpmWorkspaceSettings(pkg); - } - // remove dependency selectors targeting vite (e.g. "vite-plugin-svgr>vite") - for (const key in pkg.pnpm?.overrides) { - if (key.includes('>')) { - const splits = key.split('>'); - if (splits[splits.length - 1].trim() === 'vite') { - delete pkg.pnpm.overrides[key]; - } - } - } - // remove packages from `resolutions` field if they exist - // https://pnpm.io/9.x/package_json#resolutions - for (const key of [...overrideKeys, ...PROVIDER_OVERRIDE_DROP_NAMES]) { - if (pkg.resolutions?.[key]) { - delete pkg.resolutions[key]; - } - } - if (!usePnpmWorkspaceYaml && pnpmMajorVersion !== undefined && pkg.pnpm) { - applyBuildAllowanceToPackageJsonPnpm( - pkg.pnpm, - pnpmMajorVersion, - shouldAllowBrowserProviderBuilds, - ); - } - } - - const supportCatalog = usePnpmWorkspaceYaml || packageManager === PackageManager.yarn; - extractedStagedConfig = rewritePackageJson( - pkg, - packageManager, - supportCatalog, - skipStagedMigration, - catalogDependencyResolver, - browserMode, - providerSourceModes, - usesVitest, - retainedVitestModule, - requiredVitestPeer, - ); - - // ensure vite-plus is in devDependencies — but only when it isn't already a - // direct dependency/devDependency, so a project that declares vite-plus in - // `dependencies` is not duplicated into `devDependencies`. Force-override - // still re-pins a pre-existing devDependencies entry in place. - const forceRepinExistingDevEntry = - isForceOverrideMode() && pkg.devDependencies?.[VITE_PLUS_NAME] !== undefined; - if (!hasDirectVitePlusInstallEntry(pkg) || forceRepinExistingDevEntry) { - const existingVitePlusSpec = pkg.devDependencies?.[VITE_PLUS_NAME]; - const version = - supportCatalog && !VITE_PLUS_VERSION.startsWith('file:') - ? existingVitePlusSpec?.startsWith('catalog:') - ? existingVitePlusSpec - : (catalogDependencyResolver?.preferredCatalogSpec ?? 'catalog:') - : VITE_PLUS_VERSION; - pkg.devDependencies = { - ...pkg.devDependencies, - [VITE_PLUS_NAME]: version, - }; - } - // This caller injects vite-plus after rewritePackageJson returned, so the - // direct-`vite` pass must run here too. - ensureDirectViteForPnpm( - pkg, - packageManager, - usePnpmWorkspaceYaml && packageManager !== PackageManager.npm, - catalogDependencyResolver, - ); - return pkg; - }); - - migratePnpmSettingsToWorkspaceYaml(projectPath, movedPnpmSettings); - - if (shouldRewritePnpmWorkspaceYaml) { - rewritePnpmWorkspaceYaml( - projectPath, - pnpmMajorVersion, - shouldAllowBrowserProviderBuilds, - usesVitest, - vitestEcosystemPackages, - true, - providerCatalogAdditions, - ); - } - - if (shouldAddPnpmWorkspaceVitePlusOverride) { - migratePnpmOverridesToWorkspaceYaml(projectPath, { - [VITE_PLUS_NAME]: VITE_PLUS_VERSION, - }); - } - - if (packageManager === PackageManager.pnpm) { - ensurePnpmWorkspaceExoticSubdepsSetting(projectPath); - } - - if (packageManager === PackageManager.yarn) { - rewriteYarnrcYml(projectPath, usesVitest, vitestEcosystemPackages, providerCatalogAdditions); - } - - // Merge extracted staged config into vite.config.ts, then remove lint-staged from package.json - if (extractedStagedConfig) { - if (mergeStagedConfigToViteConfig(projectPath, extractedStagedConfig, silent, report)) { - removeLintStagedFromPackageJson(packageJsonPath); - } - } - - if (!skipStagedMigration) { - rewriteLintStagedConfigFile(projectPath, report); - } - cleanupDeprecatedTsconfigOptions(projectPath, silent, report); - rewriteTsconfigTypes(projectPath, silent, report); - mergeViteConfigFiles(projectPath, silent, report, workspaceInfo.packages); - injectLintTypeCheckDefaults(projectPath, silent, report); - injectFmtDefaults(projectPath, silent, report); - mergeTsdownConfigFile(projectPath, silent, report); - // rewrite imports in all TypeScript/JavaScript files before lazy plugin import merging - rewriteAllImports(projectPath, silent, report, true); - wrapLazyPluginsInViteConfig(projectPath, silent, report); - // set package manager - setPackageManager(projectPath, workspaceInfo.downloadPackageManager); -} - -/** - * Rewrite monorepo to add vite-plus dependencies - * @param workspaceInfo - The workspace info - */ -export function rewriteMonorepo( - workspaceInfo: WorkspaceInfo, - skipStagedMigration?: boolean, - silent = false, - report?: MigrationReport, -): void { - const catalogDependencyResolver = createCatalogDependencyResolver( - workspaceInfo.rootDir, - workspaceInfo.packageManager, - ); - const pnpmMajorVersion = pnpmMajor(workspaceInfo.downloadPackageManager.version); - const usePnpmWorkspaceSettings = pnpmSupportsWorkspaceSettings( - workspaceInfo.downloadPackageManager.version, - ); - const workspaceShouldAllowBrowserBuilds = workspaceUsesWebdriverio( - workspaceInfo.rootDir, - workspaceInfo.packages, - ); - // The SHARED workspace sinks (catalog / overrides / peer rules) keep `vitest` - // managed iff ANY package in the workspace uses vitest directly. - const workspaceUsesVitest = workspaceUsesVitestDirectly( - workspaceInfo.rootDir, - workspaceInfo.packages, - true, - ); - const vitestEcosystemPackages = collectVitestEcosystemInstallDependencyNames( - workspaceInfo.rootDir, - workspaceInfo.packages, - ); - const providerCatalogAdditions = collectInjectedProviderNames( - workspaceInfo.rootDir, - workspaceInfo.packages, - ); - // rewrite root workspace - if (workspaceInfo.packageManager === PackageManager.yarn) { - rewriteYarnrcYml( - workspaceInfo.rootDir, - workspaceUsesVitest, - vitestEcosystemPackages, - providerCatalogAdditions, - ); - } else if (workspaceInfo.packageManager === PackageManager.bun) { - rewriteBunCatalog(workspaceInfo.rootDir, workspaceUsesVitest, vitestEcosystemPackages); - } - rewriteRootWorkspacePackageJson( - workspaceInfo.rootDir, - workspaceInfo.packageManager, - skipStagedMigration, - catalogDependencyResolver, - workspaceInfo.packages, - pnpmMajorVersion, - workspaceInfo.downloadPackageManager.version, - workspaceShouldAllowBrowserBuilds, - workspaceUsesVitest, - ); - if (workspaceInfo.packageManager === PackageManager.pnpm) { - rewritePnpmWorkspaceYaml( - workspaceInfo.rootDir, - pnpmMajorVersion, - workspaceShouldAllowBrowserBuilds, - workspaceUsesVitest, - vitestEcosystemPackages, - usePnpmWorkspaceSettings, - providerCatalogAdditions, - ); - if (usePnpmWorkspaceSettings && isForceOverrideMode()) { - migratePnpmOverridesToWorkspaceYaml(workspaceInfo.rootDir, { - [VITE_PLUS_NAME]: VITE_PLUS_VERSION, - }); - } - } - // (mergeViteConfigFiles below will sanitize the merged lint config - // against this workspace's full package set.) - - // rewrite packages — pass workspace context so the per-package - // sanitizer can see hoisted deps that live elsewhere in the - // workspace, not just this sub-package's own `package.json`. - const workspaceContext = { - rootDir: workspaceInfo.rootDir, - packages: workspaceInfo.packages, - }; - // Yarn `node-modules` + an isolating `nmHoistingLimits` would give each - // vite-plus-receiving workspace its own physical `vitest` copy, splitting the - // runner across two `@vitest/runner` instances. `rewriteMonorepoProject` detects - // the layout per workspace (reading the root `.yarnrc.yml` itself) and auto-fixes - // or warns — see `applyYarnWorkspaceHoistingFix`. - for (const pkg of workspaceInfo.packages) { - rewriteMonorepoProject( - path.join(workspaceInfo.rootDir, pkg.path), - workspaceInfo.packageManager, - skipStagedMigration, - silent, - report, - catalogDependencyResolver, - workspaceContext, - true, - ); - } - - if (!skipStagedMigration) { - rewriteLintStagedConfigFile(workspaceInfo.rootDir, report); - } - cleanupDeprecatedTsconfigOptions(workspaceInfo.rootDir, silent, report); - rewriteTsconfigTypes(workspaceInfo.rootDir, silent, report); - mergeViteConfigFiles(workspaceInfo.rootDir, silent, report, workspaceInfo.packages); - injectLintTypeCheckDefaults(workspaceInfo.rootDir, silent, report); - injectFmtDefaults(workspaceInfo.rootDir, silent, report); - mergeTsdownConfigFile(workspaceInfo.rootDir, silent, report); - // rewrite imports in all TypeScript/JavaScript files before lazy plugin import merging - rewriteAllImports(workspaceInfo.rootDir, silent, report, true); - wrapLazyPluginsInViteConfig(workspaceInfo.rootDir, silent, report); - for (const pkg of workspaceInfo.packages) { - wrapLazyPluginsInViteConfig(path.join(workspaceInfo.rootDir, pkg.path), silent, report); - } - // set package manager - setPackageManager(workspaceInfo.rootDir, workspaceInfo.downloadPackageManager); -} - -/** - * Rewrite monorepo project to add vite-plus dependencies - * @param projectPath - The path to the project - * @param workspaceContext - Full workspace info, used so the lint-config - * sanitizer can see hoisted deps living elsewhere in the workspace, - * not just this sub-package's own `package.json`. `rootDir` is the - * workspace root (paths in `packages` are relative to it); `packages` - * is the workspace package list. - */ -export function rewriteMonorepoProject( - projectPath: string, - packageManager: PackageManager, - skipStagedMigration?: boolean, - silent = false, - report?: MigrationReport, - catalogDependencyResolver?: CatalogDependencyResolver, - workspaceContext?: { rootDir: string; packages: WorkspacePackage[] }, - deferLazyPluginWrapping = false, -): void { - cleanupDeprecatedTsconfigOptions(projectPath, silent, report); - rewriteTsconfigTypes(projectPath, silent, report); - mergeViteConfigFiles( - projectPath, - silent, - report, - workspaceContext?.packages, - workspaceContext?.rootDir, - ); - mergeTsdownConfigFile(projectPath, silent, report); - - const packageJsonPath = path.join(projectPath, 'package.json'); - if (!fs.existsSync(packageJsonPath)) { - return; - } - - // Yarn `nmHoistingLimits` for this workspace's project, found by walking up to the - // root `.yarnrc.yml`. Derived here (not threaded as an arg) so EVERY caller — full - // monorepo migration, a direct `rewriteMonorepoProject` call, and `vp create` - // integrating a package into an existing monorepo — is covered. undefined for - // non-Yarn repos. - const yarnHoisting = - packageManager === PackageManager.yarn - ? findYarnWorkspaceHoisting(workspaceContext?.rootDir ?? projectPath) - : undefined; - - let extractedStagedConfig: Record | null = null; - editJsonFile<{ - devDependencies?: Record; - dependencies?: Record; - peerDependencies?: Record; - optionalDependencies?: Record; - scripts?: Record; - installConfig?: { hoistingLimits?: string }; - }>(packageJsonPath, (pkg) => { - const requiredVitestPeer = projectListsRequiredVitestPeer(projectPath, pkg); - // Compute the browser-mode and retained-module source scans once and reuse - // them across rewritePackageJson and projectUsesVitestDirectly: the scans do - // not depend on package.json and nothing mutates the source tree between - // these reads, so this is identical to the previous per-call scans. - const browserMode = usesVitestBrowserMode(projectPath); - const retainedVitestModule = sourceTreeReferencesRetainedVitestModule(projectPath); - // rewrite scripts in package.json - extractedStagedConfig = rewritePackageJson( - pkg, - packageManager, - true, - skipStagedMigration, - catalogDependencyResolver, - browserMode, - collectProviderSourceModes(projectPath), - projectUsesVitestDirectly(projectPath, pkg, requiredVitestPeer, true, { - browserMode, - retainedModule: retainedVitestModule, - }), - retainedVitestModule, - requiredVitestPeer, - ); - // If this SUB-workspace now depends on `vite-plus` and Yarn isolates its - // hoisting (via the root `nmHoistingLimits` OR the workspace's own - // `installConfig.hoistingLimits`), dedupe the bundled `vitest` family to the - // single shared root copy (avoids the dual-`@vitest/runner` "reading 'config'" - // crash), or warn when the split cannot be fixed from package.json. The monorepo - // root itself is skipped (`projectPath === yarnHoisting.rootDir`): its deps - // already hoist to the top level, so it never needs an opt-out. - if ( - yarnHoisting && - path.resolve(projectPath) !== yarnHoisting.rootDir && - pkg.devDependencies?.[VITE_PLUS_NAME] - ) { - applyYarnWorkspaceHoistingFix( - pkg, - yarnHoisting.limit, - yarnHoisting.nodeLinker, - path.relative(yarnHoisting.rootDir, projectPath) || projectPath, - report, - ); - } - return pkg; - }); - - // Merge extracted staged config into vite.config.ts, then remove lint-staged from package.json - if (extractedStagedConfig) { - if (mergeStagedConfigToViteConfig(projectPath, extractedStagedConfig, silent, report)) { - removeLintStagedFromPackageJson(packageJsonPath); - } - } - - if (!deferLazyPluginWrapping) { - wrapLazyPluginsInViteConfig(projectPath, silent, report); - } -} - -/** - * Rewrite pnpm-workspace.yaml to add vite-plus dependencies - * @param projectPath - The path to the project - */ -function rewritePnpmWorkspaceYaml( - projectPath: string, - pnpmMajorVersion: number | undefined, - shouldAllowBrowserBuilds: boolean, - usesVitest: boolean, - vitestEcosystemPackages: ReadonlySet, - writeWorkspaceSettings = true, - catalogAdditions: ReadonlySet = new Set(), -): void { - const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); - if (!fs.existsSync(pnpmWorkspaceYamlPath)) { - fs.writeFileSync(pnpmWorkspaceYamlPath, ''); - } - const managed = managedOverridePackages(usesVitest); - - editYamlFile(pnpmWorkspaceYamlPath, (doc) => { - // catalog - const preferredCatalogSpec = rewriteCatalog( - doc, - usesVitest, - vitestEcosystemPackages, - catalogAdditions, - ); - if (!writeWorkspaceSettings) { - return; - } - - ensurePnpmExoticSubdepsSetting(doc); - if (pnpmMajorVersion !== undefined) { - applyBuildAllowanceToWorkspaceYaml(doc, pnpmMajorVersion, shouldAllowBrowserBuilds); - } - - // overrides - const overrides = doc.getIn(['overrides']); - pruneYamlMapLegacyWrapperAliases(overrides); - // Drop overrides for packages removed by migration (e.g. @vitest/browser*) - // so a stale workspace pin can't force an incompatible version against - // vite-plus's own direct dependency. Bare/versioned global pins - // (`pkg`, `pkg@version`), global-glob selectors (`**/pkg`), and - // `vite-plus`-parented selectors (`vite-plus>pkg`) all reach vite-plus's own - // provider dep and are removed. A selector scoped under a SPECIFIC - // non-vite-plus parent (e.g. `some-app>@vitest/browser-playwright`) only - // constrains that parent's subtree, so it is preserved — see - // `shouldDropProviderOverrideKey`. - if (overrides instanceof YAMLMap) { - const keysSnapshot = overrides.items.map((item) => item.key); - for (const keyNode of keysSnapshot) { - const rawKey = - keyNode instanceof Scalar ? String(keyNode.value ?? '') : String(keyNode ?? ''); - if (shouldDropProviderOverrideKey(rawKey)) { - overrides.delete(keyNode); - } - } - } - // Common case (no direct vitest): actively strip any lingering managed - // `vitest` override so it arrives transitively through vite-plus. - if (!usesVitest) { - removeYamlMapVitestEntry(doc.getIn(['overrides'])); - } - for (const key of Object.keys(managed)) { - const currentVersion = getYamlMapScalarStringValue(overrides, key); - const version = getCatalogDependencySpec(currentVersion, managed[key], true, { - preferredCatalogSpec, - }); - doc.setIn(['overrides', scalarString(key)], scalarString(version)); - } - // remove dependency selector from vite, e.g. "vite-plugin-svgr>vite": "npm:vite@7.0.12" - 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') { - updatedOverrides.delete(item.key); - } - } - } - - // peerDependencyRules.allowAny - let allowAny = doc.getIn(['peerDependencyRules', 'allowAny']) as YAMLSeq>; - if (!allowAny) { - allowAny = new YAMLSeq>(); - } - // Common case: drop any lingering managed `vitest` allowAny entry. - if (!usesVitest && VITEST_IS_MANAGED_OVERRIDE) { - allowAny.items = allowAny.items.filter((n) => n.value !== 'vitest'); - } - const existing = new Set(allowAny.items.map((n) => n.value)); - for (const key of Object.keys(managed)) { - if (!existing.has(key)) { - allowAny.add(scalarString(key)); - } - } - doc.setIn(['peerDependencyRules', 'allowAny'], allowAny); - - // peerDependencyRules.allowedVersions - let allowedVersions = doc.getIn(['peerDependencyRules', 'allowedVersions']) as YAMLMap< - Scalar, - Scalar - >; - if (!allowedVersions) { - allowedVersions = new YAMLMap, Scalar>(); - } - // Common case: drop any lingering managed `vitest` allowedVersions entry. - if (!usesVitest) { - removeYamlMapVitestEntry(allowedVersions); - } - for (const key of Object.keys(managed)) { - // - vite: '*' - allowedVersions.set(scalarString(key), scalarString('*')); - } - doc.setIn(['peerDependencyRules', 'allowedVersions'], allowedVersions); - - // minimumReleaseAgeExclude - if (doc.has('minimumReleaseAge')) { - // Exempt the Vite+-managed packages from the age gate: vite-plus, - // @voidzero-dev/*, the ox* family, and the vitest family. Vite+ pins - // `vitest` to an exact (sometimes freshly published) version and the - // in-tree @vitest/* siblings install transitively at that version, so the - // age gate would otherwise quarantine them and break `vp install`. - const excludes = [ - 'vite-plus', - '@voidzero-dev/*', - 'oxlint', - '@oxlint/*', - 'oxlint-tsgolint', - '@oxlint-tsgolint/*', - 'oxfmt', - '@oxfmt/*', - ...VITEST_AGE_GATE_EXEMPT_PACKAGES, - ]; - let minimumReleaseAgeExclude = doc.getIn(['minimumReleaseAgeExclude']) as YAMLSeq< - Scalar - >; - if (!minimumReleaseAgeExclude) { - minimumReleaseAgeExclude = new YAMLSeq(); - } - const existing = new Set(minimumReleaseAgeExclude.items.map((n) => n.value)); - for (const exclude of excludes) { - if (!existing.has(exclude)) { - minimumReleaseAgeExclude.add(scalarString(exclude)); - } - } - doc.setIn(['minimumReleaseAgeExclude'], minimumReleaseAgeExclude); - } - }); -} - -/** - * Move remaining non-Vite pnpm.overrides from package.json to pnpm-workspace.yaml. - * pnpm ignores workspace-level overrides when pnpm.overrides exists in package.json, - * so all overrides must live in pnpm-workspace.yaml. - */ -function migratePnpmOverridesToWorkspaceYaml( - projectPath: string, - overrides: Record, -): void { - const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); - editYamlFile(pnpmWorkspaceYamlPath, (doc) => { - for (const [key, value] of Object.entries(overrides)) { - // Always overwrite: package.json value was the effective one before migration - // (pnpm ignores workspace overrides when pnpm.overrides exists in package.json) - doc.setIn(['overrides', scalarString(key)], scalarString(value)); - } - }); -} - -type DependencyBag = { - dependencies?: Record; - devDependencies?: Record; - optionalDependencies?: Record; - peerDependencies?: Record; -}; - -function hasOwnWebdriverioDependency(pkg: DependencyBag): boolean { - for (const name of WEBDRIVERIO_ALLOW_SIGNAL_DEPS) { - if ( - pkg.dependencies?.[name] ?? - pkg.devDependencies?.[name] ?? - pkg.optionalDependencies?.[name] ?? - pkg.peerDependencies?.[name] - ) { - return true; - } - } - return false; -} - -function workspaceUsesWebdriverio( - rootDir: string, - packages: WorkspacePackage[] | undefined, -): boolean { - const rootPkg = readPackageJsonIfExists(path.join(rootDir, 'package.json')); - if (rootPkg && hasOwnWebdriverioDependency(rootPkg)) { - return true; - } - // Source-only signal: a package may target the webdriverio provider purely - // through imports (e.g. `vite-plus/test/browser-webdriverio`) without a - // declared dep yet. The migration injects the provider for those, so the - // driver postinstalls must be allowed too. - if (usesWebdriverioProvider(rootDir)) { - return true; - } - if (!packages) { - return false; - } - for (const pkg of packages) { - const packageDir = path.join(rootDir, pkg.path); - const subPkg = readPackageJsonIfExists(path.join(packageDir, 'package.json')); - if (subPkg && hasOwnWebdriverioDependency(subPkg)) { - return true; - } - if (usesWebdriverioProvider(packageDir)) { - return true; - } - } - return false; -} - -// Workspace-wide direct-vitest signal for the SHARED sinks a monorepo root -// owns (pnpm-workspace.yaml catalog/overrides/peer rules, .yarnrc.yml catalog, -// bun catalog): `vitest` stays managed there iff ANY package in the workspace — -// the root or any sub-package — uses vitest directly. See -// `projectUsesVitestDirectly`. -function workspaceUsesVitestDirectly( - rootDir: string, - packages: WorkspacePackage[] | undefined, - preserveNuxtVitestImports = true, -): boolean { - const rootPkg = readPackageJsonIfExists(path.join(rootDir, 'package.json')) ?? {}; - if (projectUsesVitestDirectly(rootDir, rootPkg, undefined, preserveNuxtVitestImports)) { - return true; - } - if (!packages) { - return false; - } - for (const pkg of packages) { - const packageDir = path.join(rootDir, pkg.path); - const subPkg = readPackageJsonIfExists(path.join(packageDir, 'package.json')) ?? {}; - if (projectUsesVitestDirectly(packageDir, subPkg, undefined, preserveNuxtVitestImports)) { - return true; - } - } - return false; -} - -function readPackageJsonIfExists(packageJsonPath: string): DependencyBag | undefined { - if (!fs.existsSync(packageJsonPath)) { - return undefined; - } - try { - return readJsonFile(packageJsonPath) as DependencyBag; - } catch { - return undefined; - } -} - -// pnpm v10 introduced the map-shaped `allowBuilds` and removed the implicit -// "build everything" default; v9 (>= 9.5) gates builds via the list-shaped -// `onlyBuiltDependencies`. Both live in pnpm-workspace.yaml or in -// `package.json`'s `pnpm` field — vp migrate writes to whichever sink the -// rest of the migration is already touching. -function pnpmMajor(version: string | undefined): number | undefined { - const coerced = version ? semver.coerce(version)?.version : undefined; - return coerced ? semver.major(coerced) : undefined; -} - -function applyBuildAllowanceToPackageJsonPnpm( - pnpm: { - allowBuilds?: Record; - onlyBuiltDependencies?: string[]; - }, - major: number, - shouldAllow: boolean, -): void { - if (major >= 10) { - if (shouldAllow) { - // WebdriverIO present -> the edgedriver/geckodriver postinstall MUST run. Write - // `true`, OVERWRITING any stale `false` a prior WebdriverIO-less migration left - // behind (a re-run after adding WebdriverIO would otherwise keep the driver build - // blocked). - for (const name of BROWSER_PROVIDER_POSTINSTALL_PACKAGES) { - (pnpm.allowBuilds ??= {})[name] = true; - } - } - // No WebdriverIO -> vite-plus does NOT manage these postinstalls. edgedriver and - // geckodriver reach the tree only via the opt-in webdriverio provider (an OPTIONAL - // peer of both vite-plus and vitest, so pnpm never auto-installs it); a project that - // does not use it never installs them, so there is nothing to allow or deny. We - // write nothing and leave any user-authored allowBuilds entry (their own trust - // decision) untouched. - } else if (shouldAllow) { - // v9 onlyBuiltDependencies is an allow-list — omission is denial, so we - // only mutate when the user actually needs these packages built. - const list = pnpm.onlyBuiltDependencies ?? []; - const existing = new Set(list); - for (const name of BROWSER_PROVIDER_POSTINSTALL_PACKAGES) { - if (!existing.has(name)) { - list.push(name); - existing.add(name); - } - } - pnpm.onlyBuiltDependencies = list; - } -} - -function applyBuildAllowanceToWorkspaceYaml( - doc: YamlDocument, - major: number, - shouldAllow: boolean, -): void { - if (major >= 10) { - if (shouldAllow) { - // WebdriverIO present -> the edgedriver/geckodriver postinstall MUST run. Set - // `true`, OVERWRITING any stale `false` a prior WebdriverIO-less migration left - // behind (a re-run after adding WebdriverIO would otherwise keep the driver build - // blocked). Mutate an existing map in place (preserving its document position); - // only attach a freshly created one. - const existing = doc.getIn(['allowBuilds']); - const isNew = !(existing instanceof YAMLMap); - const allowBuilds = isNew - ? new YAMLMap, Scalar>() - : (existing as YAMLMap, Scalar>); - for (const name of BROWSER_PROVIDER_POSTINSTALL_PACKAGES) { - allowBuilds.set(scalarString(name), new Scalar(true)); - } - if (isNew) { - doc.setIn(['allowBuilds'], allowBuilds); - } - } - // No WebdriverIO -> vite-plus does NOT manage these postinstalls and leaves any - // user-authored allowBuilds entry untouched (see the package.json sink rationale). - // The drivers reach the tree only via the opt-in webdriverio provider, so a project - // that does not use it never installs them and there is nothing to allow or deny. - } else if (shouldAllow) { - let onlyBuiltDependencies = doc.getIn(['onlyBuiltDependencies']) as YAMLSeq>; - if (!(onlyBuiltDependencies instanceof YAMLSeq)) { - onlyBuiltDependencies = new YAMLSeq>(); - } - const existing = new Set(onlyBuiltDependencies.items.map((n) => n.value)); - for (const name of BROWSER_PROVIDER_POSTINSTALL_PACKAGES) { - if (!existing.has(name)) { - onlyBuiltDependencies.add(scalarString(name)); - } - } - doc.setIn(['onlyBuiltDependencies'], onlyBuiltDependencies); - } -} - -/** - * Rewrite .yarnrc.yml to add vite-plus dependencies - * @param projectPath - The path to the project - */ -// Under Yarn's `node-modules` linker, `nmHoistingLimits: workspaces` STOPS a -// dependency from being hoisted past the workspace that declares it — so every -// workspace that gets a direct `vite-plus` dep receives its OWN physical -// `vitest`/`@vitest/runner` copy instead of sharing one hoisted copy at the -// monorepo root. `vp test` resolves the Vitest runner bin ONCE from the workspace -// root (the root copy) but spawns it with the package as cwd; Vitest's per-package -// Vite server then serves the test graph's `@vitest/runner` from the PACKAGE's own -// copy. The runner process initialises its (root) `@vitest/runner` module instance -// while the test file imports `describe` from the package's DIFFERENT instance -// whose module-level runner is undefined -> `describe(...)` -> `initSuite()` -> -// `validateTags(runner.config, …)` -> `TypeError: Cannot read properties of -// undefined (reading 'config')`. Yarn has no per-package "force-hoist this dep to -// root" lever, so the only reliable dedupe is to let the affected workspaces hoist -// normally (a per-workspace `installConfig.hoistingLimits: none`). See -// `setYarnWorkspaceHoistingOptOut`. -// -// Only `workspaces` is auto-fixable. The stricter `dependencies` limit keeps a -// dependency BELOW each dependent package even when the workspace opts out to -// `none`, so the opt-out does NOT dedupe there — verified with Yarn 4.17: two -// workspaces sharing a dep under root `nmHoistingLimits: dependencies` + per- -// workspace `hoistingLimits: none` still produced two physical copies, whereas -// the same setup under `workspaces` deduped to one root copy. For `dependencies` -// (and for a `workspaces` root where the affected workspace already pins its own -// isolating limit) the migration cannot fix the split from package.json, so it -// WARNS instead of silently leaving a known-broken layout. See -// `applyYarnWorkspaceHoistingFix`. - -// Read a SINGLE directory's `.yarnrc.yml` scalar value for `key` (or undefined when -// the file/key is absent or non-string). Malformed YAML throws inside `readYamlFile`, -// so guard with try/catch — a broken ancestor rc must not abort the migration. -// -// Values are taken VERBATIM: Yarn's `${VAR}` / `${VAR:-default}` string interpolation -// is NOT evaluated. An interpolated `nmHoistingLimits`/`nodeLinker` therefore won't -// match the literal `'workspaces'`/`'node-modules'` the caller compares against, so the -// hoisting fix conservatively does NOTHING for it — a no-op (and never a spurious -// mutation), the same outcome as a repo with no hoisting handling at all. Faithfully -// evaluating Yarn interpolation would mean reimplementing Yarn's config loader (or -// shelling out to `yarn config get`, a fragile pre-install process dependency), which -// is out of scope for this best-effort safety net. -// -// The filename is the literal `.yarnrc.yml`, not Yarn's `YARN_RC_FILENAME`-renamed rc. -// `YARN_RC_FILENAME` support is intentionally out of scope: the rest of the Yarn -// migration (catalog/`nodeLinker`/`npmPreapprovedPackages` writes in `rewriteYarnrcYml` -// et al.) only ever writes `.yarnrc.yml`, so reading a renamed rc here would be a -// partial, inconsistent treatment — and a repo with `YARN_RC_FILENAME` set cannot be -// migrated at all until the write path also honours it (a separate, larger change). -// Keeping reads and writes on the same `.yarnrc.yml` is the consistent behaviour. -function readYarnrcValue(dir: string, key: string): string | undefined { - const yarnrcYmlPath = path.join(dir, '.yarnrc.yml'); - if (!fs.existsSync(yarnrcYmlPath)) { - return undefined; - } - try { - const doc = readYamlFile(yarnrcYmlPath) as Record | null; - const value = doc?.[key]; - return typeof value === 'string' ? value : undefined; - } catch { - return undefined; - } -} - -// Resolve the EFFECTIVE value Yarn would apply for a config `key` (and its -// `YARN_` env override) for a project rooted at `workspaceRootDir`, matching -// Yarn 4.17 precedence (all verified with `yarn config get`): -// 1. the `YARN_*` environment variable wins over every `.yarnrc.yml` (e.g. -// `YARN_NM_HOISTING_LIMITS`, `YARN_NODE_LINKER`); -// 2. otherwise Yarn merges `.yarnrc.yml` across the project root AND its ancestor -// directories, the CLOSEST file that defines the key winning — so a key set only -// in an ancestor rc is in effect, while a workspace-root value overrides it. -// So check the env var, then walk UP from the workspace root, then finally the home -// `~/.yarnrc.yml`, returning the first DEFINED value; undefined when none set it (the -// caller applies Yarn's default). The ancestor walk starts AT the workspace root, -// never below it — a sub-workspace's own `.yarnrc.yml` is not part of Yarn's -// install-time config resolution and must not shadow the root. -// -// The home rc is consulted LAST (lowest precedence, below the project/ancestor chain -// — verified with Yarn 4.17: a project-root value beats the home value). For a project -// UNDER $HOME the ancestor walk already passed through $HOME, so the explicit read is -// redundant; it matters for projects OUTSIDE $HOME (e.g. devcontainers/Codespaces -// mount the repo under /workspaces while $HOME is /home/), where Yarn still -// reads the home rc and the ancestor walk would otherwise miss it. -function resolveEffectiveYarnConfigValue( - workspaceRootDir: string, - key: string, - envVar: string, -): string | undefined { - const fromEnv = process.env[envVar]?.trim(); - if (fromEnv) { - return fromEnv; - } - let dir = path.resolve(workspaceRootDir); - for (;;) { - const value = readYarnrcValue(dir, key); - if (value !== undefined) { - return value; - } - const parent = path.dirname(dir); - if (parent === dir) { - break; - } - dir = parent; - } - const home = os.homedir(); - return home ? readYarnrcValue(home, key) : undefined; -} - -export interface YarnPnpDetection { - source: 'environment' | 'configuration' | 'default'; -} - -/** - * Detect Yarn Plug'n'Play using the same precedence Yarn applies to - * `nodeLinker`. Yarn 2+ defaults to PnP when no value is configured, while - * Yarn Classic defaults to node_modules. Unknown/`latest` Yarn versions are - * treated as modern because that is the version `vp` will provision. - */ -export function detectYarnPnpMode( - projectPath: string, - yarnVersion: string, -): YarnPnpDetection | undefined { - const coercedVersion = semver.coerce(yarnVersion); - if (coercedVersion?.major === 1) { - return undefined; - } - - const environmentLinker = process.env.YARN_NODE_LINKER?.trim(); - if (environmentLinker) { - return environmentLinker.toLowerCase() === 'pnp' ? { source: 'environment' } : undefined; - } - - const configuredLinker = resolveEffectiveYarnConfigValue( - projectPath, - 'nodeLinker', - 'YARN_NODE_LINKER', - ); - if (configuredLinker) { - return configuredLinker.toLowerCase() === 'pnp' ? { source: 'configuration' } : undefined; - } - - return { source: 'default' }; -} - -/** Set the project-local Yarn linker while preserving every other rc setting. */ -export function configureYarnNodeModulesMode(projectPath: string): boolean { - const yarnrcYmlPath = path.join(projectPath, '.yarnrc.yml'); - const before = fs.existsSync(yarnrcYmlPath) ? fs.readFileSync(yarnrcYmlPath, 'utf8') : undefined; - if (before === undefined) { - fs.writeFileSync(yarnrcYmlPath, ''); - } - editYamlFile(yarnrcYmlPath, (doc) => { - doc.set('nodeLinker', 'node-modules'); - }); - return before !== fs.readFileSync(yarnrcYmlPath, 'utf8'); -} - -// True when `dir`'s package.json declares a `workspaces` field — i.e. `dir` is a -// workspace (Yarn project) root. `workspaces` may be an array or an object -// (`{ packages: [...] }`); both are truthy. -function dirIsWorkspaceRoot(dir: string): boolean { - const pkgJsonPath = path.join(dir, 'package.json'); - if (!fs.existsSync(pkgJsonPath)) { - return false; - } - try { - const pkg = readJsonFile(pkgJsonPath) as { workspaces?: unknown }; - return pkg.workspaces != null; - } catch { - return false; - } -} - -// Walk up from a workspace directory to the nearest ancestor that IS a workspace -// root (its package.json declares `workspaces`) — the real Yarn project root — and -// return that directory plus the EFFECTIVE `nmHoistingLimits` and `nodeLinker` -// resolved across env + the `.yarnrc.yml` chain at and above that root. Keying on the -// workspace-root marker (NOT the nearest `.yarnrc.yml`) is deliberate: a package-local -// `.yarnrc.yml` written under a sub-package (e.g. by `vp create` / install) must not -// shadow the real root's limit, while a limit set in an ancestor `.yarnrc.yml` above -// the root is still honoured (Yarn merges the ancestor chain). This lets -// `rewriteMonorepoProject` discover the layout for ANY caller without it being -// threaded as an argument (the omitted-arg path was a missed-auto-fix bug class), and -// lets the caller tell whether the workspace it is rewriting IS the root (the root's -// deps already hoist to the top, so it must never be opted out). `nodeLinker` gates -// the fix: `nmHoistingLimits` only splits packages under the `node-modules` linker, so -// a PnP project (Yarn's default) is left untouched. undefined when no workspace root -// is found up to the filesystem root. -function findYarnWorkspaceHoisting( - startDir: string, -): { rootDir: string; limit: string | undefined; nodeLinker: string | undefined } | undefined { - let dir = path.resolve(startDir); - for (;;) { - if (dirIsWorkspaceRoot(dir)) { - return { - rootDir: dir, - limit: resolveEffectiveYarnConfigValue(dir, 'nmHoistingLimits', 'YARN_NM_HOISTING_LIMITS'), - nodeLinker: resolveEffectiveYarnConfigValue(dir, 'nodeLinker', 'YARN_NODE_LINKER'), - }; - } - const parent = path.dirname(dir); - if (parent === dir) { - return undefined; - } - dir = parent; - } -} - -// Opt a single workspace OUT of the INHERITED root `nmHoistingLimits` isolation by -// setting its own `installConfig.hoistingLimits: none`, so its `vite-plus` (and -// thus the bundled `vitest` family) hoists to the single shared root copy the -// runner bin resolves to. Scoped to workspaces the migration adds `vite-plus` to, -// so unrelated workspaces are untouched. `none` is Yarn's DEFAULT hoisting -// behaviour, so this only re-enables ordinary deduping — it never force-promotes a -// conflicting version to root. -// -// Only relaxes the INHERITED root limit: if the workspace already carries an -// EXPLICIT `installConfig.hoistingLimits` we leave it as-is. Overwriting it would -// clobber an intentional per-workspace invariant (e.g. a React Native `example` -// that isolates its whole tree for Metro and happens to also use Vite+ for tests), -// and that field governs the workspace's ENTIRE dependency tree, not just the -// vitest family. Idempotent: a no-op when any explicit value is already present. -function setYarnWorkspaceHoistingOptOut(pkg: { - installConfig?: { hoistingLimits?: string }; -}): void { - if (pkg.installConfig?.hoistingLimits !== undefined) { - return; - } - pkg.installConfig = { ...pkg.installConfig, hoistingLimits: 'none' }; -} - -// Resolve the Yarn workspace-hoisting isolation for a workspace that now depends on -// `vite-plus`. `rootLimit` is the effective `nmHoistingLimits` and `nodeLinker` the -// effective linker (both undefined for non-Yarn repos or an unset key). Either -// auto-fixes the workspace (mutating `pkg`) or, when the split cannot be fixed from -// package.json, warns so the migration never reports success while `vp test` is still -// known-broken. -function applyYarnWorkspaceHoistingFix( - pkg: { installConfig?: { hoistingLimits?: string } }, - rootLimit: string | undefined, - nodeLinker: string | undefined, - workspaceLabel: string, - report?: MigrationReport, -): void { - // `nmHoistingLimits`/`installConfig.hoistingLimits` only govern the `node-modules` - // linker — they physically isolate copies there. Under Plug'n'Play (Yarn's DEFAULT - // when `nodeLinker` is unset) resolution is virtual: no duplicate `@vitest/runner` - // can exist, so neither the auto-fix nor the warning applies. Writing an opt-out - // there would be a spurious source mutation that weakens isolation if the repo later - // switches linkers, so skip everything unless the linker is `node-modules`. - if (nodeLinker !== 'node-modules') { - return; - } - // `workspaces` isolation with no explicit per-workspace limit is the one layout a - // `none` opt-out deduplicates — fix it silently. - if (rootLimit === 'workspaces' && pkg.installConfig?.hoistingLimits === undefined) { - setYarnWorkspaceHoistingOptOut(pkg); - return; - } - // Layouts we must NOT (or cannot) auto-fix, but which still isolate this - // workspace's `vitest`/`vite-plus` copy so `vp test` can crash with a split - // `@vitest/runner`: - // - the INHERITED root `dependencies` limit (a `none` opt-out does not dedupe - // it — verified), and - // - the workspace's OWN explicit isolating `installConfig.hoistingLimits` - // (`workspaces`/`dependencies`), which isolates it regardless of the root - // value (incl. root unset or `none`) and is intentional, so it is preserved - // rather than clobbered. - // Surface a manual step for both rather than report a silently broken migration. - const explicit = pkg.installConfig?.hoistingLimits; - const isolatedByRoot = rootLimit === 'dependencies'; - const isolatedByWorkspace = explicit === 'workspaces' || explicit === 'dependencies'; - if (isolatedByRoot || isolatedByWorkspace) { - warnMigration( - `Yarn workspace "${workspaceLabel}" isolates dependency hoisting ` + - `(hoistingLimits: ${explicit ?? rootLimit}), so it keeps its own ` + - `\`vitest\`/\`vite-plus\` copy and \`vp test\` may crash with a split ` + - `\`@vitest/runner\`. Dedupe them to a single copy — relax this workspace's ` + - `hoisting isolation or pin one \`vitest\` for the workspace.`, - report, - ); - } -} - -function rewriteYarnrcYml( - projectPath: string, - usesVitest: boolean, - vitestEcosystemPackages: ReadonlySet, - catalogAdditions: ReadonlySet = new Set(), -): void { - const yarnrcYmlPath = path.join(projectPath, '.yarnrc.yml'); - if (!fs.existsSync(yarnrcYmlPath)) { - fs.writeFileSync(yarnrcYmlPath, ''); - } - - editYamlFile(yarnrcYmlPath, (doc) => { - if (!doc.has('nodeLinker')) { - doc.set('nodeLinker', 'node-modules'); - } - // Vite+ pins the vitest family to exact, sometimes freshly published, - // versions. Yarn 4 hardened mode (auto-enabled for public-PR installs) - // quarantines packages younger than `npmMinimalAgeGate`, which makes - // `yarn install` fail on a just-released vitest pin. Preapprove the family - // so the Vite+-managed versions install regardless of release age; the - // `@vitest/*` glob also covers the optional `@vitest/browser-*` peers that - // are not in the override set. MERGE into any existing list (e.g. a project - // that already preapproves private packages) instead of skipping when set, - // otherwise the gate could still reject the freshly pinned vitest. - let npmPreapprovedPackages = doc.getIn(['npmPreapprovedPackages']) as YAMLSeq>; - if (!npmPreapprovedPackages) { - npmPreapprovedPackages = new YAMLSeq(); - } - const existingPreapproved = new Set(npmPreapprovedPackages.items.map((n) => n.value)); - for (const pkg of VITEST_AGE_GATE_EXEMPT_PACKAGES) { - if (!existingPreapproved.has(pkg)) { - npmPreapprovedPackages.add(scalarString(pkg)); - } - } - doc.setIn(['npmPreapprovedPackages'], npmPreapprovedPackages); - // catalog - rewriteCatalog(doc, usesVitest, vitestEcosystemPackages, catalogAdditions); - }); -} - -/** - * Rewrite catalog in pnpm-workspace.yaml or .yarnrc.yml - * @param doc - The document to rewrite - */ -function getCatalogDependencySpec( - currentValue: string | undefined, - version: string, - supportCatalog: boolean, - options?: { - dependencyField?: PackageJsonDependencyField; - dependencyName?: string; - packageManager?: PackageManager; - catalogDependencyResolver?: CatalogDependencyResolver; - preferredCatalogSpec?: string; - }, -): 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 ( - options?.dependencyField === 'optionalDependencies' && - options?.packageManager === PackageManager.yarn - ) { - return version; - } - if (!supportCatalog || version.startsWith('file:')) { - return version; - } - return currentValue?.startsWith('catalog:') - ? currentValue - : (options?.preferredCatalogSpec ?? 'catalog:'); -} - -/** - * #1932: under pnpm, an importer that depends on `vite-plus` (which bundles - * `vitest`) needs a DIRECT `vite` devDep so the `vite` override binds vitest's - * required `vite` peer to @voidzero-dev/vite-plus-core. Without a direct edge, - * pnpm's `autoInstallPeers` fabricates a separate upstream `vite` to satisfy the - * peer, splitting vite-plus / vite / vitest into duplicate instances (the extra - * vite also lacks vite's `@voidzero-dev/vite-task-client` integration, breaking - * the `vp test` cache). npm/yarn/bun redirect transitive/peer vite via root - * overrides/resolutions (and drop the aliased vite), so this is pnpm-only, - * mirroring the bun root-package branch in `rewriteRootWorkspacePackageJson`. - * - * A package that already declares `vite` in ANY dependency field, including - * `peerDependencies` (e.g. a vite plugin pinning `vite ^6`), is left untouched - * so its existing version contract is preserved. Call this AFTER `vite-plus` - * has been ensured in the package, so the dependency check sees it. - */ -function ensureDirectViteForPnpm( - pkg: { - dependencies?: Record; - devDependencies?: Record; - optionalDependencies?: Record; - peerDependencies?: Record; - }, - packageManager: PackageManager, - supportCatalog: boolean, - catalogDependencyResolver?: CatalogDependencyResolver, -): boolean { - const viteOverride = VITE_PLUS_OVERRIDE_PACKAGES.vite; - if (packageManager !== PackageManager.pnpm || !viteOverride) { - return false; - } - const dependsOnVitePlus = - pkg.dependencies?.[VITE_PLUS_NAME] !== undefined || - pkg.devDependencies?.[VITE_PLUS_NAME] !== undefined; - const viteAlreadyDirect = - pkg.dependencies?.vite !== undefined || - pkg.devDependencies?.vite !== undefined || - pkg.optionalDependencies?.vite !== undefined || - pkg.peerDependencies?.vite !== undefined; - if (!dependsOnVitePlus || viteAlreadyDirect) { - return false; - } - // The catalog-vs-alias choice is driven entirely by supportCatalog and the - // (file:/npm:) override spec; the extra getCatalogDependencySpec options only - // matter for an existing value or a peerDependencies field, neither of which - // applies here (we only reach this for a fresh devDependencies entry). - const viteSpec = getCatalogDependencySpec(undefined, viteOverride, supportCatalog, { - preferredCatalogSpec: catalogDependencyResolver?.preferredCatalogSpec, - }); - // Insert `vite` in sorted position rather than appending it: oxfmt sorts - // package.json dependencies and `vp migrate` has no later format pass, so an - // out-of-order key would fail a follow-up `vp check`. - const entries: [string, string][] = Object.entries(pkg.devDependencies ?? {}); - const insertAt = entries.findIndex(([name]) => name > 'vite'); - entries.splice(insertAt === -1 ? entries.length : insertAt, 0, ['vite', viteSpec]); - pkg.devDependencies = Object.fromEntries(entries); - return true; -} - -// A peer declaration does not install Vitest and therefore must not keep a -// workspace-wide managed Vitest catalog alive. Resolve its catalog reference to -// the public peer range before that catalog is pruned, so the surviving peer -// never points at a missing default/named catalog entry. -function normalizeVitestPeerCatalogSpec( - peerDependencies: Record | undefined, - catalogDependencyResolver?: CatalogDependencyResolver, -): boolean { - if (!peerDependencies) { - return false; - } - const current = peerDependencies.vitest; - if (!current?.startsWith('catalog:')) { - return false; - } - const normalized = getCatalogDependencySpec(current, VITEST_VERSION, true, { - dependencyField: 'peerDependencies', - dependencyName: 'vitest', - catalogDependencyResolver, - }); - if (normalized === current) { - return false; - } - peerDependencies.vitest = normalized; - return true; -} - -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>; - }; - // A missing/absent `workspaces.catalog` resolves identically whether the - // fallback is `undefined` (optional chaining) or `{}`, so this shares the - // exact bun catalog resolution used by the in-memory callers. - return readBunCatalogDependencyResolver(pkg); - } - return undefined; -} - -function createCatalogDependencyResolverFromCatalogs( - catalog: Record | undefined, - catalogs: Record> | undefined, -): CatalogDependencyResolver { - const preferredCatalogSpec = selectPreferredCatalogSpec(catalog, catalogs); - const resolver = (catalogSpec: string, dependencyName: string) => { - const catalogName = catalogSpec.slice('catalog:'.length); - // pnpm accepts the default catalog in either `catalog` or - // `catalogs.default`, but rejects a workspace that defines both. Both - // `catalog:` and `catalog:default` resolve through that one logical - // default catalog. - if (catalogName && catalogName !== 'default') { - return catalogs?.[catalogName]?.[dependencyName]; - } - return (catalog ?? catalogs?.default)?.[dependencyName]; - }; - return Object.assign(resolver, { preferredCatalogSpec }); -} - -function selectPreferredCatalogSpec( - catalog: Record | undefined, - catalogs: Record> | undefined, -): string { - const candidates: Array<{ spec: string; values: Record }> = []; - if (catalog) { - candidates.push({ spec: 'catalog:', values: catalog }); - } - for (const [name, values] of Object.entries(catalogs ?? {})) { - candidates.push({ - spec: name === 'default' ? 'catalog:' : `catalog:${name}`, - values, - }); - } - - // Keep the managed toolchain together when a project already has a catalog - // for it (for example Vize's `catalogs.vite-stack` and Rari's - // `catalogs.build`). Prefer vite-plus as the strongest signal, followed by - // vite and vitest. Existing dependency references keep their exact catalog - // spec; this choice is for newly injected dependencies and overrides. - for (const dependencyName of [VITE_PLUS_NAME, 'vite', 'vitest']) { - const matching = candidates.find(({ values }) => Object.hasOwn(values, dependencyName)); - if (matching) { - return matching.spec; - } - } - - // Reuse either valid spelling of the default catalog. Do not repurpose an - // unrelated named catalog; when no managed/default catalog exists, create - // the conventional top-level `catalog` instead. - if (catalog || catalogs?.default) { - return 'catalog:'; - } - return '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 pruneYamlMapLegacyWrapperAliases(map: unknown): void { - if (!(map instanceof YAMLMap)) { - return; - } - const stale: Array<{ key: Scalar; fallback: string | undefined }> = []; - for (const item of map.items) { - const value = item.value instanceof Scalar ? item.value.value : undefined; - if (typeof value === 'string' && isLegacyWrapperSpec(value) && item.key instanceof Scalar) { - stale.push({ - key: item.key, - fallback: LEGACY_WRAPPER_FALLBACK_VERSIONS[item.key.value], - }); - } - } - for (const { key, fallback } of stale) { - if (fallback !== undefined) { - map.set(key, scalarString(fallback)); - } else { - map.delete(key); - } - } -} - -function rewriteCatalog( - doc: YamlDocument, - usesVitest: boolean, - vitestEcosystemPackages: ReadonlySet, - catalogAdditions: ReadonlySet, -): string { - const parsed = doc.toJS() as { - catalog?: Record; - catalogs?: Record>; - } | null; - const preferredCatalogSpec = selectPreferredCatalogSpec(parsed?.catalog, parsed?.catalogs); - const preferredCatalogName = preferredCatalogSpec.slice('catalog:'.length); - const targetPath: readonly string[] = - preferredCatalogName && preferredCatalogName !== 'default' - ? ['catalogs', preferredCatalogName] - : doc.has('catalog') || !doc.hasIn(['catalogs', 'default']) - ? ['catalog'] - : ['catalogs', 'default']; - - rewriteYamlCatalogAtPath( - doc, - targetPath, - true, - usesVitest, - vitestEcosystemPackages, - catalogAdditions, - ); - - if (targetPath[0] !== 'catalog') { - rewriteYamlCatalogAtPath( - doc, - ['catalog'], - false, - usesVitest, - vitestEcosystemPackages, - catalogAdditions, - ); - } - - const catalogs = doc.getIn(['catalogs']); - if (catalogs instanceof YAMLMap) { - for (const item of catalogs.items) { - const catalogName = item.key instanceof Scalar ? item.key.value : undefined; - if ( - typeof catalogName !== 'string' || - !(item.value instanceof YAMLMap) || - (targetPath[0] === 'catalogs' && targetPath[1] === catalogName) - ) { - continue; - } - rewriteYamlCatalogAtPath( - doc, - ['catalogs', catalogName], - false, - usesVitest, - vitestEcosystemPackages, - catalogAdditions, - ); - } - } - - return preferredCatalogSpec; -} - -function rewriteYamlCatalogAtPath( - doc: YamlDocument, - catalogPath: readonly string[], - addMissing: boolean, - usesVitest: boolean, - vitestEcosystemPackages: ReadonlySet, - catalogAdditions: ReadonlySet, -): void { - const managed = managedOverridePackages(usesVitest); - let catalogNode = doc.getIn(catalogPath); - if (!(catalogNode instanceof YAMLMap)) { - if (!addMissing) { - return; - } - catalogNode = new YAMLMap(); - doc.setIn(catalogPath, catalogNode); - } - const catalog = catalogNode as YAMLMap; - - // Common case (no direct vitest): remove any lingering managed `vitest` - // catalog entry so it resolves transitively through vite-plus. - if (!usesVitest) { - removeYamlMapVitestEntry(catalog); - } - for (const [key, value] of Object.entries(managed)) { - // 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 - // ignore setting catalog if value starts with 'file:' - if (value.startsWith('file:') || (!addMissing && !catalog.has(key))) { - continue; - } - catalog.set(scalarString(key), scalarString(value)); - } - if (!VITE_PLUS_VERSION.startsWith('file:') && (addMissing || catalog.has(VITE_PLUS_NAME))) { - catalog.set(scalarString(VITE_PLUS_NAME), scalarString(VITE_PLUS_VERSION)); - } - if (addMissing && VITEST_IS_MANAGED_OVERRIDE) { - for (const name of catalogAdditions) { - if (isAlignableVitestEcosystemPackage(name)) { - catalog.set(scalarString(name), scalarString(VITEST_VERSION)); - } - } - } - for (const name of REMOVE_PACKAGES) { - catalog.delete(name); - } - // Drop any entry still pointing at the deleted `vite-plus-test` wrapper. - pruneYamlMapLegacyWrapperAliases(catalog); - rewriteVitestEcosystemYamlCatalog(catalog, vitestEcosystemPackages); -} - -function rewriteVitestEcosystemYamlCatalog( - catalog: unknown, - vitestEcosystemPackages: ReadonlySet, -): void { - if (!VITEST_IS_MANAGED_OVERRIDE || !(catalog instanceof YAMLMap)) { - return; - } - for (const item of catalog.items) { - const name = item.key instanceof Scalar ? item.key.value : undefined; - if ( - typeof name === 'string' && - vitestEcosystemPackages.has(name) && - isAlignableVitestEcosystemPackage(name) - ) { - catalog.set(item.key, scalarString(VITEST_VERSION)); - } - } -} - -function rewriteCatalogObject( - catalog: Record, - addMissing: boolean, - usesVitest: boolean, - vitestEcosystemPackages: ReadonlySet, -): void { - const managed = managedOverridePackages(usesVitest); - // Common case (no direct vitest): strip a lingering managed `vitest` catalog - // entry so it resolves transitively through vite-plus. - if (!usesVitest) { - removeManagedVitestEntry(catalog); - } - for (const [key, value] of Object.entries(managed)) { - 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]; - } - if (VITEST_IS_MANAGED_OVERRIDE) { - for (const name of Object.keys(catalog)) { - if (vitestEcosystemPackages.has(name) && isAlignableVitestEcosystemPackage(name)) { - catalog[name] = VITEST_VERSION; - } - } - } -} - -function rewriteCatalogsObject( - catalogs: Record>, - usesVitest: boolean, - vitestEcosystemPackages: ReadonlySet, -): void { - for (const catalog of Object.values(catalogs)) { - rewriteCatalogObject(catalog, false, usesVitest, vitestEcosystemPackages); - } -} - -/** - * Write catalog entries to root package.json for bun. - * Bun stores catalogs in package.json under the `catalog` key, - * unlike pnpm which uses pnpm-workspace.yaml. - * @see https://bun.sh/docs/pm/catalogs - */ -function rewriteBunCatalog( - projectPath: string, - usesVitest: boolean, - vitestEcosystemPackages: ReadonlySet, -): void { - const packageJsonPath = path.join(projectPath, 'package.json'); - if (!fs.existsSync(packageJsonPath)) { - return; - } - const managed = managedOverridePackages(usesVitest); - - 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 = { - ...(useWorkspacesCatalog ? workspacesObj?.catalog : pkg.catalog), - }; - - rewriteCatalogObject(catalog, true, usesVitest, vitestEcosystemPackages); - pruneLegacyWrapperAliases(catalog); - - if (useWorkspacesCatalog) { - workspacesObj.catalog = catalog; - if (pkg.catalog) { - rewriteCatalogObject(pkg.catalog, false, usesVitest, vitestEcosystemPackages); - pruneLegacyWrapperAliases(pkg.catalog); - } - } else { - pkg.catalog = catalog; - if (workspacesObj?.catalog) { - rewriteCatalogObject(workspacesObj.catalog, false, usesVitest, vitestEcosystemPackages); - pruneLegacyWrapperAliases(workspacesObj.catalog); - } - } - if (workspacesObj?.catalogs) { - rewriteCatalogsObject(workspacesObj.catalogs, usesVitest, vitestEcosystemPackages); - for (const named of Object.values(workspacesObj.catalogs)) { - pruneLegacyWrapperAliases(named); - } - } - if (pkg.catalogs) { - rewriteCatalogsObject(pkg.catalogs, usesVitest, vitestEcosystemPackages); - for (const named of Object.values(pkg.catalogs)) { - pruneLegacyWrapperAliases(named); - } - } - - // bun overrides support catalog: references - const overrides: Record = { ...pkg.overrides }; - pruneLegacyWrapperAliases(overrides); - // Common case (no direct vitest): strip a lingering managed `vitest` - // override (string-valued only — a nested user override is left intact; - // removeManagedVitestEntry also no-ops when vitest is not a managed key). - if (!usesVitest && typeof overrides.vitest === 'string') { - removeManagedVitestEntry(overrides); - } - for (const [key, value] of Object.entries(managed)) { - const current = overrides[key] as unknown; - // A nested object value is a user override scoped under this managed key, - // not a version pin — leave it intact (getCatalogDependencySpec expects a - // string and would otherwise clobber it / throw on `.startsWith`). - if (current !== undefined && typeof current !== 'string') { - continue; - } - overrides[key] = getCatalogDependencySpec(current, value, true); - } - pkg.overrides = overrides; - - return pkg; - }); -} - -/** - * Rewrite root workspace package.json to add vite-plus dependencies - * @param projectPath - The path to the project - */ -function rewriteRootWorkspacePackageJson( - projectPath: string, - packageManager: PackageManager, - skipStagedMigration?: boolean, - catalogDependencyResolver?: CatalogDependencyResolver, - // Forwarded to `rewriteMonorepoProject` so the per-root lint-config - // sanitizer can see hoisted deps in sibling workspace packages, not - // just the root's own `package.json`. - packages?: WorkspacePackage[], - pnpmMajorVersion?: number, - pnpmVersion?: string, - shouldAllowBrowserBuilds = false, - // Workspace-wide direct-vitest signal: the root resolution/override sinks are - // shared by every package, so `vitest` stays managed here iff ANY package uses - // vitest directly. - workspaceUsesVitest = true, -): void { - const packageJsonPath = path.join(projectPath, 'package.json'); - if (!fs.existsSync(packageJsonPath)) { - return; - } - const managed = managedOverridePackages(workspaceUsesVitest); - - let movedPnpmSettings: Record | undefined; - editJsonFile<{ - resolutions?: Record; - overrides?: Record; - devDependencies?: Record; - dependencies?: Record; - peerDependencies?: Record; - optionalDependencies?: Record; - pnpm?: PnpmPackageJsonSettings; - }>(packageJsonPath, (pkg) => { - // Strip stale `vite-plus-test` wrapper aliases before injecting new overrides - // so the deleted wrapper doesn't survive migration in any sink. - pruneLegacyWrapperAliases(pkg.resolutions); - pruneLegacyWrapperAliases(pkg.overrides); - pruneLegacyWrapperAliases(pkg.pnpm?.overrides); - // Drop stale provider overrides/resolutions (REMOVE_PACKAGES + the now - // user-owned opt-in providers, webdriverio/playwright) from the npm/bun - // `overrides` and yarn `resolutions` sinks before re-merging managed - // overrides. A leftover pin would conflict with the migrated direct - // `@vitest/browser-webdriverio` / `@vitest/browser-playwright` dep — npm - // hard-fails with EOVERRIDE, and yarn/bun would force the stale version over - // the bundled-vitest-aligned 4.1.9. (The pnpm sinks are pruned below.) - dropRemovePackageOverrideKeys(pkg.resolutions); - dropRemovePackageOverrideKeys(pkg.overrides); - // Common case (no workspace-wide direct vitest): strip a lingering managed - // `vitest` from the shared root sinks so it isn't re-pinned. - if (!workspaceUsesVitest) { - removeManagedVitestEntry(pkg.resolutions); - removeManagedVitestEntry(pkg.overrides); - } - if (packageManager === PackageManager.yarn) { - pkg.resolutions = { - ...pkg.resolutions, - // FIXME: yarn don't support catalog on resolutions - // https://github.com/yarnpkg/berry/issues/6979 - ...managed, - }; - } else if (packageManager === PackageManager.npm) { - pkg.overrides = { - ...pkg.overrides, - ...managed, - }; - } else if (packageManager === PackageManager.bun) { - // bun overrides are handled in rewriteBunCatalog() with catalog: references - // 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. - // See https://github.com/oven-sh/bun/issues/8406. - pkg.devDependencies = { - ...pkg.devDependencies, - vite: getCatalogDependencySpec( - pkg.devDependencies?.vite, - VITE_PLUS_OVERRIDE_PACKAGES.vite, - true, - ), - }; - } else if (packageManager === PackageManager.pnpm) { - const overrideKeys = Object.keys(managed); - const usePnpmWorkspaceSettings = pnpmSupportsWorkspaceSettings(pnpmVersion ?? ''); - if (!usePnpmWorkspaceSettings) { - // Strip selector-shaped overrides (e.g. `parent>@vitest/browser-playwright`) - // whose target is a removed package, before re-merging the user's - // overrides into the new pnpm config. - dropRemovePackageOverrideKeys(pkg.pnpm?.overrides); - // Common case: drop a lingering managed `vitest` override before merging. - if (!workspaceUsesVitest) { - removeManagedVitestEntry(pkg.pnpm?.overrides); - } - if (!workspaceUsesVitest && pkg.pnpm?.peerDependencyRules) { - removeVitestPeerDependencyRule(pkg.pnpm.peerDependencyRules); - } - pkg.pnpm = { - ...pkg.pnpm, - overrides: { - ...pkg.pnpm?.overrides, - ...managed, - ...(isForceOverrideMode() ? { [VITE_PLUS_NAME]: VITE_PLUS_VERSION } : {}), - }, - peerDependencyRules: { - ...pkg.pnpm?.peerDependencyRules, - allowAny: [ - ...new Set([...(pkg.pnpm?.peerDependencyRules?.allowAny ?? []), ...overrideKeys]), - ], - allowedVersions: { - ...pkg.pnpm?.peerDependencyRules?.allowedVersions, - ...Object.fromEntries(overrideKeys.map((key) => [key, '*'])), - }, - }, - }; - } else { - for (const key of [...overrideKeys, ...PROVIDER_OVERRIDE_DROP_NAMES]) { - if (pkg.resolutions?.[key]) { - delete pkg.resolutions[key]; - } - } - movedPnpmSettings = takePnpmWorkspaceSettings(pkg); - } - // remove dependency selectors targeting vite (e.g. "vite-plugin-svgr>vite") - for (const key in pkg.pnpm?.overrides) { - if (key.includes('>')) { - const splits = key.split('>'); - if (splits[splits.length - 1].trim() === 'vite') { - delete pkg.pnpm.overrides[key]; - } - } - } - if (pnpmMajorVersion !== undefined && pkg.pnpm) { - applyBuildAllowanceToPackageJsonPnpm(pkg.pnpm, pnpmMajorVersion, shouldAllowBrowserBuilds); - } - } - - // ensure vite-plus is in devDependencies — skip when it already lives in - // `dependencies` or `devDependencies` so it isn't duplicated across groups. - if (!hasDirectVitePlusInstallEntry(pkg)) { - pkg.devDependencies = { - ...pkg.devDependencies, - [VITE_PLUS_NAME]: - packageManager === PackageManager.npm || VITE_PLUS_VERSION.startsWith('file:') - ? VITE_PLUS_VERSION - : (catalogDependencyResolver?.preferredCatalogSpec ?? 'catalog:'), - }; - } - ensureDirectViteForPnpm(pkg, packageManager, true, catalogDependencyResolver); - return pkg; - }); - - migratePnpmSettingsToWorkspaceYaml(projectPath, movedPnpmSettings); - - // rewrite package.json — `projectPath` IS the workspace root here, so - // `workspaceContext.rootDir` matches it; sanitizer resolves - // sibling-package paths against `projectPath`. - rewriteMonorepoProject( - projectPath, - packageManager, - skipStagedMigration, - undefined, - undefined, - catalogDependencyResolver, - packages ? { rootDir: projectPath, packages } : undefined, - true, - ); -} - -const RULES_YAML_PATH = path.join(rulesDir, 'vite-tools.yml'); -const PREPARE_RULES_YAML_PATH = path.join(rulesDir, 'vite-prepare.yml'); - -// Cache YAML content to avoid repeated disk reads (called once per package in monorepos) -let cachedRulesYaml: string | undefined; -let cachedRulesYamlNoLintStaged: string | undefined; -let cachedPrepareRulesYaml: string | undefined; -function readRulesYaml(): string { - cachedRulesYaml ??= fs.readFileSync(RULES_YAML_PATH, 'utf8'); - return cachedRulesYaml; -} -function getScriptRulesYaml(skipStagedMigration?: boolean): string { - const yaml = readRulesYaml(); - if (!skipStagedMigration) { - return yaml; - } - cachedRulesYamlNoLintStaged ??= yaml - .split('\n\n\n') - .filter((block) => !block.includes('id: replace-lint-staged')) - .join('\n\n\n'); - return cachedRulesYamlNoLintStaged; -} -function readPrepareRulesYaml(): string { - cachedPrepareRulesYaml ??= fs.readFileSync(PREPARE_RULES_YAML_PATH, 'utf8'); - return cachedPrepareRulesYaml; -} - -type CoreMigrationWorkspace = { - rootDir: string; - packages?: WorkspacePackage[]; -}; - -export type PendingCoreMigration = { - scripts: boolean; - tsconfigTypes: boolean; -}; - -export type CoreMigrationFinalizationResult = { - scripts: boolean; - tsconfigTypes: boolean; - imports: boolean; -}; - -function getCoreMigrationProjectPaths(workspaceInfo: CoreMigrationWorkspace): string[] { - return [ - workspaceInfo.rootDir, - ...(workspaceInfo.packages ?? []).map((pkg) => path.join(workspaceInfo.rootDir, pkg.path)), - ]; -} - -function hasCorePackageScriptRewrites(projectPath: string): boolean { - const packageJsonPath = path.join(projectPath, 'package.json'); - if (!fs.existsSync(packageJsonPath)) { - return false; - } - const pkg = readJsonFile(packageJsonPath) as { scripts?: Record }; - if (!pkg.scripts) { - return false; - } - return !!rewriteScripts(JSON.stringify(pkg.scripts), getScriptRulesYaml(true)); -} - -function rewriteCorePackageScripts(projectPath: string): boolean { - const packageJsonPath = path.join(projectPath, 'package.json'); - if (!fs.existsSync(packageJsonPath)) { - return false; - } - - let changed = false; - editJsonFile<{ scripts?: Record }>(packageJsonPath, (pkg) => { - if (!pkg.scripts) { - return undefined; - } - const updated = rewriteScripts(JSON.stringify(pkg.scripts), getScriptRulesYaml(true)); - if (!updated) { - return undefined; - } - pkg.scripts = JSON.parse(updated); - changed = true; - return pkg; - }); - return changed; -} - -export function detectPendingCoreMigration( - workspaceInfo: CoreMigrationWorkspace, -): PendingCoreMigration { - const projectPaths = getCoreMigrationProjectPaths(workspaceInfo); - return { - scripts: projectPaths.some((projectPath) => hasCorePackageScriptRewrites(projectPath)), - tsconfigTypes: projectPaths.some((projectPath) => hasTsconfigTypesToRewrite(projectPath)), - }; -} - -export function finalizeCoreMigrationForExistingVitePlus( - workspaceInfo: CoreMigrationWorkspace, - silent = false, - report?: MigrationReport, - pending = detectPendingCoreMigration(workspaceInfo), -): CoreMigrationFinalizationResult { - const projectPaths = getCoreMigrationProjectPaths(workspaceInfo); - const result: CoreMigrationFinalizationResult = { - scripts: false, - tsconfigTypes: false, - imports: false, - }; - - if (pending.scripts) { - for (const projectPath of projectPaths) { - result.scripts = rewriteCorePackageScripts(projectPath) || result.scripts; - } - } - - if (pending.tsconfigTypes) { - for (const projectPath of projectPaths) { - result.tsconfigTypes = - rewriteTsconfigTypes(projectPath, silent, report) || result.tsconfigTypes; - } - } - - result.imports = rewriteAllImports(workspaceInfo.rootDir, silent, report, true); - - return result; -} - -type BootstrapPackageJson = { - overrides?: Record; - resolutions?: Record; - devDependencies?: Record; - dependencies?: Record; - peerDependencies?: Record; - optionalDependencies?: Record; - pnpm?: PnpmPackageJsonSettings; - packageManager?: string; - devEngines?: { packageManager?: unknown; [key: string]: unknown }; -}; - -export type VitePlusBootstrapResult = { - changed: boolean; - packageJson: boolean; - packageManagerConfig: boolean; - packageManagerField: boolean; -}; - -function isSemanticVitePlusOverrideSpec(dependencyName: string, spec: string | undefined): boolean { - if (!spec) { - return false; - } - // A spec still pointing at the deleted `@voidzero-dev/vite-plus-test` wrapper - // is stale, NOT satisfied: this release ships upstream vitest directly, so the - // wrapper must be rewritten/pruned to the bundled vitest rather than accepted - // (otherwise `detectVitePlusBootstrapPending` skips writing the new - // `vitest: VITEST_VERSION` and the override keeps installing the dead wrapper). - if (isLegacyWrapperSpec(spec)) { - return false; - } - if (spec === VITE_PLUS_OVERRIDE_PACKAGES[dependencyName]) { - return true; - } - return false; -} - -function overrideSpecSatisfiesVitePlus( - dependencyName: string, - spec: string | undefined, - catalogDependencyResolver?: CatalogDependencyResolver, -): boolean { - if (!spec) { - return false; - } - if (isSemanticVitePlusOverrideSpec(dependencyName, spec)) { - return true; - } - if (!spec.startsWith('catalog:')) { - return false; - } - return isSemanticVitePlusOverrideSpec( - dependencyName, - catalogDependencyResolver?.(spec, dependencyName), - ); -} - -function overridesSatisfyVitePlus( - overrides: Record | undefined, - usesVitest: boolean, - catalogDependencyResolver?: CatalogDependencyResolver, -): boolean { - // Common case: a lingering managed `vitest` override is NOT satisfied — it - // must be removed, so the bootstrap stays pending until it is. - if (!usesVitest && VITEST_IS_MANAGED_OVERRIDE && typeof overrides?.vitest === 'string') { - return false; - } - return Object.keys(managedOverridePackages(usesVitest)).every((dependencyName) => - overrideSpecSatisfiesVitePlus( - dependencyName, - overrides?.[dependencyName], - catalogDependencyResolver, - ), - ); -} - -function hasPackageManagerPin(pkg: BootstrapPackageJson): boolean { - return Boolean(pkg.packageManager || pkg.devEngines?.packageManager); -} - -function pinnedPackageManagerVersion(pkg: BootstrapPackageJson): string | undefined { - if (typeof pkg.packageManager === 'string') { - const separator = pkg.packageManager.indexOf('@'); - if (separator !== -1) { - return pkg.packageManager.slice(separator + 1); - } - } - const devEngine = pkg.devEngines?.packageManager; - if ( - typeof devEngine === 'object' && - devEngine !== null && - !Array.isArray(devEngine) && - 'version' in devEngine && - typeof devEngine.version === 'string' - ) { - return devEngine.version; - } - return undefined; -} - -function vitePlusDependencyNeedsConcreteVersion(pkg: BootstrapPackageJson): boolean { - const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; - return dependencyGroups.some( - (dependencies) => dependencies?.[VITE_PLUS_NAME]?.startsWith('catalog:') ?? false, - ); -} - -function catalogVitePlusDependencyPending( - pkg: BootstrapPackageJson, - catalogDependencyResolver: CatalogDependencyResolver | undefined, -): boolean { - const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; - return dependencyGroups.some((dependencies) => { - const spec = dependencies?.[VITE_PLUS_NAME]; - if (!spec?.startsWith('catalog:')) { - return false; - } - return catalogDependencyResolver?.(spec, VITE_PLUS_NAME) !== VITE_PLUS_VERSION; - }); -} - -function pnpmPeerDependencyRulesSatisfyVitePlus( - peerDependencyRules: - | { allowAny?: string[]; allowedVersions?: Record } - | undefined, - usesVitest: boolean, -): boolean { - const allowAny = new Set(peerDependencyRules?.allowAny ?? []); - const allowedVersions = peerDependencyRules?.allowedVersions ?? {}; - // Common case: a lingering managed `vitest` peer rule is NOT satisfied. - if ( - !usesVitest && - VITEST_IS_MANAGED_OVERRIDE && - (allowAny.has('vitest') || allowedVersions.vitest !== undefined) - ) { - return false; - } - const overrideKeys = Object.keys(managedOverridePackages(usesVitest)); - return overrideKeys.every((key) => allowAny.has(key) && allowedVersions[key] === '*'); -} - -function npmVitePlusManagedDependenciesPending( - pkg: BootstrapPackageJson, - usesVitest: boolean, -): boolean { - const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; - // Common case: a lingering managed `vitest` install dep is pending removal. - if ( - !usesVitest && - VITEST_IS_MANAGED_OVERRIDE && - dependencyGroups.some((dependencies) => dependencies?.vitest !== undefined) - ) { - return true; - } - return Object.keys(managedOverridePackages(usesVitest)).some((dependencyName) => - dependencyGroups.some( - (dependencies) => - dependencies?.[dependencyName] !== undefined && - !overrideSpecSatisfiesVitePlus(dependencyName, dependencies[dependencyName]), - ), - ); -} - -function readPnpmWorkspaceCatalogDependencyResolver( - projectPath: string, -): CatalogDependencyResolver | undefined { - 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); -} - -function readPnpmWorkspaceOverrides(projectPath: string): Record | undefined { - const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); - if (!fs.existsSync(pnpmWorkspaceYamlPath)) { - return undefined; - } - const doc = readYamlFile(pnpmWorkspaceYamlPath) as { overrides?: Record } | null; - return doc?.overrides; -} - -function readPnpmWorkspacePeerDependencyRules( - projectPath: string, -): { allowAny?: string[]; allowedVersions?: Record } | undefined { - const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); - if (!fs.existsSync(pnpmWorkspaceYamlPath)) { - return undefined; - } - const doc = readYamlFile(pnpmWorkspaceYamlPath) as { - peerDependencyRules?: { allowAny?: string[]; allowedVersions?: Record }; - } | null; - return doc?.peerDependencyRules; -} - -function forceOverrideUsesExoticPnpmSpec(): boolean { - if (!isForceOverrideMode()) { - return false; - } - return [VITE_PLUS_VERSION, ...Object.values(VITE_PLUS_OVERRIDE_PACKAGES)].some((spec) => - /^(?:file|https?):/.test(spec), - ); -} - -function pnpmWorkspaceExoticSubdepsSettingSatisfied(projectPath: string): boolean { - if (!forceOverrideUsesExoticPnpmSpec()) { - return true; - } - const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); - if (!fs.existsSync(pnpmWorkspaceYamlPath)) { - return false; - } - const doc = readYamlFile(pnpmWorkspaceYamlPath) as { blockExoticSubdeps?: boolean } | null; - return doc?.blockExoticSubdeps === false; -} - -function ensurePnpmExoticSubdepsSetting(doc: YamlDocument): boolean { - if (!forceOverrideUsesExoticPnpmSpec() || doc.get('blockExoticSubdeps') === false) { - return false; - } - doc.set('blockExoticSubdeps', false); - return true; -} - -function ensurePnpmWorkspaceExoticSubdepsSetting(projectPath: string): boolean { - if (!forceOverrideUsesExoticPnpmSpec()) { - return false; - } - const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); - if (!fs.existsSync(pnpmWorkspaceYamlPath)) { - fs.writeFileSync(pnpmWorkspaceYamlPath, ''); - } - let changed = false; - editYamlFile(pnpmWorkspaceYamlPath, (doc) => { - changed = ensurePnpmExoticSubdepsSetting(doc); - }); - return changed; -} - -function yarnrcSatisfiesVitePlus(projectPath: string, usesVitest: boolean): boolean { - const yarnrcYmlPath = path.join(projectPath, '.yarnrc.yml'); - if (!fs.existsSync(yarnrcYmlPath)) { - return false; - } - const doc = readYamlFile(yarnrcYmlPath) as { - nodeLinker?: string; - catalog?: Record; - catalogs?: Record>; - } | null; - const resolver = createCatalogDependencyResolverFromCatalogs(doc?.catalog, doc?.catalogs); - const catalogName = resolver.preferredCatalogSpec.slice('catalog:'.length); - const managedCatalog = - catalogName && catalogName !== 'default' - ? doc?.catalogs?.[catalogName] - : (doc?.catalog ?? doc?.catalogs?.default); - return ( - !!doc && - Object.hasOwn(doc, 'nodeLinker') && - overridesSatisfyVitePlus(managedCatalog, usesVitest) && - (VITE_PLUS_VERSION.startsWith('file:') || - resolver(resolver.preferredCatalogSpec, VITE_PLUS_NAME) === VITE_PLUS_VERSION) - ); -} - -function ensurePnpmWorkspacePackages(projectPath: string, workspacePatterns: string[]): boolean { - if (workspacePatterns.length === 0) { - return false; - } - const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); - let changed = false; - editYamlFile(pnpmWorkspaceYamlPath, (doc) => { - if (doc.has('packages')) { - return; - } - const packages = new YAMLSeq>(); - for (const pattern of workspacePatterns) { - packages.add(scalarString(pattern)); - } - doc.set('packages', packages); - changed = true; - }); - return changed; -} - -function readBunCatalogDependencyResolver(pkg: { - workspaces?: NpmWorkspaces; - catalog?: Record; - catalogs?: Record>; -}): CatalogDependencyResolver { - const workspacesObj = pkg.workspaces && !Array.isArray(pkg.workspaces) ? pkg.workspaces : {}; - const fromWorkspaces = createCatalogDependencyResolverFromCatalogs( - workspacesObj.catalog, - workspacesObj.catalogs, - ); - const fromPkg = createCatalogDependencyResolverFromCatalogs(pkg.catalog, pkg.catalogs); - const resolver = (catalogSpec: string, dependencyName: string) => - fromWorkspaces(catalogSpec, dependencyName) ?? fromPkg(catalogSpec, dependencyName); - return Object.assign(resolver, { - preferredCatalogSpec: - workspacesObj.catalog || workspacesObj.catalogs - ? fromWorkspaces.preferredCatalogSpec - : fromPkg.preferredCatalogSpec, - }); -} - -function getAlignedVitestEcosystemDependencySpec( - current: string, - dependencyName: string, - dependencyField: PackageJsonDependencyField, - packageManager: PackageManager, - supportCatalog: boolean, - catalogDependencyResolver?: CatalogDependencyResolver, -): string { - const catalogSpec = current.startsWith('catalog:') ? current : 'catalog:'; - const catalogSupported = - supportCatalog && catalogDependencyResolver?.(catalogSpec, dependencyName) !== undefined; - return getCatalogDependencySpec(current, VITEST_VERSION, catalogSupported, { - dependencyField, - dependencyName, - packageManager, - catalogDependencyResolver, - preferredCatalogSpec: catalogDependencyResolver?.preferredCatalogSpec, - }); -} - -// Align every declared official `@vitest/*` package with the bundled Vitest. -// Prefer an existing default or named catalog entry when the package manager -// supports catalogs; otherwise use the concrete bundled version. Returns true -// if any package.json spec changed. Catalog values are reconciled separately by -// the package-manager config writers above. -function alignVitestEcosystemPackages( - pkg: BootstrapPackageJson, - packageManager: PackageManager, - supportCatalog: boolean, - catalogDependencyResolver?: CatalogDependencyResolver, -): boolean { - if (!VITEST_IS_MANAGED_OVERRIDE) { - return false; - } - const dependencyGroups: Array<{ - dependencyField: PackageJsonDependencyField; - dependencies: Record | undefined; - }> = [ - { dependencyField: 'devDependencies', dependencies: pkg.devDependencies }, - { dependencyField: 'dependencies', dependencies: pkg.dependencies }, - { dependencyField: 'optionalDependencies', dependencies: pkg.optionalDependencies }, - ]; - let changed = false; - for (const { dependencyField, dependencies } of dependencyGroups) { - if (!dependencies) { - continue; - } - for (const name of Object.keys(dependencies)) { - if (!isAlignableVitestEcosystemPackage(name)) { - continue; - } - const aligned = getAlignedVitestEcosystemDependencySpec( - dependencies[name], - name, - dependencyField, - packageManager, - supportCatalog, - catalogDependencyResolver, - ); - if (dependencies[name] !== aligned) { - dependencies[name] = aligned; - changed = true; - } - } - } - return changed; -} - -function vitestEcosystemCatalogReferencesPending( - pkg: BootstrapPackageJson, - catalogDependencyResolver?: CatalogDependencyResolver, -): boolean { - if (!VITEST_IS_MANAGED_OVERRIDE || !catalogDependencyResolver) { - return false; - } - for (const dependencies of [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]) { - if (!dependencies) { - continue; - } - for (const [name, spec] of Object.entries(dependencies)) { - if ( - isAlignableVitestEcosystemPackage(name) && - spec.startsWith('catalog:') && - catalogDependencyResolver(spec, name) !== VITEST_VERSION - ) { - return true; - } - } - } - return false; -} - -/** - * Reconcile the install dependencies in one package during an existing-Vite+ - * bootstrap. Package-manager overrides are intentionally handled separately at - * the workspace root; this function owns only dependency fields so it can also - * be applied to every workspace package. - */ -function reconcileVitePlusBootstrapPackage( - projectPath: string, - pkg: BootstrapPackageJson, - vitePlusVersion: string, - packageManager: PackageManager, - supportCatalog: boolean, - ensureVitePlus: boolean, - catalogDependencyResolver?: CatalogDependencyResolver, -): boolean { - const before = JSON.stringify(pkg); - const usesVitest = projectUsesVitestDirectly(projectPath, pkg, undefined, true); - ensureVitePlusDependencySpecs(pkg, vitePlusVersion, ensureVitePlus); - - const installGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; - const dependencyGroups = [...installGroups, pkg.peerDependencies]; - - // Remove every dependency alias to the deleted wrapper before deciding - // whether this package needs a direct upstream vitest peer provider. - for (const dependencies of dependencyGroups) { - pruneLegacyWrapperAliases(dependencies); - } - - // Normalize direct Vite install entries as well as the shared override. Keep - // named catalog references intact; plain/behind aliases move to the active - // default catalog or the current core alias. - for (const dependencies of installGroups) { - if (dependencies?.vite !== undefined) { - dependencies.vite = getCatalogDependencySpec( - dependencies.vite, - VITE_PLUS_OVERRIDE_PACKAGES.vite, - supportCatalog, - { preferredCatalogSpec: catalogDependencyResolver?.preferredCatalogSpec }, - ); - } - } - - alignVitestEcosystemPackages(pkg, packageManager, supportCatalog, catalogDependencyResolver); - normalizeVitestPeerCatalogSpec(pkg.peerDependencies, catalogDependencyResolver); - - const providerSourceModes = collectProviderSourceModes(projectPath); - let usesAnyOptInProvider = false; - for (const provider of OPT_IN_BROWSER_PROVIDERS) { - const usesProvider = - providerSourceModes[provider] || - dependencyGroups.some((dependencies) => dependencies?.[provider] !== undefined); - if (!usesProvider) { - continue; - } - usesAnyOptInProvider = true; - const installGroupEntry = [ - { dependencyField: 'devDependencies' as const, dependencies: pkg.devDependencies }, - { dependencyField: 'dependencies' as const, dependencies: pkg.dependencies }, - { - dependencyField: 'optionalDependencies' as const, - dependencies: pkg.optionalDependencies, - }, - ].find(({ dependencies }) => dependencies?.[provider] !== undefined); - if (installGroupEntry?.dependencies) { - if (VITEST_IS_MANAGED_OVERRIDE) { - installGroupEntry.dependencies[provider] = getAlignedVitestEcosystemDependencySpec( - installGroupEntry.dependencies[provider], - provider, - installGroupEntry.dependencyField, - packageManager, - supportCatalog, - catalogDependencyResolver, - ); - } - } else { - pkg.devDependencies ??= {}; - pkg.devDependencies[provider] = getCatalogDependencySpec( - undefined, - VITEST_VERSION, - supportCatalog && packageManager !== PackageManager.bun, - { preferredCatalogSpec: catalogDependencyResolver?.preferredCatalogSpec }, - ); - } - const frameworkPeer = BROWSER_PROVIDER_PEER_DEPS[provider]; - const frameworkPresent = dependencyGroups.some( - (dependencies) => dependencies?.[frameworkPeer] !== undefined, - ); - if (frameworkPeer && !frameworkPresent) { - pkg.devDependencies ??= {}; - pkg.devDependencies[frameworkPeer] = '*'; - } - } - - // The base browser runtime and preview provider are bundled by vite-plus; - // only the heavy framework-specific providers remain project-owned. - for (const bundledPackage of REMOVE_PACKAGES.filter((name) => name.startsWith('@vitest/'))) { - for (const dependencies of installGroups) { - if (dependencies?.[bundledPackage] !== undefined) { - delete dependencies[bundledPackage]; - } - } - } - - if (usesAnyOptInProvider && packageManager === PackageManager.npm) { - const viteAlreadyDirect = installGroups.some( - (dependencies) => dependencies?.vite !== undefined, - ); - if (!viteAlreadyDirect) { - pkg.devDependencies ??= {}; - pkg.devDependencies.vite = VITE_PLUS_OVERRIDE_PACKAGES.vite; - } - } - - if (packageManager === PackageManager.bun) { - // Bun resolves vitest's `vite ^6 || ^7 || ^8` peer before applying the - // override that redirects `vite` to vite-plus-core, and aborts with - // "vite@... failed to resolve" unless `vite` is a direct dependency. Mirror - // the full-migration path (rewriteStandaloneProject) so the idempotent - // bootstrap path also produces an installable bun project. The override set - // above still points the direct dep at vite-plus-core. - const viteAlreadyDirect = installGroups.some( - (dependencies) => dependencies?.vite !== undefined, - ); - if (!viteAlreadyDirect) { - pkg.devDependencies ??= {}; - pkg.devDependencies.vite = VITE_PLUS_OVERRIDE_PACKAGES.vite; - } - } - - if (usesVitest) { - // A direct @vitest/*/integration dependency with a required vitest peer - // cannot use the copy nested under its sibling `vite-plus` dependency under - // Yarn PnP or strict pnpm. Provide the peer from this package and keep it on - // the same exact version as the Vite+ runner. - const existingGroup = installGroups.find((dependencies) => dependencies?.vitest !== undefined); - if (existingGroup) { - if (VITEST_IS_MANAGED_OVERRIDE) { - existingGroup.vitest = getCatalogDependencySpec( - existingGroup.vitest, - VITEST_VERSION, - supportCatalog, - { preferredCatalogSpec: catalogDependencyResolver?.preferredCatalogSpec }, - ); - } - } else { - pkg.devDependencies ??= {}; - pkg.devDependencies.vitest = getCatalogDependencySpec( - undefined, - VITEST_VERSION, - supportCatalog, - { preferredCatalogSpec: catalogDependencyResolver?.preferredCatalogSpec }, - ); - } - } else { - // Bare vitest is not itself a usage signal: older migrations injected it - // into every project. Remove that stale install pin when no remaining peer, - // source import, or browser-mode signal needs it. - for (const dependencies of installGroups) { - removeManagedVitestEntry(dependencies); - } - } - - return before !== JSON.stringify(pkg); -} - -function bootstrapProjectPaths( - rootDir: string, - packages: WorkspacePackage[] | undefined, -): string[] { - return [rootDir, ...(packages ?? []).map((pkg) => path.join(rootDir, pkg.path))]; -} - -function collectVitestEcosystemInstallDependencyNames( - rootDir: string, - packages?: WorkspacePackage[], -): Set { - const names = new Set(); - for (const packagePath of bootstrapProjectPaths(rootDir, packages)) { - const packageJsonPath = path.join(packagePath, 'package.json'); - if (!fs.existsSync(packageJsonPath)) { - continue; - } - const pkg = readJsonFile(packageJsonPath) as BootstrapPackageJson; - for (const dependencies of [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]) { - for (const name of Object.keys(dependencies ?? {})) { - if (isAlignableVitestEcosystemPackage(name)) { - names.add(name); - } - } - } - } - return names; -} - -function collectInjectedProviderNames( - rootDir: string, - packages?: WorkspacePackage[], - // Optional precomputed provider source-scan results keyed by absolute package - // path. Lets a caller that already scanned a path reuse the result instead of - // re-traversing the source tree; unknown paths fall back to a fresh scan. - precomputedSourceModes?: ReadonlyMap>, -): Set { - const names = new Set(); - for (const packagePath of bootstrapProjectPaths(rootDir, packages)) { - const packageJsonPath = path.join(packagePath, 'package.json'); - if (!fs.existsSync(packageJsonPath)) { - continue; - } - const pkg = readJsonFile(packageJsonPath) as BootstrapPackageJson; - const sourceModes = - precomputedSourceModes?.get(packagePath) ?? collectProviderSourceModes(packagePath); - const installGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; - const dependencyGroups = [...installGroups, pkg.peerDependencies]; - for (const provider of OPT_IN_BROWSER_PROVIDERS) { - const used = - sourceModes[provider] || - dependencyGroups.some((dependencies) => dependencies?.[provider] !== undefined); - const installed = installGroups.some( - (dependencies) => dependencies?.[provider] !== undefined, - ); - if (used && !installed) { - names.add(provider); - } - } - } - return names; -} - -function workspaceVitestEcosystemCatalogReferencesPending( - rootDir: string, - packages: WorkspacePackage[] | undefined, - catalogDependencyResolver?: CatalogDependencyResolver, -): boolean { - return bootstrapProjectPaths(rootDir, packages).some((packagePath) => { - const packageJsonPath = path.join(packagePath, 'package.json'); - if (!fs.existsSync(packageJsonPath)) { - return false; - } - return vitestEcosystemCatalogReferencesPending( - readJsonFile(packageJsonPath) as BootstrapPackageJson, - catalogDependencyResolver, - ); - }); -} - -export function detectVitePlusBootstrapPending( - projectPath: string, - packageManager: PackageManager | undefined, - packages?: WorkspacePackage[], - packageManagerVersion?: string, -): boolean { - const packageJsonPath = path.join(projectPath, 'package.json'); - if (!fs.existsSync(packageJsonPath)) { - return false; - } - const pkg = readJsonFile(packageJsonPath) as BootstrapPackageJson & { - workspaces?: NpmWorkspaces; - catalog?: Record; - catalogs?: Record>; - }; - - // vite-plus counts as installed when it's a direct dependency/devDependency, - // so a project that declares it in `dependencies` isn't reported as pending a - // (duplicate) devDependencies entry. - if (!hasDirectVitePlusInstallEntry(pkg) || !hasPackageManagerPin(pkg)) { - return true; - } - - if (packageManager === undefined) { - return true; - } - - const pnpmVersion = packageManagerVersion ?? pinnedPackageManagerVersion(pkg) ?? ''; - const usePnpmWorkspaceYaml = - packageManager === PackageManager.pnpm && pnpmSupportsWorkspaceSettings(pnpmVersion); - if (usePnpmWorkspaceYaml && pnpmPackageJsonSettingsPending(pkg)) { - return true; - } - const supportCatalog = - !VITE_PLUS_VERSION.startsWith('file:') && - (usePnpmWorkspaceYaml || - packageManager === PackageManager.yarn || - packageManager === PackageManager.bun); - const catalogDependencyResolver = createCatalogDependencyResolver(projectPath, packageManager); - const canonicalVitePlusSpec = supportCatalog - ? (catalogDependencyResolver?.preferredCatalogSpec ?? 'catalog:') - : VITE_PLUS_VERSION; - if ( - workspaceVitestEcosystemCatalogReferencesPending( - projectPath, - packages, - catalogDependencyResolver, - ) - ) { - return true; - } - for (const [index, packagePath] of bootstrapProjectPaths(projectPath, packages).entries()) { - const childPackageJsonPath = path.join(packagePath, 'package.json'); - if (!fs.existsSync(childPackageJsonPath)) { - continue; - } - const childPkg = readJsonFile(childPackageJsonPath) as BootstrapPackageJson; - const candidate = JSON.parse(JSON.stringify(childPkg)) as BootstrapPackageJson; - if ( - reconcileVitePlusBootstrapPackage( - packagePath, - candidate, - canonicalVitePlusSpec, - packageManager, - supportCatalog, - index === 0, - catalogDependencyResolver, - ) - ) { - return true; - } - } - - // Shared override/catalog sinks must keep vitest managed when any package in - // the workspace needs it. The direct dependency itself is localized above. - const usesVitest = workspaceUsesVitestDirectly(projectPath, packages, true); - - if (packageManager === PackageManager.yarn) { - return ( - !overridesSatisfyVitePlus(pkg.resolutions, usesVitest) || - !yarnrcSatisfiesVitePlus(projectPath, usesVitest) - ); - } - if (packageManager === PackageManager.npm) { - return ( - vitePlusDependencyNeedsConcreteVersion(pkg) || - !overridesSatisfyVitePlus(pkg.overrides, usesVitest) || - npmVitePlusManagedDependenciesPending(pkg, usesVitest) - ); - } - if (packageManager === PackageManager.bun) { - return !overridesSatisfyVitePlus( - pkg.overrides, - usesVitest, - readBunCatalogDependencyResolver(pkg), - ); - } - if (packageManager === PackageManager.pnpm) { - if (!pnpmWorkspaceExoticSubdepsSettingSatisfied(projectPath)) { - return true; - } - if (!usePnpmWorkspaceYaml) { - return ( - vitePlusDependencyNeedsConcreteVersion(pkg) || - !overridesSatisfyVitePlus(pkg.pnpm?.overrides, usesVitest) || - !pnpmPeerDependencyRulesSatisfyVitePlus(pkg.pnpm?.peerDependencyRules, usesVitest) - ); - } - const resolver = readPnpmWorkspaceCatalogDependencyResolver(projectPath); - return ( - catalogVitePlusDependencyPending(pkg, resolver) || - !overridesSatisfyVitePlus(readPnpmWorkspaceOverrides(projectPath), usesVitest, resolver) || - !pnpmPeerDependencyRulesSatisfyVitePlus( - readPnpmWorkspacePeerDependencyRules(projectPath), - usesVitest, - ) - ); - } - - return false; -} - -// vite-plus counts as already installed when it lives directly in -// `dependencies` OR `devDependencies`. `optionalDependencies` is deliberately -// excluded: an optional-only entry may be skipped at install time, so the -// package should still receive a guaranteed `devDependencies` entry. -function hasDirectVitePlusInstallEntry(pkg: { - dependencies?: Record; - devDependencies?: Record; -}): boolean { - return ( - pkg.dependencies?.[VITE_PLUS_NAME] !== undefined || - pkg.devDependencies?.[VITE_PLUS_NAME] !== undefined - ); -} - -function ensureVitePlusDependencySpecs( - pkg: BootstrapPackageJson, - version: string, - ensurePresent = true, -): boolean { - let changed = false; - // Re-pin a pre-existing vite-plus spec to the migrating toolchain target so - // the lockfile moves off an old resolution (e.g. `^0.1.24`). Mirrors the - // full-migration rule at `shouldNormalizeExistingVitePlus`/`canonicalVitePlusSpec`: - // only vanilla version ranges are rewritten; deliberate protocol pins - // (workspace:, link:, file:, npm:, github:, git, http) are preserved. - const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; - for (const dependencies of dependencyGroups) { - if (dependencies === undefined) { - continue; - } - const spec = dependencies[VITE_PLUS_NAME]; - if (spec === undefined || spec === version) { - continue; - } - // Catalog writers update every existing managed entry in place. Keep a - // package's deliberate named/default reference instead of collapsing all - // packages onto the workspace's preferred catalog, including pkg.pr.new - // force-override runs. - if (version.startsWith('catalog:') && spec.startsWith('catalog:')) { - continue; - } - // Concrete target (e.g. `latest`): also rewrite an existing `catalog:` - // pin onto the concrete version — `isProtocolPinnedSpec` matches - // `catalog:`, so handle it explicitly before the generic plain-range case. - if (!version.startsWith('catalog:') && spec.startsWith('catalog:')) { - dependencies[VITE_PLUS_NAME] = version; - changed = true; - continue; - } - // Plain (non-protocol-pinned) range like `^0.1.24` → rewrite to the target - // (`catalog:` for catalog-supporting projects, otherwise the concrete - // version). Already-`catalog:` / other protocol pins are left untouched, - // except in force-override mode where ecosystem/pkg.pr.new validation must - // replace every prior target with the requested artifact. - if (isForceOverrideMode() || !isProtocolPinnedSpec(spec)) { - dependencies[VITE_PLUS_NAME] = version; - changed = true; - } - } - if (hasDirectVitePlusInstallEntry(pkg) || !ensurePresent) { - return changed; - } - pkg.devDependencies = { - ...pkg.devDependencies, - [VITE_PLUS_NAME]: version, - }; - return true; -} - -function ensureOverrideEntries( - overrides: Record | undefined, - usesVitest: boolean, - catalogDependencyResolver?: CatalogDependencyResolver, -): { overrides: Record; changed: boolean } { - const next = { ...overrides }; - let changed = false; - // Common case: drop a lingering managed `vitest` override. - if (!usesVitest && removeManagedVitestEntry(next)) { - changed = true; - } - for (const [dependencyName, overrideSpec] of Object.entries( - managedOverridePackages(usesVitest), - )) { - if ( - !overrideSpecSatisfiesVitePlus( - dependencyName, - next[dependencyName], - catalogDependencyResolver, - ) - ) { - next[dependencyName] = overrideSpec; - changed = true; - } - } - return { overrides: next, changed }; -} - -function ensurePnpmPeerDependencyRules(pkg: BootstrapPackageJson, usesVitest: boolean): boolean { - const overrideKeys = Object.keys(managedOverridePackages(usesVitest)); - pkg.pnpm ??= {}; - // Common case: drop a lingering managed `vitest` peer rule from the source - // shape before re-deriving the managed rules. - const seed = { ...pkg.pnpm.peerDependencyRules } as { - allowAny?: string[]; - allowedVersions?: Record; - }; - if (!usesVitest && VITEST_IS_MANAGED_OVERRIDE) { - if (Array.isArray(seed.allowAny)) { - seed.allowAny = seed.allowAny.filter((key) => key !== 'vitest'); - } - if (seed.allowedVersions) { - seed.allowedVersions = { ...seed.allowedVersions }; - delete seed.allowedVersions.vitest; - } - } - const peerDependencyRules = { - ...seed, - allowAny: [...new Set([...(seed.allowAny ?? []), ...overrideKeys])], - allowedVersions: { - ...seed.allowedVersions, - ...Object.fromEntries(overrideKeys.map((key) => [key, '*'])), - }, - }; - const changed = - JSON.stringify(pkg.pnpm.peerDependencyRules ?? {}) !== JSON.stringify(peerDependencyRules); - pkg.pnpm.peerDependencyRules = peerDependencyRules; - return changed; -} - -export function ensureVitePlusBootstrap( - workspaceInfo: WorkspaceInfo, - report?: MigrationReport, -): VitePlusBootstrapResult { - const projectPath = workspaceInfo.rootDir; - const packageJsonPath = path.join(projectPath, 'package.json'); - const result: VitePlusBootstrapResult = { - changed: false, - packageJson: false, - packageManagerConfig: false, - packageManagerField: false, - }; - if (!fs.existsSync(packageJsonPath)) { - return result; - } - - // Shared override/catalog sinks are workspace-wide, so keep vitest managed - // when any package needs it. Each package's direct vitest dependency is - // reconciled independently below. - const usesVitest = workspaceUsesVitestDirectly(projectPath, workspaceInfo.packages, true); - const pnpmMajorVersion = pnpmMajor(workspaceInfo.downloadPackageManager.version); - const shouldAllowBrowserBuilds = workspaceUsesWebdriverio(projectPath, workspaceInfo.packages); - const usePnpmWorkspaceYaml = - workspaceInfo.packageManager === PackageManager.pnpm && - pnpmSupportsWorkspaceSettings(workspaceInfo.downloadPackageManager.version); - const supportCatalog = - !VITE_PLUS_VERSION.startsWith('file:') && - (usePnpmWorkspaceYaml || - workspaceInfo.packageManager === PackageManager.yarn || - workspaceInfo.packageManager === PackageManager.bun); - const catalogDependencyResolver = createCatalogDependencyResolver( - projectPath, - workspaceInfo.packageManager, - ); - const canonicalVitePlusSpec = supportCatalog - ? (catalogDependencyResolver?.preferredCatalogSpec ?? 'catalog:') - : VITE_PLUS_VERSION; - const ecosystemCatalogReferencesPending = workspaceVitestEcosystemCatalogReferencesPending( - projectPath, - workspaceInfo.packages, - catalogDependencyResolver, - ); - const vitestEcosystemPackages = collectVitestEcosystemInstallDependencyNames( - projectPath, - workspaceInfo.packages, - ); - const providerCatalogAdditions = collectInjectedProviderNames( - projectPath, - workspaceInfo.packages, - ); - let movedPnpmSettings: Record | undefined; - - editJsonFile< - BootstrapPackageJson & { - workspaces?: NpmWorkspaces; - catalog?: Record; - catalogs?: Record>; - } - >(packageJsonPath, (pkg) => { - let packageJsonChanged = reconcileVitePlusBootstrapPackage( - projectPath, - pkg, - canonicalVitePlusSpec, - workspaceInfo.packageManager, - supportCatalog, - true, - catalogDependencyResolver, - ); - - if (workspaceInfo.packageManager === PackageManager.yarn) { - const ensured = ensureOverrideEntries(pkg.resolutions, usesVitest); - if (ensured.changed) { - pkg.resolutions = ensured.overrides; - packageJsonChanged = true; - } - } else if (workspaceInfo.packageManager === PackageManager.npm) { - const ensured = ensureOverrideEntries(pkg.overrides, usesVitest); - if (ensured.changed) { - pkg.overrides = ensured.overrides; - packageJsonChanged = true; - } - } else if (workspaceInfo.packageManager === PackageManager.bun) { - const ensured = ensureOverrideEntries( - pkg.overrides, - usesVitest, - readBunCatalogDependencyResolver(pkg), - ); - if (ensured.changed) { - pkg.overrides = ensured.overrides; - packageJsonChanged = true; - } - } else if (workspaceInfo.packageManager === PackageManager.pnpm && !usePnpmWorkspaceYaml) { - pkg.pnpm ??= {}; - const ensured = ensureOverrideEntries(pkg.pnpm.overrides, usesVitest); - if (ensured.changed) { - pkg.pnpm.overrides = ensured.overrides; - packageJsonChanged = true; - } - packageJsonChanged = ensurePnpmPeerDependencyRules(pkg, usesVitest) || packageJsonChanged; - if (pnpmMajorVersion !== undefined && pkg.pnpm) { - const beforePnpm = JSON.stringify(pkg.pnpm); - applyBuildAllowanceToPackageJsonPnpm(pkg.pnpm, pnpmMajorVersion, shouldAllowBrowserBuilds); - packageJsonChanged = beforePnpm !== JSON.stringify(pkg.pnpm) || packageJsonChanged; - } - } else if (workspaceInfo.packageManager === PackageManager.pnpm) { - const hadPnpmField = pkg.pnpm !== undefined; - movedPnpmSettings = takePnpmWorkspaceSettings(pkg); - packageJsonChanged = - movedPnpmSettings !== undefined || - (hadPnpmField && pkg.pnpm === undefined) || - packageJsonChanged; - } - - result.packageJson = packageJsonChanged; - return pkg; - }); - - // Existing Vite+ monorepos take this bootstrap path instead of the full - // migration, so reconcile every workspace manifest as well as the root. - for (const workspacePackage of workspaceInfo.packages) { - const packagePath = path.join(projectPath, workspacePackage.path); - const childPackageJsonPath = path.join(packagePath, 'package.json'); - if (!fs.existsSync(childPackageJsonPath)) { - continue; - } - let childChanged = false; - editJsonFile(childPackageJsonPath, (pkg) => { - childChanged = reconcileVitePlusBootstrapPackage( - packagePath, - pkg, - canonicalVitePlusSpec, - workspaceInfo.packageManager, - supportCatalog, - false, - catalogDependencyResolver, - ); - return childChanged ? pkg : undefined; - }); - result.packageJson = result.packageJson || childChanged; - } - - if (workspaceInfo.packageManager === PackageManager.pnpm) { - const pkg = readJsonFile(packageJsonPath) as BootstrapPackageJson; - if (usePnpmWorkspaceYaml) { - const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); - const before = fs.existsSync(pnpmWorkspaceYamlPath) - ? fs.readFileSync(pnpmWorkspaceYamlPath, 'utf-8') - : undefined; - migratePnpmSettingsToWorkspaceYaml(projectPath, movedPnpmSettings); - const catalogDependencyResolver = readPnpmWorkspaceCatalogDependencyResolver(projectPath); - if ( - movedPnpmSettings !== undefined || - result.packageJson || - ecosystemCatalogReferencesPending || - !pnpmWorkspaceExoticSubdepsSettingSatisfied(projectPath) || - catalogVitePlusDependencyPending(pkg, catalogDependencyResolver) || - !overridesSatisfyVitePlus( - readPnpmWorkspaceOverrides(projectPath), - usesVitest, - catalogDependencyResolver, - ) || - !pnpmPeerDependencyRulesSatisfyVitePlus( - readPnpmWorkspacePeerDependencyRules(projectPath), - usesVitest, - ) - ) { - rewritePnpmWorkspaceYaml( - projectPath, - pnpmMajorVersion, - shouldAllowBrowserBuilds, - usesVitest, - vitestEcosystemPackages, - true, - providerCatalogAdditions, - ); - } - if (fs.existsSync(pnpmWorkspaceYamlPath)) { - ensurePnpmWorkspacePackages(projectPath, workspaceInfo.workspacePatterns); - } - const after = fs.existsSync(pnpmWorkspaceYamlPath) - ? fs.readFileSync(pnpmWorkspaceYamlPath, 'utf-8') - : undefined; - result.packageManagerConfig = before !== after; - } else if (ensurePnpmWorkspaceExoticSubdepsSetting(projectPath)) { - ensurePnpmWorkspacePackages(projectPath, workspaceInfo.workspacePatterns); - result.packageManagerConfig = true; - } - } else if (workspaceInfo.packageManager === PackageManager.yarn) { - const yarnrcYmlPath = path.join(projectPath, '.yarnrc.yml'); - const before = fs.existsSync(yarnrcYmlPath) - ? fs.readFileSync(yarnrcYmlPath, 'utf-8') - : undefined; - rewriteYarnrcYml(projectPath, usesVitest, vitestEcosystemPackages, providerCatalogAdditions); - const after = fs.readFileSync(yarnrcYmlPath, 'utf-8'); - result.packageManagerConfig = before !== after; - } else if (workspaceInfo.packageManager === PackageManager.bun) { - const before = fs.readFileSync(packageJsonPath, 'utf-8'); - rewriteBunCatalog(projectPath, usesVitest, vitestEcosystemPackages); - const after = fs.readFileSync(packageJsonPath, 'utf-8'); - result.packageJson = result.packageJson || before !== after; - } - - const beforePackageManager = fs.readFileSync(packageJsonPath, 'utf-8'); - setPackageManager(projectPath, workspaceInfo.downloadPackageManager); - const afterPackageManager = fs.readFileSync(packageJsonPath, 'utf-8'); - result.packageManagerField = beforePackageManager !== afterPackageManager; - result.changed = result.packageJson || result.packageManagerConfig || result.packageManagerField; - if (result.changed && report) { - report.packageManagerBootstrapConfigured = true; - } - return result; -} - -// Specifier fragments that signal vitest browser mode. Matched as substrings -// against source (see `sourceTreeReferencesAny`), so subpath imports are -// covered too. Each indicates the package drives vitest's browser runner: -// - `@vitest/browser` upstream, pre-migration (incl. `/context`, -// `/client`, … subpaths) -// - `vite-plus/test/browser` migrated (re-run on an already-migrated -// project); also covers `…/browser/context` and -// the `…/browser/providers/*` provider forms -// - `vite-plus/test/{client,context,locators,matchers,utils}` the published -// bare browser shims (`build.ts` -// `createBareBrowserShims`): each re-exports -// `@vitest/browser/` but DROPS the `browser` -// segment, so they carry no `browser` substring. -// The import rewriter flattens -// `@vitest/browser/{client,locators,matchers, -// utils}` to four of these in already-migrated -// source; `vite-plus/test/context` is reachable -// as the published bare export (the rewriter -// instead routes `@vitest/browser/context` to -// `vite-plus/test/browser/context`, already -// covered above). All five are browser-only -// re-exports, so they never collide with a -// non-browser vitest export. -// - `vite-plus/test/plugins/browser` prefix for the generated plugin shims -// (`build.ts` `PLUGIN_SHIM_ENTRIES`: -// `plugins/browser`, `plugins/browser-context`, -// `plugins/browser-client`, `plugins/browser- -// locators`, `plugins/browser-playwright`, -// `plugins/browser-preview`, `plugins/browser- -// webdriverio`), which re-export `@vitest/browser*` -// under a `/plugins/` segment that the -// `vite-plus/test/browser` hint does not match. -// One prefix covers the whole family. -// - `vite-plus/test/internal/browser` the published internal browser shim -// (`./test/internal/browser`, re-exports -// `vitest/internal/browser`) — also a `/browser` -// surface with no `vite-plus/test/browser` -// substring. -// Without a matching hint a package importing only one of these published -// browser surfaces (with no `@vitest/browser*` dep) would miss browser mode and -// skip pinning the direct `vitest` the browser optimizer needs resolvable from -// the package root under pnpm strict / Yarn PnP. This set is verified complete -// against every browser-surface `./test/*` export in package.json (those that -// re-export `@vitest/browser*` or `vitest/internal/browser`). -const VITEST_BROWSER_SPECIFIER_HINTS = [ - // Before v0.2, projects commonly aliased `vitest` to - // `@voidzero-dev/vite-plus-test`, whose browser exports used these paths. - 'vitest/browser', - 'vitest/plugins/browser', - '@vitest/browser', - 'vite-plus/test/browser', - 'vite-plus/test/plugins/browser', - 'vite-plus/test/internal/browser', - 'vite-plus/test/client', - 'vite-plus/test/context', - 'vite-plus/test/locators', - 'vite-plus/test/matchers', - 'vite-plus/test/utils', -] as const; - -// Specifier fragments that signal the WEBDRIVERIO provider specifically. Each -// is a prefix, matched as a substring, so subpath imports (`/context`, -// `/provider`, …) are covered too: -// - `vitest/browser-webdriverio`, `vitest/browser/providers/webdriverio`, and -// `vitest/plugins/browser-webdriverio` are legacy -// `@voidzero-dev/vite-plus-test` exports reached through the `vitest` alias -// - `@vitest/browser-webdriverio` pre-migration (incl. `/provider`, -// `/context` subpaths) -// - `vite-plus/test/browser-webdriverio` migrated (re-run); covers -// `…/context` -// - `vite-plus/test/browser/providers/webdriverio` migrated provider-subpath -// form — the import rewriter maps -// `@vitest/browser-webdriverio/provider` -// here, so an already-migrated -// project can contain it. Without -// this hint a re-run would skip the -// provider injection and the import -// would break under pnpm strict / -// Yarn PnP once the provider is no -// longer a vite-plus runtime dep. -// - `vite-plus/test/plugins/browser-webdriverio` generated plugin shim that -// re-exports `@vitest/browser- -// webdriverio` wholesale; importing -// it pulls in the (now opt-in) -// provider, so it signals usage too. -const WEBDRIVERIO_PROVIDER_SPECIFIER_HINTS = [ - 'vitest/browser-webdriverio', - 'vitest/browser/providers/webdriverio', - 'vitest/plugins/browser-webdriverio', - '@vitest/browser-webdriverio', - 'vite-plus/test/browser-webdriverio', - 'vite-plus/test/browser/providers/webdriverio', - 'vite-plus/test/plugins/browser-webdriverio', -] as const; - -// Specifier fragments that signal the PLAYWRIGHT provider specifically — the -// playwright analogue of WEBDRIVERIO_PROVIDER_SPECIFIER_HINTS (same prefix / -// substring matching for `/provider`, `/context` subpaths). Playwright is opt-in -// just like webdriverio: vite-plus no longer bundles `@vitest/browser-playwright` -// at runtime, so a source-only user (e.g. `vite.config.ts` importing the -// provider via a `vite-plus/test/browser-playwright` shim with no declared dep) -// must still have the provider kept/injected for the rewritten import to resolve. -const PLAYWRIGHT_PROVIDER_SPECIFIER_HINTS = [ - // Legacy `@voidzero-dev/vite-plus-test` exports reached through the `vitest` - // alias. These must be detected before rewriteAllImports changes the prefix. - 'vitest/browser-playwright', - 'vitest/browser/providers/playwright', - 'vitest/plugins/browser-playwright', - '@vitest/browser-playwright', - 'vite-plus/test/browser-playwright', - 'vite-plus/test/browser/providers/playwright', - 'vite-plus/test/plugins/browser-playwright', -] as const; - -// Per-provider source-scan hint lists, used to build the `providerSourceModes` -// map passed to `rewritePackageJson`. -const BROWSER_PROVIDER_SPECIFIER_HINTS: Record = { - [WEBDRIVERIO_PROVIDER]: WEBDRIVERIO_PROVIDER_SPECIFIER_HINTS, - [PLAYWRIGHT_PROVIDER]: PLAYWRIGHT_PROVIDER_SPECIFIER_HINTS, -}; - -// TypeScript/JavaScript source extensions scanned for browser-mode hints. -const VITEST_SCAN_EXTENSIONS = new Set([ - '.ts', - '.mts', - '.cts', - '.tsx', - '.js', - '.mjs', - '.cjs', - '.jsx', -]); - -// Directories never worth scanning for browser-mode hints — generated output, -// installed deps, VCS metadata. Skipped at every recursion level. -const VITEST_SCAN_SKIP_DIRS = new Set([ - 'node_modules', - 'dist', - 'build', - 'out', - 'coverage', - '.git', - '.next', - '.nuxt', - '.svelte-kit', - '.vite', - '.cache', -]); - -/** - * Detect whether a package uses vitest's browser mode. - * - * Upstream `@vitest/browser` injects `optimizeDeps.include` entries of the form - * `vitest > expect-type` (and `vitest > @vitest/snapshot > magic-string`, - * `vitest > @vitest/expect > chai`). Vite resolves the leading `vitest` segment - * from the Vite config root, so `vitest` MUST be resolvable as a package from - * the consuming package's directory. In a pnpm strict (non-hoisted) layout, - * `vitest` pulled in only transitively via `vite-plus` is NOT reachable from the - * package root — the optimizer then fails with `Failed to resolve dependency` - * and the browser test page hangs forever. - * - * When this returns true the migration adds `vitest` as a direct - * devDependency so it is hoisted next to the package and the optimizer chain - * resolves. The signal is any of the package's TS/JS files (config, workspace - * config under any name, or test file) referencing `@vitest/browser*` or - * `vite-plus/test/browser*`. The scan recurses through the package directory - * (skipping `node_modules`, build output, VCS metadata) so browser config in a - * non-standard filename or browser imports in test files are all caught. - * - * Recursion stops at nested `package.json` boundaries: a workspace sub-package - * is a separate package that the migration scans on its own pass, so the root - * package must not inherit a browser-mode signal from a sub-package. - */ -function sourceTreeMatches( - projectPath: string, - matchesContent: (content: string) => boolean, -): boolean { - const scanDir = (dir: string, isRoot: boolean): boolean => { - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return false; - } - // A nested package.json marks a separate workspace package — it is migrated - // (and scanned) on its own pass, so don't let its files leak into this one. - if (!isRoot && entries.some((e) => e.isFile() && e.name === 'package.json')) { - return false; - } - for (const entry of entries) { - const entryPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - if (VITEST_SCAN_SKIP_DIRS.has(entry.name)) { - continue; - } - if (scanDir(entryPath, false)) { - return true; - } - } else if (entry.isFile() && VITEST_SCAN_EXTENSIONS.has(path.extname(entry.name))) { - try { - if (matchesContent(fs.readFileSync(entryPath, 'utf8'))) { - return true; - } - } catch { - // Unreadable file — ignore and keep scanning. - } - } - } - return false; - }; - - return scanDir(projectPath, true); -} - -function sourceTreeReferencesAny(projectPath: string, hints: readonly string[]): boolean { - return sourceTreeMatches(projectPath, (content) => hints.some((hint) => content.includes(hint))); -} - -function findPackageTsconfigFiles(projectPath: string): string[] { - const files: string[] = []; - const scanDir = (dir: string, isRoot: boolean): void => { - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return; - } - if (!isRoot && entries.some((entry) => entry.isFile() && entry.name === 'package.json')) { - return; - } - for (const entry of entries) { - const entryPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - if (!VITEST_SCAN_SKIP_DIRS.has(entry.name)) { - scanDir(entryPath, false); - } - } else if (entry.isFile() && /^tsconfig(?:\.[\w-]+)?\.json$/i.test(entry.name)) { - files.push(entryPath); - } - } - }; - scanDir(projectPath, true); - return files; -} - -function hasNuxtTestUtilsDependency(pkg: DependencyBag): boolean { - return [pkg.dependencies, pkg.devDependencies, pkg.optionalDependencies].some( - (dependencies) => dependencies?.['@nuxt/test-utils'] !== undefined, - ); -} - -// Normal imports and triple-slash type directives from `vitest` are rewritten -// to `vite-plus/test` later in the same migration and therefore do not justify -// a lasting direct dependency. Module augmentations, `vitest/package.json`, and -// compilerOptions.types entries deliberately retain the upstream package -// identity, so keep Vitest package-local for those surfaces. -function sourceTreeReferencesRetainedVitestModule(projectPath: string): boolean { - return ( - findPackageTsconfigFiles(projectPath).some(hasVitestTypesInTsconfig) || - sourceTreeMatches(projectPath, (content) => { - return ( - /\bdeclare\s+module\s+['"]vitest(?:\/[^'"]*)?['"]/.test(content) || - content.includes('vitest/package.json') || - /\brequire\.resolve\s*\(\s*['"]vitest(?:\/[^'"]*)?['"]/.test(content) || - /\bimport\.meta\.resolve\s*\(\s*['"]vitest(?:\/[^'"]*)?['"]/.test(content) - ); - }) - ); -} - -function usesVitestBrowserMode(projectPath: string): boolean { - return sourceTreeReferencesAny(projectPath, VITEST_BROWSER_SPECIFIER_HINTS); -} - -// Source-only signal that a package targets the WEBDRIVERIO provider — used to -// allow the edgedriver/geckodriver builds even when no dep is declared yet (the -// webdriverio-specific postinstall hazard; playwright has no such drivers). See -// `usesVitestBrowserMode` for the shared traversal semantics (extensions, skip -// dirs, nested-package boundary). -function usesWebdriverioProvider(projectPath: string): boolean { - return sourceTreeReferencesAny(projectPath, WEBDRIVERIO_PROVIDER_SPECIFIER_HINTS); -} - -// Source-scan signal per opt-in browser provider, used to inject the (opt-in, -// no-longer-bundled) provider + its framework peer even when no dep is declared -// yet (e.g. a `vite.config.ts` importing the provider via a `vite-plus/test` -// shim). Mirrors `usesWebdriverioProvider`'s scan for each provider. -function collectProviderSourceModes(projectPath: string): Record { - const modes: Record = {}; - for (const provider of OPT_IN_BROWSER_PROVIDERS) { - modes[provider] = sourceTreeReferencesAny( - projectPath, - BROWSER_PROVIDER_SPECIFIER_HINTS[provider], - ); - } - return modes; -} - -export function rewritePackageJson( - pkg: { - scripts?: Record; - 'lint-staged'?: Record; - devDependencies?: Record; - dependencies?: Record; - peerDependencies?: Record; - optionalDependencies?: Record; - }, - packageManager: PackageManager, - isMonorepo?: boolean, - skipStagedMigration?: boolean, - catalogDependencyResolver?: CatalogDependencyResolver, - vitestBrowserMode?: boolean, - // Source-scan signal per opt-in browser provider name (e.g. - // `@vitest/browser-webdriverio` → true). A provider with no dep declared but - // imported in source still gets kept/injected. - providerSourceModes?: Partial>, - // Whether the project uses vitest DIRECTLY (a required-peer consumer, an - // upstream module reference, or browser mode). `vitest` is managed only - // when true; in the common case (`false`) a lingering managed `vitest` entry - // is REMOVED so it arrives transitively through vite-plus. Defaults to true to - // preserve legacy behavior for callers that don't compute the signal. - usesVitestDirectly = true, - // Module augmentations, compilerOptions.types, and `vitest/package.json` - // intentionally retain the upstream package identity after import rewriting - // and therefore require a package-local provider under strict layouts. - retainedVitestModule = false, - // Installed dependency metadata can reveal required Vitest peers whose - // package names do not include "vitest". - requiredVitestPeer = false, -): Record | null { - if (pkg.scripts) { - const updated = rewriteScripts( - JSON.stringify(pkg.scripts), - getScriptRulesYaml(skipStagedMigration), - ); - if (updated) { - pkg.scripts = JSON.parse(updated); - } - } - // Extract staged config from package.json (lint-staged) → will be merged into vite.config.ts. - // The lint-staged key is NOT deleted here — it's removed by the caller only after - // the merge into vite.config.ts succeeds, to avoid losing config on merge failure. - let extractedStagedConfig: Record | null = null; - if (!skipStagedMigration && pkg['lint-staged']) { - const config = pkg['lint-staged']; - const updated = rewriteScripts(JSON.stringify(config), readRulesYaml()); - extractedStagedConfig = updated ? JSON.parse(updated) : config; - } - const supportCatalog = !!isMonorepo && packageManager !== PackageManager.npm; - let needVitePlus = false; - 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 }, - ]; - // Scrub stale `npm:@voidzero-dev/vite-plus-test@...` aliases left over from - // earlier vite-plus migrations — the wrapper package no longer exists, so - // these entries would break `pnpm install`. Real user ranges are preserved. - for (const { dependencies } of dependencyGroups) { - if (pruneLegacyWrapperAliases(dependencies)) { - needVitePlus = true; - } - } - const managed = managedOverridePackages(usesVitestDirectly); - // Common case (no direct vitest): vite-plus consumes upstream vitest itself, - // so ACTIVELY REMOVE any lingering managed `vitest` dependency (a managed pin, - // a `catalog:` reference, or a stale wrapper alias already normalized above) — - // it arrives transitively through vite-plus and a future `vp update vite-plus` - // keeps it correct with no pin to drift. The `@vitest/*` family and unrelated - // keys are untouched. (Browser-mode / vitest-adjacent projects re-add a direct - // `vitest` below; those are direct-usage signals, so this never strips one a - // surviving consumer needs.) - if (!usesVitestDirectly) { - // Only the INSTALL groups — a `peerDependencies` `vitest` is a declaration - // about consumers, not an install pin, so it is not removed here. Catalog - // peer specs are resolved to their public range/fallback below. - for (const { dependencyField, dependencies } of dependencyGroups) { - if (dependencyField === 'peerDependencies') { - continue; - } - if (removeManagedVitestEntry(dependencies)) { - needVitePlus = true; - } - } - } - for (const [key, version] of Object.entries(managed)) { - for (const { dependencyField, dependencies } of dependencyGroups) { - if (dependencies?.[key]) { - dependencies[key] = getCatalogDependencySpec(dependencies[key], version, supportCatalog, { - dependencyField, - dependencyName: key, - packageManager, - catalogDependencyResolver, - preferredCatalogSpec: catalogDependencyResolver?.preferredCatalogSpec, - }); - needVitePlus = true; - } - } - } - if (normalizeVitestPeerCatalogSpec(pkg.peerDependencies, catalogDependencyResolver)) { - needVitePlus = true; - } - // Optional Vitest packages are published in lockstep with the runner. Keep - // every declared official @vitest/* package on the bundled version during a - // fresh migration too; existing-Vite+ upgrades use the same rule in the - // bootstrap path. - alignVitestEcosystemPackages(pkg, packageManager, supportCatalog, catalogDependencyResolver); - // Force-override mode (ecosystem CI / `vp create` E2E) must re-pin any - // pre-existing `vite-plus` range to the local tgz. Otherwise pnpm reads the - // published vite-plus metadata for transitive dep resolution (e.g. - // `@voidzero-dev/vite-plus-test`) even though the override replaces the - // vite-plus package itself, dragging the stale wrapper into node_modules. - if (isForceOverrideMode()) { - for (const { dependencies } of dependencyGroups) { - if (dependencies?.[VITE_PLUS_NAME]) { - // The referenced catalog entry is rewritten to the pkg.pr.new target - // separately. Preserve named/default catalog references so projects - // such as Vize do not gain an unnecessary default catalog. - if ( - !supportCatalog || - VITE_PLUS_VERSION.startsWith('file:') || - !dependencies[VITE_PLUS_NAME].startsWith('catalog:') - ) { - dependencies[VITE_PLUS_NAME] = VITE_PLUS_VERSION; - } - needVitePlus = true; - } - } - } - // Capture browser-mode signal from the original deps BEFORE the removal loop - // strips them. A package can drive vitest browser mode purely through config - // (`test.browser.provider: 'playwright'` in `vite.config.ts`) without ever - // importing `@vitest/browser*` in source — the provider package is listed in - // devDependencies but vitest loads it by name. The source-scan signal - // (`usesVitestBrowserMode`) misses this case; the dep declaration is the - // authoritative intent signal. - const hasBrowserDepSignal = VITEST_BROWSER_DEP_NAMES.some((name) => - dependencyGroups.some(({ dependencies }) => dependencies?.[name] !== undefined), - ); - // remove packages that are replaced with vite-plus - for (const name of REMOVE_PACKAGES) { - let wasRemoved = false; - for (const { dependencies } of dependencyGroups) { - if (dependencies?.[name]) { - delete dependencies[name]; - wasRemoved = true; - } - } - if (wasRemoved) { - needVitePlus = true; - } - // e.g., removing @vitest/browser-playwright should keep `playwright` in devDeps - const peerDep = BROWSER_PROVIDER_PEER_DEPS[name]; - if ( - wasRemoved && - peerDep && - !pkg.devDependencies?.[peerDep] && - !pkg.dependencies?.[peerDep] && - !pkg.peerDependencies?.[peerDep] && - !pkg.optionalDependencies?.[peerDep] - ) { - pkg.devDependencies ??= {}; - pkg.devDependencies[peerDep] = '*'; - } - } - // The browser providers (webdriverio, playwright) are opt-in: vite-plus no - // longer bundles them at runtime (each drags a heavy non-optional framework - // peer), so a user targeting a provider must own it themselves for the - // rewritten `vite-plus/test/browser-` import to resolve. Unlike the - // rest of the `@vitest/*` family they are deliberately NOT in - // VITE_PLUS_OVERRIDE_PACKAGES (so projects not using a provider stay - // untouched), which means the normalization loop above does not add them. We - // align each installed provider here using its existing catalog when present, - // or the concrete bundled version otherwise, and ensure its runtime framework - // peer (`webdriverio` / `playwright`). (`@vitest/browser`/preview stay bundled - // + stripped, handled in the REMOVE_PACKAGES loop above.) - let usesAnyOptInProvider = false; - for (const provider of OPT_IN_BROWSER_PROVIDERS) { - const usesProvider = - providerSourceModes?.[provider] || - dependencyGroups.some(({ dependencies }) => dependencies?.[provider] !== undefined); - if (!usesProvider) { - continue; - } - usesAnyOptInProvider = true; - // The provider must be INSTALLED (in deps/devDeps/optionalDeps, not merely a - // peer) for the rewritten `vite-plus/test/browser-` import to - // resolve. Normalize an existing install-group declaration to the bundled - // vitest version in place (the override loop above no longer pins it); - // otherwise — a source-only or peer-only user — inject it into devDeps. - const installGroupEntry = dependencyGroups.find( - ({ dependencyField, dependencies }) => - dependencyField !== 'peerDependencies' && dependencies?.[provider] !== undefined, - ); - if (installGroupEntry?.dependencies) { - if (VITEST_IS_MANAGED_OVERRIDE) { - installGroupEntry.dependencies[provider] = getAlignedVitestEcosystemDependencySpec( - installGroupEntry.dependencies[provider], - provider, - installGroupEntry.dependencyField, - packageManager, - supportCatalog, - catalogDependencyResolver, - ); - } - } else { - pkg.devDependencies ??= {}; - pkg.devDependencies[provider] = getCatalogDependencySpec( - undefined, - VITEST_VERSION, - supportCatalog && packageManager !== PackageManager.bun, - { preferredCatalogSpec: catalogDependencyResolver?.preferredCatalogSpec }, - ); - } - const peer = BROWSER_PROVIDER_PEER_DEPS[provider]; // 'webdriverio' / 'playwright' - const peerPresent = - pkg.dependencies?.[peer] ?? - pkg.devDependencies?.[peer] ?? - pkg.peerDependencies?.[peer] ?? - pkg.optionalDependencies?.[peer]; - if (peer && !peerPresent) { - pkg.devDependencies ??= {}; - pkg.devDependencies[peer] = '*'; - } - needVitePlus = true; - } - // An opt-in browser provider drags in its OWN `@vitest/browser → @vitest/mocker` - // subtree that is distinct from the one vite-plus bundles, so npm's flat - // node_modules cannot dedupe the two and leaves several nested `@vitest/mocker` - // copies. `@vitest/mocker/dist/node.js` statically `import`s `vite` (its `vite` - // peer is optional, so install never errors), and the `vite` override only lands - // deep inside the `vitest` subtree — unreachable from the nested provider chain. - // The result is `ERR_MODULE_NOT_FOUND: Cannot find package 'vite'` when loading - // the browser config. Mirror the override as a direct `vite` devDep (as the bun - // branch already does for its own resolver) so npm hoists a single top-level - // `node_modules/vite` that every nested `@vitest/mocker` resolves. Gated on - // provider usage so non-browser (node-mode) projects — which dedupe cleanly and - // need no direct `vite` — stay untouched. pnpm/yarn use symlink/PnP layouts that - // already expose the override to the provider subtree, so this is npm-only. - if (usesAnyOptInProvider && packageManager === PackageManager.npm) { - const viteOverride = VITE_PLUS_OVERRIDE_PACKAGES.vite; - const viteAlreadyDirect = - pkg.dependencies?.vite ?? pkg.devDependencies?.vite ?? pkg.optionalDependencies?.vite; - if (viteOverride && !viteAlreadyDirect) { - pkg.devDependencies ??= {}; - pkg.devDependencies.vite = viteOverride; - needVitePlus = true; - } - } - // Promote dep-derived signal to the same flag the source-scan feeds, so the - // downstream "add direct `vitest`" branch fires for config-only browser-mode - // setups too. - const effectiveBrowserMode = vitestBrowserMode || hasBrowserDepSignal; - // Trigger vite-plus install when a project has a vitest-adjacent package - // (e.g. `vitest-browser-svelte`) that declares vitest as a peer dep — even - // if the project has no vite/oxlint/tsdown dep to migrate. Only installed - // dependency groups count; a peer declaration alone installs nothing here. - const installableNames = [ - ...Object.keys(pkg.dependencies ?? {}), - ...Object.keys(pkg.devDependencies ?? {}), - ...Object.keys(pkg.optionalDependencies ?? {}), - ]; - const isVitestAdjacent = - !installableNames.includes('vitest') && - installableNames.some( - (name) => - name !== 'vitest' && name.includes('vitest') && !VITEST_DIRECT_USAGE_EXCLUDED.has(name), - ); - // Normalize a pre-existing pinned vite-plus so sub-packages don't drift - // from siblings: in catalog-supporting monorepos that's `catalog:`, under - // force-override (file:) it's the tgz path. Preserve protocol-prefixed - // specs (catalog:named, workspace:*, link:, file:, npm:, github:, git+/git:, - // http(s)://) so deliberate user pins survive; only vanilla version ranges - // (e.g. `^0.1.20`, `latest`) are rewritten. - const canonicalVitePlusSpec = - supportCatalog && !VITE_PLUS_VERSION.startsWith('file:') - ? (catalogDependencyResolver?.preferredCatalogSpec ?? 'catalog:') - : VITE_PLUS_VERSION; - // Treat vite-plus as present when it lives in either `devDependencies` or - // `dependencies` (devDeps wins when both exist). Re-pin/normalize happens in - // whichever group already owns it so a `dependencies` entry is never - // duplicated into `devDependencies`. - const existingVitePlusGroup = - pkg.devDependencies?.[VITE_PLUS_NAME] !== undefined - ? pkg.devDependencies - : pkg.dependencies?.[VITE_PLUS_NAME] !== undefined - ? pkg.dependencies - : undefined; - const existingVitePlus = existingVitePlusGroup?.[VITE_PLUS_NAME]; - const shouldNormalizeExistingVitePlus = - !!existingVitePlus && - supportCatalog && - existingVitePlus !== canonicalVitePlusSpec && - !isProtocolPinnedSpec(existingVitePlus); - // vitest-adjacent / browser-mode signals only trigger a vite-plus INSTALL when the - // project doesn't already have vite-plus — otherwise vite-plus is already present and - // re-adding it would be churn. (The direct `vitest` pin those signals also require is - // decided separately below, independent of whether vite-plus is present.) - if (!existingVitePlus && (isVitestAdjacent || effectiveBrowserMode)) { - needVitePlus = true; - } - // Browser mode AND a vitest-adjacent dep (e.g. `vitest-browser-svelte`, which - // declares a non-optional `vitest` peer) both need a direct `vitest` pin INDEPENDENT - // of whether `vite-plus` is already present: that peer must resolve from the package's - // OWN root under pnpm strict / Yarn PnP, where `vite-plus`'s transitive `vitest` is not - // visible. Tracked separately from `needVitePlus` so the pin is added without re-adding - // an already-present `vite-plus` — e.g. a monorepo root, where - // `rewriteRootWorkspacePackageJson` injects `vite-plus` BEFORE this runs (so - // `existingVitePlus` is already truthy here), or a re-migration of a project that - // already owns it. The guard below still no-ops when a direct `vitest` already exists, - // so a genuine normalize pass of an already-correct project mutates nothing. - const needDirectVitest = - needVitePlus || - effectiveBrowserMode || - isVitestAdjacent || - retainedVitestModule || - requiredVitestPeer; - if (existingVitePlusGroup) { - // Already present in `dependencies` or `devDependencies`: re-pin in place - // (only vanilla ranges are normalized; protocol pins are preserved) and - // never add a cross-group duplicate. - if (shouldNormalizeExistingVitePlus) { - existingVitePlusGroup[VITE_PLUS_NAME] = canonicalVitePlusSpec; - } - } else if (needVitePlus) { - // Absent from both groups: add it to `devDependencies` as before. - pkg.devDependencies = { - ...pkg.devDependencies, - [VITE_PLUS_NAME]: canonicalVitePlusSpec, - }; - } - ensureDirectViteForPnpm(pkg, packageManager, supportCatalog, catalogDependencyResolver); - // Add `vitest` as a direct devDependency when: - // - a remaining dependency likely peer-depends on vitest (e.g. - // vitest-browser-svelte), OR - // - the package runs vitest browser mode (`@vitest/browser` needs - // `vitest` resolvable from the package root — see usesVitestBrowserMode). - // Vite-plus already bundles upstream vitest as a direct dep, but a strict - // pnpm / yarn Plug'n'Play layout will not expose that transitive `vitest` - // to the package. Pinning it here points the dep at the same upstream - // version vite-plus ships with. Gated by needDirectVitest (browser-mode / - // vitest-adjacent, or some other change) — a pure normalize pass must not - // mutate the project beyond the vite-plus spec. - if (needDirectVitest) { - const installableDeps = { - ...pkg.dependencies, - ...pkg.devDependencies, - ...pkg.optionalDependencies, - }; - if ( - !installableDeps.vitest && - (effectiveBrowserMode || - retainedVitestModule || - requiredVitestPeer || - Object.keys(installableDeps).some((name) => name.includes('vitest'))) - ) { - pkg.devDependencies ??= {}; - pkg.devDependencies.vitest = getCatalogDependencySpec( - undefined, - VITEST_VERSION, - supportCatalog, - { preferredCatalogSpec: catalogDependencyResolver?.preferredCatalogSpec }, - ); - } - } - return extractedStagedConfig; -} - -// Returns true if the spec uses a known protocol prefix (catalog:, workspace:, -// link:, file:, npm:, github:, git+/git:, http(s)://) and so represents a -// deliberate user choice that should not be silently rewritten. -function isProtocolPinnedSpec(spec: string): boolean { - return /^(catalog:|workspace:|link:|file:|npm:|github:|git[+:]|https?:\/\/)/.test(spec); -} - -// Remove the "lint-staged" key from package.json after config has been -// successfully merged into vite.config.ts. -function removeLintStagedFromPackageJson(packageJsonPath: string): void { - editJsonFile<{ 'lint-staged'?: Record }>(packageJsonPath, (pkg) => { - if (pkg['lint-staged']) { - delete pkg['lint-staged']; - return pkg; - } - return undefined; - }); -} - -// Migrate standalone lint-staged config files into staged in vite.config.ts. -// JSON-parseable files are inlined automatically; non-JSON files get a warning. -function rewriteLintStagedConfigFile(projectPath: string, report?: MigrationReport): void { - let hasUnsupported = false; - - for (const filename of LINT_STAGED_JSON_CONFIG_FILES) { - const configPath = path.join(projectPath, filename); - if (!fs.existsSync(configPath)) { - continue; - } - if (filename === '.lintstagedrc' && !isJsonFile(configPath)) { - warnMigration( - `${displayRelative(configPath)} is not JSON format — please migrate to "staged" in vite.config.ts manually`, - report, - ); - hasUnsupported = true; - continue; - } - // Merge the JSON config into vite.config.ts as "staged" and delete the file. - // Skip if staged already exists in vite.config.ts (already migrated by rewritePackageJson). - if (!hasStagedConfigInViteConfig(projectPath)) { - const config = readJsonFile(configPath); - const updated = rewriteScripts(JSON.stringify(config), readRulesYaml()); - const finalConfig = updated ? JSON.parse(updated) : config; - if (!mergeStagedConfigToViteConfig(projectPath, finalConfig, true, report)) { - // Merge failed — preserve the original config file so the user doesn't lose their rules - continue; - } - fs.unlinkSync(configPath); - if (report) { - report.inlinedLintStagedConfigCount++; - } - } else { - warnMigration( - `${displayRelative(configPath)} found but "staged" already exists in vite.config.ts — please merge manually`, - report, - ); - } - } - // Non-JSON standalone files — warn - for (const filename of LINT_STAGED_OTHER_CONFIG_FILES) { - const configPath = path.join(projectPath, filename); - if (!fs.existsSync(configPath)) { - continue; - } - warnMigration( - `${displayRelative(configPath)} — please migrate to "staged" in vite.config.ts manually`, - report, - ); - hasUnsupported = true; - } - if (hasUnsupported) { - infoMigration( - 'Only "staged" in vite.config.ts is supported. See https://viteplus.dev/guide/migrate#lint-staged', - report, - ); - } -} - -/** - * Ensure vite.config.ts exists, create it if not - * @returns The vite config filename - */ -function ensureViteConfig( - projectPath: string, - configs: ConfigFiles, - silent = false, - report?: MigrationReport, -): string { - if (!configs.viteConfig) { - configs.viteConfig = 'vite.config.ts'; - const viteConfigPath = path.join(projectPath, 'vite.config.ts'); - fs.writeFileSync( - viteConfigPath, - `import { defineConfig } from '${VITE_PLUS_NAME}'; - -export default defineConfig({}); -`, - ); - if (report) { - report.createdViteConfigCount++; - } - if (!silent) { - prompts.log.success(`✔ Created vite.config.ts in ${displayRelative(viteConfigPath)}`); - } - } - return configs.viteConfig; -} - -/** - * Merge tsdown.config.* into vite.config.ts - * - For JSON files: merge content directly into `pack` field and delete the JSON file - * - For TS/JS files: import the config file - */ -function mergeTsdownConfigFile( - projectPath: string, - silent = false, - report?: MigrationReport, -): void { - const configs = detectConfigs(projectPath); - if (!configs.tsdownConfig) { - return; - } - const viteConfig = ensureViteConfig(projectPath, configs, silent, report); - - const fullViteConfigPath = path.join(projectPath, viteConfig); - const fullTsdownConfigPath = path.join(projectPath, configs.tsdownConfig); - - // For JSON files, merge content directly and delete the file - if (configs.tsdownConfig.endsWith('.json')) { - mergeAndRemoveJsonConfig(projectPath, viteConfig, configs.tsdownConfig, 'pack', silent, report); - return; - } - - // For TS/JS files, import the config file - const tsdownRelativePath = `./${configs.tsdownConfig}`; - const result = mergeTsdownConfig(fullViteConfigPath, tsdownRelativePath); - if (result.updated) { - fs.writeFileSync(fullViteConfigPath, result.content); - if (report) { - report.tsdownImportCount++; - } - if (!silent) { - prompts.log.success( - `✔ Added import for ${displayRelative(fullTsdownConfigPath)} in ${displayRelative(fullViteConfigPath)}`, - ); - } - } - // Show documentation link for manual merging since we only added the import - infoMigration( - `Please manually merge ${displayRelative(fullTsdownConfigPath)} into ${displayRelative(fullViteConfigPath)}, see https://viteplus.dev/guide/migrate#tsdown`, - report, - ); -} - -/** - * Best-effort: derive the Oxlint rule-namespace a JS plugin package - * contributes. Mirrors the conventions @oxlint/migrate uses when - * translating ESLint configs, and the conventions Oxlint-native plugin - * authors use (`oxlint-plugin-` — see posva/pinia-colada in the - * wild): - * `eslint-plugin-unocss` → `unocss` (rules: `unocss/order`) - * `oxlint-plugin-posva` → `posva` (rules: `posva/foo`) - * `@stylistic/eslint-plugin` → `@stylistic` (rules: `@stylistic/indent`) - * `@stylistic/eslint-plugin-ts` → `@stylistic/ts` (rules: `@stylistic/ts/indent`) - * `@scope/oxlint-plugin-x` → `@scope/x` - * anything else → the package name verbatim - */ -function deriveJsPluginNamespace(packageName: string): string { - for (const prefix of ['eslint-plugin-', 'oxlint-plugin-']) { - if (packageName.startsWith(prefix)) { - const suffix = packageName.slice(prefix.length); - return suffix || packageName; - } - } - const scoped = packageName.match(/^(@[^/]+)\/(?:eslint|oxlint)-plugin(?:-(.+))?$/); - if (scoped) { - return scoped[2] ? `${scoped[1]}/${scoped[2]}` : scoped[1]; - } - return packageName; -} - -/** - * Collect every dependency name declared across the root + workspace - * `package.json` files after the ESLint cleanup has run. Used to verify - * that JS plugins referenced by the generated `.oxlintrc.json` are - * actually installable. - */ -function collectInstalledPackageNames( - projectPath: string, - packages?: WorkspacePackage[], -): Set { - const names = new Set(); - const paths = [projectPath, ...(packages ?? []).map((p) => path.join(projectPath, p.path))]; - for (const dir of paths) { - const pkgJsonPath = path.join(dir, 'package.json'); - if (!fs.existsSync(pkgJsonPath)) { - continue; - } - let pkg: Record | undefined>; - try { - pkg = readJsonFile(pkgJsonPath) as typeof pkg; - } catch { - continue; - } - for (const field of [ - 'devDependencies', - 'dependencies', - 'peerDependencies', - 'optionalDependencies', - ] as const) { - const deps = pkg[field]; - if (deps) { - for (const name of Object.keys(deps)) { - names.add(name); - } - } - } - } - return names; -} - -/** - * Test whether a rule key (e.g. `@stylistic/ts/indent`) belongs to any - * namespace in `namespaces`. We can't just split on the first `/` — - * `@stylistic/eslint-plugin-ts` contributes the multi-segment namespace - * `@stylistic/ts`, so the lookup has to try progressively longer - * prefixes until one matches or we run out of slashes. - */ -function ruleKeyMatchesNamespace(key: string, namespaces: Set): boolean { - if (!key.includes('/')) { - return true; - } - let idx = key.indexOf('/'); - while (idx !== -1) { - if (namespaces.has(key.slice(0, idx))) { - return true; - } - idx = key.indexOf('/', idx + 1); - } - return false; -} - -/** Filter a rules object to only entries whose namespace is recognized. */ -function filterRulesAgainstNamespaces( - rules: Record, - namespaces: Set, -): Record { - const out: Record = {}; - for (const [key, value] of Object.entries(rules)) { - if (ruleKeyMatchesNamespace(key, namespaces)) { - out[key] = value; - } - } - return out; -} - -/** - * Sort a jsPlugins array into installed entries (kept) and string - * entries for packages that aren't present in the workspace. Object-form - * entries (`{ name, specifier }`) and string entries that look like - * local paths (`./X`, `/X`, `../X`) are passed through — Oxlint resolves - * them itself. - */ -function partitionJsPlugins( - entries: NonNullable, - availablePackages: Set, -): { - kept: NonNullable; - dropped: string[]; -} { - const kept: NonNullable = []; - const dropped: string[] = []; - for (const entry of entries) { - if (typeof entry !== 'string') { - kept.push(entry); - continue; - } - // Local-path specifiers don't go through `package.json`; preserve - // them so users with hand-authored local plugin imports survive - // a `vp migrate` re-run. - if (entry.startsWith('./') || entry.startsWith('../') || entry.startsWith('/')) { - kept.push(entry); - continue; - } - if (availablePackages.has(entry)) { - kept.push(entry); - } else { - dropped.push(entry); - } - } - return { kept, dropped }; -} - -/** Build the set of rule-key namespaces backed by a given jsPlugins set. */ -function jsPluginsToNamespaces(entries: NonNullable): Set { - const ns = new Set(); - for (const entry of entries) { - if (typeof entry === 'string') { - ns.add(deriveJsPluginNamespace(entry)); - } else if (entry && typeof entry === 'object' && 'name' in entry && entry.name) { - ns.add(entry.name); - } - } - // Empty-string namespace (e.g. from `eslint-plugin-` with no suffix) - // would smuggle slash-prefixed rules through; drop it defensively. - ns.delete(''); - return ns; -} - -/** - * Sanitize the `.oxlintrc.json` produced by `@oxlint/migrate` (in-place) - * before it gets merged into `vite.config.ts`. Drop references that - * won't resolve at lint time and warn the user. - * - * Why: `@oxlint/migrate` can emit `jsPlugins[]` / `plugins[]` / `rules` - * entries referring to packages the user never installed (e.g. - * translating `@unocss/eslint-config` into `eslint-plugin-unocss`), - * to plugins outside Oxlint's native set, or under namespaces no - * surviving plugin contributes. Without sanitization, `vp lint` aborts - * with "Failed to load JS plugin" / "Plugin not found" before running - * any rule. This produces a degraded-but-functional config instead. - * - * Per-override entries (`overrides[].jsPlugins`, `.plugins`, `.rules`) - * are sanitized independently — an override can introduce its own - * jsPlugin, so namespace availability is computed per-override (base - * namespaces ∪ the override's own surviving jsPlugins' namespaces). - */ -function sanitizeMigratedOxlintConfig( - config: OxlintConfig, - availablePackages: Set, - report?: MigrationReport, -): void { - // Track everything we strip so we can warn the user. - const allDroppedJsPlugins = new Set(); - const allDroppedPlugins = new Set(); - - // 1. Sanitize base-level jsPlugins. - const baseSplit = partitionJsPlugins(config.jsPlugins ?? [], availablePackages); - for (const n of baseSplit.dropped) { - allDroppedJsPlugins.add(n); - } - if (config.jsPlugins && baseSplit.dropped.length > 0) { - config.jsPlugins = baseSplit.kept; - } - - // 2. Base namespaces = native plugins + surviving jsPlugins' namespaces. - const baseNamespaces = new Set(OXLINT_NATIVE_PLUGINS); - for (const ns of jsPluginsToNamespaces(baseSplit.kept)) { - baseNamespaces.add(ns); - } - - // 3. Sanitize base-level plugins[] against base namespaces. - if (config.plugins) { - type PluginEntry = NonNullable[number]; - const keptPlugins: PluginEntry[] = []; - for (const p of config.plugins) { - if (baseNamespaces.has(p)) { - keptPlugins.push(p); - } else { - allDroppedPlugins.add(p); - } - } - if (keptPlugins.length !== config.plugins.length) { - config.plugins = keptPlugins; - } - } - - // 4. Sanitize base rules. Guard the reassignment to avoid adding a - // `rules: undefined` property that would shift downstream key - // emission in the merged vite.config.ts. - if (config.rules) { - const filtered = filterRulesAgainstNamespaces(config.rules, baseNamespaces); - if (Object.keys(filtered).length !== Object.keys(config.rules).length) { - config.rules = filtered as typeof config.rules; - } - } - - // 5. Sanitize each override INDEPENDENTLY. An override can declare - // its own `jsPlugins` / `plugins`, so we compute a per-override - // namespace set: base namespaces ∪ the override's own surviving - // jsPlugins' namespaces. If `override.plugins` is present it - // replaces base.plugins per Oxlint's schema, but for namespace - // resolution we still include the base set (rules under a base - // namespace are still valid inside the override). - if (Array.isArray(config.overrides)) { - for (const override of config.overrides) { - // Override jsPlugins. - let overrideSurvivors: NonNullable = []; - if (override.jsPlugins) { - const split = partitionJsPlugins(override.jsPlugins, availablePackages); - for (const n of split.dropped) { - allDroppedJsPlugins.add(n); - } - if (split.dropped.length > 0) { - override.jsPlugins = split.kept; - } - overrideSurvivors = split.kept; - } - const overrideNamespaces = new Set(baseNamespaces); - for (const ns of jsPluginsToNamespaces(overrideSurvivors)) { - overrideNamespaces.add(ns); - } - - // Override plugins[]. - if (override.plugins) { - type OverridePluginEntry = NonNullable[number]; - const keptOverridePlugins: OverridePluginEntry[] = []; - for (const p of override.plugins) { - if (overrideNamespaces.has(p)) { - keptOverridePlugins.push(p); - } else { - allDroppedPlugins.add(p); - } - } - if (keptOverridePlugins.length !== override.plugins.length) { - override.plugins = keptOverridePlugins; - } - } - - // Override rules. - if (override.rules) { - const filtered = filterRulesAgainstNamespaces(override.rules, overrideNamespaces); - if (Object.keys(filtered).length !== Object.keys(override.rules).length) { - override.rules = filtered as typeof override.rules; - } - } - } - } - - // 6. Warn. - // - // We deliberately don't try to distinguish "we just removed this - // package as part of the ESLint-ecosystem cleanup" from "the user - // never had it installed" — the only honest signal we have is "not - // in any package.json after cleanup", and a name-based heuristic - // (matches `eslint-plugin-*`?) misclassifies the @oxlint/migrate - // phantom-reference case (e.g. `@unocss/eslint-config` translating - // into `eslint-plugin-unocss` even though the user never had it). - // A single accurate message covers both paths. - if (allDroppedJsPlugins.size > 0) { - warnMigration( - `Stripped JS plugin reference(s) from the generated lint config: ${[...allDroppedJsPlugins].join(', ')}. ` + - 'No matching package is present in this workspace, so loading them at lint time would fail. ' + - 'If you want their Oxlint coverage back, install each package (e.g. `vp install `) and add its name back to `lint.jsPlugins` in vite.config.ts.', - report, - ); - } - if (allDroppedPlugins.size > 0) { - warnMigration( - `Stripped unknown plugin reference(s) from the generated lint config: ${[...allDroppedPlugins].join(', ')}. ` + - "These aren't native Oxlint plugins and no surviving JS plugin contributes them.", - report, - ); - } -} - -/** - * Merge oxlint and oxfmt config into vite.config.ts - */ -export function mergeViteConfigFiles( - projectPath: string, - silent = false, - report?: MigrationReport, - packages?: WorkspacePackage[], - // For per-sub-package callers: the workspace root that `packages[].path` - // is relative to. When undefined we resolve relative to `projectPath` - // (correct for the top-level standalone/monorepo callers, where - // projectPath IS the workspace root). - workspaceRoot?: string, -): void { - const configs = detectConfigs(projectPath); - if (!configs.oxfmtConfig && !configs.oxlintConfig) { - return; - } - const viteConfig = ensureViteConfig(projectPath, configs, silent, report); - if (configs.oxlintConfig) { - // Inject options.typeAware and options.typeCheck defaults before merging - const fullOxlintPath = path.join(projectPath, configs.oxlintConfig); - const oxlintJson = readJsonFile(fullOxlintPath, true) as OxlintConfig; - if (!oxlintJson.options) { - oxlintJson.options = {}; - } - // Skip typeAware/typeCheck when tsconfig.json has baseUrl (unsupported by tsgolint) - if (!hasBaseUrlInTsconfig(projectPath)) { - if (oxlintJson.options.typeAware === undefined) { - oxlintJson.options.typeAware = true; - } - if (oxlintJson.options.typeCheck === undefined) { - oxlintJson.options.typeCheck = true; - } - } else { - warnMigration(BASEURL_TSCONFIG_WARNING, report); - } - // Drop references to plugins / jsPlugins / rules that won't resolve - // at lint time (e.g. `@oxlint/migrate` translating `@unocss/eslint-config` - // → `eslint-plugin-unocss` even when that package isn't installed). - // Resolve workspace package paths against `workspaceRoot` when the - // caller is processing a sub-package — otherwise the sanitizer would - // mistakenly look for `subPath/` and miss the - // hoisted deps it's supposed to see. - sanitizeMigratedOxlintConfig( - oxlintJson, - collectInstalledPackageNames(workspaceRoot ?? projectPath, packages), - report, - ); - const normalizedOxlintConfig = ensureVitePlusImportRuleDefaults(oxlintJson); - fs.writeFileSync(fullOxlintPath, JSON.stringify(normalizedOxlintConfig, null, 2)); - // merge oxlint config into vite.config.ts - mergeAndRemoveJsonConfig(projectPath, viteConfig, configs.oxlintConfig, 'lint', silent, report); - } - if (configs.oxfmtConfig) { - // merge oxfmt config into vite.config.ts - mergeAndRemoveJsonConfig(projectPath, viteConfig, configs.oxfmtConfig, 'fmt', silent, report); - } -} - -/** - * Inject typeAware and typeCheck defaults into vite.config.ts lint config. - * Called after mergeViteConfigFiles() to handle the case where no .oxlintrc.json exists - * (e.g., newly created projects from create-vite templates). - */ -export function injectLintTypeCheckDefaults( - projectPath: string, - silent = false, - report?: MigrationReport, -): void { - if (hasBaseUrlInTsconfig(projectPath)) { - warnMigration(BASEURL_TSCONFIG_WARNING, report); - return; - } - injectConfigDefaults( - projectPath, - 'lint', - '.vite-plus-lint-init.oxlintrc.json', - JSON.stringify( - createDefaultVitePlusLintConfig({ - includeTypeAwareDefaults: true, - }), - ), - silent, - report, - ); -} - -export function injectFmtDefaults( - projectPath: string, - silent = false, - report?: MigrationReport, -): void { - injectConfigDefaults( - projectPath, - 'fmt', - '.vite-plus-fmt-init.oxfmtrc.json', - JSON.stringify({}), - silent, - report, - ); -} - -/** - * Wire `create.defaultTemplate: ''` into the new monorepo's - * `vite.config.ts`. The caller is `bin.ts`, only when scaffolding a - * monorepo from a bundled `@org` manifest entry — that's the case where - * the user just picked a template from a specific org and naturally - * wants subsequent `vp create` invocations from the workspace to default - * to that same org's picker. - */ -export function injectCreateDefaultTemplate( - projectPath: string, - scope: string, - silent = false, - report?: MigrationReport, -): void { - if (!scope) { - return; - } - injectConfigDefaults( - projectPath, - 'create', - '.vite-plus-create-init.json', - JSON.stringify({ defaultTemplate: scope }), - silent, - report, - ); -} - -function injectConfigDefaults( - projectPath: string, - configKey: string, - tempFileName: string, - tempFileContent: string, - silent: boolean, - report?: MigrationReport, -): void { - const configs = detectConfigs(projectPath); - if (configs.viteConfig && hasConfigKey(path.join(projectPath, configs.viteConfig), configKey)) { - return; - } - - const viteConfig = ensureViteConfig(projectPath, configs, silent, report); - const tempConfigPath = path.join(projectPath, tempFileName); - fs.writeFileSync(tempConfigPath, tempFileContent); - const fullViteConfigPath = path.join(projectPath, viteConfig); - let result; - try { - result = mergeJsonConfig(fullViteConfigPath, tempConfigPath, configKey); - } finally { - fs.rmSync(tempConfigPath, { force: true }); - } - if (result.updated) { - fs.writeFileSync(fullViteConfigPath, result.content); - } -} - -function mergeAndRemoveJsonConfig( - projectPath: string, - viteConfigPath: string, - jsonConfigPath: string, - configKey: string, - silent = false, - report?: MigrationReport, -): void { - const fullViteConfigPath = path.join(projectPath, viteConfigPath); - const fullJsonConfigPath = path.join(projectPath, jsonConfigPath); - // Skip merge when the key is already present in vite.config.ts — the Rust - // merge step always prepends, so without this guard a template that ships - // both an inline `${configKey}:` block and a standalone JSON file (e.g. - // create-fate's vite.config.ts + .oxfmtrc.jsonc) ends up with two of them. - // AST-based check ignores comments, string-literal occurrences, and nested - // keys (e.g. `plugins: [{ fmt: ... }]`). - if (hasConfigKey(fullViteConfigPath, configKey)) { - fs.unlinkSync(fullJsonConfigPath); - if (!silent) { - prompts.log.info( - `${configKey} config already present in ${displayRelative(fullViteConfigPath)} — removed redundant ${displayRelative(fullJsonConfigPath)}`, - ); - } - return; - } - const result = mergeJsonConfig(fullViteConfigPath, fullJsonConfigPath, configKey); - if (result.updated) { - fs.writeFileSync(fullViteConfigPath, result.content); - fs.unlinkSync(fullJsonConfigPath); - if (report) { - report.mergedConfigCount++; - } - if (!silent) { - prompts.log.success( - `✔ Merged ${displayRelative(fullJsonConfigPath)} into ${displayRelative(fullViteConfigPath)}`, - ); - } - } else { - warnMigration( - `Failed to merge ${displayRelative(fullJsonConfigPath)} into ${displayRelative(fullViteConfigPath)}`, - report, - ); - infoMigration( - 'Please complete the merge manually and follow the instructions in the documentation: https://viteplus.dev/config/', - report, - ); - } -} - -/** - * Merge a staged config object into vite.config.ts as `staged: { ... }`. - * Writes the config to a temp JSON file, calls mergeJsonConfig NAPI, then cleans up. - */ -export function mergeStagedConfigToViteConfig( - projectPath: string, - stagedConfig: Record, - silent = false, - report?: MigrationReport, -): boolean { - const configs = detectConfigs(projectPath); - const viteConfig = ensureViteConfig(projectPath, configs, silent, report); - const fullViteConfigPath = path.join(projectPath, viteConfig); - - // Write staged config to a temp JSON file for mergeJsonConfig NAPI - const tempJsonPath = path.join(projectPath, '.staged-config-temp.json'); - fs.writeFileSync(tempJsonPath, JSON.stringify(stagedConfig, null, 2)); - - let result; - try { - result = mergeJsonConfig(fullViteConfigPath, tempJsonPath, 'staged'); - } finally { - fs.unlinkSync(tempJsonPath); - } - - if (result.updated) { - fs.writeFileSync(fullViteConfigPath, result.content); - if (report) { - report.mergedStagedConfigCount++; - } - if (!silent) { - prompts.log.success(`✔ Merged staged config into ${displayRelative(fullViteConfigPath)}`); - } - return true; - } else { - warnMigration( - `Failed to merge staged config into ${displayRelative(fullViteConfigPath)}`, - report, - ); - infoMigration( - `Please add staged config to ${displayRelative(fullViteConfigPath)} manually, see https://viteplus.dev/guide/migrate#lint-staged`, - report, - ); - return false; - } -} - -/** - * Check if vite.config.ts already has a `staged` config key. - */ -export function hasStagedConfigInViteConfig(projectPath: string): boolean { - const configs = detectConfigs(projectPath); - if (!configs.viteConfig) { - return false; - } - const viteConfigPath = path.join(projectPath, configs.viteConfig); - const content = fs.readFileSync(viteConfigPath, 'utf8'); - return /\bstaged\s*:/.test(content); -} - -/** - * Wrap safe inline Vite plugin arrays with lazyPlugins so check/lint/fmt do not - * eagerly execute plugin factories while loading vite.config.ts. - */ -function wrapLazyPluginsInViteConfig( - projectPath: string, - silent = false, - report?: MigrationReport, -): void { - const configs = detectConfigs(projectPath); - if (!configs.viteConfig) { - return; - } - - const viteConfigPath = path.join(projectPath, configs.viteConfig); - const result = wrapLazyPlugins(viteConfigPath); - if (!result.updated) { - return; - } - - fs.writeFileSync(viteConfigPath, result.content); - if (report) { - report.wrappedPluginConfigCount++; - } - if (!silent) { - prompts.log.success( - `✔ Wrapped inline Vite plugins with lazyPlugins in ${displayRelative(viteConfigPath)}`, - ); - } -} - -/** - * Rewrite imports in all TypeScript/JavaScript files under a directory - * This rewrites vite/vitest imports to @voidzero-dev/vite-plus - * @param projectPath - The root directory to search for files - */ -function rewriteAllImports( - projectPath: string, - silent = false, - report?: MigrationReport, - preserveNuxtVitestImports = true, -): boolean { - const result = rewriteImportsInDirectory(projectPath, preserveNuxtVitestImports); - const modified = result.modifiedFiles.length; - const preserved = result.preservedVitestFiles.length; - const errors = result.errors.length; - - if (report) { - report.rewrittenImportFileCount += modified; - report.preservedNuxtVitestImportFileCount += preserved; - report.rewrittenImportErrors.push( - ...result.errors.map((error) => ({ - path: displayRelative(error.path), - message: error.message, - })), - ); - } - - if (!silent && modified > 0) { - prompts.log.success(`Rewrote imports in ${modified === 1 ? 'one file' : `${modified} files`}`); - prompts.log.info(result.modifiedFiles.map((file) => ` ${displayRelative(file)}`).join('\n')); - } - - if (errors > 0) { - if (report) { - warnMigration( - `${errors === 1 ? 'one file had an error' : `${errors} files had errors`} while rewriting imports`, - report, - ); - } else { - prompts.log.warn( - `⚠ ${errors === 1 ? 'one file had an error' : `${errors} files had errors`}:`, - ); - for (const error of result.errors) { - prompts.log.error(` ${displayRelative(error.path)}: ${error.message}`); - } - } - } - return modified > 0; -} - -/** - * Check if the project has an unsupported husky version (<9.0.0). - * Uses `semver.coerce` to handle ranges like `^8.0.0` → `8.0.0`. - * When the specifier is a catalog reference (e.g. `"catalog:"`), resolves - * it from the active package manager's catalog first — a `catalog:` spec is - * only meaningful to the manager that owns the workspace, so we never read a - * leftover/foreign catalog file. When it is still not coercible (e.g. - * `"latest"`), falls back to the installed version in node_modules via - * `detectPackageMetadata`. - * Returns a reason string if hooks migration should be skipped, or null - * if husky is absent or compatible. - */ -function checkUnsupportedHuskyVersion( - projectPath: string, - deps: Record | undefined, - prodDeps: Record | undefined, - packageManager: PackageManager | undefined, -): string | null { - const huskyVersion = deps?.husky ?? prodDeps?.husky; - if (!huskyVersion) { - return null; - } - let coerced = semver.coerce(huskyVersion); - if (coerced == null && packageManager != null && huskyVersion.startsWith('catalog:')) { - const resolved = createCatalogDependencyResolver(projectPath, packageManager)?.( - huskyVersion, - 'husky', - ); - if (resolved) { - coerced = semver.coerce(resolved); - } - } - if (coerced == null) { - const installed = detectPackageMetadata(projectPath, 'husky'); - if (installed) { - coerced = semver.coerce(installed.version); - } - if (coerced == null) { - return `Could not determine husky version from "${huskyVersion}" — please specify a semver-compatible version (e.g., "^9.0.0") and re-run migration.`; - } - } - if (semver.satisfies(coerced, '<9.0.0')) { - return 'Detected husky <9.0.0 — please upgrade to husky v9+ first, then re-run migration.'; - } - return null; -} - -const OTHER_HOOK_TOOLS = ['simple-git-hooks', 'lefthook', 'yorkie'] as const; - -// Packages replaced by vite-plus built-in commands and should be removed from devDependencies -const REPLACED_HOOK_PACKAGES = ['husky', 'lint-staged'] as const; - -function removeReplacedHookPackages(packageJsonPath: string): void { - editJsonFile<{ - devDependencies?: Record; - dependencies?: Record; - }>(packageJsonPath, (pkg) => { - for (const name of REPLACED_HOOK_PACKAGES) { - if (pkg.devDependencies?.[name]) { - delete pkg.devDependencies[name]; - } - if (pkg.dependencies?.[name]) { - delete pkg.dependencies[name]; - } - } - return pkg; - }); -} - -export function detectLegacyGitHooksMigrationCandidate(projectPath: string): boolean { - const packageJsonPath = path.join(projectPath, 'package.json'); - if (!fs.existsSync(packageJsonPath)) { - return false; - } - const pkg = readJsonFile(packageJsonPath) as { - scripts?: Record; - 'lint-staged'?: unknown; - }; - return getOldHooksDir(projectPath) !== undefined || pkg['lint-staged'] !== undefined; -} - -/** - * Walk up from `startPath` looking for `.git` (directory or file — submodules - * use a `.git` file). Returns the directory that contains `.git`, or `null`. - */ -function findGitRoot(startPath: string): string | null { - let dir = startPath; - while (true) { - if (fs.existsSync(path.join(dir, '.git'))) { - return dir; - } - const parent = path.dirname(dir); - if (parent === dir) { - return null; - } - dir = parent; - } -} - -/** - * Normalize "husky install [dir]" → "husky [dir]" so downstream regex - * and ast-grep rules can match a single pattern. - */ -function collapseHuskyInstall(script: string): string { - return script.replace('husky install ', 'husky ').replace('husky install', 'husky'); -} - -/** - * High-level helper: detect old hooks dir, set up git hooks, and rewrite - * the prepare script. Returns true if hooks were successfully installed. - */ -export function installGitHooks( - projectPath: string, - silent = false, - report?: MigrationReport, - packageManager?: PackageManager, -): boolean { - const oldHooksDir = getOldHooksDir(projectPath); - if (setupGitHooks(projectPath, oldHooksDir, silent, report, packageManager)) { - rewritePrepareScript(projectPath); - return true; - } - return false; -} - -/** - * Read-only probe: extract the old husky hooks directory from `scripts.prepare` - * without modifying package.json. Returns undefined when no husky reference is found. - */ -export function getOldHooksDir(rootDir: string): string | undefined { - const packageJsonPath = path.join(rootDir, 'package.json'); - if (!fs.existsSync(packageJsonPath)) { - return undefined; - } - const pkg = readJsonFile(packageJsonPath) as { scripts?: { prepare?: string } }; - if (!pkg.scripts?.prepare) { - return undefined; - } - const prepare = collapseHuskyInstall(pkg.scripts.prepare); - const match = prepare.match(/\bhusky(?:\s+([\w./-]+))?/); - if (!match) { - return undefined; - } - return match[1] ?? '.husky'; -} - -/** - * Pre-flight check: verify that git hooks can be set up for this project. - * Returns `null` if hooks setup can proceed, or a warning reason string - * explaining why hooks setup should be skipped. - * - * These checks are deterministic and read-only — they do not modify - * the project in any way, making them safe to call before migration. - * - * `packageManager` is the project's detected manager; it scopes `catalog:` - * resolution to that manager's catalog so a foreign catalog file is ignored. - */ -export function preflightGitHooksSetup( - projectPath: string, - packageManager?: PackageManager, -): string | null { - const gitRoot = findGitRoot(projectPath); - if (gitRoot && path.resolve(projectPath) !== path.resolve(gitRoot)) { - return 'Subdirectory project detected — skipping git hooks setup. Configure hooks at the repository root.'; - } - const packageJsonPath = path.join(projectPath, 'package.json'); - if (!fs.existsSync(packageJsonPath)) { - return null; // silently skip - } - const pkgContent = readJsonFile(packageJsonPath); - const deps = pkgContent.devDependencies as Record | undefined; - const prodDeps = pkgContent.dependencies as Record | undefined; - for (const tool of OTHER_HOOK_TOOLS) { - if (deps?.[tool] || prodDeps?.[tool] || pkgContent[tool]) { - return `Detected ${tool} — skipping git hooks setup. Please configure git hooks manually, see https://viteplus.dev/guide/migrate#git-hook-tools`; - } - } - const huskyReason = checkUnsupportedHuskyVersion(projectPath, deps, prodDeps, packageManager); - if (huskyReason) { - return huskyReason; - } - if (hasUnsupportedLintStagedConfig(projectPath)) { - return 'Unsupported lint-staged config format — skipping git hooks setup. Please configure git hooks manually.'; - } - return null; -} - -/** - * Set up git hooks with husky + lint-staged via vp commands. - * Skips if another hook tool is detected (warns user). - * Returns true if hooks were successfully set up, false if skipped. - */ -export function setupGitHooks( - projectPath: string, - oldHooksDir?: string, - silent = false, - report?: MigrationReport, - packageManager?: PackageManager, -): boolean { - const reason = preflightGitHooksSetup(projectPath, packageManager); - if (reason) { - warnMigration(reason, report); - return false; - } - - const packageJsonPath = path.join(projectPath, 'package.json'); - if (!fs.existsSync(packageJsonPath)) { - return false; - } - - const gitRoot = findGitRoot(projectPath); - - // Custom husky dirs (e.g. .config/husky) stay unchanged; - // only the default .husky dir gets migrated to .vite-hooks. - const isCustomDir = oldHooksDir != null && oldHooksDir !== '.husky'; - const hooksDir = isCustomDir ? oldHooksDir : '.vite-hooks'; - - editJsonFile<{ - scripts?: Record; - devDependencies?: Record; - dependencies?: Record; - }>(packageJsonPath, (pkg) => { - // Ensure vp config is present for projects that didn't have husky. - // Skip when prepare contains "husky" — rewritePrepareScript (called after - // setupGitHooks succeeds) will transform husky → vp config. - if (!pkg.scripts) { - pkg.scripts = {}; - } - if (!pkg.scripts.prepare) { - pkg.scripts.prepare = 'vp config'; - } else if ( - !pkg.scripts.prepare.includes('vp config') && - !/\bhusky\b/.test(pkg.scripts.prepare) - ) { - pkg.scripts.prepare = `vp config && ${pkg.scripts.prepare}`; - } - - return pkg; - }); - - // Add staged config to vite.config.ts if not present - let stagedMerged = hasStagedConfigInViteConfig(projectPath); - const hasStandaloneConfig = hasStandaloneLintStagedConfig(projectPath); - if (!stagedMerged && !hasStandaloneConfig) { - // Use lint-staged config from package.json if available, otherwise use default - const pkgData = readJsonFile(packageJsonPath) as { - 'lint-staged'?: Record; - }; - const stagedConfig = pkgData?.['lint-staged'] ?? DEFAULT_STAGED_CONFIG; - const updated = rewriteScripts(JSON.stringify(stagedConfig), readRulesYaml()); - const finalConfig: Record = updated - ? JSON.parse(updated) - : stagedConfig; - stagedMerged = mergeStagedConfigToViteConfig(projectPath, finalConfig, silent, report); - } - - // Only remove lint-staged key from package.json after staged config is - // confirmed in vite.config.ts — prevents losing config on merge failure - if (stagedMerged) { - removeLintStagedFromPackageJson(packageJsonPath); - } - - // Copy default .husky/ hooks to .vite-hooks/ before creating pre-commit hook. - // Custom dirs (e.g. .config/husky) are kept in-place — no copy needed. - if (oldHooksDir && !isCustomDir) { - const oldDir = path.join(projectPath, oldHooksDir); - if (fs.existsSync(oldDir)) { - const targetDir = path.join(projectPath, hooksDir); - fs.mkdirSync(targetDir, { recursive: true }); - for (const entry of fs.readdirSync(oldDir, { withFileTypes: true })) { - if (entry.isDirectory() || entry.name.startsWith('.')) { - continue; - } - const src = path.join(oldDir, entry.name); - const dest = path.join(targetDir, entry.name); - fs.copyFileSync(src, dest); - fs.chmodSync(dest, 0o755); - } - // Remove old .husky/ directory after copying hooks to .vite-hooks/ - fs.rmSync(oldDir, { recursive: true, force: true }); - } - } - - // Only create pre-commit hook if staged config was merged into vite.config.ts. - // Standalone lint-staged config files are NOT sufficient — `vp staged` only - // reads from vite.config.ts, so a hook without merged config would fail. - if (stagedMerged) { - createPreCommitHook(projectPath, hooksDir); - } - - // vp config requires a git workspace — skip if no .git found - if (!gitRoot) { - removeReplacedHookPackages(packageJsonPath); - return true; - } - - // Clear husky's core.hooksPath so vp config can set the new one. - // Only clear if it matches the old husky directory — preserve genuinely custom paths. - if (oldHooksDir) { - const checkResult = spawn.sync('git', ['config', '--local', 'core.hooksPath'], { - cwd: projectPath, - stdio: 'pipe', - }); - const existingPath = checkResult.status === 0 ? checkResult.stdout?.toString().trim() : ''; - if (existingPath === `${oldHooksDir}/_` || existingPath === oldHooksDir) { - spawn.sync('git', ['config', '--local', '--unset', 'core.hooksPath'], { - cwd: projectPath, - stdio: 'pipe', - }); - } - } - - const vpBin = process.env.VP_CLI_BIN ?? 'vp'; - - // Install git hooks via vp config (--no-agent to skip agent setup, handled by migration) - const configArgs = isCustomDir - ? ['config', '--no-agent', '--hooks-dir', hooksDir] - : ['config', '--no-agent']; - const configResult = spawn.sync(vpBin, configArgs, { - cwd: projectPath, - stdio: 'pipe', - }); - if (configResult.status === 0) { - // vp config outputs skip/info messages to stdout via log(). - // An empty message means hooks were installed successfully; - // any non-empty output indicates a skip (HUSKY=0, hooksPath - // already set, .git not found, etc.). - const stdout = configResult.stdout?.toString().trim() ?? ''; - if (stdout) { - warnMigration(`Git hooks not configured — ${stdout}`, report); - return false; - } - removeReplacedHookPackages(packageJsonPath); - if (report) { - report.gitHooksConfigured = true; - } - if (!silent) { - prompts.log.success('✔ Git hooks configured'); - } - return true; - } - warnMigration('Failed to install git hooks', report); - return false; -} - -/** - * Check if a standalone lint-staged config file exists - */ -function hasStandaloneLintStagedConfig(projectPath: string): boolean { - return LINT_STAGED_ALL_CONFIG_FILES.some((file) => fs.existsSync(path.join(projectPath, file))); -} - -/** - * Check if a standalone lint-staged config exists in a format that can't be - * auto-migrated to "staged" in vite.config.ts (non-JSON files like .yaml, - * .mjs, .cjs, .js, or a non-JSON .lintstagedrc). - */ -function hasUnsupportedLintStagedConfig(projectPath: string): boolean { - for (const filename of LINT_STAGED_OTHER_CONFIG_FILES) { - if (fs.existsSync(path.join(projectPath, filename))) { - return true; - } - } - const lintstagedrcPath = path.join(projectPath, '.lintstagedrc'); - if (fs.existsSync(lintstagedrcPath) && !isJsonFile(lintstagedrcPath)) { - return true; - } - return false; -} - -/** - * Create pre-commit hook file in the hooks directory. - */ -// Lint-staged invocation patterns — replaced in-place with `vp staged`. -// The optional prefix group captures env var assignments like `NODE_OPTIONS=... `. -// We still detect old lint-staged patterns to migrate existing hooks. -const STALE_LINT_STAGED_PATTERNS = [ - /^((?:[A-Z_][A-Z0-9_]*(?:=\S*)?\s+)*)(pnpm|pnpm exec|npx|yarn|yarn run|npm exec|npm run|bunx|bun run|bun x)\s+lint-staged\b/, - /^((?:[A-Z_][A-Z0-9_]*(?:=\S*)?\s+)*)lint-staged\b/, -]; - -const DEFAULT_STAGED_CONFIG: Record = { '*': 'vp check --fix' }; - -/** - * Ensure the pre-commit hook exists with `vp staged`, and that - * vite.config.ts contains a `staged` block (using the default config - * if none is present). Called by `vp config` after hook installation. - */ -export function ensurePreCommitHook(projectPath: string, dir = '.vite-hooks'): void { - if (!hasStagedConfigInViteConfig(projectPath)) { - mergeStagedConfigToViteConfig(projectPath, DEFAULT_STAGED_CONFIG, true); - } - createPreCommitHook(projectPath, dir); -} - -export function createPreCommitHook(projectPath: string, dir = '.vite-hooks'): void { - const huskyDir = path.join(projectPath, dir); - fs.mkdirSync(huskyDir, { recursive: true }); - const hookPath = path.join(huskyDir, 'pre-commit'); - if (fs.existsSync(hookPath)) { - const existing = fs.readFileSync(hookPath, 'utf8'); - if (existing.includes('vp staged')) { - return; // already has vp staged - } - // Replace old lint-staged invocations in-place, preserve everything else - const lines = existing.split('\n'); - let replaced = false; - const result: string[] = []; - for (const line of lines) { - const trimmed = line.trim(); - if (!replaced) { - let matched = false; - for (const pattern of STALE_LINT_STAGED_PATTERNS) { - const match = pattern.exec(trimmed); - if (match) { - // Preserve env var prefix (capture group 1) and flags/chained commands after lint-staged - const envPrefix = match[1]?.trim() ?? ''; - const rest = trimmed.slice(match[0].length).trim(); - const parts = [envPrefix, 'vp staged', rest].filter(Boolean); - result.push(parts.join(' ')); - replaced = true; - matched = true; - break; - } - } - if (matched) { - continue; - } - } - result.push(line); - } - if (!replaced) { - // No lint-staged line found — append after existing content - fs.writeFileSync(hookPath, `${result.join('\n').trimEnd()}\nvp staged\n`); - } else { - fs.writeFileSync(hookPath, result.join('\n')); - } - } else { - fs.writeFileSync(hookPath, 'vp staged\n'); - fs.chmodSync(hookPath, 0o755); - } -} - -/** - * Rewrite only `scripts.prepare` in the root package.json using vite-prepare.yml rules. - * Collapses "husky install" → "husky" before applying ast-grep so that the - * replace-husky rule produces "vp config" with any directory argument preserved. - * Returns the old husky hooks dir (if any) for migration to .vite-hooks. - * Called only when hooks are being set up (not with --no-hooks). - */ -export function rewritePrepareScript(rootDir: string): string | undefined { - const packageJsonPath = path.join(rootDir, 'package.json'); - if (!fs.existsSync(packageJsonPath)) { - return undefined; - } - - let oldDir: string | undefined; - - editJsonFile<{ scripts?: Record }>(packageJsonPath, (pkg) => { - if (!pkg.scripts?.prepare) { - return pkg; - } - - // Collapse "husky install" → "husky" so the ast-grep rule - // produces "vp config" with any directory argument preserved. - const prepare = collapseHuskyInstall(pkg.scripts.prepare); - - const prepareJson = JSON.stringify({ prepare }); - const updated = rewriteScripts(prepareJson, readPrepareRulesYaml()); - if (updated) { - let newPrepare: string = JSON.parse(updated).prepare; - newPrepare = newPrepare.replace( - /\bvp config(?:\s+(?!-)([\w./-]+))?/, - (_match: string, dir: string | undefined) => { - // Capture the old husky dir for hook migration. - // Default husky dir is .husky; custom dirs keep --hooks-dir flag. - oldDir = dir ?? '.husky'; - return dir ? `vp config --hooks-dir ${dir}` : 'vp config'; - }, - ); - pkg.scripts.prepare = newPrepare; - } else if (prepare !== pkg.scripts.prepare) { - // Pre-processing changed the script (husky install → husky) - // but no rule matched — keep the collapsed form - pkg.scripts.prepare = prepare; - } - return pkg; - }); - - return oldDir; -} - -export function setPackageManager( - projectDir: string, - downloadPackageManager: DownloadPackageManagerResult, -) { - // Set the package manager pin. Compatibility-first rule (rfcs/dev-engines.md): - // an existing `packageManager` field or `devEngines.packageManager` declaration - // is the source of truth and is left as-is; otherwise the exact resolved version - // is written to `devEngines.packageManager` (the recommended standard field). - editJsonFile<{ - packageManager?: string; - devEngines?: { packageManager?: unknown; [key: string]: unknown }; - }>(path.join(projectDir, 'package.json'), (pkg) => { - if (!pkg.packageManager && !pkg.devEngines?.packageManager) { - // Only spread a well-formed object: spreading a malformed devEngines value - // (string/array) would corrupt the field with numeric index keys - const devEngines = - typeof pkg.devEngines === 'object' && - pkg.devEngines !== null && - !Array.isArray(pkg.devEngines) - ? pkg.devEngines - : undefined; - pkg.devEngines = { - ...devEngines, - packageManager: { - name: downloadPackageManager.name, - version: downloadPackageManager.version, - onFail: 'download', - }, - }; - } - return pkg; - }); -} - -export type NodeVersionManagerDetection = - | { file: '.nvmrc'; voltaPresent?: true } - | { file: 'package.json'; voltaNodeVersion: string }; - -/** - * Detect a .nvmrc file in the project directory. - * If not found, check for a Volta node version in package.json. - * If either is found, return the relevant info for migration. - * Returns undefined if not found or .node-version already exists. - */ -export function detectNodeVersionManagerFile( - projectPath: string, -): NodeVersionManagerDetection | undefined { - // already has .node-version — skip detection to avoid false positives and preserve existing file - if (fs.existsSync(path.join(projectPath, '.node-version'))) { - return undefined; - } - - const configs = detectConfigs(projectPath); - - // .nvmrc takes priority over volta.node when both are present. - // voltaPresent is carried through so the migration step can remind the user - // to remove the now-redundant volta field from package.json. - if (configs.nvmrcFile) { - return configs.voltaNode ? { file: '.nvmrc', voltaPresent: true } : { file: '.nvmrc' }; - } - - if (configs.voltaNode) { - return { file: 'package.json', voltaNodeVersion: configs.voltaNode }; - } - - return undefined; -} - -/** - * Parse a version alias from a .nvmrc file into a .node-version compatible string. - * Accepts the first line of .nvmrc (pre-trimmed). - * Returns null for unsupported aliases like "system", "default", "iojs". - */ -export function parseNvmrcVersion(alias: string): string | null { - const version = alias.trim(); - - if (!version) { - return null; - } - - // "node" and "stable" mean "latest stable release" which maps closely to lts/*. - // Starting from Node 27, all releases will be LTS, so the gap is shrinking. - // We map these to lts/* and log the conversion so users are aware. - if (version === 'node' || version === 'stable') { - return 'lts/*'; - } - - // "iojs", "system", and "default" have no meaningful equivalent and cannot be auto-migrated. - if (version === 'iojs' || version === 'system' || version === 'default') { - return null; - } - - // LTS aliases (lts/*, lts/iron, etc.) pass through as-is - if (version.startsWith('lts/')) { - return version; - } - - // Strip optional 'v' prefix, then validate as a semver version or range - const normalized = version.startsWith('v') ? version.slice(1) : version; - if (!normalized || !semver.validRange(normalized)) { - return null; - } - return normalized; -} - -/** - * Migrate .nvmrc or Volta node version from package.json to .node-version. - * - For .nvmrc: the source file is removed after migration. - * - For package.json (Volta): the volta field is left as-is; removal is left to the user's discretion. - * Returns true on success, false if migration was skipped or failed. - */ -export function migrateNodeVersionManagerFile( - projectPath: string, - detection: NodeVersionManagerDetection, - report?: MigrationReport, -): boolean { - const nodeVersionPath = path.join(projectPath, '.node-version'); - - // Volta: node version was already extracted during detection — no package.json re-read needed - if (detection.file === 'package.json') { - const { voltaNodeVersion } = detection; - - // Normalize Volta's "lts" alias to the .node-version compatible form - const resolvedVersion = voltaNodeVersion === 'lts' ? 'lts/*' : voltaNodeVersion; - - if (!semver.valid(resolvedVersion) && resolvedVersion !== 'lts/*') { - warnMigration( - `package.json volta.node "${voltaNodeVersion}" is not an exact version. Pin an exact version (e.g. ${voltaNodeVersion}.0 or run \`volta pin node@${voltaNodeVersion}\`) then re-run migration.`, - report, - ); - return false; - } - - fs.writeFileSync(nodeVersionPath, `${resolvedVersion}\n`); - if (report) { - report.manualSteps.push('Remove the "volta" field from package.json'); - report.nodeVersionFileMigrated = true; - } else { - prompts.log.info('You can now remove the "volta" field from package.json manually.'); - } - return true; - } - - // .nvmrc: parse version alias and write to .node-version - const sourcePath = path.join(projectPath, '.nvmrc'); - const content = fs.readFileSync(sourcePath, 'utf8'); - const originalAlias = content.split('\n')[0]?.trim() ?? ''; - const version = parseNvmrcVersion(originalAlias); - - if (!version) { - warnMigration( - '.nvmrc contains an unsupported version alias. Create .node-version manually with your desired Node.js version.', - report, - ); - return false; - } - - // TODO: remove this log once Node 27+ makes all releases LTS, at which point - // "node"/"stable" and "lts/*" will be effectively equivalent. - if (version === 'lts/*' && (originalAlias === 'node' || originalAlias === 'stable')) { - prompts.log.info( - `"${originalAlias}" in .nvmrc is not a specific version; automatically mapping to "lts/*"`, - ); - } - - fs.writeFileSync(nodeVersionPath, `${version}\n`); - fs.unlinkSync(sourcePath); - - if (report) { - report.nodeVersionFileMigrated = true; - // Both .nvmrc and volta were present; .nvmrc was migrated but volta still lingers. - if (detection.voltaPresent) { - report.manualSteps.push('Remove the "volta" field from package.json'); - } - } else if (detection.voltaPresent) { - prompts.log.info('You can now remove the "volta" field from package.json manually.'); - } - return true; -} - -export function warnPackageLevelEslint() { - prompts.log.warn( - 'ESLint detected in workspace packages but no root config found. Package-level ESLint must be migrated manually.', - ); -} - -// Framework-ESLint integration packages we can't migrate cleanly today. -// When any of these is present, the ESLint migration is skipped entirely -// — the user's ESLint setup stays intact and they get told how to proceed -// manually. -// -// `@nuxt/eslint` is a Nuxt module that loads ESLint at runtime via the -// dev server and writes a generated config to `.nuxt/eslint.config.mjs`, -// which the user's `eslint.config.mjs` re-exports. Migrating it -// produces a broken state: `vite.config.ts` references `@nuxt/eslint-plugin` -// (no longer installed) and `nuxt.config.ts` still tries to load the -// removed module. Track at https://github.com/voidzero-dev/vite-plus/issues -// once an issue exists. -const INCOMPATIBLE_ESLINT_INTEGRATIONS = ['@nuxt/eslint'] as const; - -/** - * Detect framework-ESLint integration packages whose ESLint migration is - * known to be incompatible. Returns the offending package name, or - * `undefined` if none is present. - */ -export function detectIncompatibleEslintIntegration( - projectPath: string, - packages?: WorkspacePackage[], -): string | undefined { - const candidates = [projectPath, ...(packages ?? []).map((p) => path.join(projectPath, p.path))]; - for (const candidate of candidates) { - const pkgJsonPath = path.join(candidate, 'package.json'); - if (!fs.existsSync(pkgJsonPath)) { - continue; - } - let pkg: { devDependencies?: Record; dependencies?: Record }; - try { - pkg = readJsonFile(pkgJsonPath) as typeof pkg; - } catch { - continue; - } - for (const name of INCOMPATIBLE_ESLINT_INTEGRATIONS) { - if (pkg.devDependencies?.[name] || pkg.dependencies?.[name]) { - return name; - } - } - } - return undefined; -} - -export function warnIncompatibleEslintIntegration(name: string): void { - prompts.log.warn( - `${name} detected — automatic ESLint migration is skipped. ` + - `${name} wires ESLint into a framework-specific flow that Vite+ cannot migrate cleanly yet. ` + - 'Your ESLint setup is preserved. ' + - `To migrate manually, remove ${name} from package.json and re-run \`vp migrate\`.`, - ); -} - -export function warnLegacyEslintConfig(legacyConfigFile: string) { - prompts.log.warn( - `Legacy ESLint configuration detected (${legacyConfigFile}). ` + - 'Automatic migration to Oxlint requires ESLint v9+ with flat config format (eslint.config.*). ' + - 'Please upgrade to ESLint v9 first: https://eslint.org/docs/latest/use/migrate-to-9.0.0', - ); -} - -export async function confirmEslintMigration(interactive: boolean): Promise { - if (interactive) { - const confirmed = await prompts.confirm({ - message: - 'Migrate ESLint rules to Oxlint using @oxlint/migrate?\n ' + - styleText( - 'gray', - "Oxlint is Vite+'s built-in linter — significantly faster than ESLint with compatible rule support. @oxlint/migrate converts your existing rules automatically.", - ), - initialValue: true, - }); - if (prompts.isCancel(confirmed)) { - cancelAndExit(); - } - return confirmed; - } - return true; -} - -export async function promptEslintMigration( - projectPath: string, - interactive: boolean, - packages?: WorkspacePackage[], -): Promise { - const incompatible = detectIncompatibleEslintIntegration(projectPath, packages); - if (incompatible) { - warnIncompatibleEslintIntegration(incompatible); - return false; - } - const eslintProject = detectEslintProject(projectPath, packages); - if (eslintProject.hasDependency && !eslintProject.configFile && eslintProject.legacyConfigFile) { - warnLegacyEslintConfig(eslintProject.legacyConfigFile); - return false; - } - if (!eslintProject.hasDependency) { - return false; - } - if (!eslintProject.configFile) { - // Packages have eslint but no root config → warn and skip - warnPackageLevelEslint(); - return false; - } - const confirmed = await confirmEslintMigration(interactive); - if (!confirmed) { - return false; - } - const ok = await migrateEslintToOxlint( - projectPath, - interactive, - eslintProject.configFile, - packages, - ); - if (!ok) { - cancelAndExit('ESLint migration failed.', 1); - } - return true; -} - -export function warnPackageLevelPrettier() { - prompts.log.warn( - 'Prettier detected in workspace packages but no root config found. Package-level Prettier must be migrated manually.', - ); -} - -export async function confirmPrettierMigration(interactive: boolean): Promise { - if (interactive) { - const confirmed = await prompts.confirm({ - message: - 'Migrate Prettier to Oxfmt?\n ' + - styleText( - 'gray', - "Oxfmt is Vite+'s built-in formatter that replaces Prettier with faster performance. Your configuration will be converted automatically.", - ), - initialValue: true, - }); - if (prompts.isCancel(confirmed)) { - cancelAndExit(); - } - return confirmed; - } - prompts.log.info('Prettier configuration detected. Auto-migrating to Oxfmt...'); - return true; -} - -export async function promptPrettierMigration( - projectPath: string, - interactive: boolean, - packages?: WorkspacePackage[], -): Promise { - const prettierProject = detectPrettierProject(projectPath, packages); - if (!prettierProject.hasDependency) { - return false; - } - if (!prettierProject.configFile) { - // Packages have prettier but no root config → warn and skip - warnPackageLevelPrettier(); - return false; - } - const confirmed = await confirmPrettierMigration(interactive); - if (!confirmed) { - return false; - } - const ok = await migratePrettierToOxfmt( - projectPath, - interactive, - prettierProject.configFile, - packages, - ); - if (!ok) { - cancelAndExit('Prettier migration failed.', 1); - } - return true; -} +export * from './migrator/shared.ts'; +export * from './migrator/eslint.ts'; +export * from './migrator/prettier.ts'; +export * from './migrator/tsconfig.ts'; +export * from './migrator/framework-shim.ts'; +export * from './migrator/vitest-ecosystem.ts'; +export * from './migrator/catalog.ts'; +export * from './migrator/yarn.ts'; +export * from './migrator/source-scan.ts'; +export * from './migrator/vite-plus-bootstrap.ts'; +export * from './migrator/package-json.ts'; +export * from './migrator/vite-config.ts'; +export * from './migrator/git-hooks.ts'; +export * from './migrator/setup.ts'; +export * from './migrator/core-finalization.ts'; +export * from './migrator/orchestrators.ts'; diff --git a/packages/cli/src/migration/migrator/README.md b/packages/cli/src/migration/migrator/README.md new file mode 100644 index 0000000000..9829767bb6 --- /dev/null +++ b/packages/cli/src/migration/migrator/README.md @@ -0,0 +1,123 @@ +# `migrator/` — migration logic, split by category + +The `vp migrate` implementation used to live in a single ~7,300-line +`packages/cli/src/migration/migrator.ts`. It is now split into category +modules in this directory. `migration/migrator.ts` is a **barrel** that only +re-exports them: + +```ts +// migration/migrator.ts +export * from './migrator/shared.ts'; +export * from './migrator/eslint.ts'; +// ...one line per module +``` + +So **external code keeps importing from `./migrator.ts`** (the barrel) and +nothing outside this directory had to change. + +## Modules + +Pick the file by what a function _does_, not by where it happens to be called. + +| File | Owns | +| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `orchestrators.ts` | Top-level entry points that wire everything together: `rewriteStandaloneProject`, `rewriteMonorepo`, `rewriteMonorepoProject`. | +| `package-json.ts` | `rewritePackageJson` and the direct dependency-spec rewriting it does. | +| `vite-plus-bootstrap.ts` | "Already on Vite+" detection and the bootstrap/reconcile path: `detectVitePlusBootstrapPending`, `ensureVitePlusBootstrap`, `reconcileVitePlusBootstrapPackage`, the `ensure*`/`*Pending`/`*SatisfiesVitePlus` helpers. | +| `catalog.ts` | `pnpm-workspace.yaml` / catalog / overrides / build-allowance writers and catalog-dependency resolvers; bun catalog; `rewriteRootWorkspacePackageJson`; pnpm workspace-settings migration. | +| `vitest-ecosystem.ts` | Detecting direct vitest usage, the override-key ("dependency selector") parsing/dropping logic, managed-override sets, ecosystem alignment, legacy `vite-plus-test` wrapper-alias pruning. | +| `yarn.ts` | `.yarnrc.yml`, Yarn PnP detection, workspace-hoisting fix, webdriverio detection. | +| `source-scan.ts` | Scanning a project's source tree for signals (browser-mode, opt-in providers, `@nuxt/test-utils`, retained upstream-vitest references). | +| `vite-config.ts` | `vite.config.ts` merging, default-config injection, staged-config merge, lazy-plugin wrapping, import rewriting (`rewriteAllImports`), migrated-oxlint-config sanitization, lint-staged removal. | +| `eslint.ts` | ESLint → Oxlint migration, oxlint JS-plugin namespace handling, ESLint prompts/warnings. | +| `prettier.ts` | Prettier → Oxfmt migration and its prompts/warnings. | +| `tsconfig.ts` | `tsconfig.json` cleanup and `types` rewriting. | +| `framework-shim.ts` | Framework (Vue/Astro) shim detection and injection. | +| `git-hooks.ts` | husky / lint-staged → `vp staged` hook migration. | +| `setup.ts` | `packageManager` pin and Node version-manager file (`.nvmrc`/`.node-version`/Volta) migration. | +| `core-finalization.ts` | Finalizing an existing-Vite+ core migration (rules YAML, core package scripts). | +| `shared.ts` | Cross-cutting constants, types, and tiny utilities used by **two or more** modules. The only module that other modules import from directly (see rules). | + +## Rules for adding / changing code + +1. **Add a function to the module that matches its category.** Keep it + `export`ed so the barrel surfaces it. If nothing fits, prefer extending an + existing module over creating a new one; if you do add a module, add a line + for it to `migration/migrator.ts`. + +2. **Importing another module's helper — use the barrel.** Reference + cross-module **functions** via the barrel: + + ```ts + import { managedOverridePackages, rewritePackageJson } from '../migrator.ts'; + ``` + + This creates an import cycle (the barrel re-exports your module), which is + **safe only because** these helpers are referenced _inside function bodies_ + (at runtime), never at module-evaluation time. Preserve that invariant: do + not call a cross-module helper at the top level of a module. + +3. **`shared.ts` is the one exception — import it directly, never via the + barrel.** Things in `shared.ts` (e.g. `REMOVE_PACKAGES`, + `OPT_IN_BROWSER_PROVIDERS`, shared types) are referenced at _module-load_ + time, so they must be imported from a fully-evaluated leaf: + + ```ts + import { REMOVE_PACKAGES, type CatalogDependencyResolver } from './shared.ts'; + ``` + + Keep `shared.ts` a **pure leaf**: it may import external packages and + `../utils/*` / `./report.ts` etc., but it must **not** import from any + sibling module or the barrel. + +4. **Where does a new shared thing go?** A constant / type / helper used by a + single module lives in that module. The moment a second module needs it, + move it to `shared.ts` and `export` it. + +5. **This split is structure only.** The barrel contains no logic; behavior + changes belong in the relevant module and need their unit test (and snap + test) updated, not the barrel. + +## When to add a new module + +Default to extending an existing module. Add a new file only when one of these +holds: + +- **A new self-contained category appears** — usually a new tool/format being + migrated (mirrors `eslint.ts`, `prettier.ts`, `yarn.ts`, `git-hooks.ts`). + Test: you can name its single responsibility without saying "and". +- **An existing module outgrows readability _and_ has a clean seam** — a + cohesive, loosely-coupled sub-cluster. Rough trigger: **>~900–1,000 lines plus + a natural split point** (e.g. `catalog.ts` could become `catalog.ts` + + `pnpm-workspace.ts`). Size alone is not enough. +- **Scattered helpers form a coherent theme** and consolidating them aids + discoverability. + +Do **not** add a file when: the function fits an existing category (extend it); +it's one small helper (owning module, or `shared.ts` if shared); it would be 1–2 +functions with no theme (fragmentation is as bad as a monolith); or it would +have a tight two-way dependency with another module (they belong together). + +When you do add one: give it a single-responsibility name, add its `export *` +line to `migration/migrator.ts`, add a row to the module table above, and decide +its layer — if siblings reference it at **load time** it must be a pure leaf (or +those pieces go in `shared.ts`); helpers used only inside function bodies may +import from the barrel. + +## Validating a change + +From the repo root: + +```bash +vp check # format + lint + type-check +pnpm -F vite-plus exec vitest run src/migration # migration unit tests +pnpm -F vite-plus snap-test-local migration # local migration snap tests +pnpm -F vite-plus snap-test-global migration # global migration snap tests +``` + +Always inspect the `git diff` of `snap-tests*/**/snap.txt` afterwards — the +runner regenerates snapshots and can exit 0 even when output changed. + +A pure reorganization must not change `tsc` results, the unit-test count, or any +`snap.txt`. A behavior change should be accompanied by the matching test/snap +updates. diff --git a/packages/cli/src/migration/migrator/catalog.ts b/packages/cli/src/migration/migrator/catalog.ts new file mode 100644 index 0000000000..a064351788 --- /dev/null +++ b/packages/cli/src/migration/migrator/catalog.ts @@ -0,0 +1,1247 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import semver from 'semver'; +import { Scalar, YAMLMap, YAMLSeq } from 'yaml'; + +import { PackageManager, type WorkspacePackage } from '../../types/index.ts'; +import { + VITEST_AGE_GATE_EXEMPT_PACKAGES, + VITEST_VERSION, + VITE_PLUS_NAME, + VITE_PLUS_OVERRIDE_PACKAGES, + VITE_PLUS_VERSION, + isForceOverrideMode, +} from '../../utils/constants.ts'; +import { editJsonFile, readJsonFile } from '../../utils/json.ts'; +import { type NpmWorkspaces } from '../../utils/workspace.ts'; +import { editYamlFile, readYamlFile, scalarString, type YamlDocument } from '../../utils/yaml.ts'; +import { + dropRemovePackageOverrideKeys, + ensurePnpmExoticSubdepsSetting, + hasDirectVitePlusInstallEntry, + isAlignableVitestEcosystemPackage, + isLegacyWrapperSpec, + managedOverridePackages, + pruneLegacyWrapperAliases, + removeManagedVitestEntry, + removeVitestPeerDependencyRule, + removeYamlMapVitestEntry, + rewriteMonorepoProject, + shouldDropProviderOverrideKey, +} from '../migrator.ts'; +import { + LEGACY_WRAPPER_FALLBACK_VERSIONS, + PROVIDER_OVERRIDE_DROP_NAMES, + REMOVE_PACKAGES, + VITEST_IS_MANAGED_OVERRIDE, + isPlainRecord, + type CatalogDependencyResolver, + type PackageJsonDependencyField, + type PnpmPackageJsonSettings, +} from './shared.ts'; + +// Transitive packages with postinstall scripts that vite-plus's deps drag in +// via `@vitest/browser-webdriverio` → `webdriverio` → `@wdio/utils`. pnpm v10 +// refuses to run these without explicit approval, so `vp migrate` records the +// allow/deny decision up front: deny by default (the user isn't using +// webdriverio), allow when the user actually depends on webdriverio. +const BROWSER_PROVIDER_POSTINSTALL_PACKAGES = ['edgedriver', 'geckodriver'] as const; + +const PUBLIC_PEER_DEPENDENCY_FALLBACKS: Record = { + vite: '*', + vitest: '*', +}; + +const PNPM_WORKSPACE_SETTINGS_MIN_VERSION = '10.6.2'; + +// pnpm 10.5 started reading package.json#pnpm settings from +// pnpm-workspace.yaml, but overrides and peerDependencyRules needed fixes in +// 10.5.1 and 10.6.2 respectively. Use the latter as the atomic migration +// boundary so the complete object can move without splitting its ownership. +export function pnpmSupportsWorkspaceSettings(version: string): boolean { + const coerced = semver.coerce(version); + if (coerced) { + return semver.gte(coerced, PNPM_WORKSPACE_SETTINGS_MIN_VERSION); + } + return version === 'latest' || version === 'next'; +} + +// These are the root package.json#pnpm settings pnpm 10.6.2+ accepts at the +// top level of pnpm-workspace.yaml. Unknown keys may belong to third-party +// tooling and stay in package.json. +const PNPM_WORKSPACE_SETTING_KEYS = [ + 'allowNonAppliedPatches', + 'allowBuilds', + 'allowUnusedPatches', + 'allowedDeprecatedVersions', + 'auditConfig', + 'configDependencies', + 'executionEnv', + 'ignorePatchFailures', + 'ignoredBuiltDependencies', + 'ignoredOptionalDependencies', + 'neverBuiltDependencies', + 'onlyBuiltDependencies', + 'onlyBuiltDependenciesFile', + 'overrides', + 'packageExtensions', + 'patchedDependencies', + 'peerDependencyRules', + 'requiredScripts', + 'supportedArchitectures', + 'updateConfig', +] as const; + +function hasPnpmWorkspaceSettings(pkg: { pnpm?: PnpmPackageJsonSettings }): boolean { + return PNPM_WORKSPACE_SETTING_KEYS.some((key) => Object.hasOwn(pkg.pnpm ?? {}, key)); +} + +export function pnpmPackageJsonSettingsPending(pkg: { pnpm?: PnpmPackageJsonSettings }): boolean { + return ( + hasPnpmWorkspaceSettings(pkg) || (pkg.pnpm !== undefined && Object.keys(pkg.pnpm).length === 0) + ); +} + +export function takePnpmWorkspaceSettings(pkg: { + pnpm?: PnpmPackageJsonSettings; +}): Record | undefined { + if (!pkg.pnpm) { + return undefined; + } + const settings: Record = {}; + for (const key of PNPM_WORKSPACE_SETTING_KEYS) { + if (!Object.hasOwn(pkg.pnpm, key)) { + continue; + } + settings[key] = pkg.pnpm[key]; + delete pkg.pnpm[key]; + } + if (Object.keys(pkg.pnpm).length === 0) { + delete pkg.pnpm; + } + return Object.keys(settings).length > 0 ? settings : undefined; +} + +/** + * Preserve workspace-level siblings while moving the effective package.json + * pnpm settings into pnpm-workspace.yaml. Package values win at scalar leaves, + * while objects merge recursively and arrays retain unique entries from both + * locations. + */ +function mergePnpmWorkspaceSetting(existing: unknown, incoming: unknown): unknown { + if (Array.isArray(existing) && Array.isArray(incoming)) { + const seen = new Set(); + return [...existing, ...incoming].filter((value) => { + const key = JSON.stringify(value); + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); + } + if (isPlainRecord(existing) && isPlainRecord(incoming)) { + const merged: Record = { ...existing }; + for (const [key, value] of Object.entries(incoming)) { + merged[key] = Object.hasOwn(existing, key) + ? mergePnpmWorkspaceSetting(existing[key], value) + : value; + } + return merged; + } + return incoming; +} + +export function migratePnpmSettingsToWorkspaceYaml( + projectPath: string, + settings: Record | undefined, +): void { + if (!settings || Object.keys(settings).length === 0) { + return; + } + const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); + if (!fs.existsSync(pnpmWorkspaceYamlPath)) { + fs.writeFileSync(pnpmWorkspaceYamlPath, ''); + } + editYamlFile(pnpmWorkspaceYamlPath, (doc) => { + const workspace = (doc.toJS() ?? {}) as Record; + for (const [key, value] of Object.entries(settings)) { + // package.json#pnpm was the effective source before migration. Preserve + // that precedence at conflicting leaves while retaining workspace-only + // object properties and array entries. + doc.set(key, doc.createNode(mergePnpmWorkspaceSetting(workspace[key], value))); + } + }); +} + +/** + * Rewrite pnpm-workspace.yaml to add vite-plus dependencies + * @param projectPath - The path to the project + */ +export function rewritePnpmWorkspaceYaml( + projectPath: string, + pnpmMajorVersion: number | undefined, + shouldAllowBrowserBuilds: boolean, + usesVitest: boolean, + vitestEcosystemPackages: ReadonlySet, + writeWorkspaceSettings = true, + catalogAdditions: ReadonlySet = new Set(), +): void { + const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); + if (!fs.existsSync(pnpmWorkspaceYamlPath)) { + fs.writeFileSync(pnpmWorkspaceYamlPath, ''); + } + const managed = managedOverridePackages(usesVitest); + + editYamlFile(pnpmWorkspaceYamlPath, (doc) => { + // catalog + const preferredCatalogSpec = rewriteCatalog( + doc, + usesVitest, + vitestEcosystemPackages, + catalogAdditions, + ); + if (!writeWorkspaceSettings) { + return; + } + + ensurePnpmExoticSubdepsSetting(doc); + if (pnpmMajorVersion !== undefined) { + applyBuildAllowanceToWorkspaceYaml(doc, pnpmMajorVersion, shouldAllowBrowserBuilds); + } + + // overrides + const overrides = doc.getIn(['overrides']); + pruneYamlMapLegacyWrapperAliases(overrides); + // Drop overrides for packages removed by migration (e.g. @vitest/browser*) + // so a stale workspace pin can't force an incompatible version against + // vite-plus's own direct dependency. Bare/versioned global pins + // (`pkg`, `pkg@version`), global-glob selectors (`**/pkg`), and + // `vite-plus`-parented selectors (`vite-plus>pkg`) all reach vite-plus's own + // provider dep and are removed. A selector scoped under a SPECIFIC + // non-vite-plus parent (e.g. `some-app>@vitest/browser-playwright`) only + // constrains that parent's subtree, so it is preserved — see + // `shouldDropProviderOverrideKey`. + if (overrides instanceof YAMLMap) { + const keysSnapshot = overrides.items.map((item) => item.key); + for (const keyNode of keysSnapshot) { + const rawKey = + keyNode instanceof Scalar ? String(keyNode.value ?? '') : String(keyNode ?? ''); + if (shouldDropProviderOverrideKey(rawKey)) { + overrides.delete(keyNode); + } + } + } + // Common case (no direct vitest): actively strip any lingering managed + // `vitest` override so it arrives transitively through vite-plus. + if (!usesVitest) { + removeYamlMapVitestEntry(doc.getIn(['overrides'])); + } + for (const key of Object.keys(managed)) { + const currentVersion = getYamlMapScalarStringValue(overrides, key); + const version = getCatalogDependencySpec(currentVersion, managed[key], true, { + preferredCatalogSpec, + }); + doc.setIn(['overrides', scalarString(key)], scalarString(version)); + } + // remove dependency selector from vite, e.g. "vite-plugin-svgr>vite": "npm:vite@7.0.12" + 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') { + updatedOverrides.delete(item.key); + } + } + } + + // peerDependencyRules.allowAny + let allowAny = doc.getIn(['peerDependencyRules', 'allowAny']) as YAMLSeq>; + if (!allowAny) { + allowAny = new YAMLSeq>(); + } + // Common case: drop any lingering managed `vitest` allowAny entry. + if (!usesVitest && VITEST_IS_MANAGED_OVERRIDE) { + allowAny.items = allowAny.items.filter((n) => n.value !== 'vitest'); + } + const existing = new Set(allowAny.items.map((n) => n.value)); + for (const key of Object.keys(managed)) { + if (!existing.has(key)) { + allowAny.add(scalarString(key)); + } + } + doc.setIn(['peerDependencyRules', 'allowAny'], allowAny); + + // peerDependencyRules.allowedVersions + let allowedVersions = doc.getIn(['peerDependencyRules', 'allowedVersions']) as YAMLMap< + Scalar, + Scalar + >; + if (!allowedVersions) { + allowedVersions = new YAMLMap, Scalar>(); + } + // Common case: drop any lingering managed `vitest` allowedVersions entry. + if (!usesVitest) { + removeYamlMapVitestEntry(allowedVersions); + } + for (const key of Object.keys(managed)) { + // - vite: '*' + allowedVersions.set(scalarString(key), scalarString('*')); + } + doc.setIn(['peerDependencyRules', 'allowedVersions'], allowedVersions); + + // minimumReleaseAgeExclude + if (doc.has('minimumReleaseAge')) { + // Exempt the Vite+-managed packages from the age gate: vite-plus, + // @voidzero-dev/*, the ox* family, and the vitest family. Vite+ pins + // `vitest` to an exact (sometimes freshly published) version and the + // in-tree @vitest/* siblings install transitively at that version, so the + // age gate would otherwise quarantine them and break `vp install`. + const excludes = [ + 'vite-plus', + '@voidzero-dev/*', + 'oxlint', + '@oxlint/*', + 'oxlint-tsgolint', + '@oxlint-tsgolint/*', + 'oxfmt', + '@oxfmt/*', + ...VITEST_AGE_GATE_EXEMPT_PACKAGES, + ]; + let minimumReleaseAgeExclude = doc.getIn(['minimumReleaseAgeExclude']) as YAMLSeq< + Scalar + >; + if (!minimumReleaseAgeExclude) { + minimumReleaseAgeExclude = new YAMLSeq(); + } + const existing = new Set(minimumReleaseAgeExclude.items.map((n) => n.value)); + for (const exclude of excludes) { + if (!existing.has(exclude)) { + minimumReleaseAgeExclude.add(scalarString(exclude)); + } + } + doc.setIn(['minimumReleaseAgeExclude'], minimumReleaseAgeExclude); + } + }); +} + +/** + * Move remaining non-Vite pnpm.overrides from package.json to pnpm-workspace.yaml. + * pnpm ignores workspace-level overrides when pnpm.overrides exists in package.json, + * so all overrides must live in pnpm-workspace.yaml. + */ +export function migratePnpmOverridesToWorkspaceYaml( + projectPath: string, + overrides: Record, +): void { + const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); + editYamlFile(pnpmWorkspaceYamlPath, (doc) => { + for (const [key, value] of Object.entries(overrides)) { + // Always overwrite: package.json value was the effective one before migration + // (pnpm ignores workspace overrides when pnpm.overrides exists in package.json) + doc.setIn(['overrides', scalarString(key)], scalarString(value)); + } + }); +} + +export function applyBuildAllowanceToPackageJsonPnpm( + pnpm: { + allowBuilds?: Record; + onlyBuiltDependencies?: string[]; + }, + major: number, + shouldAllow: boolean, +): void { + if (major >= 10) { + if (shouldAllow) { + // WebdriverIO present -> the edgedriver/geckodriver postinstall MUST run. Write + // `true`, OVERWRITING any stale `false` a prior WebdriverIO-less migration left + // behind (a re-run after adding WebdriverIO would otherwise keep the driver build + // blocked). + for (const name of BROWSER_PROVIDER_POSTINSTALL_PACKAGES) { + (pnpm.allowBuilds ??= {})[name] = true; + } + } + // No WebdriverIO -> vite-plus does NOT manage these postinstalls. edgedriver and + // geckodriver reach the tree only via the opt-in webdriverio provider (an OPTIONAL + // peer of both vite-plus and vitest, so pnpm never auto-installs it); a project that + // does not use it never installs them, so there is nothing to allow or deny. We + // write nothing and leave any user-authored allowBuilds entry (their own trust + // decision) untouched. + } else if (shouldAllow) { + // v9 onlyBuiltDependencies is an allow-list — omission is denial, so we + // only mutate when the user actually needs these packages built. + const list = pnpm.onlyBuiltDependencies ?? []; + const existing = new Set(list); + for (const name of BROWSER_PROVIDER_POSTINSTALL_PACKAGES) { + if (!existing.has(name)) { + list.push(name); + existing.add(name); + } + } + pnpm.onlyBuiltDependencies = list; + } +} + +function applyBuildAllowanceToWorkspaceYaml( + doc: YamlDocument, + major: number, + shouldAllow: boolean, +): void { + if (major >= 10) { + if (shouldAllow) { + // WebdriverIO present -> the edgedriver/geckodriver postinstall MUST run. Set + // `true`, OVERWRITING any stale `false` a prior WebdriverIO-less migration left + // behind (a re-run after adding WebdriverIO would otherwise keep the driver build + // blocked). Mutate an existing map in place (preserving its document position); + // only attach a freshly created one. + const existing = doc.getIn(['allowBuilds']); + const isNew = !(existing instanceof YAMLMap); + const allowBuilds = isNew + ? new YAMLMap, Scalar>() + : (existing as YAMLMap, Scalar>); + for (const name of BROWSER_PROVIDER_POSTINSTALL_PACKAGES) { + allowBuilds.set(scalarString(name), new Scalar(true)); + } + if (isNew) { + doc.setIn(['allowBuilds'], allowBuilds); + } + } + // No WebdriverIO -> vite-plus does NOT manage these postinstalls and leaves any + // user-authored allowBuilds entry untouched (see the package.json sink rationale). + // The drivers reach the tree only via the opt-in webdriverio provider, so a project + // that does not use it never installs them and there is nothing to allow or deny. + } else if (shouldAllow) { + let onlyBuiltDependencies = doc.getIn(['onlyBuiltDependencies']) as YAMLSeq>; + if (!(onlyBuiltDependencies instanceof YAMLSeq)) { + onlyBuiltDependencies = new YAMLSeq>(); + } + const existing = new Set(onlyBuiltDependencies.items.map((n) => n.value)); + for (const name of BROWSER_PROVIDER_POSTINSTALL_PACKAGES) { + if (!existing.has(name)) { + onlyBuiltDependencies.add(scalarString(name)); + } + } + doc.setIn(['onlyBuiltDependencies'], onlyBuiltDependencies); + } +} + +/** + * Rewrite .yarnrc.yml to add vite-plus dependencies + * @param projectPath - The path to the project + */ +// Under Yarn's `node-modules` linker, `nmHoistingLimits: workspaces` STOPS a +// dependency from being hoisted past the workspace that declares it — so every +// workspace that gets a direct `vite-plus` dep receives its OWN physical +// `vitest`/`@vitest/runner` copy instead of sharing one hoisted copy at the +// monorepo root. `vp test` resolves the Vitest runner bin ONCE from the workspace +// root (the root copy) but spawns it with the package as cwd; Vitest's per-package +// Vite server then serves the test graph's `@vitest/runner` from the PACKAGE's own +// copy. The runner process initialises its (root) `@vitest/runner` module instance +// while the test file imports `describe` from the package's DIFFERENT instance +// whose module-level runner is undefined -> `describe(...)` -> `initSuite()` -> +// `validateTags(runner.config, …)` -> `TypeError: Cannot read properties of +// undefined (reading 'config')`. Yarn has no per-package "force-hoist this dep to +// root" lever, so the only reliable dedupe is to let the affected workspaces hoist +// normally (a per-workspace `installConfig.hoistingLimits: none`). See +// `setYarnWorkspaceHoistingOptOut`. +// +// Only `workspaces` is auto-fixable. The stricter `dependencies` limit keeps a +// dependency BELOW each dependent package even when the workspace opts out to +// `none`, so the opt-out does NOT dedupe there — verified with Yarn 4.17: two +// workspaces sharing a dep under root `nmHoistingLimits: dependencies` + per- +// workspace `hoistingLimits: none` still produced two physical copies, whereas +// the same setup under `workspaces` deduped to one root copy. For `dependencies` +// (and for a `workspaces` root where the affected workspace already pins its own +// isolating limit) the migration cannot fix the split from package.json, so it +// WARNS instead of silently leaving a known-broken layout. See +// `applyYarnWorkspaceHoistingFix`. + +/** + * Rewrite catalog in pnpm-workspace.yaml or .yarnrc.yml + * @param doc - The document to rewrite + */ +export function getCatalogDependencySpec( + currentValue: string | undefined, + version: string, + supportCatalog: boolean, + options?: { + dependencyField?: PackageJsonDependencyField; + dependencyName?: string; + packageManager?: PackageManager; + catalogDependencyResolver?: CatalogDependencyResolver; + preferredCatalogSpec?: string; + }, +): 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 ( + options?.dependencyField === 'optionalDependencies' && + options?.packageManager === PackageManager.yarn + ) { + return version; + } + if (!supportCatalog || version.startsWith('file:')) { + return version; + } + return currentValue?.startsWith('catalog:') + ? currentValue + : (options?.preferredCatalogSpec ?? 'catalog:'); +} + +/** + * #1932: under pnpm, an importer that depends on `vite-plus` (which bundles + * `vitest`) needs a DIRECT `vite` devDep so the `vite` override binds vitest's + * required `vite` peer to @voidzero-dev/vite-plus-core. Without a direct edge, + * pnpm's `autoInstallPeers` fabricates a separate upstream `vite` to satisfy the + * peer, splitting vite-plus / vite / vitest into duplicate instances (the extra + * vite also lacks vite's `@voidzero-dev/vite-task-client` integration, breaking + * the `vp test` cache). npm/yarn/bun redirect transitive/peer vite via root + * overrides/resolutions (and drop the aliased vite), so this is pnpm-only, + * mirroring the bun root-package branch in `rewriteRootWorkspacePackageJson`. + * + * A package that already declares `vite` in ANY dependency field, including + * `peerDependencies` (e.g. a vite plugin pinning `vite ^6`), is left untouched + * so its existing version contract is preserved. Call this AFTER `vite-plus` + * has been ensured in the package, so the dependency check sees it. + */ +export function ensureDirectViteForPnpm( + pkg: { + dependencies?: Record; + devDependencies?: Record; + optionalDependencies?: Record; + peerDependencies?: Record; + }, + packageManager: PackageManager, + supportCatalog: boolean, + catalogDependencyResolver?: CatalogDependencyResolver, +): boolean { + const viteOverride = VITE_PLUS_OVERRIDE_PACKAGES.vite; + if (packageManager !== PackageManager.pnpm || !viteOverride) { + return false; + } + const dependsOnVitePlus = + pkg.dependencies?.[VITE_PLUS_NAME] !== undefined || + pkg.devDependencies?.[VITE_PLUS_NAME] !== undefined; + const viteAlreadyDirect = + pkg.dependencies?.vite !== undefined || + pkg.devDependencies?.vite !== undefined || + pkg.optionalDependencies?.vite !== undefined || + pkg.peerDependencies?.vite !== undefined; + if (!dependsOnVitePlus || viteAlreadyDirect) { + return false; + } + // The catalog-vs-alias choice is driven entirely by supportCatalog and the + // (file:/npm:) override spec; the extra getCatalogDependencySpec options only + // matter for an existing value or a peerDependencies field, neither of which + // applies here (we only reach this for a fresh devDependencies entry). + const viteSpec = getCatalogDependencySpec(undefined, viteOverride, supportCatalog, { + preferredCatalogSpec: catalogDependencyResolver?.preferredCatalogSpec, + }); + // Insert `vite` in sorted position rather than appending it: oxfmt sorts + // package.json dependencies and `vp migrate` has no later format pass, so an + // out-of-order key would fail a follow-up `vp check`. + const entries: [string, string][] = Object.entries(pkg.devDependencies ?? {}); + const insertAt = entries.findIndex(([name]) => name > 'vite'); + entries.splice(insertAt === -1 ? entries.length : insertAt, 0, ['vite', viteSpec]); + pkg.devDependencies = Object.fromEntries(entries); + return true; +} + +// A peer declaration does not install Vitest and therefore must not keep a +// workspace-wide managed Vitest catalog alive. Resolve its catalog reference to +// the public peer range before that catalog is pruned, so the surviving peer +// never points at a missing default/named catalog entry. +export function normalizeVitestPeerCatalogSpec( + peerDependencies: Record | undefined, + catalogDependencyResolver?: CatalogDependencyResolver, +): boolean { + if (!peerDependencies) { + return false; + } + const current = peerDependencies.vitest; + if (!current?.startsWith('catalog:')) { + return false; + } + const normalized = getCatalogDependencySpec(current, VITEST_VERSION, true, { + dependencyField: 'peerDependencies', + dependencyName: 'vitest', + catalogDependencyResolver, + }); + if (normalized === current) { + return false; + } + peerDependencies.vitest = normalized; + return true; +} + +function isVitePlusOverrideSpec(value: string): boolean { + return ( + Object.values(VITE_PLUS_OVERRIDE_PACKAGES).includes(value) || + value.startsWith('npm:@voidzero-dev/vite-plus-') + ); +} + +export 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>; + }; + // A missing/absent `workspaces.catalog` resolves identically whether the + // fallback is `undefined` (optional chaining) or `{}`, so this shares the + // exact bun catalog resolution used by the in-memory callers. + return readBunCatalogDependencyResolver(pkg); + } + return undefined; +} + +export function createCatalogDependencyResolverFromCatalogs( + catalog: Record | undefined, + catalogs: Record> | undefined, +): CatalogDependencyResolver { + const preferredCatalogSpec = selectPreferredCatalogSpec(catalog, catalogs); + const resolver = (catalogSpec: string, dependencyName: string) => { + const catalogName = catalogSpec.slice('catalog:'.length); + // pnpm accepts the default catalog in either `catalog` or + // `catalogs.default`, but rejects a workspace that defines both. Both + // `catalog:` and `catalog:default` resolve through that one logical + // default catalog. + if (catalogName && catalogName !== 'default') { + return catalogs?.[catalogName]?.[dependencyName]; + } + return (catalog ?? catalogs?.default)?.[dependencyName]; + }; + return Object.assign(resolver, { preferredCatalogSpec }); +} + +function selectPreferredCatalogSpec( + catalog: Record | undefined, + catalogs: Record> | undefined, +): string { + const candidates: Array<{ spec: string; values: Record }> = []; + if (catalog) { + candidates.push({ spec: 'catalog:', values: catalog }); + } + for (const [name, values] of Object.entries(catalogs ?? {})) { + candidates.push({ + spec: name === 'default' ? 'catalog:' : `catalog:${name}`, + values, + }); + } + + // Keep the managed toolchain together when a project already has a catalog + // for it (for example Vize's `catalogs.vite-stack` and Rari's + // `catalogs.build`). Prefer vite-plus as the strongest signal, followed by + // vite and vitest. Existing dependency references keep their exact catalog + // spec; this choice is for newly injected dependencies and overrides. + for (const dependencyName of [VITE_PLUS_NAME, 'vite', 'vitest']) { + const matching = candidates.find(({ values }) => Object.hasOwn(values, dependencyName)); + if (matching) { + return matching.spec; + } + } + + // Reuse either valid spelling of the default catalog. Do not repurpose an + // unrelated named catalog; when no managed/default catalog exists, create + // the conventional top-level `catalog` instead. + if (catalog || catalogs?.default) { + return 'catalog:'; + } + return '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 pruneYamlMapLegacyWrapperAliases(map: unknown): void { + if (!(map instanceof YAMLMap)) { + return; + } + const stale: Array<{ key: Scalar; fallback: string | undefined }> = []; + for (const item of map.items) { + const value = item.value instanceof Scalar ? item.value.value : undefined; + if (typeof value === 'string' && isLegacyWrapperSpec(value) && item.key instanceof Scalar) { + stale.push({ + key: item.key, + fallback: LEGACY_WRAPPER_FALLBACK_VERSIONS[item.key.value], + }); + } + } + for (const { key, fallback } of stale) { + if (fallback !== undefined) { + map.set(key, scalarString(fallback)); + } else { + map.delete(key); + } + } +} + +export function rewriteCatalog( + doc: YamlDocument, + usesVitest: boolean, + vitestEcosystemPackages: ReadonlySet, + catalogAdditions: ReadonlySet, +): string { + const parsed = doc.toJS() as { + catalog?: Record; + catalogs?: Record>; + } | null; + const preferredCatalogSpec = selectPreferredCatalogSpec(parsed?.catalog, parsed?.catalogs); + const preferredCatalogName = preferredCatalogSpec.slice('catalog:'.length); + const targetPath: readonly string[] = + preferredCatalogName && preferredCatalogName !== 'default' + ? ['catalogs', preferredCatalogName] + : doc.has('catalog') || !doc.hasIn(['catalogs', 'default']) + ? ['catalog'] + : ['catalogs', 'default']; + + rewriteYamlCatalogAtPath( + doc, + targetPath, + true, + usesVitest, + vitestEcosystemPackages, + catalogAdditions, + ); + + if (targetPath[0] !== 'catalog') { + rewriteYamlCatalogAtPath( + doc, + ['catalog'], + false, + usesVitest, + vitestEcosystemPackages, + catalogAdditions, + ); + } + + const catalogs = doc.getIn(['catalogs']); + if (catalogs instanceof YAMLMap) { + for (const item of catalogs.items) { + const catalogName = item.key instanceof Scalar ? item.key.value : undefined; + if ( + typeof catalogName !== 'string' || + !(item.value instanceof YAMLMap) || + (targetPath[0] === 'catalogs' && targetPath[1] === catalogName) + ) { + continue; + } + rewriteYamlCatalogAtPath( + doc, + ['catalogs', catalogName], + false, + usesVitest, + vitestEcosystemPackages, + catalogAdditions, + ); + } + } + + return preferredCatalogSpec; +} + +function rewriteYamlCatalogAtPath( + doc: YamlDocument, + catalogPath: readonly string[], + addMissing: boolean, + usesVitest: boolean, + vitestEcosystemPackages: ReadonlySet, + catalogAdditions: ReadonlySet, +): void { + const managed = managedOverridePackages(usesVitest); + let catalogNode = doc.getIn(catalogPath); + if (!(catalogNode instanceof YAMLMap)) { + if (!addMissing) { + return; + } + catalogNode = new YAMLMap(); + doc.setIn(catalogPath, catalogNode); + } + const catalog = catalogNode as YAMLMap; + + // Common case (no direct vitest): remove any lingering managed `vitest` + // catalog entry so it resolves transitively through vite-plus. + if (!usesVitest) { + removeYamlMapVitestEntry(catalog); + } + for (const [key, value] of Object.entries(managed)) { + // 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 + // ignore setting catalog if value starts with 'file:' + if (value.startsWith('file:') || (!addMissing && !catalog.has(key))) { + continue; + } + catalog.set(scalarString(key), scalarString(value)); + } + if (!VITE_PLUS_VERSION.startsWith('file:') && (addMissing || catalog.has(VITE_PLUS_NAME))) { + catalog.set(scalarString(VITE_PLUS_NAME), scalarString(VITE_PLUS_VERSION)); + } + if (addMissing && VITEST_IS_MANAGED_OVERRIDE) { + for (const name of catalogAdditions) { + if (isAlignableVitestEcosystemPackage(name)) { + catalog.set(scalarString(name), scalarString(VITEST_VERSION)); + } + } + } + for (const name of REMOVE_PACKAGES) { + catalog.delete(name); + } + // Drop any entry still pointing at the deleted `vite-plus-test` wrapper. + pruneYamlMapLegacyWrapperAliases(catalog); + rewriteVitestEcosystemYamlCatalog(catalog, vitestEcosystemPackages); +} + +function rewriteVitestEcosystemYamlCatalog( + catalog: unknown, + vitestEcosystemPackages: ReadonlySet, +): void { + if (!VITEST_IS_MANAGED_OVERRIDE || !(catalog instanceof YAMLMap)) { + return; + } + for (const item of catalog.items) { + const name = item.key instanceof Scalar ? item.key.value : undefined; + if ( + typeof name === 'string' && + vitestEcosystemPackages.has(name) && + isAlignableVitestEcosystemPackage(name) + ) { + catalog.set(item.key, scalarString(VITEST_VERSION)); + } + } +} + +function rewriteCatalogObject( + catalog: Record, + addMissing: boolean, + usesVitest: boolean, + vitestEcosystemPackages: ReadonlySet, +): void { + const managed = managedOverridePackages(usesVitest); + // Common case (no direct vitest): strip a lingering managed `vitest` catalog + // entry so it resolves transitively through vite-plus. + if (!usesVitest) { + removeManagedVitestEntry(catalog); + } + for (const [key, value] of Object.entries(managed)) { + 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]; + } + if (VITEST_IS_MANAGED_OVERRIDE) { + for (const name of Object.keys(catalog)) { + if (vitestEcosystemPackages.has(name) && isAlignableVitestEcosystemPackage(name)) { + catalog[name] = VITEST_VERSION; + } + } + } +} + +function rewriteCatalogsObject( + catalogs: Record>, + usesVitest: boolean, + vitestEcosystemPackages: ReadonlySet, +): void { + for (const catalog of Object.values(catalogs)) { + rewriteCatalogObject(catalog, false, usesVitest, vitestEcosystemPackages); + } +} + +/** + * Write catalog entries to root package.json for bun. + * Bun stores catalogs in package.json under the `catalog` key, + * unlike pnpm which uses pnpm-workspace.yaml. + * @see https://bun.sh/docs/pm/catalogs + */ +export function rewriteBunCatalog( + projectPath: string, + usesVitest: boolean, + vitestEcosystemPackages: ReadonlySet, +): void { + const packageJsonPath = path.join(projectPath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return; + } + const managed = managedOverridePackages(usesVitest); + + 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 = { + ...(useWorkspacesCatalog ? workspacesObj?.catalog : pkg.catalog), + }; + + rewriteCatalogObject(catalog, true, usesVitest, vitestEcosystemPackages); + pruneLegacyWrapperAliases(catalog); + + if (useWorkspacesCatalog) { + workspacesObj.catalog = catalog; + if (pkg.catalog) { + rewriteCatalogObject(pkg.catalog, false, usesVitest, vitestEcosystemPackages); + pruneLegacyWrapperAliases(pkg.catalog); + } + } else { + pkg.catalog = catalog; + if (workspacesObj?.catalog) { + rewriteCatalogObject(workspacesObj.catalog, false, usesVitest, vitestEcosystemPackages); + pruneLegacyWrapperAliases(workspacesObj.catalog); + } + } + if (workspacesObj?.catalogs) { + rewriteCatalogsObject(workspacesObj.catalogs, usesVitest, vitestEcosystemPackages); + for (const named of Object.values(workspacesObj.catalogs)) { + pruneLegacyWrapperAliases(named); + } + } + if (pkg.catalogs) { + rewriteCatalogsObject(pkg.catalogs, usesVitest, vitestEcosystemPackages); + for (const named of Object.values(pkg.catalogs)) { + pruneLegacyWrapperAliases(named); + } + } + + // bun overrides support catalog: references + const overrides: Record = { ...pkg.overrides }; + pruneLegacyWrapperAliases(overrides); + // Common case (no direct vitest): strip a lingering managed `vitest` + // override (string-valued only — a nested user override is left intact; + // removeManagedVitestEntry also no-ops when vitest is not a managed key). + if (!usesVitest && typeof overrides.vitest === 'string') { + removeManagedVitestEntry(overrides); + } + for (const [key, value] of Object.entries(managed)) { + const current = overrides[key] as unknown; + // A nested object value is a user override scoped under this managed key, + // not a version pin — leave it intact (getCatalogDependencySpec expects a + // string and would otherwise clobber it / throw on `.startsWith`). + if (current !== undefined && typeof current !== 'string') { + continue; + } + overrides[key] = getCatalogDependencySpec(current, value, true); + } + pkg.overrides = overrides; + + return pkg; + }); +} + +/** + * Rewrite root workspace package.json to add vite-plus dependencies + * @param projectPath - The path to the project + */ +export function rewriteRootWorkspacePackageJson( + projectPath: string, + packageManager: PackageManager, + skipStagedMigration?: boolean, + catalogDependencyResolver?: CatalogDependencyResolver, + // Forwarded to `rewriteMonorepoProject` so the per-root lint-config + // sanitizer can see hoisted deps in sibling workspace packages, not + // just the root's own `package.json`. + packages?: WorkspacePackage[], + pnpmMajorVersion?: number, + pnpmVersion?: string, + shouldAllowBrowserBuilds = false, + // Workspace-wide direct-vitest signal: the root resolution/override sinks are + // shared by every package, so `vitest` stays managed here iff ANY package uses + // vitest directly. + workspaceUsesVitest = true, +): void { + const packageJsonPath = path.join(projectPath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return; + } + const managed = managedOverridePackages(workspaceUsesVitest); + + let movedPnpmSettings: Record | undefined; + editJsonFile<{ + resolutions?: Record; + overrides?: Record; + devDependencies?: Record; + dependencies?: Record; + peerDependencies?: Record; + optionalDependencies?: Record; + pnpm?: PnpmPackageJsonSettings; + }>(packageJsonPath, (pkg) => { + // Strip stale `vite-plus-test` wrapper aliases before injecting new overrides + // so the deleted wrapper doesn't survive migration in any sink. + pruneLegacyWrapperAliases(pkg.resolutions); + pruneLegacyWrapperAliases(pkg.overrides); + pruneLegacyWrapperAliases(pkg.pnpm?.overrides); + // Drop stale provider overrides/resolutions (REMOVE_PACKAGES + the now + // user-owned opt-in providers, webdriverio/playwright) from the npm/bun + // `overrides` and yarn `resolutions` sinks before re-merging managed + // overrides. A leftover pin would conflict with the migrated direct + // `@vitest/browser-webdriverio` / `@vitest/browser-playwright` dep — npm + // hard-fails with EOVERRIDE, and yarn/bun would force the stale version over + // the bundled-vitest-aligned 4.1.9. (The pnpm sinks are pruned below.) + dropRemovePackageOverrideKeys(pkg.resolutions); + dropRemovePackageOverrideKeys(pkg.overrides); + // Common case (no workspace-wide direct vitest): strip a lingering managed + // `vitest` from the shared root sinks so it isn't re-pinned. + if (!workspaceUsesVitest) { + removeManagedVitestEntry(pkg.resolutions); + removeManagedVitestEntry(pkg.overrides); + } + if (packageManager === PackageManager.yarn) { + pkg.resolutions = { + ...pkg.resolutions, + // FIXME: yarn don't support catalog on resolutions + // https://github.com/yarnpkg/berry/issues/6979 + ...managed, + }; + } else if (packageManager === PackageManager.npm) { + pkg.overrides = { + ...pkg.overrides, + ...managed, + }; + } else if (packageManager === PackageManager.bun) { + // bun overrides are handled in rewriteBunCatalog() with catalog: references + // 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. + // See https://github.com/oven-sh/bun/issues/8406. + pkg.devDependencies = { + ...pkg.devDependencies, + vite: getCatalogDependencySpec( + pkg.devDependencies?.vite, + VITE_PLUS_OVERRIDE_PACKAGES.vite, + true, + ), + }; + } else if (packageManager === PackageManager.pnpm) { + const overrideKeys = Object.keys(managed); + const usePnpmWorkspaceSettings = pnpmSupportsWorkspaceSettings(pnpmVersion ?? ''); + if (!usePnpmWorkspaceSettings) { + // Strip selector-shaped overrides (e.g. `parent>@vitest/browser-playwright`) + // whose target is a removed package, before re-merging the user's + // overrides into the new pnpm config. + dropRemovePackageOverrideKeys(pkg.pnpm?.overrides); + // Common case: drop a lingering managed `vitest` override before merging. + if (!workspaceUsesVitest) { + removeManagedVitestEntry(pkg.pnpm?.overrides); + } + if (!workspaceUsesVitest && pkg.pnpm?.peerDependencyRules) { + removeVitestPeerDependencyRule(pkg.pnpm.peerDependencyRules); + } + pkg.pnpm = { + ...pkg.pnpm, + overrides: { + ...pkg.pnpm?.overrides, + ...managed, + ...(isForceOverrideMode() ? { [VITE_PLUS_NAME]: VITE_PLUS_VERSION } : {}), + }, + peerDependencyRules: { + ...pkg.pnpm?.peerDependencyRules, + allowAny: [ + ...new Set([...(pkg.pnpm?.peerDependencyRules?.allowAny ?? []), ...overrideKeys]), + ], + allowedVersions: { + ...pkg.pnpm?.peerDependencyRules?.allowedVersions, + ...Object.fromEntries(overrideKeys.map((key) => [key, '*'])), + }, + }, + }; + } else { + for (const key of [...overrideKeys, ...PROVIDER_OVERRIDE_DROP_NAMES]) { + if (pkg.resolutions?.[key]) { + delete pkg.resolutions[key]; + } + } + movedPnpmSettings = takePnpmWorkspaceSettings(pkg); + } + // remove dependency selectors targeting vite (e.g. "vite-plugin-svgr>vite") + for (const key in pkg.pnpm?.overrides) { + if (key.includes('>')) { + const splits = key.split('>'); + if (splits[splits.length - 1].trim() === 'vite') { + delete pkg.pnpm.overrides[key]; + } + } + } + if (pnpmMajorVersion !== undefined && pkg.pnpm) { + applyBuildAllowanceToPackageJsonPnpm(pkg.pnpm, pnpmMajorVersion, shouldAllowBrowserBuilds); + } + } + + // ensure vite-plus is in devDependencies — skip when it already lives in + // `dependencies` or `devDependencies` so it isn't duplicated across groups. + if (!hasDirectVitePlusInstallEntry(pkg)) { + pkg.devDependencies = { + ...pkg.devDependencies, + [VITE_PLUS_NAME]: + packageManager === PackageManager.npm || VITE_PLUS_VERSION.startsWith('file:') + ? VITE_PLUS_VERSION + : (catalogDependencyResolver?.preferredCatalogSpec ?? 'catalog:'), + }; + } + ensureDirectViteForPnpm(pkg, packageManager, true, catalogDependencyResolver); + return pkg; + }); + + migratePnpmSettingsToWorkspaceYaml(projectPath, movedPnpmSettings); + + // rewrite package.json — `projectPath` IS the workspace root here, so + // `workspaceContext.rootDir` matches it; sanitizer resolves + // sibling-package paths against `projectPath`. + rewriteMonorepoProject( + projectPath, + packageManager, + skipStagedMigration, + undefined, + undefined, + catalogDependencyResolver, + packages ? { rootDir: projectPath, packages } : undefined, + true, + ); +} + +export function readPnpmWorkspaceCatalogDependencyResolver( + projectPath: string, +): CatalogDependencyResolver | undefined { + 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); +} + +export function readPnpmWorkspaceOverrides( + projectPath: string, +): Record | undefined { + const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); + if (!fs.existsSync(pnpmWorkspaceYamlPath)) { + return undefined; + } + const doc = readYamlFile(pnpmWorkspaceYamlPath) as { overrides?: Record } | null; + return doc?.overrides; +} + +export function readPnpmWorkspacePeerDependencyRules( + projectPath: string, +): { allowAny?: string[]; allowedVersions?: Record } | undefined { + const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); + if (!fs.existsSync(pnpmWorkspaceYamlPath)) { + return undefined; + } + const doc = readYamlFile(pnpmWorkspaceYamlPath) as { + peerDependencyRules?: { allowAny?: string[]; allowedVersions?: Record }; + } | null; + return doc?.peerDependencyRules; +} + +export function ensurePnpmWorkspacePackages( + projectPath: string, + workspacePatterns: string[], +): boolean { + if (workspacePatterns.length === 0) { + return false; + } + const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); + let changed = false; + editYamlFile(pnpmWorkspaceYamlPath, (doc) => { + if (doc.has('packages')) { + return; + } + const packages = new YAMLSeq>(); + for (const pattern of workspacePatterns) { + packages.add(scalarString(pattern)); + } + doc.set('packages', packages); + changed = true; + }); + return changed; +} + +export function readBunCatalogDependencyResolver(pkg: { + workspaces?: NpmWorkspaces; + catalog?: Record; + catalogs?: Record>; +}): CatalogDependencyResolver { + const workspacesObj = pkg.workspaces && !Array.isArray(pkg.workspaces) ? pkg.workspaces : {}; + const fromWorkspaces = createCatalogDependencyResolverFromCatalogs( + workspacesObj.catalog, + workspacesObj.catalogs, + ); + const fromPkg = createCatalogDependencyResolverFromCatalogs(pkg.catalog, pkg.catalogs); + const resolver = (catalogSpec: string, dependencyName: string) => + fromWorkspaces(catalogSpec, dependencyName) ?? fromPkg(catalogSpec, dependencyName); + return Object.assign(resolver, { + preferredCatalogSpec: + workspacesObj.catalog || workspacesObj.catalogs + ? fromWorkspaces.preferredCatalogSpec + : fromPkg.preferredCatalogSpec, + }); +} diff --git a/packages/cli/src/migration/migrator/core-finalization.ts b/packages/cli/src/migration/migrator/core-finalization.ts new file mode 100644 index 0000000000..30fb40ab92 --- /dev/null +++ b/packages/cli/src/migration/migrator/core-finalization.ts @@ -0,0 +1,138 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { rewriteScripts } from '../../../binding/index.js'; +import { type WorkspacePackage } from '../../types/index.ts'; +import { editJsonFile, readJsonFile } from '../../utils/json.ts'; +import { rulesDir } from '../../utils/path.ts'; +import { hasTsconfigTypesToRewrite, rewriteAllImports, rewriteTsconfigTypes } from '../migrator.ts'; +import { type MigrationReport } from '../report.ts'; + +const RULES_YAML_PATH = path.join(rulesDir, 'vite-tools.yml'); + +const PREPARE_RULES_YAML_PATH = path.join(rulesDir, 'vite-prepare.yml'); + +// Cache YAML content to avoid repeated disk reads (called once per package in monorepos) +let cachedRulesYaml: string | undefined; +let cachedRulesYamlNoLintStaged: string | undefined; +let cachedPrepareRulesYaml: string | undefined; + +export function readRulesYaml(): string { + cachedRulesYaml ??= fs.readFileSync(RULES_YAML_PATH, 'utf8'); + return cachedRulesYaml; +} + +export function getScriptRulesYaml(skipStagedMigration?: boolean): string { + const yaml = readRulesYaml(); + if (!skipStagedMigration) { + return yaml; + } + cachedRulesYamlNoLintStaged ??= yaml + .split('\n\n\n') + .filter((block) => !block.includes('id: replace-lint-staged')) + .join('\n\n\n'); + return cachedRulesYamlNoLintStaged; +} + +export function readPrepareRulesYaml(): string { + cachedPrepareRulesYaml ??= fs.readFileSync(PREPARE_RULES_YAML_PATH, 'utf8'); + return cachedPrepareRulesYaml; +} + +type CoreMigrationWorkspace = { + rootDir: string; + packages?: WorkspacePackage[]; +}; + +export type PendingCoreMigration = { + scripts: boolean; + tsconfigTypes: boolean; +}; + +export type CoreMigrationFinalizationResult = { + scripts: boolean; + tsconfigTypes: boolean; + imports: boolean; +}; + +function getCoreMigrationProjectPaths(workspaceInfo: CoreMigrationWorkspace): string[] { + return [ + workspaceInfo.rootDir, + ...(workspaceInfo.packages ?? []).map((pkg) => path.join(workspaceInfo.rootDir, pkg.path)), + ]; +} + +function hasCorePackageScriptRewrites(projectPath: string): boolean { + const packageJsonPath = path.join(projectPath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return false; + } + const pkg = readJsonFile(packageJsonPath) as { scripts?: Record }; + if (!pkg.scripts) { + return false; + } + return !!rewriteScripts(JSON.stringify(pkg.scripts), getScriptRulesYaml(true)); +} + +function rewriteCorePackageScripts(projectPath: string): boolean { + const packageJsonPath = path.join(projectPath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return false; + } + + let changed = false; + editJsonFile<{ scripts?: Record }>(packageJsonPath, (pkg) => { + if (!pkg.scripts) { + return undefined; + } + const updated = rewriteScripts(JSON.stringify(pkg.scripts), getScriptRulesYaml(true)); + if (!updated) { + return undefined; + } + pkg.scripts = JSON.parse(updated); + changed = true; + return pkg; + }); + return changed; +} + +export function detectPendingCoreMigration( + workspaceInfo: CoreMigrationWorkspace, +): PendingCoreMigration { + const projectPaths = getCoreMigrationProjectPaths(workspaceInfo); + return { + scripts: projectPaths.some((projectPath) => hasCorePackageScriptRewrites(projectPath)), + tsconfigTypes: projectPaths.some((projectPath) => hasTsconfigTypesToRewrite(projectPath)), + }; +} + +export function finalizeCoreMigrationForExistingVitePlus( + workspaceInfo: CoreMigrationWorkspace, + silent = false, + report?: MigrationReport, + pending = detectPendingCoreMigration(workspaceInfo), +): CoreMigrationFinalizationResult { + const projectPaths = getCoreMigrationProjectPaths(workspaceInfo); + const result: CoreMigrationFinalizationResult = { + scripts: false, + tsconfigTypes: false, + imports: false, + }; + + if (pending.scripts) { + for (const projectPath of projectPaths) { + result.scripts = rewriteCorePackageScripts(projectPath) || result.scripts; + } + } + + if (pending.tsconfigTypes) { + for (const projectPath of projectPaths) { + result.tsconfigTypes = + rewriteTsconfigTypes(projectPath, silent, report) || result.tsconfigTypes; + } + } + + result.imports = rewriteAllImports(workspaceInfo.rootDir, silent, report, true); + + return result; +} diff --git a/packages/cli/src/migration/migrator/eslint.ts b/packages/cli/src/migration/migrator/eslint.ts new file mode 100644 index 0000000000..5e7e71bc9c --- /dev/null +++ b/packages/cli/src/migration/migrator/eslint.ts @@ -0,0 +1,894 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { styleText } from 'node:util'; + +import * as prompts from '@voidzero-dev/vite-plus-prompts'; +import { type OxlintConfig } from 'oxlint'; + +import { rewriteEslint } from '../../../binding/index.js'; +import { type WorkspacePackage } from '../../types/index.ts'; +import { runCommandSilently } from '../../utils/command.ts'; +import { editJsonFile, isJsonFile, readJsonFile } from '../../utils/json.ts'; +import { displayRelative } from '../../utils/path.ts'; +import { cancelAndExit } from '../../utils/prompts.ts'; +import { getSpinner } from '../../utils/spinner.ts'; +import { hasBaseUrlInTsconfig } from '../../utils/tsconfig.ts'; +import { detectConfigs } from '../detector.ts'; +import { type MigrationReport } from '../report.ts'; +import { + LINT_STAGED_JSON_CONFIG_FILES, + LINT_STAGED_OTHER_CONFIG_FILES, + warnMigration, +} from './shared.ts'; + +// Plugins Oxlint resolves natively (no JS import). Source: +// `LintPluginOptionsSchema` in `node_modules/oxlint/dist/index.d.ts`. +// Anything else in the merged `lint.plugins[]` after migration is a +// reference left over from `@oxlint/migrate` that won't resolve at lint +// time. +const OXLINT_NATIVE_PLUGINS = new Set([ + 'eslint', + 'react', + 'unicorn', + 'typescript', + 'oxc', + 'import', + 'jsdoc', + 'jest', + 'vitest', + 'jsx-a11y', + 'nextjs', + 'react-perf', + 'promise', + 'node', + 'vue', +]); + +export function detectEslintProject( + projectPath: string, + packages?: WorkspacePackage[], +): { + hasDependency: boolean; + configFile?: string; + legacyConfigFile?: string; +} { + const packageJsonPath = path.join(projectPath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return { hasDependency: false }; + } + const pkg = readJsonFile(packageJsonPath) as { + devDependencies?: Record; + dependencies?: Record; + }; + let hasDependency = !!(pkg.devDependencies?.eslint || pkg.dependencies?.eslint); + const configs = detectConfigs(projectPath); + let configFile = configs.eslintConfig; + const legacyConfigFile = configs.eslintLegacyConfig; + + // If root doesn't have eslint dependency, check workspace packages + if (!hasDependency && packages) { + for (const wp of packages) { + const pkgJsonPath = path.join(projectPath, wp.path, 'package.json'); + if (!fs.existsSync(pkgJsonPath)) { + continue; + } + const wpPkg = readJsonFile(pkgJsonPath) as { + devDependencies?: Record; + dependencies?: Record; + }; + if (wpPkg.devDependencies?.eslint || wpPkg.dependencies?.eslint) { + hasDependency = true; + break; + } + } + } + + return { hasDependency, configFile, legacyConfigFile }; +} + +/** + * Run a `vp dlx @oxlint/migrate` step with graceful error handling. + * Returns true on success, false on failure (spawn error or non-zero exit). + */ +async function runOxlintMigrateStep( + vpBin: string, + cwd: string, + migratePackage: string, + args: string[], + spinner: ReturnType, + failMessage: string, + manualHint: string, +): Promise { + try { + const result = await runCommandSilently({ + command: vpBin, + args: ['dlx', migratePackage, ...args], + cwd, + envs: process.env, + }); + if (result.exitCode !== 0) { + spinner.stop(failMessage); + const stderr = result.stderr.toString().trim(); + if (stderr) { + prompts.log.warn(`⚠ ${stderr}`); + } + prompts.log.info(manualHint); + return false; + } + return true; + } catch { + spinner.stop(failMessage); + prompts.log.info(manualHint); + return false; + } +} + +export async function migrateEslintToOxlint( + projectPath: string, + interactive: boolean, + eslintConfigFile?: string, + packages?: WorkspacePackage[], + options?: { silent?: boolean; report?: MigrationReport }, +): Promise { + const vpBin = process.env.VP_CLI_BIN ?? 'vp'; + const spinner = options?.silent + ? { + start: () => {}, + stop: () => {}, + pause: () => {}, + resume: () => {}, + cancel: () => {}, + error: () => {}, + clear: () => {}, + message: () => {}, + isCancelled: false, + } + : getSpinner(interactive); + + // Steps 1-2: Only run @oxlint/migrate if there's an eslint config at root + if (eslintConfigFile) { + // Pin @oxlint/migrate to the bundled oxlint version. + // @ts-expect-error — resolved at runtime from dist/ → dist/versions.js + const { versions } = await import('../versions.js'); + const migratePackage = `@oxlint/migrate@${versions.oxlint}`; + const migrateArgs = [ + '--merge', + ...(!hasBaseUrlInTsconfig(projectPath) ? ['--type-aware'] : []), + '--with-nursery', + '--details', + ]; + + // Step 1: Generate .oxlintrc.json from ESLint config + spinner.start('Migrating ESLint config to Oxlint...'); + const migrateOk = await runOxlintMigrateStep( + vpBin, + projectPath, + migratePackage, + migrateArgs, + spinner, + 'ESLint migration failed', + `You can run \`vp dlx ${migratePackage} ${migrateArgs.join(' ')}\` manually later`, + ); + if (!migrateOk) { + return false; + } + spinner.stop('ESLint config migrated to .oxlintrc.json'); + + // Step 2: Replace eslint-disable comments with oxlint-disable + spinner.start('Replacing ESLint comments with Oxlint equivalents...'); + const replaceOk = await runOxlintMigrateStep( + vpBin, + projectPath, + migratePackage, + ['--replace-eslint-comments'], + spinner, + 'ESLint comment replacement failed', + `You can run \`vp dlx ${migratePackage} --replace-eslint-comments\` manually later`, + ); + if (replaceOk) { + spinner.stop('ESLint comments replaced'); + } + // Continue with cleanup regardless — .oxlintrc.json was generated successfully + } + + if (options?.report) { + options.report.eslintMigrated = true; + } + + // Read the generated `.oxlintrc.json` to find any packages it references + // in `lint.jsPlugins`. Those packages need to stay in `package.json` so + // Oxlint can actually `import()` them at lint time — without this carve-out, + // the next step would strip them via `isEslintEcosystemDep` and we'd + // immediately invalidate the config we just generated. Local-path + // specifiers (`./X`, `../X`, `/X`) are skipped — they're paths, not + // package names, and have no `package.json` entry to preserve. + const preserveJsPlugins = collectJsPluginPackageNames(projectPath); + + // Step 3-5: Cleanup runs uniformly across the root and every workspace + // package — delete eslint config files, scrub ESLint-ecosystem deps from + // package.json, and rewrite eslint references in any local lint-staged + // config. A monorepo running `vp migrate` is treated as adopted as a + // whole; there's no per-package opt-out today. If a workspace package + // publishes a shared ESLint preset that you want to keep intact, exclude + // it from your `pnpm-workspace.yaml` / `workspaces` before running + // `vp migrate`, then add it back afterwards. + const cleanupTargets = [ + projectPath, + ...(packages ?? []).map((p) => path.join(projectPath, p.path)), + ]; + for (const target of cleanupTargets) { + if (!fs.existsSync(path.join(target, 'package.json'))) { + continue; + } + deleteEslintConfigFiles(target, options?.report, options?.silent); + rewriteEslintPackageJson(path.join(target, 'package.json'), preserveJsPlugins); + rewriteEslintLintStagedConfigFiles(target, options?.report); + } + + return true; +} + +/** + * Read `/.oxlintrc.json` (if any) and collect the package + * names referenced via `lint.jsPlugins[]` string entries. Object-form + * entries (`{ name, specifier }`) and local-path specifiers (`./X`, + * `../X`, `/X`) are excluded — neither maps to a `package.json` entry + * we'd accidentally strip. + */ +function collectJsPluginPackageNames(projectPath: string): Set { + const out = new Set(); + const oxlintConfigPath = path.join(projectPath, '.oxlintrc.json'); + if (!fs.existsSync(oxlintConfigPath)) { + return out; + } + let config: OxlintConfig; + try { + config = readJsonFile(oxlintConfigPath, true) as OxlintConfig; + } catch { + return out; + } + const collectFrom = (jsPlugins: OxlintConfig['jsPlugins']): void => { + for (const entry of jsPlugins ?? []) { + if (typeof entry !== 'string') { + continue; + } + if (entry.startsWith('./') || entry.startsWith('../') || entry.startsWith('/')) { + continue; + } + out.add(entry); + } + }; + collectFrom(config.jsPlugins); + if (Array.isArray(config.overrides)) { + for (const override of config.overrides) { + collectFrom(override.jsPlugins); + } + } + return out; +} + +function deleteEslintConfigFiles(basePath: string, report?: MigrationReport, silent = false): void { + const configs = detectConfigs(basePath); + for (const file of [configs.eslintConfig, configs.eslintLegacyConfig]) { + if (file) { + const configPath = path.join(basePath, file); + if (fs.existsSync(configPath)) { + fs.unlinkSync(configPath); + if (report) { + report.removedConfigCount++; + } + if (!silent) { + prompts.log.success(`✔ Removed ${displayRelative(configPath)}`); + } + } + } + } +} + +// Bare names of packages whose sole purpose is to support ESLint. Removed +// at root cleanup. Reusable AST libraries published under +// `@typescript-eslint/*` (`utils`, `typescript-estree`, `scope-manager`, +// `types`) are deliberately absent so codemods and doc generators that +// import them directly keep working after migration. +const ESLINT_ECOSYSTEM_NAMES = new Set([ + 'eslint', + 'typescript-eslint', + 'eslintrc', + 'eslint-utils', + 'eslint-visitor-keys', + 'eslint-scope', + 'eslint-define-config', + 'eslint-doc-generator', + // ESLint-only typescript-eslint entry points: + '@typescript-eslint/eslint-plugin', + '@typescript-eslint/parser', + '@typescript-eslint/rule-tester', + // Note: framework-ESLint integration modules (e.g. `@nuxt/eslint`) + // are NOT listed here. They short-circuit the entire ESLint + // migration via `INCOMPATIBLE_ESLINT_INTEGRATIONS`, so this list is + // never consulted for them. Keeping them out avoids duplicating the + // "what to do about Nuxt" decision in two places. +]); + +// Flat name prefixes that mark an ESLint-only package. +const ESLINT_ECOSYSTEM_PREFIXES = ['eslint-plugin-', 'eslint-config-', 'eslint-formatter-']; + +// Scopes whose every package is part of the ESLint ecosystem. +// @eslint/* — official ESLint scope (e.g. @eslint/js, @eslint/eslintrc) +// @eslint-community/* — community-maintained ESLint dependencies +// @angular-eslint/* — Angular's ESLint integration family +const ESLINT_ECOSYSTEM_SCOPES = ['@eslint/', '@eslint-community/', '@angular-eslint/']; + +/** + * Decide whether a dependency entry should be removed alongside `eslint` + * itself. The set is intentionally broad: anything whose only purpose is + * to extend, configure, format, or wire ESLint becomes dead weight after + * migration. `@types/` packages are checked symmetrically with `` + * so type-only counterparts of removed runtime packages also go. + */ +function isEslintEcosystemDep(name: string): boolean { + const stripped = name.startsWith('@types/') ? name.slice('@types/'.length) : name; + if (ESLINT_ECOSYSTEM_NAMES.has(stripped)) { + return true; + } + if (ESLINT_ECOSYSTEM_PREFIXES.some((p) => stripped.startsWith(p))) { + return true; + } + if (ESLINT_ECOSYSTEM_SCOPES.some((s) => stripped.startsWith(s))) { + return true; + } + // Scoped plugins/configs/formatters, e.g.: + // @vue/eslint-config-typescript + // @stylistic/eslint-plugin-ts + // @vitest/eslint-plugin + if (/^@[^/]+\/eslint-(plugin|config|formatter)(-.+)?$/.test(stripped)) { + return true; + } + return false; +} + +/** + * Rewrite a project's `package.json` after ESLint has been migrated to + * Oxlint: drop every ESLint-ecosystem dependency (see + * `isEslintEcosystemDep`), strip empty containers, and rewrite eslint + * tokens in scripts / lint-staged. Applied uniformly to the root and to + * every workspace package — the migration treats the whole workspace as + * in scope for adoption, so a half-cleanup at the workspace level would + * be inconsistent with the rest of the flow (which already replaces + * vite-related overrides and adds vite-plus across all packages). + * + * `preserveJsPlugins` names packages that `@oxlint/migrate` referenced + * via `lint.jsPlugins` and that Oxlint will need to `import()` at lint + * time. They override `isEslintEcosystemDep` so the generated config + * isn't immediately invalidated by the cleanup step. + */ +export function rewriteEslintPackageJson( + packageJsonPath: string, + preserveJsPlugins: ReadonlySet = new Set(), +): void { + editJsonFile<{ + devDependencies?: Record; + dependencies?: Record; + peerDependencies?: Record; + optionalDependencies?: Record; + scripts?: Record; + 'lint-staged'?: Record; + }>(packageJsonPath, (pkg) => { + let changed = false; + for (const field of [ + 'devDependencies', + 'dependencies', + 'peerDependencies', + 'optionalDependencies', + ] as const) { + const deps = pkg[field]; + if (!deps) { + continue; + } + let removedAny = false; + for (const name of Object.keys(deps)) { + if (preserveJsPlugins.has(name)) { + continue; + } + if (isEslintEcosystemDep(name)) { + delete deps[name]; + changed = true; + removedAny = true; + } + } + // Drop the field entirely if our cleanup emptied it — avoid + // leaving `"devDependencies": {}` noise in the output. + if (removedAny && Object.keys(deps).length === 0) { + delete pkg[field]; + } + } + if (pkg.scripts) { + const updated = rewriteEslint(JSON.stringify(pkg.scripts)); + if (updated) { + pkg.scripts = JSON.parse(updated); + changed = true; + } + } + if (pkg['lint-staged']) { + const updated = rewriteEslint(JSON.stringify(pkg['lint-staged'])); + if (updated) { + pkg['lint-staged'] = JSON.parse(updated); + changed = true; + } + } + return changed ? pkg : undefined; + }); +} + +/** + * Rewrite tool references in lint-staged config files (JSON ones are rewritten, + * non-JSON ones get a warning). + */ +export function rewriteToolLintStagedConfigFiles( + projectPath: string, + rewriteFn: (json: string) => string | null, + toolName: string, + report?: MigrationReport, +): void { + for (const filename of LINT_STAGED_JSON_CONFIG_FILES) { + const configPath = path.join(projectPath, filename); + if (!fs.existsSync(configPath)) { + continue; + } + if (filename === '.lintstagedrc' && !isJsonFile(configPath)) { + warnMigration( + `${displayRelative(configPath)} is not JSON — please update ${toolName} references manually`, + report, + ); + continue; + } + editJsonFile>(configPath, (config) => { + const updated = rewriteFn(JSON.stringify(config)); + if (updated) { + return JSON.parse(updated); + } + return undefined; + }); + } + for (const filename of LINT_STAGED_OTHER_CONFIG_FILES) { + const configPath = path.join(projectPath, filename); + if (!fs.existsSync(configPath)) { + continue; + } + warnMigration( + `${displayRelative(configPath)} — please update ${toolName} references manually`, + report, + ); + } +} + +function rewriteEslintLintStagedConfigFiles(projectPath: string, report?: MigrationReport): void { + rewriteToolLintStagedConfigFiles(projectPath, rewriteEslint, 'eslint', report); +} + +/** + * Best-effort: derive the Oxlint rule-namespace a JS plugin package + * contributes. Mirrors the conventions @oxlint/migrate uses when + * translating ESLint configs, and the conventions Oxlint-native plugin + * authors use (`oxlint-plugin-` — see posva/pinia-colada in the + * wild): + * `eslint-plugin-unocss` → `unocss` (rules: `unocss/order`) + * `oxlint-plugin-posva` → `posva` (rules: `posva/foo`) + * `@stylistic/eslint-plugin` → `@stylistic` (rules: `@stylistic/indent`) + * `@stylistic/eslint-plugin-ts` → `@stylistic/ts` (rules: `@stylistic/ts/indent`) + * `@scope/oxlint-plugin-x` → `@scope/x` + * anything else → the package name verbatim + */ +function deriveJsPluginNamespace(packageName: string): string { + for (const prefix of ['eslint-plugin-', 'oxlint-plugin-']) { + if (packageName.startsWith(prefix)) { + const suffix = packageName.slice(prefix.length); + return suffix || packageName; + } + } + const scoped = packageName.match(/^(@[^/]+)\/(?:eslint|oxlint)-plugin(?:-(.+))?$/); + if (scoped) { + return scoped[2] ? `${scoped[1]}/${scoped[2]}` : scoped[1]; + } + return packageName; +} + +/** + * Collect every dependency name declared across the root + workspace + * `package.json` files after the ESLint cleanup has run. Used to verify + * that JS plugins referenced by the generated `.oxlintrc.json` are + * actually installable. + */ +export function collectInstalledPackageNames( + projectPath: string, + packages?: WorkspacePackage[], +): Set { + const names = new Set(); + const paths = [projectPath, ...(packages ?? []).map((p) => path.join(projectPath, p.path))]; + for (const dir of paths) { + const pkgJsonPath = path.join(dir, 'package.json'); + if (!fs.existsSync(pkgJsonPath)) { + continue; + } + let pkg: Record | undefined>; + try { + pkg = readJsonFile(pkgJsonPath) as typeof pkg; + } catch { + continue; + } + for (const field of [ + 'devDependencies', + 'dependencies', + 'peerDependencies', + 'optionalDependencies', + ] as const) { + const deps = pkg[field]; + if (deps) { + for (const name of Object.keys(deps)) { + names.add(name); + } + } + } + } + return names; +} + +/** + * Test whether a rule key (e.g. `@stylistic/ts/indent`) belongs to any + * namespace in `namespaces`. We can't just split on the first `/` — + * `@stylistic/eslint-plugin-ts` contributes the multi-segment namespace + * `@stylistic/ts`, so the lookup has to try progressively longer + * prefixes until one matches or we run out of slashes. + */ +function ruleKeyMatchesNamespace(key: string, namespaces: Set): boolean { + if (!key.includes('/')) { + return true; + } + let idx = key.indexOf('/'); + while (idx !== -1) { + if (namespaces.has(key.slice(0, idx))) { + return true; + } + idx = key.indexOf('/', idx + 1); + } + return false; +} + +/** Filter a rules object to only entries whose namespace is recognized. */ +function filterRulesAgainstNamespaces( + rules: Record, + namespaces: Set, +): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(rules)) { + if (ruleKeyMatchesNamespace(key, namespaces)) { + out[key] = value; + } + } + return out; +} + +/** + * Sort a jsPlugins array into installed entries (kept) and string + * entries for packages that aren't present in the workspace. Object-form + * entries (`{ name, specifier }`) and string entries that look like + * local paths (`./X`, `/X`, `../X`) are passed through — Oxlint resolves + * them itself. + */ +function partitionJsPlugins( + entries: NonNullable, + availablePackages: Set, +): { + kept: NonNullable; + dropped: string[]; +} { + const kept: NonNullable = []; + const dropped: string[] = []; + for (const entry of entries) { + if (typeof entry !== 'string') { + kept.push(entry); + continue; + } + // Local-path specifiers don't go through `package.json`; preserve + // them so users with hand-authored local plugin imports survive + // a `vp migrate` re-run. + if (entry.startsWith('./') || entry.startsWith('../') || entry.startsWith('/')) { + kept.push(entry); + continue; + } + if (availablePackages.has(entry)) { + kept.push(entry); + } else { + dropped.push(entry); + } + } + return { kept, dropped }; +} + +/** Build the set of rule-key namespaces backed by a given jsPlugins set. */ +function jsPluginsToNamespaces(entries: NonNullable): Set { + const ns = new Set(); + for (const entry of entries) { + if (typeof entry === 'string') { + ns.add(deriveJsPluginNamespace(entry)); + } else if (entry && typeof entry === 'object' && 'name' in entry && entry.name) { + ns.add(entry.name); + } + } + // Empty-string namespace (e.g. from `eslint-plugin-` with no suffix) + // would smuggle slash-prefixed rules through; drop it defensively. + ns.delete(''); + return ns; +} + +/** + * Sanitize the `.oxlintrc.json` produced by `@oxlint/migrate` (in-place) + * before it gets merged into `vite.config.ts`. Drop references that + * won't resolve at lint time and warn the user. + * + * Why: `@oxlint/migrate` can emit `jsPlugins[]` / `plugins[]` / `rules` + * entries referring to packages the user never installed (e.g. + * translating `@unocss/eslint-config` into `eslint-plugin-unocss`), + * to plugins outside Oxlint's native set, or under namespaces no + * surviving plugin contributes. Without sanitization, `vp lint` aborts + * with "Failed to load JS plugin" / "Plugin not found" before running + * any rule. This produces a degraded-but-functional config instead. + * + * Per-override entries (`overrides[].jsPlugins`, `.plugins`, `.rules`) + * are sanitized independently — an override can introduce its own + * jsPlugin, so namespace availability is computed per-override (base + * namespaces ∪ the override's own surviving jsPlugins' namespaces). + */ +export function sanitizeMigratedOxlintConfig( + config: OxlintConfig, + availablePackages: Set, + report?: MigrationReport, +): void { + // Track everything we strip so we can warn the user. + const allDroppedJsPlugins = new Set(); + const allDroppedPlugins = new Set(); + + // 1. Sanitize base-level jsPlugins. + const baseSplit = partitionJsPlugins(config.jsPlugins ?? [], availablePackages); + for (const n of baseSplit.dropped) { + allDroppedJsPlugins.add(n); + } + if (config.jsPlugins && baseSplit.dropped.length > 0) { + config.jsPlugins = baseSplit.kept; + } + + // 2. Base namespaces = native plugins + surviving jsPlugins' namespaces. + const baseNamespaces = new Set(OXLINT_NATIVE_PLUGINS); + for (const ns of jsPluginsToNamespaces(baseSplit.kept)) { + baseNamespaces.add(ns); + } + + // 3. Sanitize base-level plugins[] against base namespaces. + if (config.plugins) { + type PluginEntry = NonNullable[number]; + const keptPlugins: PluginEntry[] = []; + for (const p of config.plugins) { + if (baseNamespaces.has(p)) { + keptPlugins.push(p); + } else { + allDroppedPlugins.add(p); + } + } + if (keptPlugins.length !== config.plugins.length) { + config.plugins = keptPlugins; + } + } + + // 4. Sanitize base rules. Guard the reassignment to avoid adding a + // `rules: undefined` property that would shift downstream key + // emission in the merged vite.config.ts. + if (config.rules) { + const filtered = filterRulesAgainstNamespaces(config.rules, baseNamespaces); + if (Object.keys(filtered).length !== Object.keys(config.rules).length) { + config.rules = filtered as typeof config.rules; + } + } + + // 5. Sanitize each override INDEPENDENTLY. An override can declare + // its own `jsPlugins` / `plugins`, so we compute a per-override + // namespace set: base namespaces ∪ the override's own surviving + // jsPlugins' namespaces. If `override.plugins` is present it + // replaces base.plugins per Oxlint's schema, but for namespace + // resolution we still include the base set (rules under a base + // namespace are still valid inside the override). + if (Array.isArray(config.overrides)) { + for (const override of config.overrides) { + // Override jsPlugins. + let overrideSurvivors: NonNullable = []; + if (override.jsPlugins) { + const split = partitionJsPlugins(override.jsPlugins, availablePackages); + for (const n of split.dropped) { + allDroppedJsPlugins.add(n); + } + if (split.dropped.length > 0) { + override.jsPlugins = split.kept; + } + overrideSurvivors = split.kept; + } + const overrideNamespaces = new Set(baseNamespaces); + for (const ns of jsPluginsToNamespaces(overrideSurvivors)) { + overrideNamespaces.add(ns); + } + + // Override plugins[]. + if (override.plugins) { + type OverridePluginEntry = NonNullable[number]; + const keptOverridePlugins: OverridePluginEntry[] = []; + for (const p of override.plugins) { + if (overrideNamespaces.has(p)) { + keptOverridePlugins.push(p); + } else { + allDroppedPlugins.add(p); + } + } + if (keptOverridePlugins.length !== override.plugins.length) { + override.plugins = keptOverridePlugins; + } + } + + // Override rules. + if (override.rules) { + const filtered = filterRulesAgainstNamespaces(override.rules, overrideNamespaces); + if (Object.keys(filtered).length !== Object.keys(override.rules).length) { + override.rules = filtered as typeof override.rules; + } + } + } + } + + // 6. Warn. + // + // We deliberately don't try to distinguish "we just removed this + // package as part of the ESLint-ecosystem cleanup" from "the user + // never had it installed" — the only honest signal we have is "not + // in any package.json after cleanup", and a name-based heuristic + // (matches `eslint-plugin-*`?) misclassifies the @oxlint/migrate + // phantom-reference case (e.g. `@unocss/eslint-config` translating + // into `eslint-plugin-unocss` even though the user never had it). + // A single accurate message covers both paths. + if (allDroppedJsPlugins.size > 0) { + warnMigration( + `Stripped JS plugin reference(s) from the generated lint config: ${[...allDroppedJsPlugins].join(', ')}. ` + + 'No matching package is present in this workspace, so loading them at lint time would fail. ' + + 'If you want their Oxlint coverage back, install each package (e.g. `vp install `) and add its name back to `lint.jsPlugins` in vite.config.ts.', + report, + ); + } + if (allDroppedPlugins.size > 0) { + warnMigration( + `Stripped unknown plugin reference(s) from the generated lint config: ${[...allDroppedPlugins].join(', ')}. ` + + "These aren't native Oxlint plugins and no surviving JS plugin contributes them.", + report, + ); + } +} + +export function warnPackageLevelEslint() { + prompts.log.warn( + 'ESLint detected in workspace packages but no root config found. Package-level ESLint must be migrated manually.', + ); +} + +// Framework-ESLint integration packages we can't migrate cleanly today. +// When any of these is present, the ESLint migration is skipped entirely +// — the user's ESLint setup stays intact and they get told how to proceed +// manually. +// +// `@nuxt/eslint` is a Nuxt module that loads ESLint at runtime via the +// dev server and writes a generated config to `.nuxt/eslint.config.mjs`, +// which the user's `eslint.config.mjs` re-exports. Migrating it +// produces a broken state: `vite.config.ts` references `@nuxt/eslint-plugin` +// (no longer installed) and `nuxt.config.ts` still tries to load the +// removed module. Track at https://github.com/voidzero-dev/vite-plus/issues +// once an issue exists. +const INCOMPATIBLE_ESLINT_INTEGRATIONS = ['@nuxt/eslint'] as const; + +/** + * Detect framework-ESLint integration packages whose ESLint migration is + * known to be incompatible. Returns the offending package name, or + * `undefined` if none is present. + */ +export function detectIncompatibleEslintIntegration( + projectPath: string, + packages?: WorkspacePackage[], +): string | undefined { + const candidates = [projectPath, ...(packages ?? []).map((p) => path.join(projectPath, p.path))]; + for (const candidate of candidates) { + const pkgJsonPath = path.join(candidate, 'package.json'); + if (!fs.existsSync(pkgJsonPath)) { + continue; + } + let pkg: { devDependencies?: Record; dependencies?: Record }; + try { + pkg = readJsonFile(pkgJsonPath) as typeof pkg; + } catch { + continue; + } + for (const name of INCOMPATIBLE_ESLINT_INTEGRATIONS) { + if (pkg.devDependencies?.[name] || pkg.dependencies?.[name]) { + return name; + } + } + } + return undefined; +} + +export function warnIncompatibleEslintIntegration(name: string): void { + prompts.log.warn( + `${name} detected — automatic ESLint migration is skipped. ` + + `${name} wires ESLint into a framework-specific flow that Vite+ cannot migrate cleanly yet. ` + + 'Your ESLint setup is preserved. ' + + `To migrate manually, remove ${name} from package.json and re-run \`vp migrate\`.`, + ); +} + +export function warnLegacyEslintConfig(legacyConfigFile: string) { + prompts.log.warn( + `Legacy ESLint configuration detected (${legacyConfigFile}). ` + + 'Automatic migration to Oxlint requires ESLint v9+ with flat config format (eslint.config.*). ' + + 'Please upgrade to ESLint v9 first: https://eslint.org/docs/latest/use/migrate-to-9.0.0', + ); +} + +export async function confirmEslintMigration(interactive: boolean): Promise { + if (interactive) { + const confirmed = await prompts.confirm({ + message: + 'Migrate ESLint rules to Oxlint using @oxlint/migrate?\n ' + + styleText( + 'gray', + "Oxlint is Vite+'s built-in linter — significantly faster than ESLint with compatible rule support. @oxlint/migrate converts your existing rules automatically.", + ), + initialValue: true, + }); + if (prompts.isCancel(confirmed)) { + cancelAndExit(); + } + return confirmed; + } + return true; +} + +export async function promptEslintMigration( + projectPath: string, + interactive: boolean, + packages?: WorkspacePackage[], +): Promise { + const incompatible = detectIncompatibleEslintIntegration(projectPath, packages); + if (incompatible) { + warnIncompatibleEslintIntegration(incompatible); + return false; + } + const eslintProject = detectEslintProject(projectPath, packages); + if (eslintProject.hasDependency && !eslintProject.configFile && eslintProject.legacyConfigFile) { + warnLegacyEslintConfig(eslintProject.legacyConfigFile); + return false; + } + if (!eslintProject.hasDependency) { + return false; + } + if (!eslintProject.configFile) { + // Packages have eslint but no root config → warn and skip + warnPackageLevelEslint(); + return false; + } + const confirmed = await confirmEslintMigration(interactive); + if (!confirmed) { + return false; + } + const ok = await migrateEslintToOxlint( + projectPath, + interactive, + eslintProject.configFile, + packages, + ); + if (!ok) { + cancelAndExit('ESLint migration failed.', 1); + } + return true; +} diff --git a/packages/cli/src/migration/migrator/framework-shim.ts b/packages/cli/src/migration/migrator/framework-shim.ts new file mode 100644 index 0000000000..9d2333a517 --- /dev/null +++ b/packages/cli/src/migration/migrator/framework-shim.ts @@ -0,0 +1,101 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { readJsonFile } from '../../utils/json.ts'; +import { type MigrationReport } from '../report.ts'; + +// .svelte files are handled by @sveltejs/vite-plugin-svelte (transpilation) +// and svelte-check / Svelte Language Server (type checking). +// Module resolution for `.svelte` imports is typically set up by the +// project template (e.g. src/vite-env.d.ts in Vite svelte-ts, or +// auto-generated tsconfig in SvelteKit) rather than this file. +// https://svelte.dev/docs/svelte/typescript +export type Framework = 'vue' | 'astro'; + +const FRAMEWORK_SHIMS: Record = { + // https://vuejs.org/guide/typescript/overview#volar-takeover-mode + vue: [ + "declare module '*.vue' {", + " import type { DefineComponent } from 'vue';", + ' const component: DefineComponent<{}, {}, unknown>;', + ' export default component;', + '}', + ].join('\n'), + // astro/client is the pre-v4.14 form; v4.14+ prefers `/// ` + // but .astro/types.d.ts is generated at build time and may not exist yet after migration. + // astro/client remains valid and is still used in official Astro integrations. + // https://docs.astro.build/en/guides/typescript/#extending-global-types + astro: '/// ', +}; + +export function detectFramework(projectPath: string): Framework[] { + const packageJsonPath = path.join(projectPath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return []; + } + const pkg = readJsonFile(packageJsonPath) as { + dependencies?: Record; + devDependencies?: Record; + }; + const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }; + return (['vue', 'astro'] as const).filter((framework) => !!allDeps[framework]); +} + +function getEnvDtsPath(projectPath: string): string { + const srcEnvDts = path.join(projectPath, 'src', 'env.d.ts'); + const rootEnvDts = path.join(projectPath, 'env.d.ts'); + for (const candidate of [srcEnvDts, rootEnvDts]) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + return fs.existsSync(path.join(projectPath, 'src')) ? srcEnvDts : rootEnvDts; +} + +export function hasFrameworkShim(projectPath: string, framework: Framework): boolean { + const dirsToScan = [projectPath, path.join(projectPath, 'src')]; + for (const dir of dirsToScan) { + if (!fs.existsSync(dir)) { + continue; + } + let entries: string[]; + try { + entries = fs.readdirSync(dir); + } catch { + continue; + } + for (const entry of entries) { + if (!entry.endsWith('.d.ts')) { + continue; + } + const content = fs.readFileSync(path.join(dir, entry), 'utf-8'); + if (framework === 'astro') { + if (content.includes('astro/client')) { + return true; + } + } else if (content.includes(`'*.${framework}'`) || content.includes(`"*.${framework}"`)) { + return true; + } + } + } + return false; +} + +export function addFrameworkShim( + projectPath: string, + framework: Framework, + report?: MigrationReport, +): void { + const envDtsPath = getEnvDtsPath(projectPath); + const shim = FRAMEWORK_SHIMS[framework]; + if (fs.existsSync(envDtsPath)) { + const existing = fs.readFileSync(envDtsPath, 'utf-8'); + fs.writeFileSync(envDtsPath, `${existing.trimEnd()}\n\n${shim}\n`, 'utf-8'); + } else { + fs.mkdirSync(path.dirname(envDtsPath), { recursive: true }); + fs.writeFileSync(envDtsPath, `${shim}\n`, 'utf-8'); + } + if (report) { + report.frameworkShimAdded = true; + } +} diff --git a/packages/cli/src/migration/migrator/git-hooks.ts b/packages/cli/src/migration/migrator/git-hooks.ts new file mode 100644 index 0000000000..49347ec830 --- /dev/null +++ b/packages/cli/src/migration/migrator/git-hooks.ts @@ -0,0 +1,517 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import * as prompts from '@voidzero-dev/vite-plus-prompts'; +import spawn from 'cross-spawn'; +import semver from 'semver'; + +import { rewriteScripts } from '../../../binding/index.js'; +import { PackageManager } from '../../types/index.ts'; +import { editJsonFile, isJsonFile, readJsonFile } from '../../utils/json.ts'; +import { detectPackageMetadata } from '../../utils/package.ts'; +import { + createCatalogDependencyResolver, + hasStagedConfigInViteConfig, + mergeStagedConfigToViteConfig, + readPrepareRulesYaml, + readRulesYaml, + removeLintStagedFromPackageJson, +} from '../migrator.ts'; +import { type MigrationReport } from '../report.ts'; +import { + LINT_STAGED_ALL_CONFIG_FILES, + LINT_STAGED_OTHER_CONFIG_FILES, + warnMigration, +} from './shared.ts'; + +/** + * Check if the project has an unsupported husky version (<9.0.0). + * Uses `semver.coerce` to handle ranges like `^8.0.0` → `8.0.0`. + * When the specifier is a catalog reference (e.g. `"catalog:"`), resolves + * it from the active package manager's catalog first — a `catalog:` spec is + * only meaningful to the manager that owns the workspace, so we never read a + * leftover/foreign catalog file. When it is still not coercible (e.g. + * `"latest"`), falls back to the installed version in node_modules via + * `detectPackageMetadata`. + * Returns a reason string if hooks migration should be skipped, or null + * if husky is absent or compatible. + */ +function checkUnsupportedHuskyVersion( + projectPath: string, + deps: Record | undefined, + prodDeps: Record | undefined, + packageManager: PackageManager | undefined, +): string | null { + const huskyVersion = deps?.husky ?? prodDeps?.husky; + if (!huskyVersion) { + return null; + } + let coerced = semver.coerce(huskyVersion); + if (coerced == null && packageManager != null && huskyVersion.startsWith('catalog:')) { + const resolved = createCatalogDependencyResolver(projectPath, packageManager)?.( + huskyVersion, + 'husky', + ); + if (resolved) { + coerced = semver.coerce(resolved); + } + } + if (coerced == null) { + const installed = detectPackageMetadata(projectPath, 'husky'); + if (installed) { + coerced = semver.coerce(installed.version); + } + if (coerced == null) { + return `Could not determine husky version from "${huskyVersion}" — please specify a semver-compatible version (e.g., "^9.0.0") and re-run migration.`; + } + } + if (semver.satisfies(coerced, '<9.0.0')) { + return 'Detected husky <9.0.0 — please upgrade to husky v9+ first, then re-run migration.'; + } + return null; +} + +const OTHER_HOOK_TOOLS = ['simple-git-hooks', 'lefthook', 'yorkie'] as const; + +// Packages replaced by vite-plus built-in commands and should be removed from devDependencies +const REPLACED_HOOK_PACKAGES = ['husky', 'lint-staged'] as const; + +function removeReplacedHookPackages(packageJsonPath: string): void { + editJsonFile<{ + devDependencies?: Record; + dependencies?: Record; + }>(packageJsonPath, (pkg) => { + for (const name of REPLACED_HOOK_PACKAGES) { + if (pkg.devDependencies?.[name]) { + delete pkg.devDependencies[name]; + } + if (pkg.dependencies?.[name]) { + delete pkg.dependencies[name]; + } + } + return pkg; + }); +} + +export function detectLegacyGitHooksMigrationCandidate(projectPath: string): boolean { + const packageJsonPath = path.join(projectPath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return false; + } + const pkg = readJsonFile(packageJsonPath) as { + scripts?: Record; + 'lint-staged'?: unknown; + }; + return getOldHooksDir(projectPath) !== undefined || pkg['lint-staged'] !== undefined; +} + +/** + * Walk up from `startPath` looking for `.git` (directory or file — submodules + * use a `.git` file). Returns the directory that contains `.git`, or `null`. + */ +function findGitRoot(startPath: string): string | null { + let dir = startPath; + while (true) { + if (fs.existsSync(path.join(dir, '.git'))) { + return dir; + } + const parent = path.dirname(dir); + if (parent === dir) { + return null; + } + dir = parent; + } +} + +/** + * Normalize "husky install [dir]" → "husky [dir]" so downstream regex + * and ast-grep rules can match a single pattern. + */ +function collapseHuskyInstall(script: string): string { + return script.replace('husky install ', 'husky ').replace('husky install', 'husky'); +} + +/** + * High-level helper: detect old hooks dir, set up git hooks, and rewrite + * the prepare script. Returns true if hooks were successfully installed. + */ +export function installGitHooks( + projectPath: string, + silent = false, + report?: MigrationReport, + packageManager?: PackageManager, +): boolean { + const oldHooksDir = getOldHooksDir(projectPath); + if (setupGitHooks(projectPath, oldHooksDir, silent, report, packageManager)) { + rewritePrepareScript(projectPath); + return true; + } + return false; +} + +/** + * Read-only probe: extract the old husky hooks directory from `scripts.prepare` + * without modifying package.json. Returns undefined when no husky reference is found. + */ +export function getOldHooksDir(rootDir: string): string | undefined { + const packageJsonPath = path.join(rootDir, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return undefined; + } + const pkg = readJsonFile(packageJsonPath) as { scripts?: { prepare?: string } }; + if (!pkg.scripts?.prepare) { + return undefined; + } + const prepare = collapseHuskyInstall(pkg.scripts.prepare); + const match = prepare.match(/\bhusky(?:\s+([\w./-]+))?/); + if (!match) { + return undefined; + } + return match[1] ?? '.husky'; +} + +/** + * Pre-flight check: verify that git hooks can be set up for this project. + * Returns `null` if hooks setup can proceed, or a warning reason string + * explaining why hooks setup should be skipped. + * + * These checks are deterministic and read-only — they do not modify + * the project in any way, making them safe to call before migration. + * + * `packageManager` is the project's detected manager; it scopes `catalog:` + * resolution to that manager's catalog so a foreign catalog file is ignored. + */ +export function preflightGitHooksSetup( + projectPath: string, + packageManager?: PackageManager, +): string | null { + const gitRoot = findGitRoot(projectPath); + if (gitRoot && path.resolve(projectPath) !== path.resolve(gitRoot)) { + return 'Subdirectory project detected — skipping git hooks setup. Configure hooks at the repository root.'; + } + const packageJsonPath = path.join(projectPath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return null; // silently skip + } + const pkgContent = readJsonFile(packageJsonPath); + const deps = pkgContent.devDependencies as Record | undefined; + const prodDeps = pkgContent.dependencies as Record | undefined; + for (const tool of OTHER_HOOK_TOOLS) { + if (deps?.[tool] || prodDeps?.[tool] || pkgContent[tool]) { + return `Detected ${tool} — skipping git hooks setup. Please configure git hooks manually, see https://viteplus.dev/guide/migrate#git-hook-tools`; + } + } + const huskyReason = checkUnsupportedHuskyVersion(projectPath, deps, prodDeps, packageManager); + if (huskyReason) { + return huskyReason; + } + if (hasUnsupportedLintStagedConfig(projectPath)) { + return 'Unsupported lint-staged config format — skipping git hooks setup. Please configure git hooks manually.'; + } + return null; +} + +/** + * Set up git hooks with husky + lint-staged via vp commands. + * Skips if another hook tool is detected (warns user). + * Returns true if hooks were successfully set up, false if skipped. + */ +export function setupGitHooks( + projectPath: string, + oldHooksDir?: string, + silent = false, + report?: MigrationReport, + packageManager?: PackageManager, +): boolean { + const reason = preflightGitHooksSetup(projectPath, packageManager); + if (reason) { + warnMigration(reason, report); + return false; + } + + const packageJsonPath = path.join(projectPath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return false; + } + + const gitRoot = findGitRoot(projectPath); + + // Custom husky dirs (e.g. .config/husky) stay unchanged; + // only the default .husky dir gets migrated to .vite-hooks. + const isCustomDir = oldHooksDir != null && oldHooksDir !== '.husky'; + const hooksDir = isCustomDir ? oldHooksDir : '.vite-hooks'; + + editJsonFile<{ + scripts?: Record; + devDependencies?: Record; + dependencies?: Record; + }>(packageJsonPath, (pkg) => { + // Ensure vp config is present for projects that didn't have husky. + // Skip when prepare contains "husky" — rewritePrepareScript (called after + // setupGitHooks succeeds) will transform husky → vp config. + if (!pkg.scripts) { + pkg.scripts = {}; + } + if (!pkg.scripts.prepare) { + pkg.scripts.prepare = 'vp config'; + } else if ( + !pkg.scripts.prepare.includes('vp config') && + !/\bhusky\b/.test(pkg.scripts.prepare) + ) { + pkg.scripts.prepare = `vp config && ${pkg.scripts.prepare}`; + } + + return pkg; + }); + + // Add staged config to vite.config.ts if not present + let stagedMerged = hasStagedConfigInViteConfig(projectPath); + const hasStandaloneConfig = hasStandaloneLintStagedConfig(projectPath); + if (!stagedMerged && !hasStandaloneConfig) { + // Use lint-staged config from package.json if available, otherwise use default + const pkgData = readJsonFile(packageJsonPath) as { + 'lint-staged'?: Record; + }; + const stagedConfig = pkgData?.['lint-staged'] ?? DEFAULT_STAGED_CONFIG; + const updated = rewriteScripts(JSON.stringify(stagedConfig), readRulesYaml()); + const finalConfig: Record = updated + ? JSON.parse(updated) + : stagedConfig; + stagedMerged = mergeStagedConfigToViteConfig(projectPath, finalConfig, silent, report); + } + + // Only remove lint-staged key from package.json after staged config is + // confirmed in vite.config.ts — prevents losing config on merge failure + if (stagedMerged) { + removeLintStagedFromPackageJson(packageJsonPath); + } + + // Copy default .husky/ hooks to .vite-hooks/ before creating pre-commit hook. + // Custom dirs (e.g. .config/husky) are kept in-place — no copy needed. + if (oldHooksDir && !isCustomDir) { + const oldDir = path.join(projectPath, oldHooksDir); + if (fs.existsSync(oldDir)) { + const targetDir = path.join(projectPath, hooksDir); + fs.mkdirSync(targetDir, { recursive: true }); + for (const entry of fs.readdirSync(oldDir, { withFileTypes: true })) { + if (entry.isDirectory() || entry.name.startsWith('.')) { + continue; + } + const src = path.join(oldDir, entry.name); + const dest = path.join(targetDir, entry.name); + fs.copyFileSync(src, dest); + fs.chmodSync(dest, 0o755); + } + // Remove old .husky/ directory after copying hooks to .vite-hooks/ + fs.rmSync(oldDir, { recursive: true, force: true }); + } + } + + // Only create pre-commit hook if staged config was merged into vite.config.ts. + // Standalone lint-staged config files are NOT sufficient — `vp staged` only + // reads from vite.config.ts, so a hook without merged config would fail. + if (stagedMerged) { + createPreCommitHook(projectPath, hooksDir); + } + + // vp config requires a git workspace — skip if no .git found + if (!gitRoot) { + removeReplacedHookPackages(packageJsonPath); + return true; + } + + // Clear husky's core.hooksPath so vp config can set the new one. + // Only clear if it matches the old husky directory — preserve genuinely custom paths. + if (oldHooksDir) { + const checkResult = spawn.sync('git', ['config', '--local', 'core.hooksPath'], { + cwd: projectPath, + stdio: 'pipe', + }); + const existingPath = checkResult.status === 0 ? checkResult.stdout?.toString().trim() : ''; + if (existingPath === `${oldHooksDir}/_` || existingPath === oldHooksDir) { + spawn.sync('git', ['config', '--local', '--unset', 'core.hooksPath'], { + cwd: projectPath, + stdio: 'pipe', + }); + } + } + + const vpBin = process.env.VP_CLI_BIN ?? 'vp'; + + // Install git hooks via vp config (--no-agent to skip agent setup, handled by migration) + const configArgs = isCustomDir + ? ['config', '--no-agent', '--hooks-dir', hooksDir] + : ['config', '--no-agent']; + const configResult = spawn.sync(vpBin, configArgs, { + cwd: projectPath, + stdio: 'pipe', + }); + if (configResult.status === 0) { + // vp config outputs skip/info messages to stdout via log(). + // An empty message means hooks were installed successfully; + // any non-empty output indicates a skip (HUSKY=0, hooksPath + // already set, .git not found, etc.). + const stdout = configResult.stdout?.toString().trim() ?? ''; + if (stdout) { + warnMigration(`Git hooks not configured — ${stdout}`, report); + return false; + } + removeReplacedHookPackages(packageJsonPath); + if (report) { + report.gitHooksConfigured = true; + } + if (!silent) { + prompts.log.success('✔ Git hooks configured'); + } + return true; + } + warnMigration('Failed to install git hooks', report); + return false; +} + +/** + * Check if a standalone lint-staged config file exists + */ +function hasStandaloneLintStagedConfig(projectPath: string): boolean { + return LINT_STAGED_ALL_CONFIG_FILES.some((file) => fs.existsSync(path.join(projectPath, file))); +} + +/** + * Check if a standalone lint-staged config exists in a format that can't be + * auto-migrated to "staged" in vite.config.ts (non-JSON files like .yaml, + * .mjs, .cjs, .js, or a non-JSON .lintstagedrc). + */ +function hasUnsupportedLintStagedConfig(projectPath: string): boolean { + for (const filename of LINT_STAGED_OTHER_CONFIG_FILES) { + if (fs.existsSync(path.join(projectPath, filename))) { + return true; + } + } + const lintstagedrcPath = path.join(projectPath, '.lintstagedrc'); + if (fs.existsSync(lintstagedrcPath) && !isJsonFile(lintstagedrcPath)) { + return true; + } + return false; +} + +/** + * Create pre-commit hook file in the hooks directory. + */ +// Lint-staged invocation patterns — replaced in-place with `vp staged`. +// The optional prefix group captures env var assignments like `NODE_OPTIONS=... `. +// We still detect old lint-staged patterns to migrate existing hooks. +const STALE_LINT_STAGED_PATTERNS = [ + /^((?:[A-Z_][A-Z0-9_]*(?:=\S*)?\s+)*)(pnpm|pnpm exec|npx|yarn|yarn run|npm exec|npm run|bunx|bun run|bun x)\s+lint-staged\b/, + /^((?:[A-Z_][A-Z0-9_]*(?:=\S*)?\s+)*)lint-staged\b/, +]; + +const DEFAULT_STAGED_CONFIG: Record = { '*': 'vp check --fix' }; + +/** + * Ensure the pre-commit hook exists with `vp staged`, and that + * vite.config.ts contains a `staged` block (using the default config + * if none is present). Called by `vp config` after hook installation. + */ +export function ensurePreCommitHook(projectPath: string, dir = '.vite-hooks'): void { + if (!hasStagedConfigInViteConfig(projectPath)) { + mergeStagedConfigToViteConfig(projectPath, DEFAULT_STAGED_CONFIG, true); + } + createPreCommitHook(projectPath, dir); +} + +export function createPreCommitHook(projectPath: string, dir = '.vite-hooks'): void { + const huskyDir = path.join(projectPath, dir); + fs.mkdirSync(huskyDir, { recursive: true }); + const hookPath = path.join(huskyDir, 'pre-commit'); + if (fs.existsSync(hookPath)) { + const existing = fs.readFileSync(hookPath, 'utf8'); + if (existing.includes('vp staged')) { + return; // already has vp staged + } + // Replace old lint-staged invocations in-place, preserve everything else + const lines = existing.split('\n'); + let replaced = false; + const result: string[] = []; + for (const line of lines) { + const trimmed = line.trim(); + if (!replaced) { + let matched = false; + for (const pattern of STALE_LINT_STAGED_PATTERNS) { + const match = pattern.exec(trimmed); + if (match) { + // Preserve env var prefix (capture group 1) and flags/chained commands after lint-staged + const envPrefix = match[1]?.trim() ?? ''; + const rest = trimmed.slice(match[0].length).trim(); + const parts = [envPrefix, 'vp staged', rest].filter(Boolean); + result.push(parts.join(' ')); + replaced = true; + matched = true; + break; + } + } + if (matched) { + continue; + } + } + result.push(line); + } + if (!replaced) { + // No lint-staged line found — append after existing content + fs.writeFileSync(hookPath, `${result.join('\n').trimEnd()}\nvp staged\n`); + } else { + fs.writeFileSync(hookPath, result.join('\n')); + } + } else { + fs.writeFileSync(hookPath, 'vp staged\n'); + fs.chmodSync(hookPath, 0o755); + } +} + +/** + * Rewrite only `scripts.prepare` in the root package.json using vite-prepare.yml rules. + * Collapses "husky install" → "husky" before applying ast-grep so that the + * replace-husky rule produces "vp config" with any directory argument preserved. + * Returns the old husky hooks dir (if any) for migration to .vite-hooks. + * Called only when hooks are being set up (not with --no-hooks). + */ +export function rewritePrepareScript(rootDir: string): string | undefined { + const packageJsonPath = path.join(rootDir, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return undefined; + } + + let oldDir: string | undefined; + + editJsonFile<{ scripts?: Record }>(packageJsonPath, (pkg) => { + if (!pkg.scripts?.prepare) { + return pkg; + } + + // Collapse "husky install" → "husky" so the ast-grep rule + // produces "vp config" with any directory argument preserved. + const prepare = collapseHuskyInstall(pkg.scripts.prepare); + + const prepareJson = JSON.stringify({ prepare }); + const updated = rewriteScripts(prepareJson, readPrepareRulesYaml()); + if (updated) { + let newPrepare: string = JSON.parse(updated).prepare; + newPrepare = newPrepare.replace( + /\bvp config(?:\s+(?!-)([\w./-]+))?/, + (_match: string, dir: string | undefined) => { + // Capture the old husky dir for hook migration. + // Default husky dir is .husky; custom dirs keep --hooks-dir flag. + oldDir = dir ?? '.husky'; + return dir ? `vp config --hooks-dir ${dir}` : 'vp config'; + }, + ); + pkg.scripts.prepare = newPrepare; + } else if (prepare !== pkg.scripts.prepare) { + // Pre-processing changed the script (husky install → husky) + // but no rule matched — keep the collapsed form + pkg.scripts.prepare = prepare; + } + return pkg; + }); + + return oldDir; +} diff --git a/packages/cli/src/migration/migrator/orchestrators.ts b/packages/cli/src/migration/migrator/orchestrators.ts new file mode 100644 index 0000000000..85bffecac9 --- /dev/null +++ b/packages/cli/src/migration/migrator/orchestrators.ts @@ -0,0 +1,563 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { PackageManager, type WorkspaceInfo, type WorkspacePackage } from '../../types/index.ts'; +import { + VITE_PLUS_NAME, + VITE_PLUS_OVERRIDE_PACKAGES, + VITE_PLUS_VERSION, + isForceOverrideMode, +} from '../../utils/constants.ts'; +import { editJsonFile } from '../../utils/json.ts'; +import { + applyBuildAllowanceToPackageJsonPnpm, + applyYarnWorkspaceHoistingFix, + cleanupDeprecatedTsconfigOptions, + collectInjectedProviderNames, + collectProviderSourceModes, + collectVitestEcosystemInstallDependencyNames, + createCatalogDependencyResolver, + dropRemovePackageOverrideKeys, + ensureDirectViteForPnpm, + ensurePnpmWorkspaceExoticSubdepsSetting, + findYarnWorkspaceHoisting, + hasDirectVitePlusInstallEntry, + hasOwnWebdriverioDependency, + injectFmtDefaults, + injectLintTypeCheckDefaults, + managedOverridePackages, + mergeStagedConfigToViteConfig, + mergeTsdownConfigFile, + mergeViteConfigFiles, + migratePnpmOverridesToWorkspaceYaml, + migratePnpmSettingsToWorkspaceYaml, + pnpmSupportsWorkspaceSettings, + projectListsRequiredVitestPeer, + projectUsesVitestDirectly, + pruneLegacyWrapperAliases, + removeLintStagedFromPackageJson, + removeManagedVitestEntry, + removeVitestPeerDependencyRule, + rewriteAllImports, + rewriteBunCatalog, + rewriteLintStagedConfigFile, + rewritePackageJson, + rewritePnpmWorkspaceYaml, + rewriteRootWorkspacePackageJson, + rewriteTsconfigTypes, + rewriteYarnrcYml, + setPackageManager, + sourceTreeReferencesRetainedVitestModule, + takePnpmWorkspaceSettings, + usesVitestBrowserMode, + usesWebdriverioProvider, + workspaceUsesVitestDirectly, + workspaceUsesWebdriverio, + wrapLazyPluginsInViteConfig, +} from '../migrator.ts'; +import { type MigrationReport } from '../report.ts'; +import { + PROVIDER_OVERRIDE_DROP_NAMES, + pnpmMajor, + type CatalogDependencyResolver, + type PnpmPackageJsonSettings, +} from './shared.ts'; + +export function rewriteStandaloneProject( + projectPath: string, + workspaceInfo: WorkspaceInfo, + skipStagedMigration?: boolean, + silent = false, + report?: MigrationReport, +): void { + const packageJsonPath = path.join(projectPath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return; + } + + const packageManager = workspaceInfo.packageManager; + const catalogDependencyResolver = createCatalogDependencyResolver(projectPath, packageManager); + const vitestEcosystemPackages = collectVitestEcosystemInstallDependencyNames(projectPath); + // Source-tree scan signals are computed once here and reused below (and inside + // projectUsesVitestDirectly / collectInjectedProviderNames) so the source tree + // is traversed once each instead of repeatedly. They do not depend on + // package.json contents and no scanned source files are mutated before they + // are consumed, so the values match the previous lazy per-call scans exactly. + const providerSourceModes = collectProviderSourceModes(projectPath); + const browserMode = usesVitestBrowserMode(projectPath); + const retainedVitestModule = sourceTreeReferencesRetainedVitestModule(projectPath); + const providerCatalogAdditions = collectInjectedProviderNames( + projectPath, + undefined, + new Map([[projectPath, providerSourceModes]]), + ); + const pnpmMajorVersion = pnpmMajor(workspaceInfo.downloadPackageManager.version); + let extractedStagedConfig: Record | null = null; + let movedPnpmSettings: Record | undefined; + let shouldRewritePnpmWorkspaceYaml = false; + let shouldAddPnpmWorkspaceVitePlusOverride = false; + let shouldAllowBrowserProviderBuilds = false; + // Whether the project uses vitest directly (a required-peer consumer, an + // upstream module reference, or browser mode). Computed inside the callback and + // hoisted so the post-callback pnpm-workspace.yaml writer sees it too. + let usesVitest = false; + // Determined inside editJsonFile callback to avoid a redundant file read + let usePnpmWorkspaceYaml = false; + editJsonFile<{ + overrides?: Record; + resolutions?: Record; + devDependencies?: Record; + dependencies?: Record; + peerDependencies?: Record; + optionalDependencies?: Record; + scripts?: Record; + pnpm?: PnpmPackageJsonSettings; + }>(packageJsonPath, (pkg) => { + shouldAllowBrowserProviderBuilds = + hasOwnWebdriverioDependency(pkg) || usesWebdriverioProvider(projectPath); + const requiredVitestPeer = projectListsRequiredVitestPeer(projectPath, pkg); + usesVitest = projectUsesVitestDirectly(projectPath, pkg, requiredVitestPeer, true, { + browserMode, + retainedModule: retainedVitestModule, + }); + const managed = managedOverridePackages(usesVitest); + // Strip stale `vite-plus-test` wrapper aliases before injecting new overrides + // so the deleted wrapper doesn't survive migration in any sink. + pruneLegacyWrapperAliases(pkg.resolutions); + pruneLegacyWrapperAliases(pkg.overrides); + pruneLegacyWrapperAliases(pkg.pnpm?.overrides); + // Drop stale provider overrides/resolutions (REMOVE_PACKAGES + the now + // user-owned opt-in providers, webdriverio/playwright) from the npm/bun + // `overrides` and yarn `resolutions` sinks before re-merging managed + // overrides. A leftover pin would conflict with the migrated direct + // `@vitest/browser-webdriverio` / `@vitest/browser-playwright` dep — npm + // hard-fails with EOVERRIDE, and yarn/bun would force the stale version over + // the bundled-vitest-aligned 4.1.9. (The pnpm sinks are pruned below.) + dropRemovePackageOverrideKeys(pkg.resolutions); + dropRemovePackageOverrideKeys(pkg.overrides); + // Common case (no direct vitest): strip a lingering managed `vitest` from + // the npm/bun `overrides` and yarn `resolutions` sinks so it isn't re-pinned. + if (!usesVitest) { + removeManagedVitestEntry(pkg.resolutions); + removeManagedVitestEntry(pkg.overrides); + } + if (packageManager === PackageManager.yarn) { + pkg.resolutions = { + ...pkg.resolutions, + ...managed, + }; + } else if (packageManager === PackageManager.npm || packageManager === PackageManager.bun) { + pkg.overrides = { + ...pkg.overrides, + ...managed, + }; + if (packageManager === PackageManager.bun) { + // Bun walks transitive peer-deps before resolving overrides; vitest + // 4.1.9 declares peer `vite ^6 || ^7 || ^8` and aborts with + // "vite@... failed to resolve" if `vite` isn't a direct dep somewhere + // in the tree, even when the override would redirect it. Mirror the + // override as a devDep so bun's resolver sees `vite` immediately; + // the override above still points it at vite-plus-core. + // See https://github.com/oven-sh/bun/issues/8406. + pkg.devDependencies = { + ...pkg.devDependencies, + vite: VITE_PLUS_OVERRIDE_PACKAGES.vite, + }; + } + } else if (packageManager === PackageManager.pnpm) { + usePnpmWorkspaceYaml = pnpmSupportsWorkspaceSettings( + workspaceInfo.downloadPackageManager.version, + ); + if (usePnpmWorkspaceYaml) { + shouldRewritePnpmWorkspaceYaml = true; + shouldAddPnpmWorkspaceVitePlusOverride = isForceOverrideMode(); + } + const overrideKeys = Object.keys(managed); + if (!usePnpmWorkspaceYaml) { + // Strip selector-shaped overrides (e.g. `parent>@vitest/browser-playwright`) + // whose target is a removed package, before re-merging the user's + // overrides into the new pnpm config. + dropRemovePackageOverrideKeys(pkg.pnpm?.overrides); + // Common case: drop a lingering managed `vitest` override + its peer + // rules before re-merging. + if (!usesVitest) { + removeManagedVitestEntry(pkg.pnpm?.overrides); + if (pkg.pnpm?.peerDependencyRules) { + removeVitestPeerDependencyRule(pkg.pnpm.peerDependencyRules); + } + } + // Project already has pnpm config in package.json -- keep using it. + pkg.pnpm = { + ...pkg.pnpm, + overrides: { + ...pkg.pnpm?.overrides, + ...managed, + ...(isForceOverrideMode() ? { [VITE_PLUS_NAME]: VITE_PLUS_VERSION } : {}), + }, + peerDependencyRules: { + ...pkg.pnpm?.peerDependencyRules, + allowAny: [ + ...new Set([...(pkg.pnpm?.peerDependencyRules?.allowAny ?? []), ...overrideKeys]), + ], + allowedVersions: { + ...pkg.pnpm?.peerDependencyRules?.allowedVersions, + ...Object.fromEntries(overrideKeys.map((key) => [key, '*'])), + }, + }, + }; + } else { + movedPnpmSettings = takePnpmWorkspaceSettings(pkg); + } + // remove dependency selectors targeting vite (e.g. "vite-plugin-svgr>vite") + for (const key in pkg.pnpm?.overrides) { + if (key.includes('>')) { + const splits = key.split('>'); + if (splits[splits.length - 1].trim() === 'vite') { + delete pkg.pnpm.overrides[key]; + } + } + } + // remove packages from `resolutions` field if they exist + // https://pnpm.io/9.x/package_json#resolutions + for (const key of [...overrideKeys, ...PROVIDER_OVERRIDE_DROP_NAMES]) { + if (pkg.resolutions?.[key]) { + delete pkg.resolutions[key]; + } + } + if (!usePnpmWorkspaceYaml && pnpmMajorVersion !== undefined && pkg.pnpm) { + applyBuildAllowanceToPackageJsonPnpm( + pkg.pnpm, + pnpmMajorVersion, + shouldAllowBrowserProviderBuilds, + ); + } + } + + const supportCatalog = usePnpmWorkspaceYaml || packageManager === PackageManager.yarn; + extractedStagedConfig = rewritePackageJson( + pkg, + packageManager, + supportCatalog, + skipStagedMigration, + catalogDependencyResolver, + browserMode, + providerSourceModes, + usesVitest, + retainedVitestModule, + requiredVitestPeer, + ); + + // ensure vite-plus is in devDependencies — but only when it isn't already a + // direct dependency/devDependency, so a project that declares vite-plus in + // `dependencies` is not duplicated into `devDependencies`. Force-override + // still re-pins a pre-existing devDependencies entry in place. + const forceRepinExistingDevEntry = + isForceOverrideMode() && pkg.devDependencies?.[VITE_PLUS_NAME] !== undefined; + if (!hasDirectVitePlusInstallEntry(pkg) || forceRepinExistingDevEntry) { + const existingVitePlusSpec = pkg.devDependencies?.[VITE_PLUS_NAME]; + const version = + supportCatalog && !VITE_PLUS_VERSION.startsWith('file:') + ? existingVitePlusSpec?.startsWith('catalog:') + ? existingVitePlusSpec + : (catalogDependencyResolver?.preferredCatalogSpec ?? 'catalog:') + : VITE_PLUS_VERSION; + pkg.devDependencies = { + ...pkg.devDependencies, + [VITE_PLUS_NAME]: version, + }; + } + // This caller injects vite-plus after rewritePackageJson returned, so the + // direct-`vite` pass must run here too. + ensureDirectViteForPnpm( + pkg, + packageManager, + usePnpmWorkspaceYaml && packageManager !== PackageManager.npm, + catalogDependencyResolver, + ); + return pkg; + }); + + migratePnpmSettingsToWorkspaceYaml(projectPath, movedPnpmSettings); + + if (shouldRewritePnpmWorkspaceYaml) { + rewritePnpmWorkspaceYaml( + projectPath, + pnpmMajorVersion, + shouldAllowBrowserProviderBuilds, + usesVitest, + vitestEcosystemPackages, + true, + providerCatalogAdditions, + ); + } + + if (shouldAddPnpmWorkspaceVitePlusOverride) { + migratePnpmOverridesToWorkspaceYaml(projectPath, { + [VITE_PLUS_NAME]: VITE_PLUS_VERSION, + }); + } + + if (packageManager === PackageManager.pnpm) { + ensurePnpmWorkspaceExoticSubdepsSetting(projectPath); + } + + if (packageManager === PackageManager.yarn) { + rewriteYarnrcYml(projectPath, usesVitest, vitestEcosystemPackages, providerCatalogAdditions); + } + + // Merge extracted staged config into vite.config.ts, then remove lint-staged from package.json + if (extractedStagedConfig) { + if (mergeStagedConfigToViteConfig(projectPath, extractedStagedConfig, silent, report)) { + removeLintStagedFromPackageJson(packageJsonPath); + } + } + + if (!skipStagedMigration) { + rewriteLintStagedConfigFile(projectPath, report); + } + cleanupDeprecatedTsconfigOptions(projectPath, silent, report); + rewriteTsconfigTypes(projectPath, silent, report); + mergeViteConfigFiles(projectPath, silent, report, workspaceInfo.packages); + injectLintTypeCheckDefaults(projectPath, silent, report); + injectFmtDefaults(projectPath, silent, report); + mergeTsdownConfigFile(projectPath, silent, report); + // rewrite imports in all TypeScript/JavaScript files before lazy plugin import merging + rewriteAllImports(projectPath, silent, report, true); + wrapLazyPluginsInViteConfig(projectPath, silent, report); + // set package manager + setPackageManager(projectPath, workspaceInfo.downloadPackageManager); +} + +/** + * Rewrite monorepo to add vite-plus dependencies + * @param workspaceInfo - The workspace info + */ +export function rewriteMonorepo( + workspaceInfo: WorkspaceInfo, + skipStagedMigration?: boolean, + silent = false, + report?: MigrationReport, +): void { + const catalogDependencyResolver = createCatalogDependencyResolver( + workspaceInfo.rootDir, + workspaceInfo.packageManager, + ); + const pnpmMajorVersion = pnpmMajor(workspaceInfo.downloadPackageManager.version); + const usePnpmWorkspaceSettings = pnpmSupportsWorkspaceSettings( + workspaceInfo.downloadPackageManager.version, + ); + const workspaceShouldAllowBrowserBuilds = workspaceUsesWebdriverio( + workspaceInfo.rootDir, + workspaceInfo.packages, + ); + // The SHARED workspace sinks (catalog / overrides / peer rules) keep `vitest` + // managed iff ANY package in the workspace uses vitest directly. + const workspaceUsesVitest = workspaceUsesVitestDirectly( + workspaceInfo.rootDir, + workspaceInfo.packages, + true, + ); + const vitestEcosystemPackages = collectVitestEcosystemInstallDependencyNames( + workspaceInfo.rootDir, + workspaceInfo.packages, + ); + const providerCatalogAdditions = collectInjectedProviderNames( + workspaceInfo.rootDir, + workspaceInfo.packages, + ); + // rewrite root workspace + if (workspaceInfo.packageManager === PackageManager.yarn) { + rewriteYarnrcYml( + workspaceInfo.rootDir, + workspaceUsesVitest, + vitestEcosystemPackages, + providerCatalogAdditions, + ); + } else if (workspaceInfo.packageManager === PackageManager.bun) { + rewriteBunCatalog(workspaceInfo.rootDir, workspaceUsesVitest, vitestEcosystemPackages); + } + rewriteRootWorkspacePackageJson( + workspaceInfo.rootDir, + workspaceInfo.packageManager, + skipStagedMigration, + catalogDependencyResolver, + workspaceInfo.packages, + pnpmMajorVersion, + workspaceInfo.downloadPackageManager.version, + workspaceShouldAllowBrowserBuilds, + workspaceUsesVitest, + ); + if (workspaceInfo.packageManager === PackageManager.pnpm) { + rewritePnpmWorkspaceYaml( + workspaceInfo.rootDir, + pnpmMajorVersion, + workspaceShouldAllowBrowserBuilds, + workspaceUsesVitest, + vitestEcosystemPackages, + usePnpmWorkspaceSettings, + providerCatalogAdditions, + ); + if (usePnpmWorkspaceSettings && isForceOverrideMode()) { + migratePnpmOverridesToWorkspaceYaml(workspaceInfo.rootDir, { + [VITE_PLUS_NAME]: VITE_PLUS_VERSION, + }); + } + } + // (mergeViteConfigFiles below will sanitize the merged lint config + // against this workspace's full package set.) + + // rewrite packages — pass workspace context so the per-package + // sanitizer can see hoisted deps that live elsewhere in the + // workspace, not just this sub-package's own `package.json`. + const workspaceContext = { + rootDir: workspaceInfo.rootDir, + packages: workspaceInfo.packages, + }; + // Yarn `node-modules` + an isolating `nmHoistingLimits` would give each + // vite-plus-receiving workspace its own physical `vitest` copy, splitting the + // runner across two `@vitest/runner` instances. `rewriteMonorepoProject` detects + // the layout per workspace (reading the root `.yarnrc.yml` itself) and auto-fixes + // or warns — see `applyYarnWorkspaceHoistingFix`. + for (const pkg of workspaceInfo.packages) { + rewriteMonorepoProject( + path.join(workspaceInfo.rootDir, pkg.path), + workspaceInfo.packageManager, + skipStagedMigration, + silent, + report, + catalogDependencyResolver, + workspaceContext, + true, + ); + } + + if (!skipStagedMigration) { + rewriteLintStagedConfigFile(workspaceInfo.rootDir, report); + } + cleanupDeprecatedTsconfigOptions(workspaceInfo.rootDir, silent, report); + rewriteTsconfigTypes(workspaceInfo.rootDir, silent, report); + mergeViteConfigFiles(workspaceInfo.rootDir, silent, report, workspaceInfo.packages); + injectLintTypeCheckDefaults(workspaceInfo.rootDir, silent, report); + injectFmtDefaults(workspaceInfo.rootDir, silent, report); + mergeTsdownConfigFile(workspaceInfo.rootDir, silent, report); + // rewrite imports in all TypeScript/JavaScript files before lazy plugin import merging + rewriteAllImports(workspaceInfo.rootDir, silent, report, true); + wrapLazyPluginsInViteConfig(workspaceInfo.rootDir, silent, report); + for (const pkg of workspaceInfo.packages) { + wrapLazyPluginsInViteConfig(path.join(workspaceInfo.rootDir, pkg.path), silent, report); + } + // set package manager + setPackageManager(workspaceInfo.rootDir, workspaceInfo.downloadPackageManager); +} + +/** + * Rewrite monorepo project to add vite-plus dependencies + * @param projectPath - The path to the project + * @param workspaceContext - Full workspace info, used so the lint-config + * sanitizer can see hoisted deps living elsewhere in the workspace, + * not just this sub-package's own `package.json`. `rootDir` is the + * workspace root (paths in `packages` are relative to it); `packages` + * is the workspace package list. + */ +export function rewriteMonorepoProject( + projectPath: string, + packageManager: PackageManager, + skipStagedMigration?: boolean, + silent = false, + report?: MigrationReport, + catalogDependencyResolver?: CatalogDependencyResolver, + workspaceContext?: { rootDir: string; packages: WorkspacePackage[] }, + deferLazyPluginWrapping = false, +): void { + cleanupDeprecatedTsconfigOptions(projectPath, silent, report); + rewriteTsconfigTypes(projectPath, silent, report); + mergeViteConfigFiles( + projectPath, + silent, + report, + workspaceContext?.packages, + workspaceContext?.rootDir, + ); + mergeTsdownConfigFile(projectPath, silent, report); + + const packageJsonPath = path.join(projectPath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return; + } + + // Yarn `nmHoistingLimits` for this workspace's project, found by walking up to the + // root `.yarnrc.yml`. Derived here (not threaded as an arg) so EVERY caller — full + // monorepo migration, a direct `rewriteMonorepoProject` call, and `vp create` + // integrating a package into an existing monorepo — is covered. undefined for + // non-Yarn repos. + const yarnHoisting = + packageManager === PackageManager.yarn + ? findYarnWorkspaceHoisting(workspaceContext?.rootDir ?? projectPath) + : undefined; + + let extractedStagedConfig: Record | null = null; + editJsonFile<{ + devDependencies?: Record; + dependencies?: Record; + peerDependencies?: Record; + optionalDependencies?: Record; + scripts?: Record; + installConfig?: { hoistingLimits?: string }; + }>(packageJsonPath, (pkg) => { + const requiredVitestPeer = projectListsRequiredVitestPeer(projectPath, pkg); + // Compute the browser-mode and retained-module source scans once and reuse + // them across rewritePackageJson and projectUsesVitestDirectly: the scans do + // not depend on package.json and nothing mutates the source tree between + // these reads, so this is identical to the previous per-call scans. + const browserMode = usesVitestBrowserMode(projectPath); + const retainedVitestModule = sourceTreeReferencesRetainedVitestModule(projectPath); + // rewrite scripts in package.json + extractedStagedConfig = rewritePackageJson( + pkg, + packageManager, + true, + skipStagedMigration, + catalogDependencyResolver, + browserMode, + collectProviderSourceModes(projectPath), + projectUsesVitestDirectly(projectPath, pkg, requiredVitestPeer, true, { + browserMode, + retainedModule: retainedVitestModule, + }), + retainedVitestModule, + requiredVitestPeer, + ); + // If this SUB-workspace now depends on `vite-plus` and Yarn isolates its + // hoisting (via the root `nmHoistingLimits` OR the workspace's own + // `installConfig.hoistingLimits`), dedupe the bundled `vitest` family to the + // single shared root copy (avoids the dual-`@vitest/runner` "reading 'config'" + // crash), or warn when the split cannot be fixed from package.json. The monorepo + // root itself is skipped (`projectPath === yarnHoisting.rootDir`): its deps + // already hoist to the top level, so it never needs an opt-out. + if ( + yarnHoisting && + path.resolve(projectPath) !== yarnHoisting.rootDir && + pkg.devDependencies?.[VITE_PLUS_NAME] + ) { + applyYarnWorkspaceHoistingFix( + pkg, + yarnHoisting.limit, + yarnHoisting.nodeLinker, + path.relative(yarnHoisting.rootDir, projectPath) || projectPath, + report, + ); + } + return pkg; + }); + + // Merge extracted staged config into vite.config.ts, then remove lint-staged from package.json + if (extractedStagedConfig) { + if (mergeStagedConfigToViteConfig(projectPath, extractedStagedConfig, silent, report)) { + removeLintStagedFromPackageJson(packageJsonPath); + } + } + + if (!deferLazyPluginWrapping) { + wrapLazyPluginsInViteConfig(projectPath, silent, report); + } +} diff --git a/packages/cli/src/migration/migrator/package-json.ts b/packages/cli/src/migration/migrator/package-json.ts new file mode 100644 index 0000000000..5b65991763 --- /dev/null +++ b/packages/cli/src/migration/migrator/package-json.ts @@ -0,0 +1,412 @@ +import { rewriteScripts } from '../../../binding/index.js'; +import { PackageManager } from '../../types/index.ts'; +import { + VITEST_VERSION, + VITE_PLUS_NAME, + VITE_PLUS_OVERRIDE_PACKAGES, + VITE_PLUS_VERSION, + isForceOverrideMode, +} from '../../utils/constants.ts'; +import { + VITEST_DIRECT_USAGE_EXCLUDED, + alignVitestEcosystemPackages, + ensureDirectViteForPnpm, + getAlignedVitestEcosystemDependencySpec, + getCatalogDependencySpec, + getScriptRulesYaml, + managedOverridePackages, + normalizeVitestPeerCatalogSpec, + pruneLegacyWrapperAliases, + readRulesYaml, + removeManagedVitestEntry, +} from '../migrator.ts'; +import { + BROWSER_PROVIDER_PEER_DEPS, + OPT_IN_BROWSER_PROVIDERS, + REMOVE_PACKAGES, + VITEST_BROWSER_DEP_NAMES, + VITEST_IS_MANAGED_OVERRIDE, + type CatalogDependencyResolver, + type PackageJsonDependencyField, +} from './shared.ts'; + +export function rewritePackageJson( + pkg: { + scripts?: Record; + 'lint-staged'?: Record; + devDependencies?: Record; + dependencies?: Record; + peerDependencies?: Record; + optionalDependencies?: Record; + }, + packageManager: PackageManager, + isMonorepo?: boolean, + skipStagedMigration?: boolean, + catalogDependencyResolver?: CatalogDependencyResolver, + vitestBrowserMode?: boolean, + // Source-scan signal per opt-in browser provider name (e.g. + // `@vitest/browser-webdriverio` → true). A provider with no dep declared but + // imported in source still gets kept/injected. + providerSourceModes?: Partial>, + // Whether the project uses vitest DIRECTLY (a required-peer consumer, an + // upstream module reference, or browser mode). `vitest` is managed only + // when true; in the common case (`false`) a lingering managed `vitest` entry + // is REMOVED so it arrives transitively through vite-plus. Defaults to true to + // preserve legacy behavior for callers that don't compute the signal. + usesVitestDirectly = true, + // Module augmentations, compilerOptions.types, and `vitest/package.json` + // intentionally retain the upstream package identity after import rewriting + // and therefore require a package-local provider under strict layouts. + retainedVitestModule = false, + // Installed dependency metadata can reveal required Vitest peers whose + // package names do not include "vitest". + requiredVitestPeer = false, +): Record | null { + if (pkg.scripts) { + const updated = rewriteScripts( + JSON.stringify(pkg.scripts), + getScriptRulesYaml(skipStagedMigration), + ); + if (updated) { + pkg.scripts = JSON.parse(updated); + } + } + // Extract staged config from package.json (lint-staged) → will be merged into vite.config.ts. + // The lint-staged key is NOT deleted here — it's removed by the caller only after + // the merge into vite.config.ts succeeds, to avoid losing config on merge failure. + let extractedStagedConfig: Record | null = null; + if (!skipStagedMigration && pkg['lint-staged']) { + const config = pkg['lint-staged']; + const updated = rewriteScripts(JSON.stringify(config), readRulesYaml()); + extractedStagedConfig = updated ? JSON.parse(updated) : config; + } + const supportCatalog = !!isMonorepo && packageManager !== PackageManager.npm; + let needVitePlus = false; + 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 }, + ]; + // Scrub stale `npm:@voidzero-dev/vite-plus-test@...` aliases left over from + // earlier vite-plus migrations — the wrapper package no longer exists, so + // these entries would break `pnpm install`. Real user ranges are preserved. + for (const { dependencies } of dependencyGroups) { + if (pruneLegacyWrapperAliases(dependencies)) { + needVitePlus = true; + } + } + const managed = managedOverridePackages(usesVitestDirectly); + // Common case (no direct vitest): vite-plus consumes upstream vitest itself, + // so ACTIVELY REMOVE any lingering managed `vitest` dependency (a managed pin, + // a `catalog:` reference, or a stale wrapper alias already normalized above) — + // it arrives transitively through vite-plus and a future `vp update vite-plus` + // keeps it correct with no pin to drift. The `@vitest/*` family and unrelated + // keys are untouched. (Browser-mode / vitest-adjacent projects re-add a direct + // `vitest` below; those are direct-usage signals, so this never strips one a + // surviving consumer needs.) + if (!usesVitestDirectly) { + // Only the INSTALL groups — a `peerDependencies` `vitest` is a declaration + // about consumers, not an install pin, so it is not removed here. Catalog + // peer specs are resolved to their public range/fallback below. + for (const { dependencyField, dependencies } of dependencyGroups) { + if (dependencyField === 'peerDependencies') { + continue; + } + if (removeManagedVitestEntry(dependencies)) { + needVitePlus = true; + } + } + } + for (const [key, version] of Object.entries(managed)) { + for (const { dependencyField, dependencies } of dependencyGroups) { + if (dependencies?.[key]) { + dependencies[key] = getCatalogDependencySpec(dependencies[key], version, supportCatalog, { + dependencyField, + dependencyName: key, + packageManager, + catalogDependencyResolver, + preferredCatalogSpec: catalogDependencyResolver?.preferredCatalogSpec, + }); + needVitePlus = true; + } + } + } + if (normalizeVitestPeerCatalogSpec(pkg.peerDependencies, catalogDependencyResolver)) { + needVitePlus = true; + } + // Optional Vitest packages are published in lockstep with the runner. Keep + // every declared official @vitest/* package on the bundled version during a + // fresh migration too; existing-Vite+ upgrades use the same rule in the + // bootstrap path. + alignVitestEcosystemPackages(pkg, packageManager, supportCatalog, catalogDependencyResolver); + // Force-override mode (ecosystem CI / `vp create` E2E) must re-pin any + // pre-existing `vite-plus` range to the local tgz. Otherwise pnpm reads the + // published vite-plus metadata for transitive dep resolution (e.g. + // `@voidzero-dev/vite-plus-test`) even though the override replaces the + // vite-plus package itself, dragging the stale wrapper into node_modules. + if (isForceOverrideMode()) { + for (const { dependencies } of dependencyGroups) { + if (dependencies?.[VITE_PLUS_NAME]) { + // The referenced catalog entry is rewritten to the pkg.pr.new target + // separately. Preserve named/default catalog references so projects + // such as Vize do not gain an unnecessary default catalog. + if ( + !supportCatalog || + VITE_PLUS_VERSION.startsWith('file:') || + !dependencies[VITE_PLUS_NAME].startsWith('catalog:') + ) { + dependencies[VITE_PLUS_NAME] = VITE_PLUS_VERSION; + } + needVitePlus = true; + } + } + } + // Capture browser-mode signal from the original deps BEFORE the removal loop + // strips them. A package can drive vitest browser mode purely through config + // (`test.browser.provider: 'playwright'` in `vite.config.ts`) without ever + // importing `@vitest/browser*` in source — the provider package is listed in + // devDependencies but vitest loads it by name. The source-scan signal + // (`usesVitestBrowserMode`) misses this case; the dep declaration is the + // authoritative intent signal. + const hasBrowserDepSignal = VITEST_BROWSER_DEP_NAMES.some((name) => + dependencyGroups.some(({ dependencies }) => dependencies?.[name] !== undefined), + ); + // remove packages that are replaced with vite-plus + for (const name of REMOVE_PACKAGES) { + let wasRemoved = false; + for (const { dependencies } of dependencyGroups) { + if (dependencies?.[name]) { + delete dependencies[name]; + wasRemoved = true; + } + } + if (wasRemoved) { + needVitePlus = true; + } + // e.g., removing @vitest/browser-playwright should keep `playwright` in devDeps + const peerDep = BROWSER_PROVIDER_PEER_DEPS[name]; + if ( + wasRemoved && + peerDep && + !pkg.devDependencies?.[peerDep] && + !pkg.dependencies?.[peerDep] && + !pkg.peerDependencies?.[peerDep] && + !pkg.optionalDependencies?.[peerDep] + ) { + pkg.devDependencies ??= {}; + pkg.devDependencies[peerDep] = '*'; + } + } + // The browser providers (webdriverio, playwright) are opt-in: vite-plus no + // longer bundles them at runtime (each drags a heavy non-optional framework + // peer), so a user targeting a provider must own it themselves for the + // rewritten `vite-plus/test/browser-` import to resolve. Unlike the + // rest of the `@vitest/*` family they are deliberately NOT in + // VITE_PLUS_OVERRIDE_PACKAGES (so projects not using a provider stay + // untouched), which means the normalization loop above does not add them. We + // align each installed provider here using its existing catalog when present, + // or the concrete bundled version otherwise, and ensure its runtime framework + // peer (`webdriverio` / `playwright`). (`@vitest/browser`/preview stay bundled + // + stripped, handled in the REMOVE_PACKAGES loop above.) + let usesAnyOptInProvider = false; + for (const provider of OPT_IN_BROWSER_PROVIDERS) { + const usesProvider = + providerSourceModes?.[provider] || + dependencyGroups.some(({ dependencies }) => dependencies?.[provider] !== undefined); + if (!usesProvider) { + continue; + } + usesAnyOptInProvider = true; + // The provider must be INSTALLED (in deps/devDeps/optionalDeps, not merely a + // peer) for the rewritten `vite-plus/test/browser-` import to + // resolve. Normalize an existing install-group declaration to the bundled + // vitest version in place (the override loop above no longer pins it); + // otherwise — a source-only or peer-only user — inject it into devDeps. + const installGroupEntry = dependencyGroups.find( + ({ dependencyField, dependencies }) => + dependencyField !== 'peerDependencies' && dependencies?.[provider] !== undefined, + ); + if (installGroupEntry?.dependencies) { + if (VITEST_IS_MANAGED_OVERRIDE) { + installGroupEntry.dependencies[provider] = getAlignedVitestEcosystemDependencySpec( + installGroupEntry.dependencies[provider], + provider, + installGroupEntry.dependencyField, + packageManager, + supportCatalog, + catalogDependencyResolver, + ); + } + } else { + pkg.devDependencies ??= {}; + pkg.devDependencies[provider] = getCatalogDependencySpec( + undefined, + VITEST_VERSION, + supportCatalog && packageManager !== PackageManager.bun, + { preferredCatalogSpec: catalogDependencyResolver?.preferredCatalogSpec }, + ); + } + const peer = BROWSER_PROVIDER_PEER_DEPS[provider]; // 'webdriverio' / 'playwright' + const peerPresent = + pkg.dependencies?.[peer] ?? + pkg.devDependencies?.[peer] ?? + pkg.peerDependencies?.[peer] ?? + pkg.optionalDependencies?.[peer]; + if (peer && !peerPresent) { + pkg.devDependencies ??= {}; + pkg.devDependencies[peer] = '*'; + } + needVitePlus = true; + } + // An opt-in browser provider drags in its OWN `@vitest/browser → @vitest/mocker` + // subtree that is distinct from the one vite-plus bundles, so npm's flat + // node_modules cannot dedupe the two and leaves several nested `@vitest/mocker` + // copies. `@vitest/mocker/dist/node.js` statically `import`s `vite` (its `vite` + // peer is optional, so install never errors), and the `vite` override only lands + // deep inside the `vitest` subtree — unreachable from the nested provider chain. + // The result is `ERR_MODULE_NOT_FOUND: Cannot find package 'vite'` when loading + // the browser config. Mirror the override as a direct `vite` devDep (as the bun + // branch already does for its own resolver) so npm hoists a single top-level + // `node_modules/vite` that every nested `@vitest/mocker` resolves. Gated on + // provider usage so non-browser (node-mode) projects — which dedupe cleanly and + // need no direct `vite` — stay untouched. pnpm/yarn use symlink/PnP layouts that + // already expose the override to the provider subtree, so this is npm-only. + if (usesAnyOptInProvider && packageManager === PackageManager.npm) { + const viteOverride = VITE_PLUS_OVERRIDE_PACKAGES.vite; + const viteAlreadyDirect = + pkg.dependencies?.vite ?? pkg.devDependencies?.vite ?? pkg.optionalDependencies?.vite; + if (viteOverride && !viteAlreadyDirect) { + pkg.devDependencies ??= {}; + pkg.devDependencies.vite = viteOverride; + needVitePlus = true; + } + } + // Promote dep-derived signal to the same flag the source-scan feeds, so the + // downstream "add direct `vitest`" branch fires for config-only browser-mode + // setups too. + const effectiveBrowserMode = vitestBrowserMode || hasBrowserDepSignal; + // Trigger vite-plus install when a project has a vitest-adjacent package + // (e.g. `vitest-browser-svelte`) that declares vitest as a peer dep — even + // if the project has no vite/oxlint/tsdown dep to migrate. Only installed + // dependency groups count; a peer declaration alone installs nothing here. + const installableNames = [ + ...Object.keys(pkg.dependencies ?? {}), + ...Object.keys(pkg.devDependencies ?? {}), + ...Object.keys(pkg.optionalDependencies ?? {}), + ]; + const isVitestAdjacent = + !installableNames.includes('vitest') && + installableNames.some( + (name) => + name !== 'vitest' && name.includes('vitest') && !VITEST_DIRECT_USAGE_EXCLUDED.has(name), + ); + // Normalize a pre-existing pinned vite-plus so sub-packages don't drift + // from siblings: in catalog-supporting monorepos that's `catalog:`, under + // force-override (file:) it's the tgz path. Preserve protocol-prefixed + // specs (catalog:named, workspace:*, link:, file:, npm:, github:, git+/git:, + // http(s)://) so deliberate user pins survive; only vanilla version ranges + // (e.g. `^0.1.20`, `latest`) are rewritten. + const canonicalVitePlusSpec = + supportCatalog && !VITE_PLUS_VERSION.startsWith('file:') + ? (catalogDependencyResolver?.preferredCatalogSpec ?? 'catalog:') + : VITE_PLUS_VERSION; + // Treat vite-plus as present when it lives in either `devDependencies` or + // `dependencies` (devDeps wins when both exist). Re-pin/normalize happens in + // whichever group already owns it so a `dependencies` entry is never + // duplicated into `devDependencies`. + const existingVitePlusGroup = + pkg.devDependencies?.[VITE_PLUS_NAME] !== undefined + ? pkg.devDependencies + : pkg.dependencies?.[VITE_PLUS_NAME] !== undefined + ? pkg.dependencies + : undefined; + const existingVitePlus = existingVitePlusGroup?.[VITE_PLUS_NAME]; + const shouldNormalizeExistingVitePlus = + !!existingVitePlus && + supportCatalog && + existingVitePlus !== canonicalVitePlusSpec && + !isProtocolPinnedSpec(existingVitePlus); + // vitest-adjacent / browser-mode signals only trigger a vite-plus INSTALL when the + // project doesn't already have vite-plus — otherwise vite-plus is already present and + // re-adding it would be churn. (The direct `vitest` pin those signals also require is + // decided separately below, independent of whether vite-plus is present.) + if (!existingVitePlus && (isVitestAdjacent || effectiveBrowserMode)) { + needVitePlus = true; + } + // Browser mode AND a vitest-adjacent dep (e.g. `vitest-browser-svelte`, which + // declares a non-optional `vitest` peer) both need a direct `vitest` pin INDEPENDENT + // of whether `vite-plus` is already present: that peer must resolve from the package's + // OWN root under pnpm strict / Yarn PnP, where `vite-plus`'s transitive `vitest` is not + // visible. Tracked separately from `needVitePlus` so the pin is added without re-adding + // an already-present `vite-plus` — e.g. a monorepo root, where + // `rewriteRootWorkspacePackageJson` injects `vite-plus` BEFORE this runs (so + // `existingVitePlus` is already truthy here), or a re-migration of a project that + // already owns it. The guard below still no-ops when a direct `vitest` already exists, + // so a genuine normalize pass of an already-correct project mutates nothing. + const needDirectVitest = + needVitePlus || + effectiveBrowserMode || + isVitestAdjacent || + retainedVitestModule || + requiredVitestPeer; + if (existingVitePlusGroup) { + // Already present in `dependencies` or `devDependencies`: re-pin in place + // (only vanilla ranges are normalized; protocol pins are preserved) and + // never add a cross-group duplicate. + if (shouldNormalizeExistingVitePlus) { + existingVitePlusGroup[VITE_PLUS_NAME] = canonicalVitePlusSpec; + } + } else if (needVitePlus) { + // Absent from both groups: add it to `devDependencies` as before. + pkg.devDependencies = { + ...pkg.devDependencies, + [VITE_PLUS_NAME]: canonicalVitePlusSpec, + }; + } + ensureDirectViteForPnpm(pkg, packageManager, supportCatalog, catalogDependencyResolver); + // Add `vitest` as a direct devDependency when: + // - a remaining dependency likely peer-depends on vitest (e.g. + // vitest-browser-svelte), OR + // - the package runs vitest browser mode (`@vitest/browser` needs + // `vitest` resolvable from the package root — see usesVitestBrowserMode). + // Vite-plus already bundles upstream vitest as a direct dep, but a strict + // pnpm / yarn Plug'n'Play layout will not expose that transitive `vitest` + // to the package. Pinning it here points the dep at the same upstream + // version vite-plus ships with. Gated by needDirectVitest (browser-mode / + // vitest-adjacent, or some other change) — a pure normalize pass must not + // mutate the project beyond the vite-plus spec. + if (needDirectVitest) { + const installableDeps = { + ...pkg.dependencies, + ...pkg.devDependencies, + ...pkg.optionalDependencies, + }; + if ( + !installableDeps.vitest && + (effectiveBrowserMode || + retainedVitestModule || + requiredVitestPeer || + Object.keys(installableDeps).some((name) => name.includes('vitest'))) + ) { + pkg.devDependencies ??= {}; + pkg.devDependencies.vitest = getCatalogDependencySpec( + undefined, + VITEST_VERSION, + supportCatalog, + { preferredCatalogSpec: catalogDependencyResolver?.preferredCatalogSpec }, + ); + } + } + return extractedStagedConfig; +} + +// Returns true if the spec uses a known protocol prefix (catalog:, workspace:, +// link:, file:, npm:, github:, git+/git:, http(s)://) and so represents a +// deliberate user choice that should not be silently rewritten. +export function isProtocolPinnedSpec(spec: string): boolean { + return /^(catalog:|workspace:|link:|file:|npm:|github:|git[+:]|https?:\/\/)/.test(spec); +} diff --git a/packages/cli/src/migration/migrator/prettier.ts b/packages/cli/src/migration/migrator/prettier.ts new file mode 100644 index 0000000000..8bbcdd0453 --- /dev/null +++ b/packages/cli/src/migration/migrator/prettier.ts @@ -0,0 +1,338 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { styleText } from 'node:util'; + +import * as prompts from '@voidzero-dev/vite-plus-prompts'; + +import { rewritePrettier } from '../../../binding/index.js'; +import { type WorkspacePackage } from '../../types/index.ts'; +import { runCommandSilently } from '../../utils/command.ts'; +import { editJsonFile, readJsonFile } from '../../utils/json.ts'; +import { displayRelative } from '../../utils/path.ts'; +import { cancelAndExit } from '../../utils/prompts.ts'; +import { getSpinner } from '../../utils/spinner.ts'; +import { PRETTIER_CONFIG_FILES, PRETTIER_PACKAGE_JSON_CONFIG, detectConfigs } from '../detector.ts'; +import { rewriteToolLintStagedConfigFiles } from '../migrator.ts'; +import { type MigrationReport } from '../report.ts'; +import { warnMigration } from './shared.ts'; + +export function detectPrettierProject( + projectPath: string, + packages?: WorkspacePackage[], +): { + hasDependency: boolean; + configFile?: string; +} { + const packageJsonPath = path.join(projectPath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return { hasDependency: false }; + } + const pkg = readJsonFile(packageJsonPath) as { + devDependencies?: Record; + dependencies?: Record; + }; + let hasDependency = !!(pkg.devDependencies?.prettier || pkg.dependencies?.prettier); + const configs = detectConfigs(projectPath); + const configFile = configs.prettierConfig; + + // If root doesn't have prettier dependency, check workspace packages + if (!hasDependency && packages) { + for (const wp of packages) { + const pkgJsonPath = path.join(projectPath, wp.path, 'package.json'); + if (!fs.existsSync(pkgJsonPath)) { + continue; + } + const wpPkg = readJsonFile(pkgJsonPath) as { + devDependencies?: Record; + dependencies?: Record; + }; + if (wpPkg.devDependencies?.prettier || wpPkg.dependencies?.prettier) { + hasDependency = true; + break; + } + } + } + + return { hasDependency, configFile }; +} + +/** + * Run `vp fmt --migrate=prettier` step with graceful error handling. + * Returns true on success, false on failure. + */ +async function runPrettierMigrateStep( + vpBin: string, + cwd: string, + spinner: ReturnType, + failMessage: string, + manualHint: string, +): Promise { + try { + const result = await runCommandSilently({ + command: vpBin, + args: ['fmt', '--migrate=prettier'], + cwd, + envs: process.env, + }); + if (result.exitCode !== 0) { + spinner.stop(failMessage); + const stderr = result.stderr.toString().trim(); + if (stderr) { + prompts.log.warn(`⚠ ${stderr}`); + } + prompts.log.info(manualHint); + return false; + } + return true; + } catch { + spinner.stop(failMessage); + prompts.log.info(manualHint); + return false; + } +} + +export async function migratePrettierToOxfmt( + projectPath: string, + interactive: boolean, + prettierConfigFile?: string, + packages?: WorkspacePackage[], + options?: { silent?: boolean; report?: MigrationReport }, +): Promise { + const vpBin = process.env.VP_CLI_BIN ?? 'vp'; + const spinner = options?.silent + ? { + start: () => {}, + stop: () => {}, + pause: () => {}, + resume: () => {}, + cancel: () => {}, + error: () => {}, + clear: () => {}, + message: () => {}, + isCancelled: false, + } + : getSpinner(interactive); + + // Step 1: Generate .oxfmtrc.json from Prettier config + if (prettierConfigFile) { + let tempPrettierConfig: string | undefined; + + // If config is in package.json, extract it to a temporary .prettierrc.json + // so that `vp fmt --migrate=prettier` can read it + if (prettierConfigFile === PRETTIER_PACKAGE_JSON_CONFIG) { + const packageJsonPath = path.join(projectPath, 'package.json'); + const pkg = readJsonFile(packageJsonPath) as { prettier?: unknown }; + if (pkg.prettier) { + tempPrettierConfig = path.join(projectPath, '.prettierrc.json'); + fs.writeFileSync(tempPrettierConfig, JSON.stringify(pkg.prettier, null, 2)); + } else { + // Config disappeared between detection and migration — nothing to migrate + return true; + } + } + + try { + spinner.start('Migrating Prettier config to Oxfmt...'); + const migrateOk = await runPrettierMigrateStep( + vpBin, + projectPath, + spinner, + 'Prettier migration failed', + 'You can run `vp fmt --migrate=prettier` manually later', + ); + if (!migrateOk) { + return false; + } + spinner.stop('Prettier config migrated to .oxfmtrc.json'); + } finally { + if (tempPrettierConfig) { + try { + fs.unlinkSync(tempPrettierConfig); + } catch {} + } + } + } + + if (options?.report) { + options.report.prettierMigrated = true; + } + + // Step 2: Delete all prettier config files at root + deletePrettierConfigFiles(projectPath, options?.report, options?.silent); + + // Step 3: Remove prettier dependency and rewrite prettier scripts (root) + rewritePrettierPackageJson(path.join(projectPath, 'package.json')); + + // Step 3b: Rewrite prettier scripts in workspace packages + if (packages) { + for (const pkg of packages) { + rewritePrettierPackageJson(path.join(projectPath, pkg.path, 'package.json')); + } + } + + // Step 4: Rewrite prettier references in lint-staged config files + rewritePrettierLintStagedConfigFiles(projectPath, options?.report); + + // Step 5: Warn about .prettierignore if it exists + const prettierIgnorePath = path.join(projectPath, '.prettierignore'); + if (fs.existsSync(prettierIgnorePath)) { + warnMigration( + `${displayRelative(prettierIgnorePath)} found — Oxfmt supports .prettierignore, but using the \`ignorePatterns\` option is recommended.`, + options?.report, + ); + } + + return true; +} + +function deletePrettierConfigFiles( + basePath: string, + report?: MigrationReport, + silent = false, +): void { + // Delete detected prettier config file (like deleteEslintConfigFiles uses detectConfigs) + const configs = detectConfigs(basePath); + if (configs.prettierConfig && configs.prettierConfig !== PRETTIER_PACKAGE_JSON_CONFIG) { + const configPath = path.join(basePath, configs.prettierConfig); + if (fs.existsSync(configPath)) { + fs.unlinkSync(configPath); + if (report) { + report.removedConfigCount++; + } + if (!silent) { + prompts.log.success(`✔ Removed ${displayRelative(configPath)}`); + } + } + } + // Also clean up any stale prettier config files that detectConfigs didn't pick + // (prettier only uses one config, but users may have leftover files) + for (const file of PRETTIER_CONFIG_FILES) { + if (file === configs.prettierConfig) { + continue; // already handled above + } + const configPath = path.join(basePath, file); + if (fs.existsSync(configPath)) { + fs.unlinkSync(configPath); + if (report) { + report.removedConfigCount++; + } + if (!silent) { + prompts.log.success(`✔ Removed ${displayRelative(configPath)}`); + } + } + } + // Remove "prettier" key from package.json if present + editJsonFile<{ prettier?: unknown }>(path.join(basePath, 'package.json'), (pkg) => { + if (pkg.prettier) { + delete pkg.prettier; + return pkg; + } + return undefined; + }); +} + +function rewritePrettierPackageJson(packageJsonPath: string): void { + if (!fs.existsSync(packageJsonPath)) { + return; + } + editJsonFile<{ + devDependencies?: Record; + dependencies?: Record; + scripts?: Record; + 'lint-staged'?: Record; + }>(packageJsonPath, (pkg) => { + let changed = false; + // Remove prettier and prettier-plugin-* dependencies + if (pkg.devDependencies) { + for (const dep of Object.keys(pkg.devDependencies)) { + if (dep === 'prettier' || dep.startsWith('prettier-plugin-')) { + delete pkg.devDependencies[dep]; + changed = true; + } + } + } + if (pkg.dependencies) { + for (const dep of Object.keys(pkg.dependencies)) { + if (dep === 'prettier' || dep.startsWith('prettier-plugin-')) { + delete pkg.dependencies[dep]; + changed = true; + } + } + } + if (pkg.scripts) { + const updated = rewritePrettier(JSON.stringify(pkg.scripts)); + if (updated) { + pkg.scripts = JSON.parse(updated); + changed = true; + } + } + if (pkg['lint-staged']) { + const updated = rewritePrettier(JSON.stringify(pkg['lint-staged'])); + if (updated) { + pkg['lint-staged'] = JSON.parse(updated); + changed = true; + } + } + return changed ? pkg : undefined; + }); +} + +function rewritePrettierLintStagedConfigFiles(projectPath: string, report?: MigrationReport): void { + rewriteToolLintStagedConfigFiles(projectPath, rewritePrettier, 'prettier', report); +} + +export function warnPackageLevelPrettier() { + prompts.log.warn( + 'Prettier detected in workspace packages but no root config found. Package-level Prettier must be migrated manually.', + ); +} + +export async function confirmPrettierMigration(interactive: boolean): Promise { + if (interactive) { + const confirmed = await prompts.confirm({ + message: + 'Migrate Prettier to Oxfmt?\n ' + + styleText( + 'gray', + "Oxfmt is Vite+'s built-in formatter that replaces Prettier with faster performance. Your configuration will be converted automatically.", + ), + initialValue: true, + }); + if (prompts.isCancel(confirmed)) { + cancelAndExit(); + } + return confirmed; + } + prompts.log.info('Prettier configuration detected. Auto-migrating to Oxfmt...'); + return true; +} + +export async function promptPrettierMigration( + projectPath: string, + interactive: boolean, + packages?: WorkspacePackage[], +): Promise { + const prettierProject = detectPrettierProject(projectPath, packages); + if (!prettierProject.hasDependency) { + return false; + } + if (!prettierProject.configFile) { + // Packages have prettier but no root config → warn and skip + warnPackageLevelPrettier(); + return false; + } + const confirmed = await confirmPrettierMigration(interactive); + if (!confirmed) { + return false; + } + const ok = await migratePrettierToOxfmt( + projectPath, + interactive, + prettierProject.configFile, + packages, + ); + if (!ok) { + cancelAndExit('Prettier migration failed.', 1); + } + return true; +} diff --git a/packages/cli/src/migration/migrator/setup.ts b/packages/cli/src/migration/migrator/setup.ts new file mode 100644 index 0000000000..213f8936d1 --- /dev/null +++ b/packages/cli/src/migration/migrator/setup.ts @@ -0,0 +1,191 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import * as prompts from '@voidzero-dev/vite-plus-prompts'; +import semver from 'semver'; + +import { type DownloadPackageManagerResult } from '../../../binding/index.js'; +import { editJsonFile } from '../../utils/json.ts'; +import { detectConfigs } from '../detector.ts'; +import { type MigrationReport } from '../report.ts'; +import { warnMigration } from './shared.ts'; + +export function setPackageManager( + projectDir: string, + downloadPackageManager: DownloadPackageManagerResult, +) { + // Set the package manager pin. Compatibility-first rule (rfcs/dev-engines.md): + // an existing `packageManager` field or `devEngines.packageManager` declaration + // is the source of truth and is left as-is; otherwise the exact resolved version + // is written to `devEngines.packageManager` (the recommended standard field). + editJsonFile<{ + packageManager?: string; + devEngines?: { packageManager?: unknown; [key: string]: unknown }; + }>(path.join(projectDir, 'package.json'), (pkg) => { + if (!pkg.packageManager && !pkg.devEngines?.packageManager) { + // Only spread a well-formed object: spreading a malformed devEngines value + // (string/array) would corrupt the field with numeric index keys + const devEngines = + typeof pkg.devEngines === 'object' && + pkg.devEngines !== null && + !Array.isArray(pkg.devEngines) + ? pkg.devEngines + : undefined; + pkg.devEngines = { + ...devEngines, + packageManager: { + name: downloadPackageManager.name, + version: downloadPackageManager.version, + onFail: 'download', + }, + }; + } + return pkg; + }); +} + +export type NodeVersionManagerDetection = + | { file: '.nvmrc'; voltaPresent?: true } + | { file: 'package.json'; voltaNodeVersion: string }; + +/** + * Detect a .nvmrc file in the project directory. + * If not found, check for a Volta node version in package.json. + * If either is found, return the relevant info for migration. + * Returns undefined if not found or .node-version already exists. + */ +export function detectNodeVersionManagerFile( + projectPath: string, +): NodeVersionManagerDetection | undefined { + // already has .node-version — skip detection to avoid false positives and preserve existing file + if (fs.existsSync(path.join(projectPath, '.node-version'))) { + return undefined; + } + + const configs = detectConfigs(projectPath); + + // .nvmrc takes priority over volta.node when both are present. + // voltaPresent is carried through so the migration step can remind the user + // to remove the now-redundant volta field from package.json. + if (configs.nvmrcFile) { + return configs.voltaNode ? { file: '.nvmrc', voltaPresent: true } : { file: '.nvmrc' }; + } + + if (configs.voltaNode) { + return { file: 'package.json', voltaNodeVersion: configs.voltaNode }; + } + + return undefined; +} + +/** + * Parse a version alias from a .nvmrc file into a .node-version compatible string. + * Accepts the first line of .nvmrc (pre-trimmed). + * Returns null for unsupported aliases like "system", "default", "iojs". + */ +export function parseNvmrcVersion(alias: string): string | null { + const version = alias.trim(); + + if (!version) { + return null; + } + + // "node" and "stable" mean "latest stable release" which maps closely to lts/*. + // Starting from Node 27, all releases will be LTS, so the gap is shrinking. + // We map these to lts/* and log the conversion so users are aware. + if (version === 'node' || version === 'stable') { + return 'lts/*'; + } + + // "iojs", "system", and "default" have no meaningful equivalent and cannot be auto-migrated. + if (version === 'iojs' || version === 'system' || version === 'default') { + return null; + } + + // LTS aliases (lts/*, lts/iron, etc.) pass through as-is + if (version.startsWith('lts/')) { + return version; + } + + // Strip optional 'v' prefix, then validate as a semver version or range + const normalized = version.startsWith('v') ? version.slice(1) : version; + if (!normalized || !semver.validRange(normalized)) { + return null; + } + return normalized; +} + +/** + * Migrate .nvmrc or Volta node version from package.json to .node-version. + * - For .nvmrc: the source file is removed after migration. + * - For package.json (Volta): the volta field is left as-is; removal is left to the user's discretion. + * Returns true on success, false if migration was skipped or failed. + */ +export function migrateNodeVersionManagerFile( + projectPath: string, + detection: NodeVersionManagerDetection, + report?: MigrationReport, +): boolean { + const nodeVersionPath = path.join(projectPath, '.node-version'); + + // Volta: node version was already extracted during detection — no package.json re-read needed + if (detection.file === 'package.json') { + const { voltaNodeVersion } = detection; + + // Normalize Volta's "lts" alias to the .node-version compatible form + const resolvedVersion = voltaNodeVersion === 'lts' ? 'lts/*' : voltaNodeVersion; + + if (!semver.valid(resolvedVersion) && resolvedVersion !== 'lts/*') { + warnMigration( + `package.json volta.node "${voltaNodeVersion}" is not an exact version. Pin an exact version (e.g. ${voltaNodeVersion}.0 or run \`volta pin node@${voltaNodeVersion}\`) then re-run migration.`, + report, + ); + return false; + } + + fs.writeFileSync(nodeVersionPath, `${resolvedVersion}\n`); + if (report) { + report.manualSteps.push('Remove the "volta" field from package.json'); + report.nodeVersionFileMigrated = true; + } else { + prompts.log.info('You can now remove the "volta" field from package.json manually.'); + } + return true; + } + + // .nvmrc: parse version alias and write to .node-version + const sourcePath = path.join(projectPath, '.nvmrc'); + const content = fs.readFileSync(sourcePath, 'utf8'); + const originalAlias = content.split('\n')[0]?.trim() ?? ''; + const version = parseNvmrcVersion(originalAlias); + + if (!version) { + warnMigration( + '.nvmrc contains an unsupported version alias. Create .node-version manually with your desired Node.js version.', + report, + ); + return false; + } + + // TODO: remove this log once Node 27+ makes all releases LTS, at which point + // "node"/"stable" and "lts/*" will be effectively equivalent. + if (version === 'lts/*' && (originalAlias === 'node' || originalAlias === 'stable')) { + prompts.log.info( + `"${originalAlias}" in .nvmrc is not a specific version; automatically mapping to "lts/*"`, + ); + } + + fs.writeFileSync(nodeVersionPath, `${version}\n`); + fs.unlinkSync(sourcePath); + + if (report) { + report.nodeVersionFileMigrated = true; + // Both .nvmrc and volta were present; .nvmrc was migrated but volta still lingers. + if (detection.voltaPresent) { + report.manualSteps.push('Remove the "volta" field from package.json'); + } + } else if (detection.voltaPresent) { + prompts.log.info('You can now remove the "volta" field from package.json manually.'); + } + return true; +} diff --git a/packages/cli/src/migration/migrator/shared.ts b/packages/cli/src/migration/migrator/shared.ts new file mode 100644 index 0000000000..4c0c53849f --- /dev/null +++ b/packages/cli/src/migration/migrator/shared.ts @@ -0,0 +1,220 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import * as prompts from '@voidzero-dev/vite-plus-prompts'; +import semver from 'semver'; + +import { VITEST_VERSION, VITE_PLUS_OVERRIDE_PACKAGES } from '../../utils/constants.ts'; +import { readJsonFile } from '../../utils/json.ts'; +import { detectPackageMetadata } from '../../utils/package.ts'; +import { displayRelative } from '../../utils/path.ts'; +import { addManualStep, addMigrationWarning, type MigrationReport } from '../report.ts'; + +// All known lint-staged config file names. +// JSON-parseable ones come first so rewriteLintStagedConfigFile can rewrite them. +export const LINT_STAGED_JSON_CONFIG_FILES = ['.lintstagedrc.json', '.lintstagedrc'] as const; + +export const LINT_STAGED_OTHER_CONFIG_FILES = [ + '.lintstagedrc.yaml', + '.lintstagedrc.yml', + '.lintstagedrc.mjs', + 'lint-staged.config.mjs', + '.lintstagedrc.cjs', + 'lint-staged.config.cjs', + '.lintstagedrc.js', + 'lint-staged.config.js', + '.lintstagedrc.ts', + 'lint-staged.config.ts', + '.lintstagedrc.mts', + 'lint-staged.config.mts', + '.lintstagedrc.cts', + 'lint-staged.config.cts', +] as const; + +export const LINT_STAGED_ALL_CONFIG_FILES = [ + ...LINT_STAGED_JSON_CONFIG_FILES, + ...LINT_STAGED_OTHER_CONFIG_FILES, +] as const; + +// packages that are replaced with vite-plus +export const REMOVE_PACKAGES = [ + 'oxlint', + 'oxlint-tsgolint', + 'oxfmt', + 'tsdown', + '@vitest/browser', + '@vitest/browser-preview', +] as const; + +// The opt-in browser providers. Unlike `@vitest/browser`/preview these are NOT +// bundled by vite-plus or stripped from users (so they stay out of +// REMOVE_PACKAGES); each drags a heavy non-optional framework peer +// (`playwright` / `webdriverio`) that non-browser consumers must not be forced +// to install. The migration keeps a provider the user actually targets in their +// own deps, pinned to the bundled vitest version. +export const WEBDRIVERIO_PROVIDER = '@vitest/browser-webdriverio'; + +export const PLAYWRIGHT_PROVIDER = '@vitest/browser-playwright'; + +// All opt-in browser providers handled identically by the migration: kept in +// the user's deps (pinned to the bundled vitest), framework peer ensured, stale +// forcing pins dropped, while their catalog entries are PRESERVED. +export const OPT_IN_BROWSER_PROVIDERS = [WEBDRIVERIO_PROVIDER, PLAYWRIGHT_PROVIDER] as const; + +// Provider names whose stale pnpm overrides / resolutions are dropped during +// migration: everything vite-plus owns (REMOVE_PACKAGES) plus the user-owned +// opt-in providers. The provider DEP is preserved, but a leftover +// override/resolution pin to another version would WIN over the direct dep and +// misalign the provider against the bundled vitest — so the stale forcing pin is +// dropped while the dependency itself stays installed. NOTE: catalog deletion +// uses REMOVE_PACKAGES (not this set) on purpose — a catalog entry is only a +// version *definition*, and deleting it could dangle a surviving `catalog:` +// reference (e.g. in peerDependencies) and break install. +export const PROVIDER_OVERRIDE_DROP_NAMES = [ + ...REMOVE_PACKAGES, + ...OPT_IN_BROWSER_PROVIDERS, +] as const; + +// When a browser provider package is removed, its runtime peer dependency +// must be preserved in devDependencies so browser tests continue to work. +export const BROWSER_PROVIDER_PEER_DEPS: Record = { + '@vitest/browser-playwright': 'playwright', + '@vitest/browser-webdriverio': 'webdriverio', +}; + +// Browser-provider package names that, when present in the user's deps +// before migration, signal vitest browser mode even if no source file +// imports them. This covers config-only browser-mode setups (e.g. +// `test.browser.provider: 'playwright'` in `vite.config.ts`) where the +// provider package is declared in `devDependencies` but never `import`ed. +export const VITEST_BROWSER_DEP_NAMES = [ + '@vitest/browser', + '@vitest/browser-preview', + '@vitest/browser-playwright', + '@vitest/browser-webdriverio', +] as const; + +// Common case (`!usesVitest`): vite-plus consumes upstream vitest itself, so a +// lingering `vitest` entry — a managed pin, a stale `npm:@voidzero-dev/vite-plus-test@*` +// wrapper alias, or a `catalog:` reference — must be REMOVED from every sink so +// it arrives transitively through vite-plus and a future `vp update vite-plus` +// keeps it correct with no pin to drift. The `@vitest/*` family is left +// untouched (those are direct-usage signals handled elsewhere). +// +// The removal only applies when `vitest` is a key vite-plus actually manages in +// the active override config. In force-override / CI mode (`VP_OVERRIDE_PACKAGES` +// with file: tgz aliases) `vitest` is NOT in the override set, so a `vitest` +// entry there is the user's own and must be left untouched. +export const VITEST_IS_MANAGED_OVERRIDE = 'vitest' in VITE_PLUS_OVERRIDE_PACKAGES; + +// Fallback specs used when normalizing a stale wrapper alias. Real user +// ranges (e.g. `vitest: ^3.0.0`) are preserved — only the wrapper alias is +// rewritten. For `vitest`, we substitute the vitest version vite-plus +// bundles so any `catalog:` reference the user still has resolves cleanly. +export const LEGACY_WRAPPER_FALLBACK_VERSIONS: Record = { + vitest: VITEST_VERSION, +}; + +export type PackageJsonDependencyField = + | 'devDependencies' + | 'dependencies' + | 'peerDependencies' + | 'optionalDependencies'; + +export type CatalogDependencyResolver = (( + catalogSpec: string, + dependencyName: string, +) => string | undefined) & { + preferredCatalogSpec: string; +}; + +export function warnMigration(message: string, report?: MigrationReport) { + addMigrationWarning(report, message); + if (!report) { + prompts.log.warn(message); + } +} + +export function infoMigration(message: string, report?: MigrationReport) { + addManualStep(report, message); + if (!report) { + prompts.log.info(message); + } +} + +export function checkViteVersion(projectPath: string): boolean { + return checkPackageVersion(projectPath, 'vite', '7.0.0'); +} + +export function checkVitestVersion(projectPath: string): boolean { + return checkPackageVersion(projectPath, 'vitest', '4.0.0'); +} + +/** + * Check the package version is supported by auto migration + * @param projectPath - The path to the project + * @param name - The name of the package + * @param minVersion - The minimum version of the package + * @returns true if the package version is supported by auto migration + */ +function checkPackageVersion(projectPath: string, name: string, minVersion: string): boolean { + const metadata = detectPackageMetadata(projectPath, name); + if (!metadata || metadata.name !== name) { + return true; + } + if (semver.satisfies(metadata.version, `<${minVersion}`)) { + const packageJsonFilePath = path.join(projectPath, 'package.json'); + prompts.log.error( + `✘ ${name}@${metadata.version} in ${displayRelative(packageJsonFilePath)} is not supported by auto migration`, + ); + prompts.log.info(`Please upgrade ${name} to version >=${minVersion} first`); + return false; + } + return true; +} + +type PnpmPeerDependencyRules = { + allowAny?: string[]; + allowedVersions?: Record; + [key: string]: unknown; +}; + +export type PnpmPackageJsonSettings = { + overrides?: Record; + peerDependencyRules?: PnpmPeerDependencyRules; + allowBuilds?: Record; + onlyBuiltDependencies?: string[]; + [key: string]: unknown; +}; + +export function isPlainRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +export type DependencyBag = { + dependencies?: Record; + devDependencies?: Record; + optionalDependencies?: Record; + peerDependencies?: Record; +}; + +export function readPackageJsonIfExists(packageJsonPath: string): DependencyBag | undefined { + if (!fs.existsSync(packageJsonPath)) { + return undefined; + } + try { + return readJsonFile(packageJsonPath) as DependencyBag; + } catch { + return undefined; + } +} + +// pnpm v10 introduced the map-shaped `allowBuilds` and removed the implicit +// "build everything" default; v9 (>= 9.5) gates builds via the list-shaped +// `onlyBuiltDependencies`. Both live in pnpm-workspace.yaml or in +// `package.json`'s `pnpm` field — vp migrate writes to whichever sink the +// rest of the migration is already touching. +export function pnpmMajor(version: string | undefined): number | undefined { + const coerced = version ? semver.coerce(version)?.version : undefined; + return coerced ? semver.major(coerced) : undefined; +} diff --git a/packages/cli/src/migration/migrator/source-scan.ts b/packages/cli/src/migration/migrator/source-scan.ts new file mode 100644 index 0000000000..32d32877c8 --- /dev/null +++ b/packages/cli/src/migration/migrator/source-scan.ts @@ -0,0 +1,338 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { type WorkspacePackage } from '../../types/index.ts'; +import { hasVitestTypesInTsconfig } from '../../utils/tsconfig.ts'; +import { projectUsesVitestDirectly } from '../migrator.ts'; +import { + OPT_IN_BROWSER_PROVIDERS, + PLAYWRIGHT_PROVIDER, + WEBDRIVERIO_PROVIDER, + readPackageJsonIfExists, + type DependencyBag, +} from './shared.ts'; + +// Workspace-wide direct-vitest signal for the SHARED sinks a monorepo root +// owns (pnpm-workspace.yaml catalog/overrides/peer rules, .yarnrc.yml catalog, +// bun catalog): `vitest` stays managed there iff ANY package in the workspace — +// the root or any sub-package — uses vitest directly. See +// `projectUsesVitestDirectly`. +export function workspaceUsesVitestDirectly( + rootDir: string, + packages: WorkspacePackage[] | undefined, + preserveNuxtVitestImports = true, +): boolean { + const rootPkg = readPackageJsonIfExists(path.join(rootDir, 'package.json')) ?? {}; + if (projectUsesVitestDirectly(rootDir, rootPkg, undefined, preserveNuxtVitestImports)) { + return true; + } + if (!packages) { + return false; + } + for (const pkg of packages) { + const packageDir = path.join(rootDir, pkg.path); + const subPkg = readPackageJsonIfExists(path.join(packageDir, 'package.json')) ?? {}; + if (projectUsesVitestDirectly(packageDir, subPkg, undefined, preserveNuxtVitestImports)) { + return true; + } + } + return false; +} + +// Specifier fragments that signal vitest browser mode. Matched as substrings +// against source (see `sourceTreeReferencesAny`), so subpath imports are +// covered too. Each indicates the package drives vitest's browser runner: +// - `@vitest/browser` upstream, pre-migration (incl. `/context`, +// `/client`, … subpaths) +// - `vite-plus/test/browser` migrated (re-run on an already-migrated +// project); also covers `…/browser/context` and +// the `…/browser/providers/*` provider forms +// - `vite-plus/test/{client,context,locators,matchers,utils}` the published +// bare browser shims (`build.ts` +// `createBareBrowserShims`): each re-exports +// `@vitest/browser/` but DROPS the `browser` +// segment, so they carry no `browser` substring. +// The import rewriter flattens +// `@vitest/browser/{client,locators,matchers, +// utils}` to four of these in already-migrated +// source; `vite-plus/test/context` is reachable +// as the published bare export (the rewriter +// instead routes `@vitest/browser/context` to +// `vite-plus/test/browser/context`, already +// covered above). All five are browser-only +// re-exports, so they never collide with a +// non-browser vitest export. +// - `vite-plus/test/plugins/browser` prefix for the generated plugin shims +// (`build.ts` `PLUGIN_SHIM_ENTRIES`: +// `plugins/browser`, `plugins/browser-context`, +// `plugins/browser-client`, `plugins/browser- +// locators`, `plugins/browser-playwright`, +// `plugins/browser-preview`, `plugins/browser- +// webdriverio`), which re-export `@vitest/browser*` +// under a `/plugins/` segment that the +// `vite-plus/test/browser` hint does not match. +// One prefix covers the whole family. +// - `vite-plus/test/internal/browser` the published internal browser shim +// (`./test/internal/browser`, re-exports +// `vitest/internal/browser`) — also a `/browser` +// surface with no `vite-plus/test/browser` +// substring. +// Without a matching hint a package importing only one of these published +// browser surfaces (with no `@vitest/browser*` dep) would miss browser mode and +// skip pinning the direct `vitest` the browser optimizer needs resolvable from +// the package root under pnpm strict / Yarn PnP. This set is verified complete +// against every browser-surface `./test/*` export in package.json (those that +// re-export `@vitest/browser*` or `vitest/internal/browser`). +const VITEST_BROWSER_SPECIFIER_HINTS = [ + // Before v0.2, projects commonly aliased `vitest` to + // `@voidzero-dev/vite-plus-test`, whose browser exports used these paths. + 'vitest/browser', + 'vitest/plugins/browser', + '@vitest/browser', + 'vite-plus/test/browser', + 'vite-plus/test/plugins/browser', + 'vite-plus/test/internal/browser', + 'vite-plus/test/client', + 'vite-plus/test/context', + 'vite-plus/test/locators', + 'vite-plus/test/matchers', + 'vite-plus/test/utils', +] as const; + +// Specifier fragments that signal the WEBDRIVERIO provider specifically. Each +// is a prefix, matched as a substring, so subpath imports (`/context`, +// `/provider`, …) are covered too: +// - `vitest/browser-webdriverio`, `vitest/browser/providers/webdriverio`, and +// `vitest/plugins/browser-webdriverio` are legacy +// `@voidzero-dev/vite-plus-test` exports reached through the `vitest` alias +// - `@vitest/browser-webdriverio` pre-migration (incl. `/provider`, +// `/context` subpaths) +// - `vite-plus/test/browser-webdriverio` migrated (re-run); covers +// `…/context` +// - `vite-plus/test/browser/providers/webdriverio` migrated provider-subpath +// form — the import rewriter maps +// `@vitest/browser-webdriverio/provider` +// here, so an already-migrated +// project can contain it. Without +// this hint a re-run would skip the +// provider injection and the import +// would break under pnpm strict / +// Yarn PnP once the provider is no +// longer a vite-plus runtime dep. +// - `vite-plus/test/plugins/browser-webdriverio` generated plugin shim that +// re-exports `@vitest/browser- +// webdriverio` wholesale; importing +// it pulls in the (now opt-in) +// provider, so it signals usage too. +const WEBDRIVERIO_PROVIDER_SPECIFIER_HINTS = [ + 'vitest/browser-webdriverio', + 'vitest/browser/providers/webdriverio', + 'vitest/plugins/browser-webdriverio', + '@vitest/browser-webdriverio', + 'vite-plus/test/browser-webdriverio', + 'vite-plus/test/browser/providers/webdriverio', + 'vite-plus/test/plugins/browser-webdriverio', +] as const; + +// Specifier fragments that signal the PLAYWRIGHT provider specifically — the +// playwright analogue of WEBDRIVERIO_PROVIDER_SPECIFIER_HINTS (same prefix / +// substring matching for `/provider`, `/context` subpaths). Playwright is opt-in +// just like webdriverio: vite-plus no longer bundles `@vitest/browser-playwright` +// at runtime, so a source-only user (e.g. `vite.config.ts` importing the +// provider via a `vite-plus/test/browser-playwright` shim with no declared dep) +// must still have the provider kept/injected for the rewritten import to resolve. +const PLAYWRIGHT_PROVIDER_SPECIFIER_HINTS = [ + // Legacy `@voidzero-dev/vite-plus-test` exports reached through the `vitest` + // alias. These must be detected before rewriteAllImports changes the prefix. + 'vitest/browser-playwright', + 'vitest/browser/providers/playwright', + 'vitest/plugins/browser-playwright', + '@vitest/browser-playwright', + 'vite-plus/test/browser-playwright', + 'vite-plus/test/browser/providers/playwright', + 'vite-plus/test/plugins/browser-playwright', +] as const; + +// Per-provider source-scan hint lists, used to build the `providerSourceModes` +// map passed to `rewritePackageJson`. +const BROWSER_PROVIDER_SPECIFIER_HINTS: Record = { + [WEBDRIVERIO_PROVIDER]: WEBDRIVERIO_PROVIDER_SPECIFIER_HINTS, + [PLAYWRIGHT_PROVIDER]: PLAYWRIGHT_PROVIDER_SPECIFIER_HINTS, +}; + +// TypeScript/JavaScript source extensions scanned for browser-mode hints. +const VITEST_SCAN_EXTENSIONS = new Set([ + '.ts', + '.mts', + '.cts', + '.tsx', + '.js', + '.mjs', + '.cjs', + '.jsx', +]); + +// Directories never worth scanning for browser-mode hints — generated output, +// installed deps, VCS metadata. Skipped at every recursion level. +const VITEST_SCAN_SKIP_DIRS = new Set([ + 'node_modules', + 'dist', + 'build', + 'out', + 'coverage', + '.git', + '.next', + '.nuxt', + '.svelte-kit', + '.vite', + '.cache', +]); + +/** + * Detect whether a package uses vitest's browser mode. + * + * Upstream `@vitest/browser` injects `optimizeDeps.include` entries of the form + * `vitest > expect-type` (and `vitest > @vitest/snapshot > magic-string`, + * `vitest > @vitest/expect > chai`). Vite resolves the leading `vitest` segment + * from the Vite config root, so `vitest` MUST be resolvable as a package from + * the consuming package's directory. In a pnpm strict (non-hoisted) layout, + * `vitest` pulled in only transitively via `vite-plus` is NOT reachable from the + * package root — the optimizer then fails with `Failed to resolve dependency` + * and the browser test page hangs forever. + * + * When this returns true the migration adds `vitest` as a direct + * devDependency so it is hoisted next to the package and the optimizer chain + * resolves. The signal is any of the package's TS/JS files (config, workspace + * config under any name, or test file) referencing `@vitest/browser*` or + * `vite-plus/test/browser*`. The scan recurses through the package directory + * (skipping `node_modules`, build output, VCS metadata) so browser config in a + * non-standard filename or browser imports in test files are all caught. + * + * Recursion stops at nested `package.json` boundaries: a workspace sub-package + * is a separate package that the migration scans on its own pass, so the root + * package must not inherit a browser-mode signal from a sub-package. + */ +function sourceTreeMatches( + projectPath: string, + matchesContent: (content: string) => boolean, +): boolean { + const scanDir = (dir: string, isRoot: boolean): boolean => { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return false; + } + // A nested package.json marks a separate workspace package — it is migrated + // (and scanned) on its own pass, so don't let its files leak into this one. + if (!isRoot && entries.some((e) => e.isFile() && e.name === 'package.json')) { + return false; + } + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (VITEST_SCAN_SKIP_DIRS.has(entry.name)) { + continue; + } + if (scanDir(entryPath, false)) { + return true; + } + } else if (entry.isFile() && VITEST_SCAN_EXTENSIONS.has(path.extname(entry.name))) { + try { + if (matchesContent(fs.readFileSync(entryPath, 'utf8'))) { + return true; + } + } catch { + // Unreadable file — ignore and keep scanning. + } + } + } + return false; + }; + + return scanDir(projectPath, true); +} + +function sourceTreeReferencesAny(projectPath: string, hints: readonly string[]): boolean { + return sourceTreeMatches(projectPath, (content) => hints.some((hint) => content.includes(hint))); +} + +function findPackageTsconfigFiles(projectPath: string): string[] { + const files: string[] = []; + const scanDir = (dir: string, isRoot: boolean): void => { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + if (!isRoot && entries.some((entry) => entry.isFile() && entry.name === 'package.json')) { + return; + } + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!VITEST_SCAN_SKIP_DIRS.has(entry.name)) { + scanDir(entryPath, false); + } + } else if (entry.isFile() && /^tsconfig(?:\.[\w-]+)?\.json$/i.test(entry.name)) { + files.push(entryPath); + } + } + }; + scanDir(projectPath, true); + return files; +} + +export function hasNuxtTestUtilsDependency(pkg: DependencyBag): boolean { + return [pkg.dependencies, pkg.devDependencies, pkg.optionalDependencies].some( + (dependencies) => dependencies?.['@nuxt/test-utils'] !== undefined, + ); +} + +// Normal imports and triple-slash type directives from `vitest` are rewritten +// to `vite-plus/test` later in the same migration and therefore do not justify +// a lasting direct dependency. Module augmentations, `vitest/package.json`, and +// compilerOptions.types entries deliberately retain the upstream package +// identity, so keep Vitest package-local for those surfaces. +export function sourceTreeReferencesRetainedVitestModule(projectPath: string): boolean { + return ( + findPackageTsconfigFiles(projectPath).some(hasVitestTypesInTsconfig) || + sourceTreeMatches(projectPath, (content) => { + return ( + /\bdeclare\s+module\s+['"]vitest(?:\/[^'"]*)?['"]/.test(content) || + content.includes('vitest/package.json') || + /\brequire\.resolve\s*\(\s*['"]vitest(?:\/[^'"]*)?['"]/.test(content) || + /\bimport\.meta\.resolve\s*\(\s*['"]vitest(?:\/[^'"]*)?['"]/.test(content) + ); + }) + ); +} + +export function usesVitestBrowserMode(projectPath: string): boolean { + return sourceTreeReferencesAny(projectPath, VITEST_BROWSER_SPECIFIER_HINTS); +} + +// Source-only signal that a package targets the WEBDRIVERIO provider — used to +// allow the edgedriver/geckodriver builds even when no dep is declared yet (the +// webdriverio-specific postinstall hazard; playwright has no such drivers). See +// `usesVitestBrowserMode` for the shared traversal semantics (extensions, skip +// dirs, nested-package boundary). +export function usesWebdriverioProvider(projectPath: string): boolean { + return sourceTreeReferencesAny(projectPath, WEBDRIVERIO_PROVIDER_SPECIFIER_HINTS); +} + +// Source-scan signal per opt-in browser provider, used to inject the (opt-in, +// no-longer-bundled) provider + its framework peer even when no dep is declared +// yet (e.g. a `vite.config.ts` importing the provider via a `vite-plus/test` +// shim). Mirrors `usesWebdriverioProvider`'s scan for each provider. +export function collectProviderSourceModes(projectPath: string): Record { + const modes: Record = {}; + for (const provider of OPT_IN_BROWSER_PROVIDERS) { + modes[provider] = sourceTreeReferencesAny( + projectPath, + BROWSER_PROVIDER_SPECIFIER_HINTS[provider], + ); + } + return modes; +} diff --git a/packages/cli/src/migration/migrator/tsconfig.ts b/packages/cli/src/migration/migrator/tsconfig.ts new file mode 100644 index 0000000000..58782601f8 --- /dev/null +++ b/packages/cli/src/migration/migrator/tsconfig.ts @@ -0,0 +1,61 @@ +import * as prompts from '@voidzero-dev/vite-plus-prompts'; + +import { displayRelative } from '../../utils/path.ts'; +import { + findTsconfigFiles, + hasTypesToRewriteInTsconfig, + removeDeprecatedTsconfigFalseOption, + rewriteTypesInTsconfig, +} from '../../utils/tsconfig.ts'; +import { type MigrationReport } from '../report.ts'; +import { warnMigration } from './shared.ts'; + +export function cleanupDeprecatedTsconfigOptions( + projectPath: string, + silent = false, + report?: MigrationReport, +): void { + const deprecatedOptions = ['esModuleInterop', 'allowSyntheticDefaultImports']; + const files = findTsconfigFiles(projectPath); + for (const filePath of files) { + for (const name of deprecatedOptions) { + if (removeDeprecatedTsconfigFalseOption(filePath, name)) { + if (report) { + report.removedConfigCount++; + } + if (!silent) { + prompts.log.success(`✔ Removed ${name}: false from ${displayRelative(filePath)}`); + } + warnMigration( + `Removed \`"${name}": false\` from ${displayRelative(filePath)} — this option has been deprecated. See https://github.com/oxc-project/tsgolint/issues/351, https://github.com/microsoft/TypeScript/issues/62529`, + report, + ); + } + } + } +} + +export function rewriteTsconfigTypes( + projectPath: string, + silent = false, + report?: MigrationReport, +): boolean { + let changed = false; + const files = findTsconfigFiles(projectPath); + for (const filePath of files) { + if (rewriteTypesInTsconfig(filePath)) { + changed = true; + if (report) { + report.removedConfigCount++; + } + if (!silent) { + prompts.log.success(`✔ Rewrote types in ${displayRelative(filePath)}`); + } + } + } + return changed; +} + +export function hasTsconfigTypesToRewrite(projectPath: string): boolean { + return findTsconfigFiles(projectPath).some((filePath) => hasTypesToRewriteInTsconfig(filePath)); +} diff --git a/packages/cli/src/migration/migrator/vite-config.ts b/packages/cli/src/migration/migrator/vite-config.ts new file mode 100644 index 0000000000..73372eec25 --- /dev/null +++ b/packages/cli/src/migration/migrator/vite-config.ts @@ -0,0 +1,531 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import * as prompts from '@voidzero-dev/vite-plus-prompts'; +import { type OxlintConfig } from 'oxlint'; + +import { + hasConfigKey, + mergeJsonConfig, + mergeTsdownConfig, + rewriteImportsInDirectory, + rewriteScripts, + wrapLazyPlugins, +} from '../../../binding/index.js'; +import { + createDefaultVitePlusLintConfig, + ensureVitePlusImportRuleDefaults, +} from '../../oxlint-plugin-config.ts'; +import { type WorkspacePackage } from '../../types/index.ts'; +import { BASEURL_TSCONFIG_WARNING, VITE_PLUS_NAME } from '../../utils/constants.ts'; +import { editJsonFile, isJsonFile, readJsonFile } from '../../utils/json.ts'; +import { displayRelative } from '../../utils/path.ts'; +import { hasBaseUrlInTsconfig } from '../../utils/tsconfig.ts'; +import { detectConfigs, type ConfigFiles } from '../detector.ts'; +import { + collectInstalledPackageNames, + readRulesYaml, + sanitizeMigratedOxlintConfig, +} from '../migrator.ts'; +import { type MigrationReport } from '../report.ts'; +import { + LINT_STAGED_JSON_CONFIG_FILES, + LINT_STAGED_OTHER_CONFIG_FILES, + infoMigration, + warnMigration, +} from './shared.ts'; + +// Remove the "lint-staged" key from package.json after config has been +// successfully merged into vite.config.ts. +export function removeLintStagedFromPackageJson(packageJsonPath: string): void { + editJsonFile<{ 'lint-staged'?: Record }>(packageJsonPath, (pkg) => { + if (pkg['lint-staged']) { + delete pkg['lint-staged']; + return pkg; + } + return undefined; + }); +} + +// Migrate standalone lint-staged config files into staged in vite.config.ts. +// JSON-parseable files are inlined automatically; non-JSON files get a warning. +export function rewriteLintStagedConfigFile(projectPath: string, report?: MigrationReport): void { + let hasUnsupported = false; + + for (const filename of LINT_STAGED_JSON_CONFIG_FILES) { + const configPath = path.join(projectPath, filename); + if (!fs.existsSync(configPath)) { + continue; + } + if (filename === '.lintstagedrc' && !isJsonFile(configPath)) { + warnMigration( + `${displayRelative(configPath)} is not JSON format — please migrate to "staged" in vite.config.ts manually`, + report, + ); + hasUnsupported = true; + continue; + } + // Merge the JSON config into vite.config.ts as "staged" and delete the file. + // Skip if staged already exists in vite.config.ts (already migrated by rewritePackageJson). + if (!hasStagedConfigInViteConfig(projectPath)) { + const config = readJsonFile(configPath); + const updated = rewriteScripts(JSON.stringify(config), readRulesYaml()); + const finalConfig = updated ? JSON.parse(updated) : config; + if (!mergeStagedConfigToViteConfig(projectPath, finalConfig, true, report)) { + // Merge failed — preserve the original config file so the user doesn't lose their rules + continue; + } + fs.unlinkSync(configPath); + if (report) { + report.inlinedLintStagedConfigCount++; + } + } else { + warnMigration( + `${displayRelative(configPath)} found but "staged" already exists in vite.config.ts — please merge manually`, + report, + ); + } + } + // Non-JSON standalone files — warn + for (const filename of LINT_STAGED_OTHER_CONFIG_FILES) { + const configPath = path.join(projectPath, filename); + if (!fs.existsSync(configPath)) { + continue; + } + warnMigration( + `${displayRelative(configPath)} — please migrate to "staged" in vite.config.ts manually`, + report, + ); + hasUnsupported = true; + } + if (hasUnsupported) { + infoMigration( + 'Only "staged" in vite.config.ts is supported. See https://viteplus.dev/guide/migrate#lint-staged', + report, + ); + } +} + +/** + * Ensure vite.config.ts exists, create it if not + * @returns The vite config filename + */ +function ensureViteConfig( + projectPath: string, + configs: ConfigFiles, + silent = false, + report?: MigrationReport, +): string { + if (!configs.viteConfig) { + configs.viteConfig = 'vite.config.ts'; + const viteConfigPath = path.join(projectPath, 'vite.config.ts'); + fs.writeFileSync( + viteConfigPath, + `import { defineConfig } from '${VITE_PLUS_NAME}'; + +export default defineConfig({}); +`, + ); + if (report) { + report.createdViteConfigCount++; + } + if (!silent) { + prompts.log.success(`✔ Created vite.config.ts in ${displayRelative(viteConfigPath)}`); + } + } + return configs.viteConfig; +} + +/** + * Merge tsdown.config.* into vite.config.ts + * - For JSON files: merge content directly into `pack` field and delete the JSON file + * - For TS/JS files: import the config file + */ +export function mergeTsdownConfigFile( + projectPath: string, + silent = false, + report?: MigrationReport, +): void { + const configs = detectConfigs(projectPath); + if (!configs.tsdownConfig) { + return; + } + const viteConfig = ensureViteConfig(projectPath, configs, silent, report); + + const fullViteConfigPath = path.join(projectPath, viteConfig); + const fullTsdownConfigPath = path.join(projectPath, configs.tsdownConfig); + + // For JSON files, merge content directly and delete the file + if (configs.tsdownConfig.endsWith('.json')) { + mergeAndRemoveJsonConfig(projectPath, viteConfig, configs.tsdownConfig, 'pack', silent, report); + return; + } + + // For TS/JS files, import the config file + const tsdownRelativePath = `./${configs.tsdownConfig}`; + const result = mergeTsdownConfig(fullViteConfigPath, tsdownRelativePath); + if (result.updated) { + fs.writeFileSync(fullViteConfigPath, result.content); + if (report) { + report.tsdownImportCount++; + } + if (!silent) { + prompts.log.success( + `✔ Added import for ${displayRelative(fullTsdownConfigPath)} in ${displayRelative(fullViteConfigPath)}`, + ); + } + } + // Show documentation link for manual merging since we only added the import + infoMigration( + `Please manually merge ${displayRelative(fullTsdownConfigPath)} into ${displayRelative(fullViteConfigPath)}, see https://viteplus.dev/guide/migrate#tsdown`, + report, + ); +} + +/** + * Merge oxlint and oxfmt config into vite.config.ts + */ +export function mergeViteConfigFiles( + projectPath: string, + silent = false, + report?: MigrationReport, + packages?: WorkspacePackage[], + // For per-sub-package callers: the workspace root that `packages[].path` + // is relative to. When undefined we resolve relative to `projectPath` + // (correct for the top-level standalone/monorepo callers, where + // projectPath IS the workspace root). + workspaceRoot?: string, +): void { + const configs = detectConfigs(projectPath); + if (!configs.oxfmtConfig && !configs.oxlintConfig) { + return; + } + const viteConfig = ensureViteConfig(projectPath, configs, silent, report); + if (configs.oxlintConfig) { + // Inject options.typeAware and options.typeCheck defaults before merging + const fullOxlintPath = path.join(projectPath, configs.oxlintConfig); + const oxlintJson = readJsonFile(fullOxlintPath, true) as OxlintConfig; + if (!oxlintJson.options) { + oxlintJson.options = {}; + } + // Skip typeAware/typeCheck when tsconfig.json has baseUrl (unsupported by tsgolint) + if (!hasBaseUrlInTsconfig(projectPath)) { + if (oxlintJson.options.typeAware === undefined) { + oxlintJson.options.typeAware = true; + } + if (oxlintJson.options.typeCheck === undefined) { + oxlintJson.options.typeCheck = true; + } + } else { + warnMigration(BASEURL_TSCONFIG_WARNING, report); + } + // Drop references to plugins / jsPlugins / rules that won't resolve + // at lint time (e.g. `@oxlint/migrate` translating `@unocss/eslint-config` + // → `eslint-plugin-unocss` even when that package isn't installed). + // Resolve workspace package paths against `workspaceRoot` when the + // caller is processing a sub-package — otherwise the sanitizer would + // mistakenly look for `subPath/` and miss the + // hoisted deps it's supposed to see. + sanitizeMigratedOxlintConfig( + oxlintJson, + collectInstalledPackageNames(workspaceRoot ?? projectPath, packages), + report, + ); + const normalizedOxlintConfig = ensureVitePlusImportRuleDefaults(oxlintJson); + fs.writeFileSync(fullOxlintPath, JSON.stringify(normalizedOxlintConfig, null, 2)); + // merge oxlint config into vite.config.ts + mergeAndRemoveJsonConfig(projectPath, viteConfig, configs.oxlintConfig, 'lint', silent, report); + } + if (configs.oxfmtConfig) { + // merge oxfmt config into vite.config.ts + mergeAndRemoveJsonConfig(projectPath, viteConfig, configs.oxfmtConfig, 'fmt', silent, report); + } +} + +/** + * Inject typeAware and typeCheck defaults into vite.config.ts lint config. + * Called after mergeViteConfigFiles() to handle the case where no .oxlintrc.json exists + * (e.g., newly created projects from create-vite templates). + */ +export function injectLintTypeCheckDefaults( + projectPath: string, + silent = false, + report?: MigrationReport, +): void { + if (hasBaseUrlInTsconfig(projectPath)) { + warnMigration(BASEURL_TSCONFIG_WARNING, report); + return; + } + injectConfigDefaults( + projectPath, + 'lint', + '.vite-plus-lint-init.oxlintrc.json', + JSON.stringify( + createDefaultVitePlusLintConfig({ + includeTypeAwareDefaults: true, + }), + ), + silent, + report, + ); +} + +export function injectFmtDefaults( + projectPath: string, + silent = false, + report?: MigrationReport, +): void { + injectConfigDefaults( + projectPath, + 'fmt', + '.vite-plus-fmt-init.oxfmtrc.json', + JSON.stringify({}), + silent, + report, + ); +} + +/** + * Wire `create.defaultTemplate: ''` into the new monorepo's + * `vite.config.ts`. The caller is `bin.ts`, only when scaffolding a + * monorepo from a bundled `@org` manifest entry — that's the case where + * the user just picked a template from a specific org and naturally + * wants subsequent `vp create` invocations from the workspace to default + * to that same org's picker. + */ +export function injectCreateDefaultTemplate( + projectPath: string, + scope: string, + silent = false, + report?: MigrationReport, +): void { + if (!scope) { + return; + } + injectConfigDefaults( + projectPath, + 'create', + '.vite-plus-create-init.json', + JSON.stringify({ defaultTemplate: scope }), + silent, + report, + ); +} + +function injectConfigDefaults( + projectPath: string, + configKey: string, + tempFileName: string, + tempFileContent: string, + silent: boolean, + report?: MigrationReport, +): void { + const configs = detectConfigs(projectPath); + if (configs.viteConfig && hasConfigKey(path.join(projectPath, configs.viteConfig), configKey)) { + return; + } + + const viteConfig = ensureViteConfig(projectPath, configs, silent, report); + const tempConfigPath = path.join(projectPath, tempFileName); + fs.writeFileSync(tempConfigPath, tempFileContent); + const fullViteConfigPath = path.join(projectPath, viteConfig); + let result; + try { + result = mergeJsonConfig(fullViteConfigPath, tempConfigPath, configKey); + } finally { + fs.rmSync(tempConfigPath, { force: true }); + } + if (result.updated) { + fs.writeFileSync(fullViteConfigPath, result.content); + } +} + +function mergeAndRemoveJsonConfig( + projectPath: string, + viteConfigPath: string, + jsonConfigPath: string, + configKey: string, + silent = false, + report?: MigrationReport, +): void { + const fullViteConfigPath = path.join(projectPath, viteConfigPath); + const fullJsonConfigPath = path.join(projectPath, jsonConfigPath); + // Skip merge when the key is already present in vite.config.ts — the Rust + // merge step always prepends, so without this guard a template that ships + // both an inline `${configKey}:` block and a standalone JSON file (e.g. + // create-fate's vite.config.ts + .oxfmtrc.jsonc) ends up with two of them. + // AST-based check ignores comments, string-literal occurrences, and nested + // keys (e.g. `plugins: [{ fmt: ... }]`). + if (hasConfigKey(fullViteConfigPath, configKey)) { + fs.unlinkSync(fullJsonConfigPath); + if (!silent) { + prompts.log.info( + `${configKey} config already present in ${displayRelative(fullViteConfigPath)} — removed redundant ${displayRelative(fullJsonConfigPath)}`, + ); + } + return; + } + const result = mergeJsonConfig(fullViteConfigPath, fullJsonConfigPath, configKey); + if (result.updated) { + fs.writeFileSync(fullViteConfigPath, result.content); + fs.unlinkSync(fullJsonConfigPath); + if (report) { + report.mergedConfigCount++; + } + if (!silent) { + prompts.log.success( + `✔ Merged ${displayRelative(fullJsonConfigPath)} into ${displayRelative(fullViteConfigPath)}`, + ); + } + } else { + warnMigration( + `Failed to merge ${displayRelative(fullJsonConfigPath)} into ${displayRelative(fullViteConfigPath)}`, + report, + ); + infoMigration( + 'Please complete the merge manually and follow the instructions in the documentation: https://viteplus.dev/config/', + report, + ); + } +} + +/** + * Merge a staged config object into vite.config.ts as `staged: { ... }`. + * Writes the config to a temp JSON file, calls mergeJsonConfig NAPI, then cleans up. + */ +export function mergeStagedConfigToViteConfig( + projectPath: string, + stagedConfig: Record, + silent = false, + report?: MigrationReport, +): boolean { + const configs = detectConfigs(projectPath); + const viteConfig = ensureViteConfig(projectPath, configs, silent, report); + const fullViteConfigPath = path.join(projectPath, viteConfig); + + // Write staged config to a temp JSON file for mergeJsonConfig NAPI + const tempJsonPath = path.join(projectPath, '.staged-config-temp.json'); + fs.writeFileSync(tempJsonPath, JSON.stringify(stagedConfig, null, 2)); + + let result; + try { + result = mergeJsonConfig(fullViteConfigPath, tempJsonPath, 'staged'); + } finally { + fs.unlinkSync(tempJsonPath); + } + + if (result.updated) { + fs.writeFileSync(fullViteConfigPath, result.content); + if (report) { + report.mergedStagedConfigCount++; + } + if (!silent) { + prompts.log.success(`✔ Merged staged config into ${displayRelative(fullViteConfigPath)}`); + } + return true; + } else { + warnMigration( + `Failed to merge staged config into ${displayRelative(fullViteConfigPath)}`, + report, + ); + infoMigration( + `Please add staged config to ${displayRelative(fullViteConfigPath)} manually, see https://viteplus.dev/guide/migrate#lint-staged`, + report, + ); + return false; + } +} + +/** + * Check if vite.config.ts already has a `staged` config key. + */ +export function hasStagedConfigInViteConfig(projectPath: string): boolean { + const configs = detectConfigs(projectPath); + if (!configs.viteConfig) { + return false; + } + const viteConfigPath = path.join(projectPath, configs.viteConfig); + const content = fs.readFileSync(viteConfigPath, 'utf8'); + return /\bstaged\s*:/.test(content); +} + +/** + * Wrap safe inline Vite plugin arrays with lazyPlugins so check/lint/fmt do not + * eagerly execute plugin factories while loading vite.config.ts. + */ +export function wrapLazyPluginsInViteConfig( + projectPath: string, + silent = false, + report?: MigrationReport, +): void { + const configs = detectConfigs(projectPath); + if (!configs.viteConfig) { + return; + } + + const viteConfigPath = path.join(projectPath, configs.viteConfig); + const result = wrapLazyPlugins(viteConfigPath); + if (!result.updated) { + return; + } + + fs.writeFileSync(viteConfigPath, result.content); + if (report) { + report.wrappedPluginConfigCount++; + } + if (!silent) { + prompts.log.success( + `✔ Wrapped inline Vite plugins with lazyPlugins in ${displayRelative(viteConfigPath)}`, + ); + } +} + +/** + * Rewrite imports in all TypeScript/JavaScript files under a directory + * This rewrites vite/vitest imports to @voidzero-dev/vite-plus + * @param projectPath - The root directory to search for files + */ +export function rewriteAllImports( + projectPath: string, + silent = false, + report?: MigrationReport, + preserveNuxtVitestImports = true, +): boolean { + const result = rewriteImportsInDirectory(projectPath, preserveNuxtVitestImports); + const modified = result.modifiedFiles.length; + const preserved = result.preservedVitestFiles.length; + const errors = result.errors.length; + + if (report) { + report.rewrittenImportFileCount += modified; + report.preservedNuxtVitestImportFileCount += preserved; + report.rewrittenImportErrors.push( + ...result.errors.map((error) => ({ + path: displayRelative(error.path), + message: error.message, + })), + ); + } + + if (!silent && modified > 0) { + prompts.log.success(`Rewrote imports in ${modified === 1 ? 'one file' : `${modified} files`}`); + prompts.log.info(result.modifiedFiles.map((file) => ` ${displayRelative(file)}`).join('\n')); + } + + if (errors > 0) { + if (report) { + warnMigration( + `${errors === 1 ? 'one file had an error' : `${errors} files had errors`} while rewriting imports`, + report, + ); + } else { + prompts.log.warn( + `⚠ ${errors === 1 ? 'one file had an error' : `${errors} files had errors`}:`, + ); + for (const error of result.errors) { + prompts.log.error(` ${displayRelative(error.path)}: ${error.message}`); + } + } + } + return modified > 0; +} diff --git a/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts b/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts new file mode 100644 index 0000000000..ce3a997801 --- /dev/null +++ b/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts @@ -0,0 +1,951 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { PackageManager, type WorkspaceInfo, type WorkspacePackage } from '../../types/index.ts'; +import { + VITEST_VERSION, + VITE_PLUS_NAME, + VITE_PLUS_OVERRIDE_PACKAGES, + VITE_PLUS_VERSION, + isForceOverrideMode, +} from '../../utils/constants.ts'; +import { editJsonFile, readJsonFile } from '../../utils/json.ts'; +import { type NpmWorkspaces } from '../../utils/workspace.ts'; +import { editYamlFile, readYamlFile, type YamlDocument } from '../../utils/yaml.ts'; +import { + alignVitestEcosystemPackages, + applyBuildAllowanceToPackageJsonPnpm, + collectProviderSourceModes, + collectVitestEcosystemInstallDependencyNames, + createCatalogDependencyResolver, + ensurePnpmWorkspacePackages, + getAlignedVitestEcosystemDependencySpec, + getCatalogDependencySpec, + isLegacyWrapperSpec, + isProtocolPinnedSpec, + managedOverridePackages, + migratePnpmSettingsToWorkspaceYaml, + normalizeVitestPeerCatalogSpec, + pnpmPackageJsonSettingsPending, + pnpmSupportsWorkspaceSettings, + projectUsesVitestDirectly, + pruneLegacyWrapperAliases, + readBunCatalogDependencyResolver, + readPnpmWorkspaceCatalogDependencyResolver, + readPnpmWorkspaceOverrides, + readPnpmWorkspacePeerDependencyRules, + removeManagedVitestEntry, + rewriteBunCatalog, + rewritePnpmWorkspaceYaml, + rewriteYarnrcYml, + setPackageManager, + takePnpmWorkspaceSettings, + vitestEcosystemCatalogReferencesPending, + workspaceUsesVitestDirectly, + workspaceUsesWebdriverio, + yarnrcSatisfiesVitePlus, +} from '../migrator.ts'; +import { type MigrationReport } from '../report.ts'; +import { + BROWSER_PROVIDER_PEER_DEPS, + OPT_IN_BROWSER_PROVIDERS, + REMOVE_PACKAGES, + VITEST_IS_MANAGED_OVERRIDE, + pnpmMajor, + type CatalogDependencyResolver, + type PnpmPackageJsonSettings, +} from './shared.ts'; + +export type BootstrapPackageJson = { + overrides?: Record; + resolutions?: Record; + devDependencies?: Record; + dependencies?: Record; + peerDependencies?: Record; + optionalDependencies?: Record; + pnpm?: PnpmPackageJsonSettings; + packageManager?: string; + devEngines?: { packageManager?: unknown; [key: string]: unknown }; +}; + +export type VitePlusBootstrapResult = { + changed: boolean; + packageJson: boolean; + packageManagerConfig: boolean; + packageManagerField: boolean; +}; + +function isSemanticVitePlusOverrideSpec(dependencyName: string, spec: string | undefined): boolean { + if (!spec) { + return false; + } + // A spec still pointing at the deleted `@voidzero-dev/vite-plus-test` wrapper + // is stale, NOT satisfied: this release ships upstream vitest directly, so the + // wrapper must be rewritten/pruned to the bundled vitest rather than accepted + // (otherwise `detectVitePlusBootstrapPending` skips writing the new + // `vitest: VITEST_VERSION` and the override keeps installing the dead wrapper). + if (isLegacyWrapperSpec(spec)) { + return false; + } + if (spec === VITE_PLUS_OVERRIDE_PACKAGES[dependencyName]) { + return true; + } + return false; +} + +function overrideSpecSatisfiesVitePlus( + dependencyName: string, + spec: string | undefined, + catalogDependencyResolver?: CatalogDependencyResolver, +): boolean { + if (!spec) { + return false; + } + if (isSemanticVitePlusOverrideSpec(dependencyName, spec)) { + return true; + } + if (!spec.startsWith('catalog:')) { + return false; + } + return isSemanticVitePlusOverrideSpec( + dependencyName, + catalogDependencyResolver?.(spec, dependencyName), + ); +} + +export function overridesSatisfyVitePlus( + overrides: Record | undefined, + usesVitest: boolean, + catalogDependencyResolver?: CatalogDependencyResolver, +): boolean { + // Common case: a lingering managed `vitest` override is NOT satisfied — it + // must be removed, so the bootstrap stays pending until it is. + if (!usesVitest && VITEST_IS_MANAGED_OVERRIDE && typeof overrides?.vitest === 'string') { + return false; + } + return Object.keys(managedOverridePackages(usesVitest)).every((dependencyName) => + overrideSpecSatisfiesVitePlus( + dependencyName, + overrides?.[dependencyName], + catalogDependencyResolver, + ), + ); +} + +function hasPackageManagerPin(pkg: BootstrapPackageJson): boolean { + return Boolean(pkg.packageManager || pkg.devEngines?.packageManager); +} + +function pinnedPackageManagerVersion(pkg: BootstrapPackageJson): string | undefined { + if (typeof pkg.packageManager === 'string') { + const separator = pkg.packageManager.indexOf('@'); + if (separator !== -1) { + return pkg.packageManager.slice(separator + 1); + } + } + const devEngine = pkg.devEngines?.packageManager; + if ( + typeof devEngine === 'object' && + devEngine !== null && + !Array.isArray(devEngine) && + 'version' in devEngine && + typeof devEngine.version === 'string' + ) { + return devEngine.version; + } + return undefined; +} + +function vitePlusDependencyNeedsConcreteVersion(pkg: BootstrapPackageJson): boolean { + const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; + return dependencyGroups.some( + (dependencies) => dependencies?.[VITE_PLUS_NAME]?.startsWith('catalog:') ?? false, + ); +} + +function catalogVitePlusDependencyPending( + pkg: BootstrapPackageJson, + catalogDependencyResolver: CatalogDependencyResolver | undefined, +): boolean { + const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; + return dependencyGroups.some((dependencies) => { + const spec = dependencies?.[VITE_PLUS_NAME]; + if (!spec?.startsWith('catalog:')) { + return false; + } + return catalogDependencyResolver?.(spec, VITE_PLUS_NAME) !== VITE_PLUS_VERSION; + }); +} + +function pnpmPeerDependencyRulesSatisfyVitePlus( + peerDependencyRules: + | { allowAny?: string[]; allowedVersions?: Record } + | undefined, + usesVitest: boolean, +): boolean { + const allowAny = new Set(peerDependencyRules?.allowAny ?? []); + const allowedVersions = peerDependencyRules?.allowedVersions ?? {}; + // Common case: a lingering managed `vitest` peer rule is NOT satisfied. + if ( + !usesVitest && + VITEST_IS_MANAGED_OVERRIDE && + (allowAny.has('vitest') || allowedVersions.vitest !== undefined) + ) { + return false; + } + const overrideKeys = Object.keys(managedOverridePackages(usesVitest)); + return overrideKeys.every((key) => allowAny.has(key) && allowedVersions[key] === '*'); +} + +function npmVitePlusManagedDependenciesPending( + pkg: BootstrapPackageJson, + usesVitest: boolean, +): boolean { + const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; + // Common case: a lingering managed `vitest` install dep is pending removal. + if ( + !usesVitest && + VITEST_IS_MANAGED_OVERRIDE && + dependencyGroups.some((dependencies) => dependencies?.vitest !== undefined) + ) { + return true; + } + return Object.keys(managedOverridePackages(usesVitest)).some((dependencyName) => + dependencyGroups.some( + (dependencies) => + dependencies?.[dependencyName] !== undefined && + !overrideSpecSatisfiesVitePlus(dependencyName, dependencies[dependencyName]), + ), + ); +} + +function forceOverrideUsesExoticPnpmSpec(): boolean { + if (!isForceOverrideMode()) { + return false; + } + return [VITE_PLUS_VERSION, ...Object.values(VITE_PLUS_OVERRIDE_PACKAGES)].some((spec) => + /^(?:file|https?):/.test(spec), + ); +} + +function pnpmWorkspaceExoticSubdepsSettingSatisfied(projectPath: string): boolean { + if (!forceOverrideUsesExoticPnpmSpec()) { + return true; + } + const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); + if (!fs.existsSync(pnpmWorkspaceYamlPath)) { + return false; + } + const doc = readYamlFile(pnpmWorkspaceYamlPath) as { blockExoticSubdeps?: boolean } | null; + return doc?.blockExoticSubdeps === false; +} + +export function ensurePnpmExoticSubdepsSetting(doc: YamlDocument): boolean { + if (!forceOverrideUsesExoticPnpmSpec() || doc.get('blockExoticSubdeps') === false) { + return false; + } + doc.set('blockExoticSubdeps', false); + return true; +} + +export function ensurePnpmWorkspaceExoticSubdepsSetting(projectPath: string): boolean { + if (!forceOverrideUsesExoticPnpmSpec()) { + return false; + } + const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); + if (!fs.existsSync(pnpmWorkspaceYamlPath)) { + fs.writeFileSync(pnpmWorkspaceYamlPath, ''); + } + let changed = false; + editYamlFile(pnpmWorkspaceYamlPath, (doc) => { + changed = ensurePnpmExoticSubdepsSetting(doc); + }); + return changed; +} + +/** + * Reconcile the install dependencies in one package during an existing-Vite+ + * bootstrap. Package-manager overrides are intentionally handled separately at + * the workspace root; this function owns only dependency fields so it can also + * be applied to every workspace package. + */ +function reconcileVitePlusBootstrapPackage( + projectPath: string, + pkg: BootstrapPackageJson, + vitePlusVersion: string, + packageManager: PackageManager, + supportCatalog: boolean, + ensureVitePlus: boolean, + catalogDependencyResolver?: CatalogDependencyResolver, +): boolean { + const before = JSON.stringify(pkg); + const usesVitest = projectUsesVitestDirectly(projectPath, pkg, undefined, true); + ensureVitePlusDependencySpecs(pkg, vitePlusVersion, ensureVitePlus); + + const installGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; + const dependencyGroups = [...installGroups, pkg.peerDependencies]; + + // Remove every dependency alias to the deleted wrapper before deciding + // whether this package needs a direct upstream vitest peer provider. + for (const dependencies of dependencyGroups) { + pruneLegacyWrapperAliases(dependencies); + } + + // Normalize direct Vite install entries as well as the shared override. Keep + // named catalog references intact; plain/behind aliases move to the active + // default catalog or the current core alias. + for (const dependencies of installGroups) { + if (dependencies?.vite !== undefined) { + dependencies.vite = getCatalogDependencySpec( + dependencies.vite, + VITE_PLUS_OVERRIDE_PACKAGES.vite, + supportCatalog, + { preferredCatalogSpec: catalogDependencyResolver?.preferredCatalogSpec }, + ); + } + } + + alignVitestEcosystemPackages(pkg, packageManager, supportCatalog, catalogDependencyResolver); + normalizeVitestPeerCatalogSpec(pkg.peerDependencies, catalogDependencyResolver); + + const providerSourceModes = collectProviderSourceModes(projectPath); + let usesAnyOptInProvider = false; + for (const provider of OPT_IN_BROWSER_PROVIDERS) { + const usesProvider = + providerSourceModes[provider] || + dependencyGroups.some((dependencies) => dependencies?.[provider] !== undefined); + if (!usesProvider) { + continue; + } + usesAnyOptInProvider = true; + const installGroupEntry = [ + { dependencyField: 'devDependencies' as const, dependencies: pkg.devDependencies }, + { dependencyField: 'dependencies' as const, dependencies: pkg.dependencies }, + { + dependencyField: 'optionalDependencies' as const, + dependencies: pkg.optionalDependencies, + }, + ].find(({ dependencies }) => dependencies?.[provider] !== undefined); + if (installGroupEntry?.dependencies) { + if (VITEST_IS_MANAGED_OVERRIDE) { + installGroupEntry.dependencies[provider] = getAlignedVitestEcosystemDependencySpec( + installGroupEntry.dependencies[provider], + provider, + installGroupEntry.dependencyField, + packageManager, + supportCatalog, + catalogDependencyResolver, + ); + } + } else { + pkg.devDependencies ??= {}; + pkg.devDependencies[provider] = getCatalogDependencySpec( + undefined, + VITEST_VERSION, + supportCatalog && packageManager !== PackageManager.bun, + { preferredCatalogSpec: catalogDependencyResolver?.preferredCatalogSpec }, + ); + } + const frameworkPeer = BROWSER_PROVIDER_PEER_DEPS[provider]; + const frameworkPresent = dependencyGroups.some( + (dependencies) => dependencies?.[frameworkPeer] !== undefined, + ); + if (frameworkPeer && !frameworkPresent) { + pkg.devDependencies ??= {}; + pkg.devDependencies[frameworkPeer] = '*'; + } + } + + // The base browser runtime and preview provider are bundled by vite-plus; + // only the heavy framework-specific providers remain project-owned. + for (const bundledPackage of REMOVE_PACKAGES.filter((name) => name.startsWith('@vitest/'))) { + for (const dependencies of installGroups) { + if (dependencies?.[bundledPackage] !== undefined) { + delete dependencies[bundledPackage]; + } + } + } + + if (usesAnyOptInProvider && packageManager === PackageManager.npm) { + const viteAlreadyDirect = installGroups.some( + (dependencies) => dependencies?.vite !== undefined, + ); + if (!viteAlreadyDirect) { + pkg.devDependencies ??= {}; + pkg.devDependencies.vite = VITE_PLUS_OVERRIDE_PACKAGES.vite; + } + } + + if (packageManager === PackageManager.bun) { + // Bun resolves vitest's `vite ^6 || ^7 || ^8` peer before applying the + // override that redirects `vite` to vite-plus-core, and aborts with + // "vite@... failed to resolve" unless `vite` is a direct dependency. Mirror + // the full-migration path (rewriteStandaloneProject) so the idempotent + // bootstrap path also produces an installable bun project. The override set + // above still points the direct dep at vite-plus-core. + const viteAlreadyDirect = installGroups.some( + (dependencies) => dependencies?.vite !== undefined, + ); + if (!viteAlreadyDirect) { + pkg.devDependencies ??= {}; + pkg.devDependencies.vite = VITE_PLUS_OVERRIDE_PACKAGES.vite; + } + } + + if (usesVitest) { + // A direct @vitest/*/integration dependency with a required vitest peer + // cannot use the copy nested under its sibling `vite-plus` dependency under + // Yarn PnP or strict pnpm. Provide the peer from this package and keep it on + // the same exact version as the Vite+ runner. + const existingGroup = installGroups.find((dependencies) => dependencies?.vitest !== undefined); + if (existingGroup) { + if (VITEST_IS_MANAGED_OVERRIDE) { + existingGroup.vitest = getCatalogDependencySpec( + existingGroup.vitest, + VITEST_VERSION, + supportCatalog, + { preferredCatalogSpec: catalogDependencyResolver?.preferredCatalogSpec }, + ); + } + } else { + pkg.devDependencies ??= {}; + pkg.devDependencies.vitest = getCatalogDependencySpec( + undefined, + VITEST_VERSION, + supportCatalog, + { preferredCatalogSpec: catalogDependencyResolver?.preferredCatalogSpec }, + ); + } + } else { + // Bare vitest is not itself a usage signal: older migrations injected it + // into every project. Remove that stale install pin when no remaining peer, + // source import, or browser-mode signal needs it. + for (const dependencies of installGroups) { + removeManagedVitestEntry(dependencies); + } + } + + return before !== JSON.stringify(pkg); +} + +export function bootstrapProjectPaths( + rootDir: string, + packages: WorkspacePackage[] | undefined, +): string[] { + return [rootDir, ...(packages ?? []).map((pkg) => path.join(rootDir, pkg.path))]; +} + +export function collectInjectedProviderNames( + rootDir: string, + packages?: WorkspacePackage[], + // Optional precomputed provider source-scan results keyed by absolute package + // path. Lets a caller that already scanned a path reuse the result instead of + // re-traversing the source tree; unknown paths fall back to a fresh scan. + precomputedSourceModes?: ReadonlyMap>, +): Set { + const names = new Set(); + for (const packagePath of bootstrapProjectPaths(rootDir, packages)) { + const packageJsonPath = path.join(packagePath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + continue; + } + const pkg = readJsonFile(packageJsonPath) as BootstrapPackageJson; + const sourceModes = + precomputedSourceModes?.get(packagePath) ?? collectProviderSourceModes(packagePath); + const installGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; + const dependencyGroups = [...installGroups, pkg.peerDependencies]; + for (const provider of OPT_IN_BROWSER_PROVIDERS) { + const used = + sourceModes[provider] || + dependencyGroups.some((dependencies) => dependencies?.[provider] !== undefined); + const installed = installGroups.some( + (dependencies) => dependencies?.[provider] !== undefined, + ); + if (used && !installed) { + names.add(provider); + } + } + } + return names; +} + +function workspaceVitestEcosystemCatalogReferencesPending( + rootDir: string, + packages: WorkspacePackage[] | undefined, + catalogDependencyResolver?: CatalogDependencyResolver, +): boolean { + return bootstrapProjectPaths(rootDir, packages).some((packagePath) => { + const packageJsonPath = path.join(packagePath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return false; + } + return vitestEcosystemCatalogReferencesPending( + readJsonFile(packageJsonPath) as BootstrapPackageJson, + catalogDependencyResolver, + ); + }); +} + +export function detectVitePlusBootstrapPending( + projectPath: string, + packageManager: PackageManager | undefined, + packages?: WorkspacePackage[], + packageManagerVersion?: string, +): boolean { + const packageJsonPath = path.join(projectPath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return false; + } + const pkg = readJsonFile(packageJsonPath) as BootstrapPackageJson & { + workspaces?: NpmWorkspaces; + catalog?: Record; + catalogs?: Record>; + }; + + // vite-plus counts as installed when it's a direct dependency/devDependency, + // so a project that declares it in `dependencies` isn't reported as pending a + // (duplicate) devDependencies entry. + if (!hasDirectVitePlusInstallEntry(pkg) || !hasPackageManagerPin(pkg)) { + return true; + } + + if (packageManager === undefined) { + return true; + } + + const pnpmVersion = packageManagerVersion ?? pinnedPackageManagerVersion(pkg) ?? ''; + const usePnpmWorkspaceYaml = + packageManager === PackageManager.pnpm && pnpmSupportsWorkspaceSettings(pnpmVersion); + if (usePnpmWorkspaceYaml && pnpmPackageJsonSettingsPending(pkg)) { + return true; + } + const supportCatalog = + !VITE_PLUS_VERSION.startsWith('file:') && + (usePnpmWorkspaceYaml || + packageManager === PackageManager.yarn || + packageManager === PackageManager.bun); + const catalogDependencyResolver = createCatalogDependencyResolver(projectPath, packageManager); + const canonicalVitePlusSpec = supportCatalog + ? (catalogDependencyResolver?.preferredCatalogSpec ?? 'catalog:') + : VITE_PLUS_VERSION; + if ( + workspaceVitestEcosystemCatalogReferencesPending( + projectPath, + packages, + catalogDependencyResolver, + ) + ) { + return true; + } + for (const [index, packagePath] of bootstrapProjectPaths(projectPath, packages).entries()) { + const childPackageJsonPath = path.join(packagePath, 'package.json'); + if (!fs.existsSync(childPackageJsonPath)) { + continue; + } + const childPkg = readJsonFile(childPackageJsonPath) as BootstrapPackageJson; + const candidate = JSON.parse(JSON.stringify(childPkg)) as BootstrapPackageJson; + if ( + reconcileVitePlusBootstrapPackage( + packagePath, + candidate, + canonicalVitePlusSpec, + packageManager, + supportCatalog, + index === 0, + catalogDependencyResolver, + ) + ) { + return true; + } + } + + // Shared override/catalog sinks must keep vitest managed when any package in + // the workspace needs it. The direct dependency itself is localized above. + const usesVitest = workspaceUsesVitestDirectly(projectPath, packages, true); + + if (packageManager === PackageManager.yarn) { + return ( + !overridesSatisfyVitePlus(pkg.resolutions, usesVitest) || + !yarnrcSatisfiesVitePlus(projectPath, usesVitest) + ); + } + if (packageManager === PackageManager.npm) { + return ( + vitePlusDependencyNeedsConcreteVersion(pkg) || + !overridesSatisfyVitePlus(pkg.overrides, usesVitest) || + npmVitePlusManagedDependenciesPending(pkg, usesVitest) + ); + } + if (packageManager === PackageManager.bun) { + return !overridesSatisfyVitePlus( + pkg.overrides, + usesVitest, + readBunCatalogDependencyResolver(pkg), + ); + } + if (packageManager === PackageManager.pnpm) { + if (!pnpmWorkspaceExoticSubdepsSettingSatisfied(projectPath)) { + return true; + } + if (!usePnpmWorkspaceYaml) { + return ( + vitePlusDependencyNeedsConcreteVersion(pkg) || + !overridesSatisfyVitePlus(pkg.pnpm?.overrides, usesVitest) || + !pnpmPeerDependencyRulesSatisfyVitePlus(pkg.pnpm?.peerDependencyRules, usesVitest) + ); + } + const resolver = readPnpmWorkspaceCatalogDependencyResolver(projectPath); + return ( + catalogVitePlusDependencyPending(pkg, resolver) || + !overridesSatisfyVitePlus(readPnpmWorkspaceOverrides(projectPath), usesVitest, resolver) || + !pnpmPeerDependencyRulesSatisfyVitePlus( + readPnpmWorkspacePeerDependencyRules(projectPath), + usesVitest, + ) + ); + } + + return false; +} + +// vite-plus counts as already installed when it lives directly in +// `dependencies` OR `devDependencies`. `optionalDependencies` is deliberately +// excluded: an optional-only entry may be skipped at install time, so the +// package should still receive a guaranteed `devDependencies` entry. +export function hasDirectVitePlusInstallEntry(pkg: { + dependencies?: Record; + devDependencies?: Record; +}): boolean { + return ( + pkg.dependencies?.[VITE_PLUS_NAME] !== undefined || + pkg.devDependencies?.[VITE_PLUS_NAME] !== undefined + ); +} + +function ensureVitePlusDependencySpecs( + pkg: BootstrapPackageJson, + version: string, + ensurePresent = true, +): boolean { + let changed = false; + // Re-pin a pre-existing vite-plus spec to the migrating toolchain target so + // the lockfile moves off an old resolution (e.g. `^0.1.24`). Mirrors the + // full-migration rule at `shouldNormalizeExistingVitePlus`/`canonicalVitePlusSpec`: + // only vanilla version ranges are rewritten; deliberate protocol pins + // (workspace:, link:, file:, npm:, github:, git, http) are preserved. + const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; + for (const dependencies of dependencyGroups) { + if (dependencies === undefined) { + continue; + } + const spec = dependencies[VITE_PLUS_NAME]; + if (spec === undefined || spec === version) { + continue; + } + // Catalog writers update every existing managed entry in place. Keep a + // package's deliberate named/default reference instead of collapsing all + // packages onto the workspace's preferred catalog, including pkg.pr.new + // force-override runs. + if (version.startsWith('catalog:') && spec.startsWith('catalog:')) { + continue; + } + // Concrete target (e.g. `latest`): also rewrite an existing `catalog:` + // pin onto the concrete version — `isProtocolPinnedSpec` matches + // `catalog:`, so handle it explicitly before the generic plain-range case. + if (!version.startsWith('catalog:') && spec.startsWith('catalog:')) { + dependencies[VITE_PLUS_NAME] = version; + changed = true; + continue; + } + // Plain (non-protocol-pinned) range like `^0.1.24` → rewrite to the target + // (`catalog:` for catalog-supporting projects, otherwise the concrete + // version). Already-`catalog:` / other protocol pins are left untouched, + // except in force-override mode where ecosystem/pkg.pr.new validation must + // replace every prior target with the requested artifact. + if (isForceOverrideMode() || !isProtocolPinnedSpec(spec)) { + dependencies[VITE_PLUS_NAME] = version; + changed = true; + } + } + if (hasDirectVitePlusInstallEntry(pkg) || !ensurePresent) { + return changed; + } + pkg.devDependencies = { + ...pkg.devDependencies, + [VITE_PLUS_NAME]: version, + }; + return true; +} + +function ensureOverrideEntries( + overrides: Record | undefined, + usesVitest: boolean, + catalogDependencyResolver?: CatalogDependencyResolver, +): { overrides: Record; changed: boolean } { + const next = { ...overrides }; + let changed = false; + // Common case: drop a lingering managed `vitest` override. + if (!usesVitest && removeManagedVitestEntry(next)) { + changed = true; + } + for (const [dependencyName, overrideSpec] of Object.entries( + managedOverridePackages(usesVitest), + )) { + if ( + !overrideSpecSatisfiesVitePlus( + dependencyName, + next[dependencyName], + catalogDependencyResolver, + ) + ) { + next[dependencyName] = overrideSpec; + changed = true; + } + } + return { overrides: next, changed }; +} + +function ensurePnpmPeerDependencyRules(pkg: BootstrapPackageJson, usesVitest: boolean): boolean { + const overrideKeys = Object.keys(managedOverridePackages(usesVitest)); + pkg.pnpm ??= {}; + // Common case: drop a lingering managed `vitest` peer rule from the source + // shape before re-deriving the managed rules. + const seed = { ...pkg.pnpm.peerDependencyRules } as { + allowAny?: string[]; + allowedVersions?: Record; + }; + if (!usesVitest && VITEST_IS_MANAGED_OVERRIDE) { + if (Array.isArray(seed.allowAny)) { + seed.allowAny = seed.allowAny.filter((key) => key !== 'vitest'); + } + if (seed.allowedVersions) { + seed.allowedVersions = { ...seed.allowedVersions }; + delete seed.allowedVersions.vitest; + } + } + const peerDependencyRules = { + ...seed, + allowAny: [...new Set([...(seed.allowAny ?? []), ...overrideKeys])], + allowedVersions: { + ...seed.allowedVersions, + ...Object.fromEntries(overrideKeys.map((key) => [key, '*'])), + }, + }; + const changed = + JSON.stringify(pkg.pnpm.peerDependencyRules ?? {}) !== JSON.stringify(peerDependencyRules); + pkg.pnpm.peerDependencyRules = peerDependencyRules; + return changed; +} + +export function ensureVitePlusBootstrap( + workspaceInfo: WorkspaceInfo, + report?: MigrationReport, +): VitePlusBootstrapResult { + const projectPath = workspaceInfo.rootDir; + const packageJsonPath = path.join(projectPath, 'package.json'); + const result: VitePlusBootstrapResult = { + changed: false, + packageJson: false, + packageManagerConfig: false, + packageManagerField: false, + }; + if (!fs.existsSync(packageJsonPath)) { + return result; + } + + // Shared override/catalog sinks are workspace-wide, so keep vitest managed + // when any package needs it. Each package's direct vitest dependency is + // reconciled independently below. + const usesVitest = workspaceUsesVitestDirectly(projectPath, workspaceInfo.packages, true); + const pnpmMajorVersion = pnpmMajor(workspaceInfo.downloadPackageManager.version); + const shouldAllowBrowserBuilds = workspaceUsesWebdriverio(projectPath, workspaceInfo.packages); + const usePnpmWorkspaceYaml = + workspaceInfo.packageManager === PackageManager.pnpm && + pnpmSupportsWorkspaceSettings(workspaceInfo.downloadPackageManager.version); + const supportCatalog = + !VITE_PLUS_VERSION.startsWith('file:') && + (usePnpmWorkspaceYaml || + workspaceInfo.packageManager === PackageManager.yarn || + workspaceInfo.packageManager === PackageManager.bun); + const catalogDependencyResolver = createCatalogDependencyResolver( + projectPath, + workspaceInfo.packageManager, + ); + const canonicalVitePlusSpec = supportCatalog + ? (catalogDependencyResolver?.preferredCatalogSpec ?? 'catalog:') + : VITE_PLUS_VERSION; + const ecosystemCatalogReferencesPending = workspaceVitestEcosystemCatalogReferencesPending( + projectPath, + workspaceInfo.packages, + catalogDependencyResolver, + ); + const vitestEcosystemPackages = collectVitestEcosystemInstallDependencyNames( + projectPath, + workspaceInfo.packages, + ); + const providerCatalogAdditions = collectInjectedProviderNames( + projectPath, + workspaceInfo.packages, + ); + let movedPnpmSettings: Record | undefined; + + editJsonFile< + BootstrapPackageJson & { + workspaces?: NpmWorkspaces; + catalog?: Record; + catalogs?: Record>; + } + >(packageJsonPath, (pkg) => { + let packageJsonChanged = reconcileVitePlusBootstrapPackage( + projectPath, + pkg, + canonicalVitePlusSpec, + workspaceInfo.packageManager, + supportCatalog, + true, + catalogDependencyResolver, + ); + + if (workspaceInfo.packageManager === PackageManager.yarn) { + const ensured = ensureOverrideEntries(pkg.resolutions, usesVitest); + if (ensured.changed) { + pkg.resolutions = ensured.overrides; + packageJsonChanged = true; + } + } else if (workspaceInfo.packageManager === PackageManager.npm) { + const ensured = ensureOverrideEntries(pkg.overrides, usesVitest); + if (ensured.changed) { + pkg.overrides = ensured.overrides; + packageJsonChanged = true; + } + } else if (workspaceInfo.packageManager === PackageManager.bun) { + const ensured = ensureOverrideEntries( + pkg.overrides, + usesVitest, + readBunCatalogDependencyResolver(pkg), + ); + if (ensured.changed) { + pkg.overrides = ensured.overrides; + packageJsonChanged = true; + } + } else if (workspaceInfo.packageManager === PackageManager.pnpm && !usePnpmWorkspaceYaml) { + pkg.pnpm ??= {}; + const ensured = ensureOverrideEntries(pkg.pnpm.overrides, usesVitest); + if (ensured.changed) { + pkg.pnpm.overrides = ensured.overrides; + packageJsonChanged = true; + } + packageJsonChanged = ensurePnpmPeerDependencyRules(pkg, usesVitest) || packageJsonChanged; + if (pnpmMajorVersion !== undefined && pkg.pnpm) { + const beforePnpm = JSON.stringify(pkg.pnpm); + applyBuildAllowanceToPackageJsonPnpm(pkg.pnpm, pnpmMajorVersion, shouldAllowBrowserBuilds); + packageJsonChanged = beforePnpm !== JSON.stringify(pkg.pnpm) || packageJsonChanged; + } + } else if (workspaceInfo.packageManager === PackageManager.pnpm) { + const hadPnpmField = pkg.pnpm !== undefined; + movedPnpmSettings = takePnpmWorkspaceSettings(pkg); + packageJsonChanged = + movedPnpmSettings !== undefined || + (hadPnpmField && pkg.pnpm === undefined) || + packageJsonChanged; + } + + result.packageJson = packageJsonChanged; + return pkg; + }); + + // Existing Vite+ monorepos take this bootstrap path instead of the full + // migration, so reconcile every workspace manifest as well as the root. + for (const workspacePackage of workspaceInfo.packages) { + const packagePath = path.join(projectPath, workspacePackage.path); + const childPackageJsonPath = path.join(packagePath, 'package.json'); + if (!fs.existsSync(childPackageJsonPath)) { + continue; + } + let childChanged = false; + editJsonFile(childPackageJsonPath, (pkg) => { + childChanged = reconcileVitePlusBootstrapPackage( + packagePath, + pkg, + canonicalVitePlusSpec, + workspaceInfo.packageManager, + supportCatalog, + false, + catalogDependencyResolver, + ); + return childChanged ? pkg : undefined; + }); + result.packageJson = result.packageJson || childChanged; + } + + if (workspaceInfo.packageManager === PackageManager.pnpm) { + const pkg = readJsonFile(packageJsonPath) as BootstrapPackageJson; + if (usePnpmWorkspaceYaml) { + const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); + const before = fs.existsSync(pnpmWorkspaceYamlPath) + ? fs.readFileSync(pnpmWorkspaceYamlPath, 'utf-8') + : undefined; + migratePnpmSettingsToWorkspaceYaml(projectPath, movedPnpmSettings); + const catalogDependencyResolver = readPnpmWorkspaceCatalogDependencyResolver(projectPath); + if ( + movedPnpmSettings !== undefined || + result.packageJson || + ecosystemCatalogReferencesPending || + !pnpmWorkspaceExoticSubdepsSettingSatisfied(projectPath) || + catalogVitePlusDependencyPending(pkg, catalogDependencyResolver) || + !overridesSatisfyVitePlus( + readPnpmWorkspaceOverrides(projectPath), + usesVitest, + catalogDependencyResolver, + ) || + !pnpmPeerDependencyRulesSatisfyVitePlus( + readPnpmWorkspacePeerDependencyRules(projectPath), + usesVitest, + ) + ) { + rewritePnpmWorkspaceYaml( + projectPath, + pnpmMajorVersion, + shouldAllowBrowserBuilds, + usesVitest, + vitestEcosystemPackages, + true, + providerCatalogAdditions, + ); + } + if (fs.existsSync(pnpmWorkspaceYamlPath)) { + ensurePnpmWorkspacePackages(projectPath, workspaceInfo.workspacePatterns); + } + const after = fs.existsSync(pnpmWorkspaceYamlPath) + ? fs.readFileSync(pnpmWorkspaceYamlPath, 'utf-8') + : undefined; + result.packageManagerConfig = before !== after; + } else if (ensurePnpmWorkspaceExoticSubdepsSetting(projectPath)) { + ensurePnpmWorkspacePackages(projectPath, workspaceInfo.workspacePatterns); + result.packageManagerConfig = true; + } + } else if (workspaceInfo.packageManager === PackageManager.yarn) { + const yarnrcYmlPath = path.join(projectPath, '.yarnrc.yml'); + const before = fs.existsSync(yarnrcYmlPath) + ? fs.readFileSync(yarnrcYmlPath, 'utf-8') + : undefined; + rewriteYarnrcYml(projectPath, usesVitest, vitestEcosystemPackages, providerCatalogAdditions); + const after = fs.readFileSync(yarnrcYmlPath, 'utf-8'); + result.packageManagerConfig = before !== after; + } else if (workspaceInfo.packageManager === PackageManager.bun) { + const before = fs.readFileSync(packageJsonPath, 'utf-8'); + rewriteBunCatalog(projectPath, usesVitest, vitestEcosystemPackages); + const after = fs.readFileSync(packageJsonPath, 'utf-8'); + result.packageJson = result.packageJson || before !== after; + } + + const beforePackageManager = fs.readFileSync(packageJsonPath, 'utf-8'); + setPackageManager(projectPath, workspaceInfo.downloadPackageManager); + const afterPackageManager = fs.readFileSync(packageJsonPath, 'utf-8'); + result.packageManagerField = beforePackageManager !== afterPackageManager; + result.changed = result.packageJson || result.packageManagerConfig || result.packageManagerField; + if (result.changed && report) { + report.packageManagerBootstrapConfigured = true; + } + return result; +} diff --git a/packages/cli/src/migration/migrator/vitest-ecosystem.ts b/packages/cli/src/migration/migrator/vitest-ecosystem.ts new file mode 100644 index 0000000000..ee9cdffdba --- /dev/null +++ b/packages/cli/src/migration/migrator/vitest-ecosystem.ts @@ -0,0 +1,760 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { Scalar, YAMLMap } from 'yaml'; + +import { PackageManager, type WorkspacePackage } from '../../types/index.ts'; +import { + VITEST_VERSION, + VITE_PLUS_NAME, + VITE_PLUS_OVERRIDE_PACKAGES, +} from '../../utils/constants.ts'; +import { readJsonFile } from '../../utils/json.ts'; +import { detectPackageMetadata } from '../../utils/package.ts'; +import { + bootstrapProjectPaths, + getCatalogDependencySpec, + hasNuxtTestUtilsDependency, + sourceTreeReferencesRetainedVitestModule, + usesVitestBrowserMode, + type BootstrapPackageJson, +} from '../migrator.ts'; +import { + LEGACY_WRAPPER_FALLBACK_VERSIONS, + PROVIDER_OVERRIDE_DROP_NAMES, + REMOVE_PACKAGES, + VITEST_BROWSER_DEP_NAMES, + VITEST_IS_MANAGED_OVERRIDE, + type CatalogDependencyResolver, + type PackageJsonDependencyField, +} from './shared.ts'; + +// Official `@vitest/*` packages are versioned in lockstep with vitest and carry +// an EXACT `vitest` peer (verified against the registry: `@vitest/coverage-v8`, +// `@vitest/coverage-istanbul`, `@vitest/ui`, `@vitest/web-worker`, the browser +// family, and the runtime internals all pin `vitest: `), so any the +// project lists must match the bundled vitest or Vitest runs mixed copies (the +// `define-config.ts` coverage guard fail-fasts on exactly this skew). +// `@vitest/eslint-plugin` versions on its own line, and deprecated +// `@vitest/coverage-c8` never published on the Vitest 4 line, so neither may be +// pinned to the bundled Vitest version. +const VITEST_ALIGN_EXCLUDED = new Set([ + '@vitest/eslint-plugin', + // Deprecated at 0.33.0 and replaced by @vitest/coverage-v8. It does not + // publish versions on Vitest's current release line, so pinning it to the + // bundled Vitest version creates a dependency spec that does not exist. + '@vitest/coverage-c8', +]); + +// Official packages that do not declare a required `vitest` peer. Keep them +// aligned when a project lists them directly, but do not add a direct vitest +// merely because they are present. +export const VITEST_DIRECT_USAGE_EXCLUDED = new Set([ + '@vitest/eslint-plugin', + '@vitest/expect', + '@vitest/mocker', + '@vitest/pretty-format', + '@vitest/runner', + '@vitest/snapshot', + '@vitest/spy', + '@vitest/utils', + '@vitest/ws-client', +]); + +export function isAlignableVitestEcosystemPackage(name: string): boolean { + return name.startsWith('@vitest/') && !VITEST_ALIGN_EXCLUDED.has(name); +} + +// Extract the package name an override/resolution key *targets* — i.e. the +// package whose version would be forced. This mirrors the grammar of the real +// package-manager parsers (verified against `@yarnpkg/parsers` parseResolution): +// - bare (`pkg`, `@scope/pkg`) +// - versioned (`pkg@1`, `@scope/pkg@1`) +// - pnpm parent selectors (`parent>pkg`, chained `a@1>b>@scope/pkg`) +// - yarn `from/target` selectors (`parent/pkg`, `parent/@scope/pkg`, +// `parent@1/pkg`, glob `**/pkg`) +// For a yarn `from/target` selector the forced package is the TRAILING +// descriptor, not the parent: `@scope/pkg@4/child` targets `child`, and an +// npm-alias key like `@scope/pkg@npm:@other/fork@1` is parsed by yarn as +// `from=@scope/pkg@npm:@other`, `descriptor=fork@1` — so the target is `fork`, +// NOT `@scope/pkg`. Taking the trailing descriptor is exactly that. (Yarn +// *rejects* keys whose range embeds a slash, e.g. `pkg@patch:…/…` or git/URL +// ranges, so those never reach us as valid keys and need no special handling.) +// Scoped names keep their leading `@` and internal `/`. +function extractOverrideTargetName(key: string): string { + // pnpm parent selector `parent>child` (incl. chains `a>b>child`): the forced + // package is the deepest child. pnpm splits at a `>` whose preceding char is + // NOT space, `|`, or `@` — this is pnpm's own delimiter rule (DELIMITER_REGEX + // = /[^ |@]>/ in @pnpm/parse-overrides) — so a semver comparator range such as + // `pkg@>=4`, `pkg@>4`, or `>1 || >2` is NOT mistaken for a parent selector. + // Peel parent levels until none remain, keeping the trailing child. + let target = key.trim(); + for (let delim = target.search(/[^ |@]>/); delim !== -1; delim = target.search(/[^ |@]>/)) { + target = target.slice(delim + 2).trim(); + } + if (!target) { + return target; + } + // yarn `from/target` selector: drop leading parent/glob segments, keeping the + // trailing package descriptor (and a scoped name's own `/`). + if (target.includes('/')) { + const segments = target.split('/'); + const last = segments[segments.length - 1]; + const scope = segments[segments.length - 2]; + target = scope?.startsWith('@') ? `${scope}/${last}` : last; + } + // Strip a trailing version/range suffix. The version `@` follows the name + // (after the `/` for a scoped name); the leading scope `@` is never a version + // separator. + const nameStart = target.startsWith('@') ? target.indexOf('/') + 1 : 0; + const versionAt = target.indexOf('@', nameStart); + if (versionAt > 0) { + target = target.slice(0, versionAt); + } + return target; +} + +// True iff a pnpm.overrides key's target (after stripping selector and +// version suffixes) is a provider whose stale pin must be dropped (see +// PROVIDER_OVERRIDE_DROP_NAMES). Shared by the JSON-object and YAMLMap +// variants below. +function isRemovePackageOverrideKey(key: string): boolean { + return (PROVIDER_OVERRIDE_DROP_NAMES as readonly string[]).includes( + extractOverrideTargetName(key), + ); +} + +// Strip a trailing `@version`/range from a selector segment and keep its scope. +// Mirrors the version-suffix peeling in `extractOverrideTargetName`: the version +// `@` follows the name (after the `/` of a scoped name); the leading scope `@` +// is never a version separator. +function stripSegmentVersion(segment: string): string { + const nameStart = segment.startsWith('@') ? segment.indexOf('/') + 1 : 0; + const versionAt = segment.indexOf('@', nameStart); + return versionAt > 0 ? segment.slice(0, versionAt) : segment; +} + +// True iff a single parent-NAME glob segment matches the given literal package +// name. `*` matches any run of characters; all other glob/regex metacharacters +// are escaped. Used for the concrete ancestor segments of a selector. +function parentGlobMatchesName(glob: string, name: string): boolean { + const pattern = glob + .split('*') + .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&')) + .join('.*'); + return new RegExp(`^${pattern}$`).test(name); +} + +// True iff an ancestor segment (literal or glob) matches the given package name. +function ancestorSegmentMatches(segment: string, name: string): boolean { + return segment.includes('*') ? parentGlobMatchesName(segment, name) : segment === name; +} + +// Provider names that sit on vite-plus's OWN dependency path and can therefore +// appear as ANCESTORS of a pin that still constrains vite-plus's provider +// subtree: pnpm/yarn parent selectors are not root-anchored, so a chain like +// `@vitest/browser-preview>@vitest/browser` forces the provider's child +// everywhere that provider appears — including under vite-plus's own direct +// provider dep. Only the vite-plus-supplied `@vitest/browser*` members of +// REMOVE_PACKAGES qualify; the user-owned opt-in providers (webdriverio, +// playwright) are deliberately NOT included — vite-plus no longer ships them, so +// a `@vitest/browser-playwright>…` chain constrains the user's own provider +// subtree, not vite-plus's (see the ACCEPTED EDGE note below). +const OWNED_PROVIDER_ANCESTOR_NAMES = (REMOVE_PACKAGES as readonly string[]).filter((name) => + name.startsWith('@vitest/'), +); + +// True iff a selector's PARENT chain reaches vite-plus's OWN direct provider dep. +// The subtree migration protects is ` → vite-plus → @vitest/provider → …`; +// since vite-plus is a direct dependency of the project, a parent chain reaches +// that subtree iff it glob-matches a path along it: +// - `**` segments match zero-or-more ancestors, so they are ignored here; +// - the FIRST remaining concrete ancestor may glob-match `vite-plus` +// (`vite-plus`, `vite-*`, `*`); +// - every OTHER concrete ancestor must glob-match a vite-plus-owned provider +// (`@vitest/browser*`), because un-anchored selectors such as +// `@vitest/browser-playwright>@vitest/browser` still constrain the +// provider's children under vite-plus. +// Any chain carrying a SPECIFIC unrelated ancestor (`some-parent>vite-plus`, +// `some-parent/**`, `some-parent/vite-*`, `some-app>@vitest/browser-playwright`) +// constrains a different subtree and does NOT touch the root vite-plus provider, +// so it is preserved. A chain of only `**` (`**`, `**/**`) is global and matches. +function parentChainReachesVitePlus(segments: string[]): boolean { + const concrete = segments.filter((segment) => segment !== '**'); + let index = 0; + if (concrete.length > 0 && ancestorSegmentMatches(concrete[0], VITE_PLUS_NAME)) { + index = 1; + } + for (; index < concrete.length; index += 1) { + const segment = concrete[index]; + if (!OWNED_PROVIDER_ANCESTOR_NAMES.some((name) => ancestorSegmentMatches(segment, name))) { + return false; + } + } + return true; +} + +// Extract the ordered PARENT chain of an override/resolution key — the ancestor +// segments above the forced TARGET — or `null` when the key has no parent +// selector (a bare/versioned global pin). Each segment's own `@version`/range is +// stripped and scoped names (`@scope/name`) are kept whole; glob segments (`**`, +// `vite-*`) are preserved verbatim for `parentChainReachesVitePlus`. +// +// Mirrors `extractOverrideTargetName`'s grammar so target and parent stay +// consistent (see that function for the full delimiter rationale): +// - pnpm `a>b>child`: every `>`-separated prefix is a parent level (`a`, `b`); +// pnpm has no globs, so a chain of length > 1 always carries a specific +// ancestor. +// - yarn `from/descriptor`: the descriptor is the trailing 1 (unscoped) or 2 +// (scoped) segments; the remaining leading `/`-segments are the `from` chain, +// with scoped ancestors (`@scope/name`) rejoined. +// - bare/versioned names (`pkg`, `@scope/pkg`, `pkg@4`) have NO parent → `null`. +function extractOverrideParentSegments(key: string): string[] | null { + let rest = key.trim(); + // Peel every pnpm `>` parent level. pnpm splits at a `>` whose preceding char + // is NOT space, `|`, or `@` (its DELIMITER_REGEX), so semver comparators like + // `pkg@>=4` are not mistaken for a parent selector. + const pnpmParents: string[] = []; + for (let delim = rest.search(/[^ |@]>/); delim !== -1; delim = rest.search(/[^ |@]>/)) { + pnpmParents.push(stripSegmentVersion(rest.slice(0, delim + 1).trim())); + rest = rest.slice(delim + 2).trim(); + } + if (pnpmParents.length > 0) { + return pnpmParents; + } + // No pnpm parent — check for a yarn `from/descriptor` selector. `rest` is the + // child (target) descriptor; only a `/` beyond a single scoped name leaves a + // leading `from` (parent) chain. + if (!rest.includes('/')) { + return null; + } + const segments = rest.split('/'); + // The trailing descriptor occupies the last 2 segments when it is a scoped + // name (second-to-last segment starts with `@`), else the last 1. + const descriptorIsScoped = segments[segments.length - 2]?.startsWith('@') ?? false; + const descriptorSegmentCount = descriptorIsScoped ? 2 : 1; + const rawParents = segments.slice(0, segments.length - descriptorSegmentCount); + if (rawParents.length === 0) { + // The whole key was a bare scoped name (`@scope/pkg`) — no parent selector. + return null; + } + // Rejoin scoped ancestors (`@scope` + `name`) and strip each segment's version. + const parents: string[] = []; + for (let i = 0; i < rawParents.length; i += 1) { + const segment = rawParents[i]; + if (segment.startsWith('@') && i + 1 < rawParents.length) { + parents.push(stripSegmentVersion(`${segment}/${rawParents[i + 1]}`)); + i += 1; + } else { + parents.push(stripSegmentVersion(segment)); + } + } + return parents; +} + +// True iff a provider override/resolution key (target ∈ +// PROVIDER_OVERRIDE_DROP_NAMES) should be dropped because the pin would affect +// vite-plus's OWN direct provider dep. The pin reaches that dep iff its parent +// selector is: +// 1. ABSENT — bare/versioned global pin (`@vitest/browser-playwright`, +// `@vitest/browser-playwright@4`). +// 2. a chain that glob-matches a path along the vite-plus provider subtree: a +// pure glob (`**/...`, `*/...`), a name glob matching vite-plus +// (`vite-*/...`), the literal `vite-plus` (`vite-plus>...`, `vite-plus/...`), +// `**`-padded variants (`**/vite-plus/...`), or a chain whose remaining +// ancestors are vite-plus-owned providers — un-anchored selectors such as +// `@vitest/browser-preview>@vitest/browser` or nested npm +// `{ "@vitest/browser-preview": { "@vitest/browser": … } }` still force +// the provider's children under vite-plus. See +// `parentChainReachesVitePlus`. +// A selector carrying a SPECIFIC unrelated ancestor anywhere in its chain +// (`some-app>@vitest/...`, `some-parent/@vitest/...`, `a>vite-plus>@vitest/...`, +// `some-parent/**/@vitest/...`, `some-parent/vite-*/@vitest/...`) or a mere +// wildcard RANGE on a specific parent (`parent@*/...`) only constrains that +// parent's subtree and is preserved. The parent chain comes from the KEY STRING +// for flat pnpm/yarn selectors; for npm/bun NESTED objects it is accumulated from +// the enclosing keys by `dropRemovePackageOverrideKeys` and passed in via +// `ancestorChain`, so a nested `{ a: { vite-plus: { provider } } }` is treated +// exactly like the flat `a>vite-plus>provider` (both preserved). +// +// ACCEPTED EDGE: reachability is judged from `vite-plus` only. A pnpm selector +// whose parent is the project's OWN (root/workspace) package name — which keeps +// an opt-in provider as a direct dep after migration, e.g. +// `my-app>@vitest/browser-webdriverio` or `my-app>@vitest/browser-playwright` — +// is therefore preserved even though it could re-pin that direct dep. Likewise a +// chain parented by an opt-in provider itself (`@vitest/browser-playwright>…`) +// constrains the USER's provider subtree, not vite-plus's, so it is preserved +// (the opt-in providers are excluded from OWNED_PROVIDER_ANCESTOR_NAMES). +// Dropping these would require threading importer names through this pass; per +// PR #1588 this is left as a known, visible (the pin stays in the manifest) +// limitation rather than risk over-deleting genuinely unrelated transitive +// selectors (the behavior the posted P2 review asked us to keep). +function providerKeyReachesVitePlus(key: string, ancestorChain: string[]): boolean { + if (!isRemovePackageOverrideKey(key)) { + return false; + } + const keyParents = extractOverrideParentSegments(key) ?? []; + return parentChainReachesVitePlus([...ancestorChain, ...keyParents]); +} + +// Flat-selector entry point (no enclosing object nesting): used by the +// pnpm-workspace YAML sweep, where each key carries its whole parent chain. +export function shouldDropProviderOverrideKey(key: string): boolean { + return providerKeyReachesVitePlus(key, []); +} + +// The ancestor segments a key contributes when the recursion descends into its +// object value: the key's own embedded selector parents followed by its target +// package name (version-stripped). For a plain npm/bun nested key (`a`) this is +// just `[a]`, so the accumulated chain mirrors a flat pnpm/yarn parent chain. +function childChainContribution(key: string): string[] { + const parents = extractOverrideParentSegments(key) ?? []; + return [...parents, extractOverrideTargetName(key)]; +} + +// Drop override keys whose target is a drop-listed provider AND whose pin would +// reach vite-plus's OWN direct provider dep — the edge ` → vite-plus → +// @vitest/provider`. Covers bare, versioned, global-glob and `vite-plus`-parent +// shapes that exact-key matching would miss. A pin scoped under a SPECIFIC +// non-vite-plus parent (pnpm `some-app>@vitest/...`, yarn `some-parent/@vitest/...`, +// or the npm/bun nested `{ "some-pkg": { "@vitest/...": "x" } }`) only constrains +// that parent's subtree and is PRESERVED. +// +// The decision is uniform across sinks: a provider pin is dropped iff its FULL +// ancestor chain reaches the root vite-plus edge (see `parentChainReachesVitePlus`). +// For flat pnpm/yarn selectors the whole chain lives in the KEY STRING; for npm/bun +// nested objects it is accumulated here from the enclosing object keys +// (`ancestorChain`) — so `{ "a": { "vite-plus": { provider } } }` is treated like +// the flat `a>vite-plus>provider` (both PRESERVED: vite-plus sits under `a`, not at +// the root). A long-form provider override (`{ "@vitest/browser-playwright": { ".": +// "x", "other": "y" } }`) has its own version pin (`.`) dropped while unrelated +// children (`other`) are kept. A parent we EMPTY by dropping its last pin is pruned +// so no meaningless `{}` is left; user-authored empties and untouched maps are kept. +// (pnpm/yarn override values are flat strings, so the recursion is inert for those +// sinks.) Returns whether any key/pin was removed. +export function dropRemovePackageOverrideKeys( + overrides: Record | undefined, + ancestorChain: string[] = [], +): boolean { + if (!overrides) { + return false; + } + let removed = false; + for (const key of Object.keys(overrides)) { + const value = overrides[key]; + const child = + value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined; + if (providerKeyReachesVitePlus(key, ancestorChain)) { + if (child) { + // Long-form provider override: drop the provider's own version pin (`.`) + // but keep any unrelated child overrides scoped under it; still descend + // (with the provider appended to the chain) for any deeper root pin. + let changed = false; + if ('.' in child) { + delete child['.']; + changed = true; + } + if ( + dropRemovePackageOverrideKeys(child, [...ancestorChain, ...childChainContribution(key)]) + ) { + changed = true; + } + if (Object.keys(child).length === 0) { + delete overrides[key]; + changed = true; + } + if (changed) { + removed = true; + } + } else { + delete overrides[key]; + removed = true; + } + continue; + } + if (child) { + // Not a root-vite-plus provider pin here: descend with the chain extended by + // this key so a deeper pin sees its full ancestor path; prune the parent only + // if the descent emptied it. + if ( + dropRemovePackageOverrideKeys(child, [...ancestorChain, ...childChainContribution(key)]) + ) { + removed = true; + if (Object.keys(child).length === 0) { + delete overrides[key]; + } + } + } + } + return removed; +} + +// The managed override/catalog packages vite-plus writes and the detector +// requires. `vite` is ALWAYS managed (aliased to vite-plus-core). `vitest` is +// managed ONLY when the project uses vitest DIRECTLY — vite-plus consumes +// upstream vitest itself, so a non-vitest project gets it transitively through +// vite-plus and must NOT carry a managed `vitest` pin (which would drift on a +// future `vp update vite-plus`). When `usesVitest` is false the common-case +// removal logic ACTIVELY strips any lingering `vitest` entry. +export function managedOverridePackages(usesVitest: boolean): Record { + if (usesVitest) { + return VITE_PLUS_OVERRIDE_PACKAGES; + } + // Drop only `vitest`; every other managed key (e.g. `vite`, and in + // force-override/CI mode the `@voidzero-dev/vite-plus-core` file: alias) stays. + return Object.fromEntries( + Object.entries(VITE_PLUS_OVERRIDE_PACKAGES).filter(([key]) => key !== 'vitest'), + ); +} + +// True iff a dependency field lists a vitest ecosystem package — any name that +// contains `vitest` other than bare `vitest` itself (e.g. `@vitest/coverage-v8`, +// `@vitest/browser-playwright`, `vitest-browser-svelte`). A bare `vitest` +// dependency alone is deliberately NOT a signal — a prior migration may have +// injected it transitively-redundantly, so it must not keep the project pinned +// to a managed `vitest`. This mirrors the `isVitestAdjacent` signal used later +// when deciding to inject a direct `vitest`, so the two stay consistent. +function projectListsVitestEcosystemDep(pkg: { + dependencies?: Record; + devDependencies?: Record; + optionalDependencies?: Record; + peerDependencies?: Record; +}): boolean { + // Peer declarations do not install the package in this project; its consumer + // is responsible for satisfying that package's peers. + const dependencyGroups = [pkg.dependencies, pkg.devDependencies, pkg.optionalDependencies]; + return dependencyGroups.some((deps) => + deps + ? Object.keys(deps).some( + (name) => + name !== 'vitest' && + name.includes('vitest') && + // Excluded official packages either have no vitest peer or (for the + // ESLint plugin) only an optional `vitest: *` peer. Neither needs a + // direct install or workspace-wide override. + !VITEST_DIRECT_USAGE_EXCLUDED.has(name), + ) + : false, + ); +} + +// Detect installed dependencies whose package metadata declares a required +// Vitest peer. Package names are not authoritative: integrations such as +// `vite-plugin-gherkin` require Vitest without containing "vitest" in their +// own name. Optional peers do not require package-local provisioning. +export function projectListsRequiredVitestPeer( + projectPath: string, + pkg: { + dependencies?: Record; + devDependencies?: Record; + optionalDependencies?: Record; + }, +): boolean { + const installGroups = [pkg.dependencies, pkg.devDependencies, pkg.optionalDependencies]; + const hasExistingVitest = installGroups.some( + (dependencies) => dependencies?.vitest !== undefined, + ); + const dependencyNames = new Set([ + ...Object.keys(pkg.dependencies ?? {}), + ...Object.keys(pkg.devDependencies ?? {}), + ...Object.keys(pkg.optionalDependencies ?? {}), + ]); + dependencyNames.delete('vitest'); + dependencyNames.delete('vite'); + dependencyNames.delete(VITE_PLUS_NAME); + for (const name of VITEST_DIRECT_USAGE_EXCLUDED) { + dependencyNames.delete(name); + } + let metadataUnavailable = false; + + for (const name of dependencyNames) { + const metadata = detectPackageMetadata(projectPath, name); + if (!metadata) { + metadataUnavailable = true; + continue; + } + try { + const installedPkg = readJsonFile(path.join(metadata.path, 'package.json')) as { + peerDependencies?: Record; + peerDependenciesMeta?: Record; + }; + if ( + typeof installedPkg.peerDependencies?.vitest === 'string' && + installedPkg.peerDependenciesMeta?.vitest?.optional !== true + ) { + return true; + } + } catch { + metadataUnavailable = true; + } + } + // A clean checkout may not have node_modules/.pnp metadata yet. If the user + // already carries a direct Vitest while any dependency's peer contract is + // unknown, preserve it rather than risk removing the provider for an + // arbitrary integration such as vite-plugin-gherkin. A later migration with + // complete metadata can safely remove a genuinely redundant pin. + return metadataUnavailable && hasExistingVitest; +} + +// True iff the project uses vitest DIRECTLY — via a dependency that is expected +// to have a required vitest peer (see `projectListsVitestEcosystemDep`), an +// upstream `vitest` module specifier, a package-level @nuxt/test-utils +// compatibility boundary, or vitest browser mode. Drives +// whether the migration keeps `vitest` managed or removes it entirely; the +// browser-mode arm keeps it aligned with the direct-`vitest` injection below so +// an injected `catalog:` spec never dangles against a vitest-less catalog. +export function projectUsesVitestDirectly( + projectPath: string, + pkg: { + dependencies?: Record; + optionalDependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + }, + requiredVitestPeer = projectListsRequiredVitestPeer(projectPath, pkg), + preserveNuxtVitestImports = true, + // Optional precomputed source-tree scan results. Callers that already computed + // these for the same `projectPath` at the same point (no source mutation in + // between) thread them here to avoid re-traversing the source tree. When + // omitted, the scans run lazily as before, preserving short-circuit behavior. + precomputedScans?: { browserMode: boolean; retainedModule: boolean }, +): boolean { + return ( + projectListsVitestEcosystemDep(pkg) || + requiredVitestPeer || + // Browser packages declared only as peers still become direct installs: + // rewritePackageJson/reconcileVitePlusBootstrapPackage promote opt-in + // providers into devDependencies and treat the bundled browser packages as + // browser-mode intent. Account for that promotion before shared + // catalog/override ownership is decided, otherwise the promoted provider's + // exact Vitest peer is left unsatisfied under strict pnpm/Yarn layouts. + VITEST_BROWSER_DEP_NAMES.some((name) => pkg.peerDependencies?.[name] !== undefined) || + (precomputedScans?.retainedModule ?? sourceTreeReferencesRetainedVitestModule(projectPath)) || + (preserveNuxtVitestImports && hasNuxtTestUtilsDependency(pkg)) || + (precomputedScans?.browserMode ?? usesVitestBrowserMode(projectPath)) + ); +} + +// Remove a managed `vitest` key from a flat string-valued record (dependency +// field, npm/bun overrides, yarn resolutions, pnpm.overrides, a catalog object). +// Only a STRING value is removed: a managed pin, `catalog:` reference, or wrapper +// alias is always a string, whereas a nested object value (npm/bun `overrides`) +// is a user override scoped under `vitest` and must be left intact. Returns true +// iff an entry was removed. +export function removeManagedVitestEntry(record: Record | undefined): boolean { + if (VITEST_IS_MANAGED_OVERRIDE && typeof record?.vitest === 'string') { + delete record.vitest; + return true; + } + return false; +} + +// Remove a managed `vitest` scalar key from a YAMLMap (pnpm-workspace.yaml +// `overrides`, `catalog`, and each named `catalogs` entry). +export function removeYamlMapVitestEntry(map: unknown): void { + if (!VITEST_IS_MANAGED_OVERRIDE || !(map instanceof YAMLMap)) { + return; + } + const target = map.items.find( + (item) => item.key instanceof Scalar && item.key.value === 'vitest', + )?.key; + if (target) { + map.delete(target); + } +} + +// Remove the managed `vitest` entry from pnpm peerDependencyRules (its +// `allowAny` array entry and `allowedVersions.vitest`), in place. Works on both +// the package.json `pnpm.peerDependencyRules` JSON shape and the same shape read +// back from pnpm-workspace.yaml. +export function removeVitestPeerDependencyRule(peerDependencyRules: { + allowAny?: string[]; + allowedVersions?: Record; +}): void { + if (!VITEST_IS_MANAGED_OVERRIDE) { + return; + } + if (Array.isArray(peerDependencyRules.allowAny)) { + peerDependencyRules.allowAny = peerDependencyRules.allowAny.filter((key) => key !== 'vitest'); + } + if (peerDependencyRules.allowedVersions) { + delete peerDependencyRules.allowedVersions.vitest; + } +} + +// Legacy wrapper package names that may appear as the target of override +// aliases left over from earlier vite-plus migrations. `@voidzero-dev/vite-plus-test` +// was deleted; any catalog/override entry still pointing at it is stale. +const LEGACY_WRAPPER_PACKAGE_NAMES = ['@voidzero-dev/vite-plus-test'] as const; + +export function isLegacyWrapperSpec(value: unknown): boolean { + // A wrapper spec is always a flat string range; npm/bun `overrides` may hold + // nested object values, which can never themselves be a wrapper alias (the + // recursion in `pruneLegacyWrapperAliases` descends into those). + if (typeof value !== 'string' || !value) { + return false; + } + for (const name of LEGACY_WRAPPER_PACKAGE_NAMES) { + if (value === `npm:${name}` || value.startsWith(`npm:${name}@`)) { + return true; + } + } + return false; +} + +/** + * Rewrite or remove keys whose value points at a deleted vite-plus wrapper. + * When a fallback exists for the key (e.g. `vitest`), the value is replaced + * so existing `catalog:` references continue to resolve. Otherwise the key + * is dropped entirely. Returns true iff any entry was changed. + * + * npm/bun `overrides` may nest an object of scoped overrides under a parent + * key (e.g. `{ "some-parent": { "vitest": "npm:@voidzero-dev/vite-plus-test@latest" } }`), + * so object values are recursed into; a parent emptied by pruning is dropped so + * no `{}` is left behind. Flat maps (pnpm `overrides`, yarn `resolutions`, + * catalogs) hold only string values, where the recursion is inert. + */ +export function pruneLegacyWrapperAliases(record: Record | undefined): boolean { + if (!record) { + return false; + } + let mutated = false; + for (const key of Object.keys(record)) { + const value = record[key]; + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + if (pruneLegacyWrapperAliases(value as Record)) { + mutated = true; + if (Object.keys(value as Record).length === 0) { + delete record[key]; + } + } + continue; + } + if (isLegacyWrapperSpec(value)) { + const fallback = LEGACY_WRAPPER_FALLBACK_VERSIONS[key]; + if (fallback !== undefined) { + record[key] = fallback; + } else { + delete record[key]; + } + mutated = true; + } + } + return mutated; +} + +export function getAlignedVitestEcosystemDependencySpec( + current: string, + dependencyName: string, + dependencyField: PackageJsonDependencyField, + packageManager: PackageManager, + supportCatalog: boolean, + catalogDependencyResolver?: CatalogDependencyResolver, +): string { + const catalogSpec = current.startsWith('catalog:') ? current : 'catalog:'; + const catalogSupported = + supportCatalog && catalogDependencyResolver?.(catalogSpec, dependencyName) !== undefined; + return getCatalogDependencySpec(current, VITEST_VERSION, catalogSupported, { + dependencyField, + dependencyName, + packageManager, + catalogDependencyResolver, + preferredCatalogSpec: catalogDependencyResolver?.preferredCatalogSpec, + }); +} + +// Align every declared official `@vitest/*` package with the bundled Vitest. +// Prefer an existing default or named catalog entry when the package manager +// supports catalogs; otherwise use the concrete bundled version. Returns true +// if any package.json spec changed. Catalog values are reconciled separately by +// the package-manager config writers above. +export function alignVitestEcosystemPackages( + pkg: BootstrapPackageJson, + packageManager: PackageManager, + supportCatalog: boolean, + catalogDependencyResolver?: CatalogDependencyResolver, +): boolean { + if (!VITEST_IS_MANAGED_OVERRIDE) { + return false; + } + const dependencyGroups: Array<{ + dependencyField: PackageJsonDependencyField; + dependencies: Record | undefined; + }> = [ + { dependencyField: 'devDependencies', dependencies: pkg.devDependencies }, + { dependencyField: 'dependencies', dependencies: pkg.dependencies }, + { dependencyField: 'optionalDependencies', dependencies: pkg.optionalDependencies }, + ]; + let changed = false; + for (const { dependencyField, dependencies } of dependencyGroups) { + if (!dependencies) { + continue; + } + for (const name of Object.keys(dependencies)) { + if (!isAlignableVitestEcosystemPackage(name)) { + continue; + } + const aligned = getAlignedVitestEcosystemDependencySpec( + dependencies[name], + name, + dependencyField, + packageManager, + supportCatalog, + catalogDependencyResolver, + ); + if (dependencies[name] !== aligned) { + dependencies[name] = aligned; + changed = true; + } + } + } + return changed; +} + +export function vitestEcosystemCatalogReferencesPending( + pkg: BootstrapPackageJson, + catalogDependencyResolver?: CatalogDependencyResolver, +): boolean { + if (!VITEST_IS_MANAGED_OVERRIDE || !catalogDependencyResolver) { + return false; + } + for (const dependencies of [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]) { + if (!dependencies) { + continue; + } + for (const [name, spec] of Object.entries(dependencies)) { + if ( + isAlignableVitestEcosystemPackage(name) && + spec.startsWith('catalog:') && + catalogDependencyResolver(spec, name) !== VITEST_VERSION + ) { + return true; + } + } + } + return false; +} + +export function collectVitestEcosystemInstallDependencyNames( + rootDir: string, + packages?: WorkspacePackage[], +): Set { + const names = new Set(); + for (const packagePath of bootstrapProjectPaths(rootDir, packages)) { + const packageJsonPath = path.join(packagePath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + continue; + } + const pkg = readJsonFile(packageJsonPath) as BootstrapPackageJson; + for (const dependencies of [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]) { + for (const name of Object.keys(dependencies ?? {})) { + if (isAlignableVitestEcosystemPackage(name)) { + names.add(name); + } + } + } + } + return names; +} diff --git a/packages/cli/src/migration/migrator/yarn.ts b/packages/cli/src/migration/migrator/yarn.ts new file mode 100644 index 0000000000..7d410353f7 --- /dev/null +++ b/packages/cli/src/migration/migrator/yarn.ts @@ -0,0 +1,403 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import semver from 'semver'; +import { Scalar, YAMLSeq } from 'yaml'; + +import { type WorkspacePackage } from '../../types/index.ts'; +import { + VITEST_AGE_GATE_EXEMPT_PACKAGES, + VITE_PLUS_NAME, + VITE_PLUS_VERSION, +} from '../../utils/constants.ts'; +import { readJsonFile } from '../../utils/json.ts'; +import { editYamlFile, readYamlFile, scalarString } from '../../utils/yaml.ts'; +import { + createCatalogDependencyResolverFromCatalogs, + overridesSatisfyVitePlus, + rewriteCatalog, + usesWebdriverioProvider, +} from '../migrator.ts'; +import { type MigrationReport } from '../report.ts'; +import { + WEBDRIVERIO_PROVIDER, + readPackageJsonIfExists, + warnMigration, + type DependencyBag, +} from './shared.ts'; + +// Webdriverio is the runtime peer that drags `edgedriver` / `geckodriver` in. +const WEBDRIVERIO_PEER_DEP = 'webdriverio'; + +// Dependencies whose presence before migration signals the user will end up +// with webdriverio after migration. `@vitest/browser-webdriverio` is the opt-in +// provider vite-plus keeps in the user's deps (pinned to the bundled vitest) +// and `webdriverio` is its runtime peer (added via `BROWSER_PROVIDER_PEER_DEPS`); +// either one means the edgedriver/geckodriver postinstalls must be allowed. +const WEBDRIVERIO_ALLOW_SIGNAL_DEPS = [WEBDRIVERIO_PEER_DEP, WEBDRIVERIO_PROVIDER] as const; + +export function hasOwnWebdriverioDependency(pkg: DependencyBag): boolean { + for (const name of WEBDRIVERIO_ALLOW_SIGNAL_DEPS) { + if ( + pkg.dependencies?.[name] ?? + pkg.devDependencies?.[name] ?? + pkg.optionalDependencies?.[name] ?? + pkg.peerDependencies?.[name] + ) { + return true; + } + } + return false; +} + +export function workspaceUsesWebdriverio( + rootDir: string, + packages: WorkspacePackage[] | undefined, +): boolean { + const rootPkg = readPackageJsonIfExists(path.join(rootDir, 'package.json')); + if (rootPkg && hasOwnWebdriverioDependency(rootPkg)) { + return true; + } + // Source-only signal: a package may target the webdriverio provider purely + // through imports (e.g. `vite-plus/test/browser-webdriverio`) without a + // declared dep yet. The migration injects the provider for those, so the + // driver postinstalls must be allowed too. + if (usesWebdriverioProvider(rootDir)) { + return true; + } + if (!packages) { + return false; + } + for (const pkg of packages) { + const packageDir = path.join(rootDir, pkg.path); + const subPkg = readPackageJsonIfExists(path.join(packageDir, 'package.json')); + if (subPkg && hasOwnWebdriverioDependency(subPkg)) { + return true; + } + if (usesWebdriverioProvider(packageDir)) { + return true; + } + } + return false; +} + +// Read a SINGLE directory's `.yarnrc.yml` scalar value for `key` (or undefined when +// the file/key is absent or non-string). Malformed YAML throws inside `readYamlFile`, +// so guard with try/catch — a broken ancestor rc must not abort the migration. +// +// Values are taken VERBATIM: Yarn's `${VAR}` / `${VAR:-default}` string interpolation +// is NOT evaluated. An interpolated `nmHoistingLimits`/`nodeLinker` therefore won't +// match the literal `'workspaces'`/`'node-modules'` the caller compares against, so the +// hoisting fix conservatively does NOTHING for it — a no-op (and never a spurious +// mutation), the same outcome as a repo with no hoisting handling at all. Faithfully +// evaluating Yarn interpolation would mean reimplementing Yarn's config loader (or +// shelling out to `yarn config get`, a fragile pre-install process dependency), which +// is out of scope for this best-effort safety net. +// +// The filename is the literal `.yarnrc.yml`, not Yarn's `YARN_RC_FILENAME`-renamed rc. +// `YARN_RC_FILENAME` support is intentionally out of scope: the rest of the Yarn +// migration (catalog/`nodeLinker`/`npmPreapprovedPackages` writes in `rewriteYarnrcYml` +// et al.) only ever writes `.yarnrc.yml`, so reading a renamed rc here would be a +// partial, inconsistent treatment — and a repo with `YARN_RC_FILENAME` set cannot be +// migrated at all until the write path also honours it (a separate, larger change). +// Keeping reads and writes on the same `.yarnrc.yml` is the consistent behaviour. +function readYarnrcValue(dir: string, key: string): string | undefined { + const yarnrcYmlPath = path.join(dir, '.yarnrc.yml'); + if (!fs.existsSync(yarnrcYmlPath)) { + return undefined; + } + try { + const doc = readYamlFile(yarnrcYmlPath) as Record | null; + const value = doc?.[key]; + return typeof value === 'string' ? value : undefined; + } catch { + return undefined; + } +} + +// Resolve the EFFECTIVE value Yarn would apply for a config `key` (and its +// `YARN_` env override) for a project rooted at `workspaceRootDir`, matching +// Yarn 4.17 precedence (all verified with `yarn config get`): +// 1. the `YARN_*` environment variable wins over every `.yarnrc.yml` (e.g. +// `YARN_NM_HOISTING_LIMITS`, `YARN_NODE_LINKER`); +// 2. otherwise Yarn merges `.yarnrc.yml` across the project root AND its ancestor +// directories, the CLOSEST file that defines the key winning — so a key set only +// in an ancestor rc is in effect, while a workspace-root value overrides it. +// So check the env var, then walk UP from the workspace root, then finally the home +// `~/.yarnrc.yml`, returning the first DEFINED value; undefined when none set it (the +// caller applies Yarn's default). The ancestor walk starts AT the workspace root, +// never below it — a sub-workspace's own `.yarnrc.yml` is not part of Yarn's +// install-time config resolution and must not shadow the root. +// +// The home rc is consulted LAST (lowest precedence, below the project/ancestor chain +// — verified with Yarn 4.17: a project-root value beats the home value). For a project +// UNDER $HOME the ancestor walk already passed through $HOME, so the explicit read is +// redundant; it matters for projects OUTSIDE $HOME (e.g. devcontainers/Codespaces +// mount the repo under /workspaces while $HOME is /home/), where Yarn still +// reads the home rc and the ancestor walk would otherwise miss it. +function resolveEffectiveYarnConfigValue( + workspaceRootDir: string, + key: string, + envVar: string, +): string | undefined { + const fromEnv = process.env[envVar]?.trim(); + if (fromEnv) { + return fromEnv; + } + let dir = path.resolve(workspaceRootDir); + for (;;) { + const value = readYarnrcValue(dir, key); + if (value !== undefined) { + return value; + } + const parent = path.dirname(dir); + if (parent === dir) { + break; + } + dir = parent; + } + const home = os.homedir(); + return home ? readYarnrcValue(home, key) : undefined; +} + +export interface YarnPnpDetection { + source: 'environment' | 'configuration' | 'default'; +} + +/** + * Detect Yarn Plug'n'Play using the same precedence Yarn applies to + * `nodeLinker`. Yarn 2+ defaults to PnP when no value is configured, while + * Yarn Classic defaults to node_modules. Unknown/`latest` Yarn versions are + * treated as modern because that is the version `vp` will provision. + */ +export function detectYarnPnpMode( + projectPath: string, + yarnVersion: string, +): YarnPnpDetection | undefined { + const coercedVersion = semver.coerce(yarnVersion); + if (coercedVersion?.major === 1) { + return undefined; + } + + const environmentLinker = process.env.YARN_NODE_LINKER?.trim(); + if (environmentLinker) { + return environmentLinker.toLowerCase() === 'pnp' ? { source: 'environment' } : undefined; + } + + const configuredLinker = resolveEffectiveYarnConfigValue( + projectPath, + 'nodeLinker', + 'YARN_NODE_LINKER', + ); + if (configuredLinker) { + return configuredLinker.toLowerCase() === 'pnp' ? { source: 'configuration' } : undefined; + } + + return { source: 'default' }; +} + +/** Set the project-local Yarn linker while preserving every other rc setting. */ +export function configureYarnNodeModulesMode(projectPath: string): boolean { + const yarnrcYmlPath = path.join(projectPath, '.yarnrc.yml'); + const before = fs.existsSync(yarnrcYmlPath) ? fs.readFileSync(yarnrcYmlPath, 'utf8') : undefined; + if (before === undefined) { + fs.writeFileSync(yarnrcYmlPath, ''); + } + editYamlFile(yarnrcYmlPath, (doc) => { + doc.set('nodeLinker', 'node-modules'); + }); + return before !== fs.readFileSync(yarnrcYmlPath, 'utf8'); +} + +// True when `dir`'s package.json declares a `workspaces` field — i.e. `dir` is a +// workspace (Yarn project) root. `workspaces` may be an array or an object +// (`{ packages: [...] }`); both are truthy. +function dirIsWorkspaceRoot(dir: string): boolean { + const pkgJsonPath = path.join(dir, 'package.json'); + if (!fs.existsSync(pkgJsonPath)) { + return false; + } + try { + const pkg = readJsonFile(pkgJsonPath) as { workspaces?: unknown }; + return pkg.workspaces != null; + } catch { + return false; + } +} + +// Walk up from a workspace directory to the nearest ancestor that IS a workspace +// root (its package.json declares `workspaces`) — the real Yarn project root — and +// return that directory plus the EFFECTIVE `nmHoistingLimits` and `nodeLinker` +// resolved across env + the `.yarnrc.yml` chain at and above that root. Keying on the +// workspace-root marker (NOT the nearest `.yarnrc.yml`) is deliberate: a package-local +// `.yarnrc.yml` written under a sub-package (e.g. by `vp create` / install) must not +// shadow the real root's limit, while a limit set in an ancestor `.yarnrc.yml` above +// the root is still honoured (Yarn merges the ancestor chain). This lets +// `rewriteMonorepoProject` discover the layout for ANY caller without it being +// threaded as an argument (the omitted-arg path was a missed-auto-fix bug class), and +// lets the caller tell whether the workspace it is rewriting IS the root (the root's +// deps already hoist to the top, so it must never be opted out). `nodeLinker` gates +// the fix: `nmHoistingLimits` only splits packages under the `node-modules` linker, so +// a PnP project (Yarn's default) is left untouched. undefined when no workspace root +// is found up to the filesystem root. +export function findYarnWorkspaceHoisting( + startDir: string, +): { rootDir: string; limit: string | undefined; nodeLinker: string | undefined } | undefined { + let dir = path.resolve(startDir); + for (;;) { + if (dirIsWorkspaceRoot(dir)) { + return { + rootDir: dir, + limit: resolveEffectiveYarnConfigValue(dir, 'nmHoistingLimits', 'YARN_NM_HOISTING_LIMITS'), + nodeLinker: resolveEffectiveYarnConfigValue(dir, 'nodeLinker', 'YARN_NODE_LINKER'), + }; + } + const parent = path.dirname(dir); + if (parent === dir) { + return undefined; + } + dir = parent; + } +} + +// Opt a single workspace OUT of the INHERITED root `nmHoistingLimits` isolation by +// setting its own `installConfig.hoistingLimits: none`, so its `vite-plus` (and +// thus the bundled `vitest` family) hoists to the single shared root copy the +// runner bin resolves to. Scoped to workspaces the migration adds `vite-plus` to, +// so unrelated workspaces are untouched. `none` is Yarn's DEFAULT hoisting +// behaviour, so this only re-enables ordinary deduping — it never force-promotes a +// conflicting version to root. +// +// Only relaxes the INHERITED root limit: if the workspace already carries an +// EXPLICIT `installConfig.hoistingLimits` we leave it as-is. Overwriting it would +// clobber an intentional per-workspace invariant (e.g. a React Native `example` +// that isolates its whole tree for Metro and happens to also use Vite+ for tests), +// and that field governs the workspace's ENTIRE dependency tree, not just the +// vitest family. Idempotent: a no-op when any explicit value is already present. +function setYarnWorkspaceHoistingOptOut(pkg: { + installConfig?: { hoistingLimits?: string }; +}): void { + if (pkg.installConfig?.hoistingLimits !== undefined) { + return; + } + pkg.installConfig = { ...pkg.installConfig, hoistingLimits: 'none' }; +} + +// Resolve the Yarn workspace-hoisting isolation for a workspace that now depends on +// `vite-plus`. `rootLimit` is the effective `nmHoistingLimits` and `nodeLinker` the +// effective linker (both undefined for non-Yarn repos or an unset key). Either +// auto-fixes the workspace (mutating `pkg`) or, when the split cannot be fixed from +// package.json, warns so the migration never reports success while `vp test` is still +// known-broken. +export function applyYarnWorkspaceHoistingFix( + pkg: { installConfig?: { hoistingLimits?: string } }, + rootLimit: string | undefined, + nodeLinker: string | undefined, + workspaceLabel: string, + report?: MigrationReport, +): void { + // `nmHoistingLimits`/`installConfig.hoistingLimits` only govern the `node-modules` + // linker — they physically isolate copies there. Under Plug'n'Play (Yarn's DEFAULT + // when `nodeLinker` is unset) resolution is virtual: no duplicate `@vitest/runner` + // can exist, so neither the auto-fix nor the warning applies. Writing an opt-out + // there would be a spurious source mutation that weakens isolation if the repo later + // switches linkers, so skip everything unless the linker is `node-modules`. + if (nodeLinker !== 'node-modules') { + return; + } + // `workspaces` isolation with no explicit per-workspace limit is the one layout a + // `none` opt-out deduplicates — fix it silently. + if (rootLimit === 'workspaces' && pkg.installConfig?.hoistingLimits === undefined) { + setYarnWorkspaceHoistingOptOut(pkg); + return; + } + // Layouts we must NOT (or cannot) auto-fix, but which still isolate this + // workspace's `vitest`/`vite-plus` copy so `vp test` can crash with a split + // `@vitest/runner`: + // - the INHERITED root `dependencies` limit (a `none` opt-out does not dedupe + // it — verified), and + // - the workspace's OWN explicit isolating `installConfig.hoistingLimits` + // (`workspaces`/`dependencies`), which isolates it regardless of the root + // value (incl. root unset or `none`) and is intentional, so it is preserved + // rather than clobbered. + // Surface a manual step for both rather than report a silently broken migration. + const explicit = pkg.installConfig?.hoistingLimits; + const isolatedByRoot = rootLimit === 'dependencies'; + const isolatedByWorkspace = explicit === 'workspaces' || explicit === 'dependencies'; + if (isolatedByRoot || isolatedByWorkspace) { + warnMigration( + `Yarn workspace "${workspaceLabel}" isolates dependency hoisting ` + + `(hoistingLimits: ${explicit ?? rootLimit}), so it keeps its own ` + + `\`vitest\`/\`vite-plus\` copy and \`vp test\` may crash with a split ` + + `\`@vitest/runner\`. Dedupe them to a single copy — relax this workspace's ` + + `hoisting isolation or pin one \`vitest\` for the workspace.`, + report, + ); + } +} + +export function rewriteYarnrcYml( + projectPath: string, + usesVitest: boolean, + vitestEcosystemPackages: ReadonlySet, + catalogAdditions: ReadonlySet = new Set(), +): void { + const yarnrcYmlPath = path.join(projectPath, '.yarnrc.yml'); + if (!fs.existsSync(yarnrcYmlPath)) { + fs.writeFileSync(yarnrcYmlPath, ''); + } + + editYamlFile(yarnrcYmlPath, (doc) => { + if (!doc.has('nodeLinker')) { + doc.set('nodeLinker', 'node-modules'); + } + // Vite+ pins the vitest family to exact, sometimes freshly published, + // versions. Yarn 4 hardened mode (auto-enabled for public-PR installs) + // quarantines packages younger than `npmMinimalAgeGate`, which makes + // `yarn install` fail on a just-released vitest pin. Preapprove the family + // so the Vite+-managed versions install regardless of release age; the + // `@vitest/*` glob also covers the optional `@vitest/browser-*` peers that + // are not in the override set. MERGE into any existing list (e.g. a project + // that already preapproves private packages) instead of skipping when set, + // otherwise the gate could still reject the freshly pinned vitest. + let npmPreapprovedPackages = doc.getIn(['npmPreapprovedPackages']) as YAMLSeq>; + if (!npmPreapprovedPackages) { + npmPreapprovedPackages = new YAMLSeq(); + } + const existingPreapproved = new Set(npmPreapprovedPackages.items.map((n) => n.value)); + for (const pkg of VITEST_AGE_GATE_EXEMPT_PACKAGES) { + if (!existingPreapproved.has(pkg)) { + npmPreapprovedPackages.add(scalarString(pkg)); + } + } + doc.setIn(['npmPreapprovedPackages'], npmPreapprovedPackages); + // catalog + rewriteCatalog(doc, usesVitest, vitestEcosystemPackages, catalogAdditions); + }); +} + +export function yarnrcSatisfiesVitePlus(projectPath: string, usesVitest: boolean): boolean { + const yarnrcYmlPath = path.join(projectPath, '.yarnrc.yml'); + if (!fs.existsSync(yarnrcYmlPath)) { + return false; + } + const doc = readYamlFile(yarnrcYmlPath) as { + nodeLinker?: string; + catalog?: Record; + catalogs?: Record>; + } | null; + const resolver = createCatalogDependencyResolverFromCatalogs(doc?.catalog, doc?.catalogs); + const catalogName = resolver.preferredCatalogSpec.slice('catalog:'.length); + const managedCatalog = + catalogName && catalogName !== 'default' + ? doc?.catalogs?.[catalogName] + : (doc?.catalog ?? doc?.catalogs?.default); + return ( + !!doc && + Object.hasOwn(doc, 'nodeLinker') && + overridesSatisfyVitePlus(managedCatalog, usesVitest) && + (VITE_PLUS_VERSION.startsWith('file:') || + resolver(resolver.preferredCatalogSpec, VITE_PLUS_NAME) === VITE_PLUS_VERSION) + ); +} From 64b580ab472fc335e49eb0ee17cbcc1350ce3e4f Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 27 Jun 2026 21:30:19 +0800 Subject: [PATCH 59/78] docs(agents): point to the migrator/ structure guide Add a "Where to Start" pointer so agents read packages/cli/src/migration/migrator/README.md before changing migrator code. Claude-Session: https://claude.ai/code/session_01DQhS6o1fyQd1yjiee6W8jR --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 6231d6a916..8c0d2f40f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,6 +49,7 @@ vite-plus/ - **Managed Node runtime / shims**: start at `crates/vite_js_runtime/`. - **Static `vite.config.ts` extraction**: start at `crates/vite_static_config/README.md` and `packages/cli/src/resolve-vite-config.ts`. - **Migration behavior**: `docs/guide/migrate-rules.md`. +- **Migrator code (`vp migrate`)**: category modules under `packages/cli/src/migration/migrator/` behind the `migrator.ts` barrel; follow `migrator/README.md` when changing migrator code. - **Bundled toolchain surfaces**: start with `packages/core/BUNDLING.md`, `packages/cli/BUNDLING.md`, and `packages/test/BUNDLING.md`. - **Generated project agent guidance**: `packages/cli/AGENTS.md` and `packages/cli/src/utils/agent.ts`; do not edit these when the task is only to improve root repo guidance. - **Product/repo docs**: root contributor docs live at the repo root and the VitePress site under `docs/` (`docs/guide/`, `docs/config/`); generated agent guidance is separate. From 089f609de1da4038ad5e78cff7464a2d078dd6e3 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 27 Jun 2026 21:51:20 +0800 Subject: [PATCH 60/78] chore(skills): add test-pkg-pr-new-migrate skill Project skill that verifies a pkg.pr.new build of vite-plus against one real project: runs .github/scripts/test-pkg-pr-new-migrate.sh (vp migrate with deps resolved through the registry bridge), then `vp why` to confirm a single, correct version of vite-plus/vite/vitest. Asks for the PR/SHA and project path when not provided. Claude-Session: https://claude.ai/code/session_01DQhS6o1fyQd1yjiee6W8jR --- .../skills/test-pkg-pr-new-migrate/SKILL.md | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .claude/skills/test-pkg-pr-new-migrate/SKILL.md diff --git a/.claude/skills/test-pkg-pr-new-migrate/SKILL.md b/.claude/skills/test-pkg-pr-new-migrate/SKILL.md new file mode 100644 index 0000000000..16f3fc9cd8 --- /dev/null +++ b/.claude/skills/test-pkg-pr-new-migrate/SKILL.md @@ -0,0 +1,29 @@ +--- +name: test-pkg-pr-new-migrate +description: Verify a pkg.pr.new build of vite-plus against a real project before release — run `vp migrate` from the pkg.pr.new commit against a local project, deps resolved through the registry bridge. Use when asked to verify/e2e-test a pkg.pr.new build against a project, "test PR # on ", or check a prerelease against a repo. +allowed-tools: Bash, Read +--- + +# Verify a pkg.pr.new build against one project + +Installs an isolated global `vp` from a pkg.pr.new commit and runs `vp migrate` on a specified local project. The result pins `vite-plus`/`vite` to `0.0.0-commit.` resolved through the registry bridge, persisted into the project's `.npmrc` (and `.yarnrc.yml` for Yarn Berry) so the project's own CI installs the build too. + +Required inputs: a `` (the pkg.pr.new build to verify) and a ``. If either is missing from the request, **ask the user** for it — never guess the PR/SHA, as the build under test is the user's choice. + +```bash +.github/scripts/test-pkg-pr-new-migrate.sh [migrate-options...] +# e.g. +.github/scripts/test-pkg-pr-new-migrate.sh 1891 /path/to/npmx.dev --no-interactive +``` + +- First arg is a PR number or commit SHA; the script resolves the immutable commit and verifies the bridge serves it (the pkg.pr.new publish workflow registers each commit). +- Never touches `~/.vite-plus`; refuses a dirty worktree unless `ALLOW_DIRTY=1`; prints the project's `git status`/`diff` at the end — inspect that to confirm the migration result. + +Then confirm the resolved versions (`-r` across workspaces for monorepos): + +```bash +cd +vp why -r vite-plus vite vitest +``` + +Each must resolve to exactly ONE version: `vite-plus` and `vite` at the expected `0.0.0-commit.` (vite via the `@voidzero-dev/vite-plus-core` alias), `vitest` at the bundled upstream version. Multiple versions, or a stale/wrong version, means the migration or install is broken. From f35980877aacfb802e69f76db86d3d648f32e620 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 27 Jun 2026 22:31:10 +0800 Subject: [PATCH 61/78] fix(migrate): normalize protocol-pinned vite-plus under force-override In pkg.pr.new force-override runs (VP_VERSION=https://pkg.pr.new/...), the force-override block re-pins vite-plus to that commit URL and sets needVitePlus. The dedup rewrite only normalized non-protocol specs, so the raw URL leaked into the direct dep instead of `catalog:` (the catalog still holds the URL), breaking the migration-upgrade-pkg-pr-new-pnpm snapshot. Normalize protocol-pinned specs under force-override too, while preserving catalog:named references, matching ensureVitePlusDependencySpecs. Adds a regression spec that mocks VITE_PLUS_VERSION to a pkg.pr.new URL (the shared migrator spec mocks it to `latest`, which hid this case). Claude-Session: https://claude.ai/code/session_01DQhS6o1fyQd1yjiee6W8jR --- ...migrator-pkg-pr-new-force-override.spec.ts | 71 +++++++++++++++++++ .../src/migration/migrator/package-json.ts | 10 ++- 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/migration/__tests__/migrator-pkg-pr-new-force-override.spec.ts diff --git a/packages/cli/src/migration/__tests__/migrator-pkg-pr-new-force-override.spec.ts b/packages/cli/src/migration/__tests__/migrator-pkg-pr-new-force-override.spec.ts new file mode 100644 index 0000000000..5e19d994a9 --- /dev/null +++ b/packages/cli/src/migration/__tests__/migrator-pkg-pr-new-force-override.spec.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { PackageManager } from '../../types/index.js'; + +const PKG_PR_NEW_URL = + 'https://pkg.pr.new/voidzero-dev/vite-plus@0c515e3fbf5c140db35280d700df0bd600838617'; +const CORE_URL = + 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@0c515e3fbf5c140db35280d700df0bd600838617'; + +// pkg.pr.new force-override runs (`VP_VERSION=https://pkg.pr.new/...`) make +// VITE_PLUS_VERSION a protocol-pinned http URL instead of the usual semver. The +// shared migrator spec mocks VITE_PLUS_VERSION to `latest`, which hides this +// case, so reproduce it in an isolated module with the URL-shaped version. +vi.mock('../../utils/constants.js', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + VITE_PLUS_VERSION: PKG_PR_NEW_URL, + VITE_PLUS_OVERRIDE_PACKAGES: { + ...mod.VITE_PLUS_OVERRIDE_PACKAGES, + vite: CORE_URL, + }, + }; +}); + +const { rewritePackageJson } = await import('../migrator.js'); + +function withForceOverride(fn: () => void): void { + const saved = process.env.VP_FORCE_MIGRATE; + process.env.VP_FORCE_MIGRATE = '1'; + try { + fn(); + } finally { + if (saved === undefined) { + delete process.env.VP_FORCE_MIGRATE; + } else { + process.env.VP_FORCE_MIGRATE = saved; + } + } +} + +describe('rewritePackageJson under pkg.pr.new force-override (URL VITE_PLUS_VERSION)', () => { + // Regression: force-override re-pins vite-plus to the pkg.pr.new URL up front, + // so the catalog-supporting rewrite must normalize the direct dep back to + // `catalog:` (the catalog entry holds the URL). The dedup change stopped + // normalizing protocol-pinned specs, leaving the raw URL as the direct dep and + // breaking the migration-upgrade-pkg-pr-new-pnpm snapshot. + it('normalizes a pre-existing range vite-plus to `catalog:`', () => { + withForceOverride(() => { + const pkg = { devDependencies: { 'vite-plus': '^0.1.20' } }; + rewritePackageJson(pkg, PackageManager.pnpm, true); + expect(pkg.devDependencies['vite-plus']).toBe('catalog:'); + }); + }); + + it('normalizes a vite-plus already pinned to the pkg.pr.new URL to `catalog:`', () => { + withForceOverride(() => { + const pkg = { devDependencies: { 'vite-plus': PKG_PR_NEW_URL } }; + rewritePackageJson(pkg, PackageManager.pnpm, true); + expect(pkg.devDependencies['vite-plus']).toBe('catalog:'); + }); + }); + + it('preserves a named catalog reference instead of collapsing onto the default catalog', () => { + withForceOverride(() => { + const pkg = { devDependencies: { 'vite-plus': 'catalog:tools' } }; + rewritePackageJson(pkg, PackageManager.pnpm, true); + expect(pkg.devDependencies['vite-plus']).toBe('catalog:tools'); + }); + }); +}); diff --git a/packages/cli/src/migration/migrator/package-json.ts b/packages/cli/src/migration/migrator/package-json.ts index 5b65991763..30c93e7575 100644 --- a/packages/cli/src/migration/migrator/package-json.ts +++ b/packages/cli/src/migration/migrator/package-json.ts @@ -325,11 +325,19 @@ export function rewritePackageJson( ? pkg.dependencies : undefined; const existingVitePlus = existingVitePlusGroup?.[VITE_PLUS_NAME]; + // Mirrors `ensureVitePlusDependencySpecs`: a named/default `catalog:` reference + // is always preserved (never collapsed onto the workspace default), but every + // other non-canonical spec is rewritten when it's a vanilla range OR when + // force-override demands the requested artifact. Without the force-override + // arm, the force-override block above re-pins vite-plus to a protocol-pinned + // pkg.pr.new URL that then survives here, leaking the raw URL into the direct + // dep instead of `catalog:` (the catalog still holds the URL). const shouldNormalizeExistingVitePlus = !!existingVitePlus && supportCatalog && existingVitePlus !== canonicalVitePlusSpec && - !isProtocolPinnedSpec(existingVitePlus); + !existingVitePlus.startsWith('catalog:') && + (isForceOverrideMode() || !isProtocolPinnedSpec(existingVitePlus)); // vitest-adjacent / browser-mode signals only trigger a vite-plus INSTALL when the // project doesn't already have vite-plus — otherwise vite-plus is already present and // re-adding it would be churn. (The direct `vitest` pin those signals also require is From 142e5e966b9d6c31c73376a747b3e427594a5b61 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 27 Jun 2026 23:04:07 +0800 Subject: [PATCH 62/78] fix(migrate): address PR review feedback Re-run the Yarn PnP guard after the package manager is resolved: an existing Vite+ project with no detectable manager runs the guard with an undefined manager (a no-op), so a later Yarn resolution under YARN_NODE_LINKER=pnp would slip through. Re-running once the manager is known still rejects the unsupported PnP layout. Pin the preview CLI's own managed Node when running the pkg.pr.new migrate entry. `vp node` resolves Node from the target project's cwd, so a project pinned to an old/unsupported version could fail to launch dist/bin.js even though the isolated CLI ships a compatible runtime; probe the CLI default and pass it via `env exec --node`. Claude-Session: https://claude.ai/code/session_01DQhS6o1fyQd1yjiee6W8jR --- .github/scripts/test-pkg-pr-new-migrate.sh | 17 ++++++++++++++- packages/cli/src/migration/bin.ts | 24 +++++++++++++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/.github/scripts/test-pkg-pr-new-migrate.sh b/.github/scripts/test-pkg-pr-new-migrate.sh index 5fe3a1e05b..621a6ed9b3 100755 --- a/.github/scripts/test-pkg-pr-new-migrate.sh +++ b/.github/scripts/test-pkg-pr-new-migrate.sh @@ -290,6 +290,15 @@ echo " vite-plus spec: $commit_version" echo " vite spec: $vite_core_spec" "$vp_bin" --version +# Resolve the preview CLI's own managed Node, independent of the target +# project's pin. Probe from the isolated VP_HOME (no project .node-version) so +# we get the global default rather than the project's. A project pinned to an +# old/unsupported Node would otherwise fail to launch the preview dist/bin.js, +# even though the isolated CLI ships a compatible runtime. +cli_node_version="$(cd "$pr_home" && "$vp_bin" --version 2>/dev/null \ + | sed -nE 's/.*Node\.js[[:space:]]+v?([0-9]+\.[0-9]+\.[0-9]+).*/\1/p' | head -1)" +echo " cli node: ${cli_node_version:-unknown}" + echo echo "Running vp migrate in $project_dir" set +e @@ -297,8 +306,14 @@ set +e # Run the installed JS entry directly so a project-local vite-plus at the # same semver cannot take precedence. Keep cwd at the project root because # project config and plugins may resolve dependencies from process.cwd(). + # Pin the CLI's own Node (via `env exec --node`) so the project's pinned Node + # version cannot block dist/bin.js from starting. cd "$project_dir" - "$vp_bin" node "$global_cli_entry" migrate "$project_dir" "$@" + if [ -n "$cli_node_version" ]; then + "$vp_bin" env exec --node "$cli_node_version" node "$global_cli_entry" migrate "$project_dir" "$@" + else + "$vp_bin" node "$global_cli_entry" migrate "$project_dir" "$@" + fi ) migrate_status=$? set -e diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index 9a67b39cd1..20c5627f2b 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -1205,7 +1205,11 @@ async function main() { workspaceInfoOptional.rootDir, ) as PackageDependencies | null; if (hasVitePlusDependency(rootPkg) && !isForceOverrideMode()) { - const convertYarnPnp = await confirmYarnNodeModulesMode( + // Runs with the detected package manager, which may be undefined for an + // existing Vite+ project that has no lockfile/`packageManager` pin. In that + // case `confirmYarnNodeModulesMode` no-ops here and the guard is re-run + // below once the package manager is actually resolved. + let convertYarnPnp = await confirmYarnNodeModulesMode( workspaceInfoOptional.rootDir, workspaceInfoOptional.packageManager, workspaceInfoOptional.packageManagerVersion, @@ -1284,6 +1288,24 @@ async function main() { await ensureExistingPackageManager(); } + // The early guard ran before the package manager was resolved. If it was + // only determined to be Yarn afterwards (e.g. selected because the project + // had no detectable manager), re-run the guard so a `YARN_NODE_LINKER=pnp` + // override is still rejected instead of silently writing config for an + // unsupported PnP layout. + if ( + !convertYarnPnp && + workspaceInfoOptional.packageManager === undefined && + packageManager === PackageManager.yarn + ) { + convertYarnPnp = await confirmYarnNodeModulesMode( + workspaceInfoOptional.rootDir, + packageManager, + packageManagerVersion, + options.interactive, + ); + } + const coreMigrationResult = finalizeCoreMigrationForExistingVitePlus( workspaceInfoOptional, true, From 87b376da93e470b982ae85027a4f88ea4f9d6e98 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 27 Jun 2026 23:43:25 +0800 Subject: [PATCH 63/78] test(migrate): drop pkg.pr.new force-override in favor of the bridge The registry bridge serves pkg.pr.new commit builds as ordinary npm versions (0.0.0-commit.), so the migrate test path no longer needs force-override to pin raw pkg.pr.new URLs. Remove VP_FORCE_MIGRATE from the bridge e2e script and convert the migration-upgrade-pkg-pr-new-{pnpm,npm} and migration-upgrade-pnpm-named-catalog snap fixtures to the bridge version so they exercise the normal upgrade path (force-override itself stays for the vp create / ecosystem-ci local-tgz installs). Revert the force-override protocol-pin normalization in rewritePackageJson and its spec, since only the now-removed URL+force-override flow reached it. Also bound the snap normalizer prerelease match to version characters so a `@0.0.0-commit.` npm alias in JSON no longer swallows the closing quote. Claude-Session: https://claude.ai/code/session_01DQhS6o1fyQd1yjiee6W8jR --- .github/scripts/test-pkg-pr-new-migrate.sh | 8 +-- .../migration-upgrade-pkg-pr-new-npm/snap.txt | 25 +++---- .../steps.json | 13 ++-- .../snap.txt | 28 ++++---- .../steps.json | 16 ++--- .../snap.txt | 19 ++--- .../steps.json | 10 ++- ...migrator-pkg-pr-new-force-override.spec.ts | 71 ------------------- .../src/migration/migrator/package-json.ts | 10 +-- packages/tools/src/utils.ts | 5 +- 10 files changed, 56 insertions(+), 149 deletions(-) delete mode 100644 packages/cli/src/migration/__tests__/migrator-pkg-pr-new-force-override.spec.ts diff --git a/.github/scripts/test-pkg-pr-new-migrate.sh b/.github/scripts/test-pkg-pr-new-migrate.sh index 621a6ed9b3..21a073d188 100755 --- a/.github/scripts/test-pkg-pr-new-migrate.sh +++ b/.github/scripts/test-pkg-pr-new-migrate.sh @@ -224,12 +224,12 @@ fi export VP_HOME="$pr_home" export PATH="$VP_HOME/bin:$PATH" -# vite-plus and vite (-> vite-plus-core) become ordinary npm versions. The -# values are constrained (commit SHA, semver) so the override JSON needs no -# escaping. +# vite-plus and vite (-> vite-plus-core) become ordinary npm versions resolved +# through the bridge, so the normal upgrade path re-pins them (no force-override +# needed). The values are constrained (commit SHA, semver) so the override JSON +# needs no escaping. export VP_VERSION="$commit_version" export VP_OVERRIDE_PACKAGES="{\"vite\":\"$vite_core_spec\",\"vitest\":\"$vitest_version\"}" -export VP_FORCE_MIGRATE=1 # Point every package manager at the registry bridge. It serves the vite-plus / # vite-plus-core / per-platform CLI commit builds and proxies everything else to # npmjs, so the project resolves the commit versions like any released package. diff --git a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/snap.txt index 3a77a693ac..9f04d1e641 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/snap.txt @@ -1,28 +1,25 @@ -> vp migrate --no-interactive # pkg.pr.new targets replace every stale managed spec +> vp migrate --no-interactive # bridge commit builds replace every stale managed spec ◇ Migrated . to Vite+ • Node npm -• 2 config updates applied +• Package manager settings configured -> cat package.json # direct dependencies and npm overrides use the same immutable commit URLs +> cat package.json # direct dependencies and npm overrides use the same immutable commit version { "name": "migration-upgrade-pkg-pr-new-npm", "devDependencies": { - "vite": "https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@0c515e3fbf5c140db35280d700df0bd600838617", - "vite-plus": "https://pkg.pr.new/voidzero-dev/vite-plus@0c515e3fbf5c140db35280d700df0bd600838617" + "vite": "npm:@voidzero-dev/vite-plus-core@", + "vite-plus": "" }, "overrides": { - "vite": "https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@0c515e3fbf5c140db35280d700df0bd600838617" + "vite": "npm:@voidzero-dev/vite-plus-core@" }, - "packageManager": "npm@", - "scripts": { - "prepare": "vp config" - } + "packageManager": "npm@" } -> node -e "const p = require('./package.json'); const sha = '0c515e3fbf5c140db35280d700df0bd600838617'; const vp = 'https://pkg.pr.new/voidzero-dev/vite-plus@' + sha; const core = 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@' + sha; if (p.devDependencies['vite-plus'] !== vp || p.devDependencies.vite !== core || p.overrides.vite !== core || p.overrides['@voidzero-dev/vite-plus-core'] !== undefined || JSON.stringify(p).includes('@1891')) process.exit(1)" # pkg.pr.new specs use one immutable commit and the minimal override shape +> node -e "const p = require('./package.json'); const v = '0.0.0-commit.0c515e3fbf5c140db35280d700df0bd600838617'; const core = 'npm:@voidzero-dev/vite-plus-core@' + v; if (p.devDependencies['vite-plus'] !== v || p.devDependencies.vite !== core || p.overrides.vite !== core || p.overrides['@voidzero-dev/vite-plus-core'] !== undefined || JSON.stringify(p).includes('@1891') || JSON.stringify(p).includes('pkg.pr.new')) process.exit(1)" # bridge specs use one immutable commit and the minimal override shape > node -e "require('node:fs').copyFileSync('package.json', 'package.after-first-migration.json')" # capture first migration result -> vp migrate --no-interactive # pkg.pr.new migration is idempotent -◇ Migrated . to Vite+ -• Node npm +> vp migrate --no-interactive # bridge commit migration is idempotent +This project is already using Vite+! Happy coding! + > node -e "const fs = require('node:fs'); if (fs.readFileSync('package.json', 'utf8') !== fs.readFileSync('package.after-first-migration.json', 'utf8')) process.exit(1)" # rerun leaves package.json unchanged \ No newline at end of file diff --git a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/steps.json index 112332ff98..74bba51572 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/steps.json +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/steps.json @@ -1,15 +1,14 @@ { "env": { - "VP_FORCE_MIGRATE": "1", - "VP_OVERRIDE_PACKAGES": "{\"vite\":\"https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@0c515e3fbf5c140db35280d700df0bd600838617\",\"vitest\":\"4.1.9\"}", - "VP_VERSION": "https://pkg.pr.new/voidzero-dev/vite-plus@0c515e3fbf5c140db35280d700df0bd600838617" + "VP_OVERRIDE_PACKAGES": "{\"vite\":\"npm:@voidzero-dev/vite-plus-core@0.0.0-commit.0c515e3fbf5c140db35280d700df0bd600838617\",\"vitest\":\"4.1.9\"}", + "VP_VERSION": "0.0.0-commit.0c515e3fbf5c140db35280d700df0bd600838617" }, "commands": [ - "vp migrate --no-interactive # pkg.pr.new targets replace every stale managed spec", - "cat package.json # direct dependencies and npm overrides use the same immutable commit URLs", - "node -e \"const p = require('./package.json'); const sha = '0c515e3fbf5c140db35280d700df0bd600838617'; const vp = 'https://pkg.pr.new/voidzero-dev/vite-plus@' + sha; const core = 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@' + sha; if (p.devDependencies['vite-plus'] !== vp || p.devDependencies.vite !== core || p.overrides.vite !== core || p.overrides['@voidzero-dev/vite-plus-core'] !== undefined || JSON.stringify(p).includes('@1891')) process.exit(1)\" # pkg.pr.new specs use one immutable commit and the minimal override shape", + "vp migrate --no-interactive # bridge commit builds replace every stale managed spec", + "cat package.json # direct dependencies and npm overrides use the same immutable commit version", + "node -e \"const p = require('./package.json'); const v = '0.0.0-commit.0c515e3fbf5c140db35280d700df0bd600838617'; const core = 'npm:@voidzero-dev/vite-plus-core@' + v; if (p.devDependencies['vite-plus'] !== v || p.devDependencies.vite !== core || p.overrides.vite !== core || p.overrides['@voidzero-dev/vite-plus-core'] !== undefined || JSON.stringify(p).includes('@1891') || JSON.stringify(p).includes('pkg.pr.new')) process.exit(1)\" # bridge specs use one immutable commit and the minimal override shape", "node -e \"require('node:fs').copyFileSync('package.json', 'package.after-first-migration.json')\" # capture first migration result", - "vp migrate --no-interactive # pkg.pr.new migration is idempotent", + "vp migrate --no-interactive # bridge commit migration is idempotent", "node -e \"const fs = require('node:fs'); if (fs.readFileSync('package.json', 'utf8') !== fs.readFileSync('package.after-first-migration.json', 'utf8')) process.exit(1)\" # rerun leaves package.json unchanged" ] } diff --git a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/snap.txt index 8e4954dc33..66469f4482 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/snap.txt @@ -1,9 +1,9 @@ -> vp migrate --no-interactive # pkg.pr.new pnpm migration allows URL-resolved subdependencies +> vp migrate --no-interactive # bridge commit builds upgrade like an ordinary npm version ◇ Migrated . to Vite+ • Node pnpm -• 2 config updates applied +• Package manager settings configured -> cat package.json # direct dependencies use catalogs aligned to the pkg.pr.new build +> cat package.json # direct dependencies use catalogs aligned to the bridge build { "name": "migration-upgrade-pkg-pr-new-pnpm", "devDependencies": { @@ -12,27 +12,23 @@ "vite-plus": "catalog:", "vitest": "catalog:" }, - "packageManager": "pnpm@", - "scripts": { - "prepare": "vp config" - } + "packageManager": "pnpm@" } -> cat pnpm-workspace.yaml # pkg.pr.new URLs are pinned and exotic subdependencies are allowed +> cat pnpm-workspace.yaml # the catalog holds the immutable commit version packages: - . -blockExoticSubdeps: false +blockExoticSubdeps: true catalog: - vite: https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@0c515e3fbf5c140db35280d700df0bd600838617 - vite-plus: https://pkg.pr.new/voidzero-dev/vite-plus@0c515e3fbf5c140db35280d700df0bd600838617 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: vitest: overrides: vite: 'catalog:' vitest: 'catalog:' - vite-plus: https://pkg.pr.new/voidzero-dev/vite-plus@0c515e3fbf5c140db35280d700df0bd600838617 peerDependencyRules: allowAny: @@ -42,10 +38,10 @@ peerDependencyRules: vite: '*' vitest: '*' -> node -e "const fs = require('node:fs'); const y = fs.readFileSync('pnpm-workspace.yaml', 'utf8'); const sha = '0c515e3fbf5c140db35280d700df0bd600838617'; if (!y.includes('blockExoticSubdeps: false') || !y.includes('https://pkg.pr.new/voidzero-dev/vite-plus@' + sha) || !y.includes('https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@' + sha) || y.includes('@1891')) process.exit(1)" # pnpm policy and immutable commit targets are persisted +> node -e "const fs = require('node:fs'); const y = fs.readFileSync('pnpm-workspace.yaml', 'utf8'); const v = '0.0.0-commit.0c515e3fbf5c140db35280d700df0bd600838617'; if (!y.includes('vite-plus: ' + v) || !y.includes('vite: npm:@voidzero-dev/vite-plus-core@' + v) || y.includes('pkg.pr.new') || y.includes('@1891')) process.exit(1)" # the immutable commit version is pinned as an ordinary npm version > node -e "const fs = require('node:fs'); fs.copyFileSync('package.json', 'package.after-first-migration.json'); fs.copyFileSync('pnpm-workspace.yaml', 'workspace.after-first-migration.yaml')" # capture first migration result -> vp migrate --no-interactive # pkg.pr.new pnpm migration is idempotent -◇ Migrated . to Vite+ -• Node pnpm +> vp migrate --no-interactive # bridge commit migration is idempotent +This project is already using Vite+! Happy coding! + > node -e "const fs = require('node:fs'); if (fs.readFileSync('package.json', 'utf8') !== fs.readFileSync('package.after-first-migration.json', 'utf8') || fs.readFileSync('pnpm-workspace.yaml', 'utf8') !== fs.readFileSync('workspace.after-first-migration.yaml', 'utf8')) process.exit(1)" # rerun leaves manifests unchanged \ No newline at end of file diff --git a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/steps.json index ae70002660..d15ef08956 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/steps.json +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/steps.json @@ -1,17 +1,15 @@ { "env": { - "PNPM_CONFIG_BLOCK_EXOTIC_SUBDEPS": "false", - "VP_FORCE_MIGRATE": "1", - "VP_OVERRIDE_PACKAGES": "{\"vite\":\"https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@0c515e3fbf5c140db35280d700df0bd600838617\",\"vitest\":\"4.1.9\"}", - "VP_VERSION": "https://pkg.pr.new/voidzero-dev/vite-plus@0c515e3fbf5c140db35280d700df0bd600838617" + "VP_OVERRIDE_PACKAGES": "{\"vite\":\"npm:@voidzero-dev/vite-plus-core@0.0.0-commit.0c515e3fbf5c140db35280d700df0bd600838617\",\"vitest\":\"4.1.9\"}", + "VP_VERSION": "0.0.0-commit.0c515e3fbf5c140db35280d700df0bd600838617" }, "commands": [ - "vp migrate --no-interactive # pkg.pr.new pnpm migration allows URL-resolved subdependencies", - "cat package.json # direct dependencies use catalogs aligned to the pkg.pr.new build", - "cat pnpm-workspace.yaml # pkg.pr.new URLs are pinned and exotic subdependencies are allowed", - "node -e \"const fs = require('node:fs'); const y = fs.readFileSync('pnpm-workspace.yaml', 'utf8'); const sha = '0c515e3fbf5c140db35280d700df0bd600838617'; if (!y.includes('blockExoticSubdeps: false') || !y.includes('https://pkg.pr.new/voidzero-dev/vite-plus@' + sha) || !y.includes('https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@' + sha) || y.includes('@1891')) process.exit(1)\" # pnpm policy and immutable commit targets are persisted", + "vp migrate --no-interactive # bridge commit builds upgrade like an ordinary npm version", + "cat package.json # direct dependencies use catalogs aligned to the bridge build", + "cat pnpm-workspace.yaml # the catalog holds the immutable commit version", + "node -e \"const fs = require('node:fs'); const y = fs.readFileSync('pnpm-workspace.yaml', 'utf8'); const v = '0.0.0-commit.0c515e3fbf5c140db35280d700df0bd600838617'; if (!y.includes('vite-plus: ' + v) || !y.includes('vite: npm:@voidzero-dev/vite-plus-core@' + v) || y.includes('pkg.pr.new') || y.includes('@1891')) process.exit(1)\" # the immutable commit version is pinned as an ordinary npm version", "node -e \"const fs = require('node:fs'); fs.copyFileSync('package.json', 'package.after-first-migration.json'); fs.copyFileSync('pnpm-workspace.yaml', 'workspace.after-first-migration.yaml')\" # capture first migration result", - "vp migrate --no-interactive # pkg.pr.new pnpm migration is idempotent", + "vp migrate --no-interactive # bridge commit migration is idempotent", "node -e \"const fs = require('node:fs'); if (fs.readFileSync('package.json', 'utf8') !== fs.readFileSync('package.after-first-migration.json', 'utf8') || fs.readFileSync('pnpm-workspace.yaml', 'utf8') !== fs.readFileSync('workspace.after-first-migration.yaml', 'utf8')) process.exit(1)\" # rerun leaves manifests unchanged" ] } diff --git a/packages/cli/snap-tests-global/migration-upgrade-pnpm-named-catalog/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-pnpm-named-catalog/snap.txt index bc923b4a1e..5df69b209c 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-pnpm-named-catalog/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-pnpm-named-catalog/snap.txt @@ -1,7 +1,7 @@ > vp migrate --no-interactive # reuse the existing named-only Vite stack catalog ◇ Migrated . to Vite+ • Node pnpm -• 2 config updates applied +• Package manager settings configured > cat package.json # catalog:vite-stack dependency references are preserved { @@ -16,13 +16,10 @@ "version": "", "onFail": "download" } - }, - "scripts": { - "prepare": "vp config" } } -> cat pnpm-workspace.yaml # managed versions and overrides stay in vite-stack +> cat pnpm-workspace.yaml # the bridge commit version is written into vite-stack packages: - . @@ -30,23 +27,21 @@ catalogs: repo-tooling: prettier: vite-stack: - vite: https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@0c515e3fbf5c140db35280d700df0bd600838617 + vite: npm:@voidzero-dev/vite-plus-core@ vitest: - vite-plus: https://pkg.pr.new/voidzero-dev/vite-plus@0c515e3fbf5c140db35280d700df0bd600838617 -blockExoticSubdeps: false + vite-plus: overrides: vite: catalog:vite-stack - vite-plus: https://pkg.pr.new/voidzero-dev/vite-plus@0c515e3fbf5c140db35280d700df0bd600838617 peerDependencyRules: allowAny: - vite allowedVersions: vite: '*' -> node -e "const fs = require('node:fs'); const p = require('./package.json'); const y = fs.readFileSync('pnpm-workspace.yaml', 'utf8'); if (p.devDependencies.vite !== 'catalog:vite-stack' || p.devDependencies['vite-plus'] !== 'catalog:vite-stack' || /^catalog:/m.test(y) || !y.includes(' vite-stack:') || !y.includes(' vite: catalog:vite-stack')) process.exit(1)" # no default catalog is introduced +> node -e "const fs = require('node:fs'); const p = require('./package.json'); const y = fs.readFileSync('pnpm-workspace.yaml', 'utf8'); const v = '0.0.0-commit.0c515e3fbf5c140db35280d700df0bd600838617'; if (p.devDependencies.vite !== 'catalog:vite-stack' || p.devDependencies['vite-plus'] !== 'catalog:vite-stack' || /^catalog:/m.test(y) || !y.includes(' vite-stack:') || !y.includes('vite-plus: ' + v) || y.includes('pkg.pr.new')) process.exit(1)" # no default catalog is introduced and vite-stack holds the commit version > node -e "const fs = require('node:fs'); fs.copyFileSync('package.json', 'package.after-first-migration.json'); fs.copyFileSync('pnpm-workspace.yaml', 'workspace.after-first-migration.yaml')" # capture first migration result > vp migrate --no-interactive # named-only catalog migration is idempotent -◇ Migrated . to Vite+ -• Node pnpm +This project is already using Vite+! Happy coding! + > node -e "const fs = require('node:fs'); if (fs.readFileSync('package.json', 'utf8') !== fs.readFileSync('package.after-first-migration.json', 'utf8') || fs.readFileSync('pnpm-workspace.yaml', 'utf8') !== fs.readFileSync('workspace.after-first-migration.yaml', 'utf8')) process.exit(1)" # rerun leaves catalog placement unchanged \ No newline at end of file diff --git a/packages/cli/snap-tests-global/migration-upgrade-pnpm-named-catalog/steps.json b/packages/cli/snap-tests-global/migration-upgrade-pnpm-named-catalog/steps.json index b6689469ea..c8b9488b04 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-pnpm-named-catalog/steps.json +++ b/packages/cli/snap-tests-global/migration-upgrade-pnpm-named-catalog/steps.json @@ -1,15 +1,13 @@ { "env": { - "PNPM_CONFIG_BLOCK_EXOTIC_SUBDEPS": "false", - "VP_FORCE_MIGRATE": "1", - "VP_OVERRIDE_PACKAGES": "{\"vite\":\"https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@0c515e3fbf5c140db35280d700df0bd600838617\"}", - "VP_VERSION": "https://pkg.pr.new/voidzero-dev/vite-plus@0c515e3fbf5c140db35280d700df0bd600838617" + "VP_OVERRIDE_PACKAGES": "{\"vite\":\"npm:@voidzero-dev/vite-plus-core@0.0.0-commit.0c515e3fbf5c140db35280d700df0bd600838617\"}", + "VP_VERSION": "0.0.0-commit.0c515e3fbf5c140db35280d700df0bd600838617" }, "commands": [ "vp migrate --no-interactive # reuse the existing named-only Vite stack catalog", "cat package.json # catalog:vite-stack dependency references are preserved", - "cat pnpm-workspace.yaml # managed versions and overrides stay in vite-stack", - "node -e \"const fs = require('node:fs'); const p = require('./package.json'); const y = fs.readFileSync('pnpm-workspace.yaml', 'utf8'); if (p.devDependencies.vite !== 'catalog:vite-stack' || p.devDependencies['vite-plus'] !== 'catalog:vite-stack' || /^catalog:/m.test(y) || !y.includes(' vite-stack:') || !y.includes(' vite: catalog:vite-stack')) process.exit(1)\" # no default catalog is introduced", + "cat pnpm-workspace.yaml # the bridge commit version is written into vite-stack", + "node -e \"const fs = require('node:fs'); const p = require('./package.json'); const y = fs.readFileSync('pnpm-workspace.yaml', 'utf8'); const v = '0.0.0-commit.0c515e3fbf5c140db35280d700df0bd600838617'; if (p.devDependencies.vite !== 'catalog:vite-stack' || p.devDependencies['vite-plus'] !== 'catalog:vite-stack' || /^catalog:/m.test(y) || !y.includes(' vite-stack:') || !y.includes('vite-plus: ' + v) || y.includes('pkg.pr.new')) process.exit(1)\" # no default catalog is introduced and vite-stack holds the commit version", "node -e \"const fs = require('node:fs'); fs.copyFileSync('package.json', 'package.after-first-migration.json'); fs.copyFileSync('pnpm-workspace.yaml', 'workspace.after-first-migration.yaml')\" # capture first migration result", "vp migrate --no-interactive # named-only catalog migration is idempotent", "node -e \"const fs = require('node:fs'); if (fs.readFileSync('package.json', 'utf8') !== fs.readFileSync('package.after-first-migration.json', 'utf8') || fs.readFileSync('pnpm-workspace.yaml', 'utf8') !== fs.readFileSync('workspace.after-first-migration.yaml', 'utf8')) process.exit(1)\" # rerun leaves catalog placement unchanged" diff --git a/packages/cli/src/migration/__tests__/migrator-pkg-pr-new-force-override.spec.ts b/packages/cli/src/migration/__tests__/migrator-pkg-pr-new-force-override.spec.ts deleted file mode 100644 index 5e19d994a9..0000000000 --- a/packages/cli/src/migration/__tests__/migrator-pkg-pr-new-force-override.spec.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -import { PackageManager } from '../../types/index.js'; - -const PKG_PR_NEW_URL = - 'https://pkg.pr.new/voidzero-dev/vite-plus@0c515e3fbf5c140db35280d700df0bd600838617'; -const CORE_URL = - 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@0c515e3fbf5c140db35280d700df0bd600838617'; - -// pkg.pr.new force-override runs (`VP_VERSION=https://pkg.pr.new/...`) make -// VITE_PLUS_VERSION a protocol-pinned http URL instead of the usual semver. The -// shared migrator spec mocks VITE_PLUS_VERSION to `latest`, which hides this -// case, so reproduce it in an isolated module with the URL-shaped version. -vi.mock('../../utils/constants.js', async (importOriginal) => { - const mod = await importOriginal(); - return { - ...mod, - VITE_PLUS_VERSION: PKG_PR_NEW_URL, - VITE_PLUS_OVERRIDE_PACKAGES: { - ...mod.VITE_PLUS_OVERRIDE_PACKAGES, - vite: CORE_URL, - }, - }; -}); - -const { rewritePackageJson } = await import('../migrator.js'); - -function withForceOverride(fn: () => void): void { - const saved = process.env.VP_FORCE_MIGRATE; - process.env.VP_FORCE_MIGRATE = '1'; - try { - fn(); - } finally { - if (saved === undefined) { - delete process.env.VP_FORCE_MIGRATE; - } else { - process.env.VP_FORCE_MIGRATE = saved; - } - } -} - -describe('rewritePackageJson under pkg.pr.new force-override (URL VITE_PLUS_VERSION)', () => { - // Regression: force-override re-pins vite-plus to the pkg.pr.new URL up front, - // so the catalog-supporting rewrite must normalize the direct dep back to - // `catalog:` (the catalog entry holds the URL). The dedup change stopped - // normalizing protocol-pinned specs, leaving the raw URL as the direct dep and - // breaking the migration-upgrade-pkg-pr-new-pnpm snapshot. - it('normalizes a pre-existing range vite-plus to `catalog:`', () => { - withForceOverride(() => { - const pkg = { devDependencies: { 'vite-plus': '^0.1.20' } }; - rewritePackageJson(pkg, PackageManager.pnpm, true); - expect(pkg.devDependencies['vite-plus']).toBe('catalog:'); - }); - }); - - it('normalizes a vite-plus already pinned to the pkg.pr.new URL to `catalog:`', () => { - withForceOverride(() => { - const pkg = { devDependencies: { 'vite-plus': PKG_PR_NEW_URL } }; - rewritePackageJson(pkg, PackageManager.pnpm, true); - expect(pkg.devDependencies['vite-plus']).toBe('catalog:'); - }); - }); - - it('preserves a named catalog reference instead of collapsing onto the default catalog', () => { - withForceOverride(() => { - const pkg = { devDependencies: { 'vite-plus': 'catalog:tools' } }; - rewritePackageJson(pkg, PackageManager.pnpm, true); - expect(pkg.devDependencies['vite-plus']).toBe('catalog:tools'); - }); - }); -}); diff --git a/packages/cli/src/migration/migrator/package-json.ts b/packages/cli/src/migration/migrator/package-json.ts index 30c93e7575..5b65991763 100644 --- a/packages/cli/src/migration/migrator/package-json.ts +++ b/packages/cli/src/migration/migrator/package-json.ts @@ -325,19 +325,11 @@ export function rewritePackageJson( ? pkg.dependencies : undefined; const existingVitePlus = existingVitePlusGroup?.[VITE_PLUS_NAME]; - // Mirrors `ensureVitePlusDependencySpecs`: a named/default `catalog:` reference - // is always preserved (never collapsed onto the workspace default), but every - // other non-canonical spec is rewritten when it's a vanilla range OR when - // force-override demands the requested artifact. Without the force-override - // arm, the force-override block above re-pins vite-plus to a protocol-pinned - // pkg.pr.new URL that then survives here, leaking the raw URL into the direct - // dep instead of `catalog:` (the catalog still holds the URL). const shouldNormalizeExistingVitePlus = !!existingVitePlus && supportCatalog && existingVitePlus !== canonicalVitePlusSpec && - !existingVitePlus.startsWith('catalog:') && - (isForceOverrideMode() || !isProtocolPinnedSpec(existingVitePlus)); + !isProtocolPinnedSpec(existingVitePlus); // vitest-adjacent / browser-mode signals only trigger a vite-plus INSTALL when the // project doesn't already have vite-plus — otherwise vite-plus is already present and // re-adding it would be churn. (The direct `vitest` pin those signals also require is diff --git a/packages/tools/src/utils.ts b/packages/tools/src/utils.ts index b1c9626b12..470dd41c33 100644 --- a/packages/tools/src/utils.ts +++ b/packages/tools/src/utils.ts @@ -48,7 +48,10 @@ export function replaceUnstableOutput(output: string, cwd?: string) { // semver version // e.g.: ` v1.0.0` -> ` ` // e.g.: `/1.0.0` -> `/` - .replaceAll(/([@/\s]v?)\d+\.\d+\.\d+(?:-.*)?/g, '$1') + // The prerelease is bounded to version characters so a long + // `@0.0.0-commit.` npm alias inside JSON does not greedily swallow the + // closing quote/comma (e.g. `"npm:...core@0.0.0-commit.",`). + .replaceAll(/([@/\s]v?)\d+\.\d+\.\d+(?:-[\w.+-]*)?/g, '$1') // vitest-family pins written as JSON values (catalog blocks, devDependencies, // overrides/resolutions) all track the bundled VITEST_VERSION and so change on // every daily upgrade-deps bump. The quote-preceded value is not caught by the From d83d6fd41fb623e5f92f7bfa7e3f9dc3f1213cb1 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 28 Jun 2026 00:03:08 +0800 Subject: [PATCH 64/78] test: regenerate command-upgrade-check for the bounded normalizer The bounded-prerelease snap normalizer (515443d0b) correctly stops eating the ` (current: )` suffix after the alpha `found` version. This snap was reverted with the network-flaky regen batch; regenerate it for real. Claude-Session: https://claude.ai/code/session_01DQhS6o1fyQd1yjiee6W8jR --- packages/cli/snap-tests-global/command-upgrade-check/snap.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/snap-tests-global/command-upgrade-check/snap.txt b/packages/cli/snap-tests-global/command-upgrade-check/snap.txt index 9c1f99380b..305fe64ef8 100644 --- a/packages/cli/snap-tests-global/command-upgrade-check/snap.txt +++ b/packages/cli/snap-tests-global/command-upgrade-check/snap.txt @@ -1,5 +1,5 @@ > vp upgrade --check --tag alpha # alpha tag avoids release-day flake (dev version equals npm latest right after a release, hiding the Update-available branch) info: checking for updates... -info: found vite-plus@ +info: found vite-plus@ (current: ) Update available: Run `vp upgrade` to update. From a1f74951a8877fae48238ce7e0a404ebf1377df6 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 28 Jun 2026 00:16:39 +0800 Subject: [PATCH 65/78] ci(pkg-pr-new): comment the bridge version on the PR after registration Once the registry bridge accepts a commit build, post (or update via a sticky marker) a PR comment with the resolved vite-plus / vite-plus-core npm versions (0.0.0-commit.) and the per-package-manager bridge registry config, so reviewers can install the build directly. Gated on the bridge step's real outcome and skipped for fork PRs; never fails the publish. Claude-Session: https://claude.ai/code/session_01DQhS6o1fyQd1yjiee6W8jR --- .github/bridge-comment-template.md | 27 ++++++++++++++++++++ .github/workflows/publish-to-pkg.pr.new.yml | 28 +++++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 .github/bridge-comment-template.md diff --git a/.github/bridge-comment-template.md b/.github/bridge-comment-template.md new file mode 100644 index 0000000000..b355f1a7ed --- /dev/null +++ b/.github/bridge-comment-template.md @@ -0,0 +1,27 @@ + +### Registry bridge build (`__SHORT__`) + +This commit is published to pkg.pr.new and registered with the [registry bridge](https://github.com/fengmk2/pkg-pr-registry-bridge), which serves these as ordinary npm versions (every other package proxies to npmjs): + +| Package | Version | +| --- | --- | +| `vite-plus` | `0.0.0-commit.__SHA__` | +| `@voidzero-dev/vite-plus-core` | `0.0.0-commit.__SHA__` | + +**Point your package manager at the bridge registry** `https://pkg-pr-registry-bridge.render.vip/`: + +| Package manager | Registry config | +| --- | --- | +| npm / pnpm / Bun | `.npmrc`: `registry=https://pkg-pr-registry-bridge.render.vip/` | +| Yarn (v2+) | `.yarnrc.yml`: `npmRegistryServer: "https://pkg-pr-registry-bridge.render.vip/"` | + +Then pin the build (`vite` aliases to vite-plus-core; pnpm can use a catalog, npm an `overrides` entry): + +```json +{ + "devDependencies": { + "vite-plus": "0.0.0-commit.__SHA__", + "vite": "npm:@voidzero-dev/vite-plus-core@0.0.0-commit.__SHA__" + } +} +``` diff --git a/.github/workflows/publish-to-pkg.pr.new.yml b/.github/workflows/publish-to-pkg.pr.new.yml index f6834436c4..bcdb5c9bf2 100644 --- a/.github/workflows/publish-to-pkg.pr.new.yml +++ b/.github/workflows/publish-to-pkg.pr.new.yml @@ -146,6 +146,7 @@ jobs: # Restricted to same-repo PRs because fork PRs do not receive the admin # token secret; never fails the publish if the bridge is unreachable. - name: Register commit build with the registry bridge + id: bridge if: github.event.pull_request.head.repo.full_name == github.repository continue-on-error: true env: @@ -157,3 +158,30 @@ jobs: -H 'content-type: application/json' \ -d "{\"ref\":\"commit.${HEAD_SHA}\"}" \ https://pkg-pr-registry-bridge.render.vip/-/refs + + # Once the bridge has the commit build, post (or update) a sticky PR comment + # with the resolved npm versions and per-package-manager registry config, so + # reviewers can install the build directly. Gated on the bridge step's real + # outcome (it is continue-on-error) and skipped for fork PRs. + - name: Comment bridge version on the PR + if: steps.bridge.outcome == 'success' + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + run: | + sed -e "s/__SHA__/${HEAD_SHA}/g" -e "s/__SHORT__/${HEAD_SHA:0:7}/g" \ + .github/bridge-comment-template.md > bridge-comment.md + existing=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" --paginate \ + --jq 'map(select(.body | contains(""))) | last | .id // empty') + if [ -n "$existing" ]; then + gh api -X PATCH "repos/${REPO}/issues/comments/${existing}" \ + -f body="$(cat bridge-comment.md)" >/dev/null + echo "Updated bridge comment ${existing}" + else + gh api -X POST "repos/${REPO}/issues/${PR_NUMBER}/comments" \ + -f body="$(cat bridge-comment.md)" >/dev/null + echo "Created bridge comment" + fi From b06c350eb0f532c40688377d6b669024a92ce546 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 28 Jun 2026 00:37:23 +0800 Subject: [PATCH 66/78] style: run oxfmt on bridge-comment-template.md Align the markdown tables; formatting only (follow-up to the bridge-comment workflow step, which was committed without running vp check). Claude-Session: https://claude.ai/code/session_01DQhS6o1fyQd1yjiee6W8jR --- .github/bridge-comment-template.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/bridge-comment-template.md b/.github/bridge-comment-template.md index b355f1a7ed..0ceb3cb8f6 100644 --- a/.github/bridge-comment-template.md +++ b/.github/bridge-comment-template.md @@ -1,19 +1,20 @@ + ### Registry bridge build (`__SHORT__`) This commit is published to pkg.pr.new and registered with the [registry bridge](https://github.com/fengmk2/pkg-pr-registry-bridge), which serves these as ordinary npm versions (every other package proxies to npmjs): -| Package | Version | -| --- | --- | -| `vite-plus` | `0.0.0-commit.__SHA__` | +| Package | Version | +| ------------------------------ | ---------------------- | +| `vite-plus` | `0.0.0-commit.__SHA__` | | `@voidzero-dev/vite-plus-core` | `0.0.0-commit.__SHA__` | **Point your package manager at the bridge registry** `https://pkg-pr-registry-bridge.render.vip/`: -| Package manager | Registry config | -| --- | --- | -| npm / pnpm / Bun | `.npmrc`: `registry=https://pkg-pr-registry-bridge.render.vip/` | -| Yarn (v2+) | `.yarnrc.yml`: `npmRegistryServer: "https://pkg-pr-registry-bridge.render.vip/"` | +| Package manager | Registry config | +| ---------------- | -------------------------------------------------------------------------------- | +| npm / pnpm / Bun | `.npmrc`: `registry=https://pkg-pr-registry-bridge.render.vip/` | +| Yarn (v2+) | `.yarnrc.yml`: `npmRegistryServer: "https://pkg-pr-registry-bridge.render.vip/"` | Then pin the build (`vite` aliases to vite-plus-core; pnpm can use a catalog, npm an `overrides` entry): From 0e26437e9a9acae989e9a8ef50805f64c5218387 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 28 Jun 2026 00:37:23 +0800 Subject: [PATCH 67/78] refactor(migrate): group Rolldown compat files under compat/ Move the four compat-* files into migration/compat/ with concise names: compat-protocol.ts -> protocol.ts, compat.ts -> manual-chunks.ts, compat-runner.ts -> runner.ts, compat-worker.ts -> worker.ts. Update the cross-file imports, bin.ts and both specs, the tsdown worker entry (now emits dist/migration/compat/worker.js), and the runner's subprocess path (./compat/worker.js). Behavior-preserving: vp check clean, both compat specs pass, and the built bin.js resolves the relocated worker entry. Claude-Session: https://claude.ai/code/session_01DQhS6o1fyQd1yjiee6W8jR --- .../cli/src/migration/__tests__/compat-runner.spec.ts | 4 ++-- packages/cli/src/migration/__tests__/compat.spec.ts | 2 +- packages/cli/src/migration/bin.ts | 2 +- .../migration/{compat.ts => compat/manual-chunks.ts} | 2 +- .../{compat-protocol.ts => compat/protocol.ts} | 0 .../migration/{compat-runner.ts => compat/runner.ts} | 8 ++++---- .../migration/{compat-worker.ts => compat/worker.ts} | 10 +++++----- packages/cli/tsdown.config.ts | 2 +- 8 files changed, 15 insertions(+), 15 deletions(-) rename packages/cli/src/migration/{compat.ts => compat/manual-chunks.ts} (90%) rename packages/cli/src/migration/{compat-protocol.ts => compat/protocol.ts} (100%) rename packages/cli/src/migration/{compat-runner.ts => compat/runner.ts} (87%) rename packages/cli/src/migration/{compat-worker.ts => compat/worker.ts} (80%) diff --git a/packages/cli/src/migration/__tests__/compat-runner.spec.ts b/packages/cli/src/migration/__tests__/compat-runner.spec.ts index 5282ad105f..26ce2dd9bc 100644 --- a/packages/cli/src/migration/__tests__/compat-runner.spec.ts +++ b/packages/cli/src/migration/__tests__/compat-runner.spec.ts @@ -5,7 +5,7 @@ vi.mock('../../utils/command.ts', () => ({ })); import { runCommandSilently } from '../../utils/command.ts'; -import { checkRolldownCompatibility, ROLLDOWN_COMPAT_RESULT_PREFIX } from '../compat-runner.ts'; +import { checkRolldownCompatibility, ROLLDOWN_COMPAT_RESULT_PREFIX } from '../compat/runner.ts'; import { createMigrationReport } from '../report.ts'; const mockRunCommandSilently = vi.mocked(runCommandSilently); @@ -30,7 +30,7 @@ describe('checkRolldownCompatibility', () => { expect(report.warnings).toEqual(['manualChunks warning']); expect(mockRunCommandSilently).toHaveBeenCalledWith({ command: process.execPath, - args: [expect.stringMatching(/compat-worker\.js$/), '/project'], + args: [expect.stringMatching(/compat\/worker\.js$/), '/project'], cwd: '/project', envs: process.env, }); diff --git a/packages/cli/src/migration/__tests__/compat.spec.ts b/packages/cli/src/migration/__tests__/compat.spec.ts index f0035a28e9..36220d6445 100644 --- a/packages/cli/src/migration/__tests__/compat.spec.ts +++ b/packages/cli/src/migration/__tests__/compat.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { checkManualChunksCompat } from '../compat.js'; +import { checkManualChunksCompat } from '../compat/manual-chunks.js'; import { createMigrationReport } from '../report.js'; describe('checkManualChunksCompat', () => { diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index 20c5627f2b..58d4681b17 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -45,7 +45,7 @@ import { } from '../utils/tsconfig.ts'; import type { PackageDependencies } from '../utils/types.ts'; import { detectWorkspace } from '../utils/workspace.ts'; -import { checkRolldownCompatibility } from './compat-runner.ts'; +import { checkRolldownCompatibility } from './compat/runner.ts'; import { canFormatWithOxfmt, collectChangedFormatPaths, formatMigratedProject } from './format.ts'; import { addFrameworkShim, diff --git a/packages/cli/src/migration/compat.ts b/packages/cli/src/migration/compat/manual-chunks.ts similarity index 90% rename from packages/cli/src/migration/compat.ts rename to packages/cli/src/migration/compat/manual-chunks.ts index 89f61a9e15..cd275e1ce5 100644 --- a/packages/cli/src/migration/compat.ts +++ b/packages/cli/src/migration/compat/manual-chunks.ts @@ -1,4 +1,4 @@ -import { addMigrationWarning, type MigrationReport } from './report.ts'; +import { addMigrationWarning, type MigrationReport } from '../report.ts'; /** * Check for Rolldown-incompatible manualChunks config patterns. diff --git a/packages/cli/src/migration/compat-protocol.ts b/packages/cli/src/migration/compat/protocol.ts similarity index 100% rename from packages/cli/src/migration/compat-protocol.ts rename to packages/cli/src/migration/compat/protocol.ts diff --git a/packages/cli/src/migration/compat-runner.ts b/packages/cli/src/migration/compat/runner.ts similarity index 87% rename from packages/cli/src/migration/compat-runner.ts rename to packages/cli/src/migration/compat/runner.ts index 62ad62c319..1521402cc9 100644 --- a/packages/cli/src/migration/compat-runner.ts +++ b/packages/cli/src/migration/compat/runner.ts @@ -1,8 +1,8 @@ import { fileURLToPath } from 'node:url'; -import { runCommandSilently } from '../utils/command.ts'; -import { ROLLDOWN_COMPAT_RESULT_PREFIX } from './compat-protocol.ts'; -import { addMigrationWarning, type MigrationReport } from './report.ts'; +import { runCommandSilently } from '../../utils/command.ts'; +import { addMigrationWarning, type MigrationReport } from '../report.ts'; +import { ROLLDOWN_COMPAT_RESULT_PREFIX } from './protocol.ts'; export { ROLLDOWN_COMPAT_RESULT_PREFIX }; @@ -46,7 +46,7 @@ export async function checkRolldownCompatibility( report: MigrationReport, ): Promise { try { - const workerPath = fileURLToPath(new URL('./compat-worker.js', import.meta.url)); + const workerPath = fileURLToPath(new URL('./compat/worker.js', import.meta.url)); const result = await runCommandSilently({ command: process.execPath, args: [workerPath, rootDir], diff --git a/packages/cli/src/migration/compat-worker.ts b/packages/cli/src/migration/compat/worker.ts similarity index 80% rename from packages/cli/src/migration/compat-worker.ts rename to packages/cli/src/migration/compat/worker.ts index 3c832f04c7..aaf92715ec 100644 --- a/packages/cli/src/migration/compat-worker.ts +++ b/packages/cli/src/migration/compat/worker.ts @@ -1,8 +1,8 @@ import { writeSync } from 'node:fs'; -import { ROLLDOWN_COMPAT_RESULT_PREFIX } from './compat-protocol.ts'; -import { checkManualChunksCompat } from './compat.ts'; -import { createMigrationReport } from './report.ts'; +import { createMigrationReport } from '../report.ts'; +import { checkManualChunksCompat } from './manual-chunks.ts'; +import { ROLLDOWN_COMPAT_RESULT_PREFIX } from './protocol.ts'; async function main(): Promise { const rootDir = process.argv[2]; @@ -11,8 +11,8 @@ async function main(): Promise { } try { - const { resolveConfig } = await import('../index.js'); - const { withConfigMetadataResolution } = await import('../define-config.js'); + const { resolveConfig } = await import('../../index.js'); + const { withConfigMetadataResolution } = await import('../../define-config.js'); // Use 'runner' configLoader to avoid Rolldown bundling the config file, // which prints UNRESOLVED_IMPORT warnings that cannot be suppressed via logLevel. // Reads the config only for the manualChunks compat check, so skip the user's diff --git a/packages/cli/tsdown.config.ts b/packages/cli/tsdown.config.ts index b0cffbfa00..b7f22145ab 100644 --- a/packages/cli/tsdown.config.ts +++ b/packages/cli/tsdown.config.ts @@ -57,7 +57,7 @@ export default defineConfig([ // Without these, tsdown inlines them into bin.js, breaking on-demand loading. 'create/bin': './src/create/bin.ts', 'migration/bin': './src/migration/bin.ts', - 'migration/compat-worker': './src/migration/compat-worker.ts', + 'migration/compat/worker': './src/migration/compat/worker.ts', version: './src/version.ts', 'config/bin': './src/config/bin.ts', 'staged/bin': './src/staged/bin.ts', From c0970dcc1c7559467d3146d4e1c71796d619cf09 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 28 Jun 2026 00:57:36 +0800 Subject: [PATCH 68/78] fix(migrate): preserve the bunx --bun flag when rewriting to vp A `bunx --bun ` script (e.g. `bunx --bun vite build`) lost its `--bun` flag when the tool was rewritten to a vp subcommand, producing `bunx vp build` and silently switching the user's chosen runtime from Bun to Node. Stop stripping it so it becomes `bunx --bun vp build`, and drop the now-unused forced_bun_suffix_indices tracking. Update the vite_migration Rust unit tests and the JS rewritePackageJson vitest snapshot (package.json scripts are rewritten through the crate via NAPI). Claude-Session: https://claude.ai/code/session_01DQhS6o1fyQd1yjiee6W8jR --- crates/vite_migration/src/eslint.rs | 4 +-- crates/vite_migration/src/package.rs | 26 ++++++++++++------- crates/vite_migration/src/prettier.rs | 4 +-- crates/vite_migration/src/script_rewrite.rs | 24 +++++++---------- .../__snapshots__/migrator.spec.ts.snap | 20 +++++++------- 5 files changed, 39 insertions(+), 39 deletions(-) diff --git a/crates/vite_migration/src/eslint.rs b/crates/vite_migration/src/eslint.rs index 59f8dacc4b..2dfb8d96c2 100644 --- a/crates/vite_migration/src/eslint.rs +++ b/crates/vite_migration/src/eslint.rs @@ -161,11 +161,11 @@ mod tests { fn test_rewrite_eslint_bunx() { assert_eq!( rewrite_eslint_script("bunx --bun eslint --cache --fix ."), - "bunx vp lint --fix ." + "bunx --bun vp lint --fix ." ); assert_eq!( rewrite_eslint_script("dotenv -e .env -- bunx --bun eslint --ext .ts ."), - "dotenv -e .env -- bunx vp lint ." + "dotenv -e .env -- bunx --bun vp lint ." ); assert_eq!( rewrite_eslint_script("bunx --bun eslint-plugin-foo"), diff --git a/crates/vite_migration/src/package.rs b/crates/vite_migration/src/package.rs index aa570f13c6..511a288e8e 100644 --- a/crates/vite_migration/src/package.rs +++ b/crates/vite_migration/src/package.rs @@ -291,24 +291,30 @@ fix: vp pack rewrite_script("NODE_ENV=test oxlint --type-aware", &rules), "NODE_ENV=test vp lint --type-aware" ); - // bunx is preserved, but --bun is removed so vp runs through Node - assert_eq!(rewrite_script("bunx --bun vite build", &rules), "bunx vp build"); - assert_eq!(rewrite_script("bunx --bun vite preview", &rules), "bunx vp preview"); - assert_eq!(rewrite_script("bunx --bun vitest run", &rules), "bunx vp test run"); + // bunx and its --bun flag are preserved so the user's runtime choice survives + assert_eq!(rewrite_script("bunx --bun vite build", &rules), "bunx --bun vp build"); + assert_eq!(rewrite_script("bunx --bun vite preview", &rules), "bunx --bun vp preview"); + assert_eq!(rewrite_script("bunx --bun vitest run", &rules), "bunx --bun vp test run"); assert_eq!( rewrite_script("bunx --bun oxlint --type-aware", &rules), - "bunx vp lint --type-aware" + "bunx --bun vp lint --type-aware" ); - assert_eq!(rewrite_script("bunx --bun oxfmt --check .", &rules), "bunx vp fmt --check ."); - assert_eq!(rewrite_script("bunx --bun tsdown --watch", &rules), "bunx vp pack --watch"); - assert_eq!(rewrite_script("bunx --bun lint-staged", &rules), "bunx vp staged"); + assert_eq!( + rewrite_script("bunx --bun oxfmt --check .", &rules), + "bunx --bun vp fmt --check ." + ); + assert_eq!( + rewrite_script("bunx --bun tsdown --watch", &rules), + "bunx --bun vp pack --watch" + ); + assert_eq!(rewrite_script("bunx --bun lint-staged", &rules), "bunx --bun vp staged"); assert_eq!( rewrite_script("NODE_ENV=development portless --tailscale run bunx --bun vite", &rules,), - "NODE_ENV=development portless --tailscale run bunx vp dev" + "NODE_ENV=development portless --tailscale run bunx --bun vp dev" ); assert_eq!( rewrite_script("dotenv -e .env.test -- bunx --bun vitest run", &rules), - "dotenv -e .env.test -- bunx vp test run" + "dotenv -e .env.test -- bunx --bun vp test run" ); // unrelated executor calls and non-launcher arguments stay unchanged assert_eq!( diff --git a/crates/vite_migration/src/prettier.rs b/crates/vite_migration/src/prettier.rs index 63f4926816..db4ad7b0a1 100644 --- a/crates/vite_migration/src/prettier.rs +++ b/crates/vite_migration/src/prettier.rs @@ -195,11 +195,11 @@ mod tests { fn test_rewrite_prettier_bunx() { assert_eq!( rewrite_prettier_script("bunx --bun prettier --write --single-quote ."), - "bunx vp fmt ." + "bunx --bun vp fmt ." ); assert_eq!( rewrite_prettier_script("dotenv -e .env -- bunx --bun prettier --check ."), - "dotenv -e .env -- bunx vp fmt --check ." + "dotenv -e .env -- bunx --bun vp fmt --check ." ); assert_eq!( rewrite_prettier_script("bunx --bun prettier-plugin-foo"), diff --git a/crates/vite_migration/src/script_rewrite.rs b/crates/vite_migration/src/script_rewrite.rs index 52b842bbd3..26f8067562 100644 --- a/crates/vite_migration/src/script_rewrite.rs +++ b/crates/vite_migration/src/script_rewrite.rs @@ -170,7 +170,6 @@ struct CommandWord { struct BunxInvocation { target_suffix_index: usize, - forced_bun_suffix_indices: Vec, } fn collect_command_words(cmd: &ast::SimpleCommand) -> Vec { @@ -196,7 +195,7 @@ fn collect_command_words(cmd: &ast::SimpleCommand) -> Vec { words } -fn bunx_target(words: &[CommandWord], start: usize) -> Option<(usize, Vec)> { +fn bunx_target(words: &[CommandWord], start: usize) -> Option { let contiguous = |left: usize, right: usize| { words.get(left).zip(words.get(right)).is_some_and(|(a, b)| b.ordinal == a.ordinal + 1) }; @@ -206,15 +205,13 @@ fn bunx_target(words: &[CommandWord], start: usize) -> Option<(usize, Vec return None; } let mut target = next(start)?; - let mut forced_bun_suffix_indices = Vec::new(); + // Skip over `--bun` flags to locate the inner command target. The flags are + // preserved (not removed) so the user's runtime choice survives the rewrite. while words.get(target)?.value == "--bun" { - if let CommandWordPosition::Suffix(index) = words[target].position { - forced_bun_suffix_indices.push(index); - } target = next(target)?; } - Some((target, forced_bun_suffix_indices)) + Some(target) } fn find_bunx_invocations(cmd: &ast::SimpleCommand) -> Vec { @@ -222,7 +219,7 @@ fn find_bunx_invocations(cmd: &ast::SimpleCommand) -> Vec { let mut invocations = Vec::new(); for start in 0..words.len() { - let Some((target, forced_bun_suffix_indices)) = bunx_target(&words, start) else { + let Some(target) = bunx_target(&words, start) else { continue; }; let CommandWordPosition::Suffix(target_suffix_index) = words[target].position else { @@ -244,7 +241,7 @@ fn find_bunx_invocations(cmd: &ast::SimpleCommand) -> Vec { }), }; if allowed_position { - invocations.push(BunxInvocation { target_suffix_index, forced_bun_suffix_indices }); + invocations.push(BunxInvocation { target_suffix_index }); } } @@ -314,17 +311,14 @@ fn rewrite_bunx_in_simple_command( replacement_items.extend(inner_suffix.0); } suffix.0.splice(invocation.target_suffix_index.., replacement_items); - for index in invocation.forced_bun_suffix_indices.iter().rev() { - suffix.0.remove(*index); - } return true; } false } -/// Rewrite commands launched through `bunx`. The runner is preserved, but its -/// `--bun` flag is removed when the inner command becomes `vp` so Vite+ runs -/// through its Node runtime rather than being forced into Bun. +/// Rewrite commands launched through `bunx`. The runner and its `--bun` flag are +/// preserved when the inner command becomes `vp`, so the user's runtime choice +/// survives the rewrite (e.g. `bunx --bun vite build` → `bunx --bun vp build`). pub(crate) fn rewrite_bunx_commands( script: &str, mut rewrite_inner: impl FnMut(&str) -> String, diff --git a/packages/cli/src/migration/__tests__/__snapshots__/migrator.spec.ts.snap b/packages/cli/src/migration/__tests__/__snapshots__/migrator.spec.ts.snap index 7e62cab2dc..e62998d564 100644 --- a/packages/cli/src/migration/__tests__/__snapshots__/migrator.spec.ts.snap +++ b/packages/cli/src/migration/__tests__/__snapshots__/migrator.spec.ts.snap @@ -86,16 +86,16 @@ exports[`rewritePackageJson > should rewrite package.json scripts and extract st "test_run": "vp test run && vp test --ui", "version": "vp --version", "version_short": "vp -v", - "wrapped_build": "bunx vp build", - "wrapped_dev": "bunx vp dev", - "wrapped_fmt": "bunx vp fmt --check .", - "wrapped_lint": "bunx vp lint --type-aware", - "wrapped_nested_dev": "NODE_ENV=development portless --tailscale run bunx vp dev", - "wrapped_nested_test": "dotenv -e .env.test -- bunx vp test run", - "wrapped_pack": "bunx vp pack --watch", - "wrapped_preview": "bunx vp preview", - "wrapped_staged": "bunx vp staged", - "wrapped_test": "bunx vp test run", + "wrapped_build": "bunx --bun vp build", + "wrapped_dev": "bunx --bun vp dev", + "wrapped_fmt": "bunx --bun vp fmt --check .", + "wrapped_lint": "bunx --bun vp lint --type-aware", + "wrapped_nested_dev": "NODE_ENV=development portless --tailscale run bunx --bun vp dev", + "wrapped_nested_test": "dotenv -e .env.test -- bunx --bun vp test run", + "wrapped_pack": "bunx --bun vp pack --watch", + "wrapped_preview": "bunx --bun vp preview", + "wrapped_staged": "bunx --bun vp staged", + "wrapped_test": "bunx --bun vp test run", "wrapped_unrelated": "bunx --bun playwright test", }, } From 823decd627242a47be1484e729f49bd25c28ebfc Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 28 Jun 2026 01:17:13 +0800 Subject: [PATCH 69/78] fix(migrate): clear the progress spinner before the upgrade prompts In the existing-Vite+ upgrade path, ensureExistingPackageManager (the package manager download) starts the "Preparing migration" timer spinner, but nothing stopped it before the interactive setup prompts (collectMigrationSetupPlan). The live spinner kept re-rendering its timer line over the prompt and corrupted it. Clear it right after the download, matching the clearMigrationProgress pattern already used elsewhere in the flow; it restarts for the bootstrap/install phase. Claude-Session: https://claude.ai/code/session_01DQhS6o1fyQd1yjiee6W8jR --- packages/cli/src/migration/bin.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index 58d4681b17..bd45289932 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -1288,6 +1288,12 @@ async function main() { await ensureExistingPackageManager(); } + // The package-manager download above starts the "Preparing migration" + // spinner. Stop it before gathering interactive decisions below: a live + // spinner keeps re-rendering its timer line over the prompts and corrupts + // them (the spinner is restarted for the bootstrap/install phase). + clearMigrationProgress(); + // The early guard ran before the package manager was resolved. If it was // only determined to be Yarn afterwards (e.g. selected because the project // had no detectable manager), re-run the guard so a `YARN_NODE_LINKER=pnp` From 5e3e95edbe2b838628c196fdf00256e0c15652fe Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 28 Jun 2026 10:33:23 +0800 Subject: [PATCH 70/78] feat(migrate): upgrade below-range Node versions to the latest supported A project pinning a Node version below Vite+'s supported range (package.json engines.node: ^20.19.0 || ^22.18.0 || >=24.11.0), e.g. .node-version 24.3.0 or 24.2, made engine-strict skip the native binding's optional dependency ("Cannot find native binding"). During migrate, read the effective Node pin (reusing the vite_js_runtime resolver: .node-version -> devEngines.runtime -> engines.node; .nvmrc/Volta are converted to .node-version first) and, when it is an exact or major.minor pin below the range, rewrite it to the concrete latest release of that major (24.3.0/24.2 -> 24.18.0). A bare major or open range that resolves to a supported release is left unchanged. The supported range and latest-of-major resolution come from package.json engines.node via NAPI (no hardcoded range or per-major floors). Interactive migration confirms the upgrade (default yes), pausing the migration progress spinner so it does not animate beneath the prompt; non-interactive applies it directly. The two volta/nvmrc snap fixtures use supported versions to stay deterministic; the logic is covered by mocked unit tests. Docs synced in migrate-rules.md and the migrate RFCs. Claude-Session: https://claude.ai/code/session_01DQhS6o1fyQd1yjiee6W8jR --- Cargo.lock | 2 + crates/vite_js_runtime/src/lib.rs | 2 +- crates/vite_js_runtime/src/providers/mod.rs | 2 +- crates/vite_js_runtime/src/providers/node.rs | 2 +- docs/guide/migrate-rules.md | 39 +- packages/cli/binding/Cargo.toml | 2 + packages/cli/binding/index.cjs | 2 + packages/cli/binding/index.d.cts | 89 ++++ packages/cli/binding/src/migration.rs | 420 ++++++++++++++++++ .../migration-volta-with-nvmrc/.nvmrc | 2 +- .../migration-volta-with-nvmrc/snap.txt | 4 +- .../migration-volta-with-nvmrc/steps.json | 2 +- .../migration-volta/package.json | 2 +- .../migration-volta/snap.txt | 2 +- .../__tests__/node-version-upgrade.spec.ts | 243 ++++++++++ packages/cli/src/migration/bin.ts | 25 ++ packages/cli/src/migration/migrator/setup.ts | 151 ++++++- packages/cli/src/utils/constants.ts | 8 + rfcs/migrate-existing-projects.md | 14 + rfcs/migration-command.md | 12 +- 20 files changed, 998 insertions(+), 27 deletions(-) create mode 100644 packages/cli/src/migration/__tests__/node-version-upgrade.spec.ts diff --git a/Cargo.lock b/Cargo.lock index 7407ba2b66..ac5291aac0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8276,6 +8276,7 @@ dependencies = [ "napi", "napi-build", "napi-derive", + "node-semver", "owo-colors", "petgraph 0.8.3", "pretty_assertions", @@ -8288,6 +8289,7 @@ dependencies = [ "vite_command", "vite_error", "vite_install", + "vite_js_runtime", "vite_migration", "vite_path", "vite_pm_cli", diff --git a/crates/vite_js_runtime/src/lib.rs b/crates/vite_js_runtime/src/lib.rs index 461f622430..c98b4d48fb 100644 --- a/crates/vite_js_runtime/src/lib.rs +++ b/crates/vite_js_runtime/src/lib.rs @@ -61,7 +61,7 @@ pub use platform::{Arch, Os, Platform}; pub use provider::{ ArchiveFormat, DownloadInfo, HashVerification, JsRuntimeProvider, ShasumsSignature, }; -pub use providers::{LtsInfo, NodeProvider, NodeVersionEntry}; +pub use providers::{LtsInfo, NodeProvider, NodeVersionEntry, resolve_version_from_list}; pub use runtime::{ JsRuntime, JsRuntimeType, VersionResolution, VersionSource, download_runtime, download_runtime_for_project, download_runtime_with_provider, is_valid_version, diff --git a/crates/vite_js_runtime/src/providers/mod.rs b/crates/vite_js_runtime/src/providers/mod.rs index 96230597d7..866c88b415 100644 --- a/crates/vite_js_runtime/src/providers/mod.rs +++ b/crates/vite_js_runtime/src/providers/mod.rs @@ -5,4 +5,4 @@ mod node; -pub use node::{LtsInfo, NodeProvider, NodeVersionEntry}; +pub use node::{LtsInfo, NodeProvider, NodeVersionEntry, resolve_version_from_list}; diff --git a/crates/vite_js_runtime/src/providers/node.rs b/crates/vite_js_runtime/src/providers/node.rs index 88f9650d6c..e28261d3a1 100644 --- a/crates/vite_js_runtime/src/providers/node.rs +++ b/crates/vite_js_runtime/src/providers/node.rs @@ -497,7 +497,7 @@ fn find_absolute_latest_version(versions: &[NodeVersionEntry]) -> Result Result { diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 49189e0b5b..4f785f7b11 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -159,20 +159,37 @@ their arguments: `vite` to `vp dev` or the matching `vp` subcommand, `vitest` to `lint-staged` to `vp staged`. When their optional migrations run, `eslint` and `prettier` are similarly rewritten to `vp lint` and `vp fmt`. -For commands launched through `bunx`, migration preserves `bunx` and rewrites -the managed command. It removes `--bun` from rewritten commands so `vp` runs -through its Node runtime. This also works when `bunx` follows a command-launcher -delimiter such as `run` or `--`: - -| Before | After | -| ------------------------------------------------------- | -------------------------------------------------- | -| `bunx --bun vite build` | `bunx vp build` | -| `bunx --bun vitest run` | `bunx vp test run` | -| `portless --tailscale run bunx --bun vite` | `portless --tailscale run bunx vp dev` | -| `dotenv -e .env.test -- bunx --bun oxlint --type-aware` | `dotenv -e .env.test -- bunx vp lint --type-aware` | +For commands launched through `bunx`, migration preserves `bunx` and its +`--bun` flag (keeping the user's chosen runtime) and rewrites only the managed +command. This also works when `bunx` follows a command-launcher delimiter such +as `run` or `--`: + +| Before | After | +| ------------------------------------------------------- | -------------------------------------------------------- | +| `bunx --bun vite build` | `bunx --bun vp build` | +| `bunx --bun vitest run` | `bunx --bun vp test run` | +| `portless --tailscale run bunx --bun vite` | `portless --tailscale run bunx --bun vp dev` | +| `dotenv -e .env.test -- bunx --bun oxlint --type-aware` | `dotenv -e .env.test -- bunx --bun vp lint --type-aware` | Unrelated `bunx` commands and other package-executor forms remain unchanged. +## Node.js Version Rules + +Migration normalizes the project's Node.js pin: + +- `.nvmrc` and Volta `volta.node` pins are converted to `.node-version` (the + format Vite+ reads). An existing `.node-version` is kept. +- The effective pin (resolved with the `.node-version` → `devEngines.runtime` → + `engines.node` priority) is checked against the Vite+ supported range + (`package.json#engines.node`). An exact or `major.minor` pin below that range, + for example `24.3.0` or `24.2` (below `>=24.11.0`), is upgraded to the + concrete latest release of that major, for example `24.18.0`. An unsupported + Node otherwise makes the package manager skip the native binding's optional + dependency. A bare major (`24`) or an open range (`^20`, `>=18`) that can + still resolve to a supported release is left unchanged. +- Interactive migration confirms the upgrade (default yes); `--no-interactive` + applies it directly. + ## Package-Manager Rules ### pnpm diff --git a/packages/cli/binding/Cargo.toml b/packages/cli/binding/Cargo.toml index 50ebbb5648..c54332237b 100644 --- a/packages/cli/binding/Cargo.toml +++ b/packages/cli/binding/Cargo.toml @@ -16,6 +16,7 @@ async-trait = { workspace = true } clap = { workspace = true, features = ["derive"] } cow-utils = { workspace = true } fspy = { workspace = true } +node-semver = { workspace = true } rustc-hash = { workspace = true } napi = { workspace = true } napi-derive = { workspace = true } @@ -28,6 +29,7 @@ tracing = { workspace = true } vite_command = { workspace = true } vite_error = { workspace = true } vite_install = { workspace = true } +vite_js_runtime = { workspace = true } vite_migration = { workspace = true } vite_pm_cli = { workspace = true } vite_path = { workspace = true } diff --git a/packages/cli/binding/index.cjs b/packages/cli/binding/index.cjs index 73ace23db9..c88b246a9c 100644 --- a/packages/cli/binding/index.cjs +++ b/packages/cli/binding/index.cjs @@ -853,6 +853,8 @@ module.exports.downloadPackageManager = nativeBinding.downloadPackageManager; module.exports.hasConfigKey = nativeBinding.hasConfigKey; module.exports.mergeJsonConfig = nativeBinding.mergeJsonConfig; module.exports.mergeTsdownConfig = nativeBinding.mergeTsdownConfig; +module.exports.resolveProjectNodeVersion = nativeBinding.resolveProjectNodeVersion; +module.exports.resolveSupportedNodeVersion = nativeBinding.resolveSupportedNodeVersion; module.exports.rewriteEslint = nativeBinding.rewriteEslint; module.exports.rewriteImportsInDirectory = nativeBinding.rewriteImportsInDirectory; module.exports.rewritePrettier = nativeBinding.rewritePrettier; diff --git a/packages/cli/binding/index.d.cts b/packages/cli/binding/index.d.cts index 5d1ad7d870..dd3d86646e 100644 --- a/packages/cli/binding/index.d.cts +++ b/packages/cli/binding/index.d.cts @@ -3496,6 +3496,95 @@ export interface PathAccess { readDir: boolean; } +/** The effective Node.js version pin resolved from a project's configuration. */ +export interface ProjectNodeVersion { + /** The pinned version string, exactly as written in the source. */ + version: string; + /** + * Which source the pin came from: `"node-version-file"`, + * `"dev-engines-runtime"`, or `"engines-node"`. + */ + source: string; + /** + * Absolute path to the file the pin was read from (the `.node-version` + * file or the `package.json`). + */ + sourcePath: string; +} + +/** + * Resolve the single effective Node.js version pin for a project, reusing the + * shared Rust resolver so the JS migrator does not re-implement source + * detection. + * + * Checks, in priority order (see `rfcs/dev-engines.md`): + * 1. `.node-version` + * 2. `package.json#devEngines.runtime[name="node"].version` + * 3. `package.json#engines.node` + * + * Does not walk up to parent directories: the migrator operates on the project + * root it was given. + * + * # Arguments + * + * * `project_path` - Absolute path to the project directory + * + * # Returns + * + * * `Some(ProjectNodeVersion)` - the effective pin, its source label, and the + * absolute source path + * * `None` - when no version source is found + * + * # Example + * + * ```javascript + * const pin = await resolveProjectNodeVersion('/path/to/project'); + * // pin === { version: '24.3.0', source: 'node-version-file', sourcePath: '/path/to/project/.node-version' } + * ``` + */ +export declare function resolveProjectNodeVersion( + projectPath: string, +): Promise; + +/** + * Resolve a Node.js version that is below Vite+'s supported range to the + * concrete latest release of the same major. + * + * Engine-strict installers skip the native optional dependency under an + * unsupported Node.js version (causing "Cannot find native binding"), so + * `vp migrate` uses this to bump a too-old pin up to a supported release of the + * same major line. + * + * # Arguments + * + * * `current` - The pinned Node.js version, treated as a semver range so + * partials are accepted (e.g. `24.3.0`, `24.2`, `24`, optionally `v`-prefixed) + * * `supported_range` - The Vite+-supported Node.js range, sourced from the + * `engines.node` field in `package.json` (e.g. + * `^20.19.0 || ^22.18.0 || >=24.11.0`). This is the only source of truth for + * what is supported. + * + * # Returns + * + * * `Some(latest)` - The concrete latest supported release of `current`'s major + * (e.g. `24.18.0`) when `current`'s range cannot resolve to any supported + * version but its major has a supported release + * * `None` - When `current`'s range can already resolve to a supported version + * (e.g. `24`, `24.11`), cannot be parsed (e.g. `lts/*`), or belongs to an + * unsupported major (e.g. `21`, `23`) + * + * # Example + * + * ```javascript + * const upgraded = await resolveSupportedNodeVersion('24.3.0', '^20.19.0 || ^22.18.0 || >=24.11.0'); + * // upgraded === '24.18.0' (latest 24.x at the time of resolution) + * ``` + */ +export declare function resolveSupportedNodeVersion( + current: string, + supportedRange: string, +): Promise; + /** * Rewrite ESLint scripts: rename `eslint` → `vp lint` and strip ESLint-only flags. * diff --git a/packages/cli/binding/src/migration.rs b/packages/cli/binding/src/migration.rs index 9306f7b79d..363f4d306d 100644 --- a/packages/cli/binding/src/migration.rs +++ b/packages/cli/binding/src/migration.rs @@ -2,6 +2,200 @@ use std::path::Path; use napi::{anyhow, bindgen_prelude::*}; use napi_derive::napi; +use node_semver::{Range, Version}; +use vite_js_runtime::{NodeProvider, VersionSource, resolve_node_version}; +use vite_path::AbsolutePathBuf; + +/// Compute the semver requirement that selects the latest release of the same +/// major as `current`. +/// +/// `supported_range` is the Vite+-supported Node.js range, sourced from the +/// `engines.node` field in `package.json` (e.g. +/// `^20.19.0 || ^22.18.0 || >=24.11.0`). It is the only source of truth: there +/// are no hardcoded per-major floors here. +/// +/// `current` is treated as a semver **range**, so partial pins like `24` or +/// `24.2` are accepted (a leading `v` is tolerated). `node-semver` expands a +/// bare partial to its implied range: `24` → `>=24.0.0 <25.0.0`, `24.2` → +/// `>=24.2.0 <24.3.0`, an exact `24.3.0` → just `24.3.0`. +/// +/// Returns `None` when: +/// - `current` cannot be parsed as a range (a true alias like `lts/*` or +/// garbage), or +/// - `current`'s range overlaps `supported_range` — i.e. the pin can already +/// resolve to a supported version, so it is left untouched. This covers a +/// bare major such as `24` (`>=24.0.0 <25.0.0` overlaps `>=24.11.0`) and a +/// partial that is already in range such as `24.11`. +/// +/// Otherwise returns a constrained range like `>=24.0.0 <25.0.0` that, when +/// resolved against the Node.js release index, yields the latest release of that +/// major. The resolved version is verified against `supported_range` separately, +/// which is what rejects unsupported majors (e.g. 21 or 23). +fn supported_node_requirement(current: &str, supported_range: &Range) -> Option { + let normalized = current.strip_prefix('v').unwrap_or(current); + + // Treat the pin as a range so partials ("24", "24.2") are accepted. A true + // alias ("lts/*") or non-version string fails to parse and is left as-is. + let current_range = Range::parse(normalized).ok()?; + + // The pin can resolve to a supported version (its range overlaps the + // supported range) — nothing to upgrade (and never hits the network). + if supported_range.allows_any(¤t_range) { + return None; + } + + // Below/outside the supported range: target the latest release of the same + // major, taken from the leading numeric component (e.g. "24.2" → 24). + let major: u64 = normalized.split('.').next()?.parse().ok()?; + Some(format!(">={major}.0.0 <{}.0.0", major + 1)) +} + +/// Resolve the latest supported Node.js release matching `current`'s major from +/// an explicit version list, verifying the result against `supported_range`. +/// Shared by the NAPI entry point and unit tests. +#[cfg(test)] +fn resolve_supported_node_version_from_list( + current: &str, + supported_range: &str, + versions: &[vite_js_runtime::NodeVersionEntry], +) -> Option { + let supported = Range::parse(supported_range).ok()?; + let requirement = supported_node_requirement(current, &supported)?; + let resolved = + vite_js_runtime::resolve_version_from_list(&requirement, versions).ok()?.to_string(); + // Verify the resolved version actually satisfies the supported range. An + // unsupported major (e.g. 21 or 23) resolves to a concrete release but must + // not be returned. + Version::parse(resolved.as_str()).ok().filter(|v| supported.satisfies(v)).map(|_| resolved) +} + +/// Resolve a Node.js version that is below Vite+'s supported range to the +/// concrete latest release of the same major. +/// +/// Engine-strict installers skip the native optional dependency under an +/// unsupported Node.js version (causing "Cannot find native binding"), so +/// `vp migrate` uses this to bump a too-old pin up to a supported release of the +/// same major line. +/// +/// # Arguments +/// +/// * `current` - The pinned Node.js version, treated as a semver range so +/// partials are accepted (e.g. `24.3.0`, `24.2`, `24`, optionally `v`-prefixed) +/// * `supported_range` - The Vite+-supported Node.js range, sourced from the +/// `engines.node` field in `package.json` (e.g. +/// `^20.19.0 || ^22.18.0 || >=24.11.0`). This is the only source of truth for +/// what is supported. +/// +/// # Returns +/// +/// * `Some(latest)` - The concrete latest supported release of `current`'s major +/// (e.g. `24.18.0`) when `current`'s range cannot resolve to any supported +/// version but its major has a supported release +/// * `None` - When `current`'s range can already resolve to a supported version +/// (e.g. `24`, `24.11`), cannot be parsed (e.g. `lts/*`), or belongs to an +/// unsupported major (e.g. `21`, `23`) +/// +/// # Example +/// +/// ```javascript +/// const upgraded = await resolveSupportedNodeVersion('24.3.0', '^20.19.0 || ^22.18.0 || >=24.11.0'); +/// // upgraded === '24.18.0' (latest 24.x at the time of resolution) +/// ``` +#[napi] +pub async fn resolve_supported_node_version( + current: String, + supported_range: String, +) -> Result> { + let Ok(supported) = Range::parse(&supported_range) else { + return Ok(None); + }; + let Some(requirement) = supported_node_requirement(¤t, &supported) else { + return Ok(None); + }; + + let provider = NodeProvider::new(); + let latest = provider.resolve_version(&requirement).await.map_err(anyhow::Error::from)?; + let latest = latest.to_string(); + + // Verify the resolved version is actually supported. An unsupported major + // (e.g. 21 or 23) resolves to a concrete release but must not be returned. + match Version::parse(latest.as_str()) { + Ok(version) if supported.satisfies(&version) => Ok(Some(latest)), + _ => Ok(None), + } +} + +/// Stable string label for a [`VersionSource`], used as the `source` field of +/// [`resolve_project_node_version`]'s result so the JS migrator can branch on a +/// fixed value instead of the human-facing `Display` string. +fn version_source_label(source: VersionSource) -> &'static str { + match source { + VersionSource::NodeVersionFile => "node-version-file", + VersionSource::DevEnginesRuntime => "dev-engines-runtime", + VersionSource::EnginesNode => "engines-node", + } +} + +/// The effective Node.js version pin resolved from a project's configuration. +#[napi(object)] +pub struct ProjectNodeVersion { + /// The pinned version string, exactly as written in the source. + pub version: String, + /// Which source the pin came from: `"node-version-file"`, + /// `"dev-engines-runtime"`, or `"engines-node"`. + pub source: String, + /// Absolute path to the file the pin was read from (the `.node-version` + /// file or the `package.json`). + pub source_path: String, +} + +/// Resolve the single effective Node.js version pin for a project, reusing the +/// shared Rust resolver so the JS migrator does not re-implement source +/// detection. +/// +/// Checks, in priority order (see `rfcs/dev-engines.md`): +/// 1. `.node-version` +/// 2. `package.json#devEngines.runtime[name="node"].version` +/// 3. `package.json#engines.node` +/// +/// Does not walk up to parent directories: the migrator operates on the project +/// root it was given. +/// +/// # Arguments +/// +/// * `project_path` - Absolute path to the project directory +/// +/// # Returns +/// +/// * `Some(ProjectNodeVersion)` - the effective pin, its source label, and the +/// absolute source path +/// * `None` - when no version source is found +/// +/// # Example +/// +/// ```javascript +/// const pin = await resolveProjectNodeVersion('/path/to/project'); +/// // pin === { version: '24.3.0', source: 'node-version-file', sourcePath: '/path/to/project/.node-version' } +/// ``` +#[napi] +pub async fn resolve_project_node_version( + project_path: String, +) -> Result> { + let project_path = AbsolutePathBuf::new(project_path.into()) + .ok_or_else(|| napi::Error::from_reason("invalid project path"))?; + + let resolution = + resolve_node_version(&project_path, false).await.map_err(anyhow::Error::from)?; + + Ok(resolution.map(|r| ProjectNodeVersion { + version: r.version.to_string(), + source: version_source_label(r.source).to_string(), + source_path: r + .source_path + .map(|p| p.as_path().to_string_lossy().to_string()) + .unwrap_or_default(), + })) +} /// Rewrite scripts json content using rules from rules_yaml /// @@ -320,3 +514,229 @@ pub fn rewrite_imports_in_directory( .collect(), }) } + +#[cfg(test)] +mod tests { + use vite_js_runtime::{LtsInfo, NodeVersionEntry}; + + use super::*; + + /// The Vite+-supported Node.js range used as test input. Mirrors the + /// `engines.node` field shipped in `packages/cli/package.json`. + const SUPPORTED_RANGE: &str = "^20.19.0 || ^22.18.0 || >=24.11.0"; + + /// A mock Node.js release index spanning several majors, mirroring the + /// shape used in `vite_js_runtime`'s own `resolve_version_from_list` tests. + fn mock_versions() -> Vec { + vec![ + NodeVersionEntry { + version: "v24.18.0".into(), + lts: LtsInfo::Codename("Krypton".into()), + }, + NodeVersionEntry { + version: "v24.11.0".into(), + lts: LtsInfo::Codename("Krypton".into()), + }, + NodeVersionEntry { version: "v24.3.0".into(), lts: LtsInfo::Boolean(false) }, + NodeVersionEntry { version: "v23.5.0".into(), lts: LtsInfo::Boolean(false) }, + NodeVersionEntry { version: "v22.18.0".into(), lts: LtsInfo::Codename("Jod".into()) }, + NodeVersionEntry { version: "v22.10.0".into(), lts: LtsInfo::Codename("Jod".into()) }, + NodeVersionEntry { version: "v21.5.0".into(), lts: LtsInfo::Boolean(false) }, + NodeVersionEntry { version: "v20.19.0".into(), lts: LtsInfo::Codename("Iron".into()) }, + NodeVersionEntry { version: "v20.11.0".into(), lts: LtsInfo::Codename("Iron".into()) }, + ] + } + + #[test] + fn version_source_label_is_stable() { + // These labels are part of the JS<->Rust contract; the JS migrator + // branches on them, so they must stay fixed. + assert_eq!(version_source_label(VersionSource::NodeVersionFile), "node-version-file"); + assert_eq!(version_source_label(VersionSource::DevEnginesRuntime), "dev-engines-runtime"); + assert_eq!(version_source_label(VersionSource::EnginesNode), "engines-node"); + } + + #[test] + fn upgrades_below_range_major_24() { + // 24.3.0 is below the 24.11.0 floor → latest 24.x (24.18.0). + let result = + resolve_supported_node_version_from_list("24.3.0", SUPPORTED_RANGE, &mock_versions()); + assert_eq!(result.as_deref(), Some("24.18.0")); + } + + #[test] + fn leaves_supported_major_24_unchanged() { + // 24.11.0 already satisfies `>=24.11.0`. + let result = + resolve_supported_node_version_from_list("24.11.0", SUPPORTED_RANGE, &mock_versions()); + assert_eq!(result, None); + } + + #[test] + fn leaves_supported_major_22_unchanged() { + // 22.18.0 already satisfies `^22.18.0`. + let result = + resolve_supported_node_version_from_list("22.18.0", SUPPORTED_RANGE, &mock_versions()); + assert_eq!(result, None); + } + + #[test] + fn upgrades_below_range_major_20() { + // 20.10.0 is below the 20.19.0 floor → latest 20.x (20.19.0). + let result = + resolve_supported_node_version_from_list("20.10.0", SUPPORTED_RANGE, &mock_versions()); + assert_eq!(result.as_deref(), Some("20.19.0")); + } + + #[test] + fn skips_unsupported_major_21() { + // Major 21 is not part of the supported range; the resolved release + // fails the verify-against-range step, so it is never upgraded. + let result = + resolve_supported_node_version_from_list("21.5.0", SUPPORTED_RANGE, &mock_versions()); + assert_eq!(result, None); + } + + #[test] + fn skips_unsupported_major_23() { + // Major 23 is not part of the supported range; the resolved release + // fails the verify-against-range step, so it is never upgraded. + let result = + resolve_supported_node_version_from_list("23.5.0", SUPPORTED_RANGE, &mock_versions()); + assert_eq!(result, None); + } + + #[test] + fn skips_non_semver_input() { + assert_eq!( + resolve_supported_node_version_from_list("lts/*", SUPPORTED_RANGE, &mock_versions()), + None + ); + assert_eq!( + resolve_supported_node_version_from_list("^24.3.0", SUPPORTED_RANGE, &mock_versions()), + None + ); + assert_eq!( + resolve_supported_node_version_from_list( + "not-a-version", + SUPPORTED_RANGE, + &mock_versions() + ), + None + ); + assert_eq!( + resolve_supported_node_version_from_list("", SUPPORTED_RANGE, &mock_versions()), + None + ); + } + + #[test] + fn tolerates_leading_v_prefix() { + // A `v`-prefixed exact version is normalized before resolving. + let result = + resolve_supported_node_version_from_list("v24.3.0", SUPPORTED_RANGE, &mock_versions()); + assert_eq!(result.as_deref(), Some("24.18.0")); + } + + #[test] + fn partial_pin_bare_major_left_unchanged() { + // "24" → >=24.0.0 <25.0.0 overlaps the supported >=24.11.0, so it can + // resolve to a supported version → leave it. + assert_eq!( + resolve_supported_node_version_from_list("24", SUPPORTED_RANGE, &mock_versions()), + None + ); + // "20" → >=20.0.0 <21.0.0 overlaps ^20.19.0 → leave it. + assert_eq!( + resolve_supported_node_version_from_list("20", SUPPORTED_RANGE, &mock_versions()), + None + ); + } + + #[test] + fn partial_pin_below_range_upgrades_to_latest_of_major() { + // "24.2" → >=24.2.0 <24.3.0 cannot reach >=24.11.0 → latest 24.x. + assert_eq!( + resolve_supported_node_version_from_list("24.2", SUPPORTED_RANGE, &mock_versions()) + .as_deref(), + Some("24.18.0") + ); + // "20.5" → >=20.5.0 <20.6.0 cannot reach ^20.19.0 → latest 20.x. + assert_eq!( + resolve_supported_node_version_from_list("20.5", SUPPORTED_RANGE, &mock_versions()) + .as_deref(), + Some("20.19.0") + ); + } + + #[test] + fn partial_pin_in_range_left_unchanged() { + // "24.11" → >=24.11.0 <24.12.0 is a subset of >=24.11.0 → leave it. + assert_eq!( + resolve_supported_node_version_from_list("24.11", SUPPORTED_RANGE, &mock_versions()), + None + ); + } + + #[test] + fn partial_pin_unsupported_major_left_unchanged() { + // "21.5" → >=21.5.0 <21.6.0 has no supported release → None. + assert_eq!( + resolve_supported_node_version_from_list("21.5", SUPPORTED_RANGE, &mock_versions()), + None + ); + // Bare unsupported major "21" → resolves latest 21.x, fails verify → None. + assert_eq!( + resolve_supported_node_version_from_list("21", SUPPORTED_RANGE, &mock_versions()), + None + ); + } + + #[test] + fn exact_pin_below_range_upgrades_and_already_supported_left() { + // exact "24.3.0" → no overlap → latest 24.x. + assert_eq!( + resolve_supported_node_version_from_list("24.3.0", SUPPORTED_RANGE, &mock_versions()) + .as_deref(), + Some("24.18.0") + ); + // exact already-supported "24.18.0" → overlaps → leave it. + assert_eq!( + resolve_supported_node_version_from_list("24.18.0", SUPPORTED_RANGE, &mock_versions()), + None + ); + } + + #[test] + fn requirement_targets_same_major_bracket() { + let range = Range::parse(SUPPORTED_RANGE).unwrap(); + // The requirement brackets the whole major; verification against the + // range happens after resolution, not here. + assert_eq!( + supported_node_requirement("24.3.0", &range).as_deref(), + Some(">=24.0.0 <25.0.0") + ); + assert_eq!( + supported_node_requirement("20.10.0", &range).as_deref(), + Some(">=20.0.0 <21.0.0") + ); + assert_eq!( + supported_node_requirement("22.5.0", &range).as_deref(), + Some(">=22.0.0 <23.0.0") + ); + // Unsupported majors still produce a major bracket; only the later + // verify-against-range step rejects them. + assert_eq!( + supported_node_requirement("21.5.0", &range).as_deref(), + Some(">=21.0.0 <22.0.0") + ); + assert_eq!( + supported_node_requirement("23.5.0", &range).as_deref(), + Some(">=23.0.0 <24.0.0") + ); + // Majors above 24 already satisfy `>=24.11.0`, so they are reported as + // supported (no upgrade) before a requirement is computed. + assert_eq!(supported_node_requirement("26.0.0", &range), None); + assert_eq!(supported_node_requirement("25.0.0", &range), None); + } +} diff --git a/packages/cli/snap-tests-global/migration-volta-with-nvmrc/.nvmrc b/packages/cli/snap-tests-global/migration-volta-with-nvmrc/.nvmrc index 1df6fd41c7..5f53e875de 100644 --- a/packages/cli/snap-tests-global/migration-volta-with-nvmrc/.nvmrc +++ b/packages/cli/snap-tests-global/migration-volta-with-nvmrc/.nvmrc @@ -1 +1 @@ -v20.5.0 +v20.19.0 diff --git a/packages/cli/snap-tests-global/migration-volta-with-nvmrc/snap.txt b/packages/cli/snap-tests-global/migration-volta-with-nvmrc/snap.txt index 329c663b0b..cbc7cdd7f9 100644 --- a/packages/cli/snap-tests-global/migration-volta-with-nvmrc/snap.txt +++ b/packages/cli/snap-tests-global/migration-volta-with-nvmrc/snap.txt @@ -6,8 +6,8 @@ → Manual follow-up: - Remove the "volta" field from package.json -> cat .node-version # check .node-version comes from .nvmrc (v20.5.0), not volta.node (18.0.0) -20.5.0 +> cat .node-version # check .node-version comes from .nvmrc (v20.19.0), not volta.node (18.0.0) +20.19.0 > test ! -f .nvmrc # check .nvmrc is removed > grep '"volta"' package.json # volta field must remain intact diff --git a/packages/cli/snap-tests-global/migration-volta-with-nvmrc/steps.json b/packages/cli/snap-tests-global/migration-volta-with-nvmrc/steps.json index 498900d554..a1c1e3b51a 100644 --- a/packages/cli/snap-tests-global/migration-volta-with-nvmrc/steps.json +++ b/packages/cli/snap-tests-global/migration-volta-with-nvmrc/steps.json @@ -1,7 +1,7 @@ { "commands": [ "vp migrate --no-interactive # .nvmrc should take priority over volta.node", - "cat .node-version # check .node-version comes from .nvmrc (v20.5.0), not volta.node (18.0.0)", + "cat .node-version # check .node-version comes from .nvmrc (v20.19.0), not volta.node (18.0.0)", "test ! -f .nvmrc # check .nvmrc is removed", "grep '\"volta\"' package.json # volta field must remain intact" ] diff --git a/packages/cli/snap-tests-global/migration-volta/package.json b/packages/cli/snap-tests-global/migration-volta/package.json index d8f6102082..2b9893f340 100644 --- a/packages/cli/snap-tests-global/migration-volta/package.json +++ b/packages/cli/snap-tests-global/migration-volta/package.json @@ -4,7 +4,7 @@ "vite": "^7.0.0" }, "volta": { - "node": "20.5.0", + "node": "20.19.0", "npm": "10.2.5" } } diff --git a/packages/cli/snap-tests-global/migration-volta/snap.txt b/packages/cli/snap-tests-global/migration-volta/snap.txt index 6b1fb3a1ff..4e8c1ab85b 100644 --- a/packages/cli/snap-tests-global/migration-volta/snap.txt +++ b/packages/cli/snap-tests-global/migration-volta/snap.txt @@ -7,7 +7,7 @@ - Remove the "volta" field from package.json > cat .node-version # check .node-version is created from volta.node -20.5.0 +20.19.0 > grep '"volta"' package.json # check volta field is preserved in package.json (not removed) "volta": { diff --git a/packages/cli/src/migration/__tests__/node-version-upgrade.spec.ts b/packages/cli/src/migration/__tests__/node-version-upgrade.spec.ts new file mode 100644 index 0000000000..8f46efa801 --- /dev/null +++ b/packages/cli/src/migration/__tests__/node-version-upgrade.spec.ts @@ -0,0 +1,243 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createMigrationReport } from '../report.js'; + +// Hoisted mock fns so the vi.mock factories (hoisted above imports) can close +// over them and tests can program/inspect them per case. +const { resolveProjectNodeVersion, resolveSupportedNodeVersion, confirm } = vi.hoisted(() => ({ + // resolveProjectNodeVersion → the effective pin { version, source, sourcePath }. + resolveProjectNodeVersion: vi.fn(), + // resolveSupportedNodeVersion → the upgrade target, or null when in-range. + resolveSupportedNodeVersion: vi.fn(), + // prompts.confirm → the interactive Yes/No answer. + confirm: vi.fn(), +})); + +// Partially mock the native binding: keep every real export, but stub the two +// Node-version resolvers so reading/range-intersection is fully driven by tests. +vi.mock('../../../binding/index.js', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, resolveProjectNodeVersion, resolveSupportedNodeVersion }; +}); + +// Partially mock the prompts module: keep everything real (incl. the real +// isCancel, which returns false for plain booleans) but stub confirm. +vi.mock('@voidzero-dev/vite-plus-prompts', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, confirm }; +}); + +const { upgradeUnsupportedNodeVersions } = await import('../migrator/setup.js'); + +const tempDirs: string[] = []; +function makeTempDir() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-node-upgrade-')); + tempDirs.push(dir); + return dir; +} + +function readPkg(dir: string): Record { + return JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); +} + +beforeEach(() => { + resolveProjectNodeVersion.mockReset(); + resolveSupportedNodeVersion.mockReset(); + confirm.mockReset(); + confirm.mockResolvedValue(true); +}); + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('upgradeUnsupportedNodeVersions', () => { + it('upgrades a below-range .node-version (24.2 → 24.18.0) without prompting in non-interactive mode', async () => { + const dir = makeTempDir(); + const sourcePath = path.join(dir, '.node-version'); + fs.writeFileSync(sourcePath, '24.2\n'); + resolveProjectNodeVersion.mockResolvedValue({ + version: '24.2', + source: 'node-version-file', + sourcePath, + }); + resolveSupportedNodeVersion.mockResolvedValue('24.18.0'); + const report = createMigrationReport(); + + const changed = await upgradeUnsupportedNodeVersions(dir, false, report); + + expect(changed).toBe(true); + expect(confirm).not.toHaveBeenCalled(); + expect(fs.readFileSync(sourcePath, 'utf8')).toBe('24.18.0\n'); + expect(report.warnings).toContain( + 'Upgraded Node.js 24.2 to 24.18.0 (below the supported range)', + ); + }); + + it('upgrades when interactive and the user confirms', async () => { + const dir = makeTempDir(); + const sourcePath = path.join(dir, '.node-version'); + fs.writeFileSync(sourcePath, '24.3.0\n'); + resolveProjectNodeVersion.mockResolvedValue({ + version: '24.3.0', + source: 'node-version-file', + sourcePath, + }); + resolveSupportedNodeVersion.mockResolvedValue('24.18.0'); + confirm.mockResolvedValue(true); + + const changed = await upgradeUnsupportedNodeVersions(dir, true); + + expect(confirm).toHaveBeenCalledTimes(1); + expect(changed).toBe(true); + expect(fs.readFileSync(sourcePath, 'utf8')).toBe('24.18.0\n'); + }); + + it('leaves the pin unchanged when interactive and the user declines', async () => { + const dir = makeTempDir(); + const sourcePath = path.join(dir, '.node-version'); + fs.writeFileSync(sourcePath, '24.3.0\n'); + resolveProjectNodeVersion.mockResolvedValue({ + version: '24.3.0', + source: 'node-version-file', + sourcePath, + }); + resolveSupportedNodeVersion.mockResolvedValue('24.18.0'); + confirm.mockResolvedValue(false); + const report = createMigrationReport(); + + const changed = await upgradeUnsupportedNodeVersions(dir, true, report); + + expect(confirm).toHaveBeenCalledTimes(1); + expect(changed).toBe(false); + expect(fs.readFileSync(sourcePath, 'utf8')).toBe('24.3.0\n'); + expect(report.warnings).toHaveLength(0); + }); + + it('writes nothing when the resolved pin is already supported', async () => { + const dir = makeTempDir(); + const sourcePath = path.join(dir, '.node-version'); + fs.writeFileSync(sourcePath, '24.18.0\n'); + resolveProjectNodeVersion.mockResolvedValue({ + version: '24.18.0', + source: 'node-version-file', + sourcePath, + }); + // In-range → the binding reports no upgrade. + resolveSupportedNodeVersion.mockResolvedValue(null); + const report = createMigrationReport(); + + const changed = await upgradeUnsupportedNodeVersions(dir, false, report); + + expect(changed).toBe(false); + expect(confirm).not.toHaveBeenCalled(); + expect(fs.readFileSync(sourcePath, 'utf8')).toBe('24.18.0\n'); + expect(report.warnings).toHaveLength(0); + }); + + it('writes nothing when no version source is found', async () => { + const dir = makeTempDir(); + resolveProjectNodeVersion.mockResolvedValue(null); + + const changed = await upgradeUnsupportedNodeVersions(dir, false); + + expect(changed).toBe(false); + expect(resolveSupportedNodeVersion).not.toHaveBeenCalled(); + }); + + it('pauses the migration progress spinner before the confirm prompt renders', async () => { + const dir = makeTempDir(); + const sourcePath = path.join(dir, '.node-version'); + fs.writeFileSync(sourcePath, '24.3.0\n'); + resolveProjectNodeVersion.mockResolvedValue({ + version: '24.3.0', + source: 'node-version-file', + sourcePath, + }); + resolveSupportedNodeVersion.mockResolvedValue('24.18.0'); + + // Record call order: the spinner must be cleared BEFORE confirm renders, + // otherwise it animates underneath the prompt. + const order: string[] = []; + const pauseProgress = vi.fn(() => order.push('pause')); + confirm.mockImplementation(async () => { + order.push('confirm'); + return true; + }); + + await upgradeUnsupportedNodeVersions(dir, true, undefined, pauseProgress); + + expect(pauseProgress).toHaveBeenCalledTimes(1); + expect(order).toEqual(['pause', 'confirm']); + }); + + it('does not pause the progress spinner when non-interactive (no prompt)', async () => { + const dir = makeTempDir(); + const sourcePath = path.join(dir, '.node-version'); + fs.writeFileSync(sourcePath, '24.3.0\n'); + resolveProjectNodeVersion.mockResolvedValue({ + version: '24.3.0', + source: 'node-version-file', + sourcePath, + }); + resolveSupportedNodeVersion.mockResolvedValue('24.18.0'); + const pauseProgress = vi.fn(); + + await upgradeUnsupportedNodeVersions(dir, false, undefined, pauseProgress); + + expect(pauseProgress).not.toHaveBeenCalled(); + expect(confirm).not.toHaveBeenCalled(); + }); + + it('upgrades an engines.node pin in package.json', async () => { + const dir = makeTempDir(); + const sourcePath = path.join(dir, 'package.json'); + fs.writeFileSync( + sourcePath, + `${JSON.stringify({ name: 'x', engines: { node: '24.3.0' } }, null, 2)}\n`, + ); + resolveProjectNodeVersion.mockResolvedValue({ + version: '24.3.0', + source: 'engines-node', + sourcePath, + }); + resolveSupportedNodeVersion.mockResolvedValue('24.18.0'); + + const changed = await upgradeUnsupportedNodeVersions(dir, false); + + expect(changed).toBe(true); + expect((readPkg(dir).engines as { node: string }).node).toBe('24.18.0'); + }); + + it('upgrades a devEngines.runtime node entry in package.json', async () => { + const dir = makeTempDir(); + const sourcePath = path.join(dir, 'package.json'); + fs.writeFileSync( + sourcePath, + `${JSON.stringify( + { name: 'x', devEngines: { runtime: [{ name: 'node', version: '24.3.0' }] } }, + null, + 2, + )}\n`, + ); + resolveProjectNodeVersion.mockResolvedValue({ + version: '24.3.0', + source: 'dev-engines-runtime', + sourcePath, + }); + resolveSupportedNodeVersion.mockResolvedValue('24.18.0'); + + const changed = await upgradeUnsupportedNodeVersions(dir, false); + + expect(changed).toBe(true); + expect( + (readPkg(dir).devEngines as { runtime: Array<{ version: string }> }).runtime[0].version, + ).toBe('24.18.0'); + }); +}); diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index bd45289932..d9becfbcf4 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -75,6 +75,7 @@ import { preflightGitHooksSetup, rewriteMonorepo, rewriteStandaloneProject, + upgradeUnsupportedNodeVersions, warnIncompatibleEslintIntegration, warnLegacyEslintConfig, warnPackageLevelEslint, @@ -1008,6 +1009,17 @@ async function executeMigrationPlan( migrateNodeVersionManagerFile(workspaceInfo.rootDir, plan.nodeVersionDetection, report); } + // 3b. Upgrade any Node.js pin below the Vite+ supported range to the latest + // release of the same major. Runs independently of the migration above (an + // existing .node-version may still be too old) and is best-effort. + updateMigrationProgress('Checking Node.js version support'); + await upgradeUnsupportedNodeVersions( + workspaceInfo.rootDir, + interactive, + report, + clearMigrationProgress, + ); + // 4. Run vp install to ensure the project is ready updateMigrationProgress('Installing dependencies'); const initialInstallSummary = await runViteInstall( @@ -1458,6 +1470,19 @@ async function main() { } } + // Upgrade any below-range Node.js pin to the latest release of the same + // major (independent of the .node-version migration above; best-effort). + if ( + await upgradeUnsupportedNodeVersions( + workspaceInfoOptional.rootDir, + options.interactive, + report, + ) + ) { + didMigrate = true; + needsInstall = true; + } + if (convertYarnPnp) { updateMigrationProgress('Configuring Yarn node-modules mode'); const yarnPnpConverted = configureYarnNodeModulesMode(workspaceInfoOptional.rootDir); diff --git a/packages/cli/src/migration/migrator/setup.ts b/packages/cli/src/migration/migrator/setup.ts index 213f8936d1..9884daad9e 100644 --- a/packages/cli/src/migration/migrator/setup.ts +++ b/packages/cli/src/migration/migrator/setup.ts @@ -4,8 +4,14 @@ import path from 'node:path'; import * as prompts from '@voidzero-dev/vite-plus-prompts'; import semver from 'semver'; -import { type DownloadPackageManagerResult } from '../../../binding/index.js'; +import { + type DownloadPackageManagerResult, + resolveProjectNodeVersion, + resolveSupportedNodeVersion, +} from '../../../binding/index.js'; +import { SUPPORTED_NODE_RANGE } from '../../utils/constants.ts'; import { editJsonFile } from '../../utils/json.ts'; +import { cancelAndExit } from '../../utils/prompts.ts'; import { detectConfigs } from '../detector.ts'; import { type MigrationReport } from '../report.ts'; import { warnMigration } from './shared.ts'; @@ -189,3 +195,146 @@ export function migrateNodeVersionManagerFile( } return true; } + +interface NodePinnedPackageJson { + devEngines?: { runtime?: unknown; [key: string]: unknown }; + engines?: { node?: unknown; [key: string]: unknown }; + [key: string]: unknown; +} + +type NodeRuntimeEntry = { name?: unknown; version?: unknown; [key: string]: unknown }; + +/** + * Locate the `node` entry inside a `devEngines.runtime` value, which may be a + * single object or an array of runtime objects. Returns the live object so the + * caller can mutate its `version` in place. + */ +function findNodeRuntimeEntry(runtime: unknown): NodeRuntimeEntry | undefined { + const isNodeEntry = (entry: unknown): entry is NodeRuntimeEntry => + typeof entry === 'object' && entry !== null && (entry as NodeRuntimeEntry).name === 'node'; + + if (Array.isArray(runtime)) { + return runtime.find(isNodeEntry); + } + if (isNodeEntry(runtime)) { + return runtime; + } + return undefined; +} + +/** + * Write an upgraded Node.js version back to the source it was resolved from + * (returned by {@link resolveProjectNodeVersion}): + * - `node-version-file` → overwrite the `.node-version` file at `sourcePath`. + * - `dev-engines-runtime` → set the `node` runtime entry's `.version` in package.json. + * - `engines-node` → set `engines.node` in package.json. + * + * package.json edits go through {@link editJsonFile} so the file's formatting is + * preserved. + */ +function writeUpgradedNodeVersion(source: string, sourcePath: string, version: string): void { + if (source === 'node-version-file') { + fs.writeFileSync(sourcePath, `${version}\n`); + return; + } + if (source === 'dev-engines-runtime') { + editJsonFile(sourcePath, (pkg) => { + const entry = findNodeRuntimeEntry(pkg.devEngines?.runtime); + if (entry) { + entry.version = version; + } + return pkg; + }); + return; + } + if (source === 'engines-node') { + editJsonFile(sourcePath, (pkg) => { + if (pkg.engines) { + pkg.engines.node = version; + } + return pkg; + }); + } +} + +/** + * Bump the project's effective Node.js pin up to the concrete latest release of + * the same major when it sits BELOW the Vite+ supported range (sourced from this + * package's `engines.node`, e.g. `^20.19.0 || ^22.18.0 || >=24.11.0`). This + * fixes "Cannot find native binding" failures caused by engine-strict + * installers skipping the native optional dependency under an unsupported + * Node.js version. + * + * The effective pin and its source are read with the shared Rust resolver + * {@link resolveProjectNodeVersion}, which checks, in priority order: + * `.node-version` → `devEngines.runtime[node]` → `engines.node`. Only that + * single effective source is upgraded; shadowed lower-priority pins don't affect + * the runtime. `.nvmrc`/Volta pins are converted to `.node-version` by + * {@link migrateNodeVersionManagerFile}, which runs first, so they are covered + * via the `.node-version` source here. + * + * Whether the pin is below range (and what to upgrade it to) is decided by the + * {@link resolveSupportedNodeVersion} binding via range intersection, so true + * ranges, caret unions, and aliases like `lts/*` are left untouched. The binding + * calls are best-effort: any failure (e.g. offline) is treated as "nothing to + * upgrade". + * + * In interactive mode the upgrade is confirmed first (default Yes); in + * non-interactive mode it proceeds directly. + * + * @returns true if the pin was rewritten. + */ +export async function upgradeUnsupportedNodeVersions( + projectPath: string, + interactive: boolean, + report?: MigrationReport, + // Clears the migration progress spinner before the confirm prompt renders so + // it does not keep animating underneath the prompt. The caller restarts the + // spinner with its next progress update. + pauseProgress?: () => void, +): Promise { + // 1. Read the effective pin + source via the shared Rust resolver. + let resolution: Awaited>; + try { + resolution = await resolveProjectNodeVersion(projectPath); + } catch { + return false; + } + if (!resolution) { + return false; + } + const { version: from, source, sourcePath } = resolution; + + // 2. Plan: resolve the supported upgrade target. null = already supported, a + // true range/alias, or an unsupported major — nothing to do. + let to: string | null; + try { + to = (await resolveSupportedNodeVersion(from, SUPPORTED_NODE_RANGE)) ?? null; + } catch { + return false; + } + if (!to) { + return false; + } + + // 3. Confirm before writing (default Yes in interactive mode; proceed + // directly when non-interactive). + if (interactive) { + pauseProgress?.(); + const confirmed = await prompts.confirm({ + message: `Upgrade Node.js ${from} to ${to}? ${from} is below the Vite+ supported range.`, + initialValue: true, + }); + if (prompts.isCancel(confirmed)) { + cancelAndExit(); + } + if (!confirmed) { + return false; + } + } + + // 4. Write the upgrade back to its source. + writeUpgradedNodeVersion(source, sourcePath, to); + warnMigration(`Upgraded Node.js ${from} to ${to} (below the supported range)`, report); + return true; +} diff --git a/packages/cli/src/utils/constants.ts b/packages/cli/src/utils/constants.ts index 35df628090..dc1fb105fe 100644 --- a/packages/cli/src/utils/constants.ts +++ b/packages/cli/src/utils/constants.ts @@ -5,6 +5,14 @@ import cliPkg from '../../package.json' with { type: 'json' }; export const VITE_PLUS_NAME = 'vite-plus'; export const VITE_PLUS_VERSION = process.env.VP_VERSION || cliPkg.version; +/** + * The Node.js range Vite+ supports, sourced from this package's + * `engines.node` field (e.g. `^20.19.0 || ^22.18.0 || >=24.11.0`). This is the + * single source of truth: the migrator passes it into the native binding so the + * supported range can never drift from `package.json`. + */ +export const SUPPORTED_NODE_RANGE: string = cliPkg.engines.node; + export const VITEST_VERSION = '4.1.9'; export const VITE_PLUS_OVERRIDE_PACKAGES: Record = process.env.VP_OVERRIDE_PACKAGES diff --git a/rfcs/migrate-existing-projects.md b/rfcs/migrate-existing-projects.md index 52c3929a4c..6fbbb0315f 100644 --- a/rfcs/migrate-existing-projects.md +++ b/rfcs/migrate-existing-projects.md @@ -55,6 +55,20 @@ provider usage, installs the matching `@vitest/browser-` package and framework peer, and then rewrites the import to the equivalent `vite-plus/test*` surface. +### Node.js version + +`vp migrate` converts `.nvmrc` and Volta `volta.node` pins to `.node-version`, +then reads the effective Node pin (`.node-version` → `devEngines.runtime` → +`engines.node`, reusing the Rust runtime resolver rather than re-implementing +the lookup in JS) and upgrades it when it falls below the Vite+ supported range +(`package.json#engines.node`). An exact or `major.minor` pin below the range, +for example `24.3.0` or `24.2` (below `>=24.11.0`), is rewritten to the concrete +latest release of that major, for example `24.18.0`, so the package manager no +longer skips the native binding's optional dependency. A bare major (`24`) or an +open range that still resolves to a supported release is left unchanged. +Interactive migration confirms the upgrade (default yes); `--no-interactive` +applies it directly. + ## `@nuxt/test-utils` compatibility `@nuxt/test-utils`'s transform detects an existing `vi` import only when its module specifier is exactly `vitest`. When a test uses `mockNuxtImport` or `mockComponent`, changing that import to `vite-plus/test` makes the transform inject a second `vi` import and can fail compilation with a duplicate identifier. Requiring users to know which individual files exercise that transform is brittle, so the migration uses one package-level rule instead. diff --git a/rfcs/migration-command.md b/rfcs/migration-command.md index 1dcfe16dd2..9e1ed740da 100644 --- a/rfcs/migration-command.md +++ b/rfcs/migration-command.md @@ -579,10 +579,10 @@ A successful migration should: The normal script rules rewrite `vite`, `vitest`, `oxlint`, `oxfmt`, `tsdown`, and `lint-staged` to their corresponding `vp` commands. When one of these tools -is launched through `bunx`, migration preserves `bunx`, removes the `--bun` -runtime override, and rewrites the inner command. For example, -`bunx --bun vite build` becomes `bunx vp build` and -`bunx --bun vitest run` becomes `bunx vp test run`. +is launched through `bunx`, migration preserves `bunx` and its `--bun` runtime +override, and rewrites only the inner command. For example, +`bunx --bun vite build` becomes `bunx --bun vp build` and +`bunx --bun vitest run` becomes `bunx --bun vp test run`. The same behavior applies to `eslint` and `prettier` when their optional migrations run. Nested launcher forms such as @@ -624,7 +624,7 @@ When an ESLint flat config (`eslint.config.{js,mjs,cjs,ts,mts,cts}`) and `eslint | `cross-env NODE_ENV=test eslint --cache .` | `cross-env NODE_ENV=test vp lint .` | | `eslint . && vite build` | `vp lint . && vite build` | | `if [ -f .eslintrc ]; then eslint .; fi` | `if [ -f .eslintrc ]; then vp lint . fi` | -| `bunx --bun eslint .` | `bunx vp lint .` | +| `bunx --bun eslint .` | `bunx --bun vp lint .` | | `npx eslint .` | `npx eslint .` (unchanged) | Stripped ESLint-only flags: `--cache`, `--ext`, `--parser`, `--parser-options`, `--plugin`, `--rulesdir`, `--resolve-plugins-relative-to`, `--output-file`, `--env`, `--no-eslintrc`, `--no-error-on-unmatched-pattern`, `--debug`, `--no-inline-config` @@ -680,7 +680,7 @@ When a Prettier configuration file (`.prettierrc*`, `prettier.config.*`, or `"pr | `prettier --plugin prettier-plugin-tailwindcss .` | `vp fmt .` | | `cross-env NODE_ENV=test prettier --write .` | `cross-env NODE_ENV=test vp fmt .` | | `prettier --write . && eslint --fix .` | `vp fmt . && eslint --fix .` | -| `bunx --bun prettier --write .` | `bunx vp fmt .` | +| `bunx --bun prettier --write .` | `bunx --bun vp fmt .` | | `npx prettier --write .` | `npx prettier --write .` (unchanged) | **Stripped Prettier-only flags**: From 104bf6d78df132c1041c40ff47cf7a2886c6897b Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 28 Jun 2026 13:57:44 +0800 Subject: [PATCH 71/78] fix(migrate): make the rolldown-compat worker-path test Windows-safe The compat-runner spec matched the worker path with /compat\/worker\.js$/, but fileURLToPath yields OS-native separators, so on Windows the path ends with compat\worker.js and the assertion failed. Match either separator. Claude-Session: https://claude.ai/code/session_01DQhS6o1fyQd1yjiee6W8jR --- packages/cli/src/migration/__tests__/compat-runner.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/migration/__tests__/compat-runner.spec.ts b/packages/cli/src/migration/__tests__/compat-runner.spec.ts index 26ce2dd9bc..d56541a2d3 100644 --- a/packages/cli/src/migration/__tests__/compat-runner.spec.ts +++ b/packages/cli/src/migration/__tests__/compat-runner.spec.ts @@ -30,7 +30,8 @@ describe('checkRolldownCompatibility', () => { expect(report.warnings).toEqual(['manualChunks warning']); expect(mockRunCommandSilently).toHaveBeenCalledWith({ command: process.execPath, - args: [expect.stringMatching(/compat\/worker\.js$/), '/project'], + // fileURLToPath yields OS-native separators: '/' on POSIX, '\' on Windows. + args: [expect.stringMatching(/compat[/\\]worker\.js$/), '/project'], cwd: '/project', envs: process.env, }); From d9c74ffa07a78977561184ea419f7ce6b9c79b7e Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 28 Jun 2026 13:57:44 +0800 Subject: [PATCH 72/78] chore(skills): add verify-interactive-cli A project skill that drives and captures vp's interactive clack prompts in a tmux session, with a STOP_AT mode that double-captures a prompt to detect a spinner animating beneath it (how the Node-upgrade confirm overlap was found). Claude-Session: https://claude.ai/code/session_01DQhS6o1fyQd1yjiee6W8jR --- .../skills/verify-interactive-cli/SKILL.md | 55 +++++++++++++++ .../interactive-cli-tmux-driver.sh | 70 +++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 .claude/skills/verify-interactive-cli/SKILL.md create mode 100755 .claude/skills/verify-interactive-cli/interactive-cli-tmux-driver.sh diff --git a/.claude/skills/verify-interactive-cli/SKILL.md b/.claude/skills/verify-interactive-cli/SKILL.md new file mode 100644 index 0000000000..72cdef44c0 --- /dev/null +++ b/.claude/skills/verify-interactive-cli/SKILL.md @@ -0,0 +1,55 @@ +--- +name: verify-interactive-cli +description: Drive and capture vp's interactive (clack) prompts in a tmux session to verify interactive UX and catch spinner-over-prompt bugs that snap tests (which run non-interactively) miss. Use when asked to test/verify/capture an interactive vp command's prompts (vp migrate, vp create, ...), reproduce a prompt-rendering bug, or show the real interactive CLI output for a PR. +allowed-tools: Bash, Read +--- + +# Verify vp's interactive CLI prompts + +Snap tests run `vp` non-interactively, so interactive clack prompts (hooks / agent / editor confirms, the Node-version upgrade confirm) and TTY-only rendering bugs are never exercised. This skill drives the real prompts in a TTY (via tmux), captures clean output, and can catch a spinner animating underneath an active prompt (a real, recurring UX bug class, e.g. the "Preparing migration" and "Checking Node.js version support" spinners). + +## Prerequisites + +- `tmux` (macOS has none by default): `brew install tmux`. +- The global `vp` must contain the code under test. To exercise working-tree changes, rebuild first: `pnpm bootstrap-cli` (~5 min), then confirm with `vp --version`. + +## Driver + +`interactive-cli-tmux-driver.sh` is bundled in this skill's directory. + +```bash +# Run to completion, auto-accepting every prompt's DEFAULT; prints a clean transcript. +.claude/skills/verify-interactive-cli/interactive-cli-tmux-driver.sh "vp migrate" + +# STOP at a specific prompt (do NOT answer it) and check for a spinner animating under it. +.claude/skills/verify-interactive-cli/interactive-cli-tmux-driver.sh "vp migrate" "Upgrade Node.js" +``` + +How it works: + +- Runs the command in a detached tmux session; `tmux capture-pane -p` yields clean text (clack's in-place redraws overwrite, so only resolved lines remain), far cleaner than `expect`'s raw ANSI capture. +- Auto-accepts each prompt's default by sending Enter when the pane goes STABLE. A waiting prompt is static; animating spinners keep the pane changing, so Enter never fires mid-work. +- End-detection uses `; echo "$M1$M2 exit=$?"` where M1/M2 are split vars, so the literal end marker is not in the typed command line (otherwise a grep matches the echoed command, not the program output). +- With a STOP_AT regex it halts at the target prompt and captures twice ~3s apart: identical captures = static prompt (OK); differing captures (or a `Checking ... (Xs)` line) = a spinner is animating under the prompt = a UX bug. + +## Setting up a target project + +Create a throwaway project that triggers the prompts you want to see, e.g. a fresh Vite app whose `.node-version` is below the supported range to exercise the Node-upgrade confirm: + +```bash +mkdir -p /tmp/vp-demo && cd /tmp/vp-demo +printf '{\n "name":"d","private":true,"type":"module","packageManager":"pnpm@10.18.0",\n "scripts":{"build":"vite build","test":"vitest run"},\n "devDependencies":{"vite":"^8.0.0","vitest":"^4.1.0"}\n}\n' > package.json +echo "24.3.0" > .node-version +printf 'import { defineConfig } from "vite";\nexport default defineConfig({});\n' > vite.config.ts +git init -q && git add -A && git -c user.email=x@x -c user.name=x commit -qm init +``` + +## When you find a spinner-over-prompt bug + +Fix it test-first: the prompt's code must pause the migration progress spinner before the confirm renders. Assert the call order is `['pause', 'confirm']`. Reference fix: `upgradeUnsupportedNodeVersions` (in `packages/cli/src/migration/migrator/setup.ts`) takes a `pauseProgress` callback that `bin.ts` wires to `clearMigrationProgress`, called right before `prompts.confirm`. + +## Gotchas + +- clack's active-prompt marker here is `›` (not always `◆`); detect a waiting prompt by pane stability, not a specific glyph. +- `VP_SKIP_INSTALL=1` skips the dependency install but breaks steps that load `vite.config.ts` (e.g. the prettier auto-migration) because `vite` isn't installed; use it only when you stop before the install step. +- A `&` inside a background task detaches the script, so the task reports "completed" early while it keeps running; poll a status file or `capture-pane` to track real progress. diff --git a/.claude/skills/verify-interactive-cli/interactive-cli-tmux-driver.sh b/.claude/skills/verify-interactive-cli/interactive-cli-tmux-driver.sh new file mode 100755 index 0000000000..5e0d45918e --- /dev/null +++ b/.claude/skills/verify-interactive-cli/interactive-cli-tmux-driver.sh @@ -0,0 +1,70 @@ +#!/bin/bash +# Drive and capture an interactive `vp` (clack) prompt flow inside tmux, to verify +# interactive UX that snap tests (which run non-interactively) never cover. +# +# Usage: +# interactive-cli-tmux-driver.sh "" [STOP_AT_REGEX] +# interactive-cli-tmux-driver.sh /tmp/demo "vp migrate" +# interactive-cli-tmux-driver.sh /tmp/demo "vp migrate" "Upgrade Node.js" +# +# No STOP_AT : run to completion, auto-accepting every prompt's DEFAULT (Enter), +# then print a clean transcript. +# With STOP_AT: drive prompts until one matching the regex appears, then STOP +# (do NOT answer it) and capture the pane twice 3s apart. Identical +# captures => prompt is static (good). Differing captures (or a +# "Checking ... (Xs)" line) => a spinner is animating UNDER the +# prompt -- a real UX bug (this is how the Node-upgrade confirm +# spinner-overlap was found). +# +# Why tmux and not `expect`: expect's raw capture is full of ANSI cursor/redraw +# noise; `tmux capture-pane -p` returns clean text because in-place redraws +# overwrite and only the resolved lines remain in scrollback. +# macOS has no tmux/timeout by default: `brew install tmux`. +set -u +DIR="${1:?project dir}"; CMD="${2:?command, e.g. \"vp migrate\"}"; STOP_AT="${3:-}" +command -v tmux >/dev/null || { echo "need tmux: brew install tmux" >&2; exit 1; } +S="clicap_$$" +cap1="$(mktemp)"; cap2="$(mktemp)" + +tmux kill-session -t "$S" 2>/dev/null +tmux new-session -d -s "$S" -x 100 -y 50 +tmux set-option -t "$S" history-limit 50000 +# M1/M2 are split so the end marker never appears in the TYPED command line -- +# otherwise a grep for it matches the echoed command, not the program output. +tmux send-keys -t "$S" 'export PS1="$ " PROMPT="%% " M1=CLI M2=CAPDONE' Enter; sleep 1 +tmux send-keys -t "$S" "cd '$DIR'" Enter; sleep 1 +tmux send-keys -t "$S" 'clear' Enter; sleep 1 +tmux send-keys -t "$S" "$CMD"'; echo "$M1$M2 exit=$?"' Enter + +prev=""; stable=0; sent=0 +for i in $(seq 1 180); do + sleep 2 + pane="$(tmux capture-pane -t "$S" -p -S -120 2>/dev/null)" + # reached the prompt we want to inspect -> stop without answering it + if [ -n "$STOP_AT" ] && printf '%s' "$pane" | grep -q "$STOP_AT"; then + tmux capture-pane -t "$S" -p -S -60 > "$cap1"; sleep 3 + tmux capture-pane -t "$S" -p -S -60 > "$cap2" + if diff -q "$cap1" "$cap2" >/dev/null; then + echo "STATIC prompt (nothing animating underneath) -- OK" + else + echo "ANIMATING under the prompt (likely spinner-over-prompt bug):" + diff "$cap1" "$cap2" + fi + echo "--- prompt ---"; sed -n "/$STOP_AT/,\$p" "$cap1" + tmux kill-session -t "$S" 2>/dev/null; rm -f "$cap1" "$cap2"; exit 0 + fi + # program finished + printf '%s' "$pane" | grep -q "CLICAPDONE exit=" && break + # A waiting prompt makes the pane STABLE; animating spinners keep it changing, + # so this won't fire mid-work. Accept the default with Enter, once per state. + if [ "$pane" = "$prev" ]; then + stable=$((stable+1)) + if [ "$stable" -ge 2 ] && [ "$sent" -eq 0 ]; then tmux send-keys -t "$S" Enter; sent=1; fi + else + prev="$pane"; stable=0; sent=0 + fi +done + +echo "=== clean transcript ===" +tmux capture-pane -t "$S" -p -S -3000 +tmux kill-session -t "$S" 2>/dev/null; rm -f "$cap1" "$cap2" From 941383b070d18b3e1ae20738d80019a0b72a398a Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 28 Jun 2026 14:11:22 +0800 Subject: [PATCH 73/78] refactor(ci): build the pkg.pr.new bridge comment with github-script Match the docker-image workflow's method: build the sticky bridge-version comment as a github-script line array (so the fenced JSON block can't collide with YAML block-scalar indentation) and post it via a marker-based listComments -> updateComment/createComment, replacing the gh api + sed shell logic and the now-inlined .github/bridge-comment-template.md. Claude-Session: https://claude.ai/code/session_01DQhS6o1fyQd1yjiee6W8jR --- .github/bridge-comment-template.md | 28 -------- .github/workflows/publish-to-pkg.pr.new.yml | 80 ++++++++++++++++----- 2 files changed, 61 insertions(+), 47 deletions(-) delete mode 100644 .github/bridge-comment-template.md diff --git a/.github/bridge-comment-template.md b/.github/bridge-comment-template.md deleted file mode 100644 index 0ceb3cb8f6..0000000000 --- a/.github/bridge-comment-template.md +++ /dev/null @@ -1,28 +0,0 @@ - - -### Registry bridge build (`__SHORT__`) - -This commit is published to pkg.pr.new and registered with the [registry bridge](https://github.com/fengmk2/pkg-pr-registry-bridge), which serves these as ordinary npm versions (every other package proxies to npmjs): - -| Package | Version | -| ------------------------------ | ---------------------- | -| `vite-plus` | `0.0.0-commit.__SHA__` | -| `@voidzero-dev/vite-plus-core` | `0.0.0-commit.__SHA__` | - -**Point your package manager at the bridge registry** `https://pkg-pr-registry-bridge.render.vip/`: - -| Package manager | Registry config | -| ---------------- | -------------------------------------------------------------------------------- | -| npm / pnpm / Bun | `.npmrc`: `registry=https://pkg-pr-registry-bridge.render.vip/` | -| Yarn (v2+) | `.yarnrc.yml`: `npmRegistryServer: "https://pkg-pr-registry-bridge.render.vip/"` | - -Then pin the build (`vite` aliases to vite-plus-core; pnpm can use a catalog, npm an `overrides` entry): - -```json -{ - "devDependencies": { - "vite-plus": "0.0.0-commit.__SHA__", - "vite": "npm:@voidzero-dev/vite-plus-core@0.0.0-commit.__SHA__" - } -} -``` diff --git a/.github/workflows/publish-to-pkg.pr.new.yml b/.github/workflows/publish-to-pkg.pr.new.yml index bcdb5c9bf2..df07fce0ae 100644 --- a/.github/workflows/publish-to-pkg.pr.new.yml +++ b/.github/workflows/publish-to-pkg.pr.new.yml @@ -166,22 +166,64 @@ jobs: - name: Comment bridge version on the PR if: steps.bridge.outcome == 'success' continue-on-error: true - env: - GH_TOKEN: ${{ github.token }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - PR_NUMBER: ${{ github.event.pull_request.number }} - REPO: ${{ github.repository }} - run: | - sed -e "s/__SHA__/${HEAD_SHA}/g" -e "s/__SHORT__/${HEAD_SHA:0:7}/g" \ - .github/bridge-comment-template.md > bridge-comment.md - existing=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" --paginate \ - --jq 'map(select(.body | contains(""))) | last | .id // empty') - if [ -n "$existing" ]; then - gh api -X PATCH "repos/${REPO}/issues/comments/${existing}" \ - -f body="$(cat bridge-comment.md)" >/dev/null - echo "Updated bridge comment ${existing}" - else - gh api -X POST "repos/${REPO}/issues/${PR_NUMBER}/comments" \ - -f body="$(cat bridge-comment.md)" >/dev/null - echo "Created bridge comment" - fi + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + const sha = context.payload.pull_request.head.sha; + const shortSha = sha.slice(0, 7); + const pr = context.payload.pull_request.number; + const marker = ''; + const bridge = 'https://pkg-pr-registry-bridge.render.vip/'; + // Built as a line array (not a template literal) so the fenced code + // block doesn't collide with the YAML block-scalar indentation. + const body = [ + marker, + '', + `### Registry bridge build (\`${shortSha}\`)`, + '', + 'This commit is published to pkg.pr.new and registered with the [registry bridge](https://github.com/fengmk2/pkg-pr-registry-bridge), which serves these as ordinary npm versions (every other package proxies to npmjs):', + '', + '| Package | Version |', + '| --- | --- |', + `| \`vite-plus\` | \`0.0.0-commit.${sha}\` |`, + `| \`@voidzero-dev/vite-plus-core\` | \`0.0.0-commit.${sha}\` |`, + '', + `**Point your package manager at the bridge registry** \`${bridge}\`:`, + '', + '| Package manager | Registry config |', + '| --- | --- |', + `| npm / pnpm / Bun | \`.npmrc\`: \`registry=${bridge}\` |`, + `| Yarn (v2+) | \`.yarnrc.yml\`: \`npmRegistryServer: "${bridge}"\` |`, + '', + 'Then pin the build (`vite` aliases to vite-plus-core; pnpm can use a catalog, npm an `overrides` entry):', + '', + '```json', + '{', + ' "devDependencies": {', + ` "vite-plus": "0.0.0-commit.${sha}",`, + ` "vite": "npm:@voidzero-dev/vite-plus-core@0.0.0-commit.${sha}"`, + ' }', + '}', + '```', + ].join('\n'); + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr, + }); + const existing = comments.find((c) => c.body && c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr, + body, + }); + } From fbe8d5cd10247b1c5f07b53a3918fd8910493a3d Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 28 Jun 2026 14:39:58 +0800 Subject: [PATCH 74/78] refactor(migrate): simplify node-version binding, format, and vitest checks - migration.rs: extract a shared resolved_if_supported helper so the NAPI entry point and the test-only mirror verify the resolved version against the supported range with one implementation, and correct the inaccurate "shared by the NAPI entry point" doc on the test helper. - format.ts: isExistingFile uses a single statSync().isFile() in try/catch instead of existsSync + statSync (two stat syscalls per changed file). - vitest-ecosystem.ts: compute the required-vitest-peer dependency scan lazily (requiredVitestPeer ?? scan()) so the cheap ecosystem-dep check short-circuits it, matching the file's existing precomputedScans idiom. Claude-Session: https://claude.ai/code/session_01DQhS6o1fyQd1yjiee6W8jR --- packages/cli/binding/src/migration.rs | 27 ++++++++++--------- packages/cli/src/migration/format.ts | 6 ++++- .../migration/migrator/vitest-ecosystem.ts | 7 +++-- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/packages/cli/binding/src/migration.rs b/packages/cli/binding/src/migration.rs index 363f4d306d..ebad3d3701 100644 --- a/packages/cli/binding/src/migration.rs +++ b/packages/cli/binding/src/migration.rs @@ -50,9 +50,21 @@ fn supported_node_requirement(current: &str, supported_range: &Range) -> Option< Some(format!(">={major}.0.0 <{}.0.0", major + 1)) } +/// Return `resolved` only when it satisfies `supported_range`. An unsupported +/// major (e.g. 21 or 23) resolves to a concrete release of its own major but +/// must not be returned. Shared by the NAPI entry point and the unit tests so +/// the resolve-then-verify contract lives in one place. +fn resolved_if_supported(resolved: String, supported_range: &Range) -> Option { + Version::parse(resolved.as_str()) + .ok() + .filter(|version| supported_range.satisfies(version)) + .map(|_| resolved) +} + /// Resolve the latest supported Node.js release matching `current`'s major from /// an explicit version list, verifying the result against `supported_range`. -/// Shared by the NAPI entry point and unit tests. +/// Test-only mirror of [`resolve_supported_node_version`] that takes a fixed +/// version list instead of hitting the Node.js release index. #[cfg(test)] fn resolve_supported_node_version_from_list( current: &str, @@ -63,10 +75,7 @@ fn resolve_supported_node_version_from_list( let requirement = supported_node_requirement(current, &supported)?; let resolved = vite_js_runtime::resolve_version_from_list(&requirement, versions).ok()?.to_string(); - // Verify the resolved version actually satisfies the supported range. An - // unsupported major (e.g. 21 or 23) resolves to a concrete release but must - // not be returned. - Version::parse(resolved.as_str()).ok().filter(|v| supported.satisfies(v)).map(|_| resolved) + resolved_if_supported(resolved, &supported) } /// Resolve a Node.js version that is below Vite+'s supported range to the @@ -115,14 +124,8 @@ pub async fn resolve_supported_node_version( let provider = NodeProvider::new(); let latest = provider.resolve_version(&requirement).await.map_err(anyhow::Error::from)?; - let latest = latest.to_string(); - // Verify the resolved version is actually supported. An unsupported major - // (e.g. 21 or 23) resolves to a concrete release but must not be returned. - match Version::parse(latest.as_str()) { - Ok(version) if supported.satisfies(&version) => Ok(Some(latest)), - _ => Ok(None), - } + Ok(resolved_if_supported(latest.to_string(), &supported)) } /// Stable string label for a [`VersionSource`], used as the `source` field of diff --git a/packages/cli/src/migration/format.ts b/packages/cli/src/migration/format.ts index 229ac06f3e..b82187f05f 100644 --- a/packages/cli/src/migration/format.ts +++ b/packages/cli/src/migration/format.ts @@ -58,7 +58,11 @@ function parseNullDelimitedPaths(output: Buffer): string[] { function isExistingFile(projectRoot: string, relativePath: string): boolean { const absolutePath = path.join(projectRoot, relativePath); - return fs.existsSync(absolutePath) && fs.statSync(absolutePath).isFile(); + try { + return fs.statSync(absolutePath).isFile(); + } catch { + return false; + } } /** diff --git a/packages/cli/src/migration/migrator/vitest-ecosystem.ts b/packages/cli/src/migration/migrator/vitest-ecosystem.ts index ee9cdffdba..9f59968fee 100644 --- a/packages/cli/src/migration/migrator/vitest-ecosystem.ts +++ b/packages/cli/src/migration/migrator/vitest-ecosystem.ts @@ -513,7 +513,10 @@ export function projectUsesVitestDirectly( devDependencies?: Record; peerDependencies?: Record; }, - requiredVitestPeer = projectListsRequiredVitestPeer(projectPath, pkg), + // Lazily computed when omitted, after the cheap ecosystem-dep check below + // short-circuits, mirroring the precomputedScans pattern. Avoids the + // dependency scan when the project already lists a vitest-ecosystem dep. + requiredVitestPeer?: boolean, preserveNuxtVitestImports = true, // Optional precomputed source-tree scan results. Callers that already computed // these for the same `projectPath` at the same point (no source mutation in @@ -523,7 +526,7 @@ export function projectUsesVitestDirectly( ): boolean { return ( projectListsVitestEcosystemDep(pkg) || - requiredVitestPeer || + (requiredVitestPeer ?? projectListsRequiredVitestPeer(projectPath, pkg)) || // Browser packages declared only as peers still become direct installs: // rewritePackageJson/reconcileVitePlusBootstrapPackage promote opt-in // providers into devDependencies and treat the bundled browser packages as From 8ec6428d497c7f92c3616d1f6f000783d68ae337 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 28 Jun 2026 15:32:55 +0800 Subject: [PATCH 75/78] docs(migrate): drop the interactive-confirm note from Node.js version rules Per PR review: the migrate-rules guide does not need to spell out the interactive vs --no-interactive confirm behavior for the Node.js upgrade. Claude-Session: https://claude.ai/code/session_01DQhS6o1fyQd1yjiee6W8jR --- docs/guide/migrate-rules.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 4f785f7b11..04a6a1b7c6 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -187,8 +187,6 @@ Migration normalizes the project's Node.js pin: Node otherwise makes the package manager skip the native binding's optional dependency. A bare major (`24`) or an open range (`^20`, `>=18`) that can still resolve to a supported release is left unchanged. -- Interactive migration confirms the upgrade (default yes); `--no-interactive` - applies it directly. ## Package-Manager Rules From c0a3a57de28c581e83ba2dc359311d67af46e6c4 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 28 Jun 2026 19:40:43 +0800 Subject: [PATCH 76/78] ci(pkg-pr-new): publish to the bridge via its reusable action Replace the manual `curl POST /-/refs` with the bridge's publish-preview action (fengmk2/pkg-pr-registry-bridge), which downloads each pkg.pr.new package, re-packs the two preview packages, uploads them with matching integrity, and registers the ref, the CPU work that previously ran in the bridge's GitHub webhook. SHA-pinned to bridge main. Claude-Session: https://claude.ai/code/session_01DQhS6o1fyQd1yjiee6W8jR --- .github/workflows/publish-to-pkg.pr.new.yml | 31 ++++++++++----------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/.github/workflows/publish-to-pkg.pr.new.yml b/.github/workflows/publish-to-pkg.pr.new.yml index df07fce0ae..46fa0fc311 100644 --- a/.github/workflows/publish-to-pkg.pr.new.yml +++ b/.github/workflows/publish-to-pkg.pr.new.yml @@ -138,26 +138,25 @@ jobs: './packages/core' \ './packages/prompts' - # Register this commit build with the pkg.pr.new registry bridge so it can - # be installed as the npm version 0.0.0-commit. - # (https://github.com/fengmk2/pkg-pr-registry-bridge). This CI step - # replaces the bridge's GitHub webhook. pkg-pr-new publishes under the PR - # head commit, so register that SHA (not the merge commit github.sha). - # Restricted to same-repo PRs because fork PRs do not receive the admin - # token secret; never fails the publish if the bridge is unreachable. + # Publish this commit build to the pkg.pr.new registry bridge so it can be + # installed as the npm version 0.0.0-commit. + # (https://github.com/fengmk2/pkg-pr-registry-bridge). The bridge action + # downloads each pkg.pr.new package (the two preview packages and every + # platform binary), re-packs them under the commit version with matching + # integrity, uploads them to the bridge, and registers the ref (the CPU + # work that previously ran in the bridge's GitHub webhook). pkg-pr-new + # publishes under the PR head commit, so pass that SHA (not the merge + # commit github.sha). Restricted to same-repo PRs because fork PRs do not + # receive the admin token secret; never fails the publish if the bridge is + # unreachable. - name: Register commit build with the registry bridge id: bridge if: github.event.pull_request.head.repo.full_name == github.repository continue-on-error: true - env: - PKG_PR_BRIDGE_ADMIN_TOKEN: ${{ secrets.PKG_PR_BRIDGE_ADMIN_TOKEN }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - curl -fsS -X POST \ - -H "authorization: Bearer ${PKG_PR_BRIDGE_ADMIN_TOKEN}" \ - -H 'content-type: application/json' \ - -d "{\"ref\":\"commit.${HEAD_SHA}\"}" \ - https://pkg-pr-registry-bridge.render.vip/-/refs + uses: fengmk2/pkg-pr-registry-bridge/.github/actions/publish-preview@01ceebc2f6f269d162d0aa0d9f00cbfd8bc16c8d # main + with: + sha: ${{ github.event.pull_request.head.sha }} + admin-token: ${{ secrets.PKG_PR_BRIDGE_ADMIN_TOKEN }} # Once the bridge has the commit build, post (or update) a sticky PR comment # with the resolved npm versions and per-package-manager registry config, so From b1ae30a57c5e42a445564857d675a0abe843a173 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 28 Jun 2026 21:27:36 +0800 Subject: [PATCH 77/78] refactor(ci): consume the bridge action's version output in the PR comment The bridge comment rebuilt 0.0.0-commit. from the raw sha; read the publish-preview action's `version` output instead so the version scheme has a single source of truth. Also correct the line-array comment to name the real reason (avoid escaping the inline backticks and fenced json block). Claude-Session: https://claude.ai/code/session_01DQhS6o1fyQd1yjiee6W8jR --- .github/workflows/publish-to-pkg.pr.new.yml | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/.github/workflows/publish-to-pkg.pr.new.yml b/.github/workflows/publish-to-pkg.pr.new.yml index 46fa0fc311..971ceaab24 100644 --- a/.github/workflows/publish-to-pkg.pr.new.yml +++ b/.github/workflows/publish-to-pkg.pr.new.yml @@ -166,15 +166,19 @@ jobs: if: steps.bridge.outcome == 'success' continue-on-error: true uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + # The synthetic version (0.0.0-commit.) the bridge action + # published, so the version scheme has one source of truth (the action). + BRIDGE_VERSION: ${{ steps.bridge.outputs.version }} with: script: | - const sha = context.payload.pull_request.head.sha; - const shortSha = sha.slice(0, 7); + const version = process.env.BRIDGE_VERSION; + const shortSha = context.payload.pull_request.head.sha.slice(0, 7); const pr = context.payload.pull_request.number; const marker = ''; const bridge = 'https://pkg-pr-registry-bridge.render.vip/'; - // Built as a line array (not a template literal) so the fenced code - // block doesn't collide with the YAML block-scalar indentation. + // Built as a line array (not a template literal) so the inline + // backticks and the fenced json block don't need backslash-escaping. const body = [ marker, '', @@ -184,8 +188,8 @@ jobs: '', '| Package | Version |', '| --- | --- |', - `| \`vite-plus\` | \`0.0.0-commit.${sha}\` |`, - `| \`@voidzero-dev/vite-plus-core\` | \`0.0.0-commit.${sha}\` |`, + `| \`vite-plus\` | \`${version}\` |`, + `| \`@voidzero-dev/vite-plus-core\` | \`${version}\` |`, '', `**Point your package manager at the bridge registry** \`${bridge}\`:`, '', @@ -199,8 +203,8 @@ jobs: '```json', '{', ' "devDependencies": {', - ` "vite-plus": "0.0.0-commit.${sha}",`, - ` "vite": "npm:@voidzero-dev/vite-plus-core@0.0.0-commit.${sha}"`, + ` "vite-plus": "${version}",`, + ` "vite": "npm:@voidzero-dev/vite-plus-core@${version}"`, ' }', '}', '```', From 4f61f920640a17d1ebd019f516e883f6de6a9811 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 28 Jun 2026 22:52:08 +0800 Subject: [PATCH 78/78] ci(pkg-pr-new): point the registry bridge at voidzero-dev/void.app The bridge moved from fengmk2/pkg-pr-registry-bridge (render.vip) to voidzero-dev/pkg-pr-registry-bridge (void.app). Per the new docs/ci-setup.md, switch the publish workflow to the root action voidzero-dev/pkg-pr-registry-bridge and the void.app bridge URL, and update the test-pkg-pr-new-migrate helper's bridge registry to void.app. Claude-Session: https://claude.ai/code/session_01DQhS6o1fyQd1yjiee6W8jR --- .github/scripts/test-pkg-pr-new-migrate.sh | 4 ++-- .github/workflows/publish-to-pkg.pr.new.yml | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/scripts/test-pkg-pr-new-migrate.sh b/.github/scripts/test-pkg-pr-new-migrate.sh index 21a073d188..93a0d5f79d 100755 --- a/.github/scripts/test-pkg-pr-new-migrate.sh +++ b/.github/scripts/test-pkg-pr-new-migrate.sh @@ -9,7 +9,7 @@ Usage: .github/scripts/test-pkg-pr-new-migrate.sh [mi Installs an isolated global Vite+ CLI built from a pkg.pr.new commit and runs `vp migrate` against a local project. The migrated project pins `vite-plus` and `vite` to the matching commit build, resolved through the pkg.pr.new registry -bridge (https://github.com/fengmk2/pkg-pr-registry-bridge) so they install like +bridge (https://github.com/voidzero-dev/pkg-pr-registry-bridge) so they install like ordinary npm versions (0.0.0-commit.) instead of mutable pkg.pr.new URLs. Persists the bridge registry into the project's `.npmrc` (npm/pnpm/Yarn @@ -72,7 +72,7 @@ if command -v git >/dev/null 2>&1 && git -C "$project_dir" rev-parse --is-inside fi fi -bridge_registry="https://pkg-pr-registry-bridge.render.vip/" +bridge_registry="https://pkg-pr-registry-bridge.void.app/" pkg_pr_new_base="https://pkg.pr.new/voidzero-dev/vite-plus" requested_vite_plus_spec="$pkg_pr_new_base@$pr_ref" diff --git a/.github/workflows/publish-to-pkg.pr.new.yml b/.github/workflows/publish-to-pkg.pr.new.yml index 971ceaab24..9eeef4741d 100644 --- a/.github/workflows/publish-to-pkg.pr.new.yml +++ b/.github/workflows/publish-to-pkg.pr.new.yml @@ -140,7 +140,7 @@ jobs: # Publish this commit build to the pkg.pr.new registry bridge so it can be # installed as the npm version 0.0.0-commit. - # (https://github.com/fengmk2/pkg-pr-registry-bridge). The bridge action + # (https://github.com/voidzero-dev/pkg-pr-registry-bridge). The bridge action # downloads each pkg.pr.new package (the two preview packages and every # platform binary), re-packs them under the commit version with matching # integrity, uploads them to the bridge, and registers the ref (the CPU @@ -153,7 +153,7 @@ jobs: id: bridge if: github.event.pull_request.head.repo.full_name == github.repository continue-on-error: true - uses: fengmk2/pkg-pr-registry-bridge/.github/actions/publish-preview@01ceebc2f6f269d162d0aa0d9f00cbfd8bc16c8d # main + uses: voidzero-dev/pkg-pr-registry-bridge@3ee9882f423375081609dd9160b50db722b42fd4 # main with: sha: ${{ github.event.pull_request.head.sha }} admin-token: ${{ secrets.PKG_PR_BRIDGE_ADMIN_TOKEN }} @@ -176,7 +176,7 @@ jobs: const shortSha = context.payload.pull_request.head.sha.slice(0, 7); const pr = context.payload.pull_request.number; const marker = ''; - const bridge = 'https://pkg-pr-registry-bridge.render.vip/'; + const bridge = 'https://pkg-pr-registry-bridge.void.app/'; // Built as a line array (not a template literal) so the inline // backticks and the fenced json block don't need backslash-escaping. const body = [ @@ -184,7 +184,7 @@ jobs: '', `### Registry bridge build (\`${shortSha}\`)`, '', - 'This commit is published to pkg.pr.new and registered with the [registry bridge](https://github.com/fengmk2/pkg-pr-registry-bridge), which serves these as ordinary npm versions (every other package proxies to npmjs):', + 'This commit is published to pkg.pr.new and registered with the [registry bridge](https://github.com/voidzero-dev/pkg-pr-registry-bridge), which serves these as ordinary npm versions (every other package proxies to npmjs):', '', '| Package | Version |', '| --- | --- |',