Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
c79cac5
docs(rfc): vp migrate upgrade path for existing Vite+ projects
fengmk2 Jun 10, 2026
6f46706
docs(rfc): cover real 0.1.24->0.2.0 upgrade failure in vp migrate
fengmk2 Jun 18, 2026
fd2c046
docs(rfc): align vp migrate upgrade with the v0.2.1 prompt spec
fengmk2 Jun 18, 2026
da602ef
fix(migrate): make vp migrate upgrade v0.1.x projects to v0.2.x
fengmk2 Jun 18, 2026
d56c85f
feat(migrate): manage vitest only when the project uses it directly
fengmk2 Jun 18, 2026
6812791
feat(migrate): align the full @vitest/* ecosystem to the bundled vitest
fengmk2 Jun 19, 2026
6eb15e1
docs(rfc): revise migrate RFC for vitest provisioning and ecosystem r…
fengmk2 Jun 19, 2026
9a8a596
fix(migrate): make upgrade provisioning peer-safe
fengmk2 Jun 19, 2026
5e478cf
fix(migrate): validate upgrade scenarios in snapshots
fengmk2 Jun 19, 2026
9cc5983
test(migrate): update default vitest snapshots
fengmk2 Jun 21, 2026
26adc38
fix(migrate): handle peer and override edge cases
fengmk2 Jun 21, 2026
59c4a0a
fix(migrate): cover remaining vitest upgrade cases
fengmk2 Jun 21, 2026
d019d15
fix(test): normalize snapshot file endings
fengmk2 Jun 21, 2026
9d96c5a
test(migrate): sync idempotency snapshots
fengmk2 Jun 21, 2026
1c18e54
test(create): update standalone Yarn catalog snapshot
fengmk2 Jun 21, 2026
1db1199
fix(migrate): preserve vitest imports for Nuxt tests
fengmk2 Jun 23, 2026
ac4ead7
test(ecosystem-ci): update npmx.dev fixture
fengmk2 Jun 23, 2026
90b08c0
test(cli): stabilize Nuxt lint snapshot
fengmk2 Jun 23, 2026
d60870c
fix(migrate): preserve Vitest across Nuxt packages
fengmk2 Jun 23, 2026
92b5682
fix(migrate): convert Yarn PnP projects
fengmk2 Jun 23, 2026
59cdd47
test(ecosystem): install Playwright for npmx.dev
fengmk2 Jun 23, 2026
4a57d7c
test(migrate): cover conservative monorepo retention
fengmk2 Jun 23, 2026
5ba5d3a
fix(migrate): pin pkg.pr.new targets in test helper
fengmk2 Jun 23, 2026
15c0eb4
fix(test): keep pkg.pr.new overrides minimal
fengmk2 Jun 23, 2026
ea0a786
fix(migrate): allow pkg.pr.new pnpm subdependencies
fengmk2 Jun 23, 2026
890a165
fix(test): refresh mutable pkg.pr.new installs
fengmk2 Jun 24, 2026
49f5dce
fix(migrate): preserve Vitest ecosystem catalogs
fengmk2 Jun 24, 2026
9aa1e2b
fix(migrate): pin vite-plus toolchain versions
fengmk2 Jun 24, 2026
c978c47
fix(test): reuse unchanged pkg.pr.new install
fengmk2 Jun 24, 2026
1808d55
fix(test): run pkg.pr.new migration from project root
fengmk2 Jun 24, 2026
dcef017
fix(migrate): isolate config compatibility checks
fengmk2 Jun 24, 2026
7020d2a
fix(test): pin pkg.pr.new migration builds by commit
fengmk2 Jun 24, 2026
1dd7b6f
fix(migrate): move pnpm settings to workspace config
fengmk2 Jun 25, 2026
f550119
docs(migrate): document user-facing migration rules
fengmk2 Jun 25, 2026
018f48c
test(migrate): update migration snapshots
fengmk2 Jun 25, 2026
670c987
docs(migrate): clarify pnpm vite dependency rule
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