diff --git a/Cargo.lock b/Cargo.lock index 9f3786249e..f9fb7b0ea9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1872,7 +1872,7 @@ dependencies = [ [[package]] name = "fspy" version = "0.1.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" dependencies = [ "allocator-api2", "anyhow", @@ -1908,7 +1908,7 @@ dependencies = [ [[package]] name = "fspy_detours_sys" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" dependencies = [ "cc", "winapi", @@ -1917,7 +1917,7 @@ dependencies = [ [[package]] name = "fspy_preload_unix" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" dependencies = [ "anyhow", "bstr", @@ -1932,7 +1932,7 @@ dependencies = [ [[package]] name = "fspy_preload_windows" version = "0.1.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" dependencies = [ "constcat", "fspy_detours_sys", @@ -1948,7 +1948,7 @@ dependencies = [ [[package]] name = "fspy_seccomp_unotify" version = "0.1.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" dependencies = [ "futures-util", "libc", @@ -1965,7 +1965,7 @@ dependencies = [ [[package]] name = "fspy_shared" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" dependencies = [ "allocator-api2", "bitflags 2.11.0", @@ -1984,7 +1984,7 @@ dependencies = [ [[package]] name = "fspy_shared_unix" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" dependencies = [ "anyhow", "base64 0.22.1", @@ -3124,7 +3124,7 @@ dependencies = [ [[package]] name = "materialized_artifact" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" dependencies = [ "tempfile", ] @@ -3132,7 +3132,7 @@ dependencies = [ [[package]] name = "materialized_artifact_build" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" dependencies = [ "xxhash-rust", ] @@ -3346,7 +3346,7 @@ dependencies = [ [[package]] name = "native_str" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" dependencies = [ "allocator-api2", "bytemuck", @@ -4821,7 +4821,7 @@ dependencies = [ [[package]] name = "pty_terminal_test_client" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" [[package]] name = "quote" @@ -7467,6 +7467,8 @@ dependencies = [ "tracing", "vite_error", "vite_path", + "vite_powershell", + "vite_shared", "which", ] @@ -7496,7 +7498,7 @@ dependencies = [ [[package]] name = "vite_glob" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" dependencies = [ "thiserror 2.0.18", "vite_path", @@ -7536,7 +7538,7 @@ dependencies = [ [[package]] name = "vite_graph_ser" version = "0.1.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" dependencies = [ "petgraph 0.8.3", "serde", @@ -7635,7 +7637,7 @@ dependencies = [ [[package]] name = "vite_path" version = "0.1.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" dependencies = [ "diff-struct", "path-clean", @@ -7646,10 +7648,19 @@ dependencies = [ "wincode", ] +[[package]] +name = "vite_powershell" +version = "0.1.0" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" +dependencies = [ + "vite_path", + "which", +] + [[package]] name = "vite_select" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" dependencies = [ "anyhow", "crossterm", @@ -7699,7 +7710,7 @@ dependencies = [ [[package]] name = "vite_shell" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" dependencies = [ "brush-parser 0.3.0 (git+https://github.com/reubeno/brush?rev=dcb760933b10ee0433d7b740a5709b06f5c67c6b)", "diff-struct", @@ -7726,7 +7737,7 @@ dependencies = [ [[package]] name = "vite_str" version = "0.1.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" dependencies = [ "compact_str", "diff-struct", @@ -7737,7 +7748,7 @@ dependencies = [ [[package]] name = "vite_task" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" dependencies = [ "anyhow", "async-trait", @@ -7775,7 +7786,7 @@ dependencies = [ [[package]] name = "vite_task_graph" version = "0.1.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" dependencies = [ "anyhow", "async-trait", @@ -7797,7 +7808,7 @@ dependencies = [ [[package]] name = "vite_task_plan" version = "0.1.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" dependencies = [ "anyhow", "async-trait", @@ -7815,6 +7826,7 @@ dependencies = [ "vite_glob", "vite_graph_ser", "vite_path", + "vite_powershell", "vite_shell", "vite_str", "vite_task_graph", @@ -7829,7 +7841,7 @@ version = "0.0.0" [[package]] name = "vite_workspace" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" dependencies = [ "clap", "petgraph 0.8.3", diff --git a/Cargo.toml b/Cargo.toml index 02932f7530..c6ba93484e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -89,7 +89,7 @@ dunce = "1.0.5" fast-glob = "1.0.0" flate2 = { version = "=1.1.9", features = ["zlib-rs"] } form_urlencoded = "1.2.1" -fspy = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" } +fspy = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "88bacaa770200ddab151dea252e04ba8cdcc4ade" } futures = "0.3.31" futures-util = "0.3.31" glob = "0.3.2" @@ -194,16 +194,17 @@ vfs = "0.13.0" vite_command = { path = "crates/vite_command" } vite_error = { path = "crates/vite_error" } vite_js_runtime = { path = "crates/vite_js_runtime" } -vite_glob = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" } +vite_glob = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "88bacaa770200ddab151dea252e04ba8cdcc4ade" } vite_install = { path = "crates/vite_install" } vite_migration = { path = "crates/vite_migration" } vite_setup = { path = "crates/vite_setup" } vite_shared = { path = "crates/vite_shared" } vite_static_config = { path = "crates/vite_static_config" } -vite_path = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" } -vite_str = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" } -vite_task = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" } -vite_workspace = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" } +vite_path = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "88bacaa770200ddab151dea252e04ba8cdcc4ade" } +vite_powershell = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "88bacaa770200ddab151dea252e04ba8cdcc4ade" } +vite_str = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "88bacaa770200ddab151dea252e04ba8cdcc4ade" } +vite_task = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "88bacaa770200ddab151dea252e04ba8cdcc4ade" } +vite_workspace = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "88bacaa770200ddab151dea252e04ba8cdcc4ade" } walkdir = "2.5.0" wax = "0.6.0" which = "8.0.0" @@ -314,6 +315,7 @@ string_wizard = { path = "./rolldown/crates/string_wizard", features = ["serde"] # fspy = { path = "../vite-task/crates/fspy" } # vite_glob = { path = "../vite-task/crates/vite_glob" } # vite_path = { path = "../vite-task/crates/vite_path" } +# vite_powershell = { path = "../vite-task/crates/vite_powershell" } # vite_str = { path = "../vite-task/crates/vite_str" } # vite_task = { path = "../vite-task/crates/vite_task" } # vite_workspace = { path = "../vite-task/crates/vite_workspace" } diff --git a/crates/vite_command/Cargo.toml b/crates/vite_command/Cargo.toml index 4d031bfe42..b2bc76aead 100644 --- a/crates/vite_command/Cargo.toml +++ b/crates/vite_command/Cargo.toml @@ -14,6 +14,8 @@ tokio-util = { workspace = true } tracing = { workspace = true } vite_error = { workspace = true } vite_path = { workspace = true } +vite_powershell = { workspace = true } +vite_shared = { workspace = true } which = { workspace = true, features = ["tracing"] } [target.'cfg(not(target_os = "windows"))'.dependencies] diff --git a/crates/vite_command/src/lib.rs b/crates/vite_command/src/lib.rs index 9f724e3aac..64da37182f 100644 --- a/crates/vite_command/src/lib.rs +++ b/crates/vite_command/src/lib.rs @@ -2,7 +2,7 @@ use std::os::fd::{BorrowedFd, RawFd}; use std::{ collections::HashMap, - ffi::OsStr, + ffi::{OsStr, OsString}, process::{ExitStatus, Stdio}, }; @@ -12,6 +12,8 @@ use tokio_util::sync::CancellationToken; use vite_error::Error; use vite_path::{AbsolutePath, AbsolutePathBuf, RelativePathBuf}; +mod ps1_shim; + /// Result of running a command with fspy tracking. #[derive(Debug)] pub struct FspyCommandResult { @@ -43,6 +45,22 @@ pub fn resolve_bin( AbsolutePathBuf::new(path).ok_or_else(|| Error::CannotFindBinaryPath(bin_name.into())) } +/// Resolve `bin_name` to a path and apply the Windows `.cmd` → PowerShell +/// rewrite. Returns the program to spawn and the arg prefix to prepend +/// before the user args (empty when no rewrite applies). +fn resolve_program( + bin_name: &str, + envs: &HashMap, + cwd: &AbsolutePath, +) -> Result<(AbsolutePathBuf, Vec), Error> { + let path_env = envs.get("PATH").map(|p| OsStr::new(p.as_str())); + let bin_path = resolve_bin(bin_name, path_env, cwd)?; + Ok(match ps1_shim::rewrite_cmd_to_powershell(&bin_path) { + Some(rewritten) => rewritten, + None => (bin_path, Vec::new()), + }) +} + /// 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.). @@ -140,10 +158,9 @@ where S: AsRef, { let cwd = cwd.as_ref(); - 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 (program, prefix_args) = resolve_program(bin_name, envs, cwd)?; + let mut cmd = build_command(&program, cwd); + cmd.args(&prefix_args).args(args).envs(envs); let status = cmd.status().await?; Ok(status) } diff --git a/crates/vite_command/src/ps1_shim.rs b/crates/vite_command/src/ps1_shim.rs new file mode 100644 index 0000000000..c5ed9f3cb1 --- /dev/null +++ b/crates/vite_command/src/ps1_shim.rs @@ -0,0 +1,235 @@ +//! Windows-specific: when a vp-managed package-manager `.cmd` shim has a +//! sibling `.ps1`, rewrite the spawn to go through +//! `powershell.exe -File `. +//! +//! Running a `.cmd` from any shell makes `cmd.exe` prompt "Terminate batch +//! job (Y/N)?" on Ctrl+C, which leaves the terminal corrupt. Routing through +//! `PowerShell` sidesteps the prompt and lets Ctrl+C propagate cleanly. +//! +//! The rewrite is scoped to two patterns: +//! - Inside `$VP_HOME` (`~/.vite-plus` by default) — vp's managed shims: +//! - `$VP_HOME/js_runtime/node//{npm,npx}.cmd`, +//! - `$VP_HOME/package_manager////bin/.cmd`. +//! - Any `<...>/node_modules/.bin/*.cmd` — the canonical layout for +//! npm/pnpm/yarn-emitted shims (cmd-shim writes both `.cmd` and `.ps1` +//! so the wrappers stay equivalent). +//! +//! Anything outside both patterns — system tools, third-party CLIs whose +//! `.cmd` and `.ps1` wrappers may diverge — keeps its existing `.cmd` +//! path (Ctrl+C corruption included), so we don't silently change +//! execution semantics for unrelated commands or bypass execution +//! policies on locked-down hosts. +//! +//! See +//! and . + +use std::ffi::OsString; + +use vite_path::{AbsolutePath, AbsolutePathBuf}; +use vite_powershell::{POWERSHELL_PREFIX, find_ps1_sibling, powershell_host}; + +/// Rewrite a vp-managed `.cmd` invocation to go through PowerShell. +/// +/// Returns `Some((powershell_host, prefix_args))` when the rewrite applies. +/// `prefix_args` is `["-NoProfile", "-NoLogo", "-ExecutionPolicy", "Bypass", +/// "-File", ]`; callers prepend it to the user args and spawn +/// `powershell_host`. +/// +/// Returns `None` when: +/// - not on Windows, +/// - no PowerShell host (`pwsh.exe` or `powershell.exe`) is on PATH, +/// - `$VP_HOME` could not be resolved, +/// - the resolved path is outside `$VP_HOME` AND not under any +/// `node_modules/.bin/`, +/// - the resolved path is not a `.cmd` (case-insensitive), +/// - the `.cmd` has no sibling `.ps1`. +#[must_use] +pub fn rewrite_cmd_to_powershell( + resolved: &AbsolutePath, +) -> Option<(AbsolutePathBuf, Vec)> { + let vp_home = vp_home()?; + let host = powershell_host()?; + rewrite_in_scope(resolved, vp_home, host) +} + +/// Cached `$VP_HOME` (`~/.vite-plus` by default; overridable via env var). +/// `None` only if `vite_shared::get_vp_home()` failed to resolve a home — +/// in that case we conservatively skip the rewrite rather than retarget +/// arbitrary PATH commands. +fn vp_home() -> Option<&'static AbsolutePathBuf> { + use std::sync::LazyLock; + + static VP_HOME: LazyLock> = + LazyLock::new(|| vite_shared::get_vp_home().ok()); + VP_HOME.as_ref() +} + +/// Pure rewrite logic. Factored out so tests can drive it on any platform +/// without depending on a real `powershell.exe` or a real `$VP_HOME`. +fn rewrite_in_scope( + resolved: &AbsolutePath, + vp_home: &AbsolutePath, + host: &AbsolutePath, +) -> Option<(AbsolutePathBuf, Vec)> { + if !is_in_managed_scope(resolved, vp_home) { + return None; + } + let ps1 = find_ps1_sibling(resolved)?; + + tracing::debug!( + "rewriting .cmd to powershell: {} -> {} -File {}", + resolved.as_path().display(), + host.as_path().display(), + ps1.as_path().display(), + ); + + let mut prefix_args: Vec = + POWERSHELL_PREFIX.iter().copied().map(OsString::from).collect(); + prefix_args.push(ps1.as_path().as_os_str().to_owned()); + + Some((host.to_absolute_path_buf(), prefix_args)) +} + +fn is_in_managed_scope(resolved: &AbsolutePath, vp_home: &AbsolutePath) -> bool { + resolved.as_path().starts_with(vp_home.as_path()) || is_in_node_modules_bin(resolved) +} + +/// `true` when `resolved` is `<...>/node_modules/.bin/` (matched +/// case-insensitively on the `.bin`/`node_modules` components — Windows +/// is case-insensitive, and pnpm's hoisted layouts can vary in casing). +fn is_in_node_modules_bin(resolved: &AbsolutePath) -> bool { + let mut parents = resolved.as_path().components().rev(); + parents.next(); // shim filename + let Some(bin) = parents.next() else { return false }; + if !bin.as_os_str().eq_ignore_ascii_case(".bin") { + return false; + } + let Some(node_modules) = parents.next() else { return false }; + node_modules.as_os_str().eq_ignore_ascii_case("node_modules") +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::tempdir; + + use super::*; + + #[expect(clippy::disallowed_types, reason = "tempdir bridges std PathBuf into AbsolutePath")] + fn abs(buf: std::path::PathBuf) -> AbsolutePathBuf { + AbsolutePathBuf::new(buf).unwrap() + } + + fn host_buf(root: &AbsolutePath) -> AbsolutePathBuf { + abs(root.as_path().join("powershell.exe")) + } + + #[test] + fn rewrites_cmd_inside_vp_home_to_powershell() { + let dir = tempdir().unwrap(); + let vp_home = abs(dir.path().canonicalize().unwrap()); + // Mimic the real layout: $VP_HOME/js_runtime/node//npm.cmd. + let bin_dir = vp_home.as_path().join("js_runtime").join("node").join("24.0.0"); + fs::create_dir_all(&bin_dir).unwrap(); + fs::write(bin_dir.join("npm.cmd"), "").unwrap(); + fs::write(bin_dir.join("npm.ps1"), "").unwrap(); + + let host = host_buf(&vp_home); + let resolved = abs(bin_dir.join("npm.cmd")); + + let (program, prefix_args) = + rewrite_in_scope(&resolved, &vp_home, &host).expect("should rewrite"); + + assert_eq!(program.as_path(), host.as_path()); + let as_strs: Vec<&str> = prefix_args.iter().filter_map(|a| a.to_str()).collect(); + let ps1_path = bin_dir.join("npm.ps1"); + let ps1_str = ps1_path.to_str().unwrap(); + assert_eq!( + as_strs, + vec!["-NoProfile", "-NoLogo", "-ExecutionPolicy", "Bypass", "-File", ps1_str] + ); + } + + /// Any `<...>/node_modules/.bin/*.cmd` rewrites, regardless of where + /// the project root sits — covers single-package projects, hoisted + /// monorepos, and globally-installed shims uniformly. + #[test] + fn rewrites_cmd_in_node_modules_bin() { + let dir = tempdir().unwrap(); + let root = abs(dir.path().canonicalize().unwrap()); + // vp_home points elsewhere — this scope is the node_modules path. + let vp_home_path = root.as_path().join("vite-plus"); + fs::create_dir_all(&vp_home_path).unwrap(); + let vp_home = abs(vp_home_path); + + let bin = root.as_path().join("my-project").join("node_modules").join(".bin"); + fs::create_dir_all(&bin).unwrap(); + fs::write(bin.join("vite.cmd"), "").unwrap(); + fs::write(bin.join("vite.ps1"), "").unwrap(); + + let host = host_buf(&root); + let resolved = abs(bin.join("vite.cmd")); + + let result = rewrite_in_scope(&resolved, &vp_home, &host); + assert!(result.is_some(), "any node_modules/.bin/*.cmd must rewrite"); + } + + /// The `.bin`/`node_modules` component check is case-insensitive so + /// a `.CMD` shim under `Node_Modules\.Bin\` (or any casing variant) + /// still matches. + #[test] + fn rewrites_cmd_in_node_modules_bin_case_insensitive() { + let dir = tempdir().unwrap(); + let root = abs(dir.path().canonicalize().unwrap()); + let vp_home = abs(root.as_path().join("vite-plus")); + fs::create_dir_all(vp_home.as_path()).unwrap(); + + let bin = root.as_path().join("Node_Modules").join(".Bin"); + fs::create_dir_all(&bin).unwrap(); + fs::write(bin.join("vite.cmd"), "").unwrap(); + fs::write(bin.join("vite.ps1"), "").unwrap(); + + let host = host_buf(&root); + let resolved = abs(bin.join("vite.cmd")); + + assert!(rewrite_in_scope(&resolved, &vp_home, &host).is_some()); + } + + /// A `.cmd`+`.ps1` pair outside `$VP_HOME` AND outside any + /// `node_modules/.bin/` (e.g. a system tool living at `/global/bin/foo.cmd`) + /// must NOT be retargeted. + #[test] + fn returns_none_for_cmd_outside_managed_scope() { + let dir = tempdir().unwrap(); + let root = abs(dir.path().canonicalize().unwrap()); + let vp_home_path = root.as_path().join("vite-plus"); + fs::create_dir_all(&vp_home_path).unwrap(); + let vp_home = abs(vp_home_path); + + let outside_bin = root.as_path().join("global").join("bin"); + fs::create_dir_all(&outside_bin).unwrap(); + fs::write(outside_bin.join("foo.cmd"), "").unwrap(); + fs::write(outside_bin.join("foo.ps1"), "").unwrap(); + + let host = host_buf(&root); + let resolved = abs(outside_bin.join("foo.cmd")); + + assert!( + rewrite_in_scope(&resolved, &vp_home, &host).is_none(), + "rewrite must stay hands-off for .cmd outside both vp_home and node_modules/.bin" + ); + } + + #[test] + fn returns_none_when_no_ps1_sibling() { + let dir = tempdir().unwrap(); + let vp_home = abs(dir.path().canonicalize().unwrap()); + fs::write(vp_home.as_path().join("npm.cmd"), "").unwrap(); + + let host = host_buf(&vp_home); + let resolved = abs(vp_home.as_path().join("npm.cmd")); + + assert!(rewrite_in_scope(&resolved, &vp_home, &host).is_none()); + } +} diff --git a/packages/cli/snap-tests-global/new-create-vite-migrates-eslint-prettier/snap.txt b/packages/cli/snap-tests-global/new-create-vite-migrates-eslint-prettier/snap.txt index dd2e928386..b7cef79680 100644 --- a/packages/cli/snap-tests-global/new-create-vite-migrates-eslint-prettier/snap.txt +++ b/packages/cli/snap-tests-global/new-create-vite-migrates-eslint-prettier/snap.txt @@ -1,4 +1,60 @@ > vp create vite@9.0.5 --no-interactive -- my-react-ts --template react-ts # create vite app with pinned version + react-ts template, should auto-migrate ESLint -> Oxlint and merge lint config into vite.config.ts + +Using default package manager: pnpm + +pnpm@latest installing... + +pnpm@ installed + +Generating project… + +Running: pnpm dlx create-vite@ my-react-ts --template react-ts --no-immediate --no-rolldown +│ +◇ Scaffolding project in /my-react-ts... +│ +└ Done. Now run: + + cd my-react-ts + pnpm install + pnpm dev + + +Wrote agent instructions to AGENTS.md + +Installing dependencies... + +Dependencies installed + +Migrating ESLint config to Oxlint... + +ESLint config migrated to .oxlintrc.json + +Replacing ESLint comments with Oxlint equivalents... + +ESLint comments replaced + +✔ Removed my-react-ts/eslint.config.js + +✔ Merged my-react-ts/.oxlintrc.json into my-react-ts/vite.config.ts + +Rewrote imports in one file + + my-react-ts/vite.config.ts + +✔ Merged staged config into my-react-ts/vite.config.ts + +Installing dependencies... + +Dependencies installed + +Formatting code... + +Code formatted +◇ Scaffolded my-react-ts with React + TypeScript +• Node pnpm +✓ Dependencies installed in ms +→ Next: cd my-react-ts && vp run + > test ! -f my-react-ts/eslint.config.js && echo 'eslint.config.js removed' # eslint config deleted eslint.config.js removed diff --git a/packages/cli/snap-tests-global/new-create-vite-migrates-eslint-prettier/steps.json b/packages/cli/snap-tests-global/new-create-vite-migrates-eslint-prettier/steps.json index e1fde38fa5..44befe57c6 100644 --- a/packages/cli/snap-tests-global/new-create-vite-migrates-eslint-prettier/steps.json +++ b/packages/cli/snap-tests-global/new-create-vite-migrates-eslint-prettier/steps.json @@ -6,7 +6,7 @@ "commands": [ { "command": "vp create vite@9.0.5 --no-interactive -- my-react-ts --template react-ts # create vite app with pinned version + react-ts template, should auto-migrate ESLint -> Oxlint and merge lint config into vite.config.ts", - "ignoreOutput": true + "ignoreOutput": false }, "test ! -f my-react-ts/eslint.config.js && echo 'eslint.config.js removed' # eslint config deleted", "test ! -f my-react-ts/.oxlintrc.json && echo '.oxlintrc.json merged into vite.config.ts' # migration output merged by rewrite step (matches vp migrate)", diff --git a/packages/cli/src/create/bin.ts b/packages/cli/src/create/bin.ts index a1152fcc08..323a59b87a 100644 --- a/packages/cli/src/create/bin.ts +++ b/packages/cli/src/create/bin.ts @@ -46,7 +46,7 @@ import { updatePackageJsonWithDeps, updateWorkspaceConfig, } from '../utils/workspace.ts'; -import type { ExecutionResult } from './command.ts'; +import type { ExecutionWithProjectDir } from './command.ts'; import { discoverTemplate, inferGitHubRepoName, inferParentDir, isGitHubUrl } from './discovery.ts'; import { getInitialTemplateOptions } from './initial-template-options.ts'; import { @@ -935,7 +935,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h // #region Handle single project template - let result: ExecutionResult; + let result: ExecutionWithProjectDir; if (templateInfo.type === TemplateType.bundled) { pauseCreateProgress(); await checkProjectDirExists(path.join(workspaceInfo.rootDir, targetDir), options.interactive); diff --git a/packages/cli/src/create/command.ts b/packages/cli/src/create/command.ts index 39fe68318e..0083c60b55 100644 --- a/packages/cli/src/create/command.ts +++ b/packages/cli/src/create/command.ts @@ -1,28 +1,20 @@ import fs from 'node:fs/promises'; import path from 'node:path'; -import spawn from 'cross-spawn'; - import { runCommand as runCommandWithFspy } from '../../binding/index.js'; import type { WorkspaceInfo } from '../types/index.ts'; +import type { ExecutionResult, RunCommandOptions } from '../utils/command.ts'; -export interface ExecutionResult { - exitCode: number; +/** Set by `runCommandAndDetectProjectDir` and the template executors + * that call it; plain `runCommand` / `runCommandSilently` don't. */ +export interface ExecutionWithProjectDir extends ExecutionResult { projectDir?: string; } -export interface RunCommandOptions { - command: string; - args: string[]; - cwd: string; - envs: NodeJS.ProcessEnv; -} - -// Run a command and detect the project directory export async function runCommandAndDetectProjectDir( options: RunCommandOptions, parentDir?: string, -): Promise { +): Promise { const cwd = parentDir ? path.join(options.cwd, parentDir) : options.cwd; const existingDirs = new Set(); if (parentDir) { @@ -89,57 +81,6 @@ export async function runCommandAndDetectProjectDir( }; } -export interface RunCommandResult extends ExecutionResult { - stdout: Buffer; - stderr: Buffer; -} - -export async function runCommandSilently(options: RunCommandOptions): Promise { - const child = spawn(options.command, options.args, { - stdio: 'pipe', - cwd: options.cwd, - env: options.envs, - }); - const promise = new Promise((resolve, reject) => { - const stdout: Buffer[] = []; - const stderr: Buffer[] = []; - child.stdout?.on('data', (data) => { - stdout.push(data); - }); - child.stderr?.on('data', (data) => { - stderr.push(data); - }); - child.on('close', (code) => { - resolve({ - exitCode: code ?? 0, - stdout: Buffer.concat(stdout), - stderr: Buffer.concat(stderr), - }); - }); - child.on('error', (err) => { - reject(err); - }); - }); - return await promise; -} - -export async function runCommand(options: RunCommandOptions): Promise { - const child = spawn(options.command, options.args, { - stdio: 'inherit', - cwd: options.cwd, - env: options.envs, - }); - const promise = new Promise((resolve, reject) => { - child.on('close', (code) => { - resolve({ exitCode: code ?? 0 }); - }); - child.on('error', (err) => { - reject(err); - }); - }); - return await promise; -} - // Get the package runner command for each package manager export function getPackageRunner(workspaceInfo: WorkspaceInfo) { switch (workspaceInfo.packageManager) { diff --git a/packages/cli/src/create/templates/builtin.ts b/packages/cli/src/create/templates/builtin.ts index d30b415953..3766f65bc3 100644 --- a/packages/cli/src/create/templates/builtin.ts +++ b/packages/cli/src/create/templates/builtin.ts @@ -5,7 +5,7 @@ import * as prompts from '@voidzero-dev/vite-plus-prompts'; import colors from 'picocolors'; import type { WorkspaceInfo } from '../../types/index.ts'; -import type { ExecutionResult } from '../command.ts'; +import type { ExecutionWithProjectDir } from '../command.ts'; import { discoverTemplate } from '../discovery.ts'; import { setPackageName } from '../utils.ts'; import { executeGeneratorScaffold } from './generator.ts'; @@ -16,7 +16,7 @@ export async function executeBuiltinTemplate( workspaceInfo: WorkspaceInfo, templateInfo: BuiltinTemplateInfo, options?: { silent?: boolean }, -): Promise { +): Promise { assert(templateInfo.targetDir, 'targetDir is required'); assert(templateInfo.packageName, 'packageName is required'); diff --git a/packages/cli/src/create/templates/bundled.ts b/packages/cli/src/create/templates/bundled.ts index 03e3541878..316f32dfb5 100644 --- a/packages/cli/src/create/templates/bundled.ts +++ b/packages/cli/src/create/templates/bundled.ts @@ -2,7 +2,7 @@ import assert from 'node:assert'; import path from 'node:path'; import type { WorkspaceInfo } from '../../types/index.ts'; -import type { ExecutionResult } from '../command.ts'; +import type { ExecutionWithProjectDir } from '../command.ts'; import { copyDir, setPackageName } from '../utils.ts'; import type { BuiltinTemplateInfo } from './types.ts'; @@ -13,7 +13,7 @@ import type { BuiltinTemplateInfo } from './types.ts'; export async function executeBundledTemplate( workspaceInfo: WorkspaceInfo, templateInfo: BuiltinTemplateInfo, -): Promise { +): Promise { assert(templateInfo.localPath, 'localPath is required for bundled templates'); assert(templateInfo.targetDir, 'targetDir is required'); assert(templateInfo.packageName, 'packageName is required'); diff --git a/packages/cli/src/create/templates/generator.ts b/packages/cli/src/create/templates/generator.ts index d550331b85..a87d651db6 100644 --- a/packages/cli/src/create/templates/generator.ts +++ b/packages/cli/src/create/templates/generator.ts @@ -6,7 +6,7 @@ import * as prompts from '@voidzero-dev/vite-plus-prompts'; import type { WorkspaceInfo } from '../../types/index.ts'; import { editJsonFile } from '../../utils/json.ts'; import { templatesDir } from '../../utils/path.ts'; -import type { ExecutionResult } from '../command.ts'; +import type { ExecutionWithProjectDir } from '../command.ts'; import { copyDir } from '../utils.ts'; import type { BuiltinTemplateInfo } from './types.ts'; @@ -15,7 +15,7 @@ export async function executeGeneratorScaffold( workspaceInfo: WorkspaceInfo, templateInfo: BuiltinTemplateInfo, options?: { silent?: boolean }, -): Promise { +): Promise { if (!options?.silent) { prompts.log.step('Creating generator scaffold...'); } diff --git a/packages/cli/src/create/templates/monorepo.ts b/packages/cli/src/create/templates/monorepo.ts index edc57a290f..3d72da0d47 100644 --- a/packages/cli/src/create/templates/monorepo.ts +++ b/packages/cli/src/create/templates/monorepo.ts @@ -8,7 +8,7 @@ import { rewriteMonorepoProject } from '../../migration/migrator.ts'; import { PackageManager, type WorkspaceInfo } from '../../types/index.ts'; import { editJsonFile } from '../../utils/json.ts'; import { templatesDir } from '../../utils/path.ts'; -import type { ExecutionResult } from '../command.ts'; +import type { ExecutionWithProjectDir } from '../command.ts'; import { discoverTemplate } from '../discovery.ts'; import { copyDir, formatDisplayTargetDir, setPackageName } from '../utils.ts'; import { runRemoteTemplateCommand } from './remote.ts'; @@ -21,7 +21,7 @@ export async function executeMonorepoTemplate( workspaceInfo: WorkspaceInfo, templateInfo: BuiltinTemplateInfo, options?: { silent?: boolean }, -): Promise { +): Promise { assert(templateInfo.packageName, 'packageName is required'); assert(templateInfo.targetDir, 'targetDir is required'); diff --git a/packages/cli/src/create/templates/remote.ts b/packages/cli/src/create/templates/remote.ts index 77c84fbe02..a6800c7a9c 100644 --- a/packages/cli/src/create/templates/remote.ts +++ b/packages/cli/src/create/templates/remote.ts @@ -2,13 +2,12 @@ import * as prompts from '@voidzero-dev/vite-plus-prompts'; import colors from 'picocolors'; import type { WorkspaceInfo } from '../../types/index.ts'; +import { runCommand, runCommandSilently } from '../../utils/command.ts'; import { checkNpmPackageExists } from '../../utils/package.ts'; import { - type ExecutionResult, + type ExecutionWithProjectDir, formatDlxCommand, - runCommand, runCommandAndDetectProjectDir, - runCommandSilently, } from '../command.ts'; import type { TemplateInfo } from './types.ts'; @@ -18,14 +17,14 @@ export async function executeRemoteTemplate( workspaceInfo: WorkspaceInfo, templateInfo: TemplateInfo, options?: { silent?: boolean }, -): Promise { +): Promise { const silent = options?.silent ?? false; if (!silent) { prompts.log.step('Generating project…'); } let isGitHubTemplate = templateInfo.command === 'degit'; - let result: ExecutionResult; + let result: ExecutionWithProjectDir; if (templateInfo.command === 'node') { // Template found locally - execute directly const command = templateInfo.command; @@ -84,7 +83,7 @@ export async function runRemoteTemplateCommand( templateInfo: TemplateInfo, detectCreatedProjectDir?: boolean, silent = false, -): Promise { +): Promise { autoFixRemoteTemplateCommand(templateInfo, workspaceInfo); const remotePackageName = templateInfo.command; const execArgs = [...templateInfo.args]; diff --git a/packages/cli/src/utils/command.ts b/packages/cli/src/utils/command.ts index df58a49543..27bed9900a 100644 --- a/packages/cli/src/utils/command.ts +++ b/packages/cli/src/utils/command.ts @@ -7,15 +7,21 @@ export interface RunCommandOptions { envs: NodeJS.ProcessEnv; } -export interface RunCommandResult { +export interface ExecutionResult { exitCode: number; +} + +export interface RunCommandResult extends ExecutionResult { stdout: Buffer; stderr: Buffer; } export async function runCommandSilently(options: RunCommandOptions): Promise { const child = spawn(options.command, options.args, { - stdio: 'pipe', + // No stdin pipe: leaving one open would deadlock any descendant `.ps1` + // shim whose `$MyInvocation.ExpectingInput` branch waits for EOF on + // stdin before invoking `node`. + stdio: ['ignore', 'pipe', 'pipe'], cwd: options.cwd, env: options.envs, }); @@ -42,15 +48,15 @@ export async function runCommandSilently(options: RunCommandOptions): Promise { +export async function runCommand(options: RunCommandOptions): Promise { const child = spawn(options.command, options.args, { stdio: 'inherit', cwd: options.cwd, env: options.envs, }); - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { child.on('close', (code) => { - resolve(code ?? 0); + resolve({ exitCode: code ?? 0 }); }); child.on('error', (err) => { reject(err); diff --git a/packages/tools/src/snap-test.ts b/packages/tools/src/snap-test.ts index 11c1bc08d8..e4c92a3ed7 100755 --- a/packages/tools/src/snap-test.ts +++ b/packages/tools/src/snap-test.ts @@ -168,7 +168,13 @@ export async function snapTest() { ); // Clean up the temporary directory on exit - process.on('exit', () => fs.rmSync(tempTmpDir, { recursive: true, force: true })); + process.on('exit', () => { + try { + fs.rmSync(tempTmpDir, { recursive: true, force: true }); + } catch (error) { + console.error('Error cleaning up temporary directory: %s, %s', tempTmpDir, error); + } + }); const casesDir = path.resolve(values.dir || 'snap-tests');