Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
a8deac9
docs(rfc): vp migrate upgrade path for existing Vite+ projects
fengmk2 Jun 10, 2026
52e03ee
docs(rfc): cover real 0.1.24->0.2.0 upgrade failure in vp migrate
fengmk2 Jun 18, 2026
de71b55
docs(rfc): align vp migrate upgrade with the v0.2.1 prompt spec
fengmk2 Jun 18, 2026
ad76e8d
fix(migrate): make vp migrate upgrade v0.1.x projects to v0.2.x
fengmk2 Jun 18, 2026
6c75209
feat(migrate): manage vitest only when the project uses it directly
fengmk2 Jun 18, 2026
a3f23d6
feat(migrate): align the full @vitest/* ecosystem to the bundled vitest
fengmk2 Jun 19, 2026
85b9ae3
docs(rfc): revise migrate RFC for vitest provisioning and ecosystem r…
fengmk2 Jun 19, 2026
d5d13c1
fix(migrate): make upgrade provisioning peer-safe
fengmk2 Jun 19, 2026
1a0f9d4
fix(migrate): validate upgrade scenarios in snapshots
fengmk2 Jun 19, 2026
73a21e6
test(migrate): update default vitest snapshots
fengmk2 Jun 21, 2026
6221bb1
fix(migrate): handle peer and override edge cases
fengmk2 Jun 21, 2026
42d9f1c
fix(migrate): cover remaining vitest upgrade cases
fengmk2 Jun 21, 2026
e43e47a
fix(test): normalize snapshot file endings
fengmk2 Jun 21, 2026
1f0945e
test(migrate): sync idempotency snapshots
fengmk2 Jun 21, 2026
376a5b8
test(create): update standalone Yarn catalog snapshot
fengmk2 Jun 21, 2026
2574f2e
fix(migrate): preserve vitest imports for Nuxt tests
fengmk2 Jun 23, 2026
9618733
test(ecosystem-ci): update npmx.dev fixture
fengmk2 Jun 23, 2026
bc3d1a9
test(cli): stabilize Nuxt lint snapshot
fengmk2 Jun 23, 2026
3da96af
fix(migrate): preserve Vitest across Nuxt packages
fengmk2 Jun 23, 2026
c1e337f
fix(migrate): convert Yarn PnP projects
fengmk2 Jun 23, 2026
493fe0c
test(ecosystem): install Playwright for npmx.dev
fengmk2 Jun 23, 2026
4a07a8b
test(migrate): cover conservative monorepo retention
fengmk2 Jun 23, 2026
8674c09
fix(migrate): pin pkg.pr.new targets in test helper
fengmk2 Jun 23, 2026
6a797e5
fix(test): keep pkg.pr.new overrides minimal
fengmk2 Jun 23, 2026
577e84a
fix(migrate): allow pkg.pr.new pnpm subdependencies
fengmk2 Jun 23, 2026
ae00efe
fix(test): refresh mutable pkg.pr.new installs
fengmk2 Jun 24, 2026
8b7e568
fix(migrate): preserve Vitest ecosystem catalogs
fengmk2 Jun 24, 2026
4246db4
fix(migrate): pin vite-plus toolchain versions
fengmk2 Jun 24, 2026
bc22b55
fix(test): reuse unchanged pkg.pr.new install
fengmk2 Jun 24, 2026
b2c220d
fix(test): run pkg.pr.new migration from project root
fengmk2 Jun 24, 2026
0ac4e9e
fix(migrate): isolate config compatibility checks
fengmk2 Jun 24, 2026
d94cb65
fix(test): pin pkg.pr.new migration builds by commit
fengmk2 Jun 24, 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
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