diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/app/index.html b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/app/index.html
new file mode 100644
index 0000000000..e302a8d292
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/app/index.html
@@ -0,0 +1,6 @@
+
+
+
+ positional root app
+
+
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/app/node_modules/vite/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/app/node_modules/vite/package.json
new file mode 100644
index 0000000000..ab9435ac0e
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/app/node_modules/vite/package.json
@@ -0,0 +1,4 @@
+{
+ "name": "vite",
+ "version": "99.0.0"
+}
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/index.html b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/index.html
new file mode 100644
index 0000000000..9cdb9b29a3
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/index.html
@@ -0,0 +1,6 @@
+
+
+
+ core version guard
+
+
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/node_modules/vite/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/node_modules/vite/package.json
new file mode 100644
index 0000000000..d446a2b458
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/node_modules/vite/package.json
@@ -0,0 +1,4 @@
+{
+ "name": "@voidzero-dev/vite-plus-core",
+ "version": "0.0.1"
+}
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/package.json
new file mode 100644
index 0000000000..737587eccc
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/package.json
@@ -0,0 +1,4 @@
+{
+ "name": "core-version-guard-test",
+ "private": true
+}
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/snapshots.toml
new file mode 100644
index 0000000000..f4ea887cfe
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/snapshots.toml
@@ -0,0 +1,15 @@
+# The vendored node_modules/vite/package.json shadows the runner's run-root
+# `vite` -> core link with a core at a version the CLI never shipped with,
+# simulating a dependency-bot bump of the `vite` alias without the matching
+# vite-plus bump (issue #2356). The app/ subdir vendors a real-vite-shaped
+# package, so the positional-root step passes only when the guard checks the
+# selected root instead of the workspace cwd.
+[[case]]
+name = "core_version_guard"
+vp = "local"
+steps = [
+ { argv = ["vp", "build"], continue-on-failure = true },
+ { argv = ["vp", "test"], continue-on-failure = true },
+ { argv = ["vp", "build", "app"], comment = "the guard checks the positional root, where vite is real Vite" },
+ { argv = ["vp", "build"], comment = "VP_SKIP_CORE_VERSION_CHECK=1 skips the guard", envs = [["VP_SKIP_CORE_VERSION_CHECK", "1"]] },
+]
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/snapshots/core_version_guard.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/snapshots/core_version_guard.md
new file mode 100644
index 0000000000..6105332d38
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_version_guard/snapshots/core_version_guard.md
@@ -0,0 +1,42 @@
+# core_version_guard
+
+## `vp build`
+
+**Exit code:** 1
+
+```
+error: Failed to resolve vite command: GenericFailure, Error: The project's `vite` alias resolves to @voidzero-dev/vite-plus-core@, but this vite-plus CLI requires @voidzero-dev/vite-plus-core@: the two packages are published in lockstep and other pairings are untested. A dependency bot usually causes this by updating vite-plus and the `vite` alias in separate PRs. Update the `vite` alias to npm:@voidzero-dev/vite-plus-core@ where it is declared (catalog, overrides, resolutions, or dependencies), or run `vp migrate` to realign it. Set VP_SKIP_CORE_VERSION_CHECK=1 to skip this check.
+```
+
+## `vp test`
+
+**Exit code:** 1
+
+```
+error: Failed to resolve test command: GenericFailure, Error: The project's `vite` alias resolves to @voidzero-dev/vite-plus-core@, but this vite-plus CLI requires @voidzero-dev/vite-plus-core@: the two packages are published in lockstep and other pairings are untested. A dependency bot usually causes this by updating vite-plus and the `vite` alias in separate PRs. Update the `vite` alias to npm:@voidzero-dev/vite-plus-core@ where it is declared (catalog, overrides, resolutions, or dependencies), or run `vp migrate` to realign it. Set VP_SKIP_CORE_VERSION_CHECK=1 to skip this check.
+```
+
+## `vp build app`
+
+the guard checks the positional root, where vite is real Vite
+
+```
+note: `vp build app` sets Vite's root without changing the working directory. To run as if started there, use `vp -C app build`.
+✓ 2 modules transformed.
+computing gzip size...
+app/dist/index.html kB │ gzip: kB
+
+✓ built in
+```
+
+## `VP_SKIP_CORE_VERSION_CHECK=1 vp build`
+
+VP_SKIP_CORE_VERSION_CHECK=1 skips the guard
+
+```
+✓ 2 modules transformed.
+computing gzip size...
+dist/index.html kB │ gzip: kB
+
+✓ built in
+```
diff --git a/packages/cli/binding/index.d.cts b/packages/cli/binding/index.d.cts
index 54660f8ba8..87f8a6777f 100644
--- a/packages/cli/binding/index.d.cts
+++ b/packages/cli/binding/index.d.cts
@@ -3370,12 +3370,12 @@ export interface BatchRewriteResult {
/** Configuration options passed from JavaScript to Rust. */
export interface CliOptions {
- lint: (err: Error | null) => Promise;
- fmt: (err: Error | null) => Promise;
- vite: (err: Error | null) => Promise;
- test: (err: Error | null) => Promise;
- pack: (err: Error | null) => Promise;
- doc: (err: Error | null) => Promise;
+ lint: (err: Error | null, arg: string) => Promise;
+ fmt: (err: Error | null, arg: string) => Promise;
+ vite: (err: Error | null, arg: string) => Promise;
+ test: (err: Error | null, arg: string) => Promise;
+ pack: (err: Error | null, arg: string) => Promise;
+ doc: (err: Error | null, arg: string) => Promise;
cwd?: string;
/** CLI arguments (should be process.argv.slice(2) from JavaScript) */
args?: Array;
diff --git a/packages/cli/binding/src/cli/app_target.rs b/packages/cli/binding/src/cli/app_target.rs
index 6c687ba7cd..5561666333 100644
--- a/packages/cli/binding/src/cli/app_target.rs
+++ b/packages/cli/binding/src/cli/app_target.rs
@@ -153,6 +153,62 @@ fn classify_args<'a>(command: &str, args: &'a [String]) -> ArgTarget<'a> {
ArgTarget::Bare
}
+/// The directory Vite loads the app config from, when the args select one:
+/// the parent of an explicit `-c`/`--config` file (which wins over a
+/// positional), else the `[root]` positional. `None` means the command's cwd.
+/// Walks the args like [`classify_args`] (cac/mri value consumption), but
+/// scans them all instead of returning at the first target, because Vite
+/// accepts `--config` and `[root]` in either order.
+///
+/// Used to aim the core-version guard at the copy of `vite` the app's config
+/// and plugins will import: Vite keeps the process cwd unchanged and rebases
+/// config lookup onto the selected root.
+pub(super) fn vite_config_dir(args: &[String], cwd: &AbsolutePath) -> Option {
+ let mut positional: Option<&str> = None;
+ let mut config: Option<&str> = None;
+ let mut iter = args.iter().peekable();
+ while let Some(arg) = iter.next() {
+ if !arg.starts_with('-') {
+ positional.get_or_insert(arg);
+ continue;
+ }
+ // `--` terminates options: the first following token is the
+ // positional, and nothing after it can be a flag.
+ if arg == "--" {
+ if positional.is_none() {
+ positional = iter.next().map(String::as_str);
+ }
+ break;
+ }
+ if arg == "-c" || arg == "--config" {
+ if let Some(next) = iter.peek() {
+ if !next.starts_with('-') {
+ config = iter.next().map(String::as_str);
+ }
+ }
+ continue;
+ }
+ if let Some(value) = arg.strip_prefix("-c=").or_else(|| arg.strip_prefix("--config=")) {
+ config = Some(value);
+ continue;
+ }
+ let is_boolean = VITE_BOOLEAN_FLAGS.contains(&arg.as_str()) || arg.starts_with("--no-");
+ if !is_boolean
+ && !arg.contains('=')
+ && iter.peek().is_some_and(|next| !next.starts_with('-'))
+ {
+ iter.next();
+ }
+ }
+ if let Some(config) = config {
+ // The config's parent dir, resolved like Vite resolves `--config`
+ // (relative to cwd). An empty parent means the config sits in cwd.
+ let parent = std::path::Path::new(config).parent().unwrap_or(std::path::Path::new(""));
+ return Some(cwd.join(parent).clean());
+ }
+ positional.map(|root| cwd.join(root).clean())
+}
+
/// Heuristic ranking signal: does a directory look runnable for `command`?
/// Used for ordering and single-candidate auto-selection, never for hiding.
/// The rules are documented in rfcs/cwd-flag.md ("The likely-runnable
@@ -513,6 +569,44 @@ pub(super) fn resolve_app_target(
mod tests {
use super::*;
+ #[test]
+ fn vite_config_dir_selects_the_app_dir() {
+ let to_args = |args: &[&str]| args.iter().map(|s| (*s).to_string()).collect::>();
+ let root = if cfg!(windows) { "C:\\ws" } else { "/ws" };
+ let cwd = AbsolutePath::new(root).unwrap();
+ let dir = |rel: &str| Some(cwd.join(rel).clean());
+
+ // No target selection: fall back to the command's cwd.
+ assert_eq!(vite_config_dir(&to_args(&[]), cwd), None);
+ assert_eq!(vite_config_dir(&to_args(&["--mode", "production"]), cwd), None);
+
+ // The `[root]` positional, wherever cac would see one.
+ assert_eq!(vite_config_dir(&to_args(&["apps/web"]), cwd), dir("apps/web"));
+ assert_eq!(
+ vite_config_dir(&to_args(&["--mode", "production", "apps/web"]), cwd),
+ dir("apps/web")
+ );
+ assert_eq!(vite_config_dir(&to_args(&["-w", "apps/web"]), cwd), dir("apps/web"));
+ assert_eq!(vite_config_dir(&to_args(&["--", "apps/web"]), cwd), dir("apps/web"));
+
+ // An explicit config wins over the positional in either order; its
+ // parent is where the config's imports resolve from.
+ assert_eq!(
+ vite_config_dir(&to_args(&["-c", "apps/web/vite.config.ts"]), cwd),
+ dir("apps/web")
+ );
+ assert_eq!(
+ vite_config_dir(&to_args(&["apps/web", "--config=conf/vite.config.ts"]), cwd),
+ dir("conf")
+ );
+ assert_eq!(
+ vite_config_dir(&to_args(&["-c", "conf/vite.config.ts", "apps/web"]), cwd),
+ dir("conf")
+ );
+ // A config in the cwd itself keeps the cwd as the guard dir.
+ assert_eq!(vite_config_dir(&to_args(&["-c", "vite.config.ts"]), cwd), dir(""));
+ }
+
#[test]
fn bare_means_no_positional_target_and_no_help() {
let to_args = |args: &[&str]| args.iter().map(|s| (*s).to_string()).collect::>();
diff --git a/packages/cli/binding/src/cli/execution.rs b/packages/cli/binding/src/cli/execution.rs
index 322a88913a..c0ad16f4fe 100644
--- a/packages/cli/binding/src/cli/execution.rs
+++ b/packages/cli/binding/src/cli/execution.rs
@@ -22,7 +22,7 @@ async fn resolve_and_build_command(
cwd: &AbsolutePathBuf,
) -> Result {
let resolved = resolver
- .resolve(subcommand, resolved_vite_config, envs)
+ .resolve(subcommand, resolved_vite_config, envs, cwd)
.await
.map_err(|e| Error::Anyhow(e))?;
diff --git a/packages/cli/binding/src/cli/handler.rs b/packages/cli/binding/src/cli/handler.rs
index 0811dd5091..a99fc70fa4 100644
--- a/packages/cli/binding/src/cli/handler.rs
+++ b/packages/cli/binding/src/cli/handler.rs
@@ -96,7 +96,8 @@ impl CommandHandler for VitePlusCommandHandler {
if super::app_target::needs_elicitation(&subcmd, &command.cwd) {
return Ok(HandledCommand::Verbatim);
}
- let resolved = self.resolver.resolve(subcmd, None, &command.envs).await?;
+ let resolved =
+ self.resolver.resolve(subcmd, None, &command.envs, &command.cwd).await?;
Ok(HandledCommand::Synthesized(resolved.into_synthetic_plan_request()))
}
CLIArgs::ViteTask(cmd) => Ok(HandledCommand::ViteTaskCommand(cmd)),
diff --git a/packages/cli/binding/src/cli/resolver.rs b/packages/cli/binding/src/cli/resolver.rs
index d5130042ab..54c2ce4257 100644
--- a/packages/cli/binding/src/cli/resolver.rs
+++ b/packages/cli/binding/src/cli/resolver.rs
@@ -12,6 +12,25 @@ use super::{
types::{CliOptions, ResolvedSubcommand, ResolvedUniversalViteConfig, SynthesizableSubcommand},
};
+/// The directory string handed to the JS `vite` resolver: the Vite app dir
+/// selected by a `[root]` positional or an explicit `-c`/`--config` file
+/// (where the app's config and plugins resolve `vite` from), falling back to
+/// the command's cwd.
+fn vite_resolver_dir(
+ args: &[String],
+ cwd: &AbsolutePath,
+ cwd_string: &str,
+) -> anyhow::Result {
+ match super::app_target::vite_config_dir(args, cwd) {
+ Some(dir) => Ok(dir
+ .as_path()
+ .to_str()
+ .ok_or_else(|| anyhow::anyhow!("vite root is not valid UTF-8"))?
+ .to_string()),
+ None => Ok(cwd_string.to_string()),
+ }
+}
+
/// Resolves synthesizable subcommands to concrete programs and arguments.
/// Used by both direct CLI execution and CommandHandler.
pub struct SubcommandResolver {
@@ -62,13 +81,16 @@ impl SubcommandResolver {
}
/// Resolve a synthesizable subcommand to a concrete program, args, cache config, and envs.
+ /// `cwd` is the directory the resolved command will run in (the task cwd
+ /// for intercepted script commands); it is forwarded to the JS resolvers.
pub(super) async fn resolve(
&self,
subcommand: SynthesizableSubcommand,
resolved_vite_config: Option<&ResolvedUniversalViteConfig>,
envs: &Arc, Arc>>,
+ cwd: &AbsolutePath,
) -> anyhow::Result {
- self.resolve_inner(subcommand, resolved_vite_config, envs).await
+ self.resolve_inner(subcommand, resolved_vite_config, envs, cwd).await
}
async fn resolve_inner(
@@ -76,11 +98,17 @@ impl SubcommandResolver {
subcommand: SynthesizableSubcommand,
resolved_vite_config: Option<&ResolvedUniversalViteConfig>,
envs: &Arc, Arc>>,
+ cwd: &AbsolutePath,
) -> anyhow::Result {
+ let cwd_string = cwd
+ .as_path()
+ .to_str()
+ .ok_or_else(|| anyhow::anyhow!("command cwd is not valid UTF-8"))?
+ .to_string();
match subcommand {
SynthesizableSubcommand::Lint { mut args } => {
let cli_options = self.cli_options()?;
- let resolved = (cli_options.lint)().await?;
+ let resolved = (cli_options.lint)(cwd_string.clone()).await?;
let js_path = resolved.bin_path;
let js_path_str = js_path
.to_str()
@@ -117,7 +145,7 @@ impl SubcommandResolver {
}
SynthesizableSubcommand::Fmt { mut args } => {
let cli_options = self.cli_options()?;
- let resolved = (cli_options.fmt)().await?;
+ let resolved = (cli_options.fmt)(cwd_string.clone()).await?;
let js_path = resolved.bin_path;
let js_path_str = js_path
.to_str()
@@ -153,7 +181,8 @@ impl SubcommandResolver {
}
SynthesizableSubcommand::Build { args } => {
let cli_options = self.cli_options()?;
- let resolved = (cli_options.vite)().await?;
+ let resolved =
+ (cli_options.vite)(vite_resolver_dir(&args, cwd, &cwd_string)?).await?;
let js_path = resolved.bin_path;
let js_path_str = js_path
.to_str()
@@ -182,7 +211,7 @@ impl SubcommandResolver {
}
SynthesizableSubcommand::Test { args } => {
let cli_options = self.cli_options()?;
- let resolved = (cli_options.test)().await?;
+ let resolved = (cli_options.test)(cwd_string.clone()).await?;
let js_path = resolved.bin_path;
let js_path_str = js_path
.to_str()
@@ -214,7 +243,7 @@ impl SubcommandResolver {
}
SynthesizableSubcommand::Pack { args } => {
let cli_options = self.cli_options()?;
- let resolved = (cli_options.pack)().await?;
+ let resolved = (cli_options.pack)(cwd_string.clone()).await?;
let js_path = resolved.bin_path;
let js_path_str = js_path
.to_str()
@@ -236,7 +265,8 @@ impl SubcommandResolver {
}
SynthesizableSubcommand::Dev { args } => {
let cli_options = self.cli_options()?;
- let resolved = (cli_options.vite)().await?;
+ let resolved =
+ (cli_options.vite)(vite_resolver_dir(&args, cwd, &cwd_string)?).await?;
let js_path = resolved.bin_path;
let js_path_str = js_path
.to_str()
@@ -254,7 +284,8 @@ impl SubcommandResolver {
}
SynthesizableSubcommand::Preview { args } => {
let cli_options = self.cli_options()?;
- let resolved = (cli_options.vite)().await?;
+ let resolved =
+ (cli_options.vite)(vite_resolver_dir(&args, cwd, &cwd_string)?).await?;
let js_path = resolved.bin_path;
let js_path_str = js_path
.to_str()
@@ -272,7 +303,7 @@ impl SubcommandResolver {
}
SynthesizableSubcommand::Doc { args } => {
let cli_options = self.cli_options()?;
- let resolved = (cli_options.doc)().await?;
+ let resolved = (cli_options.doc)(cwd_string.clone()).await?;
let js_path = resolved.bin_path;
let js_path_str = js_path
.to_str()
diff --git a/packages/cli/binding/src/cli/types.rs b/packages/cli/binding/src/cli/types.rs
index c2fda6c821..e5c3f06c55 100644
--- a/packages/cli/binding/src/cli/types.rs
+++ b/packages/cli/binding/src/cli/types.rs
@@ -133,10 +133,13 @@ pub(super) enum CLIArgs {
Toolchain(ToolchainArgs),
}
-/// Type alias for boxed async resolver function
+/// Type alias for boxed async resolver function. Takes the directory the
+/// resolved command will run in (the task cwd for intercepted script
+/// commands), so JS-side checks can resolve against the right package.
/// NOTE: Uses anyhow::Error to avoid NAPI type inference issues
-pub type BoxedResolverFn =
- Box Pin> + 'static>>>;
+pub type BoxedResolverFn = Box<
+ dyn Fn(String) -> Pin> + 'static>>,
+>;
/// Type alias for vite config resolver function (takes package path, returns JSON string)
/// Uses Arc for cloning and Send + Sync for use in UserConfigLoader
diff --git a/packages/cli/binding/src/lib.rs b/packages/cli/binding/src/lib.rs
index 4c5b3a8f2f..12103642bb 100644
--- a/packages/cli/binding/src/lib.rs
+++ b/packages/cli/binding/src/lib.rs
@@ -65,12 +65,12 @@ pub fn ensure_blocking_stdio() {
/// Configuration options passed from JavaScript to Rust.
#[napi(object, object_to_js = false)]
pub struct CliOptions {
- pub lint: Arc>>,
- pub fmt: Arc>>,
- pub vite: Arc>>,
- pub test: Arc>>,
- pub pack: Arc>>,
- pub doc: Arc>>,
+ pub lint: Arc>>,
+ pub fmt: Arc>>,
+ pub vite: Arc>>,
+ pub test: Arc>>,
+ pub pack: Arc>>,
+ pub doc: Arc>>,
pub cwd: Option,
/// CLI arguments (should be process.argv.slice(2) from JavaScript)
pub args: Option>,
@@ -98,18 +98,20 @@ impl From for ResolveCommandResult {
}
}
-/// Create a boxed resolver function from a ThreadsafeFunction
+/// Create a boxed resolver function from a ThreadsafeFunction. The `cwd`
+/// argument is the directory the resolved command will run in; it reaches the
+/// JS resolver as its second (callee-handled) argument.
/// NOTE: Uses anyhow::Error to avoid NAPI type interference with vp_error::Error
fn create_resolver(
- tsf: Arc>>,
+ tsf: Arc>>,
error_message: &'static str,
) -> BoxedResolverFn {
- Box::new(move || {
+ Box::new(move |cwd: String| {
let tsf = tsf.clone();
Box::pin(async move {
// Call JS function - map napi::Error to anyhow::Error
let promise: Promise = tsf
- .call_async(Ok(()))
+ .call_async(Ok(cwd))
.await
.map_err(|e| anyhow::anyhow!("{}: {}", error_message, e))?;
diff --git a/packages/cli/src/resolve-test.ts b/packages/cli/src/resolve-test.ts
index 42e4d6f850..b342aa1f05 100644
--- a/packages/cli/src/resolve-test.ts
+++ b/packages/cli/src/resolve-test.ts
@@ -14,6 +14,7 @@ import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { DEFAULT_ENVS, resolveBundled } from './utils/constants.ts';
+import { checkCoreVersionMatchOnce } from './utils/core-version-guard.ts';
interface VitestPackageJson {
bin?: string | Record;
@@ -34,10 +35,19 @@ interface VitestPackageJson {
* unreachable. See `resolveBundled` for the rationale (avoiding dual-copy
* Vitest internal-state / mock-hoisting mismatches).
*/
-export async function test(): Promise<{
+export async function test(
+ // Callee-handled NAPI callback: the payload (the command's cwd) is the
+ // second argument, after the error slot.
+ _err?: unknown,
+ taskDir?: string,
+): Promise<{
binPath: string;
envs: Record;
}> {
+ // Fail fast before the bundled Vitest loads a skewed `vite` alias as its
+ // vite (see core-version-guard.ts).
+ checkCoreVersionMatchOnce(taskDir);
+
const pkgJsonPath = resolveBundled('vitest/package.json');
const pkgRoot = dirname(pkgJsonPath);
const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')) as VitestPackageJson;
diff --git a/packages/cli/src/resolve-vite.ts b/packages/cli/src/resolve-vite.ts
index 506cbe8588..cbc3d758dd 100644
--- a/packages/cli/src/resolve-vite.ts
+++ b/packages/cli/src/resolve-vite.ts
@@ -12,6 +12,7 @@
import { dirname, join } from 'node:path';
import { DEFAULT_ENVS, resolve } from './utils/constants.ts';
+import { checkCoreVersionMatchOnce } from './utils/core-version-guard.ts';
/**
* Resolves the Vite binary path and environment variables.
@@ -24,10 +25,18 @@ import { DEFAULT_ENVS, resolve } from './utils/constants.ts';
* to vite package (for direct vite installations).
* It constructs the path to the CLI binary within the resolved package.
*/
-export async function vite(): Promise<{
+export async function vite(
+ // Callee-handled NAPI callback: the payload (the command's cwd) is the
+ // second argument, after the error slot.
+ _err?: unknown,
+ taskDir?: string,
+): Promise<{
binPath: string;
envs: Record;
}> {
+ // Fail fast on a `vite` alias that skews from the CLI (see core-version-guard.ts).
+ checkCoreVersionMatchOnce(taskDir);
+
// Vite's CLI binary is located at bin/vite.js relative to the package root
const vitePackagePath = dirname(resolve('@voidzero-dev/vite-plus-core'));
const binPath = join(vitePackagePath, 'cli.js');
diff --git a/packages/cli/src/utils/__tests__/core-version-guard.spec.ts b/packages/cli/src/utils/__tests__/core-version-guard.spec.ts
new file mode 100644
index 0000000000..e7d5d280bd
--- /dev/null
+++ b/packages/cli/src/utils/__tests__/core-version-guard.spec.ts
@@ -0,0 +1,75 @@
+import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { VITE_PLUS_CORE_PACKAGE_NAME as CORE } from '../constants.ts';
+import {
+ assertCoreVersionMatch,
+ checkCoreVersionMatch,
+ SKIP_CORE_VERSION_CHECK_ENV,
+} from '../core-version-guard.ts';
+
+describe('assertCoreVersionMatch', () => {
+ it('does not throw when the aliased core matches the expected version', () => {
+ expect(() => assertCoreVersionMatch('1.2.3', '1.2.3')).not.toThrow();
+ });
+
+ it('throws with both versions, the fix spec, and the escape hatch on a skew', () => {
+ expect(() => assertCoreVersionMatch('1.2.0', '1.2.3')).toThrow(
+ new RegExp(`${CORE}@1\\.2\\.0.*npm:${CORE}@1\\.2\\.3.*${SKIP_CORE_VERSION_CHECK_ENV}`, 's'),
+ );
+ });
+
+ it('does not throw when no aliased core is installed', () => {
+ expect(() => assertCoreVersionMatch(null, '1.2.3')).not.toThrow();
+ expect(() => assertCoreVersionMatch(undefined, '1.2.3')).not.toThrow();
+ });
+});
+
+describe('checkCoreVersionMatch', () => {
+ let projectDir: string;
+
+ // What `vite` resolves to in the project, shaped like a real install.
+ function writeVitePackage(pkg: { name: string; version: string }) {
+ const dir = join(projectDir, 'node_modules', 'vite');
+ mkdirSync(dir, { recursive: true });
+ writeFileSync(join(dir, 'package.json'), JSON.stringify(pkg));
+ }
+
+ beforeEach(() => {
+ // realpath so resolution output matches (macOS tmpdir is /var -> /private/var).
+ projectDir = realpathSync(mkdtempSync(join(tmpdir(), 'vp-core-guard-')));
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ rmSync(projectDir, { recursive: true, force: true });
+ });
+
+ it('throws when the installed aliased core skews from the expected version', () => {
+ writeVitePackage({ name: CORE, version: '1.2.0' });
+ expect(() => checkCoreVersionMatch(projectDir, '1.2.3')).toThrow(`${CORE}@1.2.0`);
+ });
+
+ it('does not throw when the installed aliased core matches', () => {
+ writeVitePackage({ name: CORE, version: '1.2.3' });
+ expect(() => checkCoreVersionMatch(projectDir, '1.2.3')).not.toThrow();
+ });
+
+ it(`skips the check when ${SKIP_CORE_VERSION_CHECK_ENV} is set`, () => {
+ vi.stubEnv(SKIP_CORE_VERSION_CHECK_ENV, '1');
+ writeVitePackage({ name: CORE, version: '1.2.0' });
+ expect(() => checkCoreVersionMatch(projectDir, '1.2.3')).not.toThrow();
+ });
+
+ it('does not throw for a project on real Vite', () => {
+ writeVitePackage({ name: 'vite', version: '99.0.0' });
+ expect(() => checkCoreVersionMatch(projectDir, '1.2.3')).not.toThrow();
+ });
+
+ it('does not throw when vite is not installed', () => {
+ expect(() => checkCoreVersionMatch(projectDir, '1.2.3')).not.toThrow();
+ });
+});
diff --git a/packages/cli/src/utils/constants.ts b/packages/cli/src/utils/constants.ts
index 9561e2bd60..eb09937499 100644
--- a/packages/cli/src/utils/constants.ts
+++ b/packages/cli/src/utils/constants.ts
@@ -3,8 +3,18 @@ import { createRequire } from 'node:module';
import cliPkg from '../../package.json' with { type: 'json' };
export const VITE_PLUS_NAME = 'vite-plus';
+export const VITE_PLUS_CORE_PACKAGE_NAME = '@voidzero-dev/vite-plus-core';
export const VITE_PLUS_VERSION = process.env.VP_VERSION || cliPkg.version;
+/**
+ * The version of the CLI package that is actually running, untouched by
+ * `VP_VERSION`. The installer docs tell users to set `VP_VERSION` (and on
+ * PowerShell it persists for the session), and the Rust CLI injects it into
+ * every child env, so anything that must describe the running CLI (not the
+ * install/migrate target) has to read this instead of {@link VITE_PLUS_VERSION}.
+ */
+export const CLI_PACKAGE_VERSION: string = cliPkg.version;
+
// Mirrors Vite's DEFAULT_CONFIG_FILES order so readers and writers target the same file.
export const VITE_CONFIG_FILES = [
'vite.config.js',
@@ -20,7 +30,7 @@ export const VITEST_VERSION = '4.1.10';
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@${VITE_PLUS_VERSION}`,
+ vite: `npm:${VITE_PLUS_CORE_PACKAGE_NAME}@${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/core-version-guard.ts b/packages/cli/src/utils/core-version-guard.ts
new file mode 100644
index 0000000000..c23f4bfa07
--- /dev/null
+++ b/packages/cli/src/utils/core-version-guard.ts
@@ -0,0 +1,91 @@
+/**
+ * Version-skew guard for the project's `vite` alias.
+ *
+ * `vp create` / `vp migrate` scaffold two entries that must move in lockstep:
+ * the `vite-plus` dependency and the `vite` alias
+ * (`npm:@voidzero-dev/vite-plus-core@`). A dependency bot sees
+ * two unrelated packages and bumps them in separate PRs, so a project can end
+ * up running a CLI/core pairing that was never published together (#2356).
+ * The skew is silent: `vp build`/`vp dev`/`vp test` execute the CLI's own
+ * core dependency, while plugins and configs that `import 'vite'` load the
+ * project's aliased copy at the other version. Fail fast instead, so a
+ * mismatched bot PR fails CI before the pairing ships.
+ *
+ * The expected version is the running CLI package's own version
+ * ({@link CLI_PACKAGE_VERSION}), never an env-derived one: `VP_VERSION` can
+ * linger from the installer session or arrive injected by a parent `vp`
+ * process, and preview builds publish CLI and core from one commit with equal
+ * versions, so the package version is correct for every flow.
+ */
+
+import { CLI_PACKAGE_VERSION, VITE_PLUS_CORE_PACKAGE_NAME } from './constants.ts';
+import { detectPackageMetadata } from './package.ts';
+
+export const SKIP_CORE_VERSION_CHECK_ENV = 'VP_SKIP_CORE_VERSION_CHECK';
+
+/**
+ * Throw when the project's aliased core version differs from the version the
+ * CLI expects. A no-op when no aliased core is installed.
+ *
+ * Exported for unit testing.
+ */
+export function assertCoreVersionMatch(
+ installedVersion: string | null | undefined,
+ expectedVersion: string,
+): void {
+ if (installedVersion && installedVersion !== expectedVersion) {
+ // Keep every version inside a `@voidzero-dev/vite-plus-core@` context:
+ // the PTY snapshot redactor masks the CLI's own version only in that form
+ // (a bare `vite-plus@` stays verbatim and would churn every release).
+ throw new Error(
+ `The project's \`vite\` alias resolves to ${VITE_PLUS_CORE_PACKAGE_NAME}@${installedVersion}, ` +
+ `but this vite-plus CLI requires ${VITE_PLUS_CORE_PACKAGE_NAME}@${expectedVersion}: the two ` +
+ `packages are published in lockstep and other pairings are untested. A dependency ` +
+ `bot usually causes this by updating vite-plus and the \`vite\` alias in separate ` +
+ `PRs. Update the \`vite\` alias to npm:${VITE_PLUS_CORE_PACKAGE_NAME}@${expectedVersion} ` +
+ `where it is declared (catalog, overrides, resolutions, or dependencies), or run ` +
+ `\`vp migrate\` to realign it. Set ${SKIP_CORE_VERSION_CHECK_ENV}=1 to skip this check.`,
+ );
+ }
+}
+
+/**
+ * Orchestrates the guard: honor the escape hatch, read what `vite` resolves
+ * to from the command's directory (the copy plugins and configs import), and
+ * assert it against the running CLI's version. A project on real Vite, or
+ * with no `vite` installed, passes.
+ *
+ * The `expectedVersion` parameter exists for unit tests; production callers
+ * use the default.
+ */
+export function checkCoreVersionMatch(
+ projectDir: string = process.cwd(),
+ expectedVersion: string = CLI_PACKAGE_VERSION,
+): void {
+ if (process.env[SKIP_CORE_VERSION_CHECK_ENV]) {
+ return;
+ }
+ const installed = detectPackageMetadata(projectDir, 'vite');
+ assertCoreVersionMatch(
+ installed && installed.name === VITE_PLUS_CORE_PACKAGE_NAME ? installed.version : null,
+ expectedVersion,
+ );
+}
+
+const checkedDirs = new Set();
+
+/**
+ * Memoized wrapper for the resolver path. The `vite`/`test` resolvers run
+ * once per intercepted script command, so a `vp run` across a large workspace
+ * would repeat the same read; one check per execution directory suffices.
+ * The directory comes from the Rust side (the task cwd), because retargeted
+ * runs (`defaultPackage`, `vp run -r`) execute in a package dir while the
+ * Node process cwd stays at the invocation root.
+ */
+export function checkCoreVersionMatchOnce(projectDir: string = process.cwd()): void {
+ if (checkedDirs.has(projectDir)) {
+ return;
+ }
+ checkedDirs.add(projectDir);
+ checkCoreVersionMatch(projectDir);
+}