Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
38d3b0d
docs(rfc): vp migrate upgrade path for existing Vite+ projects
fengmk2 Jun 10, 2026
bdcd028
docs(rfc): cover real 0.1.24->0.2.0 upgrade failure in vp migrate
fengmk2 Jun 18, 2026
abb80b1
docs(rfc): align vp migrate upgrade with the v0.2.1 prompt spec
fengmk2 Jun 18, 2026
48741fc
fix(migrate): make vp migrate upgrade v0.1.x projects to v0.2.x
fengmk2 Jun 18, 2026
83b09d0
feat(migrate): manage vitest only when the project uses it directly
fengmk2 Jun 18, 2026
147a524
feat(migrate): align the full @vitest/* ecosystem to the bundled vitest
fengmk2 Jun 19, 2026
537dd8d
docs(rfc): revise migrate RFC for vitest provisioning and ecosystem r…
fengmk2 Jun 19, 2026
51d173c
fix(migrate): make upgrade provisioning peer-safe
fengmk2 Jun 19, 2026
9c6a10e
fix(migrate): validate upgrade scenarios in snapshots
fengmk2 Jun 19, 2026
75ee940
test(migrate): update default vitest snapshots
fengmk2 Jun 21, 2026
8cec6cd
fix(migrate): handle peer and override edge cases
fengmk2 Jun 21, 2026
abb378f
fix(migrate): cover remaining vitest upgrade cases
fengmk2 Jun 21, 2026
be64221
fix(test): normalize snapshot file endings
fengmk2 Jun 21, 2026
2a1858c
test(migrate): sync idempotency snapshots
fengmk2 Jun 21, 2026
c4eece4
test(create): update standalone Yarn catalog snapshot
fengmk2 Jun 21, 2026
3a88b1f
fix(migrate): preserve vitest imports for Nuxt tests
fengmk2 Jun 23, 2026
c1c76ff
test(ecosystem-ci): update npmx.dev fixture
fengmk2 Jun 23, 2026
79fc024
test(cli): stabilize Nuxt lint snapshot
fengmk2 Jun 23, 2026
5bd3ac6
fix(migrate): preserve Vitest across Nuxt packages
fengmk2 Jun 23, 2026
d5f8413
fix(migrate): convert Yarn PnP projects
fengmk2 Jun 23, 2026
671ecf1
test(ecosystem): install Playwright for npmx.dev
fengmk2 Jun 23, 2026
8d4943d
test(migrate): cover conservative monorepo retention
fengmk2 Jun 23, 2026
1d4c232
fix(migrate): pin pkg.pr.new targets in test helper
fengmk2 Jun 23, 2026
2a36b25
fix(test): keep pkg.pr.new overrides minimal
fengmk2 Jun 23, 2026
86b987a
fix(migrate): allow pkg.pr.new pnpm subdependencies
fengmk2 Jun 23, 2026
2fc05c2
fix(test): refresh mutable pkg.pr.new installs
fengmk2 Jun 24, 2026
2221d69
fix(migrate): preserve Vitest ecosystem catalogs
fengmk2 Jun 24, 2026
911881e
fix(migrate): pin vite-plus toolchain versions
fengmk2 Jun 24, 2026
f16719c
fix(test): reuse unchanged pkg.pr.new install
fengmk2 Jun 24, 2026
083c285
fix(test): run pkg.pr.new migration from project root
fengmk2 Jun 24, 2026
0c515e3
fix(migrate): isolate config compatibility checks
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
224 changes: 224 additions & 0 deletions .github/scripts/test-pkg-pr-new-migrate.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
#!/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")"
cached_version_dir="$pr_home/pkg-pr-new-$pr_ref"
vp_bin="$pr_home/bin/vp"
vite_plus_package_json="$pr_home/current/node_modules/vite-plus/package.json"
global_cli_entry="$pr_home/current/node_modules/vite-plus/dist/bin.js"
commit_marker="$cached_version_dir/.pkg-pr-new-commit"
pkg_pr_new_base="https://pkg.pr.new/voidzero-dev/vite-plus"
vite_plus_spec="$pkg_pr_new_base@$pr_ref"
vite_plus_core_spec="$pkg_pr_new_base/@voidzero-dev/vite-plus-core@$pr_ref"

resolve_pkg_pr_new_commit() {
curl -fsSIL "$vite_plus_spec" | tr -d '\r' | awk -F ': ' '
tolower($1) == "x-commit-key" {
count = split($2, parts, ":")
print parts[count]
exit
}
'
}

read_installed_commit() {
if [ -f "$commit_marker" ]; then
head -n 1 "$commit_marker"
return
fi

if [ -f "$vite_plus_package_json" ]; then
awk -F '"' '
$2 == "@voidzero-dev/vite-plus-core" {
value = $4
sub(/^.*@/, "", value)
print value
exit
}
' "$vite_plus_package_json"
fi
}

available_commit="$(resolve_pkg_pr_new_commit || true)"
installed_commit="$(read_installed_commit || true)"
current_target="$(readlink "$pr_home/current" 2>/dev/null || true)"
reuse_install=0

if [ -n "$available_commit" ] &&
[ "$installed_commit" = "$available_commit" ] &&
[ "$current_target" = "pkg-pr-new-$pr_ref" ] &&
[ -x "$vp_bin" ] &&
[ -f "$vite_plus_package_json" ] &&
[ -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' "$available_commit" > "$commit_marker"
echo "Reusing installed Vite+ pkg.pr.new build $pr_ref ($available_commit) from $pr_home"
else
if [ -z "$available_commit" ]; then
echo "Could not verify the current pkg.pr.new commit; reinstalling $pr_ref."
elif [ -n "$installed_commit" ]; then
echo "pkg.pr.new build changed: $installed_commit -> $available_commit"
fi

# Numeric pkg.pr.new references are mutable PR aliases. If the published
# commit changed, the reused lockfile can retain the checksum from the older
# tarball and fail with ERR_PNPM_TARBALL_INTEGRITY. Keep the downloaded
# runtime/package-manager cache, but force the wrapper dependency to resolve
# again. Commit SHA references are immutable and use their own cache path.
case "$pr_ref" in
*[!0-9]*) ;;
*)
rm -rf "$cached_version_dir/node_modules"
rm -f "$cached_version_dir/pnpm-lock.yaml"
;;
esac

echo "Installing Vite+ pkg.pr.new build $pr_ref into $pr_home"
HOME="$installer_home" \
VP_HOME="$pr_home" \
VP_PR_VERSION="$pr_ref" \
VP_NODE_MANAGER=no \
bash "$installer"

if [ -n "$available_commit" ]; then
printf '%s\n' "$available_commit" > "$commit_marker"
fi
fi

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