# RFC: Migrating Existing Vite+ Projects to a New Version

- 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)

## Goal: upgrade in two commands

Any later Vite+ upgrade is two commands: upgrade the global CLI, then migrate the project.

```bash
vp upgrade   # update the global `vp` binary
vp migrate   # bring the project up to the new toolchain
```

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.

`vp migrate` is idempotent: on an already-current project it reports "already using Vite+" and changes nothing.

On an existing Vite+ project, `vp migrate` upgrades the toolchain version only (the rules below). The setup bucket (git hooks, editor config, agent files, ESLint and Prettier migration, framework shims, tsconfig `baseUrl`, and `.nvmrc`/Volta to `.node-version`) runs only with `--full`. The per-action flags `--hooks`, `--agent`, and `--editor` opt into a single setup action without `--full`. A default upgrade that skips available setup actions prints a hint to run `vp migrate --full`. Fresh (non Vite+) projects always run the full migration.

## 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 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 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@<old>` 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).                                                                                                                                                                                                                                                                                                                                                                                |
| 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.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| 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.

Catalogs are a separate pnpm feature from workspace settings, supported since
pnpm 9.5.0, so they are independent of the 10.6.2 settings boundary. Below 10.6.2,
where overrides stay in `package.json#pnpm`, migration still rewrites the
workspace catalog off stale `@voidzero-dev/vite-plus-test` wrappers and keeps
`package.json` `catalog:` overrides as references rather than inlining them to
concrete versions.

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-<provider>`, `vitest/browser/providers/<provider>`, or
`vitest/plugins/browser-<provider>`. Migration treats all three forms as opt-in
provider usage, installs the matching `@vitest/browser-<provider>` package and
framework peer, and then rewrites the import to the equivalent
`vite-plus/test*` surface.

`vite` imports are rewritten to `vite-plus` only in config entry files
(`vite.config.*`, `vitest.config.*`, and any config the migrate resolved). Every
other file keeps its `vite` imports, because `vite-plus` is not a guaranteed
superset of Vite's exposed surface: it owns only `defineConfig`, `defineProject`,
and `lazyPlugins`, so rewriting a pass-through symbol (e.g. `createBuilder`,
`loadConfigFromFile`, including in `typeof import('vite')` type positions) carries
no benefit and can break the type. An unrewritten `vite` import still resolves
through the managed `@voidzero-dev/vite-plus-core` alias in a Vite+ project and
stays consumable by plain Vite projects. Plugin packages additionally skip the
rewrite even in their config files: a package is recognized as a plugin when its
unscoped name starts with `vite-plugin-` (<https://vite.dev/guide/api-plugin>) or
`unplugin-` (<https://unplugin.unjs.io/guide/plugin-conventions.html>; cross-bundler
plugins that ship a Vite entry), or it declares `vite` in `peerDependencies` or
`dependencies`. The scope is `vite`; `vitest` rewriting is unaffected.

### Node.js version

`vp migrate` converts `.nvmrc` and Volta `volta.node` pins to `.node-version`
(the format Vite+ reads). An existing `.node-version` is kept, and when `.nvmrc`
is removed any `actions/setup-node` `node-version-file: .nvmrc` reference in the
project's workflows and composite actions is repointed to `.node-version` so CI
does not break. The native binding runs on any Node `>=20.0.0`, so a Node pin
whose floor sits below the supported-range minimum but at or above that ABI floor
(`engines.node: 24.x`, `devEngines.runtime` `^24`, `.node-version` `24.3.0`) is
left untouched: the platform packages declare the true ABI floor, so package
managers no longer skip the optional native dependency over a gap in the
supported range.

## `@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.

Detection and scope:

1. A package is eligible when its `dependencies`, `devDependencies`, or `optionalDependencies` contains `@nuxt/test-utils`.
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.

This rule is automatic in interactive and non-interactive migrations; there is no per-file prompt. A migration reports:

```text
• Kept upstream `vitest` imports in 135 files for @nuxt/test-utils compatibility
```

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.

## 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; 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/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

| Area                                                             | Change                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `crates/vp_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/vp_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; preserve `vite` and `vite/*` specifiers in plugin packages (unscoped name `vite-plugin-*` or `unplugin-*`, or `vite` in peer/runtime deps) so published plugins stay vite-consumable; 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 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 `vp_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 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` |
| 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`                                                                 |
| 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 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 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`                                                    |
| 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`                                                     |
| pnpm below 10.6.2 keeps `package.json` `catalog:` overrides while rewriting the catalog     | `migration-upgrade-pnpm9-overrides`                                                        |
| 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`          |

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)

- 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.
