Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -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.')
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "missing-local-cli-warning",
"version": "1.0.0"
}
Original file line number Diff line number Diff line change
@@ -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" },
]
Original file line number Diff line number Diff line change
@@ -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`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We may need to consider which commands require reminders. For example, vp lint/vp fmt can actually work normally without the project depending on vite-plus?


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 <duration> on 1 file with <n> rules using <n> 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 `<workspace>` to install dependencies.
Found 0 warnings and 0 errors.
Finished in <duration> on 1 file with <n> rules using <n> threads.
```

## `vpt rm package.json`


## `node assert-silent-fallback.mjs`

outside a project, global fallback remains silent

```
Global fallback remained silent.
```
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const answer = 42
2 changes: 1 addition & 1 deletion crates/vp_global_cli/src/commands/delegate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ pub async fn execute_output(
command: &str,
args: &[String],
) -> Result<Output, Error> {
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
Expand Down
2 changes: 1 addition & 1 deletion crates/vp_global_cli/src/commands/migrate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ExitStatus, Error> {
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
Expand Down
85 changes: 67 additions & 18 deletions crates/vp_global_cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand All @@ -30,35 +30,84 @@ struct DepCheckPackageJson {
dependencies: HashMap<String, serde_json::Value>,
#[serde(default)]
dev_dependencies: HashMap<String, serde_json::Value>,
#[serde(default)]
optional_dependencies: HashMap<String, serde_json::Value>,
}

/// 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<AbsolutePathBuf> {
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<AbsolutePathBuf> {
let mut current = cwd;
loop {
if package_json_has_vite_plus_dependency(&current.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.
///
Expand Down
23 changes: 21 additions & 2 deletions crates/vp_global_cli/src/js_executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand All @@ -30,6 +33,8 @@ pub struct JsExecutor {
scripts_dir: Option<AbsolutePathBuf>,
/// Subcommand as the user wrote it, forwarded to the CLI this one runs
raw_subcommand: Option<String>,
/// Whether a project-local CLI miss should emit a warning before global fallback
warn_on_missing_local_cli: bool,
}

impl JsExecutor {
Expand All @@ -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<AbsolutePathBuf>) -> 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.
Expand All @@ -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:
Expand Down Expand Up @@ -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);
}
Comment thread
liangmiQwQ marked this conversation as resolved.
// Fall back to the global installation's bin.js
let scripts_dir = self.get_scripts_dir()?;
scripts_dir.join("bin.js")
Expand Down
18 changes: 9 additions & 9 deletions rfcs/exec-command.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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)

Expand All @@ -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
Expand Down
Loading