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. 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" 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..21a073d188 --- /dev/null +++ b/.github/scripts/test-pkg-pr-new-migrate.sh @@ -0,0 +1,328 @@ +#!/usr/bin/env bash + +set -euo pipefail + +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. + +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 + +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 + +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" { + count = split($2, parts, ":") + print parts[count] + exit + } + ' +} + +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 + +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" + +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 +} + +installed_commit="$(read_installed_commit || true)" +current_target="$(readlink "$pr_home/current" 2>/dev/null || true)" +reuse_install=0 + +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 + reuse_install=1 +fi + +cleanup() { + rm -rf "$installer_home" +} +trap cleanup EXIT + +if [ "$reuse_install" -eq 1 ]; then + 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 [ -n "$installed_commit" ] && [ "$installed_commit" != "$resolved_ref" ]; then + echo "pkg.pr.new build changed: $installed_commit -> $resolved_ref" + elif [ -n "$installed_commit" ]; then + 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 + + # 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" \ + VP_PR_VERSION="$resolved_ref" \ + VP_NODE_MANAGER=no \ + bash "$installer" + + 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 + echo "error: installed vp executable not found: $vp_bin" >&2 + exit 1 +fi + +if [ ! -f "$vite_plus_package_json" ]; then + echo "error: installed vite-plus package not found: $vite_plus_package_json" >&2 + 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 + exit 1 +fi + +export VP_HOME="$pr_home" +export PATH="$VP_HOME/bin:$PATH" +# 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\"}" +# 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. +# 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 +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 " registry bridge: $bridge_registry" +echo " project .npmrc: $project_npmrc" +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 +( + # 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" + 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 + +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/.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/.github/workflows/publish-to-pkg.pr.new.yml b/.github/workflows/publish-to-pkg.pr.new.yml index 79279257c2..df07fce0ae 100644 --- a/.github/workflows/publish-to-pkg.pr.new.yml +++ b/.github/workflows/publish-to-pkg.pr.new.yml @@ -137,3 +137,93 @@ 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 + 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 + + # 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 + 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, + }); + } diff --git a/AGENTS.md b/AGENTS.md index d19082c8bd..8c0d2f40f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,8 @@ 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`. +- **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. 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_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..bbf25fa9b6 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")); + // Unparsable 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/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/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/import_rewriter.rs b/crates/vite_migration/src/import_rewriter.rs index d0de5a0840..7b94ae88f7 100644 --- a/crates/vite_migration/src/import_rewriter.rs +++ b/crates/vite_migration/src/import_rewriter.rs @@ -1575,6 +1575,40 @@ 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()) +} + +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_unscoped_vitest_rule) + .collect() +}); + +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_unscoped_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") }); @@ -1689,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 (/// bool { @@ -1937,15 +1995,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 +2013,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 +2043,8 @@ struct RewriteResult { pub content: String, /// Whether any changes were made pub updated: bool, + /// Whether an upstream `vitest` specifier was intentionally preserved. + pub preserved_vitest: bool, } /// Result of rewriting imports in multiple files @@ -1981,6 +2054,8 @@ pub struct BatchRewriteResult { pub modified_files: Vec, /// Files that had no changes pub unchanged_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)>, } @@ -2021,47 +2096,60 @@ enum FileResult { /// } /// ``` pub fn rewrite_imports_in_directory(root: &Path) -> Result { + rewrite_imports_in_directory_with_options(root, RewriteImportsOptions::default()) +} + +/// Rewrite imports with package-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_vitest_in_nuxt_packages && 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_vitest) } } else { - (file_path, FileResult::Unchanged) + (file_path, FileResult::Unchanged, rewrite_result.preserved_vitest) } } - Err(e) => (file_path, FileResult::Error(e.to_string())), + Err(e) => (file_path, FileResult::Error(e.to_string()), false), } }) .collect(); @@ -2070,10 +2158,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 +2192,16 @@ pub fn rewrite_imports_in_directory(root: &Path) -> Result Result { +fn rewrite_import( + file_path: &Path, + skip_packages: &SkipPackages, + preserve_vitest_in_nuxt_package: bool, +) -> Result { // Read the file let content = std::fs::read_to_string(file_path)?; // Rewrite the imports - rewrite_import_content(&content, skip_packages) + 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. @@ -2128,17 +2224,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_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 }); + return Ok(RewriteResult { + content: content.to_string(), + updated: false, + preserved_vitest: false, + }); } let mut new_content = content.to_string(); let mut updated = false; + let mut preserved_vitest = false; // Apply vite rules if not skipped (using pre-parsed rules) if !skip_packages.skip_vite { @@ -2151,7 +2261,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_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 + }; + let vitest_content = ast_grep::apply_loaded_rules(&new_content, vitest_rules); if vitest_content != new_content { new_content = vitest_content; updated = true; @@ -2169,9 +2287,9 @@ fn rewrite_import_content( // 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 }) + Ok(RewriteResult { content: new_content, updated, preserved_vitest }) } #[cfg(test)] @@ -2301,7 +2419,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 +2896,85 @@ describe('test', () => {});"#, assert!(!utils_content.contains("vite-plus")); } + #[test] + fn test_preserves_unscoped_vitest_in_nuxt_test_utils_packages() { + 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"), + "/// \nimport { expect } from 'vitest';\n", + ) + .unwrap(); + + let result = rewrite_imports_in_directory_with_options( + temp.path(), + RewriteImportsOptions { preserve_vitest_in_nuxt_packages: true }, + ) + .unwrap(); + + 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 '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 'vitest'")); + assert!(ordinary.contains("types=\"vitest/globals\"")); + } + + #[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_vitest_in_nuxt_packages: true }, + ) + .unwrap(); + + 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'")); + } + #[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/crates/vite_migration/src/package.rs b/crates/vite_migration/src/package.rs index 0a8b089173..511a288e8e 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,44 @@ fix: vp pack rewrite_script("NODE_ENV=test oxlint --type-aware", &rules), "NODE_ENV=test vp lint --type-aware" ); + // 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 --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 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), + "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..26f8067562 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,190 @@ 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)?; + + // 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" { + 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 `--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, +) -> 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 new file mode 100644 index 0000000000..04a6a1b7c6 --- /dev/null +++ b/docs/guide/migrate-rules.md @@ -0,0 +1,257 @@ +# 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 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. 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. 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 + +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. 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 +`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. +- 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 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 +`--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. + +## 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. +- 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 + 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. +- 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. + +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 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/docs/guide/migrate.md b/docs/guide/migrate.md index c5472a25c8..c954fd5de3 100644 --- a/docs/guide/migrate.md +++ b/docs/guide/migrate.md @@ -48,6 +48,10 @@ 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. Most projects will require further manual adjustments after running `vp migrate`. diff --git a/ecosystem-ci/repo.json b/ecosystem-ci/repo.json index 052fb48ee0..3a5940a04e 100644 --- a/ecosystem-ci/repo.json +++ b/ecosystem-ci/repo.json @@ -94,8 +94,9 @@ "npmx.dev": { "repository": "https://github.com/npmx-dev/npmx.dev.git", "branch": "main", - "hash": "230b7c7ddb6bb8551ce797144f0ce0f047ff8d7d", - "forceFreshMigration": true + "hash": "035776c96cf8f089c44e6011264b534b0bcde53c", + "forceFreshMigration": true, + "playwright": true }, "vite-plus-jest-dom-repro": { "repository": "https://github.com/why-reproductions-are-required/vite-plus-jest-dom-repro.git", 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 50de42f8fd..dd3d86646e 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; + /** Files in Nuxt test-utils packages where upstream `vitest` imports were preserved */ + preservedVitestFiles: Array; /** Files that had errors */ errors: Array; } @@ -3494,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. * @@ -3520,6 +3611,8 @@ export declare function rewriteEslint(scriptsJson: string): string | null; * # Arguments * * * `root` - The root directory to search for files + * * `preserve_vitest_in_nuxt_packages` - Preserve `vitest` and `vitest/*` + * specifiers throughout packages that declare `@nuxt/test-utils` * * # Returns * @@ -3537,7 +3630,10 @@ export declare function rewriteEslint(scriptsJson: string): string | null; * } * ``` */ -export declare function rewriteImportsInDirectory(root: string): BatchRewriteResult; +export declare function rewriteImportsInDirectory( + root: string, + preserveVitestInNuxtPackages?: 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..ebad3d3701 100644 --- a/packages/cli/binding/src/migration.rs +++ b/packages/cli/binding/src/migration.rs @@ -2,6 +2,203 @@ 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)) +} + +/// 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`. +/// 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, + 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(); + resolved_if_supported(resolved, &supported) +} + +/// 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)?; + + Ok(resolved_if_supported(latest.to_string(), &supported)) +} + +/// 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 /// @@ -197,6 +394,8 @@ pub struct BatchRewriteError { pub struct BatchRewriteResult { /// Files that were modified pub modified_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, } @@ -266,6 +465,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_vitest_in_nuxt_packages: Option, +) -> Result { + let result = vite_migration::rewrite_imports_in_directory_with_options( + Path::new(&root), + vite_migration::RewriteImportsOptions { + preserve_vitest_in_nuxt_packages: preserve_vitest_in_nuxt_packages.unwrap_or(false), + }, + ) + .map_err(anyhow::Error::from)?; Ok(BatchRewriteResult { modified_files: result @@ -293,6 +502,11 @@ pub fn rewrite_imports_in_directory(root: String) -> Result .iter() .map(|p| p.to_string_lossy().to_string()) .collect(), + preserved_vitest_files: result + .preserved_vitest_files + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect(), errors: result .errors .iter() @@ -303,3 +517,229 @@ pub fn rewrite_imports_in_directory(root: String) -> Result .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/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. 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..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,19 +26,15 @@ > 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,8 +14,8 @@ "prepare": "vp config" }, "devDependencies": { - "vite": "^7.0.0", - "vite-plus": "latest" + "vite": "catalog:", + "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..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,8 +13,8 @@ "prepare": "vp config" }, "devDependencies": { - "vite": "^7.0.0", - "vite-plus": "latest" + "vite": "catalog:", + "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..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 @@ -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 @@ -12,11 +12,10 @@ { "name": "migration-already-vite-plus", "devDependencies": { - "vite-plus": "latest" + "vite-plus": "" }, "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "" + "vite": "npm:@voidzero-dev/vite-plus-core@" }, "devEngines": { "packageManager": { 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-auto-create-vite-config/snap.txt b/packages/cli/snap-tests-global/migration-auto-create-vite-config/snap.txt index e8971d018f..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,16 +57,12 @@ 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,16 +60,12 @@ 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' 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..6dd611b66b --- /dev/null +++ b/packages/cli/snap-tests-global/migration-bunfig-inline-array/snap.txt @@ -0,0 +1,9 @@ +> 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 Bun configuration is unchanged +[install] +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..f0b62921ef --- /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 Bun configuration is unchanged" + ] +} 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..b1e0633677 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-bunfig-missing/snap.txt @@ -0,0 +1,6 @@ +> vp migrate --no-interactive # migration does not create bunfig.toml +◇ Migrated . to Vite+ +• Node bun +• 2 config updates applied + +> 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 new file mode 100644 index 0000000000..d4580d8a44 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-bunfig-missing/steps.json @@ -0,0 +1,6 @@ +{ + "commands": [ + "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/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..a54d454753 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-bunfig-no-install-section/snap.txt @@ -0,0 +1,8 @@ +> 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 Bun configuration is unchanged +[run] +shell = "bun" 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..af78bbf93c --- /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 preserves bunfig.toml without an install section", + "cat bunfig.toml # check Bun configuration is unchanged" + ] +} 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..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,19 +26,15 @@ > 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,19 +26,15 @@ > 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,16 +26,12 @@ > 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,19 +26,15 @@ > 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,19 +26,15 @@ > 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,19 +26,15 @@ > 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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" }, @@ -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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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-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/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..4c46bc311c 100644 --- a/packages/cli/snap-tests-global/migration-eslint/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint/snap.txt @@ -29,19 +29,15 @@ > 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,19 +26,15 @@ > 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,19 +29,15 @@ > 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,19 +29,15 @@ > 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,19 +26,15 @@ > 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,19 +26,15 @@ > 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,19 +26,15 @@ > 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,19 +27,15 @@ > 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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-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-from-tsdown-json-config/snap.txt b/packages/cli/snap-tests-global/migration-from-tsdown-json-config/snap.txt index 16087a6ade..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,19 +51,15 @@ 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,19 +53,15 @@ 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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-vitest-config/snap.txt b/packages/cli/snap-tests-global/migration-from-vitest-config/snap.txt index 9a6c718500..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:" @@ -59,9 +59,10 @@ 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: + '@vitest/browser-webdriverio': 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 3a30efa064..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,19 +29,15 @@ > 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,19 +34,15 @@ packages: catalog: husky: ^9.1.7 lint-staged: ^16.2.6 - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,16 +26,12 @@ > 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,16 +28,12 @@ > 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,16 +26,12 @@ > 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,16 +26,12 @@ > 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,16 +32,12 @@ > 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,16 +35,12 @@ 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,19 +27,15 @@ > 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,19 +35,15 @@ 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,19 +30,15 @@ > 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,19 +100,15 @@ 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,19 +32,15 @@ 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,16 +44,12 @@ 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,19 +27,15 @@ > 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,16 +57,12 @@ 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' 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 b32ff43659..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 @@ -38,23 +38,20 @@ packages: - packages/* catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: - '@vitejs/plugin-react>vite': 'npm:vite@' - 'supertest>superagent': - vite: 'catalog:' - vitest: 'catalog:' + '@vitejs/plugin-react>vite': npm:vite@ + supertest>superagent: react-click-away-listener>react: + vite: 'catalog:' + '@vitejs/plugin-react-swc>vite': npm:vite@ 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..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 6bd15e4900..2e8b54bad1 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'; @@ -65,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": "" } } @@ -76,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-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..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,19 +24,15 @@ > 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,19 +31,15 @@ > 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,19 +22,15 @@ > 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,16 +34,12 @@ > 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,16 +55,12 @@ 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,16 +57,12 @@ 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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 @@ -27,8 +27,8 @@ "@vitejs/plugin-react": "^6.0.1", "globals": "^17.6.0", "typescript": "~6.0.2", - "vite": "^8.0.12", - "vite-plus": "^0.1.24" + "vite": "catalog:", + "vite-plus": "catalog:" }, "devEngines": { "packageManager": { @@ -41,19 +41,15 @@ > cat pnpm-workspace.yaml # pnpm overrides and peerDependencyRules should be configured catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,19 +29,15 @@ > 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,19 +33,15 @@ 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,18 +31,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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,19 +28,15 @@ 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,19 +29,15 @@ 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..98486f0ebc 100644 --- a/packages/cli/snap-tests-global/migration-prettier/snap.txt +++ b/packages/cli/snap-tests-global/migration-prettier/snap.txt @@ -31,19 +31,15 @@ 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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-rewrite-declare-module/snap.txt b/packages/cli/snap-tests-global/migration-rewrite-declare-module/snap.txt index 052b0d5b4f..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 @@ -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,6 +39,7 @@ declare module 'vitest/config' { "name": "migration-rewrite-declare-module", "devDependencies": { "vite": "catalog:", + "vitest": "catalog:", "vite-plus": "catalog:" }, "devEngines": { @@ -55,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-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-skip-vite-dependency/snap.txt b/packages/cli/snap-tests-global/migration-skip-vite-dependency/snap.txt index d74639391b..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,16 +49,12 @@ 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,16 +49,12 @@ 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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 @@ -8,16 +12,14 @@ { "name": "migration-standalone-npm", "devDependencies": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "", - "vite-plus": "latest" + "vite": "npm:@voidzero-dev/vite-plus-core@", + "vite-plus": "" }, - "packageManager": "npm@", "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "" - } + "vite": "npm:@voidzero-dev/vite-plus-core@" + }, + "packageManager": "npm@" } -[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-standalone-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-standalone-pnpm/snap.txt index 53b010c9be..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 @@ -9,7 +13,6 @@ "name": "migration-standalone-pnpm", "devDependencies": { "vite": "catalog:", - "vitest": "catalog:", "vite-plus": "catalog:" }, "packageManager": "pnpm@" @@ -17,16 +20,12 @@ > 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: - vite: 'catalog:' - vitest: 'catalog:' + vite: "catalog:" peerDependencyRules: allowAny: - vite - - vitest allowedVersions: - vite: '*' - vitest: '*' + vite: "*" 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..dd4f96ad7b --- /dev/null +++ b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/snap.txt @@ -0,0 +1,44 @@ +> 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 +{ + "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@" + } +} + +> 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@ + vite-plus: + +> 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..09ea344a15 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/steps.json @@ -0,0 +1,9 @@ +{ + "commands": [ + "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", + "vp migrate --no-interactive # a freshly migrated standalone Yarn project is complete" + ] +} diff --git a/packages/cli/snap-tests-global/migration-subpath/snap.txt b/packages/cli/snap-tests-global/migration-subpath/snap.txt index d8e7263702..b89fab770c 100644 --- a/packages/cli/snap-tests-global/migration-subpath/snap.txt +++ b/packages/cli/snap-tests-global/migration-subpath/snap.txt @@ -44,16 +44,12 @@ 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,16 +46,12 @@ 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' 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..b6769c1cd5 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/snap.txt @@ -0,0 +1,46 @@ +> 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": "catalog:", + "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@ + vite-plus: + vitest: + '@vitest/browser-playwright': +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-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..0a7b84d588 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/snap.txt @@ -0,0 +1,39 @@ +> 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": "catalog:", + "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@ + vite-plus: + vitest: + '@vitest/browser-playwright': +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..5c7bde5526 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/snap.txt @@ -0,0 +1,42 @@ +> 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": "catalog:", + "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@ + vite-plus: + vitest: + '@vitest/browser-webdriverio': +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-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..2f2872b2b8 --- /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": "", + "vitest": "" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@", + "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-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..614252bdef --- /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@ + vite-plus: + 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-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..62fd9dd6b8 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/snap.txt @@ -0,0 +1,29 @@ +> vp migrate --no-interactive # nested Vitest override is user-owned and not pending removal +◇ Migrated . to Vite+ +• Node npm +• Package manager settings configured + +> cat package.json # object-valued override is preserved +{ + "name": "migration-upgrade-nested-vitest-override-npm", + "devDependencies": { + "vite-plus": "" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@", + "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-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/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/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..0098d1f77f --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/snap.txt @@ -0,0 +1,68 @@ +> vp migrate --no-interactive # preserve upstream Vitest package-wide and localize it to the affected workspace +◇ Migrated . to Vite+ +• Node pnpm +• 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 +{ + "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: + vitest: + vite: npm:@voidzero-dev/vite-plus-core@ + +overrides: + vite: 'catalog:' + vitest: 'catalog:' +peerDependencyRules: + allowAny: + - vite + - vitest + allowedVersions: + vite: '*' + vitest: '*' + +> 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 'vitest/node'; + +mockNuxtImport('useExample', () => vi.fn()); +void expect; +void startVitest; + +> 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; + +> 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..b454ea054b --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/steps.json @@ -0,0 +1,18 @@ +{ + "commands": [ + "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 # 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": [ + { + "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..7078ddd2c8 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/snap.txt @@ -0,0 +1,46 @@ +> vp migrate --no-interactive # preserve upstream Vitest throughout packages that declare @nuxt/test-utils +◇ Migrated . to Vite+ +• Node npm +• 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 package-level exception +{ + "name": "migration-upgrade-nuxt-test-utils", + "devDependencies": { + "@nuxt/test-utils": "file:.fixture/nuxt-test-utils", + "vite-plus": "", + "vitest": "" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@", + "vitest": "" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "", + "onFail": "download" + } + } +} + +> 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 'vitest/config'; + +mockNuxtImport('useExample', () => vi.fn()); +void page; +void defineConfig; + +> 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 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 new file mode 100644 index 0000000000..a3c9b5ae00 --- /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 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": [ + { + "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-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..9937c35595 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/snap.txt @@ -0,0 +1,40 @@ +> 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@ + vite-plus: +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-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..9f04d1e641 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/snap.txt @@ -0,0 +1,25 @@ +> vp migrate --no-interactive # bridge commit builds replace every stale managed spec +◇ Migrated . to Vite+ +• Node npm +• Package manager settings configured + +> cat package.json # direct dependencies and npm overrides use the same immutable commit version +{ + "name": "migration-upgrade-pkg-pr-new-npm", + "devDependencies": { + "vite": "npm:@voidzero-dev/vite-plus-core@", + "vite-plus": "" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@" + }, + "packageManager": "npm@" +} + +> 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 # 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 new file mode 100644 index 0000000000..74bba51572 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/steps.json @@ -0,0 +1,14 @@ +{ + "env": { + "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 # 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 # 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/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..66469f4482 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/snap.txt @@ -0,0 +1,47 @@ +> vp migrate --no-interactive # bridge commit builds upgrade like an ordinary npm version +◇ Migrated . to Vite+ +• Node pnpm +• Package manager settings configured + +> cat package.json # direct dependencies use catalogs aligned to the bridge build +{ + "name": "migration-upgrade-pkg-pr-new-pnpm", + "devDependencies": { + "@vitest/coverage-v8": "4.1.9", + "vite": "catalog:", + "vite-plus": "catalog:", + "vitest": "catalog:" + }, + "packageManager": "pnpm@" +} + +> cat pnpm-workspace.yaml # the catalog holds the immutable commit version +packages: + - . + +blockExoticSubdeps: true + +catalog: + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: + 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'); 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 # 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 new file mode 100644 index 0000000000..d15ef08956 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/steps.json @@ -0,0 +1,15 @@ +{ + "env": { + "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 # 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 # 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-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..9153456e8c --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-pnpm-catalogs-default/snap.txt @@ -0,0 +1,46 @@ +> vp migrate --no-interactive # reuse the managed named catalog beside catalogs.default +◇ Migrated . to Vite+ +• Node pnpm +• Package manager settings configured + +> 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" + } + } +} + +> cat pnpm-workspace.yaml # catalogs.default remains the only default catalog definition +packages: + - . + +catalogs: + build: + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: + default: + rari: ^0.14.12 +overrides: + vite: catalog:build +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 +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 new file mode 100644 index 0000000000..4fc78638a4 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-pnpm-catalogs-default/steps.json @@ -0,0 +1,12 @@ +{ + "env": {}, + "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..5df69b209c --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-pnpm-named-catalog/snap.txt @@ -0,0 +1,47 @@ +> vp migrate --no-interactive # reuse the existing named-only Vite stack catalog +◇ Migrated . to Vite+ +• Node pnpm +• Package manager settings configured + +> 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" + } + } +} + +> cat pnpm-workspace.yaml # the bridge commit version is written into vite-stack +packages: + - . + +catalogs: + repo-tooling: + prettier: + vite-stack: + vite: npm:@voidzero-dev/vite-plus-core@ + vitest: + vite-plus: +overrides: + vite: catalog:vite-stack +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'); 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 +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 new file mode 100644 index 0000000000..c8b9488b04 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-pnpm-named-catalog/steps.json @@ -0,0 +1,15 @@ +{ + "env": { + "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 # 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/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..7fc7f09994 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/snap.txt @@ -0,0 +1,30 @@ +> vp migrate --no-interactive # clean checkout conservatively preserves existing Vitest +◇ 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": "", + "vitest": "" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@", + "vitest": "" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "", + "onFail": "download" + } + } +} + +> 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 new file mode 100644 index 0000000000..46f3b70402 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/steps.json @@ -0,0 +1,8 @@ +{ + "commands": [ + "vp migrate --no-interactive # clean checkout conservatively preserves existing Vitest", + "cat package.json # package-local Vitest and its shared override remain aligned", + "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-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..3367f7a492 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/snap.txt @@ -0,0 +1,33 @@ +> 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; empty pnpm field should be removed +{ + "name": "migration-upgrade-stale-local-pnpm", + "devDependencies": { + "vite": "catalog:", + "vite-plus": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } +} + +> cat pnpm-workspace.yaml # pnpm settings should be consolidated here +overrides: + vite: 'catalog:' +peerDependencyRules: + allowAny: + - vite + allowedVersions: + vite: '*' +catalog: + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..f32489a2b5 --- /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; empty pnpm field should be removed", + "cat pnpm-workspace.yaml # pnpm settings should be consolidated here" + ] +} 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..2edd4a9266 --- /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@" + }, + "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..86fcab4b69 --- /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": "", + "vitest": "" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@", + "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..8a24282c5c --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/snap.txt @@ -0,0 +1,39 @@ +> 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 + +> 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@", + "vitest": "" + }, + "devEngines": { + "packageManager": { + "name": "yarn", + "version": "", + "onFail": "download" + } + } +} + +> cat .yarnrc.yml # linker conversion and aligned Vitest catalog are persisted +nodeLinker: node-modules +catalog: + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: + 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..2c014edafb --- /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 converts to node-modules before exact-peer migration", + "cat package.json # direct deps and resolutions should use the managed catalog/version", + "cat .yarnrc.yml # linker conversion and aligned Vitest catalog are persisted" + ] +} 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..4698aff3e8 --- /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": "" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@" + }, + "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-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..771edda50e --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/snap.txt @@ -0,0 +1,42 @@ +> 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 # rewritten directive does not retain a redundant Vitest dependency +{ + "name": "migration-upgrade-vitest-reference-whitespace-pnpm", + "devDependencies": { + "vite": "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 # rewritten directive does not retain shared Vitest management +catalog: + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: +overrides: + vite: 'catalog:' +peerDependencyRules: + allowAny: + - vite + allowedVersions: + vite: '*' + +> 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 new file mode 100644 index 0000000000..188941dff5 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/steps.json @@ -0,0 +1,9 @@ +{ + "commands": [ + "vp migrate --no-interactive # TypeScript whitespace in a Vitest type directive is valid", + "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 # 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/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/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/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 new file mode 100644 index 0000000000..53707ada64 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/snap.txt @@ -0,0 +1,50 @@ +> 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": "", + "vitest": "" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@", + "vitest": "" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "", + "onFail": "download" + } + } +} + +> cat tsconfig.json # compilerOptions.types remains an upstream Vitest reference +{ + "compilerOptions": { + "types": ["vitest/globals"] + } +} + +> 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'; + +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..0f3fbd9146 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/steps.json @@ -0,0 +1,11 @@ +{ + "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 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/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-vite-version/snap.txt b/packages/cli/snap-tests-global/migration-vite-version/snap.txt index 9cdfbfdcad..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,16 +26,12 @@ > 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 + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' 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..ab2d6feba2 --- /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@ + vite-plus: +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-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/package.json b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/package.json new file mode 100644 index 0000000000..184290e34f --- /dev/null +++ b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/package.json @@ -0,0 +1,11 @@ +{ + "name": "migration-vitest-unmanaged-override", + "scripts": { + "test": "vitest" + }, + "devDependencies": { + "@vitest/ui": "4.0.13", + "vite": "^7.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 new file mode 100644 index 0000000000..0c4eb4176f --- /dev/null +++ b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/snap.txt @@ -0,0 +1,43 @@ +> 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 and exact-peer UI versions should both be preserved +{ + "name": "migration-vitest-unmanaged-override", + "scripts": { + "test": "vp test", + "prepare": "vp config" + }, + "devDependencies": { + "@vitest/ui": "", + "vite": "catalog:", + "vitest": "", + "vite-plus": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } +} + +> 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 + vite-plus: +overrides: + vite: 'catalog:' +peerDependencyRules: + allowAny: + - 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 new file mode 100644 index 0000000000..767e603b45 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/steps.json @@ -0,0 +1,12 @@ +{ + "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 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/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/snap-tests-global/new-vite-monorepo-bun/snap.txt b/packages/cli/snap-tests-global/new-vite-monorepo-bun/snap.txt index 931bc042ab..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 @@ -30,8 +29,7 @@ vite.config.ts "vite-plus": "catalog:" }, "overrides": { - "vite": "catalog:", - "vitest": "catalog:" + "vite": "catalog:" }, "devEngines": { "packageManager": { @@ -44,9 +42,8 @@ vite.config.ts "node": ">=22.18.0" }, "catalog": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "", - "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 4dc309f2fe..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,19 +65,15 @@ catalogMode: prefer catalog: "@types/node": ^24 typescript: ^5 - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,12 +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", - "vitest": "" + "vite": "npm:@voidzero-dev/vite-plus-core@" }, "devEngines": { "packageManager": { @@ -57,12 +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", - "vitest": "" + "vite": "npm:@voidzero-dev/vite-plus-core@" }, "devEngines": { "packageManager": { @@ -93,12 +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", - "vitest": "" + "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 ac8879bff3..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,19 +10,15 @@ 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 - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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 @@ -40,19 +36,15 @@ 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 - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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){}" @@ -62,16 +54,12 @@ peerDependencyRules: allowBuilds: core-js: true catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..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,19 +8,15 @@ allowBuilds: core-js: true catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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 @@ -36,19 +32,15 @@ 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 - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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){}" @@ -58,16 +50,12 @@ peerDependencyRules: allowBuilds: core-js: true catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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..497b533e1c 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": { @@ -24,8 +24,7 @@ } }, "resolutions": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "" + "vite": "npm:@voidzero-dev/vite-plus-core@" }, "devEngines": { "packageManager": { @@ -58,11 +57,10 @@ 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", - "vitest": "" + "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 3fe290bc75..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,19 +25,15 @@ packages: - apps/* - packages/* catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: 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 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..f98e748c25 --- /dev/null +++ b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/snap.txt @@ -0,0 +1,35 @@ +[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' 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 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 # 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 'vitest/node'; + +mockNuxtImport('useExample', () => vi.fn()); +void expect; +void startVitest; + +> cat src/unit.spec.ts +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 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/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..ec1d98893d --- /dev/null +++ b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/src/unit.spec.ts @@ -0,0 +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 new file mode 100644 index 0000000000..454491842b --- /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 --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 package-level 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/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/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/__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..0c66f92072 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,15 @@ 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', +); +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', () => { expect( @@ -147,8 +158,22 @@ 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, + }, + { + 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. @@ -211,5 +236,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: 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';`, + 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__/__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__/bun-catalog-file-protocol.spec.ts b/packages/cli/src/migration/__tests__/bun-catalog-file-protocol.spec.ts index d04dbce46c..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', () => { @@ -195,7 +187,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', ); @@ -203,4 +197,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__/compat-runner.spec.ts b/packages/cli/src/migration/__tests__/compat-runner.spec.ts new file mode 100644 index 0000000000..d56541a2d3 --- /dev/null +++ b/packages/cli/src/migration/__tests__/compat-runner.spec.ts @@ -0,0 +1,61 @@ +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, + // fileURLToPath yields OS-native separators: '/' on POSIX, '\' on Windows. + 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/__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/__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 new file mode 100644 index 0000000000..a4d7796566 --- /dev/null +++ b/packages/cli/src/migration/__tests__/format.spec.ts @@ -0,0 +1,185 @@ +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, collectChangedFormatPaths, 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(); + const collectPaths = vi.fn().mockResolvedValue(['package.json', 'vite.config.ts']); + const excludedPaths = new Set(['notes.md']); + + await expect( + 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, + 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('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(); + 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, + 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.', + ]); + }); +}); + +describe('collectChangedFormatPaths', () => { + 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, 'template.mdx'), '# untouched\n'); + fs.writeFileSync(path.join(projectRoot, 'bun.lock'), 'lockfileVersion = 1\n'); + 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.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, new Set(preExistingPaths)), + ).resolves.toEqual(['future.custom', '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); + }); + + 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/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index cae77d6282..0afcd3c58f 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 { @@ -39,9 +46,102 @@ const { detectIncompatibleEslintIntegration, preflightGitHooksSetup, 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 = {}; + 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: 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(); + }); + + 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 = { @@ -73,6 +173,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', @@ -1313,11 +1424,39 @@ 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('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'), JSON.stringify({ @@ -1335,22 +1474,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 `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. + // 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.vitest).toBe('4.1.9'); + 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'), @@ -1378,7 +1557,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', () => { @@ -1423,6 +1604,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@0c515e3fbf5c140db35280d700df0bd600838617'; + 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'), @@ -1464,153 +1705,1136 @@ describe('ensureVitePlusBootstrap', () => { expect(workspace.catalog['vite-plus']).toBe('latest'); }); - it('uses a concrete vite-plus version when pnpm config stays in package.json', () => { + 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: 'test', - dependencies: { 'vite-plus': 'latest' }, + 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'), + ); - const result = ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); - expect(result.changed).toBe(true); - expect(fs.existsSync(path.join(tmpDir, 'pnpm-workspace.yaml'))).toBe(false); + // 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; - pnpm: { overrides: Record }; }; - expect(pkg.devDependencies['vite-plus']).toBe('latest'); - expect(pkg.pnpm.overrides.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + 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); }); - it('normalizes an existing catalog vite-plus pin when pnpm config stays in package.json', () => { + 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': 'catalog:' }, - devEngines: { - packageManager: { name: 'pnpm', version: '10.33.0', onFail: 'download' }, + devDependencies: { + 'vite-plus': 'latest', + '@vitest/coverage-v8': '^4.1.8', }, - pnpm: { - overrides: { - vite: 'npm:@voidzero-dev/vite-plus-core@latest', - vitest: 'npm:@voidzero-dev/vite-plus-test@latest', - }, - peerDependencyRules: { - allowAny: ['vite', 'vitest'], - allowedVersions: { vite: '*', vitest: '*' }, - }, + 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.pnpm)).toBe(true); - const result = ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(true); + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.npm)); - expect(result.changed).toBe(true); - expect(fs.existsSync(path.join(tmpDir, 'pnpm-workspace.yaml'))).toBe(false); const pkg = readJson(path.join(tmpDir, 'package.json')) as { devDependencies: Record; }; - expect(pkg.devDependencies['vite-plus']).toBe('latest'); - expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); + expect(pkg.devDependencies['@vitest/coverage-v8']).toBe(VITEST_VERSION); + expect(pkg.devDependencies.vitest).toBe(VITEST_VERSION); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(false); }); - it('normalizes catalog vite-plus pins outside devDependencies when pnpm config stays in package.json', () => { + 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', - dependencies: { 'vite-plus': 'catalog:' }, - optionalDependencies: { 'vite-plus': 'catalog:' }, - devEngines: { - packageManager: { name: 'pnpm', version: '10.33.0', onFail: 'download' }, + devDependencies: { + 'vite-plus': 'latest', + '@vitest/ui': '^4.1.0', + '@vitest/web-worker': '^4.1.0', + '@vitest/eslint-plugin': '^1.0.0', }, - pnpm: { - overrides: { - vite: 'npm:@voidzero-dev/vite-plus-core@latest', - vitest: 'npm:@voidzero-dev/vite-plus-test@latest', - }, - peerDependencyRules: { - allowAny: ['vite', 'vitest'], - allowedVersions: { vite: '*', vitest: '*' }, - }, + overrides: { + vite: 'npm:@voidzero-dev/vite-plus-core@latest', + }, + devEngines: { + packageManager: { name: 'npm', version: '10.33.0', onFail: 'download' }, }, }), ); - expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(true); - const result = ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.npm)); - expect(result.changed).toBe(true); - expect(fs.existsSync(path.join(tmpDir, 'pnpm-workspace.yaml'))).toBe(false); const pkg = readJson(path.join(tmpDir, 'package.json')) as { devDependencies: Record; - dependencies: Record; - optionalDependencies: Record; }; - expect(pkg.devDependencies['vite-plus']).toBe('latest'); - expect(pkg.dependencies['vite-plus']).toBe('latest'); - expect(pkg.optionalDependencies['vite-plus']).toBe('latest'); - expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); + 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('uses a concrete vite-plus version for pnpm monorepos that keep pnpm config in package.json', () => { + 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: 'test', - dependencies: { 'vite-plus': 'latest' }, - pnpm: {}, + name: 'root', + private: true, + devDependencies: { 'vite-plus': 'catalog:' }, + devEngines: { + packageManager: { name: 'pnpm', version: '10.33.0', onFail: 'download' }, + }, }), ); - - const result = ensureVitePlusBootstrap({ + 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(result.changed).toBe(true); - expect(fs.existsSync(path.join(tmpDir, 'pnpm-workspace.yaml'))).toBe(false); - const pkg = readJson(path.join(tmpDir, 'package.json')) as { + 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['vite-plus']).toBe('latest'); + 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('keeps yarn monorepo bootstrap rewrites out of package dependency specs', () => { + 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', vite: '^7.0.0' }, + devDependencies: { + 'vite-plus': 'latest', + '@vitest/coverage-c8': '^0.33.0', + }, + overrides: { + vite: 'npm:@voidzero-dev/vite-plus-core@latest', + }, devEngines: { - packageManager: { name: 'yarn', version: '4.0.0', onFail: 'download' }, + packageManager: { name: 'npm', version: '10.33.0', onFail: 'download' }, }, }), ); - expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.yarn)).toBe(true); - const result = ensureVitePlusBootstrap({ - ...makeWorkspaceInfo(tmpDir, PackageManager.yarn), - isMonorepo: true, - workspacePatterns: ['packages/*'], - }); + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.npm)); - expect(result.changed).toBe(true); + 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('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', + writeReference: (projectPath: string) => + fs.writeFileSync( + path.join(projectPath, 'tsconfig.json'), + 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) => + fs.writeFileSync( + path.join(projectPath, 'version.ts'), + "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'), + 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'), + 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('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); + }); + + 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('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); + }); + + it('rewrites whitespace-tolerant Vitest directives without leaving rerun mutations', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'typed-library', + devDependencies: { vite: '^7.0.0', vitest: '^4.0.0' }, + }), + ); + 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'), + ); + + const workspaceInfo = makeWorkspaceInfo(tmpDir, PackageManager.pnpm); + rewriteStandaloneProject(tmpDir, workspaceInfo, true, true); + + 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', () => { + 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 — + // 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 + // 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('removes an empty pnpm field and creates pnpm-workspace.yaml on pnpm 10.6.2+', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + dependencies: { 'vite-plus': 'latest' }, + pnpm: {}, + }), + ); + + const result = ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); + + 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 { + dependencies: Record; + devDependencies?: Record; + pnpm?: unknown; + }; + // 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; + overrides: Record; + }; + expect(workspace.catalog['vite-plus']).toBe('latest'); + expect(workspace.overrides.vite).toBe('catalog:'); + }); + + it('moves existing pnpm settings to pnpm-workspace.yaml', () => { + 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' }, + }, + pnpm: { + overrides: { + 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: '*' }, + }, + }, + }), + ); + 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)); + + 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; + pnpm?: unknown; + }; + 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; + overrides: Record; + peerDependencyRules: { allowAny: string[]; allowedVersions: Record }; + }; + 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.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); + }); + + 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('keeps catalog vite-plus pins outside devDependencies while moving pnpm settings', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + dependencies: { 'vite-plus': 'catalog:' }, + optionalDependencies: { 'vite-plus': 'catalog:' }, + devEngines: { + packageManager: { name: 'pnpm', version: '10.33.0', onFail: 'download' }, + }, + pnpm: { + overrides: { + vite: 'npm:@voidzero-dev/vite-plus-core@latest', + vitest: 'npm:@voidzero-dev/vite-plus-test@latest', + }, + peerDependencyRules: { + allowAny: ['vite', 'vitest'], + allowedVersions: { vite: '*', vitest: '*' }, + }, + }, + }), + ); + + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(true); + const result = ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); + + 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; + optionalDependencies: Record; + pnpm?: unknown; + }; + // 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(); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); + }); + + it('uses workspace catalog settings for pnpm 10.6.2+ monorepos', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + dependencies: { 'vite-plus': 'latest' }, + pnpm: {}, + }), + ); + + const result = ensureVitePlusBootstrap({ + ...makeWorkspaceInfo(tmpDir, PackageManager.pnpm), + isMonorepo: true, + workspacePatterns: ['packages/*'], + }); + + 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 { + dependencies: Record; + devDependencies?: Record; + pnpm?: unknown; + }; + // 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(); + }); + + it('normalizes yarn monorepo dependency specs through the shared catalog', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { 'vite-plus': 'latest', vite: '^7.0.0' }, + devEngines: { + packageManager: { name: 'yarn', version: '4.0.0', onFail: 'download' }, + }, + }), + ); + + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.yarn)).toBe(true); + const result = ensureVitePlusBootstrap({ + ...makeWorkspaceInfo(tmpDir, PackageManager.yarn), + isMonorepo: true, + workspacePatterns: ['packages/*'], + }); + + expect(result.changed).toBe(true); expect(result.packageManagerConfig).toBe(true); expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.yarn)).toBe(false); const pkg = readJson(path.join(tmpDir, 'package.json')) as { 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; @@ -1618,7 +2842,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'); }); @@ -1652,13 +2878,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: '*', }); }); @@ -1778,6 +3010,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', () => { @@ -1846,7 +3107,25 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { expect(devDeps['vite-plus']).toBe('catalog:'); }); - it('keeps pnpm config in package.json when existing pnpm field present', () => { + 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'), JSON.stringify({ @@ -1860,29 +3139,27 @@ 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(); - // 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(); - // 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({ @@ -1900,8 +3177,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'])); @@ -1918,9 +3198,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', () => { @@ -1963,16 +3244,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(); @@ -1981,18 +3262,177 @@ 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'); - // 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. + // 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'); }); - 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('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 // 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 @@ -2027,10 +3467,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'); @@ -2113,11 +3555,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({ @@ -2136,9 +3576,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. @@ -2147,6 +3586,77 @@ 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('preserves all upstream Vitest imports in a Nuxt test-utils package', () => { + 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 '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', () => { // 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. @@ -2568,15 +4078,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); }); @@ -2609,11 +4121,118 @@ 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([ + ['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('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}'`, + ); + }, + ); + + 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('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'", + ); }); it('injects the playwright provider on a re-run from the migrated provider-subpath import', () => { @@ -2644,8 +4263,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', () => { @@ -2677,11 +4300,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); }); @@ -2712,11 +4337,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); }); @@ -2750,8 +4377,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). @@ -2762,7 +4390,8 @@ 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); }); @@ -3058,8 +4687,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'); @@ -3103,8 +4733,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', () => { @@ -3136,8 +4767,7 @@ 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 '*'. + // Never expose the deleted wrapper alias as a public peer range. expect(pkg.peerDependencies.vitest).toBe('*'); }); @@ -3984,7 +5614,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; @@ -4006,7 +5638,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; @@ -4024,7 +5658,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). @@ -4043,18 +5679,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', () => { @@ -4063,15 +5700,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', () => { @@ -4323,9 +5961,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 { @@ -4334,9 +5972,6 @@ 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'); }); }); @@ -4430,9 +6065,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(); @@ -4492,16 +6127,14 @@ 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'); }); @@ -4537,9 +6170,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/__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/__tests__/npm-reinstall.spec.ts b/packages/cli/src/migration/__tests__/npm-reinstall.spec.ts new file mode 100644 index 0000000000..d2dbc69986 --- /dev/null +++ b/packages/cli/src/migration/__tests__/npm-reinstall.spec.ts @@ -0,0 +1,100 @@ +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); + }); + + 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/bin.ts b/packages/cli/src/migration/bin.ts index 58ea7e75da..d9becfbcf4 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -45,6 +45,8 @@ 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 { canFormatWithOxfmt, collectChangedFormatPaths, formatMigratedProject } from './format.ts'; import { addFrameworkShim, checkVitestVersion, @@ -58,6 +60,7 @@ import { detectPendingCoreMigration, detectPrettierProject, detectVitePlusBootstrapPending, + detectYarnPnpMode, ensureVitePlusBootstrap, finalizeCoreMigrationForExistingVitePlus, hasFrameworkShim, @@ -68,9 +71,11 @@ import { migrateEslintToOxlint, migrateNodeVersionManagerFile, migratePrettierToOxfmt, + configureYarnNodeModulesMode, preflightGitHooksSetup, rewriteMonorepo, rewriteStandaloneProject, + upgradeUnsupportedNodeVersions, warnIncompatibleEslintIntegration, warnLegacyEslintConfig, warnPackageLevelEslint, @@ -78,6 +83,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( @@ -124,6 +130,45 @@ async function confirmFrameworkShim(framework: Framework, interactive: boolean): return true; } +async function confirmYarnNodeModulesMode( + 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.'); + } + } + + return true; +} + async function fixBaseUrlForWorkspace( workspaceInfo: { rootDir: string; packages?: WorkspacePackage[] }, fixBaseUrl: boolean, @@ -340,7 +385,9 @@ interface MigrationSetupPlan { interface MigrationPlan extends MigrationSetupPlan { packageManager: PackageManager; + convertYarnPnp: boolean; migratePrettier: boolean; + hasPrettierDependency: boolean; prettierConfigFile?: string; fixBaseUrl: boolean; migrateNodeVersionFile: boolean; @@ -628,12 +675,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 convertYarnPnp = await confirmYarnNodeModulesMode( + rootDir, + packageManager, + detectedPackageManager ? detectedPackageManagerVersion : 'latest', + options.interactive, + ); // 2. Shared setup/tooling decisions const setupPlan = await collectMigrationSetupPlan(rootDir, packageManager, options, packages); @@ -667,8 +721,10 @@ async function collectMigrationPlan( const plan: MigrationPlan = { packageManager, + convertYarnPnp, ...setupPlan, migratePrettier, + hasPrettierDependency: prettierProject.hasDependency, prettierConfigFile: prettierProject.configFile, fixBaseUrl, migrateNodeVersionFile, @@ -788,6 +844,13 @@ function showMigrationSummary(options: { } log(`${styleText('gray', '•')} ${parts.join(', ')}`); } + if (report.preservedNuxtVitestImportFileCount > 0) { + log( + `${styleText('gray', '•')} Kept upstream \`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`); } @@ -825,24 +888,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; @@ -901,6 +946,7 @@ async function executeMigrationPlan( workspaceInfoOptional: WorkspaceInfoOptional, plan: MigrationPlan, interactive: boolean, + preExistingChangedPaths?: ReadonlySet, ): Promise<{ installDurationMs: number; finalInstallOk: boolean; @@ -949,12 +995,31 @@ 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'); 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( @@ -1083,6 +1148,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, @@ -1111,6 +1179,14 @@ async function executeMigrationPlan( workspaceInfo.rootDir, report, ); + if ( + finalInstallSummary.status === 'installed' && + canFormatWithOxfmt(plan.hasPrettierDependency, plan.migratePrettier) + ) { + await formatMigratedProject(workspaceInfo.rootDir, interactive, report, { + excludedPaths: preExistingChangedPaths, + }); + } return { installDurationMs: initialInstallDurationMs + finalInstallDurationMs, finalInstallOk: finalInstallSummary.status === 'installed', @@ -1131,6 +1207,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) @@ -1139,9 +1217,20 @@ async function main() { workspaceInfoOptional.rootDir, ) as PackageDependencies | null; if (hasVitePlusDependency(rootPkg) && !isForceOverrideMode()) { + // 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, + options.interactive, + ); let didMigrate = false; let installDurationMs = 0; let finalInstallOk = true; + let canFormatMigratedProject = !process.env.VP_SKIP_INSTALL; const report = createMigrationReport(); const migrationProgress = options.interactive ? prompts.spinner({ indicator: 'timer' }) @@ -1178,6 +1267,8 @@ async function main() { const vitePlusBootstrapPending = detectVitePlusBootstrapPending( workspaceInfoOptional.rootDir, workspaceInfoOptional.packageManager, + workspaceInfoOptional.packages, + workspaceInfoOptional.packageManagerVersion, ); let packageManager: PackageManager | undefined = vitePlusBootstrapPending ? (workspaceInfoOptional.packageManager ?? @@ -1209,6 +1300,30 @@ 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` + // 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, @@ -1225,6 +1340,7 @@ async function main() { if ( !didMigrate && + !convertYarnPnp && report.warnings.length === 0 && !vitePlusBootstrapPending && !hasExistingVitePlusMigrationCandidates(workspaceInfoOptional, options) @@ -1354,6 +1470,30 @@ 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); + if (yarnPnpConverted) { + prompts.log.success('✔ Switched Yarn to node-modules mode'); + report.packageManagerBootstrapConfigured = true; + didMigrate = true; + needsInstall = true; + } + } + if ( addFrameworkShimsForWorkspace( workspaceInfoOptional.rootDir, @@ -1394,6 +1534,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, @@ -1419,6 +1565,8 @@ async function main() { if (installSummary.status === 'failed') { clearMigrationProgress(); } + finalInstallOk = installSummary.status !== 'failed'; + canFormatMigratedProject = finalInstallOk && canFormatMigratedProject; installDurationMs += handleInstallResult( installSummary, workspaceInfoOptional.rootDir, @@ -1461,6 +1609,18 @@ async function main() { } } + if ( + didMigrate && + finalInstallOk && + canFormatMigratedProject && + canFormatWithOxfmt(prettierProject.hasDependency, prettierMigrated) + ) { + clearMigrationProgress(); + await formatMigratedProject(workspaceInfoOptional.rootDir, options.interactive, report, { + excludedPaths: preExistingChangedPaths, + }); + } + if (didMigrate || report.warnings.length > 0) { clearMigrationProgress(); showMigrationSummary({ @@ -1482,12 +1642,18 @@ async function main() { const plan = await collectMigrationPlan( workspaceInfoOptional.rootDir, workspaceInfoOptional.packageManager, + workspaceInfoOptional.packageManagerVersion, options, workspaceInfoOptional.packages, ); // 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/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 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..1521402cc9 --- /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 { addMigrationWarning, type MigrationReport } from '../report.ts'; +import { ROLLDOWN_COMPAT_RESULT_PREFIX } from './protocol.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..aaf92715ec --- /dev/null +++ b/packages/cli/src/migration/compat/worker.ts @@ -0,0 +1,42 @@ +import { writeSync } from 'node:fs'; + +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]; + if (!rootDir) { + return; + } + + 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. + // 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); + 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/src/migration/format.ts b/packages/cli/src/migration/format.ts new file mode 100644 index 0000000000..b82187f05f --- /dev/null +++ b/packages/cli/src/migration/format.ts @@ -0,0 +1,186 @@ +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'; + +type FormatRunner = ( + cwd: string, + interactive?: boolean, + paths?: string[], + options?: { silent?: boolean; command?: string; commandArgs?: 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.'; + +// 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); +} + +function isExistingFile(projectRoot: string, relativePath: string): boolean { + const absolutePath = path.join(projectRoot, relativePath); + try { + return fs.statSync(absolutePath).isFile(); + } catch { + return false; + } +} + +/** + * 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, + excludedPaths?: ReadonlySet, +): Promise { + 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([ + '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) { + // Inside a worktree but Git could not list changes; skip targeted + // formatting rather than reformatting the entire project. + return []; + } + + 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) => !excludedPaths?.has(file) && isExistingFile(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. + */ +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 + * 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, + options: FormatMigratedProjectOptions = {}, +): Promise { + const { format = runViteFmt, collectPaths = collectChangedFormatPaths, excludedPaths } = options; + try { + const paths = await collectPaths(projectRoot, excludedPaths); + if (paths?.length === 0) { + return true; + } + const cliEntry = process.argv[1] ? path.resolve(process.cwd(), process.argv[1]) : undefined; + const formatOptions = { + silent: false, + ...(cliEntry + ? { command: process.execPath, commandArgs: [...process.execArgv, cliEntry] } + : {}), + }; + // `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 { + // 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/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 8d41d2344e..fa4c23f13d 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -1,5825 +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, - 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; - -// 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: '*', -}; - -// 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; - -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; - } -} - -/** - * Rewrite standalone project to add vite-plus dependencies - * @param projectPath - The path to the project - */ -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 pnpmMajorVersion = pnpmMajor(workspaceInfo.downloadPackageManager.version); - let extractedStagedConfig: Record | null = null; - let remainingPnpmOverrides: Record | undefined; - let shouldRewritePnpmWorkspaceYaml = false; - let shouldAddPnpmWorkspaceVitePlusOverride = false; - let shouldAllowBrowserProviderBuilds = 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?: { - overrides?: Record; - peerDependencyRules?: { - allowAny?: string[]; - allowedVersions?: Record; - }; - allowBuilds?: Record; - onlyBuiltDependencies?: string[]; - }; - }>(packageJsonPath, (pkg) => { - shouldAllowBrowserProviderBuilds = - hasOwnWebdriverioDependency(pkg) || usesWebdriverioProvider(projectPath); - // 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); - if (packageManager === PackageManager.yarn) { - pkg.resolutions = { - ...pkg.resolutions, - ...VITE_PLUS_OVERRIDE_PACKAGES, - }; - } else if (packageManager === PackageManager.npm || packageManager === PackageManager.bun) { - pkg.overrides = { - ...pkg.overrides, - ...VITE_PLUS_OVERRIDE_PACKAGES, - }; - 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) { - // If package.json already has a "pnpm" field, keep using it; - // otherwise use pnpm-workspace.yaml. - usePnpmWorkspaceYaml = !pkg.pnpm; - if (usePnpmWorkspaceYaml) { - shouldRewritePnpmWorkspaceYaml = true; - shouldAddPnpmWorkspaceVitePlusOverride = isForceOverrideMode(); - } - const overrideKeys = Object.keys(VITE_PLUS_OVERRIDE_PACKAGES); - 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); - // Project already has pnpm config in package.json -- keep using it. - pkg.pnpm = { - ...pkg.pnpm, - overrides: { - ...pkg.pnpm?.overrides, - ...VITE_PLUS_OVERRIDE_PACKAGES, - ...(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 { - remainingPnpmOverrides = cleanupPnpmOverridesForWorkspaceYaml(pkg, overrideKeys); - } - // 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, - ); - } - } - - extractedStagedConfig = rewritePackageJson( - pkg, - packageManager, - usePnpmWorkspaceYaml, - skipStagedMigration, - catalogDependencyResolver, - usesVitestBrowserMode(projectPath), - collectProviderSourceModes(projectPath), - ); - - // 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; - 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, - ); - return pkg; - }); - - if (shouldRewritePnpmWorkspaceYaml) { - rewritePnpmWorkspaceYaml(projectPath, pnpmMajorVersion, shouldAllowBrowserProviderBuilds); - } - - // Move remaining non-Vite pnpm.overrides to pnpm-workspace.yaml - if (remainingPnpmOverrides) { - migratePnpmOverridesToWorkspaceYaml(projectPath, remainingPnpmOverrides); - } - - if (shouldAddPnpmWorkspaceVitePlusOverride) { - migratePnpmOverridesToWorkspaceYaml(projectPath, { - [VITE_PLUS_NAME]: VITE_PLUS_VERSION, - }); - } - - if (packageManager === PackageManager.yarn) { - rewriteYarnrcYml(projectPath); - } else if (packageManager === PackageManager.bun) { - ensureBunfigPeerSuppression(projectPath); - } - - // 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); - 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 workspaceShouldAllowBrowserBuilds = workspaceUsesWebdriverio( - workspaceInfo.rootDir, - workspaceInfo.packages, - ); - // rewrite root workspace - if (workspaceInfo.packageManager === PackageManager.pnpm) { - rewritePnpmWorkspaceYaml( - workspaceInfo.rootDir, - pnpmMajorVersion, - workspaceShouldAllowBrowserBuilds, - ); - } else if (workspaceInfo.packageManager === PackageManager.yarn) { - rewriteYarnrcYml(workspaceInfo.rootDir); - } else if (workspaceInfo.packageManager === PackageManager.bun) { - rewriteBunCatalog(workspaceInfo.rootDir); - } - rewriteRootWorkspacePackageJson( - workspaceInfo.rootDir, - workspaceInfo.packageManager, - skipStagedMigration, - catalogDependencyResolver, - workspaceInfo.packages, - pnpmMajorVersion, - workspaceShouldAllowBrowserBuilds, - ); - // (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); - 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) => { - // rewrite scripts in package.json - extractedStagedConfig = rewritePackageJson( - pkg, - packageManager, - true, - skipStagedMigration, - catalogDependencyResolver, - usesVitestBrowserMode(projectPath), - collectProviderSourceModes(projectPath), - ); - // 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, -): void { - const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); - if (!fs.existsSync(pnpmWorkspaceYamlPath)) { - fs.writeFileSync(pnpmWorkspaceYamlPath, ''); - } - - editYamlFile(pnpmWorkspaceYamlPath, (doc) => { - // catalog - rewriteCatalog(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); - } - } - } - for (const key of Object.keys(VITE_PLUS_OVERRIDE_PACKAGES)) { - const currentVersion = getYamlMapScalarStringValue(overrides, key); - const version = getCatalogDependencySpec( - currentVersion, - VITE_PLUS_OVERRIDE_PACKAGES[key], - true, - ); - doc.setIn(['overrides', scalarString(key)], scalarString(version)); - } - // remove dependency selector from vite, e.g. "vite-plugin-svgr>vite": "npm:vite@7.0.12" - const 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>(); - } - const existing = new Set(allowAny.items.map((n) => n.value)); - for (const key of Object.keys(VITE_PLUS_OVERRIDE_PACKAGES)) { - 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>(); - } - for (const key of Object.keys(VITE_PLUS_OVERRIDE_PACKAGES)) { - // - 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); - } - }); -} - -/** - * 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 - const catalogOverrides: Record = {}; - const overrides = pkg.pnpm?.overrides; - for (const key of [...overrideKeys, ...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 - cleanupPeerDependencyRules(pkg.pnpm?.peerDependencyRules, overrideKeys); - 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, - * 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; -} - -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); - } -} - -/** - * 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 - */ -// 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; -} - -// 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): 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); - }); -} - -/** - * 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; - }, -): 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 : '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, -): 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); - // 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; -} - -function isVitePlusOverrideSpec(value: string): boolean { - return ( - Object.values(VITE_PLUS_OVERRIDE_PACKAGES).includes(value) || - value.startsWith('npm:@voidzero-dev/vite-plus-') - ); -} - -function createCatalogDependencyResolver( - projectPath: string, - packageManager: PackageManager, -): CatalogDependencyResolver | undefined { - if (packageManager === PackageManager.pnpm) { - const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); - if (!fs.existsSync(pnpmWorkspaceYamlPath)) { - return undefined; - } - const doc = readYamlFile(pnpmWorkspaceYamlPath) as { - catalog?: Record; - catalogs?: Record>; - } | null; - return createCatalogDependencyResolverFromCatalogs(doc?.catalog, doc?.catalogs); - } - if (packageManager === PackageManager.yarn) { - const yarnrcYmlPath = path.join(projectPath, '.yarnrc.yml'); - if (!fs.existsSync(yarnrcYmlPath)) { - return undefined; - } - const doc = readYamlFile(yarnrcYmlPath) as { - catalog?: Record; - catalogs?: Record>; - } | null; - return createCatalogDependencyResolverFromCatalogs(doc?.catalog, doc?.catalogs); - } - if (packageManager === PackageManager.bun) { - const packageJsonPath = path.join(projectPath, 'package.json'); - if (!fs.existsSync(packageJsonPath)) { - return undefined; - } - const pkg = readJsonFile(packageJsonPath) as { - workspaces?: NpmWorkspaces; - catalog?: Record; - catalogs?: Record>; - }; - const workspacesObj = - pkg.workspaces && !Array.isArray(pkg.workspaces) ? pkg.workspaces : undefined; - const fromWorkspaces = createCatalogDependencyResolverFromCatalogs( - workspacesObj?.catalog, - workspacesObj?.catalogs, - ); - const fromPkg = createCatalogDependencyResolverFromCatalogs(pkg.catalog, pkg.catalogs); - return (catalogSpec, dependencyName) => - fromWorkspaces(catalogSpec, dependencyName) ?? fromPkg(catalogSpec, dependencyName); - } - return undefined; -} - -function createCatalogDependencyResolverFromCatalogs( - catalog: Record | undefined, - catalogs: Record> | undefined, -): CatalogDependencyResolver { - return (catalogSpec, dependencyName) => { - 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. - if (catalogName && catalogName !== 'default') { - return catalogs?.[catalogName]?.[dependencyName]; - } - return catalog?.[dependencyName]; - }; -} - -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): void { - for (const [key, value] of Object.entries(VITE_PLUS_OVERRIDE_PACKAGES)) { - // ERR_PNPM_CATALOG_IN_OVERRIDES  Could not resolve a catalog in the overrides: The entry for 'vite' in catalog 'default' declares a dependency using the 'file' protocol - // ignore setting catalog if value starts with 'file:' - if (value.startsWith('file:')) { - continue; - } - doc.setIn(['catalog', key], scalarString(value)); - } - if (!VITE_PLUS_VERSION.startsWith('file:')) { - doc.setIn(['catalog', VITE_PLUS_NAME], scalarString(VITE_PLUS_VERSION)); - } - for (const name of REMOVE_PACKAGES) { - const path = ['catalog', name]; - if (doc.hasIn(path)) { - doc.deleteIn(path); - } - } - // Drop any entry still pointing at the deleted `vite-plus-test` wrapper. - pruneYamlMapLegacyWrapperAliases(doc.getIn(['catalog'])); - - const catalogs = doc.getIn(['catalogs']); - if (!(catalogs instanceof YAMLMap)) { - return; - } - for (const item of catalogs.items) { - const catalogName = item.key instanceof Scalar ? item.key.value : undefined; - if (typeof catalogName !== 'string' || !(item.value instanceof YAMLMap)) { - continue; - } - for (const [key, value] of Object.entries(VITE_PLUS_OVERRIDE_PACKAGES)) { - const catalogPath = ['catalogs', catalogName, key]; - if (!value.startsWith('file:') && doc.hasIn(catalogPath)) { - doc.setIn(catalogPath, scalarString(value)); - } - } - const vitePlusPath = ['catalogs', catalogName, VITE_PLUS_NAME]; - if (!VITE_PLUS_VERSION.startsWith('file:') && doc.hasIn(vitePlusPath)) { - doc.setIn(vitePlusPath, scalarString(VITE_PLUS_VERSION)); - } - for (const name of REMOVE_PACKAGES) { - const catalogPath = ['catalogs', catalogName, name]; - if (doc.hasIn(catalogPath)) { - doc.deleteIn(catalogPath); - } - } - pruneYamlMapLegacyWrapperAliases(item.value); - } -} - -function rewriteCatalogObject(catalog: Record, addMissing: boolean): void { - for (const [key, value] of Object.entries(VITE_PLUS_OVERRIDE_PACKAGES)) { - if (value.startsWith('file:') || (!addMissing && !(key in catalog))) { - continue; - } - catalog[key] = value; - } - if (!VITE_PLUS_VERSION.startsWith('file:') && (addMissing || VITE_PLUS_NAME in catalog)) { - catalog[VITE_PLUS_NAME] = VITE_PLUS_VERSION; - } - for (const name of REMOVE_PACKAGES) { - delete catalog[name]; - } -} - -function rewriteCatalogsObject(catalogs: Record>): void { - for (const catalog of Object.values(catalogs)) { - rewriteCatalogObject(catalog, false); - } -} - -/** - * 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; - } - // 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`) - : `${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, - * unlike pnpm which uses pnpm-workspace.yaml. - * @see https://bun.sh/docs/pm/catalogs - */ -function rewriteBunCatalog(projectPath: string): void { - const packageJsonPath = path.join(projectPath, 'package.json'); - if (!fs.existsSync(packageJsonPath)) { - return; - } - - 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); - pruneLegacyWrapperAliases(catalog); - - if (useWorkspacesCatalog) { - workspacesObj.catalog = catalog; - if (pkg.catalog) { - rewriteCatalogObject(pkg.catalog, false); - pruneLegacyWrapperAliases(pkg.catalog); - } - } else { - pkg.catalog = catalog; - if (workspacesObj?.catalog) { - rewriteCatalogObject(workspacesObj.catalog, false); - pruneLegacyWrapperAliases(workspacesObj.catalog); - } - } - if (workspacesObj?.catalogs) { - rewriteCatalogsObject(workspacesObj.catalogs); - for (const named of Object.values(workspacesObj.catalogs)) { - pruneLegacyWrapperAliases(named); - } - } - if (pkg.catalogs) { - rewriteCatalogsObject(pkg.catalogs); - for (const named of Object.values(pkg.catalogs)) { - pruneLegacyWrapperAliases(named); - } - } - - // bun overrides support catalog: references - const overrides: Record = { ...pkg.overrides }; - pruneLegacyWrapperAliases(overrides); - for (const [key, value] of Object.entries(VITE_PLUS_OVERRIDE_PACKAGES)) { - 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; - }); - - ensureBunfigPeerSuppression(projectPath); -} - -/** - * 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, - shouldAllowBrowserBuilds = false, -): void { - const packageJsonPath = path.join(projectPath, 'package.json'); - if (!fs.existsSync(packageJsonPath)) { - return; - } - - let remainingPnpmOverrides: Record | undefined; - editJsonFile<{ - resolutions?: Record; - overrides?: Record; - devDependencies?: Record; - dependencies?: Record; - peerDependencies?: Record; - optionalDependencies?: Record; - pnpm?: { - overrides?: Record; - peerDependencyRules?: { - allowAny?: string[]; - allowedVersions?: Record; - }; - allowBuilds?: Record; - onlyBuiltDependencies?: string[]; - }; - }>(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); - 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, - }; - } else if (packageManager === PackageManager.npm) { - pkg.overrides = { - ...pkg.overrides, - ...VITE_PLUS_OVERRIDE_PACKAGES, - }; - } 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(VITE_PLUS_OVERRIDE_PACKAGES); - 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); - // 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). - pkg.pnpm = { - ...pkg.pnpm, - overrides: { - ...pkg.pnpm?.overrides, - ...VITE_PLUS_OVERRIDE_PACKAGES, - [VITE_PLUS_NAME]: VITE_PLUS_VERSION, - }, - }; - } else { - for (const key of [...overrideKeys, ...PROVIDER_OVERRIDE_DROP_NAMES]) { - if (pkg.resolutions?.[key]) { - delete pkg.resolutions[key]; - } - } - remainingPnpmOverrides = cleanupPnpmOverridesForWorkspaceYaml(pkg, overrideKeys); - } - // 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 - if (!pkg.devDependencies?.[VITE_PLUS_NAME]) { - pkg.devDependencies = { - ...pkg.devDependencies, - [VITE_PLUS_NAME]: - packageManager === PackageManager.npm || VITE_PLUS_VERSION.startsWith('file:') - ? VITE_PLUS_VERSION - : 'catalog:', - }; - } - ensureDirectViteForPnpm(pkg, packageManager, true); - return pkg; - }); - - // Move remaining non-Vite pnpm.overrides to pnpm-workspace.yaml - if (remainingPnpmOverrides) { - migratePnpmOverridesToWorkspaceYaml(projectPath, remainingPnpmOverrides); - } - - // 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); - - return result; -} - -type BootstrapPackageJson = { - overrides?: Record; - resolutions?: Record; - devDependencies?: Record; - dependencies?: Record; - optionalDependencies?: Record; - pnpm?: { - overrides?: Record; - peerDependencyRules?: { - allowAny?: string[]; - allowedVersions?: Record; - }; - }; - packageManager?: string; - devEngines?: { packageManager?: unknown; [key: string]: unknown }; -}; - -export type VitePlusBootstrapResult = { - changed: boolean; - packageJson: boolean; - packageManagerConfig: boolean; - 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; - } - // 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; - } - const packageName = getVitePlusOverridePackageName(dependencyName); - return packageName !== undefined && spec.includes(packageName); -} - -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, - catalogDependencyResolver?: CatalogDependencyResolver, -): boolean { - return Object.keys(VITE_PLUS_OVERRIDE_PACKAGES).every((dependencyName) => - overrideSpecSatisfiesVitePlus( - dependencyName, - overrides?.[dependencyName], - catalogDependencyResolver, - ), - ); -} - -function hasPackageManagerPin(pkg: BootstrapPackageJson): boolean { - return Boolean(pkg.packageManager || pkg.devEngines?.packageManager); -} - -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 defaultCatalogVitePlusDependencyPending( - 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') { - return false; - } - return catalogDependencyResolver?.(spec, VITE_PLUS_NAME) !== VITE_PLUS_VERSION; - }); -} - -function pnpmPeerDependencyRulesSatisfyVitePlus( - peerDependencyRules: - | { allowAny?: string[]; allowedVersions?: Record } - | undefined, -): boolean { - const overrideKeys = Object.keys(VITE_PLUS_OVERRIDE_PACKAGES); - const allowAny = new Set(peerDependencyRules?.allowAny ?? []); - const allowedVersions = peerDependencyRules?.allowedVersions ?? {}; - return overrideKeys.every((key) => allowAny.has(key) && allowedVersions[key] === '*'); -} - -function npmVitePlusManagedDependenciesPending(pkg: BootstrapPackageJson): boolean { - const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; - return Object.keys(VITE_PLUS_OVERRIDE_PACKAGES).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 yarnrcSatisfiesVitePlus(projectPath: string): boolean { - const yarnrcYmlPath = path.join(projectPath, '.yarnrc.yml'); - if (!fs.existsSync(yarnrcYmlPath)) { - return false; - } - const doc = readYamlFile(yarnrcYmlPath) as { - nodeLinker?: string; - catalog?: Record; - } | null; - return ( - !!doc && - Object.hasOwn(doc, 'nodeLinker') && - overridesSatisfyVitePlus(doc.catalog) && - (VITE_PLUS_VERSION.startsWith('file:') || doc.catalog?.[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); - return (catalogSpec, dependencyName) => - fromWorkspaces(catalogSpec, dependencyName) ?? fromPkg(catalogSpec, dependencyName); -} - -export function detectVitePlusBootstrapPending( - projectPath: string, - packageManager: PackageManager | undefined, -): 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>; - }; - - if (!pkg.devDependencies?.[VITE_PLUS_NAME] || !hasPackageManagerPin(pkg)) { - return true; - } - - if (packageManager === undefined) { - return true; - } - - if (packageManager === PackageManager.yarn) { - return !overridesSatisfyVitePlus(pkg.resolutions) || !yarnrcSatisfiesVitePlus(projectPath); - } - if (packageManager === PackageManager.npm) { - return ( - vitePlusDependencyNeedsConcreteVersion(pkg) || - !overridesSatisfyVitePlus(pkg.overrides) || - npmVitePlusManagedDependenciesPending(pkg) - ); - } - if (packageManager === PackageManager.bun) { - return !overridesSatisfyVitePlus(pkg.overrides, readBunCatalogDependencyResolver(pkg)); - } - if (packageManager === PackageManager.pnpm) { - if (pkg.pnpm) { - return ( - vitePlusDependencyNeedsConcreteVersion(pkg) || - !overridesSatisfyVitePlus(pkg.pnpm.overrides) || - !pnpmPeerDependencyRulesSatisfyVitePlus(pkg.pnpm.peerDependencyRules) - ); - } - const resolver = readPnpmWorkspaceCatalogDependencyResolver(projectPath); - return ( - defaultCatalogVitePlusDependencyPending(pkg, resolver) || - !overridesSatisfyVitePlus(readPnpmWorkspaceOverrides(projectPath), resolver) || - !pnpmPeerDependencyRulesSatisfyVitePlus(readPnpmWorkspacePeerDependencyRules(projectPath)) - ); - } - - return false; -} - -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; - } - } - } - if (pkg.devDependencies?.[VITE_PLUS_NAME]) { - return changed; - } - pkg.devDependencies = { - ...pkg.devDependencies, - [VITE_PLUS_NAME]: version, - }; - return true; -} - -function ensureOverrideEntries( - overrides: Record | undefined, - catalogDependencyResolver?: CatalogDependencyResolver, -): { overrides: Record; changed: boolean } { - const next = { ...overrides }; - let changed = false; - for (const [dependencyName, overrideSpec] of Object.entries(VITE_PLUS_OVERRIDE_PACKAGES)) { - if ( - !overrideSpecSatisfiesVitePlus( - dependencyName, - next[dependencyName], - catalogDependencyResolver, - ) - ) { - next[dependencyName] = overrideSpec; - changed = true; - } - } - return { overrides: next, changed }; -} - -function ensureNpmVitePlusManagedDependencies(pkg: BootstrapPackageJson): boolean { - let changed = false; - const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; - for (const [dependencyName, version] of Object.entries(VITE_PLUS_OVERRIDE_PACKAGES)) { - for (const dependencies of dependencyGroups) { - if ( - dependencies?.[dependencyName] !== undefined && - !overrideSpecSatisfiesVitePlus(dependencyName, dependencies[dependencyName]) - ) { - dependencies[dependencyName] = version; - changed = true; - } - } - } - return changed; -} - -function ensurePnpmPeerDependencyRules(pkg: BootstrapPackageJson): boolean { - const overrideKeys = Object.keys(VITE_PLUS_OVERRIDE_PACKAGES); - pkg.pnpm ??= {}; - const peerDependencyRules = { - ...pkg.pnpm.peerDependencyRules, - allowAny: [...new Set([...(pkg.pnpm.peerDependencyRules?.allowAny ?? []), ...overrideKeys])], - allowedVersions: { - ...pkg.pnpm.peerDependencyRules?.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; - } - - editJsonFile< - BootstrapPackageJson & { - workspaces?: NpmWorkspaces; - catalog?: Record; - catalogs?: Record>; - } - >(packageJsonPath, (pkg) => { - const usePnpmWorkspaceYaml = workspaceInfo.packageManager === PackageManager.pnpm && !pkg.pnpm; - const supportCatalog = - !VITE_PLUS_VERSION.startsWith('file:') && - (usePnpmWorkspaceYaml || workspaceInfo.packageManager === PackageManager.bun); - let packageJsonChanged = ensureVitePlusDependencySpecs( - pkg, - supportCatalog ? 'catalog:' : VITE_PLUS_VERSION, - ); - if (workspaceInfo.packageManager === PackageManager.npm) { - packageJsonChanged = ensureNpmVitePlusManagedDependencies(pkg) || packageJsonChanged; - } - - if (workspaceInfo.packageManager === PackageManager.yarn) { - const ensured = ensureOverrideEntries(pkg.resolutions); - if (ensured.changed) { - pkg.resolutions = ensured.overrides; - packageJsonChanged = true; - } - } else if (workspaceInfo.packageManager === PackageManager.npm) { - const ensured = ensureOverrideEntries(pkg.overrides); - if (ensured.changed) { - pkg.overrides = ensured.overrides; - packageJsonChanged = true; - } - } else if (workspaceInfo.packageManager === PackageManager.bun) { - const ensured = ensureOverrideEntries(pkg.overrides, readBunCatalogDependencyResolver(pkg)); - if (ensured.changed) { - pkg.overrides = ensured.overrides; - packageJsonChanged = true; - } - } else if (workspaceInfo.packageManager === PackageManager.pnpm && pkg.pnpm) { - const ensured = ensureOverrideEntries(pkg.pnpm.overrides); - if (ensured.changed) { - pkg.pnpm.overrides = ensured.overrides; - packageJsonChanged = true; - } - packageJsonChanged = ensurePnpmPeerDependencyRules(pkg) || packageJsonChanged; - } - - result.packageJson = packageJsonChanged; - return pkg; - }); - - if (workspaceInfo.packageManager === PackageManager.pnpm) { - const pkg = readJsonFile(packageJsonPath) as BootstrapPackageJson; - if (!pkg.pnpm) { - const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); - const before = fs.existsSync(pnpmWorkspaceYamlPath) - ? fs.readFileSync(pnpmWorkspaceYamlPath, 'utf-8') - : undefined; - const catalogDependencyResolver = readPnpmWorkspaceCatalogDependencyResolver(projectPath); - if ( - defaultCatalogVitePlusDependencyPending(pkg, catalogDependencyResolver) || - !overridesSatisfyVitePlus( - readPnpmWorkspaceOverrides(projectPath), - catalogDependencyResolver, - ) || - !pnpmPeerDependencyRulesSatisfyVitePlus(readPnpmWorkspacePeerDependencyRules(projectPath)) - ) { - // 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); - } - 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 (workspaceInfo.packageManager === PackageManager.yarn) { - const yarnrcYmlPath = path.join(projectPath, '.yarnrc.yml'); - const before = fs.existsSync(yarnrcYmlPath) - ? fs.readFileSync(yarnrcYmlPath, 'utf-8') - : undefined; - rewriteYarnrcYml(projectPath); - 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); - 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 = [ - '@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` 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', - '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 = [ - '@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 sourceTreeReferencesAny(projectPath: string, hints: readonly string[]): boolean { - const matchesHint = (content: string): boolean => hints.some((hint) => content.includes(hint)); - - 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 (matchesHint(fs.readFileSync(entryPath, 'utf8'))) { - return true; - } - } catch { - // Unreadable file — ignore and keep scanning. - } - } - } - return false; - }; - - return scanDir(projectPath, true); -} - -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>, -): 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; - } - } - for (const [key, version] of Object.entries(VITE_PLUS_OVERRIDE_PACKAGES)) { - for (const { dependencyField, dependencies } of dependencyGroups) { - if (dependencies?.[key]) { - dependencies[key] = getCatalogDependencySpec(dependencies[key], version, supportCatalog, { - dependencyField, - dependencyName: key, - packageManager, - catalogDependencyResolver, - }); - needVitePlus = true; - } - } - } - // 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]) { - 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 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.) - 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 installGroup = [pkg.dependencies, pkg.devDependencies, pkg.optionalDependencies].find( - (deps) => deps?.[provider] !== undefined, - ); - if (installGroup) { - installGroup[provider] = VITEST_VERSION; - } else { - pkg.devDependencies ??= {}; - pkg.devDependencies[provider] = VITEST_VERSION; - } - 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. 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. - 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')); - // 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:') ? 'catalog:' : VITE_PLUS_VERSION; - const existingVitePlus = pkg.devDependencies?.[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; - if (needVitePlus || shouldNormalizeExistingVitePlus) { - pkg.devDependencies = { - ...pkg.devDependencies, - [VITE_PLUS_NAME]: canonicalVitePlusSpec, - }; - } - ensureDirectViteForPnpm(pkg, packageManager, supportCatalog); - // 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 || Object.keys(installableDeps).some((name) => name.includes('vitest'))) - ) { - pkg.devDependencies ??= {}; - pkg.devDependencies.vitest = getCatalogDependencySpec( - undefined, - VITEST_VERSION, - supportCatalog, - ); - } - } - 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): boolean { - const result = rewriteImportsInDirectory(projectPath); - const modified = result.modifiedFiles.length; - const errors = result.errors.length; - - if (report) { - report.rewrittenImportFileCount += modified; - 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..9884daad9e --- /dev/null +++ b/packages/cli/src/migration/migrator/setup.ts @@ -0,0 +1,340 @@ +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, + 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'; + +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; +} + +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/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..9f59968fee --- /dev/null +++ b/packages/cli/src/migration/migrator/vitest-ecosystem.ts @@ -0,0 +1,763 @@ +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; + }, + // 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 + // 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 ?? 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 + // 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) + ); +} diff --git a/packages/cli/src/migration/npm-reinstall.ts b/packages/cli/src/migration/npm-reinstall.ts new file mode 100644 index 0000000000..6ae903ae42 --- /dev/null +++ b/packages/cli/src/migration/npm-reinstall.ts @@ -0,0 +1,104 @@ +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)) { + 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 (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. + } + } + + // 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/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..01c4b80fd7 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,71 @@ function quoteSpecifier(literal: ESTree.StringLiteral, replacement: string): str return `${quote}${replacement}${quote}`; } +// 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< + string, + { mtimeMs: number; usesNuxtTestUtils: boolean } +>(); + +function isUpstreamVitestSpecifier(specifier: string): boolean { + return specifier === 'vitest' || specifier.startsWith('vitest/'); +} + +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)) { + 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 && cached.mtimeMs === mtimeMs) { + return cached.usesNuxtTestUtils; + } + 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, { mtimeMs, 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, + preserveUpstreamVitest = false, ) { if (!literal || literal.type !== 'Literal' || typeof literal.value !== 'string') { return; } + if (preserveUpstreamVitest && isUpstreamVitestSpecifier(literal.value)) { + return; + } const replacement = rewriteVitePlusImportSpecifier(literal.value); if (!replacement) { @@ -138,24 +199,28 @@ export const preferVitePlusImportsRule = defineRule({ }, }, createOnce(context: Context) { + let preserveUpstreamVitest = false; return { + Program() { + preserveUpstreamVitest = nearestPackageUsesNuxtTestUtils(context.filename); + }, ImportDeclaration(node) { - maybeReportLiteral(context, node.source); + maybeReportLiteral(context, node.source, preserveUpstreamVitest); }, ExportAllDeclaration(node) { - maybeReportLiteral(context, node.source); + maybeReportLiteral(context, node.source, preserveUpstreamVitest); }, ExportNamedDeclaration(node) { - maybeReportLiteral(context, node.source); + maybeReportLiteral(context, node.source, preserveUpstreamVitest); }, ImportExpression(node) { - maybeReportLiteral(context, node.source); + maybeReportLiteral(context, node.source, preserveUpstreamVitest); }, TSImportType(node) { - maybeReportLiteral(context, node.source); + maybeReportLiteral(context, node.source, preserveUpstreamVitest); }, TSExternalModuleReference(node) { - maybeReportLiteral(context, node.expression); + maybeReportLiteral(context, node.expression, preserveUpstreamVitest); }, TSModuleDeclaration(node) { if (node.global) { @@ -169,7 +234,7 @@ export const preferVitePlusImportsRule = defineRule({ ) { return; } - maybeReportLiteral(context, id); + maybeReportLiteral(context, id, preserveUpstreamVitest); }, }; }, 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..dc1fb105fe 100644 --- a/packages/cli/src/utils/constants.ts +++ b/packages/cli/src/utils/constants.ts @@ -1,14 +1,24 @@ 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; + +/** + * 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 ? 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/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/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, 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/packages/cli/tsdown.config.ts b/packages/cli/tsdown.config.ts index 944111ecdf..b7f22145ab 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', diff --git a/packages/tools/src/utils.ts b/packages/tools/src/utils.ts index 328b826061..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 @@ -73,6 +76,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 new file mode 100644 index 0000000000..6fbbb0315f --- /dev/null +++ b/rfcs/migrate-existing-projects.md @@ -0,0 +1,161 @@ +# 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. + +## 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@` 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. + +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. + +### 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. + +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/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 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 + +| 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` | +| 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. diff --git a/rfcs/migration-command.md b/rfcs/migration-command.md index e7a63cafd7..9e1ed740da 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` @@ -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: @@ -252,6 +255,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: @@ -260,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"], @@ -497,14 +505,14 @@ 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` ```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: @@ -522,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@" } } ``` @@ -536,7 +544,7 @@ peerDependencyRules: ```yaml catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest + vite: npm:@voidzero-dev/vite-plus-core@ ``` `package.json` @@ -567,6 +575,29 @@ 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 `--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 +`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 files changed +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 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 +616,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 +668,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**: