diff --git a/Cargo.lock b/Cargo.lock index 3b897f53..cd8ea0b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3889,6 +3889,15 @@ dependencies = [ "wincode", ] +[[package]] +name = "vite_powershell" +version = "0.1.0" +dependencies = [ + "tempfile", + "vite_path", + "which", +] + [[package]] name = "vite_select" version = "0.0.0" @@ -4046,6 +4055,7 @@ dependencies = [ "vite_glob", "vite_graph_ser", "vite_path", + "vite_powershell", "vite_shell", "vite_str", "vite_task", diff --git a/Cargo.toml b/Cargo.toml index 56300f3e..e1e16cef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -145,6 +145,7 @@ vec1 = "1.12.1" vite_glob = { path = "crates/vite_glob" } vite_graph_ser = { path = "crates/vite_graph_ser" } vite_path = { path = "crates/vite_path" } +vite_powershell = { path = "crates/vite_powershell" } vite_select = { path = "crates/vite_select" } vite_shell = { path = "crates/vite_shell" } vite_str = { path = "crates/vite_str" } diff --git a/crates/vite_powershell/Cargo.toml b/crates/vite_powershell/Cargo.toml new file mode 100644 index 00000000..b656ee7d --- /dev/null +++ b/crates/vite_powershell/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "vite_powershell" +version = "0.1.0" +authors.workspace = true +edition.workspace = true +license.workspace = true +publish = false +rust-version.workspace = true + +[dependencies] +vite_path = { workspace = true } +which = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } + +[lints] +workspace = true + +[lib] +doctest = false diff --git a/crates/vite_powershell/src/lib.rs b/crates/vite_powershell/src/lib.rs new file mode 100644 index 00000000..d59ab9c4 --- /dev/null +++ b/crates/vite_powershell/src/lib.rs @@ -0,0 +1,146 @@ +//! Windows-specific helpers for routing `.cmd` invocations through +//! `PowerShell` so spawning never goes through `cmd.exe`. +//! +//! 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` against the sibling `.ps1` shim sidesteps the prompt +//! and lets Ctrl+C propagate cleanly. +//! +//! This crate carries only the platform-shared primitives (the +//! `PowerShell` host lookup, the fixed argument prefix, and the +//! sibling-`.ps1` discovery). Higher-level wrappers in +//! `vite_task_plan::ps1_shim` (cwd-relative arg rewrite, scoped to +//! `node_modules/.bin`) and `vite_command::ps1_shim` (absolute-path +//! arg rewrite, applied to any `.cmd`) compose these primitives with +//! their own scope rules and return-type conventions. +//! +//! See and +//! . + +use std::sync::Arc; + +use vite_path::{AbsolutePath, AbsolutePathBuf}; + +/// Fixed arguments prepended before the `.ps1` path. `-NoProfile`/`-NoLogo` +/// skip user profile loading; `-ExecutionPolicy Bypass` allows running the +/// unsigned shims that npm/pnpm/yarn install. +pub const POWERSHELL_PREFIX: &[&str] = + &["-NoProfile", "-NoLogo", "-ExecutionPolicy", "Bypass", "-File"]; + +/// Cached location of the `PowerShell` host. Prefers cross-platform +/// `pwsh.exe` when present, falling back to the Windows built-in +/// `powershell.exe`. Returns `None` on non-Windows or when neither host +/// is on `PATH`. +/// +/// Cached as `Arc` so callers that want shared ownership +/// (e.g. `vite_task_plan`'s plan-time rewrite) can do `Arc::clone(host)` +/// without copying the path. +#[cfg(windows)] +#[must_use] +pub fn powershell_host() -> Option<&'static Arc> { + use std::sync::LazyLock; + + static POWERSHELL_HOST: LazyLock>> = LazyLock::new(|| { + let resolved = which::which("pwsh.exe").or_else(|_| which::which("powershell.exe")).ok()?; + AbsolutePathBuf::new(resolved).map(Arc::::from) + }); + POWERSHELL_HOST.as_ref() +} + +#[cfg(not(windows))] +#[must_use] +pub const fn powershell_host() -> Option<&'static Arc> { + None +} + +/// Given a resolved `.cmd` path, return its sibling `.ps1` if one exists +/// on disk. The extension match is case-insensitive (matches `.cmd`, +/// `.CMD`, `.Cmd`). +/// +/// Returns `None` when the path is not a `.cmd` or no `.ps1` sibling +/// exists. Callers that need additional scope checks (e.g. "must live +/// inside the workspace's `node_modules/.bin`") should layer those on +/// top of this primitive. +#[must_use] +pub fn find_ps1_sibling(resolved: &AbsolutePath) -> Option { + let ext = resolved.as_path().extension().and_then(|e| e.to_str())?; + if !ext.eq_ignore_ascii_case("cmd") { + return None; + } + + let ps1 = resolved.with_extension("ps1"); + if !ps1.as_path().is_file() { + return None; + } + + Some(ps1) +} + +#[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() + } + + #[test] + fn find_ps1_sibling_returns_path_when_both_present() { + let dir = tempdir().unwrap(); + let root = abs(dir.path().canonicalize().unwrap()); + fs::write(root.as_path().join("npm.cmd"), "").unwrap(); + fs::write(root.as_path().join("npm.ps1"), "").unwrap(); + + let resolved = abs(root.as_path().join("npm.cmd")); + let sibling = find_ps1_sibling(&resolved).expect("should find sibling"); + assert_eq!(sibling.as_path(), root.as_path().join("npm.ps1")); + } + + #[test] + fn find_ps1_sibling_is_case_insensitive_on_extension() { + let dir = tempdir().unwrap(); + let root = abs(dir.path().canonicalize().unwrap()); + fs::write(root.as_path().join("pnpm.CMD"), "").unwrap(); + fs::write(root.as_path().join("pnpm.ps1"), "").unwrap(); + + let resolved = abs(root.as_path().join("pnpm.CMD")); + assert!(find_ps1_sibling(&resolved).is_some()); + } + + #[test] + fn find_ps1_sibling_returns_none_when_sibling_missing() { + let dir = tempdir().unwrap(); + let root = abs(dir.path().canonicalize().unwrap()); + fs::write(root.as_path().join("npm.cmd"), "").unwrap(); + + let resolved = abs(root.as_path().join("npm.cmd")); + assert!(find_ps1_sibling(&resolved).is_none()); + } + + #[test] + fn find_ps1_sibling_returns_none_for_non_cmd() { + let dir = tempdir().unwrap(); + let root = abs(dir.path().canonicalize().unwrap()); + fs::write(root.as_path().join("bun.exe"), "").unwrap(); + fs::write(root.as_path().join("bun.ps1"), "").unwrap(); + + let resolved = abs(root.as_path().join("bun.exe")); + assert!(find_ps1_sibling(&resolved).is_none()); + } + + #[test] + fn find_ps1_sibling_returns_none_for_no_extension() { + let dir = tempdir().unwrap(); + let root = abs(dir.path().canonicalize().unwrap()); + fs::write(root.as_path().join("node"), "").unwrap(); + fs::write(root.as_path().join("node.ps1"), "").unwrap(); + + let resolved = abs(root.as_path().join("node")); + assert!(find_ps1_sibling(&resolved).is_none()); + } +} diff --git a/crates/vite_task_plan/Cargo.toml b/crates/vite_task_plan/Cargo.toml index 17abd81a..f63fec68 100644 --- a/crates/vite_task_plan/Cargo.toml +++ b/crates/vite_task_plan/Cargo.toml @@ -28,6 +28,7 @@ tracing = { workspace = true } vite_glob = { workspace = true } vite_graph_ser = { workspace = true } vite_path = { workspace = true } +vite_powershell = { workspace = true } vite_shell = { workspace = true } vite_str = { workspace = true } vite_task_graph = { workspace = true } diff --git a/crates/vite_task_plan/src/ps1_shim.rs b/crates/vite_task_plan/src/ps1_shim.rs index 60e380d8..0bcb381c 100644 --- a/crates/vite_task_plan/src/ps1_shim.rs +++ b/crates/vite_task_plan/src/ps1_shim.rs @@ -21,6 +21,10 @@ //! globally installed tool's shim somewhere on the user's system PATH — is //! left alone even if it happens to live under some other `node_modules/.bin`. //! +//! Cross-platform primitives (`POWERSHELL_PREFIX`, `powershell_host`, +//! `find_ps1_sibling`) live in the `vite_powershell` crate so +//! `vite_command::ps1_shim` can share them. +//! //! See . use std::sync::Arc; @@ -28,17 +32,8 @@ use std::sync::Arc; #[cfg(any(windows, test))] use cow_utils::CowUtils as _; use vite_path::AbsolutePath; -#[cfg(any(windows, test))] -use vite_path::AbsolutePathBuf; use vite_str::Str; -/// Fixed arguments prepended before the `.ps1` path. `-NoProfile`/`-NoLogo` -/// skip user profile loading; `-ExecutionPolicy Bypass` allows running the -/// unsigned shims that npm/pnpm install into `node_modules/.bin`. -#[cfg(any(windows, test))] -const POWERSHELL_PREFIX: &[&str] = - &["-NoProfile", "-NoLogo", "-ExecutionPolicy", "Bypass", "-File"]; - /// Rewrite a `node_modules/.bin/*.cmd` invocation to go through PowerShell. /// See the module docstring for the full contract; the short form: returns /// `(powershell.exe, [-NoProfile, …, -File, , ...args])` @@ -51,7 +46,7 @@ pub fn rewrite_cmd_shim_with_args( cwd: &AbsolutePath, workspace_root: &AbsolutePath, ) -> (Arc, Arc<[Str]>) { - if let Some(host) = powershell_host() + if let Some(host) = vite_powershell::powershell_host() && let Some(rewritten) = rewrite_with_host(&resolved, &args, cwd, workspace_root, host) { return rewritten; @@ -70,21 +65,6 @@ pub const fn rewrite_cmd_shim_with_args( (resolved, args) } -/// Cached location of the PowerShell host used to run `.ps1` shims. Prefers -/// cross-platform `pwsh.exe` when present, falling back to the Windows -/// built-in `powershell.exe`. `None` means no host was found in PATH (or we -/// aren't on Windows). -#[cfg(windows)] -fn powershell_host() -> Option<&'static Arc> { - use std::sync::LazyLock; - - static POWERSHELL_HOST: LazyLock>> = LazyLock::new(|| { - let resolved = which::which("pwsh.exe").or_else(|_| which::which("powershell.exe")).ok()?; - AbsolutePathBuf::new(resolved).map(Arc::::from) - }); - POWERSHELL_HOST.as_ref() -} - /// Pure rewrite logic, factored out so tests can exercise it on any platform /// without depending on a real `powershell.exe` being on PATH. #[cfg(any(windows, test))] @@ -95,7 +75,10 @@ fn rewrite_with_host( workspace_root: &AbsolutePath, host: &Arc, ) -> Option<(Arc, Arc<[Str]>)> { - let ps1 = find_ps1_sibling(resolved, workspace_root)?; + if !is_in_workspace_node_modules_bin(resolved, workspace_root) { + return None; + } + let ps1 = vite_powershell::find_ps1_sibling(resolved)?; let ps1_rel = pathdiff::diff_paths(ps1.as_path(), cwd.as_path())?; let ps1_rel_str = ps1_rel.to_str()?.cow_replace('\\', "/"); @@ -106,7 +89,7 @@ fn rewrite_with_host( ps1_rel_str, ); - let new_args: Arc<[Str]> = POWERSHELL_PREFIX + let new_args: Arc<[Str]> = vite_powershell::POWERSHELL_PREFIX .iter() .copied() .map(Str::from) @@ -117,38 +100,26 @@ fn rewrite_with_host( Some((Arc::clone(host), new_args)) } +/// True when `resolved` is a `/.../node_modules/.bin/` path +/// inside the workspace. Used to keep the rewrite hands-off for globally +/// installed shims (e.g. `%AppData%\npm\node_modules\.bin`). #[cfg(any(windows, test))] -fn find_ps1_sibling( +fn is_in_workspace_node_modules_bin( resolved: &AbsolutePath, workspace_root: &AbsolutePath, -) -> Option { +) -> bool { let path = resolved.as_path(); - let ext = path.extension().and_then(|e| e.to_str())?; - if !ext.eq_ignore_ascii_case("cmd") { - return None; - } - - // Must live inside the workspace so we don't retarget system-wide / - // globally installed shims (e.g. a user's `%AppData%\npm\node_modules\.bin`). if !path.starts_with(workspace_root.as_path()) { - return None; + return false; } - let mut parents = path.components().rev(); - parents.next()?; // shim filename - if !parents.next()?.as_os_str().eq_ignore_ascii_case(".bin") { - return None; + parents.next(); // shim filename + let Some(bin) = parents.next() else { return false }; + if !bin.as_os_str().eq_ignore_ascii_case(".bin") { + return false; } - if !parents.next()?.as_os_str().eq_ignore_ascii_case("node_modules") { - return None; - } - - let ps1 = path.with_extension("ps1"); - if !ps1.is_file() { - return None; - } - - AbsolutePathBuf::new(ps1) + let Some(node_modules) = parents.next() else { return false }; + node_modules.as_os_str().eq_ignore_ascii_case("node_modules") } #[cfg(test)] @@ -156,8 +127,9 @@ mod tests { use std::fs; use tempfile::tempdir; + use vite_path::AbsolutePathBuf; - use super::{AbsolutePath, AbsolutePathBuf, Arc, Str, rewrite_with_host}; + use super::{AbsolutePath, Arc, Str, rewrite_with_host}; #[expect(clippy::disallowed_types, reason = "tempdir bridges std PathBuf into AbsolutePath")] fn abs(buf: std::path::PathBuf) -> Arc { @@ -171,6 +143,12 @@ mod tests { bin } + fn host_arc(root: &AbsolutePath) -> Arc { + Arc::::from( + AbsolutePathBuf::new(root.as_path().join("powershell.exe")).unwrap(), + ) + } + #[test] fn rewrites_cmd_to_cwd_relative_ps1_at_workspace_root() { let dir = tempdir().unwrap(); @@ -179,7 +157,7 @@ mod tests { fs::write(bin.join("vite.CMD"), "").unwrap(); fs::write(bin.join("vite.ps1"), "").unwrap(); - let host = abs(workspace.as_path().join("powershell.exe")); + let host = host_arc(&workspace); let resolved = abs(bin.join("vite.CMD")); let args: Arc<[Str]> = Arc::from(vec![Str::from("--port"), Str::from("3000")]); @@ -219,7 +197,7 @@ mod tests { fs::create_dir_all(&sub_pkg_path).unwrap(); let sub_pkg = abs(sub_pkg_path); - let host = abs(workspace.as_path().join("powershell.exe")); + let host = host_arc(&workspace); let resolved = abs(bin.join("vite.cmd")); let args: Arc<[Str]> = Arc::from(vec![]); @@ -240,7 +218,7 @@ mod tests { let bin = bin_dir(workspace.as_path()); fs::write(bin.join("vite.cmd"), "").unwrap(); - let host = abs(workspace.as_path().join("powershell.exe")); + let host = host_arc(&workspace); let resolved = abs(bin.join("vite.cmd")); let args: Arc<[Str]> = Arc::from(vec![Str::from("build")]); @@ -254,7 +232,7 @@ mod tests { fs::write(workspace.as_path().join("where.cmd"), "").unwrap(); fs::write(workspace.as_path().join("where.ps1"), "").unwrap(); - let host = abs(workspace.as_path().join("powershell.exe")); + let host = host_arc(&workspace); let resolved = abs(workspace.as_path().join("where.cmd")); let args: Arc<[Str]> = Arc::from(vec![]); @@ -269,7 +247,7 @@ mod tests { fs::write(bin.join("node.exe"), "").unwrap(); fs::write(bin.join("node.ps1"), "").unwrap(); - let host = abs(workspace.as_path().join("powershell.exe")); + let host = host_arc(&workspace); let resolved = abs(bin.join("node.exe")); let args: Arc<[Str]> = Arc::from(vec![Str::from("--version")]); @@ -294,7 +272,7 @@ mod tests { fs::write(global_bin.join("vite.cmd"), "").unwrap(); fs::write(global_bin.join("vite.ps1"), "").unwrap(); - let host = abs(root.as_path().join("powershell.exe")); + let host = host_arc(&workspace); let resolved = abs(global_bin.join("vite.cmd")); let args: Arc<[Str]> = Arc::from(vec![]);