Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
05dadbf
docs(rfc): vp migrate upgrade path for existing Vite+ projects
fengmk2 Jun 10, 2026
1156c29
docs(rfc): cover real 0.1.24->0.2.0 upgrade failure in vp migrate
fengmk2 Jun 18, 2026
0c23ca5
docs(rfc): align vp migrate upgrade with the v0.2.1 prompt spec
fengmk2 Jun 18, 2026
d3491a2
fix(migrate): make vp migrate upgrade v0.1.x projects to v0.2.x
fengmk2 Jun 18, 2026
70c2976
feat(migrate): manage vitest only when the project uses it directly
fengmk2 Jun 18, 2026
b7ec2f8
feat(migrate): align the full @vitest/* ecosystem to the bundled vitest
fengmk2 Jun 19, 2026
0f7cbba
docs(rfc): revise migrate RFC for vitest provisioning and ecosystem r…
fengmk2 Jun 19, 2026
ac0f9e0
fix(migrate): make upgrade provisioning peer-safe
fengmk2 Jun 19, 2026
9651374
fix(migrate): validate upgrade scenarios in snapshots
fengmk2 Jun 19, 2026
7ab281f
test(migrate): update default vitest snapshots
fengmk2 Jun 21, 2026
cc38978
fix(migrate): handle peer and override edge cases
fengmk2 Jun 21, 2026
79d3017
fix(migrate): cover remaining vitest upgrade cases
fengmk2 Jun 21, 2026
379d331
fix(test): normalize snapshot file endings
fengmk2 Jun 21, 2026
f72e920
test(migrate): sync idempotency snapshots
fengmk2 Jun 21, 2026
02b20f6
test(create): update standalone Yarn catalog snapshot
fengmk2 Jun 21, 2026
f85a7d6
fix(migrate): preserve vitest imports for Nuxt tests
fengmk2 Jun 23, 2026
43ef921
test(ecosystem-ci): update npmx.dev fixture
fengmk2 Jun 23, 2026
55d74a5
test(cli): stabilize Nuxt lint snapshot
fengmk2 Jun 23, 2026
53c3d9e
fix(migrate): preserve Vitest across Nuxt packages
fengmk2 Jun 23, 2026
d1b68ea
fix(migrate): convert Yarn PnP projects
fengmk2 Jun 23, 2026
27cdfde
test(ecosystem): install Playwright for npmx.dev
fengmk2 Jun 23, 2026
6ff0d46
test(migrate): cover conservative monorepo retention
fengmk2 Jun 23, 2026
8fc2ccc
fix(migrate): pin pkg.pr.new targets in test helper
fengmk2 Jun 23, 2026
924904a
fix(test): keep pkg.pr.new overrides minimal
fengmk2 Jun 23, 2026
4ecbc52
fix(migrate): allow pkg.pr.new pnpm subdependencies
fengmk2 Jun 23, 2026
c128298
fix(test): refresh mutable pkg.pr.new installs
fengmk2 Jun 24, 2026
d6c503c
fix(migrate): preserve Vitest ecosystem catalogs
fengmk2 Jun 24, 2026
af7e35e
fix(migrate): pin vite-plus toolchain versions
fengmk2 Jun 24, 2026
98e5d8f
fix(test): reuse unchanged pkg.pr.new install
fengmk2 Jun 24, 2026
66cdf2f
fix(test): run pkg.pr.new migration from project root
fengmk2 Jun 24, 2026
4555183
fix(migrate): isolate config compatibility checks
fengmk2 Jun 24, 2026
e9b1ae2
fix(test): pin pkg.pr.new migration builds by commit
fengmk2 Jun 24, 2026
e4922bb
fix(migrate): move pnpm settings to workspace config
fengmk2 Jun 25, 2026
1f71bc3
docs(migrate): document user-facing migration rules
fengmk2 Jun 25, 2026
5f8ecb6
test(migrate): update migration snapshots
fengmk2 Jun 25, 2026
ee016e6
docs(migrate): clarify pnpm vite dependency rule
fengmk2 Jun 25, 2026
1064bde
fix(migrate): format projects after migration
fengmk2 Jun 25, 2026
5cb22f0
fix(migrate): preserve unmigrated Prettier projects
fengmk2 Jun 25, 2026
529396f
fix(migrate): detect legacy browser providers
fengmk2 Jun 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
242 changes: 242 additions & 0 deletions .github/scripts/test-pkg-pr-new-migrate.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
#!/usr/bin/env bash

set -euo pipefail

usage() {
cat <<'EOF'
Usage: .github/scripts/test-pkg-pr-new-migrate.sh <PR-or-SHA> <project-path> [migrate-options...]

Examples:
.github/scripts/test-pkg-pr-new-migrate.sh 1891 /path/to/npmx.dev
.github/scripts/test-pkg-pr-new-migrate.sh 4eb2104c /path/to/project --no-interactive

Environment variables:
VP_PKG_PR_NEW_HOME Override the isolated global CLI installation directory.
ALLOW_DIRTY=1 Allow migration in a dirty Git worktree.
EOF
}

if [ "$#" -lt 2 ]; then
usage >&2
exit 2
fi

pr_ref="$1"
project_input="$2"
shift 2

case "$pr_ref" in
'' | *[![:alnum:]._-]*)
echo "error: PR or SHA contains unsupported characters: $pr_ref" >&2
exit 2
;;
esac

if [ ! -d "$project_input" ]; then
echo "error: project directory does not exist: $project_input" >&2
exit 2
fi

project_dir="$(cd "$project_input" && pwd -P)"
if [ ! -f "$project_dir/package.json" ]; then
echo "error: package.json not found in project: $project_dir" >&2
exit 2
fi

script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
repo_root="$(cd "$script_dir/../.." && pwd -P)"
installer="$repo_root/packages/cli/install.sh"

if [ ! -f "$installer" ]; then
echo "error: Vite+ installer not found: $installer" >&2
exit 2
fi

is_git_repo=0
if command -v git >/dev/null 2>&1 && git -C "$project_dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
is_git_repo=1
if [ "${ALLOW_DIRTY:-0}" != "1" ] && [ -n "$(git -C "$project_dir" status --porcelain)" ]; then
echo "error: project worktree is dirty: $project_dir" >&2
echo "Commit or stash its changes, or rerun with ALLOW_DIRTY=1." >&2
exit 2
fi
fi

original_home="$HOME"
cache_root="${XDG_CACHE_HOME:-$original_home/.cache}"
pr_home="${VP_PKG_PR_NEW_HOME:-$cache_root/vite-plus/pkg-pr-new/$pr_ref}"
installer_home="$(mktemp -d "${TMPDIR:-/tmp}/vite-plus-pr-installer.XXXXXX")"
pkg_pr_new_base="https://pkg.pr.new/voidzero-dev/vite-plus"
requested_vite_plus_spec="$pkg_pr_new_base@$pr_ref"

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

# PR-number URLs are mutable and pkg.pr.new packages reference their internal
# workspace dependencies by commit SHA. Persisting the PR URL alongside those
# SHA URLs makes package managers install duplicate copies of the same package.
# Resolve once, then use the immutable SHA for the global install and every
# dependency spec written by migration.
resolved_ref="$available_commit"
cached_version_dir="$pr_home/pkg-pr-new-$resolved_ref"
vp_bin="$pr_home/bin/vp"
vite_plus_package_json="$pr_home/current/node_modules/vite-plus/package.json"
global_cli_entry="$pr_home/current/node_modules/vite-plus/dist/bin.js"
commit_marker="$cached_version_dir/.pkg-pr-new-commit"
vite_plus_spec="$pkg_pr_new_base@$resolved_ref"
vite_plus_core_spec="$pkg_pr_new_base/@voidzero-dev/vite-plus-core@$resolved_ref"

read_installed_commit() {
if [ -f "$commit_marker" ]; then
head -n 1 "$commit_marker"
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

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"
export VP_VERSION="$vite_plus_spec"
export VP_OVERRIDE_PACKAGES="$(printf \
'{"vite":"%s","vitest":"%s"}' \
"$vite_plus_core_spec" \
"$vitest_version")"
export VP_FORCE_MIGRATE=1
# pkg.pr.new packages depend on URL-resolved platform binaries. pnpm blocks
# those transitive URL dependencies when blockExoticSubdeps is enabled. The
# migration persists the corresponding workspace setting, while this temporary
# override also lets its pre-rewrite install recover a partially migrated tree.
export PNPM_CONFIG_BLOCK_EXOTIC_SUBDEPS=false
hash -r

echo
echo "Using isolated global CLI:"
echo " requested ref: $pr_ref"
echo " resolved commit: $resolved_ref"
echo " executable: $vp_bin"
echo " installation: $(readlink "$pr_home/current" 2>/dev/null || echo unknown)"
echo " vite-plus spec: $VP_VERSION"
echo " vite spec: $vite_plus_core_spec"
"$vp_bin" --version

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().
cd "$project_dir"
"$vp_bin" node "$global_cli_entry" migrate "$project_dir" "$@"
)
migrate_status=$?
set -e

if [ "$is_git_repo" -eq 1 ]; then
echo
echo "Migration worktree changes:"
git -C "$project_dir" status --short
git -C "$project_dir" diff --stat
fi

exit "$migrate_status"
1 change: 1 addition & 0 deletions .github/workflows/e2e-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ vite-plus/
- **Package-manager commands**: start at `crates/vite_pm_cli/` and `crates/vite_install/`.
- **Managed Node runtime / shims**: start at `crates/vite_js_runtime/`.
- **Static `vite.config.ts` extraction**: start at `crates/vite_static_config/README.md` and `packages/cli/src/resolve-vite-config.ts`.
- **Migration behavior**: `docs/guide/migrate-rules.md`.
- **Bundled toolchain surfaces**: start with `packages/core/BUNDLING.md`, `packages/cli/BUNDLING.md`, and `packages/test/BUNDLING.md`.
- **Generated project agent guidance**: `packages/cli/AGENTS.md` and `packages/cli/src/utils/agent.ts`; do not edit these when the task is only to improve root repo guidance.
- **Product/repo docs**: root contributor docs live at the repo root and the VitePress site under `docs/` (`docs/guide/`, `docs/config/`); generated agent guidance is separate.
Expand Down
11 changes: 9 additions & 2 deletions crates/vite_global_cli/src/commands/migrate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ExitStatus, Error> {
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)]
Expand Down
63 changes: 63 additions & 0 deletions crates/vite_global_cli/src/js_executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ExitStatus, Error> {
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
Expand Down Expand Up @@ -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<String> {
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
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading