diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/missing_local_cli_warning/assert-silent-fallback.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/missing_local_cli_warning/assert-silent-fallback.mjs new file mode 100644 index 0000000000..2b4e532c28 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/missing_local_cli_warning/assert-silent-fallback.mjs @@ -0,0 +1,15 @@ +import { spawnSync } from 'node:child_process' + +const result = spawnSync('vp', ['lint', '--help'], { encoding: 'utf8' }) +const output = `${result.stdout}${result.stderr}` + +if (result.error) + throw result.error + +if (result.status !== 0 || !output.includes('Usage: vp lint')) + throw new Error(`Global CLI did not run successfully:\n${output}`) + +if (output.includes('No project-local vite-plus installation was found')) + throw new Error(`Unexpected missing local CLI warning:\n${output}`) + +console.log('Global fallback remained silent.') diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/missing_local_cli_warning/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/missing_local_cli_warning/package.json new file mode 100644 index 0000000000..d616721d28 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/missing_local_cli_warning/package.json @@ -0,0 +1,4 @@ +{ + "name": "missing-local-cli-warning", + "version": "1.0.0" +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/missing_local_cli_warning/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/missing_local_cli_warning/snapshots.toml new file mode 100644 index 0000000000..8a861543b3 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/missing_local_cli_warning/snapshots.toml @@ -0,0 +1,12 @@ +[[case]] +name = "missing_local_cli_warning" +vp = "global" +skip-platforms = ["windows"] +steps = [ + { argv = ["vpt", "write-file", "node_modules/vite-plus/package.json", '{"name":"vite-plus","version":"0.0.0"}'], snapshot = false }, + { argv = ["vp", "lint", "src/index.js"], comment = "a project that does not declare vite-plus gets migration guidance" }, + { argv = ["vpt", "json-edit", "package.json", "devDependencies.vite-plus", "0.0.0"], snapshot = false }, + { argv = ["vp", "lint", "src/index.js"], comment = "a project that declares vite-plus but has no local CLI gets installation guidance" }, + { argv = ["vpt", "rm", "package.json"], snapshot = false }, + { argv = ["node", "assert-silent-fallback.mjs"], comment = "outside a project, global fallback remains silent" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/missing_local_cli_warning/snapshots/missing_local_cli_warning.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/missing_local_cli_warning/snapshots/missing_local_cli_warning.md new file mode 100644 index 0000000000..1dff992a4b --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/missing_local_cli_warning/snapshots/missing_local_cli_warning.md @@ -0,0 +1,42 @@ +# missing_local_cli_warning + +## `vpt write-file node_modules/vite-plus/package.json '{"name":"vite-plus","version":"0.0.0"}'` + + +## `vp lint src/index.js` + +a project that does not declare vite-plus gets migration guidance + +``` +VITE+ - The Unified Toolchain for the Web + +warn: This project does not use vite-plus. Learn how to migrate: https://viteplus.dev/guide/migrate +Found 0 warnings and 0 errors. +Finished in on 1 file with rules using threads. +``` + +## `vpt json-edit package.json devDependencies.vite-plus 0.0.0` + + +## `vp lint src/index.js` + +a project that declares vite-plus but has no local CLI gets installation guidance + +``` +VITE+ - The Unified Toolchain for the Web + +warn: No project-local vite-plus installation was found. Run `vp install` in `` to install dependencies. +Found 0 warnings and 0 errors. +Finished in on 1 file with rules using threads. +``` + +## `vpt rm package.json` + + +## `node assert-silent-fallback.mjs` + +outside a project, global fallback remains silent + +``` +Global fallback remained silent. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/missing_local_cli_warning/src/index.js b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/missing_local_cli_warning/src/index.js new file mode 100644 index 0000000000..eab39ce89c --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/missing_local_cli_warning/src/index.js @@ -0,0 +1 @@ +export const answer = 42 diff --git a/crates/vp_global_cli/src/commands/delegate.rs b/crates/vp_global_cli/src/commands/delegate.rs index 553a2e8893..879f47b0a6 100644 --- a/crates/vp_global_cli/src/commands/delegate.rs +++ b/crates/vp_global_cli/src/commands/delegate.rs @@ -28,7 +28,7 @@ pub async fn execute_output( command: &str, args: &[String], ) -> Result { - let mut executor = JsExecutor::new(None); + let mut executor = JsExecutor::new(None).without_missing_local_cli_warning(); let mut full_args = vec![command.to_string()]; full_args.extend(args.iter().cloned()); executor.delegate_to_local_cli_output(&cwd, &full_args).await diff --git a/crates/vp_global_cli/src/commands/migrate.rs b/crates/vp_global_cli/src/commands/migrate.rs index 8affa4c055..4ccc3c7862 100644 --- a/crates/vp_global_cli/src/commands/migrate.rs +++ b/crates/vp_global_cli/src/commands/migrate.rs @@ -12,7 +12,7 @@ use crate::{error::Error, js_executor::JsExecutor}; /// 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 { - let mut executor = JsExecutor::new(None); + let mut executor = JsExecutor::new(None).without_missing_local_cli_warning(); let mut full_args = vec!["migrate".to_string()]; full_args.extend(args.iter().cloned()); executor.delegate_migrate(&cwd, &full_args).await diff --git a/crates/vp_global_cli/src/commands/mod.rs b/crates/vp_global_cli/src/commands/mod.rs index 47b97046a1..a7bf308f88 100644 --- a/crates/vp_global_cli/src/commands/mod.rs +++ b/crates/vp_global_cli/src/commands/mod.rs @@ -18,8 +18,8 @@ use std::{collections::HashMap, io::BufReader}; -use vp_shared::{PrependOptions, prepend_to_path_env}; -use vt_path::AbsolutePath; +use vp_shared::{PrependOptions, output, prepend_to_path_env}; +use vt_path::{AbsolutePath, AbsolutePathBuf}; use crate::{error::Error, js_executor::JsExecutor}; @@ -30,35 +30,84 @@ struct DepCheckPackageJson { dependencies: HashMap, #[serde(default)] dev_dependencies: HashMap, + #[serde(default)] + optional_dependencies: HashMap, } -/// Check if vite-plus is listed in the nearest package.json's -/// dependencies or devDependencies. -/// -/// Returns `true` if vite-plus is found, `false` if not found -/// or if no package.json exists. -pub fn has_vite_plus_dependency(cwd: &AbsolutePath) -> bool { +fn find_nearest_package_json(cwd: &AbsolutePath) -> Option { let mut current = cwd; loop { let package_json_path = current.join("package.json"); if package_json_path.as_path().exists() { - if let Ok(file) = std::fs::File::open(&package_json_path) { - if let Ok(pkg) = - serde_json::from_reader::<_, DepCheckPackageJson>(BufReader::new(file)) - { - return pkg.dependencies.contains_key("vite-plus") - || pkg.dev_dependencies.contains_key("vite-plus"); - } - } - return false; // Found package.json but couldn't parse deps → treat as no dependency + return Some(package_json_path); + } + match current.parent() { + Some(parent) if parent != current => current = parent, + _ => return None, + } + } +} + +fn package_json_has_vite_plus_dependency(package_json_path: &AbsolutePath) -> bool { + if let Ok(file) = std::fs::File::open(package_json_path) + && let Ok(pkg) = serde_json::from_reader::<_, DepCheckPackageJson>(BufReader::new(file)) + { + return pkg.dependencies.contains_key("vite-plus") + || pkg.dev_dependencies.contains_key("vite-plus") + || pkg.optional_dependencies.contains_key("vite-plus"); + } + false +} + +fn find_vite_plus_dependency(cwd: &AbsolutePath) -> Option { + let mut current = cwd; + loop { + if package_json_has_vite_plus_dependency(¤t.join("package.json")) { + return Some(current.to_absolute_path_buf()); } match current.parent() { Some(parent) if parent != current => current = parent, - _ => return false, // Reached filesystem root + _ => return None, } } } +/// Check if vite-plus is listed in the nearest package.json's +/// dependencies, devDependencies, or optionalDependencies. +/// +/// Returns `true` if vite-plus is found, `false` if not found +/// or if no package.json exists. +pub fn has_vite_plus_dependency(cwd: &AbsolutePath) -> bool { + find_nearest_package_json(cwd) + .is_some_and(|package_json_path| package_json_has_vite_plus_dependency(&package_json_path)) +} + +pub(crate) fn warn_missing_local_cli_if_project(cwd: &AbsolutePath) { + if find_nearest_package_json(cwd).is_none() { + return; + } + + let install_dir = if has_vite_plus_dependency(cwd) + || vt_workspace::find_workspace_root(cwd) + .is_ok_and(|(workspace_root, _)| has_vite_plus_dependency(workspace_root.path.as_ref())) + { + Some(cwd.to_absolute_path_buf()) + } else { + find_vite_plus_dependency(cwd) + }; + + if let Some(install_dir) = install_dir { + output::warn(&format!( + "No project-local vite-plus installation was found. Run `vp install` in `{}` to install dependencies.", + install_dir.as_path().display() + )); + } else { + output::warn( + "This project does not use vite-plus. Learn how to migrate: https://viteplus.dev/guide/migrate", + ); + } +} + /// Ensure the JS runtime is downloaded and prepend its bin directory to PATH. /// This should be called before executing any package manager command. /// diff --git a/crates/vp_global_cli/src/js_executor.rs b/crates/vp_global_cli/src/js_executor.rs index 66c263ec49..053110cab9 100644 --- a/crates/vp_global_cli/src/js_executor.rs +++ b/crates/vp_global_cli/src/js_executor.rs @@ -11,7 +11,10 @@ use vp_shared::{PrependOptions, PrependResult, env_vars, format_path_with_prepen use vt_path::{AbsolutePath, AbsolutePathBuf}; use crate::{ - commands::env::config::{self, ShimMode}, + commands::{ + self, + env::config::{self, ShimMode}, + }, error::Error, shim, }; @@ -30,6 +33,8 @@ pub struct JsExecutor { scripts_dir: Option, /// Subcommand as the user wrote it, forwarded to the CLI this one runs raw_subcommand: Option, + /// Whether a project-local CLI miss should emit a warning before global fallback + warn_on_missing_local_cli: bool, } impl JsExecutor { @@ -40,7 +45,13 @@ impl JsExecutor { /// If not provided, will be auto-detected from the binary location. #[must_use] pub const fn new(scripts_dir: Option) -> Self { - Self { cli_runtime: None, project_runtime: None, scripts_dir, raw_subcommand: None } + Self { + cli_runtime: None, + project_runtime: None, + scripts_dir, + raw_subcommand: None, + warn_on_missing_local_cli: true, + } } /// Forward the subcommand as the user wrote it to the CLI this one runs. @@ -52,6 +63,11 @@ impl JsExecutor { self } + pub(crate) fn without_missing_local_cli_warning(mut self) -> Self { + self.warn_on_missing_local_cli = false; + self + } + /// Get the JS scripts directory. /// /// Resolution order: @@ -339,6 +355,9 @@ impl JsExecutor { let entry_point = match Self::resolve_local_vite_plus(project_path) { Some(path) => path, None => { + if self.warn_on_missing_local_cli { + commands::warn_missing_local_cli_if_project(project_path); + } // Fall back to the global installation's bin.js let scripts_dir = self.get_scripts_dir()?; scripts_dir.join("bin.js") diff --git a/rfcs/exec-command.md b/rfcs/exec-command.md index 3a9162ea37..5e5b00e550 100644 --- a/rfcs/exec-command.md +++ b/rfcs/exec-command.md @@ -179,7 +179,7 @@ Based on pnpm exec behavior (reference: `exec/plugin-commands-script-runners/src ### Key Differences from vpx - `vp exec` prepends only `./node_modules/.bin` from the current directory — it does **not** walk up parent directories. Use `vpx` if you want monorepo root binaries. -- `vp exec` never falls back to global vp packages or remote download — commands resolve through `node_modules/.bin` + system PATH only. +- After the Vite+ CLI is selected, `vp exec` never falls back to globally installed executable packages or remote downloads — commands resolve through `node_modules/.bin` + system PATH only. ## Implementation Architecture @@ -206,7 +206,7 @@ Route in `execute_command()`: Commands::Exec { args } => commands::delegate::execute(cwd, "exec", &args).await, ``` -The global CLI always delegates `exec` to the local CLI — there is no fallback path or direct execution in the global CLI. This follows the same unconditional delegation pattern as other Category C commands. +The global CLI always delegates `exec` to the JavaScript CLI. Delegation resolves the project's local `vite-plus` first, then falls back to the globally installed `vite-plus` when no local CLI is available. When this fallback occurs inside a project, `vp` recommends migration if `vite-plus` is not declared as a dependency, or recommends installing dependencies if it is declared but unavailable. The Rust global CLI has no direct `exec` implementation. ### Local CLI @@ -256,16 +256,16 @@ The following existing code is reused: ## Design Decisions -### 1. Unconditional Delegation (No Global CLI Fallback) +### 1. Local-First Delegation with Global CLI Fallback -**Decision**: The global CLI always delegates `exec` to the local CLI. There is no fallback path for projects without vite-plus as a dependency. +**Decision**: The global CLI delegates `exec` to the project-local `vite-plus` when available. Otherwise, it provides migration or installation guidance inside projects and continues with the globally installed `vite-plus` CLI. **Rationale**: - Simplifies the global CLI — no need for a direct-execution codepath - Consistent with how all Category C commands are dispatched -- The local CLI has all the workspace awareness needed for `--recursive`, `--filter`, etc. -- Projects using `vp exec` are expected to have vite-plus installed +- The delegated CLI has all the workspace awareness needed for `--recursive`, `--filter`, etc. +- The warning directs projects to migrate or install their declared dependencies without making the global fallback unusable ### 2. No Directory Walk-Up (Unlike vpx) @@ -278,14 +278,14 @@ The following existing code is reused: - Walking up would blur the boundary between package-level and workspace-level binaries - Use `vpx` if you want walk-up behavior -### 3. Workspace Features Only via Local CLI +### 3. Workspace Features Use the Delegated CLI -**Decision**: `--recursive`, `--workspace-root`, `--filter`, `--parallel`, `--reverse`, `--resume-from`, and `--report-summary` only work when vite-plus is a local dependency (local CLI handles them). +**Decision**: `--recursive`, `--workspace-root`, `--filter`, `--parallel`, `--reverse`, `--resume-from`, and `--report-summary` are handled by the resolved `vite-plus` CLI, whether project-local or the global fallback. **Rationale**: - These features require workspace awareness from vite-task infrastructure -- The global CLI fallback is for simple, single-directory exec +- The project-local and globally installed CLIs use the same workspace-aware implementation - This is consistent with how `vp run` handles workspace features ### 4. Same Env Var Convention