Skip to content

Commit 732f8cd

Browse files
authored
feat: add vp exec command for local node_modules/.bin execution (#620)
Add `vp exec` as the equivalent of `pnpm exec` — prepends `./node_modules/.bin` to PATH and executes a command. Commands resolve through the modified PATH (local bins first, then system PATH), matching pnpm exec's real behavior. No remote fallback unlike `vpx`. Global CLI: delegates to local CLI when vite-plus is a dependency, otherwise handles exec directly (prepend PATH + spawn). Local CLI: prepends PM bin dir and node_modules/.bin to PATH, sets VITE_PLUS_PACKAGE_NAME env var, supports --shell-mode/-c flag. closes [VP-194](https://linear.app/voidzero/issue/VP-194/equivalent-of-pnpm-filter-pkgname-exec-some-cmd)
1 parent 52709db commit 732f8cd

48 files changed

Lines changed: 2870 additions & 94 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
name: spawn-process
3+
description: Guide for writing subprocess execution code using the vite_command crate
4+
allowed-tools: Read, Grep, Glob, Edit, Write, Bash
5+
---
6+
7+
# Add Subprocess Execution Code
8+
9+
When writing Rust code that needs to spawn subprocesses (resolve binaries, build commands, execute programs), always use the `vite_command` crate. Never use `which`, `tokio::process::Command::new`, or `std::process::Command::new` directly.
10+
11+
## Available APIs
12+
13+
### `vite_command::resolve_bin(name, path_env, cwd)` — Resolve a binary name to an absolute path
14+
15+
Handles PATHEXT (`.cmd`/`.bat`) on Windows. Pass `None` for `path_env` to search the current process PATH.
16+
17+
```rust
18+
// Resolve using current PATH
19+
let bin = vite_command::resolve_bin("node", None, &cwd)?;
20+
21+
// Resolve using a custom PATH
22+
let custom_path = std::ffi::OsString::from(&path_env_str);
23+
let bin = vite_command::resolve_bin("eslint", Some(&custom_path), &cwd)?;
24+
```
25+
26+
### `vite_command::build_command(bin_path, cwd)` — Build a command for a pre-resolved binary
27+
28+
Returns `tokio::process::Command` with cwd, inherited stdio, and `fix_stdio_streams` on Unix already configured. Add args, envs, or override stdio as needed.
29+
30+
```rust
31+
let bin = vite_command::resolve_bin("eslint", None, &cwd)?;
32+
let mut cmd = vite_command::build_command(&bin, &cwd);
33+
cmd.args(&[".", "--fix"]);
34+
cmd.env("NODE_ENV", "production");
35+
let mut child = cmd.spawn()?;
36+
let status = child.wait().await?;
37+
```
38+
39+
### `vite_command::build_shell_command(shell_cmd, cwd)` — Build a shell command
40+
41+
Uses `/bin/sh -c` on Unix, `cmd.exe /C` on Windows. Same stdio and `fix_stdio_streams` setup as `build_command`.
42+
43+
```rust
44+
let mut cmd = vite_command::build_shell_command("echo hello && ls", &cwd);
45+
let mut child = cmd.spawn()?;
46+
let status = child.wait().await?;
47+
```
48+
49+
### `vite_command::run_command(bin_name, args, envs, cwd)` — Resolve + build + run in one call
50+
51+
Combines resolve_bin, build_command, and status().await. The `envs` HashMap must include `"PATH"` if you want custom PATH resolution.
52+
53+
```rust
54+
let envs = HashMap::from([("PATH".to_string(), path_value)]);
55+
let status = vite_command::run_command("node", &["--version"], &envs, &cwd).await?;
56+
```
57+
58+
## Dependency Setup
59+
60+
Add `vite_command` to the crate's `Cargo.toml`:
61+
62+
```toml
63+
[dependencies]
64+
vite_command = { workspace = true }
65+
```
66+
67+
Do NOT add `which` as a direct dependency — binary resolution goes through `vite_command::resolve_bin`.
68+
69+
## Exception
70+
71+
`crates/vite_global_cli/src/shim/exec.rs` uses synchronous `std::process::Command` with Unix `exec()` for process replacement. This is the only place that bypasses `vite_command`.

Cargo.lock

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/vite_command/src/lib.rs

Lines changed: 75 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use std::{
77
use fspy::AccessMode;
88
use tokio::process::Command;
99
use vite_error::Error;
10-
use vite_path::{AbsolutePath, RelativePathBuf};
10+
use vite_path::{AbsolutePath, AbsolutePathBuf, RelativePathBuf};
1111

1212
/// Result of running a command with fspy tracking.
1313
#[derive(Debug)]
@@ -18,6 +18,76 @@ pub struct FspyCommandResult {
1818
pub path_accesses: HashMap<RelativePathBuf, AccessMode>,
1919
}
2020

21+
/// Resolve a binary name to a full path using the `which` crate.
22+
/// Handles PATHEXT (`.cmd`/`.bat`) resolution natively on Windows.
23+
///
24+
/// If `path_env` is `None`, searches the process's current `PATH`.
25+
pub fn resolve_bin(
26+
bin_name: &str,
27+
path_env: Option<&OsStr>,
28+
cwd: impl AsRef<AbsolutePath>,
29+
) -> Result<AbsolutePathBuf, Error> {
30+
let current_path;
31+
let path_env = match path_env {
32+
Some(p) => p,
33+
None => {
34+
current_path = std::env::var_os("PATH").unwrap_or_default();
35+
&current_path
36+
}
37+
};
38+
let path = which::which_in(bin_name, Some(path_env), cwd.as_ref())
39+
.map_err(|_| Error::CannotFindBinaryPath(bin_name.into()))?;
40+
AbsolutePathBuf::new(path).ok_or_else(|| Error::CannotFindBinaryPath(bin_name.into()))
41+
}
42+
43+
/// Build a `tokio::process::Command` for a pre-resolved binary path.
44+
/// Sets inherited stdio and `fix_stdio_streams` (Unix pre_exec).
45+
/// Callers can further customize (add args, envs, override stdio, etc.).
46+
pub fn build_command(bin_path: &AbsolutePath, cwd: &AbsolutePath) -> Command {
47+
let mut cmd = Command::new(bin_path.as_path());
48+
cmd.current_dir(cwd).stdin(Stdio::inherit()).stdout(Stdio::inherit()).stderr(Stdio::inherit());
49+
50+
#[cfg(unix)]
51+
unsafe {
52+
cmd.pre_exec(|| {
53+
fix_stdio_streams();
54+
Ok(())
55+
});
56+
}
57+
58+
cmd
59+
}
60+
61+
/// Build a `tokio::process::Command` for shell execution.
62+
/// Uses `/bin/sh -c` on Unix, `cmd.exe /C` on Windows.
63+
pub fn build_shell_command(shell_cmd: &str, cwd: &AbsolutePath) -> Command {
64+
#[cfg(unix)]
65+
let mut cmd = {
66+
let mut cmd = Command::new("/bin/sh");
67+
cmd.arg("-c").arg(shell_cmd);
68+
cmd
69+
};
70+
71+
#[cfg(windows)]
72+
let mut cmd = {
73+
let mut cmd = Command::new("cmd.exe");
74+
cmd.arg("/C").arg(shell_cmd);
75+
cmd
76+
};
77+
78+
cmd.current_dir(cwd).stdin(Stdio::inherit()).stdout(Stdio::inherit()).stderr(Stdio::inherit());
79+
80+
#[cfg(unix)]
81+
unsafe {
82+
cmd.pre_exec(|| {
83+
fix_stdio_streams();
84+
Ok(())
85+
});
86+
}
87+
88+
cmd
89+
}
90+
2191
/// Run a command with the given bin name, arguments, environment variables, and current working directory.
2292
///
2393
/// # Arguments
@@ -40,31 +110,11 @@ where
40110
I: IntoIterator<Item = S>,
41111
S: AsRef<OsStr>,
42112
{
43-
// Resolve the command path using which crate
44-
// If PATH is provided in envs, use which_in to search in custom paths
45-
// Otherwise, use which to search in system PATH
46-
let paths = envs.get("PATH");
47113
let cwd = cwd.as_ref();
48-
let bin_path = which::which_in(bin_name, paths, cwd)
49-
.map_err(|_| Error::CannotFindBinaryPath(bin_name.into()))?;
50-
51-
let mut cmd = Command::new(bin_path);
52-
cmd.args(args)
53-
.envs(envs)
54-
.current_dir(cwd)
55-
.stdin(Stdio::inherit())
56-
.stdout(Stdio::inherit())
57-
.stderr(Stdio::inherit());
58-
59-
// fix stdio streams on unix
60-
#[cfg(unix)]
61-
unsafe {
62-
cmd.pre_exec(|| {
63-
fix_stdio_streams();
64-
Ok(())
65-
});
66-
}
67-
114+
let paths = envs.get("PATH");
115+
let bin_path = resolve_bin(bin_name, paths.map(|p| OsStr::new(p.as_str())), cwd)?;
116+
let mut cmd = build_command(&bin_path, cwd);
117+
cmd.args(args).envs(envs);
68118
let status = cmd.status().await?;
69119
Ok(status)
70120
}

crates/vite_global_cli/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,10 @@ vite_error = { workspace = true }
3030
vite_install = { workspace = true }
3131
vite_js_runtime = { workspace = true }
3232
vite_path = { workspace = true }
33+
vite_command = { workspace = true }
3334
vite_shared = { workspace = true }
3435
vite_str = { workspace = true }
3536
vite_workspace = { workspace = true }
36-
which = { workspace = true }
3737

3838
[target.'cfg(windows)'.dependencies]
3939
junction = { workspace = true }

crates/vite_global_cli/src/cli.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,14 @@ pub enum Commands {
585585
args: Vec<String>,
586586
},
587587

588+
/// Execute a command from local node_modules/.bin
589+
#[command(disable_help_flag = true)]
590+
Exec {
591+
/// Additional arguments
592+
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
593+
args: Vec<String>,
594+
},
595+
588596
/// Preview production build
589597
#[command(disable_help_flag = true)]
590598
Preview {
@@ -1791,6 +1799,8 @@ pub async fn run_command(cwd: AbsolutePathBuf, args: Args) -> Result<ExitStatus,
17911799

17921800
Commands::Run { args } => commands::run_or_delegate::execute(cwd, &args).await,
17931801

1802+
Commands::Exec { args } => commands::delegate::execute(cwd, "exec", &args).await,
1803+
17941804
Commands::Preview { args } => commands::delegate::execute(cwd, "preview", &args).await,
17951805

17961806
Commands::Cache { args } => commands::delegate::execute(cwd, "cache", &args).await,
@@ -1814,7 +1824,7 @@ pub async fn run_command(cwd: AbsolutePathBuf, args: Args) -> Result<ExitStatus,
18141824
}
18151825

18161826
/// Create an exit status with the given code.
1817-
fn exit_status(code: i32) -> ExitStatus {
1827+
pub(crate) fn exit_status(code: i32) -> ExitStatus {
18181828
#[cfg(unix)]
18191829
{
18201830
use std::os::unix::process::ExitStatusExt;
@@ -1849,6 +1859,7 @@ fn apply_custom_help(cmd: clap::Command) -> clap::Command {
18491859
{bold}fmt{reset} Format code
18501860
{bold}pack{reset} Build library
18511861
{bold}run{reset} Run tasks
1862+
{bold}exec{reset} Execute a command from local node_modules/.bin
18521863
{bold}preview{reset} Preview production build
18531864
{bold}env{reset} Manage Node.js versions
18541865
{bold}migrate{reset} Migrate an existing project to Vite+

crates/vite_global_cli/src/commands/env/doctor.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -297,9 +297,9 @@ fn find_system_node() -> Option<std::path::PathBuf> {
297297

298298
let filtered_path = std::env::join_paths(filtered_paths).ok()?;
299299

300-
// Use which::which_in with filtered PATH - stops at first match
300+
// Use vite_command::resolve_bin with filtered PATH - stops at first match
301301
let cwd = current_dir().ok()?;
302-
which::which_in("node", Some(filtered_path), cwd).ok()
302+
vite_command::resolve_bin("node", Some(&filtered_path), &cwd).ok().map(|p| p.into_path_buf())
303303
}
304304

305305
/// Check for active session override via VITE_PLUS_NODE_VERSION or session file.
@@ -393,7 +393,8 @@ async fn check_path() -> bool {
393393

394394
/// Find an executable in PATH.
395395
fn find_in_path(name: &str) -> Option<std::path::PathBuf> {
396-
which::which(name).ok()
396+
let cwd = current_dir().ok()?;
397+
vite_command::resolve_bin(name, None, &cwd).ok().map(|p| p.into_path_buf())
397398
}
398399

399400
/// Print PATH fix instructions for shell setup.

crates/vite_global_cli/src/commands/vpx.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,8 +202,7 @@ fn find_on_path(cmd: &str) -> Option<AbsolutePathBuf> {
202202

203203
let filtered_path = std::env::join_paths(filtered_paths).ok()?;
204204
let cwd = vite_path::current_dir().ok()?;
205-
let path = which::which_in(cmd, Some(filtered_path), cwd).ok()?;
206-
AbsolutePathBuf::new(path)
205+
vite_command::resolve_bin(cmd, Some(&filtered_path), &cwd).ok()
207206
}
208207

209208
/// Prepend all `node_modules/.bin` directories from cwd upward to PATH.

crates/vite_global_cli/src/shim/dispatch.rs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -506,10 +506,9 @@ fn find_system_tool(tool: &str) -> Option<AbsolutePathBuf> {
506506

507507
let filtered_path = std::env::join_paths(filtered_paths).ok()?;
508508

509-
// Use which::which_in with filtered PATH - stops at first match
509+
// Use vite_command::resolve_bin with filtered PATH - stops at first match
510510
let cwd = current_dir().ok()?;
511-
let path = which::which_in(tool, Some(filtered_path), cwd).ok()?;
512-
AbsolutePathBuf::new(path)
511+
vite_command::resolve_bin(tool, Some(&filtered_path), &cwd).ok()
513512
}
514513

515514
#[cfg(test)]

packages/cli/AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ This project is using Vite+, a modern toolchain built on top of Vite, Rolldown,
1818
- lib - Build library
1919
- migrate - Migrate an existing project to Vite+
2020
- new - Create a new monorepo package (in-project) or a new project (global)
21+
- exec - Execute a command in workspace packages (supports `--filter`, `-r`, `--parallel`)
2122
- run - Run tasks from `package.json` scripts
2223

2324
These commands map to their corresponding tools. For example, `vp dev --port 3000` runs Vite's dev server and works the same as Vite. `vp test` runs JavaScript tests through the bundled Vitest. The version of all tools can be checked using `vp --version`. This is useful when researching documentation, features, and bugs.

packages/cli/binding/Cargo.toml

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,6 @@ name = "vite-plus-cli"
33
version = "0.0.0"
44
edition.workspace = true
55

6-
[[bin]]
7-
name = "vite"
8-
path = "src/main.rs"
9-
106
[features]
117
rolldown = ["dep:rolldown_binding"]
128

@@ -15,9 +11,11 @@ anyhow = { workspace = true }
1511
async-trait = { workspace = true }
1612
clap = { workspace = true, features = ["derive"] }
1713
fspy = { workspace = true }
14+
glob = { workspace = true }
1815
rustc-hash = { workspace = true }
1916
napi = { workspace = true }
2017
napi-derive = { workspace = true }
18+
petgraph = { workspace = true }
2119
serde = { workspace = true, features = ["derive"] }
2220
serde_json = { workspace = true }
2321
tokio = { workspace = true, features = ["fs"] }
@@ -31,8 +29,6 @@ vite_shared = { workspace = true }
3129
vite_str = { workspace = true }
3230
vite_task = { workspace = true }
3331
vite_workspace = { workspace = true }
34-
which = { workspace = true }
35-
3632
rolldown_binding = { workspace = true, optional = true }
3733

3834
[build-dependencies]

0 commit comments

Comments
 (0)