diff --git a/crates/vite_task/src/session/execute/mod.rs b/crates/vite_task/src/session/execute/mod.rs index 8af09888..af844629 100644 --- a/crates/vite_task/src/session/execute/mod.rs +++ b/crates/vite_task/src/session/execute/mod.rs @@ -2,6 +2,7 @@ pub mod fingerprint; pub mod glob_inputs; mod hash; pub mod pipe; +mod powershell; pub mod spawn; pub mod tracked_accesses; #[cfg(windows)] diff --git a/crates/vite_task/src/session/execute/powershell.rs b/crates/vite_task/src/session/execute/powershell.rs new file mode 100644 index 00000000..8af6ae12 --- /dev/null +++ b/crates/vite_task/src/session/execute/powershell.rs @@ -0,0 +1,130 @@ +//! On Windows, `CreateProcess` cannot execute a `.ps1` script directly. +//! When the planner has already picked a `.ps1` (see `vite_task_plan::ps1_shim`), +//! this module wraps the spawn as `powershell.exe -File `, +//! preferring `pwsh.exe` when available. The PowerShell host is the same one +//! the planner consulted, so plan-time and spawn-time decisions stay in sync. + +use std::sync::Arc; + +use vite_path::AbsolutePath; +use vite_str::Str; +#[cfg(windows)] +use vite_task_plan::ps1_shim::powershell_host; + +/// 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"]; + +/// If `program_path` is a `.ps1`, return a rewritten +/// `(powershell.exe, [-File , ...args])` invocation. Returns `None` +/// when the program isn't a `.ps1` or no PowerShell host is available, so +/// callers can reuse the original references without cloning. +#[cfg(windows)] +pub(super) fn wrap_ps1_with_powershell( + program_path: &Arc, + args: &Arc<[Str]>, +) -> Option<(Arc, Arc<[Str]>)> { + wrap_with_host(program_path, args, powershell_host()?) +} + +#[cfg(not(windows))] +pub(super) const fn wrap_ps1_with_powershell( + _program_path: &Arc, + _args: &Arc<[Str]>, +) -> Option<(Arc, Arc<[Str]>)> { + None +} + +/// 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))] +fn wrap_with_host( + program_path: &Arc, + args: &Arc<[Str]>, + host: &Arc, +) -> Option<(Arc, Arc<[Str]>)> { + let ext = program_path.as_path().extension().and_then(|e| e.to_str())?; + if !ext.eq_ignore_ascii_case("ps1") { + return None; + } + + let ps1_str = program_path.as_path().to_str()?; + + tracing::debug!( + "wrapping .ps1 with powershell: {} -File {}", + host.as_path().display(), + ps1_str, + ); + + let new_args: Arc<[Str]> = POWERSHELL_PREFIX + .iter() + .copied() + .map(Str::from) + .chain(std::iter::once(Str::from(ps1_str))) + .chain(args.iter().cloned()) + .collect(); + + Some((Arc::clone(host), new_args)) +} + +#[cfg(test)] +mod tests { + use std::{fs, sync::Arc}; + + use tempfile::tempdir; + use vite_path::{AbsolutePath, AbsolutePathBuf}; + use vite_str::Str; + + use super::wrap_with_host; + + #[expect(clippy::disallowed_types, reason = "tempdir bridges std PathBuf into AbsolutePath")] + fn abs_path(buf: std::path::PathBuf) -> Arc { + Arc::::from(AbsolutePathBuf::new(buf).unwrap()) + } + + #[test] + fn wraps_ps1_with_powershell_host() { + let dir = tempdir().unwrap(); + let ps1_path = dir.path().join("vite.ps1"); + fs::write(&ps1_path, "").unwrap(); + + let host = abs_path(dir.path().join("powershell.exe")); + let program = abs_path(ps1_path.clone()); + let args: Arc<[Str]> = Arc::from(vec![Str::from("--port"), Str::from("3000")]); + + let (rewritten_program, rewritten_args) = + wrap_with_host(&program, &args, &host).expect("should wrap"); + + assert_eq!(rewritten_program.as_path(), host.as_path()); + let as_strs: Vec<&str> = rewritten_args.iter().map(Str::as_str).collect(); + assert_eq!( + as_strs, + vec![ + "-NoProfile", + "-NoLogo", + "-ExecutionPolicy", + "Bypass", + "-File", + ps1_path.to_str().unwrap(), + "--port", + "3000", + ] + ); + } + + #[test] + fn returns_none_for_non_ps1_programs() { + let dir = tempdir().unwrap(); + let cmd_path = dir.path().join("vite.cmd"); + fs::write(&cmd_path, "").unwrap(); + + let host = abs_path(dir.path().join("powershell.exe")); + let program = abs_path(cmd_path); + let args: Arc<[Str]> = Arc::from(vec![Str::from("build")]); + + assert!(wrap_with_host(&program, &args, &host).is_none()); + } +} diff --git a/crates/vite_task/src/session/execute/spawn.rs b/crates/vite_task/src/session/execute/spawn.rs index 0f29926b..2fb43d36 100644 --- a/crates/vite_task/src/session/execute/spawn.rs +++ b/crates/vite_task/src/session/execute/spawn.rs @@ -54,8 +54,18 @@ pub async fn spawn( stdio: SpawnStdio, cancellation_token: CancellationToken, ) -> anyhow::Result { - let mut fspy_cmd = fspy::Command::new(cmd.program_path.as_path()); - fspy_cmd.args(cmd.args.iter().map(vite_str::Str::as_str)); + // `.ps1` scripts can't be exec'd directly on Windows; wrap them in + // `powershell.exe -File` when we see one. Selection of the `.ps1` target + // happens at plan time (see `vite_task_plan::ps1_shim`) so fingerprints + // key on the real script path. + let rewrite = super::powershell::wrap_ps1_with_powershell(&cmd.program_path, &cmd.args); + let (program_path, args) = match rewrite.as_ref() { + Some((p, a)) => (p.as_path(), a.as_ref()), + None => (cmd.program_path.as_path(), cmd.args.as_ref()), + }; + + let mut fspy_cmd = fspy::Command::new(program_path); + fspy_cmd.args(args.iter().map(vite_str::Str::as_str)); fspy_cmd.envs(cmd.all_envs.iter()); fspy_cmd.current_dir(&*cmd.cwd); diff --git a/crates/vite_task_plan/src/lib.rs b/crates/vite_task_plan/src/lib.rs index c749ef29..a20995c0 100644 --- a/crates/vite_task_plan/src/lib.rs +++ b/crates/vite_task_plan/src/lib.rs @@ -7,6 +7,7 @@ mod in_process; mod path_env; mod plan; pub mod plan_request; +pub mod ps1_shim; use std::{collections::BTreeMap, ffi::OsStr, fmt::Debug, sync::Arc}; diff --git a/crates/vite_task_plan/src/plan.rs b/crates/vite_task_plan/src/plan.rs index 8e657d84..189db3be 100644 --- a/crates/vite_task_plan/src/plan.rs +++ b/crates/vite_task_plan/src/plan.rs @@ -58,9 +58,9 @@ fn which( error: err, } })?; - Ok(AbsolutePathBuf::new(executable_path) - .expect("path returned by which::which_in should always be absolute") - .into()) + let absolute = AbsolutePathBuf::new(executable_path) + .expect("path returned by which::which_in should always be absolute"); + Ok(crate::ps1_shim::prefer_ps1_sibling(absolute).into()) } /// Compute the effective cache config for a task, applying the global cache config. diff --git a/crates/vite_task_plan/src/ps1_shim.rs b/crates/vite_task_plan/src/ps1_shim.rs new file mode 100644 index 00000000..9c6d5527 --- /dev/null +++ b/crates/vite_task_plan/src/ps1_shim.rs @@ -0,0 +1,152 @@ +//! Windows-specific: substitute `.cmd` shims in `node_modules/.bin` with +//! their sibling `.ps1`. +//! +//! Running a `.cmd` shim from any shell causes `cmd.exe` to prompt "Terminate +//! batch job (Y/N)?" on Ctrl+C, which leaves the terminal corrupt. Picking the +//! `.ps1` sibling at plan time makes the task graph record the script that +//! actually runs; spawn-time logic wraps it with `powershell.exe -File` so +//! Ctrl+C propagates without the `cmd.exe` hop. +//! +//! The substitution is limited to `node_modules/.bin/` triplets produced by +//! npm/pnpm/yarn (via cmd-shim, which only emits `.cmd` — not `.bat`) so +//! unrelated `.cmd` files elsewhere on PATH are left alone. +//! +//! See . + +use std::sync::Arc; + +use vite_path::{AbsolutePath, AbsolutePathBuf}; + +/// 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 at all). +/// +/// Exposed so the spawn layer wraps the `.ps1` with the same host this module +/// validated at plan time — there's one cache, one lookup. +#[cfg(windows)] +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 +} + +/// If `resolved` is a `.cmd` shim under `node_modules/.bin` with a sibling +/// `.ps1` **and** a PowerShell host is available to run that `.ps1`, return +/// the `.ps1` path. Otherwise return the input unchanged — so callers that +/// couldn't use PowerShell anyway fall back to the original `.cmd` instead of +/// producing a path `CreateProcess` can't execute. +#[cfg(windows)] +pub fn prefer_ps1_sibling(resolved: AbsolutePathBuf) -> AbsolutePathBuf { + if powershell_host().is_none() { + return resolved; + } + find_ps1_sibling(&resolved).unwrap_or(resolved) +} + +#[cfg(not(windows))] +#[must_use] +pub const fn prefer_ps1_sibling(resolved: AbsolutePathBuf) -> AbsolutePathBuf { + resolved +} + +#[cfg(any(windows, test))] +fn find_ps1_sibling(resolved: &AbsolutePath) -> Option { + let path = resolved.as_path(); + let ext = path.extension().and_then(|e| e.to_str())?; + if !ext.eq_ignore_ascii_case("cmd") { + return None; + } + + let mut parents = path.components().rev(); + parents.next()?; // shim filename + if !parents.next()?.as_os_str().eq_ignore_ascii_case(".bin") { + return None; + } + 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; + } + + let ps1 = AbsolutePathBuf::new(ps1)?; + tracing::debug!("preferring .ps1 sibling: {} -> {}", path.display(), ps1.as_path().display()); + Some(ps1) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::tempdir; + + use super::{AbsolutePath, AbsolutePathBuf, Arc, find_ps1_sibling}; + + #[expect(clippy::disallowed_types, reason = "tempdir bridges std PathBuf into AbsolutePath")] + fn abs(buf: std::path::PathBuf) -> Arc { + Arc::::from(AbsolutePathBuf::new(buf).unwrap()) + } + + #[expect(clippy::disallowed_types, reason = "tempdir hands out std Path for the test root")] + fn bin_dir(root: &std::path::Path) -> std::path::PathBuf { + let bin = root.join("node_modules").join(".bin"); + fs::create_dir_all(&bin).unwrap(); + bin + } + + #[test] + fn substitutes_cmd_when_ps1_sibling_exists_in_node_modules_bin() { + let dir = tempdir().unwrap(); + let bin = bin_dir(dir.path()); + fs::write(bin.join("vite.CMD"), "").unwrap(); + fs::write(bin.join("vite.ps1"), "").unwrap(); + + let resolved = abs(bin.join("vite.CMD")); + let result = find_ps1_sibling(&resolved).expect("should substitute"); + assert_eq!(result.as_path(), bin.join("vite.ps1")); + } + + #[test] + fn leaves_cmd_alone_without_ps1_sibling() { + let dir = tempdir().unwrap(); + let bin = bin_dir(dir.path()); + fs::write(bin.join("vite.cmd"), "").unwrap(); + + let resolved = abs(bin.join("vite.cmd")); + assert!(find_ps1_sibling(&resolved).is_none()); + } + + #[test] + fn leaves_cmd_alone_outside_node_modules_bin() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("where.cmd"), "").unwrap(); + fs::write(dir.path().join("where.ps1"), "").unwrap(); + + let resolved = abs(dir.path().join("where.cmd")); + assert!(find_ps1_sibling(&resolved).is_none()); + } + + #[test] + fn leaves_non_shim_extensions_alone() { + let dir = tempdir().unwrap(); + let bin = bin_dir(dir.path()); + fs::write(bin.join("node.exe"), "").unwrap(); + fs::write(bin.join("node.ps1"), "").unwrap(); + + let resolved = abs(bin.join("node.exe")); + assert!(find_ps1_sibling(&resolved).is_none()); + } +}