diff --git a/.claude/skills/spawn-process/SKILL.md b/.claude/skills/spawn-process/SKILL.md new file mode 100644 index 0000000000..221e180471 --- /dev/null +++ b/.claude/skills/spawn-process/SKILL.md @@ -0,0 +1,71 @@ +--- +name: spawn-process +description: Guide for writing subprocess execution code using the vite_command crate +allowed-tools: Read, Grep, Glob, Edit, Write, Bash +--- + +# Add Subprocess Execution Code + +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. + +## Available APIs + +### `vite_command::resolve_bin(name, path_env, cwd)` — Resolve a binary name to an absolute path + +Handles PATHEXT (`.cmd`/`.bat`) on Windows. Pass `None` for `path_env` to search the current process PATH. + +```rust +// Resolve using current PATH +let bin = vite_command::resolve_bin("node", None, &cwd)?; + +// Resolve using a custom PATH +let custom_path = std::ffi::OsString::from(&path_env_str); +let bin = vite_command::resolve_bin("eslint", Some(&custom_path), &cwd)?; +``` + +### `vite_command::build_command(bin_path, cwd)` — Build a command for a pre-resolved binary + +Returns `tokio::process::Command` with cwd, inherited stdio, and `fix_stdio_streams` on Unix already configured. Add args, envs, or override stdio as needed. + +```rust +let bin = vite_command::resolve_bin("eslint", None, &cwd)?; +let mut cmd = vite_command::build_command(&bin, &cwd); +cmd.args(&[".", "--fix"]); +cmd.env("NODE_ENV", "production"); +let mut child = cmd.spawn()?; +let status = child.wait().await?; +``` + +### `vite_command::build_shell_command(shell_cmd, cwd)` — Build a shell command + +Uses `/bin/sh -c` on Unix, `cmd.exe /C` on Windows. Same stdio and `fix_stdio_streams` setup as `build_command`. + +```rust +let mut cmd = vite_command::build_shell_command("echo hello && ls", &cwd); +let mut child = cmd.spawn()?; +let status = child.wait().await?; +``` + +### `vite_command::run_command(bin_name, args, envs, cwd)` — Resolve + build + run in one call + +Combines resolve_bin, build_command, and status().await. The `envs` HashMap must include `"PATH"` if you want custom PATH resolution. + +```rust +let envs = HashMap::from([("PATH".to_string(), path_value)]); +let status = vite_command::run_command("node", &["--version"], &envs, &cwd).await?; +``` + +## Dependency Setup + +Add `vite_command` to the crate's `Cargo.toml`: + +```toml +[dependencies] +vite_command = { workspace = true } +``` + +Do NOT add `which` as a direct dependency — binary resolution goes through `vite_command::resolve_bin`. + +## Exception + +`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`. diff --git a/Cargo.lock b/Cargo.lock index 9481c06ae8..659852780c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7128,9 +7128,11 @@ dependencies = [ "async-trait", "clap", "fspy", + "glob", "napi", "napi-build", "napi-derive", + "petgraph 0.8.3", "pretty_assertions", "rolldown_binding", "rustc-hash", @@ -7147,7 +7149,6 @@ dependencies = [ "vite_str", "vite_task", "vite_workspace", - "which", ] [[package]] @@ -7217,6 +7218,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tracing", + "vite_command", "vite_error", "vite_install", "vite_js_runtime", @@ -7224,7 +7226,6 @@ dependencies = [ "vite_shared", "vite_str", "vite_workspace", - "which", ] [[package]] diff --git a/crates/vite_command/src/lib.rs b/crates/vite_command/src/lib.rs index ce598f37fe..4c2570d419 100644 --- a/crates/vite_command/src/lib.rs +++ b/crates/vite_command/src/lib.rs @@ -7,7 +7,7 @@ use std::{ use fspy::AccessMode; use tokio::process::Command; use vite_error::Error; -use vite_path::{AbsolutePath, RelativePathBuf}; +use vite_path::{AbsolutePath, AbsolutePathBuf, RelativePathBuf}; /// Result of running a command with fspy tracking. #[derive(Debug)] @@ -18,6 +18,76 @@ pub struct FspyCommandResult { pub path_accesses: HashMap, } +/// Resolve a binary name to a full path using the `which` crate. +/// Handles PATHEXT (`.cmd`/`.bat`) resolution natively on Windows. +/// +/// If `path_env` is `None`, searches the process's current `PATH`. +pub fn resolve_bin( + bin_name: &str, + path_env: Option<&OsStr>, + cwd: impl AsRef, +) -> Result { + let current_path; + let path_env = match path_env { + Some(p) => p, + None => { + current_path = std::env::var_os("PATH").unwrap_or_default(); + ¤t_path + } + }; + let path = which::which_in(bin_name, Some(path_env), cwd.as_ref()) + .map_err(|_| Error::CannotFindBinaryPath(bin_name.into()))?; + AbsolutePathBuf::new(path).ok_or_else(|| Error::CannotFindBinaryPath(bin_name.into())) +} + +/// Build a `tokio::process::Command` for a pre-resolved binary path. +/// Sets inherited stdio and `fix_stdio_streams` (Unix pre_exec). +/// Callers can further customize (add args, envs, override stdio, etc.). +pub fn build_command(bin_path: &AbsolutePath, cwd: &AbsolutePath) -> Command { + let mut cmd = Command::new(bin_path.as_path()); + cmd.current_dir(cwd).stdin(Stdio::inherit()).stdout(Stdio::inherit()).stderr(Stdio::inherit()); + + #[cfg(unix)] + unsafe { + cmd.pre_exec(|| { + fix_stdio_streams(); + Ok(()) + }); + } + + cmd +} + +/// Build a `tokio::process::Command` for shell execution. +/// Uses `/bin/sh -c` on Unix, `cmd.exe /C` on Windows. +pub fn build_shell_command(shell_cmd: &str, cwd: &AbsolutePath) -> Command { + #[cfg(unix)] + let mut cmd = { + let mut cmd = Command::new("/bin/sh"); + cmd.arg("-c").arg(shell_cmd); + cmd + }; + + #[cfg(windows)] + let mut cmd = { + let mut cmd = Command::new("cmd.exe"); + cmd.arg("/C").arg(shell_cmd); + cmd + }; + + cmd.current_dir(cwd).stdin(Stdio::inherit()).stdout(Stdio::inherit()).stderr(Stdio::inherit()); + + #[cfg(unix)] + unsafe { + cmd.pre_exec(|| { + fix_stdio_streams(); + Ok(()) + }); + } + + cmd +} + /// Run a command with the given bin name, arguments, environment variables, and current working directory. /// /// # Arguments @@ -40,31 +110,11 @@ where I: IntoIterator, S: AsRef, { - // Resolve the command path using which crate - // If PATH is provided in envs, use which_in to search in custom paths - // Otherwise, use which to search in system PATH - let paths = envs.get("PATH"); let cwd = cwd.as_ref(); - let bin_path = which::which_in(bin_name, paths, cwd) - .map_err(|_| Error::CannotFindBinaryPath(bin_name.into()))?; - - let mut cmd = Command::new(bin_path); - cmd.args(args) - .envs(envs) - .current_dir(cwd) - .stdin(Stdio::inherit()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()); - - // fix stdio streams on unix - #[cfg(unix)] - unsafe { - cmd.pre_exec(|| { - fix_stdio_streams(); - Ok(()) - }); - } - + let paths = envs.get("PATH"); + let bin_path = resolve_bin(bin_name, paths.map(|p| OsStr::new(p.as_str())), cwd)?; + let mut cmd = build_command(&bin_path, cwd); + cmd.args(args).envs(envs); let status = cmd.status().await?; Ok(status) } diff --git a/crates/vite_global_cli/Cargo.toml b/crates/vite_global_cli/Cargo.toml index d9f93f0a72..80b2f46a7f 100644 --- a/crates/vite_global_cli/Cargo.toml +++ b/crates/vite_global_cli/Cargo.toml @@ -30,10 +30,10 @@ vite_error = { workspace = true } vite_install = { workspace = true } vite_js_runtime = { workspace = true } vite_path = { workspace = true } +vite_command = { workspace = true } vite_shared = { workspace = true } vite_str = { workspace = true } vite_workspace = { workspace = true } -which = { workspace = true } [target.'cfg(windows)'.dependencies] junction = { workspace = true } diff --git a/crates/vite_global_cli/src/cli.rs b/crates/vite_global_cli/src/cli.rs index 9abdb85283..7ed2b1eaae 100644 --- a/crates/vite_global_cli/src/cli.rs +++ b/crates/vite_global_cli/src/cli.rs @@ -585,6 +585,14 @@ pub enum Commands { args: Vec, }, + /// Execute a command from local node_modules/.bin + #[command(disable_help_flag = true)] + Exec { + /// Additional arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Preview production build #[command(disable_help_flag = true)] Preview { @@ -1791,6 +1799,8 @@ pub async fn run_command(cwd: AbsolutePathBuf, args: Args) -> Result commands::run_or_delegate::execute(cwd, &args).await, + Commands::Exec { args } => commands::delegate::execute(cwd, "exec", &args).await, + Commands::Preview { args } => commands::delegate::execute(cwd, "preview", &args).await, 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 { +pub(crate) fn exit_status(code: i32) -> ExitStatus { #[cfg(unix)] { use std::os::unix::process::ExitStatusExt; @@ -1849,6 +1859,7 @@ fn apply_custom_help(cmd: clap::Command) -> clap::Command { {bold}fmt{reset} Format code {bold}pack{reset} Build library {bold}run{reset} Run tasks + {bold}exec{reset} Execute a command from local node_modules/.bin {bold}preview{reset} Preview production build {bold}env{reset} Manage Node.js versions {bold}migrate{reset} Migrate an existing project to Vite+ diff --git a/crates/vite_global_cli/src/commands/env/doctor.rs b/crates/vite_global_cli/src/commands/env/doctor.rs index a3f4710830..1efbac3b36 100644 --- a/crates/vite_global_cli/src/commands/env/doctor.rs +++ b/crates/vite_global_cli/src/commands/env/doctor.rs @@ -297,9 +297,9 @@ fn find_system_node() -> Option { let filtered_path = std::env::join_paths(filtered_paths).ok()?; - // Use which::which_in with filtered PATH - stops at first match + // Use vite_command::resolve_bin with filtered PATH - stops at first match let cwd = current_dir().ok()?; - which::which_in("node", Some(filtered_path), cwd).ok() + vite_command::resolve_bin("node", Some(&filtered_path), &cwd).ok().map(|p| p.into_path_buf()) } /// Check for active session override via VITE_PLUS_NODE_VERSION or session file. @@ -393,7 +393,8 @@ async fn check_path() -> bool { /// Find an executable in PATH. fn find_in_path(name: &str) -> Option { - which::which(name).ok() + let cwd = current_dir().ok()?; + vite_command::resolve_bin(name, None, &cwd).ok().map(|p| p.into_path_buf()) } /// Print PATH fix instructions for shell setup. diff --git a/crates/vite_global_cli/src/commands/vpx.rs b/crates/vite_global_cli/src/commands/vpx.rs index e11481f65c..5ea8fd7008 100644 --- a/crates/vite_global_cli/src/commands/vpx.rs +++ b/crates/vite_global_cli/src/commands/vpx.rs @@ -202,8 +202,7 @@ fn find_on_path(cmd: &str) -> Option { let filtered_path = std::env::join_paths(filtered_paths).ok()?; let cwd = vite_path::current_dir().ok()?; - let path = which::which_in(cmd, Some(filtered_path), cwd).ok()?; - AbsolutePathBuf::new(path) + vite_command::resolve_bin(cmd, Some(&filtered_path), &cwd).ok() } /// Prepend all `node_modules/.bin` directories from cwd upward to PATH. diff --git a/crates/vite_global_cli/src/shim/dispatch.rs b/crates/vite_global_cli/src/shim/dispatch.rs index 1a83a8248b..6b9f65da11 100644 --- a/crates/vite_global_cli/src/shim/dispatch.rs +++ b/crates/vite_global_cli/src/shim/dispatch.rs @@ -506,10 +506,9 @@ fn find_system_tool(tool: &str) -> Option { let filtered_path = std::env::join_paths(filtered_paths).ok()?; - // Use which::which_in with filtered PATH - stops at first match + // Use vite_command::resolve_bin with filtered PATH - stops at first match let cwd = current_dir().ok()?; - let path = which::which_in(tool, Some(filtered_path), cwd).ok()?; - AbsolutePathBuf::new(path) + vite_command::resolve_bin(tool, Some(&filtered_path), &cwd).ok() } #[cfg(test)] diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 37bd4d187a..30461485a7 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -18,6 +18,7 @@ This project is using Vite+, a modern toolchain built on top of Vite, Rolldown, - lib - Build library - migrate - Migrate an existing project to Vite+ - new - Create a new monorepo package (in-project) or a new project (global) +- exec - Execute a command in workspace packages (supports `--filter`, `-r`, `--parallel`) - run - Run tasks from `package.json` scripts 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. diff --git a/packages/cli/binding/Cargo.toml b/packages/cli/binding/Cargo.toml index 5983866cef..bc12da88fd 100644 --- a/packages/cli/binding/Cargo.toml +++ b/packages/cli/binding/Cargo.toml @@ -3,10 +3,6 @@ name = "vite-plus-cli" version = "0.0.0" edition.workspace = true -[[bin]] -name = "vite" -path = "src/main.rs" - [features] rolldown = ["dep:rolldown_binding"] @@ -15,9 +11,11 @@ anyhow = { workspace = true } async-trait = { workspace = true } clap = { workspace = true, features = ["derive"] } fspy = { workspace = true } +glob = { workspace = true } rustc-hash = { workspace = true } napi = { workspace = true } napi-derive = { workspace = true } +petgraph = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tokio = { workspace = true, features = ["fs"] } @@ -31,8 +29,6 @@ vite_shared = { workspace = true } vite_str = { workspace = true } vite_task = { workspace = true } vite_workspace = { workspace = true } -which = { workspace = true } - rolldown_binding = { workspace = true, optional = true } [build-dependencies] diff --git a/packages/cli/binding/src/cli.rs b/packages/cli/binding/src/cli.rs index 48ecefa945..c6f8d6c72a 100644 --- a/packages/cli/binding/src/cli.rs +++ b/packages/cli/binding/src/cli.rs @@ -3,9 +3,7 @@ //! This module contains all the CLI-related code. //! It handles argument parsing, command dispatching, and orchestration of the task execution. -use std::{ - env, ffi::OsStr, future::Future, iter, path::PathBuf, pin::Pin, process::Stdio, sync::Arc, -}; +use std::{env, ffi::OsStr, future::Future, iter, path::PathBuf, pin::Pin, sync::Arc}; use clap::{Parser, Subcommand}; use rustc_hash::FxHashMap; @@ -112,6 +110,13 @@ enum CLIArgs { /// Built-in subcommands (lint, build, test, etc.) #[command(flatten)] Synthesizable(SynthesizableSubcommand), + + /// Execute a command from local node_modules/.bin + #[command(disable_help_flag = true)] + Exec { + #[clap(allow_hyphen_values = true, trailing_var_arg = true)] + args: Vec, + }, } /// Type alias for boxed async resolver function @@ -589,6 +594,10 @@ impl CommandHandler for VitePlusCommandHandler { Ok(HandledCommand::Synthesized(resolved.into_synthetic_plan_request())) } CLIArgs::ViteTask(cmd) => Ok(HandledCommand::ViteTaskCommand(cmd)), + CLIArgs::Exec { .. } => { + // exec in task scripts should run as a subprocess + Ok(HandledCommand::Verbatim) + } } } } @@ -697,31 +706,17 @@ async fn execute_direct_subcommand( }; if is_path { Some(v.as_ref().to_os_string()) } else { None } }); - which::which_in(resolved.program.as_ref(), paths, cwd.as_path()).map_err(|_| { - Error::Anyhow(anyhow::anyhow!( - "Cannot find program: {}", - resolved.program.to_string_lossy() - )) - })? + vite_command::resolve_bin( + resolved.program.as_ref().to_str().unwrap_or_default(), + paths.as_deref(), + cwd, + )? }; - let mut cmd = tokio::process::Command::new(&program_path); + let mut cmd = vite_command::build_command(&program_path, cwd); cmd.args(resolved.args.iter().map(|s| s.as_str())) .env_clear() - .envs(resolved.envs.iter().map(|(k, v)| (k.as_ref(), v.as_ref()))) - .current_dir(cwd.as_path()) - .stdin(Stdio::inherit()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()); - - // Clear FD_CLOEXEC on stdio fds before exec, since Node.js (NAPI host) may have set it. - #[cfg(unix)] - unsafe { - cmd.pre_exec(|| { - vite_command::fix_stdio_streams(); - Ok(()) - }); - } + .envs(resolved.envs.iter().map(|(k, v)| (k.as_ref(), v.as_ref()))); let mut child = cmd.spawn().map_err(|e| Error::Anyhow(e.into()))?; @@ -825,6 +820,7 @@ pub async fn main( match cli_args { CLIArgs::Synthesizable(subcmd) => execute_direct_subcommand(subcmd, &cwd, options).await, CLIArgs::ViteTask(command) => execute_vite_task_command(command, cwd, options).await, + CLIArgs::Exec { args } => crate::exec::execute(&args, &cwd).await, } } @@ -864,6 +860,7 @@ fn print_help() { {bold}fmt{reset} Format code {bold}pack{reset} Build library {bold}run{reset} Run tasks + {bold}exec{reset} Execute a command from local node_modules/.bin {bold}preview{reset} Preview production build {bold}cache{reset} Manage the task cache diff --git a/packages/cli/binding/src/exec/args.rs b/packages/cli/binding/src/exec/args.rs new file mode 100644 index 0000000000..5ea8442fba --- /dev/null +++ b/packages/cli/binding/src/exec/args.rs @@ -0,0 +1,253 @@ +/// Parsed exec flags. +pub(super) struct ExecFlags { + pub shell_mode: bool, + pub help: bool, + pub recursive: bool, + pub filters: Vec, + pub parallel: bool, + pub reverse: bool, + pub resume_from: Option, + pub report_summary: bool, + pub include_workspace_root: bool, + pub workspace_root: bool, +} + +/// Parse exec-specific flags from argument slice. +/// +/// Handles: -c/--shell-mode, -h/--help, -r/--recursive, --filter, --parallel, +/// and leading -- stripping. +/// All other arguments (including unknown flags) are treated as positional. +pub(super) fn parse_exec_args(args: &[String]) -> (ExecFlags, Vec) { + let mut flags = ExecFlags { + shell_mode: false, + help: false, + recursive: false, + filters: Vec::new(), + parallel: false, + reverse: false, + resume_from: None, + report_summary: false, + include_workspace_root: false, + workspace_root: false, + }; + let mut positional = Vec::new(); + let mut i = 0; + + while i < args.len() { + let arg = &args[i]; + + // Strip leading -- + if arg == "--" { + positional.extend_from_slice(&args[i + 1..]); + break; + } + + // Once we see a non-flag argument, everything else is positional + if !arg.starts_with('-') { + positional.extend_from_slice(&args[i..]); + break; + } + + match arg.as_str() { + "-c" | "--shell-mode" => { + flags.shell_mode = true; + } + "-h" | "--help" => { + flags.help = true; + } + "-r" | "--recursive" => { + flags.recursive = true; + } + "--parallel" => { + flags.parallel = true; + } + "--reverse" => { + flags.reverse = true; + } + "--resume-from" => { + i += 1; + if i < args.len() { + flags.resume_from = Some(args[i].clone()); + } + } + "--report-summary" => { + flags.report_summary = true; + } + "--include-workspace-root" => { + flags.include_workspace_root = true; + } + "-w" | "--workspace-root" => { + flags.workspace_root = true; + } + "--filter" => { + i += 1; + if i < args.len() { + flags.filters.push(args[i].clone()); + } + } + _ => { + if let Some(value) = arg.strip_prefix("--filter=") { + flags.filters.push(value.to_string()); + } else if let Some(value) = arg.strip_prefix("--resume-from=") { + flags.resume_from = Some(value.to_string()); + } else { + // Unknown flag — treat as start of positional args + positional.extend_from_slice(&args[i..]); + break; + } + } + } + + i += 1; + } + + (flags, positional) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_exec_args_recursive() { + let args: Vec = + vec!["-r", "--", "echo", "hello"].iter().map(|s| s.to_string()).collect(); + let (flags, positional) = parse_exec_args(&args); + assert!(flags.recursive); + assert!(!flags.shell_mode); + assert!(!flags.parallel); + assert!(flags.filters.is_empty()); + assert_eq!(positional, vec!["echo", "hello"]); + } + + #[test] + fn test_parse_exec_args_filter() { + let args: Vec = + vec!["--filter", "app-*", "--", "echo", "hi"].iter().map(|s| s.to_string()).collect(); + let (flags, positional) = parse_exec_args(&args); + assert!(!flags.recursive); + assert_eq!(flags.filters, vec!["app-*"]); + assert_eq!(positional, vec!["echo", "hi"]); + } + + #[test] + fn test_parse_exec_args_multiple_filters() { + let args: Vec = vec!["--filter", "app-*", "--filter", "lib-*", "--", "echo", "hi"] + .iter() + .map(|s| s.to_string()) + .collect(); + let (flags, positional) = parse_exec_args(&args); + assert_eq!(flags.filters, vec!["app-*", "lib-*"]); + assert_eq!(positional, vec!["echo", "hi"]); + } + + #[test] + fn test_parse_exec_args_parallel() { + let args: Vec = + vec!["-r", "--parallel", "--", "echo", "test"].iter().map(|s| s.to_string()).collect(); + let (flags, positional) = parse_exec_args(&args); + assert!(flags.recursive); + assert!(flags.parallel); + assert_eq!(positional, vec!["echo", "test"]); + } + + #[test] + fn test_parse_exec_args_combined_flags() { + let args: Vec = + vec!["-r", "-c", "--parallel", "echo hello"].iter().map(|s| s.to_string()).collect(); + let (flags, positional) = parse_exec_args(&args); + assert!(flags.recursive); + assert!(flags.shell_mode); + assert!(flags.parallel); + assert_eq!(positional, vec!["echo hello"]); + } + + #[test] + fn test_parse_exec_args_filter_with_recursive() { + let args: Vec = + vec!["-r", "--filter", "app-a", "--", "tsc"].iter().map(|s| s.to_string()).collect(); + let (flags, positional) = parse_exec_args(&args); + assert!(flags.recursive); + assert_eq!(flags.filters, vec!["app-a"]); + assert_eq!(positional, vec!["tsc"]); + } + + #[test] + fn test_parse_exec_args_reverse() { + let args: Vec = + vec!["-r", "--reverse", "--", "echo", "hi"].iter().map(|s| s.to_string()).collect(); + let (flags, positional) = parse_exec_args(&args); + assert!(flags.recursive); + assert!(flags.reverse); + assert_eq!(positional, vec!["echo", "hi"]); + } + + #[test] + fn test_parse_exec_args_resume_from() { + let args: Vec = vec!["-r", "--resume-from", "lib-c", "--", "echo", "hi"] + .iter() + .map(|s| s.to_string()) + .collect(); + let (flags, positional) = parse_exec_args(&args); + assert!(flags.recursive); + assert_eq!(flags.resume_from.as_deref(), Some("lib-c")); + assert_eq!(positional, vec!["echo", "hi"]); + } + + #[test] + fn test_parse_exec_args_report_summary() { + let args: Vec = vec!["-r", "--report-summary", "--", "echo", "hi"] + .iter() + .map(|s| s.to_string()) + .collect(); + let (flags, positional) = parse_exec_args(&args); + assert!(flags.recursive); + assert!(flags.report_summary); + assert_eq!(positional, vec!["echo", "hi"]); + } + + #[test] + fn test_parse_exec_args_filter_equals() { + let args: Vec = + vec!["--filter=app-*", "--", "echo", "hi"].iter().map(|s| s.to_string()).collect(); + let (flags, positional) = parse_exec_args(&args); + assert_eq!(flags.filters, vec!["app-*"]); + assert_eq!(positional, vec!["echo", "hi"]); + } + + #[test] + fn test_parse_exec_args_resume_from_equals() { + let args: Vec = vec!["-r", "--resume-from=lib-c", "--", "echo", "hi"] + .iter() + .map(|s| s.to_string()) + .collect(); + let (flags, positional) = parse_exec_args(&args); + assert!(flags.recursive); + assert_eq!(flags.resume_from.as_deref(), Some("lib-c")); + assert_eq!(positional, vec!["echo", "hi"]); + } + + #[test] + fn test_parse_exec_args_include_workspace_root() { + let args: Vec = vec!["-r", "--include-workspace-root", "--", "echo", "hi"] + .iter() + .map(|s| s.to_string()) + .collect(); + let (flags, positional) = parse_exec_args(&args); + assert!(flags.recursive); + assert!(flags.include_workspace_root); + assert!(!flags.workspace_root); + assert_eq!(positional, vec!["echo", "hi"]); + } + + #[test] + fn test_parse_exec_args_workspace_root() { + let args: Vec = + vec!["-w", "--", "echo", "hi"].iter().map(|s| s.to_string()).collect(); + let (flags, positional) = parse_exec_args(&args); + assert!(flags.workspace_root); + assert!(!flags.recursive); + assert!(!flags.include_workspace_root); + assert_eq!(positional, vec!["echo", "hi"]); + } +} diff --git a/packages/cli/binding/src/exec/filter.rs b/packages/cli/binding/src/exec/filter.rs new file mode 100644 index 0000000000..b25e16fb63 --- /dev/null +++ b/packages/cli/binding/src/exec/filter.rs @@ -0,0 +1,903 @@ +use std::collections::{BTreeMap, HashSet, VecDeque}; + +use petgraph::Direction; +use rustc_hash::{FxHashMap, FxHashSet}; +use vite_path::AbsolutePath; + +/// Parsed package selector for `--filter` flag (pnpm-compatible syntax). +#[derive(Debug, Clone)] +pub(super) struct PackageSelector { + pub name_pattern: Option, + pub parent_dir: Option, + pub include_dependencies: bool, + pub include_dependents: bool, + pub exclude_self: bool, + pub exclude: bool, +} + +/// Check if a selector string is a path selector (starts with `.` or `..`). +/// Matches pnpm's `isSelectorByLocation` logic. +fn is_path_selector(s: &str) -> bool { + if !s.starts_with('.') { + return false; + } + if s.len() == 1 { + return true; // "." + } + let second = s.as_bytes()[1]; + if second == b'/' || second == b'\\' { + return true; // "./" or ".\" + } + if second != b'.' { + return false; + } + // ".." or "../" or "..\" + s.len() == 2 || s.as_bytes()[2] == b'/' || s.as_bytes()[2] == b'\\' +} + +/// Parse a pnpm-compatible package selector string. +/// +/// Supported syntax: +/// - `` — name match with glob (`*` wildcard) +/// - `...` — package + all its dependencies +/// - `^...` — only dependencies, exclude the package itself +/// - `...` — package + all packages that depend on it +/// - `...^` — only dependents, exclude the package itself +/// - `......` — both dependencies and dependents +/// - `!` — exclusion +/// - `{./path}` — braced path selector +/// - `{./path}` — combined name pattern + path selector +pub(super) fn parse_package_selector(raw: &str) -> PackageSelector { + let mut s = raw; + let mut exclude = false; + let mut exclude_self = false; + let mut include_dependencies = false; + let mut include_dependents = false; + + // Check for ! prefix (exclude) + if let Some(rest) = s.strip_prefix('!') { + exclude = true; + s = rest; + } + + // Check for ... suffix (include dependencies) + if let Some(rest) = s.strip_suffix("...") { + include_dependencies = true; + s = rest; + if let Some(rest) = s.strip_suffix('^') { + exclude_self = true; + s = rest; + } + } + + // Check for ... prefix (include dependents) + if let Some(rest) = s.strip_prefix("...") { + include_dependents = true; + s = rest; + if let Some(rest) = s.strip_prefix('^') { + exclude_self = true; + s = rest; + } + } + + // Parse remaining: could be "namePattern{parentDir}", "{parentDir}", "namePattern", or path + let (name_pattern, parent_dir) = if let Some(brace_start) = s.find('{') { + if let Some(brace_end) = s.find('}') { + let path_part = &s[brace_start + 1..brace_end]; + let name_part = if brace_start > 0 { Some(s[..brace_start].to_string()) } else { None }; + (name_part, Some(path_part.to_string())) + } else { + // Malformed — treat as name pattern + (Some(s.to_string()), None) + } + } else if is_path_selector(s) { + // Unbraced path selectors don't support traversal modifiers (matching pnpm). + // Use braced syntax {./path}... for path + dependency traversal. + include_dependencies = false; + include_dependents = false; + exclude_self = false; + (None, Some(s.to_string())) + } else if s.is_empty() { + (None, None) + } else { + (Some(s.to_string()), None) + }; + + PackageSelector { + name_pattern, + parent_dir, + include_dependencies, + include_dependents, + exclude_self, + exclude, + } +} + +/// Filter packages from a dependency graph using pnpm-compatible selectors. +/// +/// Returns node indices sorted alphabetically by package name. +pub(super) fn filter_packages( + graph: &petgraph::graph::DiGraph< + vite_workspace::PackageInfo, + vite_workspace::DependencyType, + vite_workspace::PackageIx, + >, + selectors: &[PackageSelector], + cwd: &AbsolutePath, +) -> Vec { + let mut included = HashSet::new(); + let mut excluded = HashSet::new(); + + // If every selector is an exclusion, seed `included` with all non-root + // packages so that exclusions subtract from the full set. + let all_exclude = selectors.iter().all(|s| s.exclude); + if all_exclude { + for idx in graph.node_indices() { + if !graph[idx].path.as_str().is_empty() { + included.insert(idx); + } + } + } + + for selector in selectors { + let mut matched = HashSet::new(); + + // Match by path (parent_dir) first + if let Some(dir) = &selector.parent_dir { + let filter_path = cwd.join(dir); + if let Ok(canonical) = std::fs::canonicalize(filter_path.as_path()) { + for idx in graph.node_indices() { + let pkg_abs = graph[idx].absolute_path.as_path(); + if let Ok(pkg_canonical) = std::fs::canonicalize(pkg_abs) { + // Match if filter path equals or is a parent of pkg path + if pkg_canonical.starts_with(&canonical) { + matched.insert(idx); + } + } + } + } + } + + // Match by name pattern (intersect with path results if both present) + if let Some(pattern) = &selector.name_pattern { + if let Ok(pat) = glob::Pattern::new(pattern) { + if selector.parent_dir.is_some() { + // Both path and name: filter path matches by name + matched.retain(|&idx| { + let name = graph[idx].package_json.name.as_str(); + pat.matches(name) + }); + } else { + // Name only: match all packages + for idx in graph.node_indices() { + let name = graph[idx].package_json.name.as_str(); + if pat.matches(name) { + matched.insert(idx); + } + } + } + } + } + + // Expand with dependency/dependent traversal + let mut expanded = HashSet::new(); + + for &node in &matched { + if !selector.exclude_self { + expanded.insert(node); + } + + // Include transitive dependencies (follow outgoing edges: A→B means A depends on B) + if selector.include_dependencies { + let mut queue = VecDeque::new(); + let mut visited = HashSet::new(); + visited.insert(node); + for neighbor in graph.neighbors_directed(node, Direction::Outgoing) { + if visited.insert(neighbor) { + queue.push_back(neighbor); + expanded.insert(neighbor); + } + } + while let Some(current) = queue.pop_front() { + for neighbor in graph.neighbors_directed(current, Direction::Outgoing) { + if visited.insert(neighbor) { + queue.push_back(neighbor); + expanded.insert(neighbor); + } + } + } + } + + // Include transitive dependents (follow incoming edges) + if selector.include_dependents { + let mut queue = VecDeque::new(); + let mut visited = HashSet::new(); + visited.insert(node); + for neighbor in graph.neighbors_directed(node, Direction::Incoming) { + if visited.insert(neighbor) { + queue.push_back(neighbor); + expanded.insert(neighbor); + } + } + while let Some(current) = queue.pop_front() { + for neighbor in graph.neighbors_directed(current, Direction::Incoming) { + if visited.insert(neighbor) { + queue.push_back(neighbor); + expanded.insert(neighbor); + } + } + } + } + } + + // If both flags set, also walk dependencies of dependents (matching pnpm behavior) + if selector.include_dependencies && selector.include_dependents { + let dependents: Vec<_> = expanded.iter().copied().collect(); + for dep_node in dependents { + let mut queue = VecDeque::new(); + let mut visited = HashSet::new(); + visited.insert(dep_node); + for neighbor in graph.neighbors_directed(dep_node, Direction::Outgoing) { + if visited.insert(neighbor) { + queue.push_back(neighbor); + expanded.insert(neighbor); + } + } + while let Some(current) = queue.pop_front() { + for neighbor in graph.neighbors_directed(current, Direction::Outgoing) { + if visited.insert(neighbor) { + queue.push_back(neighbor); + expanded.insert(neighbor); + } + } + } + } + } + + if selector.exclude { + excluded.extend(expanded); + } else { + included.extend(expanded); + } + } + + // Return included minus excluded, in topological order + let result: Vec<_> = included.difference(&excluded).copied().collect(); + topological_sort_packages(graph, &result) +} + +/// Sort package indices in topological order (dependencies before dependents) +/// using Kahn's algorithm, with alphabetical tie-breaking for determinism. +/// +/// Packages involved in dependency cycles are appended at the end in +/// alphabetical order, ensuring the command completes rather than failing. +pub(super) fn topological_sort_packages( + graph: &petgraph::graph::DiGraph< + vite_workspace::PackageInfo, + vite_workspace::DependencyType, + vite_workspace::PackageIx, + >, + selected: &[vite_workspace::PackageNodeIndex], +) -> Vec { + let selected_set: FxHashSet<_> = selected.iter().copied().collect(); + + // Count how many selected dependencies each selected package has + // (Outgoing edges = dependencies in this graph) + let mut dep_count: FxHashMap = FxHashMap::default(); + for &idx in selected { + let count = graph + .neighbors_directed(idx, Direction::Outgoing) + .filter(|n| selected_set.contains(n)) + .count(); + dep_count.insert(idx, count); + } + + // BTreeMap keyed by name for deterministic alphabetical ordering among peers + let mut ready: BTreeMap<&str, vite_workspace::PackageNodeIndex> = BTreeMap::new(); + for (&idx, &count) in &dep_count { + if count == 0 { + ready.insert(graph[idx].package_json.name.as_str(), idx); + } + } + + let mut result = Vec::with_capacity(selected.len()); + while let Some((_, idx)) = ready.pop_first() { + result.push(idx); + // Decrement dep counts for dependents (incoming edges = dependents) + for dependent in graph.neighbors_directed(idx, Direction::Incoming) { + if let Some(count) = dep_count.get_mut(&dependent) { + *count -= 1; + if *count == 0 { + ready.insert(graph[dependent].package_json.name.as_str(), dependent); + } + } + } + } + + // Cycle fallback: iteratively break cycles by forcing the alphabetically-first + // remaining node, then continue Kahn's algorithm to correctly order any + // non-cyclic dependents that become unblocked. + let mut placed: FxHashSet<_> = result.iter().copied().collect(); + while result.len() < selected.len() { + let mut remaining: Vec<_> = + selected.iter().copied().filter(|idx| !placed.contains(idx)).collect(); + remaining.sort_by(|a, b| graph[*a].package_json.name.cmp(&graph[*b].package_json.name)); + + let cyclic_names: Vec<&str> = + remaining.iter().map(|&idx| graph[idx].package_json.name.as_str()).collect(); + tracing::debug!( + "Circular dependencies detected among packages: {}. Breaking cycle at '{}'.", + cyclic_names.join(", "), + graph[remaining[0]].package_json.name + ); + + // Force-add the alphabetically-first remaining node to break the cycle + let forced = remaining[0]; + result.push(forced); + placed.insert(forced); + + // Decrement dep counts for its dependents, potentially freeing non-cyclic nodes + for dependent in graph.neighbors_directed(forced, Direction::Incoming) { + if let Some(count) = dep_count.get_mut(&dependent) { + if *count > 0 { + *count -= 1; + if *count == 0 && !placed.contains(&dependent) { + ready.insert(graph[dependent].package_json.name.as_str(), dependent); + } + } + } + } + + // Continue Kahn's algorithm with any newly freed nodes + while let Some((_, idx)) = ready.pop_first() { + result.push(idx); + placed.insert(idx); + for dependent in graph.neighbors_directed(idx, Direction::Incoming) { + if let Some(count) = dep_count.get_mut(&dependent) { + if *count > 0 { + *count -= 1; + if *count == 0 && !placed.contains(&dependent) { + ready.insert(graph[dependent].package_json.name.as_str(), dependent); + } + } + } + } + } + } + + result +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use vite_path::{AbsolutePathBuf, RelativePathBuf}; + use vite_workspace::{DependencyType, PackageInfo, PackageJson}; + + use super::*; + + /// Build a test dependency graph: + /// - app-a depends on lib-c + /// - app-b has no workspace dependencies + /// - lib-c has no workspace dependencies + /// - root (workspace root, empty path) + fn build_test_graph() -> petgraph::graph::DiGraph< + vite_workspace::PackageInfo, + vite_workspace::DependencyType, + vite_workspace::PackageIx, + > { + let mut graph = petgraph::graph::DiGraph::default(); + + let root = graph.add_node(PackageInfo { + package_json: PackageJson { name: "root".into(), ..Default::default() }, + path: RelativePathBuf::default(), + absolute_path: AbsolutePathBuf::new(PathBuf::from("/workspace")).unwrap().into(), + }); + let app_a = graph.add_node(PackageInfo { + package_json: PackageJson { name: "app-a".into(), ..Default::default() }, + path: RelativePathBuf::try_from("packages/app-a").unwrap(), + absolute_path: AbsolutePathBuf::new(PathBuf::from("/workspace/packages/app-a")) + .unwrap() + .into(), + }); + let app_b = graph.add_node(PackageInfo { + package_json: PackageJson { name: "app-b".into(), ..Default::default() }, + path: RelativePathBuf::try_from("packages/app-b").unwrap(), + absolute_path: AbsolutePathBuf::new(PathBuf::from("/workspace/packages/app-b")) + .unwrap() + .into(), + }); + let lib_c = graph.add_node(PackageInfo { + package_json: PackageJson { name: "lib-c".into(), ..Default::default() }, + path: RelativePathBuf::try_from("packages/lib-c").unwrap(), + absolute_path: AbsolutePathBuf::new(PathBuf::from("/workspace/packages/lib-c")) + .unwrap() + .into(), + }); + + // app-a depends on lib-c + graph.add_edge(app_a, lib_c, DependencyType::Normal); + + let _ = (root, app_b); // suppress unused warnings + graph + } + + #[test] + fn test_parse_package_selector_simple_name() { + let sel = parse_package_selector("my-app"); + assert_eq!(sel.name_pattern.as_deref(), Some("my-app")); + assert!(!sel.include_dependencies); + assert!(!sel.include_dependents); + assert!(!sel.exclude_self); + assert!(!sel.exclude); + } + + #[test] + fn test_parse_package_selector_glob_pattern() { + let sel = parse_package_selector("app-*"); + assert_eq!(sel.name_pattern.as_deref(), Some("app-*")); + assert!(!sel.include_dependencies); + assert!(!sel.include_dependents); + } + + #[test] + fn test_parse_package_selector_scoped_glob() { + let sel = parse_package_selector("@myorg/*"); + assert_eq!(sel.name_pattern.as_deref(), Some("@myorg/*")); + } + + #[test] + fn test_parse_package_selector_with_dependencies() { + let sel = parse_package_selector("app-a..."); + assert_eq!(sel.name_pattern.as_deref(), Some("app-a")); + assert!(sel.include_dependencies); + assert!(!sel.include_dependents); + assert!(!sel.exclude_self); + } + + #[test] + fn test_parse_package_selector_with_dependencies_exclude_self() { + let sel = parse_package_selector("app-a^..."); + assert_eq!(sel.name_pattern.as_deref(), Some("app-a")); + assert!(sel.include_dependencies); + assert!(!sel.include_dependents); + assert!(sel.exclude_self); + } + + #[test] + fn test_parse_package_selector_with_dependents() { + let sel = parse_package_selector("...lib-c"); + assert_eq!(sel.name_pattern.as_deref(), Some("lib-c")); + assert!(!sel.include_dependencies); + assert!(sel.include_dependents); + assert!(!sel.exclude_self); + } + + #[test] + fn test_parse_package_selector_with_dependents_exclude_self() { + let sel = parse_package_selector("...^lib-c"); + assert_eq!(sel.name_pattern.as_deref(), Some("lib-c")); + assert!(!sel.include_dependencies); + assert!(sel.include_dependents); + assert!(sel.exclude_self); + } + + #[test] + fn test_parse_package_selector_exclude() { + let sel = parse_package_selector("!app-b"); + assert_eq!(sel.name_pattern.as_deref(), Some("app-b")); + assert!(sel.exclude); + } + + #[test] + fn test_parse_package_selector_exclude_with_dependencies() { + let sel = parse_package_selector("!app-a..."); + assert_eq!(sel.name_pattern.as_deref(), Some("app-a")); + assert!(sel.exclude); + assert!(sel.include_dependencies); + } + + #[test] + fn test_filter_packages_simple_name() { + let graph = build_test_graph(); + let selectors = vec![parse_package_selector("app-a")]; + let dummy_cwd = AbsolutePathBuf::new(PathBuf::from("/workspace")).unwrap(); + let result = filter_packages(&graph, &selectors, &dummy_cwd); + let names: Vec<&str> = + result.iter().map(|&idx| graph[idx].package_json.name.as_str()).collect(); + assert_eq!(names, vec!["app-a"]); + } + + #[test] + fn test_filter_packages_glob() { + let graph = build_test_graph(); + let selectors = vec![parse_package_selector("app-*")]; + let dummy_cwd = AbsolutePathBuf::new(PathBuf::from("/workspace")).unwrap(); + let result = filter_packages(&graph, &selectors, &dummy_cwd); + let names: Vec<&str> = + result.iter().map(|&idx| graph[idx].package_json.name.as_str()).collect(); + assert_eq!(names, vec!["app-a", "app-b"]); + } + + #[test] + fn test_filter_packages_with_dependencies() { + let graph = build_test_graph(); + let selectors = vec![parse_package_selector("app-a...")]; + let dummy_cwd = AbsolutePathBuf::new(PathBuf::from("/workspace")).unwrap(); + let result = filter_packages(&graph, &selectors, &dummy_cwd); + let names: Vec<&str> = + result.iter().map(|&idx| graph[idx].package_json.name.as_str()).collect(); + // app-a depends on lib-c; topological order: lib-c first (dependency), then app-a + assert_eq!(names, vec!["lib-c", "app-a"]); + } + + #[test] + fn test_filter_packages_dependencies_exclude_self() { + let graph = build_test_graph(); + let selectors = vec![parse_package_selector("app-a^...")]; + let dummy_cwd = AbsolutePathBuf::new(PathBuf::from("/workspace")).unwrap(); + let result = filter_packages(&graph, &selectors, &dummy_cwd); + let names: Vec<&str> = + result.iter().map(|&idx| graph[idx].package_json.name.as_str()).collect(); + // Only dependencies, not app-a itself + assert_eq!(names, vec!["lib-c"]); + } + + #[test] + fn test_filter_packages_with_dependents() { + let graph = build_test_graph(); + let selectors = vec![parse_package_selector("...lib-c")]; + let dummy_cwd = AbsolutePathBuf::new(PathBuf::from("/workspace")).unwrap(); + let result = filter_packages(&graph, &selectors, &dummy_cwd); + let names: Vec<&str> = + result.iter().map(|&idx| graph[idx].package_json.name.as_str()).collect(); + // lib-c and all packages that depend on it (app-a); topological order: lib-c first + assert_eq!(names, vec!["lib-c", "app-a"]); + } + + #[test] + fn test_filter_packages_exclude() { + let graph = build_test_graph(); + let selectors = vec![parse_package_selector("app-*"), parse_package_selector("!app-b")]; + let dummy_cwd = AbsolutePathBuf::new(PathBuf::from("/workspace")).unwrap(); + let result = filter_packages(&graph, &selectors, &dummy_cwd); + let names: Vec<&str> = + result.iter().map(|&idx| graph[idx].package_json.name.as_str()).collect(); + assert_eq!(names, vec!["app-a"]); + } + + #[test] + fn test_is_path_selector() { + // Valid path selectors + assert!(is_path_selector(".")); + assert!(is_path_selector("./foo")); + assert!(is_path_selector("./packages/app-a")); + assert!(is_path_selector("..")); + assert!(is_path_selector("../foo")); + assert!(is_path_selector("../packages/app-a")); + assert!(is_path_selector(".\\foo")); // Windows-style + assert!(is_path_selector("..\\foo")); // Windows-style + + // Not path selectors + assert!(!is_path_selector("foo")); + assert!(!is_path_selector(".foo")); // dotfile name, not a path + assert!(!is_path_selector("app-*")); + assert!(!is_path_selector("@myorg/*")); + assert!(!is_path_selector("")); + } + + #[test] + fn test_parse_package_selector_path() { + let sel = parse_package_selector("./packages/app-a"); + assert_eq!(sel.name_pattern, None); + assert_eq!(sel.parent_dir.as_deref(), Some("./packages/app-a")); + assert!(!sel.include_dependencies); + assert!(!sel.include_dependents); + assert!(!sel.exclude_self); + assert!(!sel.exclude); + } + + #[test] + fn test_parse_package_selector_path_ignores_traversal() { + // Unbraced path selectors don't support traversal modifiers (matching pnpm). + // The ... suffix is stripped but traversal flags are reset. + let sel = parse_package_selector("./packages/app-a..."); + assert_eq!(sel.name_pattern, None); + assert_eq!(sel.parent_dir.as_deref(), Some("./packages/app-a")); + assert!(!sel.include_dependencies); + assert!(!sel.include_dependents); + assert!(!sel.exclude_self); + } + + #[test] + fn test_parse_package_selector_path_exclude() { + let sel = parse_package_selector("!./packages/app-b"); + assert_eq!(sel.name_pattern, None); + assert_eq!(sel.parent_dir.as_deref(), Some("./packages/app-b")); + assert!(sel.exclude); + } + + #[test] + fn test_parse_package_selector_path_parent_dir() { + let sel = parse_package_selector("../other-pkg"); + assert_eq!(sel.name_pattern, None); + assert_eq!(sel.parent_dir.as_deref(), Some("../other-pkg")); + } + + #[test] + fn test_parse_package_selector_dot_only() { + let sel = parse_package_selector("."); + assert_eq!(sel.name_pattern, None); + assert_eq!(sel.parent_dir.as_deref(), Some(".")); + } + + #[test] + fn test_parse_package_selector_braced_path() { + let sel = parse_package_selector("{./packages/app-a}"); + assert_eq!(sel.name_pattern, None); + assert_eq!(sel.parent_dir.as_deref(), Some("./packages/app-a")); + assert!(!sel.include_dependencies); + assert!(!sel.include_dependents); + assert!(!sel.exclude_self); + assert!(!sel.exclude); + } + + #[test] + fn test_parse_package_selector_braced_path_deps() { + let sel = parse_package_selector("{./packages/app-a}..."); + assert_eq!(sel.name_pattern, None); + assert_eq!(sel.parent_dir.as_deref(), Some("./packages/app-a")); + assert!(sel.include_dependencies); + assert!(!sel.include_dependents); + } + + #[test] + fn test_parse_package_selector_braced_path_dependents() { + let sel = parse_package_selector("...{./packages/app-a}"); + assert_eq!(sel.name_pattern, None); + assert_eq!(sel.parent_dir.as_deref(), Some("./packages/app-a")); + assert!(!sel.include_dependencies); + assert!(sel.include_dependents); + } + + #[test] + fn test_parse_package_selector_name_and_path() { + let sel = parse_package_selector("app-*{./packages}"); + assert_eq!(sel.name_pattern.as_deref(), Some("app-*")); + assert_eq!(sel.parent_dir.as_deref(), Some("./packages")); + assert!(!sel.include_dependencies); + assert!(!sel.include_dependents); + } + + #[test] + fn test_parse_package_selector_both_directions() { + let sel = parse_package_selector("...foo..."); + assert_eq!(sel.name_pattern.as_deref(), Some("foo")); + assert!(sel.include_dependencies); + assert!(sel.include_dependents); + assert!(!sel.exclude_self); + } + + #[test] + fn test_parse_package_selector_braced_path_exclude() { + let sel = parse_package_selector("!{./packages/app-b}"); + assert_eq!(sel.name_pattern, None); + assert_eq!(sel.parent_dir.as_deref(), Some("./packages/app-b")); + assert!(sel.exclude); + } + + #[test] + fn test_parse_package_selector_name_and_path_with_deps() { + let sel = parse_package_selector("app-*{./packages}..."); + assert_eq!(sel.name_pattern.as_deref(), Some("app-*")); + assert_eq!(sel.parent_dir.as_deref(), Some("./packages")); + assert!(sel.include_dependencies); + } + + #[test] + fn test_parse_package_selector_malformed_brace() { + // Missing closing brace — treated as name pattern + let sel = parse_package_selector("{./packages/app-a"); + assert_eq!(sel.name_pattern.as_deref(), Some("{./packages/app-a")); + assert_eq!(sel.parent_dir, None); + } + + #[test] + fn test_topological_sort_simple() { + let graph = build_test_graph(); + // All non-root packages + let all: Vec<_> = + graph.node_indices().filter(|&idx| !graph[idx].path.as_str().is_empty()).collect(); + let sorted = super::topological_sort_packages(&graph, &all); + let names: Vec<&str> = + sorted.iter().map(|&idx| graph[idx].package_json.name.as_str()).collect(); + // app-b and lib-c have no deps, sorted alphabetically first + // app-a depends on lib-c, so it comes after lib-c + assert_eq!(names, vec!["app-b", "lib-c", "app-a"]); + } + + #[test] + fn test_topological_sort_with_cycles() { + let mut graph = petgraph::graph::DiGraph::default(); + + let root = graph.add_node(PackageInfo { + package_json: PackageJson { name: "root".into(), ..Default::default() }, + path: RelativePathBuf::default(), + absolute_path: AbsolutePathBuf::new(PathBuf::from("/workspace")).unwrap().into(), + }); + let pkg_a = graph.add_node(PackageInfo { + package_json: PackageJson { name: "pkg-a".into(), ..Default::default() }, + path: RelativePathBuf::try_from("packages/pkg-a").unwrap(), + absolute_path: AbsolutePathBuf::new(PathBuf::from("/workspace/packages/pkg-a")) + .unwrap() + .into(), + }); + let pkg_b = graph.add_node(PackageInfo { + package_json: PackageJson { name: "pkg-b".into(), ..Default::default() }, + path: RelativePathBuf::try_from("packages/pkg-b").unwrap(), + absolute_path: AbsolutePathBuf::new(PathBuf::from("/workspace/packages/pkg-b")) + .unwrap() + .into(), + }); + let pkg_c = graph.add_node(PackageInfo { + package_json: PackageJson { name: "pkg-c".into(), ..Default::default() }, + path: RelativePathBuf::try_from("packages/pkg-c").unwrap(), + absolute_path: AbsolutePathBuf::new(PathBuf::from("/workspace/packages/pkg-c")) + .unwrap() + .into(), + }); + + // Circular: pkg-a <-> pkg-b + graph.add_edge(pkg_a, pkg_b, DependencyType::Normal); + graph.add_edge(pkg_b, pkg_a, DependencyType::Normal); + // pkg-c has no dependencies + let _ = root; + + let selected = vec![pkg_a, pkg_b, pkg_c]; + let sorted = super::topological_sort_packages(&graph, &selected); + let names: Vec<&str> = + sorted.iter().map(|&idx| graph[idx].package_json.name.as_str()).collect(); + // pkg-c has no deps, comes first; pkg-a and pkg-b are in a cycle, appended alphabetically + assert_eq!(names, vec!["pkg-c", "pkg-a", "pkg-b"]); + } + + #[test] + fn test_topological_sort_cycle_with_dependent() { + let mut graph = petgraph::graph::DiGraph::default(); + + let _root = graph.add_node(PackageInfo { + package_json: PackageJson { name: "root".into(), ..Default::default() }, + path: RelativePathBuf::default(), + absolute_path: AbsolutePathBuf::new(PathBuf::from("/workspace")).unwrap().into(), + }); + let a = graph.add_node(PackageInfo { + package_json: PackageJson { name: "a".into(), ..Default::default() }, + path: RelativePathBuf::try_from("packages/a").unwrap(), + absolute_path: AbsolutePathBuf::new(PathBuf::from("/workspace/packages/a")) + .unwrap() + .into(), + }); + let b = graph.add_node(PackageInfo { + package_json: PackageJson { name: "b".into(), ..Default::default() }, + path: RelativePathBuf::try_from("packages/b").unwrap(), + absolute_path: AbsolutePathBuf::new(PathBuf::from("/workspace/packages/b")) + .unwrap() + .into(), + }); + let aa = graph.add_node(PackageInfo { + package_json: PackageJson { name: "aa".into(), ..Default::default() }, + path: RelativePathBuf::try_from("packages/aa").unwrap(), + absolute_path: AbsolutePathBuf::new(PathBuf::from("/workspace/packages/aa")) + .unwrap() + .into(), + }); + + // Cycle: a <-> b + graph.add_edge(a, b, DependencyType::Normal); + graph.add_edge(b, a, DependencyType::Normal); + // aa depends on b (non-cyclic dependent) + graph.add_edge(aa, b, DependencyType::Normal); + + let selected = vec![a, b, aa]; + let sorted = super::topological_sort_packages(&graph, &selected); + let names: Vec<&str> = + sorted.iter().map(|&idx| graph[idx].package_json.name.as_str()).collect(); + // Force 'a' first (alphabetical cycle break), frees 'b', then 'aa' follows + assert_eq!(names, vec!["a", "b", "aa"]); + } + + #[test] + fn test_filter_packages_multiple_inclusion() { + let graph = build_test_graph(); + let selectors = vec![parse_package_selector("app-a"), parse_package_selector("lib-c")]; + let dummy_cwd = AbsolutePathBuf::new(PathBuf::from("/workspace")).unwrap(); + let result = filter_packages(&graph, &selectors, &dummy_cwd); + let names: Vec<&str> = + result.iter().map(|&idx| graph[idx].package_json.name.as_str()).collect(); + // Union of both selectors, topological order: lib-c first (app-a depends on it) + assert_eq!(names, vec!["lib-c", "app-a"]); + } + + #[test] + fn test_filter_packages_exclusion_only() { + let graph = build_test_graph(); + let selectors = vec![parse_package_selector("!app-b")]; + let dummy_cwd = AbsolutePathBuf::new(PathBuf::from("/workspace")).unwrap(); + let result = filter_packages(&graph, &selectors, &dummy_cwd); + let names: Vec<&str> = + result.iter().map(|&idx| graph[idx].package_json.name.as_str()).collect(); + // All non-root packages minus app-b, in topological order + assert_eq!(names, vec!["lib-c", "app-a"]); + } + + #[test] + fn test_filter_packages_exclusion_only_multiple() { + let graph = build_test_graph(); + let selectors = vec![parse_package_selector("!app-a"), parse_package_selector("!app-b")]; + let dummy_cwd = AbsolutePathBuf::new(PathBuf::from("/workspace")).unwrap(); + let result = filter_packages(&graph, &selectors, &dummy_cwd); + let names: Vec<&str> = + result.iter().map(|&idx| graph[idx].package_json.name.as_str()).collect(); + assert_eq!(names, vec!["lib-c"]); + } + + #[test] + fn test_filter_packages_exclusion_only_all_excluded() { + let graph = build_test_graph(); + let selectors = vec![ + parse_package_selector("!app-a"), + parse_package_selector("!app-b"), + parse_package_selector("!lib-c"), + ]; + let dummy_cwd = AbsolutePathBuf::new(PathBuf::from("/workspace")).unwrap(); + let result = filter_packages(&graph, &selectors, &dummy_cwd); + assert!(result.is_empty()); + } + + #[test] + fn test_filter_packages_exclusion_nonexistent() { + let graph = build_test_graph(); + // Excluding a package that doesn't exist should return all non-root packages + let selectors = vec![parse_package_selector("!no-such-pkg")]; + let dummy_cwd = AbsolutePathBuf::new(PathBuf::from("/workspace")).unwrap(); + let result = filter_packages(&graph, &selectors, &dummy_cwd); + let names: Vec<&str> = + result.iter().map(|&idx| graph[idx].package_json.name.as_str()).collect(); + assert_eq!(names, vec!["app-b", "lib-c", "app-a"]); + } + + #[test] + fn test_filter_packages_mixed_inclusion_matches_nothing() { + let graph = build_test_graph(); + // An inclusion selector that matches nothing + an exclusion selector + // should return empty (the inclusion intent found nothing) + let selectors = vec![parse_package_selector("app-x"), parse_package_selector("!app-b")]; + let dummy_cwd = AbsolutePathBuf::new(PathBuf::from("/workspace")).unwrap(); + let result = filter_packages(&graph, &selectors, &dummy_cwd); + assert!(result.is_empty()); + } + + #[test] + fn test_filter_packages_glob_star_includes_root() { + let graph = build_test_graph(); + // `*` matches all package names including root + let selectors = vec![parse_package_selector("*")]; + let dummy_cwd = AbsolutePathBuf::new(PathBuf::from("/workspace")).unwrap(); + let result = filter_packages(&graph, &selectors, &dummy_cwd); + let names: Vec<&str> = + result.iter().map(|&idx| graph[idx].package_json.name.as_str()).collect(); + // All 4 nodes including root, in topological order with alphabetical tie-breaking + assert_eq!(names, vec!["app-b", "lib-c", "app-a", "root"]); + } + + #[test] + fn test_filter_packages_exclusion_star_returns_empty() { + let graph = build_test_graph(); + // `!*` excludes everything, returns empty + let selectors = vec![parse_package_selector("!*")]; + let dummy_cwd = AbsolutePathBuf::new(PathBuf::from("/workspace")).unwrap(); + let result = filter_packages(&graph, &selectors, &dummy_cwd); + assert!(result.is_empty()); + } +} diff --git a/packages/cli/binding/src/exec/mod.rs b/packages/cli/binding/src/exec/mod.rs new file mode 100644 index 0000000000..f54f865a2c --- /dev/null +++ b/packages/cli/binding/src/exec/mod.rs @@ -0,0 +1,122 @@ +mod args; +mod filter; +mod workspace; + +use vite_error::Error; +use vite_path::AbsolutePathBuf; +use vite_shared::{PrependOptions, prepend_to_path_env}; +use vite_task::ExitStatus; + +use self::{args::parse_exec_args, workspace::execute_exec_workspace}; + +/// Help text for `vp exec`. +const EXEC_HELP: &str = "\ +Execute a command from local node_modules/.bin + +Usage: vp exec [OPTIONS] [--] [args...] + +Arguments: + Command to execute from node_modules/.bin + [args...] Arguments to pass to the command + +Options: + -c, --shell-mode Execute the command within a shell environment + -r, --recursive Run in every workspace package + -w, --workspace-root Run on the workspace root package only + --include-workspace-root Include workspace root when running recursively + --filter Filter packages (can be used multiple times) + --parallel Run concurrently without topological ordering + --reverse Reverse execution order + --resume-from Resume from a specific package + --report-summary Save results to vp-exec-summary.json + -h, --help Print help + +Examples: + vp exec eslint . # Run local eslint + vp exec tsc --noEmit # Run local TypeScript compiler + vp exec -c 'eslint . && prettier --check .' # Shell mode + vp exec -r -- eslint . # Run in all workspace packages + vp exec --filter 'app...' -- tsc # Run in filtered packages"; + +/// Execute `vp exec` command in the local CLI. +/// +/// Prepends `./node_modules/.bin` and package manager bin directory to PATH, +/// then spawns the specified command. +pub async fn execute(args: &[String], cwd: &AbsolutePathBuf) -> Result { + let (flags, positional) = parse_exec_args(args); + + // Show help + if flags.help { + println!("{EXEC_HELP}"); + return Ok(ExitStatus::SUCCESS); + } + + // No command specified + if positional.is_empty() { + vite_shared::output::error( + "'vp exec' requires a command to run\n\n\ + Usage: vp exec [--] [args...]\n\n\ + Examples:\n\ + \x20 vp exec eslint .\n\ + \x20 vp exec tsc --noEmit", + ); + return Ok(ExitStatus(1)); + } + + // Workspace mode: --recursive, --workspace-root, or --filter + if flags.recursive || flags.workspace_root || !flags.filters.is_empty() { + return execute_exec_workspace(&flags, &positional, cwd).await; + } + + // Single-package mode + // Prepend package manager bin dir to PATH + if let Ok(pm) = vite_install::PackageManager::builder(cwd).build().await { + let bin_prefix = pm.get_bin_prefix(); + prepend_to_path_env(&bin_prefix, PrependOptions::default()); + } + + // Prepend ./node_modules/.bin to PATH (current dir only, no walk-up) + let bin_dir = cwd.join("node_modules").join(".bin"); + if bin_dir.as_path().is_dir() { + prepend_to_path_env(&bin_dir, PrependOptions { dedupe_anywhere: true }); + } + + // Set VITE_PLUS_PACKAGE_NAME from package.json if available + if let Ok(pkg_json) = std::fs::read_to_string(cwd.join("package.json")) { + if let Ok(pkg) = serde_json::from_str::(&pkg_json) { + if let Some(name) = pkg.get("name").and_then(|n| n.as_str()) { + // SAFETY: only called from single-threaded local CLI context + unsafe { + std::env::set_var("VITE_PLUS_PACKAGE_NAME", name); + } + } + } + } + + if flags.shell_mode { + let shell_cmd = positional.join(" "); + let mut cmd = vite_command::build_shell_command(&shell_cmd, cwd); + let mut child = cmd.spawn().map_err(|e| Error::Anyhow(e.into()))?; + let status = child.wait().await.map_err(|e| Error::Anyhow(e.into()))?; + Ok(ExitStatus(status.code().unwrap_or(1) as u8)) + } else { + let bin_path = match vite_command::resolve_bin(&positional[0], None, cwd) { + Ok(p) => p, + Err(_) => { + vite_shared::output::error(&format!( + "Command '{}' not found in node_modules/.bin\n\n\ + Hint: Run 'vp install' to install dependencies, or use 'vpx' for remote fallback.", + positional[0] + )); + return Ok(ExitStatus(1)); + } + }; + let mut cmd = vite_command::build_command(&bin_path, cwd); + if positional.len() > 1 { + cmd.args(&positional[1..]); + } + let mut child = cmd.spawn().map_err(|e| Error::Anyhow(e.into()))?; + let status = child.wait().await.map_err(|e| Error::Anyhow(e.into()))?; + Ok(ExitStatus(status.code().unwrap_or(1) as u8)) + } +} diff --git a/packages/cli/binding/src/exec/workspace.rs b/packages/cli/binding/src/exec/workspace.rs new file mode 100644 index 0000000000..063b18824c --- /dev/null +++ b/packages/cli/binding/src/exec/workspace.rs @@ -0,0 +1,249 @@ +use std::process::Stdio; + +use vite_error::Error; +use vite_path::AbsolutePathBuf; +use vite_task::ExitStatus; + +use super::{ + args::ExecFlags, + filter::{PackageSelector, filter_packages, parse_package_selector, topological_sort_packages}, +}; + +/// Execute `vp exec` across workspace packages (--recursive or --filter mode). +pub(super) async fn execute_exec_workspace( + flags: &ExecFlags, + positional: &[String], + cwd: &AbsolutePathBuf, +) -> Result { + // Find workspace root and load package graph + let (workspace_root, _) = + vite_workspace::find_workspace_root(cwd).map_err(|e| Error::Anyhow(e.into()))?; + let graph = + vite_workspace::load_package_graph(&workspace_root).map_err(|e| Error::Anyhow(e.into()))?; + + // Select packages + let selected: Vec = if flags.workspace_root { + // -w: workspace root only + let indices: Vec<_> = + graph.node_indices().filter(|&idx| graph[idx].path.as_str().is_empty()).collect(); + topological_sort_packages(&graph, &indices) + } else if !flags.filters.is_empty() { + let selectors: Vec = + flags.filters.iter().map(|f| parse_package_selector(f)).collect(); + filter_packages(&graph, &selectors, cwd) + } else { + // Recursive: non-root packages, optionally including root + let indices: Vec<_> = graph + .node_indices() + .filter(|&idx| flags.include_workspace_root || !graph[idx].path.as_str().is_empty()) + .collect(); + topological_sort_packages(&graph, &indices) + }; + + // Apply --reverse: reverse the execution order + let mut selected = selected; + if flags.reverse { + selected.reverse(); + } + + // Apply --resume-from: skip packages until the named one + if let Some(ref resume_pkg) = flags.resume_from { + if let Some(pos) = selected + .iter() + .position(|&idx| graph[idx].package_json.name.as_str() == resume_pkg.as_str()) + { + selected = selected[pos..].to_vec(); + } else { + eprintln!("Package '{}' not found in selected packages", resume_pkg); + return Ok(ExitStatus(1)); + } + } + + if selected.is_empty() { + eprintln!("No packages matched the filter(s)"); + return Ok(ExitStatus::SUCCESS); + } + + // Build base PATH: :: + let base_path_dirs: Vec = { + let mut dirs = Vec::new(); + // Include package manager bin dir + if let Ok(pm) = vite_install::PackageManager::builder(&*workspace_root.path).build().await { + dirs.push(pm.get_bin_prefix().as_path().to_path_buf()); + } + // Include workspace root's node_modules/.bin + let ws_bin = workspace_root.path.join("node_modules").join(".bin"); + if ws_bin.as_path().is_dir() { + dirs.push(ws_bin.as_path().to_path_buf()); + } + dirs.extend(std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default())); + dirs + }; + let base_path = std::env::join_paths(&base_path_dirs).unwrap_or_default(); + + let cmd_display = positional.join(" "); + + // Track per-package results for --report-summary + let mut summary: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + + let exit_status = if flags.parallel { + // Parallel: spawn all processes with independent timing via tokio::spawn + let mut handles: Vec<( + String, + tokio::task::JoinHandle< + Result<(std::process::Output, std::time::Duration), std::io::Error>, + >, + )> = Vec::new(); + for &idx in &selected { + let pkg = &graph[idx]; + let pkg_name = pkg.package_json.name.to_string(); + let pkg_path = &pkg.absolute_path; + + // Build per-package PATH + let bin_dir = pkg_path.join("node_modules").join(".bin"); + let path_env = if bin_dir.as_path().is_dir() { + std::env::join_paths( + std::iter::once(bin_dir.as_path().to_path_buf()) + .chain(base_path_dirs.iter().cloned()), + ) + .unwrap_or_default() + } else { + base_path.clone() + }; + + let mut cmd = if flags.shell_mode { + vite_command::build_shell_command(&cmd_display, pkg_path) + } else { + let bin_path = + vite_command::resolve_bin(&positional[0], Some(&path_env), pkg_path)?; + let mut cmd = vite_command::build_command(&bin_path, pkg_path); + if positional.len() > 1 { + cmd.args(&positional[1..]); + } + cmd + }; + cmd.env("PATH", &path_env) + .env("VITE_PLUS_PACKAGE_NAME", &pkg_name) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let start = std::time::Instant::now(); + let child = cmd.spawn().map_err(|e| Error::Anyhow(e.into()))?; + let handle = tokio::spawn(async move { + let output = child.wait_with_output().await?; + let duration = start.elapsed(); + Ok((output, duration)) + }); + handles.push((pkg_name, handle)); + } + + // Collect results in order for deterministic output + let mut results = Vec::new(); + for (name, handle) in handles { + let (output, duration) = handle + .await + .map_err(|e| Error::Anyhow(e.into()))? + .map_err(|e| Error::Anyhow(e.into()))?; + results.push((name, output, duration)); + } + + // Print outputs in order and track worst exit code + let mut worst_exit = 0u8; + for (name, output, duration) in &results { + println!("{name}$ {cmd_display}"); + use std::io::Write; + let _ = std::io::stdout().write_all(&output.stdout); + let _ = std::io::stderr().write_all(&output.stderr); + let code = output.status.code().unwrap_or(1) as u8; + if code > worst_exit { + worst_exit = code; + } + if flags.report_summary { + let status = if code == 0 { "passed" } else { "failed" }; + summary.insert( + name.clone(), + serde_json::json!({ + "status": status, + "duration": duration.as_secs_f64() * 1000.0, + }), + ); + } + } + + ExitStatus(worst_exit) + } else { + // Sequential execution + let mut final_status = ExitStatus::SUCCESS; + for &idx in &selected { + let pkg = &graph[idx]; + let pkg_name = pkg.package_json.name.as_str(); + let pkg_path = &pkg.absolute_path; + + // Build per-package PATH + let bin_dir = pkg_path.join("node_modules").join(".bin"); + let path_env = if bin_dir.as_path().is_dir() { + std::env::join_paths( + std::iter::once(bin_dir.as_path().to_path_buf()) + .chain(base_path_dirs.iter().cloned()), + ) + .unwrap_or_default() + } else { + base_path.clone() + }; + + println!("{pkg_name}$ {cmd_display}"); + + let start = std::time::Instant::now(); + + let mut cmd = if flags.shell_mode { + vite_command::build_shell_command(&cmd_display, pkg_path) + } else { + let bin_path = + vite_command::resolve_bin(&positional[0], Some(&path_env), pkg_path)?; + let mut cmd = vite_command::build_command(&bin_path, pkg_path); + if positional.len() > 1 { + cmd.args(&positional[1..]); + } + cmd + }; + cmd.env("PATH", &path_env).env("VITE_PLUS_PACKAGE_NAME", pkg_name); + + let mut child = cmd.spawn().map_err(|e| Error::Anyhow(e.into()))?; + let status = child.wait().await.map_err(|e| Error::Anyhow(e.into()))?; + let duration = start.elapsed(); + let code = status.code().unwrap_or(1) as u8; + + if flags.report_summary { + let pkg_status = if code == 0 { "passed" } else { "failed" }; + summary.insert( + pkg_name.to_string(), + serde_json::json!({ + "status": pkg_status, + "duration": duration.as_secs_f64() * 1000.0, + }), + ); + } + + if code != 0 { + final_status = ExitStatus(code); + break; + } + } + + final_status + }; + + // Write report summary if requested + if flags.report_summary { + let report = serde_json::json!({ "executionStatus": summary }); + let report_path = cwd.join("vp-exec-summary.json"); + if let Err(e) = + std::fs::write(report_path.as_path(), serde_json::to_string_pretty(&report).unwrap()) + { + eprintln!("Failed to write vp-exec-summary.json: {e}"); + } + } + + Ok(exit_status) +} diff --git a/packages/cli/binding/src/lib.rs b/packages/cli/binding/src/lib.rs index 25e0b56a74..1bcfac8591 100644 --- a/packages/cli/binding/src/lib.rs +++ b/packages/cli/binding/src/lib.rs @@ -8,6 +8,7 @@ pub extern crate rolldown_binding; mod cli; +mod exec; // These modules export NAPI functions only called from JavaScript at runtime. // allow(dead_code) suppresses warnings in the test target which doesn't link NAPI. #[allow(dead_code)] diff --git a/packages/cli/binding/src/main.rs b/packages/cli/binding/src/main.rs deleted file mode 100644 index 194642400b..0000000000 --- a/packages/cli/binding/src/main.rs +++ /dev/null @@ -1,27 +0,0 @@ -use vite_error::Error; -use vite_path::current_dir; - -mod cli; - -use cli::init_tracing; - -#[tokio::main] -async fn main() -> Result<(), Error> { - init_tracing(); - - // Pass None for args - main.rs uses env::args() directly - let result = cli::main(current_dir()?, None, None).await; - - match result { - Ok(exit_status) => std::process::exit(exit_status.0.into()), - - Err(err) => { - tracing::error!("Error: {}", err); - match err { - // Standard exit code for Ctrl+C - Error::UserCancelled => std::process::exit(130), - _ => return Err(err), - } - } - } -} diff --git a/packages/cli/snap-tests-global/cli-helper-message/snap.txt b/packages/cli/snap-tests-global/cli-helper-message/snap.txt index a0a370c3b7..b4380fde07 100644 --- a/packages/cli/snap-tests-global/cli-helper-message/snap.txt +++ b/packages/cli/snap-tests-global/cli-helper-message/snap.txt @@ -12,6 +12,7 @@ Core Commands: fmt Format code pack Build library run Run tasks + exec Execute a command from local node_modules/.bin preview Preview production build env Manage Node.js versions migrate Migrate an existing project to Vite+ diff --git a/packages/cli/snap-tests-global/command-exec/package.json b/packages/cli/snap-tests-global/command-exec/package.json new file mode 100644 index 0000000000..71b4413afb --- /dev/null +++ b/packages/cli/snap-tests-global/command-exec/package.json @@ -0,0 +1,5 @@ +{ + "name": "command-exec-pnpm10", + "version": "1.0.0", + "packageManager": "pnpm@10.19.0" +} diff --git a/packages/cli/snap-tests-global/command-exec/setup-bin.js b/packages/cli/snap-tests-global/command-exec/setup-bin.js new file mode 100644 index 0000000000..520ccabc06 --- /dev/null +++ b/packages/cli/snap-tests-global/command-exec/setup-bin.js @@ -0,0 +1,8 @@ +const fs = require('fs'); +fs.mkdirSync('node_modules/.bin', { recursive: true }); +fs.writeFileSync( + 'node_modules/.bin/hello-test', + '#!/usr/bin/env node\nconsole.log("hello from test-bin");\n', + { mode: 0o755 }, +); +fs.writeFileSync('node_modules/.bin/hello-test.cmd', '@node "%~dp0\\hello-test" %*\n'); diff --git a/packages/cli/snap-tests-global/command-exec/snap.txt b/packages/cli/snap-tests-global/command-exec/snap.txt new file mode 100644 index 0000000000..32ba00a13b --- /dev/null +++ b/packages/cli/snap-tests-global/command-exec/snap.txt @@ -0,0 +1,57 @@ +> node setup-bin.js +> vp exec hello-test # exec binary from node_modules/.bin +hello from test-bin + +> vp exec echo hello # basic exec +hello + +> vp exec -- echo with-separator # explicit -- separator +with-separator + +> vp exec node -e "console.log('hi')" # exec with args passthrough +hi + +> vp exec -c 'echo hello from shell' # shell mode +hello from shell + +> vp exec --help # should show help message +Execute a command from local node_modules/.bin + +Usage: vp exec [OPTIONS] [--] [args...] + +Arguments: + Command to execute from node_modules/.bin + [args...] Arguments to pass to the command + +Options: + -c, --shell-mode Execute the command within a shell environment + -r, --recursive Run in every workspace package + -w, --workspace-root Run on the workspace root package only + --include-workspace-root Include workspace root when running recursively + --filter Filter packages (can be used multiple times) + --parallel Run concurrently without topological ordering + --reverse Reverse execution order + --resume-from Resume from a specific package + --report-summary Save results to vp-exec-summary.json + -h, --help Print help + +Examples: + vp exec eslint . # Run local eslint + vp exec tsc --noEmit # Run local TypeScript compiler + vp exec -c 'eslint . && prettier --check .' # Shell mode + vp exec -r -- eslint . # Run in all workspace packages + vp exec --filter 'app...' -- tsc # Run in filtered packages + +[1]> vp exec # missing command should error +error: 'vp exec' requires a command to run + +Usage: vp exec [--] [args...] + +Examples: + vp exec eslint . + vp exec tsc --noEmit + +[1]> vp exec nonexistent-cmd-12345 # command not found error +error: Command 'nonexistent-cmd-12345' not found in node_modules/.bin + +Hint: Run 'vp install' to install dependencies, or use 'vpx' for remote fallback. diff --git a/packages/cli/snap-tests-global/command-exec/steps.json b/packages/cli/snap-tests-global/command-exec/steps.json new file mode 100644 index 0000000000..14f1809a97 --- /dev/null +++ b/packages/cli/snap-tests-global/command-exec/steps.json @@ -0,0 +1,16 @@ +{ + "env": { + "VITE_DISABLE_AUTO_INSTALL": "1" + }, + "commands": [ + { "command": "node setup-bin.js", "ignoreOutput": true }, + "vp exec hello-test # exec binary from node_modules/.bin", + "vp exec echo hello # basic exec", + "vp exec -- echo with-separator # explicit -- separator", + "vp exec node -e \"console.log('hi')\" # exec with args passthrough", + "vp exec -c 'echo hello from shell' # shell mode", + "vp exec --help # should show help message", + "vp exec # missing command should error", + "vp exec nonexistent-cmd-12345 # command not found error" + ] +} diff --git a/packages/cli/snap-tests/command-exec-monorepo-order/package.json b/packages/cli/snap-tests/command-exec-monorepo-order/package.json new file mode 100644 index 0000000000..03fc3bc78f --- /dev/null +++ b/packages/cli/snap-tests/command-exec-monorepo-order/package.json @@ -0,0 +1,6 @@ +{ + "name": "exec-monorepo-order", + "workspaces": [ + "packages/*" + ] +} diff --git a/packages/cli/snap-tests/command-exec-monorepo-order/packages/app-mobile/package.json b/packages/cli/snap-tests/command-exec-monorepo-order/packages/app-mobile/package.json new file mode 100644 index 0000000000..ddae79da24 --- /dev/null +++ b/packages/cli/snap-tests/command-exec-monorepo-order/packages/app-mobile/package.json @@ -0,0 +1,6 @@ +{ + "name": "app-mobile", + "dependencies": { + "lib-ui": "workspace:*" + } +} diff --git a/packages/cli/snap-tests/command-exec-monorepo-order/packages/app-web/package.json b/packages/cli/snap-tests/command-exec-monorepo-order/packages/app-web/package.json new file mode 100644 index 0000000000..e61f89ef10 --- /dev/null +++ b/packages/cli/snap-tests/command-exec-monorepo-order/packages/app-web/package.json @@ -0,0 +1,7 @@ +{ + "name": "app-web", + "dependencies": { + "lib-ui": "workspace:*", + "lib-utils": "workspace:*" + } +} diff --git a/packages/cli/snap-tests/command-exec-monorepo-order/packages/cycle-a/package.json b/packages/cli/snap-tests/command-exec-monorepo-order/packages/cycle-a/package.json new file mode 100644 index 0000000000..b2e53bfe0f --- /dev/null +++ b/packages/cli/snap-tests/command-exec-monorepo-order/packages/cycle-a/package.json @@ -0,0 +1,6 @@ +{ + "name": "cycle-a", + "dependencies": { + "cycle-b": "workspace:*" + } +} diff --git a/packages/cli/snap-tests/command-exec-monorepo-order/packages/cycle-b/package.json b/packages/cli/snap-tests/command-exec-monorepo-order/packages/cycle-b/package.json new file mode 100644 index 0000000000..bb1f1e1107 --- /dev/null +++ b/packages/cli/snap-tests/command-exec-monorepo-order/packages/cycle-b/package.json @@ -0,0 +1,6 @@ +{ + "name": "cycle-b", + "dependencies": { + "cycle-a": "workspace:*" + } +} diff --git a/packages/cli/snap-tests/command-exec-monorepo-order/packages/cycle-c/package.json b/packages/cli/snap-tests/command-exec-monorepo-order/packages/cycle-c/package.json new file mode 100644 index 0000000000..86874f25a1 --- /dev/null +++ b/packages/cli/snap-tests/command-exec-monorepo-order/packages/cycle-c/package.json @@ -0,0 +1,6 @@ +{ + "name": "cycle-c", + "dependencies": { + "cycle-d": "workspace:*" + } +} diff --git a/packages/cli/snap-tests/command-exec-monorepo-order/packages/cycle-d/package.json b/packages/cli/snap-tests/command-exec-monorepo-order/packages/cycle-d/package.json new file mode 100644 index 0000000000..dfe446faba --- /dev/null +++ b/packages/cli/snap-tests/command-exec-monorepo-order/packages/cycle-d/package.json @@ -0,0 +1,6 @@ +{ + "name": "cycle-d", + "dependencies": { + "cycle-e": "workspace:*" + } +} diff --git a/packages/cli/snap-tests/command-exec-monorepo-order/packages/cycle-e/package.json b/packages/cli/snap-tests/command-exec-monorepo-order/packages/cycle-e/package.json new file mode 100644 index 0000000000..b94fd1f8ad --- /dev/null +++ b/packages/cli/snap-tests/command-exec-monorepo-order/packages/cycle-e/package.json @@ -0,0 +1,6 @@ +{ + "name": "cycle-e", + "dependencies": { + "cycle-c": "workspace:*" + } +} diff --git a/packages/cli/snap-tests/command-exec-monorepo-order/packages/lib-core/package.json b/packages/cli/snap-tests/command-exec-monorepo-order/packages/lib-core/package.json new file mode 100644 index 0000000000..7c0d80d6e7 --- /dev/null +++ b/packages/cli/snap-tests/command-exec-monorepo-order/packages/lib-core/package.json @@ -0,0 +1,3 @@ +{ + "name": "lib-core" +} diff --git a/packages/cli/snap-tests/command-exec-monorepo-order/packages/lib-ui/package.json b/packages/cli/snap-tests/command-exec-monorepo-order/packages/lib-ui/package.json new file mode 100644 index 0000000000..4112db43c7 --- /dev/null +++ b/packages/cli/snap-tests/command-exec-monorepo-order/packages/lib-ui/package.json @@ -0,0 +1,7 @@ +{ + "name": "lib-ui", + "dependencies": { + "lib-core": "workspace:*", + "lib-utils": "workspace:*" + } +} diff --git a/packages/cli/snap-tests/command-exec-monorepo-order/packages/lib-utils/package.json b/packages/cli/snap-tests/command-exec-monorepo-order/packages/lib-utils/package.json new file mode 100644 index 0000000000..cb51653871 --- /dev/null +++ b/packages/cli/snap-tests/command-exec-monorepo-order/packages/lib-utils/package.json @@ -0,0 +1,6 @@ +{ + "name": "lib-utils", + "dependencies": { + "lib-core": "workspace:*" + } +} diff --git a/packages/cli/snap-tests/command-exec-monorepo-order/snap.txt b/packages/cli/snap-tests/command-exec-monorepo-order/snap.txt new file mode 100644 index 0000000000..37f80fb4b0 --- /dev/null +++ b/packages/cli/snap-tests/command-exec-monorepo-order/snap.txt @@ -0,0 +1,73 @@ +> vp exec -r -- node -e "console.log(process.env.VITE_PLUS_PACKAGE_NAME)" # recursive: topological order +lib-core$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +lib-core +lib-utils$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +lib-utils +lib-ui$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +lib-ui +app-mobile$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +app-mobile +app-web$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +app-web +cycle-a$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +cycle-a +cycle-b$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +cycle-b +cycle-c$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +cycle-c +cycle-e$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +cycle-e +cycle-d$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +cycle-d + +> vp exec --filter 'app-web...' -- node -e "console.log(process.env.VITE_PLUS_PACKAGE_NAME)" # filter with transitive deps +lib-core$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +lib-core +lib-utils$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +lib-utils +lib-ui$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +lib-ui +app-web$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +app-web + +> vp exec --filter 'lib-ui...' -- node -e "console.log(process.env.VITE_PLUS_PACKAGE_NAME)" # filter mid-graph with deps +lib-core$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +lib-core +lib-utils$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +lib-utils +lib-ui$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +lib-ui + +> vp exec --filter '...lib-core' -- node -e "console.log(process.env.VITE_PLUS_PACKAGE_NAME)" # filter dependents of foundation +lib-core$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +lib-core +lib-utils$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +lib-utils +lib-ui$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +lib-ui +app-mobile$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +app-mobile +app-web$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +app-web + +> vp exec -r --reverse -- node -e "console.log(process.env.VITE_PLUS_PACKAGE_NAME)" # reverse topological order +cycle-d$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +cycle-d +cycle-e$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +cycle-e +cycle-c$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +cycle-c +cycle-b$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +cycle-b +cycle-a$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +cycle-a +app-web$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +app-web +app-mobile$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +app-mobile +lib-ui$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +lib-ui +lib-utils$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +lib-utils +lib-core$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +lib-core diff --git a/packages/cli/snap-tests/command-exec-monorepo-order/steps.json b/packages/cli/snap-tests/command-exec-monorepo-order/steps.json new file mode 100644 index 0000000000..2597c228a7 --- /dev/null +++ b/packages/cli/snap-tests/command-exec-monorepo-order/steps.json @@ -0,0 +1,13 @@ +{ + "ignoredPlatforms": ["win32"], + "env": { + "VITE_DISABLE_AUTO_INSTALL": "1" + }, + "commands": [ + "vp exec -r -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # recursive: topological order", + "vp exec --filter 'app-web...' -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # filter with transitive deps", + "vp exec --filter 'lib-ui...' -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # filter mid-graph with deps", + "vp exec --filter '...lib-core' -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # filter dependents of foundation", + "vp exec -r --reverse -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # reverse topological order" + ] +} diff --git a/packages/cli/snap-tests/command-exec-monorepo/package.json b/packages/cli/snap-tests/command-exec-monorepo/package.json new file mode 100644 index 0000000000..4e316f7662 --- /dev/null +++ b/packages/cli/snap-tests/command-exec-monorepo/package.json @@ -0,0 +1,6 @@ +{ + "name": "exec-monorepo", + "workspaces": [ + "packages/*" + ] +} diff --git a/packages/cli/snap-tests/command-exec-monorepo/packages/app-a/package.json b/packages/cli/snap-tests/command-exec-monorepo/packages/app-a/package.json new file mode 100644 index 0000000000..276db21865 --- /dev/null +++ b/packages/cli/snap-tests/command-exec-monorepo/packages/app-a/package.json @@ -0,0 +1,6 @@ +{ + "name": "app-a", + "dependencies": { + "lib-c": "workspace:*" + } +} diff --git a/packages/cli/snap-tests/command-exec-monorepo/packages/app-b/package.json b/packages/cli/snap-tests/command-exec-monorepo/packages/app-b/package.json new file mode 100644 index 0000000000..ffb3b06a3a --- /dev/null +++ b/packages/cli/snap-tests/command-exec-monorepo/packages/app-b/package.json @@ -0,0 +1,3 @@ +{ + "name": "app-b" +} diff --git a/packages/cli/snap-tests/command-exec-monorepo/packages/lib-c/package.json b/packages/cli/snap-tests/command-exec-monorepo/packages/lib-c/package.json new file mode 100644 index 0000000000..f403884982 --- /dev/null +++ b/packages/cli/snap-tests/command-exec-monorepo/packages/lib-c/package.json @@ -0,0 +1,3 @@ +{ + "name": "lib-c" +} diff --git a/packages/cli/snap-tests/command-exec-monorepo/snap.txt b/packages/cli/snap-tests/command-exec-monorepo/snap.txt new file mode 100644 index 0000000000..bb57e86f5a --- /dev/null +++ b/packages/cli/snap-tests/command-exec-monorepo/snap.txt @@ -0,0 +1,146 @@ +> vp exec -r -- node -e "console.log(process.env.VITE_PLUS_PACKAGE_NAME)" # recursive exec with env var +app-b$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +app-b +lib-c$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +lib-c +app-a$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +app-a + +> vp exec --filter app-* -- node -e "console.log(process.env.VITE_PLUS_PACKAGE_NAME)" # glob filter +app-a$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +app-a +app-b$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +app-b + +> vp exec --filter lib-c -- echo lib-only # exact name filter +lib-c$ echo lib-only +lib-only + +> vp exec --filter app-a... -- node -e "console.log(process.env.VITE_PLUS_PACKAGE_NAME)" # filter with dependencies +lib-c$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +lib-c +app-a$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +app-a + +> vp exec --filter app-a^... -- node -e "console.log(process.env.VITE_PLUS_PACKAGE_NAME)" # dependencies only, exclude self +lib-c$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +lib-c + +> vp exec -r --parallel -- node -e "console.log(process.env.VITE_PLUS_PACKAGE_NAME)" # parallel mode +app-b$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +app-b +lib-c$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +lib-c +app-a$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +app-a + +> vp exec -r -c 'echo shell-$VITE_PLUS_PACKAGE_NAME' # recursive + shell mode +app-b$ echo shell-$VITE_PLUS_PACKAGE_NAME +shell-app-b +lib-c$ echo shell-$VITE_PLUS_PACKAGE_NAME +shell-lib-c +app-a$ echo shell-$VITE_PLUS_PACKAGE_NAME +shell-app-a + +> vp exec --filter ./packages/app-a -- node -e "console.log(process.env.VITE_PLUS_PACKAGE_NAME)" # path filter +app-a$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +app-a + +> vp exec --filter '{./packages/app-a}' -- node -e "console.log(process.env.VITE_PLUS_PACKAGE_NAME)" # braced path filter +app-a$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +app-a + +> vp exec --filter '{./packages/app-a}...' -- node -e "console.log(process.env.VITE_PLUS_PACKAGE_NAME)" # braced path filter with deps +lib-c$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +lib-c +app-a$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +app-a + +> vp exec -r --reverse -- node -e "console.log(process.env.VITE_PLUS_PACKAGE_NAME)" # reverse order +app-a$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +app-a +lib-c$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +lib-c +app-b$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +app-b + +> vp exec -r --resume-from lib-c -- node -e "console.log(process.env.VITE_PLUS_PACKAGE_NAME)" # resume from +lib-c$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +lib-c +app-a$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +app-a + +> vp exec --filter '!app-b' -- node -e "console.log(process.env.VITE_PLUS_PACKAGE_NAME)" # exclusion-only filter +lib-c$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +lib-c +app-a$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +app-a + +> vp exec --filter '!app-a' --filter '!app-b' -- node -e "console.log(process.env.VITE_PLUS_PACKAGE_NAME)" # multiple exclusion-only filters +lib-c$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +lib-c + +> vp exec --filter app-a --filter lib-c -- node -e "console.log(process.env.VITE_PLUS_PACKAGE_NAME)" # multiple inclusion filters (union) +lib-c$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +lib-c +app-a$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +app-a + +> vp exec --filter 'app-*' --filter '!app-b' -- node -e "console.log(process.env.VITE_PLUS_PACKAGE_NAME)" # mixed inclusion + exclusion filters +app-a$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +app-a + +> vp exec --filter '!no-such-pkg' -- node -e "console.log(process.env.VITE_PLUS_PACKAGE_NAME)" # exclusion of nonexistent pkg returns all +app-b$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +app-b +lib-c$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +lib-c +app-a$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +app-a + +> vp exec --filter='app-*' -- node -e "console.log(process.env.VITE_PLUS_PACKAGE_NAME)" # equals-form filter +app-a$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +app-a +app-b$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +app-b + +> vp exec --filter '*' -- node -e "console.log(process.env.VITE_PLUS_PACKAGE_NAME)" # glob star includes root +app-b$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +app-b +exec-monorepo$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +exec-monorepo +lib-c$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +lib-c +app-a$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +app-a + +> vp exec -w -- node -e "console.log(process.env.VITE_PLUS_PACKAGE_NAME)" # workspace root only +exec-monorepo$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +exec-monorepo + +> vp exec -r --include-workspace-root -- node -e "console.log(process.env.VITE_PLUS_PACKAGE_NAME)" # recursive with workspace root +app-b$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +app-b +exec-monorepo$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +exec-monorepo +lib-c$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +lib-c +app-a$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +app-a + +> vp exec -w --include-workspace-root -- node -e "console.log(process.env.VITE_PLUS_PACKAGE_NAME)" # -w wins over --include-workspace-root (root only) +exec-monorepo$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +exec-monorepo + +> vp exec -r --report-summary -- node -e "console.log(process.env.VITE_PLUS_PACKAGE_NAME)" # report summary +app-b$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +app-b +lib-c$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +lib-c +app-a$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME) +app-a + +> node -e "const r=JSON.parse(require('fs').readFileSync('vp-exec-summary.json','utf8'));const s=r.executionStatus;for(const[k,v]of Object.entries(s)){console.log(k+': '+v.status+' '+(typeof v.duration==='number'?'has_duration':'no_duration'))}" # verify summary file +app-a: passed has_duration +app-b: passed has_duration +lib-c: passed has_duration diff --git a/packages/cli/snap-tests/command-exec-monorepo/steps.json b/packages/cli/snap-tests/command-exec-monorepo/steps.json new file mode 100644 index 0000000000..b554a5db94 --- /dev/null +++ b/packages/cli/snap-tests/command-exec-monorepo/steps.json @@ -0,0 +1,32 @@ +{ + "ignoredPlatforms": ["win32"], + "env": { + "VITE_DISABLE_AUTO_INSTALL": "1" + }, + "commands": [ + "vp exec -r -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # recursive exec with env var", + "vp exec --filter app-* -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # glob filter", + "vp exec --filter lib-c -- echo lib-only # exact name filter", + "vp exec --filter app-a... -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # filter with dependencies", + "vp exec --filter app-a^... -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # dependencies only, exclude self", + "vp exec -r --parallel -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # parallel mode", + "vp exec -r -c 'echo shell-$VITE_PLUS_PACKAGE_NAME' # recursive + shell mode", + "vp exec --filter ./packages/app-a -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # path filter", + "vp exec --filter '{./packages/app-a}' -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # braced path filter", + "vp exec --filter '{./packages/app-a}...' -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # braced path filter with deps", + "vp exec -r --reverse -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # reverse order", + "vp exec -r --resume-from lib-c -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # resume from", + "vp exec --filter '!app-b' -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # exclusion-only filter", + "vp exec --filter '!app-a' --filter '!app-b' -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # multiple exclusion-only filters", + "vp exec --filter app-a --filter lib-c -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # multiple inclusion filters (union)", + "vp exec --filter 'app-*' --filter '!app-b' -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # mixed inclusion + exclusion filters", + "vp exec --filter '!no-such-pkg' -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # exclusion of nonexistent pkg returns all", + "vp exec --filter='app-*' -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # equals-form filter", + "vp exec --filter '*' -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # glob star includes root", + "vp exec -w -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # workspace root only", + "vp exec -r --include-workspace-root -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # recursive with workspace root", + "vp exec -w --include-workspace-root -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # -w wins over --include-workspace-root (root only)", + "vp exec -r --report-summary -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # report summary", + "node -e \"const r=JSON.parse(require('fs').readFileSync('vp-exec-summary.json','utf8'));const s=r.executionStatus;for(const[k,v]of Object.entries(s)){console.log(k+': '+v.status+' '+(typeof v.duration==='number'?'has_duration':'no_duration'))}\" # verify summary file" + ] +} diff --git a/packages/cli/snap-tests/command-exec/package.json b/packages/cli/snap-tests/command-exec/package.json new file mode 100644 index 0000000000..b48ed99a48 --- /dev/null +++ b/packages/cli/snap-tests/command-exec/package.json @@ -0,0 +1,5 @@ +{ + "scripts": { + "foo": "vp exec node -e \"console.log(5173)\"" + } +} diff --git a/packages/cli/snap-tests/command-exec/setup-bin.js b/packages/cli/snap-tests/command-exec/setup-bin.js new file mode 100644 index 0000000000..520ccabc06 --- /dev/null +++ b/packages/cli/snap-tests/command-exec/setup-bin.js @@ -0,0 +1,8 @@ +const fs = require('fs'); +fs.mkdirSync('node_modules/.bin', { recursive: true }); +fs.writeFileSync( + 'node_modules/.bin/hello-test', + '#!/usr/bin/env node\nconsole.log("hello from test-bin");\n', + { mode: 0o755 }, +); +fs.writeFileSync('node_modules/.bin/hello-test.cmd', '@node "%~dp0\\hello-test" %*\n'); diff --git a/packages/cli/snap-tests/command-exec/snap.txt b/packages/cli/snap-tests/command-exec/snap.txt new file mode 100644 index 0000000000..d81aa98bea --- /dev/null +++ b/packages/cli/snap-tests/command-exec/snap.txt @@ -0,0 +1,62 @@ +> node setup-bin.js +> vp exec hello-test # exec binary from node_modules/.bin +hello from test-bin + +> vp exec echo hello # basic exec +hello + +> vp exec -- echo with-separator # explicit -- separator +with-separator + +> vp exec node -e "console.log('from node')" # exec node with args +from node + +> vp exec -c 'echo hello from shell' # shell mode +hello from shell + +> vp exec --help # help message +Execute a command from local node_modules/.bin + +Usage: vp exec [OPTIONS] [--] [args...] + +Arguments: + Command to execute from node_modules/.bin + [args...] Arguments to pass to the command + +Options: + -c, --shell-mode Execute the command within a shell environment + -r, --recursive Run in every workspace package + -w, --workspace-root Run on the workspace root package only + --include-workspace-root Include workspace root when running recursively + --filter Filter packages (can be used multiple times) + --parallel Run concurrently without topological ordering + --reverse Reverse execution order + --resume-from Resume from a specific package + --report-summary Save results to vp-exec-summary.json + -h, --help Print help + +Examples: + vp exec eslint . # Run local eslint + vp exec tsc --noEmit # Run local TypeScript compiler + vp exec -c 'eslint . && prettier --check .' # Shell mode + vp exec -r -- eslint . # Run in all workspace packages + vp exec --filter 'app...' -- tsc # Run in filtered packages + +[1]> vp exec # missing command should error +error: 'vp exec' requires a command to run + +Usage: vp exec [--] [args...] + +Examples: + vp exec eslint . + vp exec tsc --noEmit + +[1]> vp exec nonexistent-cmd-12345 # command not found error +error: Command 'nonexistent-cmd-12345' not found in node_modules/.bin + +Hint: Run 'vp install' to install dependencies, or use 'vpx' for remote fallback. + +> vp run foo # vp exec works in package.json scripts +$ vp exec node -e "console.log(5173)" ⊘ cache disabled +5173 + diff --git a/packages/cli/snap-tests/command-exec/steps.json b/packages/cli/snap-tests/command-exec/steps.json new file mode 100644 index 0000000000..412429e5d2 --- /dev/null +++ b/packages/cli/snap-tests/command-exec/steps.json @@ -0,0 +1,17 @@ +{ + "env": { + "VITE_DISABLE_AUTO_INSTALL": "1" + }, + "commands": [ + { "command": "node setup-bin.js", "ignoreOutput": true }, + "vp exec hello-test # exec binary from node_modules/.bin", + "vp exec echo hello # basic exec", + "vp exec -- echo with-separator # explicit -- separator", + "vp exec node -e \"console.log('from node')\" # exec node with args", + "vp exec -c 'echo hello from shell' # shell mode", + "vp exec --help # help message", + "vp exec # missing command should error", + "vp exec nonexistent-cmd-12345 # command not found error", + "vp run foo # vp exec works in package.json scripts" + ] +} diff --git a/packages/cli/snap-tests/command-helper/snap.txt b/packages/cli/snap-tests/command-helper/snap.txt index ba95c3aad1..c2c2440c78 100644 --- a/packages/cli/snap-tests/command-helper/snap.txt +++ b/packages/cli/snap-tests/command-helper/snap.txt @@ -11,6 +11,7 @@ Vite+/ fmt Format code pack Build library run Run tasks + exec Execute a command from local node_modules/.bin preview Preview production build cache Manage the task cache diff --git a/packages/cli/snap-tests/command-vp-alias/snap.txt b/packages/cli/snap-tests/command-vp-alias/snap.txt index 8b3c89b0c1..c6293a591d 100644 --- a/packages/cli/snap-tests/command-vp-alias/snap.txt +++ b/packages/cli/snap-tests/command-vp-alias/snap.txt @@ -11,6 +11,7 @@ Vite+/ fmt Format code pack Build library run Run tasks + exec Execute a command from local node_modules/.bin preview Preview production build cache Manage the task cache diff --git a/rfcs/exec-command.md b/rfcs/exec-command.md new file mode 100644 index 0000000000..48713522d9 --- /dev/null +++ b/rfcs/exec-command.md @@ -0,0 +1,621 @@ +# RFC: `vp exec` Command + +## Summary + +Add `vp exec` as a subcommand that prepends `./node_modules/.bin` to PATH and executes a command. This is the equivalent of `pnpm exec`. + +The command completes the execution story alongside existing commands: + +| Command | Behavior | Analogy | +| ------------- | -------------------------------------------------------------- | --------------- | +| `vp dlx` | Always downloads from remote | `pnpm dlx` | +| `vpx` | Local → global → PATH → remote fallback | `npx` | +| **`vp exec`** | **Prepend `node_modules/.bin` to PATH, then execute normally** | **`pnpm exec`** | + +## Motivation + +Currently, to run a command with `node_modules/.bin` on PATH, developers must use `vpx` (which has global/remote fallback) or call `./node_modules/.bin/` directly. There is no simple way to prepend the local bin directory to PATH and execute — the behavior that `pnpm exec` provides. + +### Why `vp exec` Is Needed + +1. **No remote fallback**: Unlike `vpx`, `vp exec` never downloads from the registry — commands resolve via `node_modules/.bin` + existing PATH only +2. **Workspace iteration**: `pnpm exec --recursive` runs a command in every workspace package — `vpx` doesn't support this +3. **pnpm exec parity**: Projects migrating from pnpm expect `exec` to exist as a subcommand +4. **Explicit intent**: `vp exec` means "run with local bins on PATH" vs `vpx` which means "find it anywhere, download if needed" + +### Current Pain Points + +```bash +# Developer wants to run with node_modules/.bin on PATH, no remote fallback +vpx eslint . # Has remote fallback — may download unexpectedly +./node_modules/.bin/eslint . # Verbose, not portable + +# Developer wants to run a command in every workspace package +pnpm exec --recursive -- eslint . # Works with pnpm +# No vp equivalent exists today +``` + +### Proposed Solution + +```bash +# Run with node_modules/.bin on PATH (no remote fallback) +vp exec eslint . + +# Run in every workspace package +vp exec --recursive -- eslint . + +# Shell mode +vp exec -c 'echo $PATH' +``` + +## Command Syntax + +```bash +vp exec [OPTIONS] [--] [args...] +``` + +The leading `--` is optional and stripped for backward compatibility (matching pnpm exec behavior). + +**Options:** + +- `--shell-mode, -c` — Execute within a shell environment (`/bin/sh` on UNIX, `cmd.exe` on Windows) +- `--recursive, -r` — Run in every workspace package (local CLI only) +- `--workspace-root, -w` — Run on the workspace root package only (local CLI only) +- `--include-workspace-root` — Include workspace root when running recursively (local CLI only) +- `--filter ` — Filter packages by name pattern or relative path (local CLI only); also accepts `--filter=` form +- `--parallel` — Run concurrently without topological sort (local CLI only) +- `--reverse` — Reverse topological order (local CLI only) +- `--resume-from ` — Resume from a specific package (local CLI only); also accepts `--resume-from=` form +- `--report-summary` — Save results to `vp-exec-summary.json` (local CLI only) + +### Usage Examples + +```bash +# Basic: run locally installed binary +vp exec eslint . + +# With arguments +vp exec tsc --noEmit + +# Shell mode (pipe commands, expand variables) +vp exec -c 'echo $PATH' +vp exec -c 'eslint . && prettier --check .' + +# Run in every workspace package +vp exec -r -- eslint . + +# Filter to specific packages +vp exec --filter 'app...' -- tsc --noEmit + +# Filter by relative path +vp exec --filter ./packages/app-a -- tsc --noEmit + +# Braced path filter with dependency traversal +vp exec --filter '{./packages/app-a}...' -- tsc --noEmit + +# Run in parallel (no topological ordering) +vp exec -r --parallel -- eslint . + +# Resume from a specific package (after failure) +vp exec -r --resume-from @my/app -- tsc --noEmit + +# Run on workspace root only +vp exec -w -- node -e "console.log(process.env.VITE_PLUS_PACKAGE_NAME)" + +# Recursive including workspace root +vp exec -r --include-workspace-root -- eslint . + +# Save execution summary +vp exec -r --report-summary -- vitest run +``` + +## Filter Selector Syntax + +The `--filter` flag supports pnpm-compatible selectors: + +**Name patterns:** + +- `app-a` — exact package name +- `app-*` — glob pattern matching package names +- `@myorg/*` — scoped package glob + +**Path selectors** (detected by leading `.` or `..`): + +- `./packages/app-a` — match packages whose directory is at or under this path +- `../other-pkg` — relative path from cwd + +**Braced path selectors** (pnpm-compatible syntax): + +- `{./packages/app-a}` — equivalent to `./packages/app-a` +- `{./packages/app-a}...` — path with dependency traversal +- `...{./packages/app-a}` — path with dependent traversal +- `app-*{./packages}` — combined name pattern + path filter (match by path first, then filter by name) + +**Modifiers:** + +- `...` — include the package and all its transitive dependencies +- `...` — include the package and all packages that depend on it +- `^...` — only dependencies, exclude the matched package itself +- `...^` — only dependents, exclude the matched package itself +- `!` — exclude matched packages from the result set + +Modifiers work with name patterns (e.g., `app-a...`) and braced path selectors (e.g., `{./packages/app-a}...`). Unbraced path selectors (e.g., `./packages/app-a`) do not support traversal modifiers. + +**Exclusion-only filters**: When all selectors are exclusion-only (e.g., `--filter '!app-b'`), the result is all non-root workspace packages minus the excluded ones. This matches pnpm behavior — exclusion without an explicit inclusion implies "start with everything". + +**Workspace root inclusion rules**: + +- `-r` (recursive) excludes the workspace root by default +- `-r --include-workspace-root` includes the workspace root along with all workspace packages +- `-w` (workspace root) runs on the workspace root package only +- `--filter '*'` includes the workspace root because `*` name-matches all packages including root + +## Core Behavior + +Based on pnpm exec behavior (reference: `exec/plugin-commands-script-runners/src/exec.ts`): + +1. **Prepend `./node_modules/.bin`** (and extra bin paths from the package manager) to `PATH` +2. **Strip leading `--`** from the command for backward compatibility +3. **Execute command** via process spawn with `stdio: inherit` — the command resolves through the modified PATH (local bins first, then system PATH) +4. **Shell mode**: When `-c` is specified, pass `shell: true` to the child process +5. **Set `VITE_PLUS_PACKAGE_NAME`** env var with the current package name (analogous to pnpm's `PNPM_PACKAGE_NAME`) +6. **Error if no command**: `'vp exec' requires a command to run` + +## Relationship Between Commands + +| Behavior | `vp exec` | `vpx` | `vp dlx` | +| -------------------- | -------------------------------- | --------------------------- | -------------- | +| Prepend to PATH | `./node_modules/.bin` (cwd only) | Walk up `node_modules/.bin` | No | +| Global vp pkg lookup | No | Yes | No | +| System PATH | Yes (after `node_modules/.bin`) | Yes | No | +| Remote download | No | Yes (fallback) | Always | +| Workspace iteration | Yes (`-r`, `--filter`) | No | No | +| Shell mode | Yes (`-c`) | Yes (`-c`) | Yes (`-c`) | +| Use case | Run with local bins on PATH | Run any tool, find it | Download & run | + +### 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. + +## Implementation Architecture + +### Global CLI + +**File**: `crates/vite_global_cli/src/cli.rs` + +The `Exec` variant in `Commands` enum (Category C) unconditionally delegates to the local CLI: + +```rust +// Category C: Local CLI Delegation +/// Execute a command from local node_modules/.bin +#[command(disable_help_flag = true)] +Exec { + /// Additional arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, +}, +``` + +Route in `execute_command()`: + +```rust +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. + +### Local CLI + +**Module**: `packages/cli/binding/src/exec/` + +The local CLI receives the `exec` command via delegation from the global CLI (same mechanism as `run`, `build`, etc.). The exec logic is organized into a dedicated module with submodules: + +``` +packages/cli/binding/src/exec/ +├── mod.rs — entry point (execute), help text, command builder +├── args.rs — ExecFlags struct, parse_exec_args() +├── filter.rs — PackageSelector, parse_package_selector(), filter_packages() +└── workspace.rs — execute_exec_workspace() for --recursive/--filter mode +``` + +The local CLI has full workspace awareness and can handle: + +- `--recursive` — iterate workspace packages with topological sort +- `--filter` — filter packages by selector +- `--parallel` — run concurrently +- `--reverse` — reverse topological order +- `--resume-from` — resume from specific package +- `--report-summary` — save results JSON + +For the local CLI, exec uses the workspace package graph to iterate packages, prepending each package's `node_modules/.bin` to PATH before spawning the command in that package's directory. + +### Reusable Code + +The following existing code is reused: + +| Module | Function | Purpose | +| ----------------- | ------------------------------------ | --------------------------------------------- | +| `vpx.rs` | `find_local_binary()` | Check if binary exists in `node_modules/.bin` | +| `vpx.rs` | `prepend_node_modules_bin_to_path()` | PATH manipulation for `node_modules/.bin` | +| `vite_shared` | `prepend_to_path_env()` | Generic PATH prepend | +| `commands/mod.rs` | `has_vite_plus_dependency()` | Check for local vite-plus | +| `commands/mod.rs` | `prepend_js_runtime_to_path_env()` | Ensure Node.js in fallback path | + +## Design Decisions + +### 1. Unconditional Delegation (No 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. + +**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 + +### 2. No Directory Walk-Up (Unlike vpx) + +**Decision**: `vp exec` only checks `./node_modules/.bin` in the current directory, not parent directories. + +**Rationale**: + +- Matches `pnpm exec` behavior — strict local scope +- In workspace iteration (`-r`), each package should use its own `node_modules/.bin` +- 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 + +**Decision**: `--recursive`, `--workspace-root`, `--include-workspace-root`, `--filter`, `--parallel`, `--reverse`, `--resume-from`, and `--report-summary` only work when vite-plus is a local dependency (local CLI handles them). + +**Rationale**: + +- These features require workspace awareness from vite-task infrastructure +- The global CLI fallback is for simple, single-directory exec +- This is consistent with how `vp run` handles workspace features + +### 4. Same Env Var Convention + +**Decision**: Set `VITE_PLUS_PACKAGE_NAME` env var when executing in a workspace package. + +**Rationale**: + +- Follows pnpm's `PNPM_PACKAGE_NAME` convention +- Allows scripts to know which package they're running in +- Consistent naming with vite-plus branding + +### 5. Strip Leading `--` + +**Decision**: Automatically strip a leading `--` from the command arguments. + +**Rationale**: + +- Matches pnpm exec backward compatibility behavior +- `vp exec -- eslint .` and `vp exec eslint .` should behave identically +- Reduces friction for users coming from pnpm + +### 6. Execution Ordering + +**Decision**: When `--recursive` or `--filter` is used, packages execute in topological order (dependencies first). Packages with no ordering constraint are sorted alphabetically for determinism. + +**Rationale**: + +- **Topological ordering by default**: Commands like `tsc --noEmit` or `build` need dependencies to complete before dependents. Running in dependency order ensures correctness without requiring users to specify `--topological` explicitly. +- **Deterministic tie-breaking**: Packages with no ordering constraint between them (e.g., two unrelated leaf packages) are sorted alphabetically by name for consistent, reproducible behavior across runs. +- **`--parallel` skips ordering**: In parallel mode, all packages are spawned concurrently — topological order only affects the order of output collection. +- **`--reverse`**: Reverses the topological order (dependents first, then dependencies). Useful for cleanup operations. +- **Circular dependency handling**: When workspace packages have circular dependencies, strict topological sorting is impossible. The algorithm uses Kahn's algorithm which naturally detects cycles — packages involved in cycles will never reach zero in-degree. When cycles are detected, the algorithm iteratively breaks them by force-adding the alphabetically-first remaining node, then continuing Kahn's to correctly order any dependents that become unblocked. + + **Example — normal dependency chain (no cycle):** + + ``` + a → b → c → d → e (a depends on b, b depends on c, ...) + + Kahn's: e has in-degree 0, start there + → Process e → d's in-degree drops to 0 + → Process d → c's in-degree drops to 0 + → Process c → b's in-degree drops to 0 + → Process b → a's in-degree drops to 0 + → Process a + Result: [e, d, c, b, a] + ``` + + Dependencies are executed first — standard topological order, no cycle-breaking needed. + + **Example — simple cycle:** + + ``` + a ←→ b (mutual dependency) + + Kahn's: neither a nor b reaches 0 in-degree + → Force 'a' (alphabetically first) + → b's in-degree drops to 0, process b + Result: [a, b] + ``` + + **Example — cycle with a non-cyclic dependent:** + + ``` + a ←→ b ← aa (a↔b cycle, aa depends on b) + + Kahn's: all three stuck (a:1, b:1, aa:1) + → Force 'a' → b's in-degree drops to 0 + → Process b → aa's in-degree drops to 0 + → Process aa + Result: [a, b, aa] + ``` + + Without iterative cycle-breaking, all three would be appended alphabetically as `[a, aa, b]` — placing `aa` before its dependency `b`. + + **Example — 3-node cycle:** + + ``` + c → d → e → c (circular chain) + + Kahn's: all stuck at in-degree 1 + → Force 'c' → e's in-degree drops to 0 + → Process e → d's in-degree drops to 0 + → Process d + Result: [c, e, d] + ``` + + **Example — 5-node indirect cycle:** + + ``` + a → b → c → d → e → a (indirect circular chain) + + Kahn's: all stuck at in-degree 1 + → Force 'a' → e's in-degree drops to 0 + → Process e → d's in-degree drops to 0 + → Process d → c's in-degree drops to 0 + → Process c → b's in-degree drops to 0 + → Process b + Result: [a, e, d, c, b] + ``` + + A single force-break at the alphabetically-first node unravels the entire chain in reverse dependency order. + +- **Platform-safe PATH construction**: PATH environment variable is constructed using `std::env::join_paths()` instead of hardcoded `:` separator, ensuring correct behavior on both Unix (`:`) and Windows (`;`). + +## CLI Help Output + +```bash +$ vp exec --help +Execute a command from local node_modules/.bin + +Usage: vp exec [OPTIONS] [--] [args...] + +Arguments: + Command to execute from node_modules/.bin + [args...] Arguments to pass to the command + +Options: + -c, --shell-mode Execute the command within a shell environment + -r, --recursive Run in every workspace package + -w, --workspace-root Run on the workspace root package only + --include-workspace-root Include workspace root when running recursively + --filter Filter packages (can be used multiple times) + --parallel Run concurrently without topological ordering + --reverse Reverse execution order + --resume-from Resume from a specific package + --report-summary Save results to vp-exec-summary.json + -h, --help Print help + +Examples: + vp exec eslint . # Run local eslint + vp exec tsc --noEmit # Run local TypeScript compiler + vp exec -c 'eslint . && prettier --check .' # Shell mode + vp exec -r -- eslint . # Run in all workspace packages + vp exec --filter 'app...' -- tsc # Run in filtered packages +``` + +## Error Handling + +### Missing Command + +```bash +$ vp exec +Error: 'vp exec' requires a command to run + +Usage: vp exec [--] [args...] + +Examples: + vp exec eslint . + vp exec tsc --noEmit +``` + +### Command Not Found + +```bash +$ vp exec nonexistent-cmd +Error: Command 'nonexistent-cmd' not found + +Hint: Run 'vp install' to install dependencies, or use 'vpx' for remote fallback. +``` + +## Snap Tests + +### Global CLI Test: `command-exec-pnpm10` + +**Location**: `packages/cli/snap-tests-global/command-exec-pnpm10/` + +``` +command-exec-pnpm10/ +├── package.json +├── steps.json +└── snap.txt # auto-generated +``` + +**`package.json`**: + +```json +{ + "name": "command-exec-pnpm10", + "version": "1.0.0", + "packageManager": "pnpm@10.19.0" +} +``` + +**`steps.json`**: + +```json +{ + "env": { + "VITE_DISABLE_AUTO_INSTALL": "1" + }, + "commands": [ + "vp exec echo hello # basic exec, no vite-plus dep (global CLI handles directly)", + "vp exec node -e \"console.log('hi')\" # exec with args passthrough", + "vp exec nonexistent-cmd # command not found error", + "vp exec -c 'echo hello from shell' # shell mode" + ] +} +``` + +**Test cases**: + +1. `vp exec echo hello` — basic execution with a command found on PATH after `node_modules/.bin` prepend +2. `vp exec node -e "console.log('hi')"` — argument passthrough to a multi-arg command +3. `vp exec nonexistent-cmd` — command-not-found error message +4. `vp exec -c 'echo hello from shell'` — shell mode execution + +### Local CLI Test: `command-exec` + +**Location**: `packages/cli/snap-tests/command-exec/` + +``` +command-exec/ +├── package.json +├── steps.json +└── snap.txt # auto-generated +``` + +**`package.json`**: + +```json +{ + "name": "command-exec", + "version": "1.0.0", + "packageManager": "pnpm@10.19.0", + "devDependencies": { + "vite-plus": "workspace:*", + "cowsay": "^1.6.0" + } +} +``` + +**`steps.json`**: + +```json +{ + "commands": [ + "vp exec cowsay hello # exec with installed binary", + "vp exec -c 'echo $PATH' # verify PATH includes node_modules/.bin" + ] +} +``` + +**Test cases**: + +1. `vp exec cowsay hello` — execute locally installed binary via local CLI delegation +2. `vp exec -c 'echo $PATH'` — verify that `node_modules/.bin` is prepended to PATH + +## Edge Cases + +### Leading `--` Stripping + +```bash +# Both are equivalent +vp exec -- eslint . +vp exec eslint . +``` + +### Shell Mode with Complex Commands + +```bash +# Pipes and redirects require shell mode +vp exec -c 'eslint . 2>&1 | tee lint-output.txt' + +# Environment variable expansion +vp exec -c 'echo $NODE_ENV' +``` + +### Recursive with Failures + +When running recursively, a failure in one package stops execution (unless `--parallel` is used, in which case all packages run and failures are collected): + +```bash +$ vp exec -r -- tsc --noEmit +@my/utils: tsc --noEmit ... ok +@my/app: tsc --noEmit ... FAILED (exit code 1) +Error: 1 of 5 packages failed +``` + +### Empty args After `--` + +```bash +$ vp exec -- +Error: 'vp exec' requires a command to run +``` + +## Security Considerations + +1. **No remote fallback**: Unlike `vpx`, `vp exec` never downloads from the registry, eliminating supply chain risk from accidental remote execution +2. **PATH behavior**: Commands resolve through `./node_modules/.bin` (prepended) + system PATH. This matches `pnpm exec` behavior — system commands like `echo`, `node`, etc. are still reachable +3. **Shell mode risks**: Shell mode (`-c`) allows arbitrary shell commands — same considerations as pnpm exec + +## Backward Compatibility + +This is a new feature with no breaking changes: + +- Existing `vp dlx` and `vpx` behavior unchanged +- New `exec` subcommand is purely additive +- No changes to configuration format +- Follows established delegation pattern (like `vp run`) + +## Comparison with pnpm exec + +| Behavior | `pnpm exec` | `vp exec` | +| ---------------------- | ---------------------------------------- | ---------------------------------------- | +| PATH modification | Prepend `./node_modules/.bin` | Prepend `./node_modules/.bin` | +| Command resolution | Modified PATH (local bins + system PATH) | Modified PATH (local bins + system PATH) | +| Walk-up | No | No | +| Shell mode (`-c`) | Yes | Yes | +| Recursive (`-r`) | Yes (workspace iteration) | Yes (via local CLI) | +| Workspace root (`-w`) | Yes (root only) | Yes (root only) | +| Include workspace root | `--include-workspace-root` | `--include-workspace-root` | +| Filter | `--filter` | `--filter` | +| Path-based filter | `--filter ./packages/app` | `--filter ./packages/app` | +| Braced path filter | `--filter {./packages/app}` | `--filter {./packages/app}` | +| Name + path filter | `--filter 'app-*{./packages}'` | `--filter 'app-*{./packages}'` | +| Parallel | `--parallel` | `--parallel` | +| Report summary | `--report-summary` | `--report-summary` | +| Package name env var | `PNPM_PACKAGE_NAME` | `VITE_PLUS_PACKAGE_NAME` | +| Strip leading `--` | Yes | Yes | + +## Future Enhancements + +### 1. `--if-present` Flag + +```bash +# Skip packages where the command doesn't exist (useful with -r) +vp exec -r --if-present -- eslint . +``` + +## Conclusion + +This RFC proposes adding `vp exec` to complete the execution command trio in Vite+: + +- `vp dlx` — always remote (like `pnpm dlx`) +- `vpx` — local-first with fallback chain (like `npx`) +- `vp exec` — prepend local bins to PATH, no remote fallback (like `pnpm exec`) + +The design: + +- Matches `pnpm exec` semantics for familiar developer experience +- Follows the established unconditional delegation pattern for global/local CLI routing +- Reuses existing infrastructure (`vpx.rs` helpers, delegation, PATH manipulation) +- Supports workspace features (recursive, filter, parallel) via local CLI +- Is purely additive with no breaking changes