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); +}