From 592f313ea64535f16a4011ef0a54066586f45b12 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 18 Apr 2026 14:21:01 +0800 Subject: [PATCH 01/19] fix(plan): prefer .ps1 over .cmd shim on Windows Spawning a .cmd shim (e.g. `vite.cmd`) from any shell forces Windows to start `cmd.exe`, which intercepts Ctrl+C with a "Terminate batch job (Y/N)?" prompt and leaves the terminal in a corrupt state. When the resolved binary is `.cmd`/`.bat` and a sibling `.ps1` exists, route the spawn through `powershell.exe -File` (preferring `pwsh.exe`) so Ctrl+C propagates cleanly without the cmd.exe hop. Closes voidzero-dev/vite-plus#1176 --- crates/vite_task_plan/src/lib.rs | 1 + crates/vite_task_plan/src/plan.rs | 9 +- crates/vite_task_plan/src/powershell.rs | 183 ++++++++++++++++++++++++ 3 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 crates/vite_task_plan/src/powershell.rs diff --git a/crates/vite_task_plan/src/lib.rs b/crates/vite_task_plan/src/lib.rs index c749ef29..c0e5cbb8 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; +mod powershell; 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..256eeda9 100644 --- a/crates/vite_task_plan/src/plan.rs +++ b/crates/vite_task_plan/src/plan.rs @@ -294,6 +294,11 @@ async fn plan_task_as_execution_node( &script_command.envs, &script_command.cwd, )?; + let (program_path, spawn_args) = + crate::powershell::rewrite_cmd_shim_to_powershell( + program_path, + script_command.args, + ); let resolved_options = ResolvedTaskOptions { cwd: Arc::clone(&task_node.resolved_config.resolved_options.cwd), cache_config: effective_cache_config( @@ -309,7 +314,7 @@ async fn plan_task_as_execution_node( &resolved_options, &script_command.envs, program_path, - script_command.args, + spawn_args, )?; ExecutionItemKind::Leaf(LeafExecutionKind::Spawn(spawn_execution)) } @@ -507,6 +512,8 @@ pub fn plan_synthetic_request( let SyntheticPlanRequest { program, args, cache_config, envs } = synthetic_plan_request; let program_path = which(&program, &envs, cwd)?; + let (program_path, args) = + crate::powershell::rewrite_cmd_shim_to_powershell(program_path, args); let resolved_cache_config = resolve_synthetic_cache_config( parent_cache_config, cache_config, diff --git a/crates/vite_task_plan/src/powershell.rs b/crates/vite_task_plan/src/powershell.rs new file mode 100644 index 00000000..960bc195 --- /dev/null +++ b/crates/vite_task_plan/src/powershell.rs @@ -0,0 +1,183 @@ +//! Windows-specific: prefer `.ps1` shims over `.cmd` shims. +//! +//! npm/pnpm/yarn install binary shims as `.cmd`, `.ps1`, and POSIX `.sh` triplets. +//! Spawning the `.cmd` wrapper from any shell causes `cmd.exe` to prompt +//! "Terminate batch job (Y/N)?" when the child exits via Ctrl+C, which leaves +//! the terminal in a corrupt state. Routing through the `.ps1` variant via +//! `powershell.exe -File` sidesteps that prompt. +//! +//! See . + +use std::sync::Arc; + +use vite_path::AbsolutePath; +use vite_str::Str; + +/// If `program_path` is a `.cmd`/`.bat` shim with a sibling `.ps1`, rewrite the +/// spawn to invoke the `.ps1` via PowerShell. Returns the inputs unchanged on +/// non-Windows platforms, when no sibling `.ps1` exists, or when no PowerShell +/// host (`pwsh.exe` / `powershell.exe`) can be located. +#[cfg_attr( + not(windows), + expect(clippy::missing_const_for_fn, reason = "Windows branch has runtime-only logic") +)] +pub fn rewrite_cmd_shim_to_powershell( + program_path: Arc, + args: Arc<[Str]>, +) -> (Arc, Arc<[Str]>) { + #[cfg(windows)] + { + let Some(host) = imp::cached_host() else { + return (program_path, args); + }; + imp::rewrite_with_host(program_path, args, host) + } + + #[cfg(not(windows))] + { + let _ = (&program_path, &args); + (program_path, args) + } +} + +#[cfg(windows)] +mod imp { + use std::sync::{Arc, LazyLock}; + + use vite_path::{AbsolutePath, AbsolutePathBuf}; + use vite_str::Str; + + /// 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. + 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) + }); + + pub(super) fn cached_host() -> Option<&'static Arc> { + POWERSHELL_HOST.as_ref() + } + + pub(super) fn rewrite_with_host( + program_path: Arc, + args: Arc<[Str]>, + host: &Arc, + ) -> (Arc, Arc<[Str]>) { + let ext = program_path + .as_path() + .extension() + .and_then(|e| e.to_str()) + .map(str::to_ascii_lowercase); + if !matches!(ext.as_deref(), Some("cmd" | "bat")) { + return (program_path, args); + } + + let ps1_path = program_path.as_path().with_extension("ps1"); + if !ps1_path.is_file() { + return (program_path, args); + } + + let Some(ps1_str) = ps1_path.to_str() else { + return (program_path, args); + }; + + tracing::debug!( + "rewriting cmd shim to powershell: {} -> {} -File {}", + program_path.as_path().display(), + host.as_path().display(), + ps1_str, + ); + + let mut new_args: Vec = Vec::with_capacity(6 + args.len()); + new_args.push(Str::from("-NoProfile")); + new_args.push(Str::from("-NoLogo")); + new_args.push(Str::from("-ExecutionPolicy")); + new_args.push(Str::from("Bypass")); + new_args.push(Str::from("-File")); + new_args.push(Str::from(ps1_str)); + new_args.extend(args.iter().cloned()); + + (Arc::clone(host), Arc::<[Str]>::from(new_args)) + } +} + +#[cfg(all(test, windows))] +mod tests { + use std::{fs, sync::Arc}; + + use tempfile::tempdir; + use vite_path::{AbsolutePath, AbsolutePathBuf}; + use vite_str::Str; + + use super::imp::rewrite_with_host; + + fn abs_path(buf: std::path::PathBuf) -> Arc { + Arc::::from(AbsolutePathBuf::new(buf).unwrap()) + } + + #[test] + fn rewrites_cmd_to_powershell_when_ps1_sibling_exists() { + let dir = tempdir().unwrap(); + let cmd_path = dir.path().join("vite.CMD"); + let ps1_path = dir.path().join("vite.ps1"); + fs::write(&cmd_path, "").unwrap(); + fs::write(&ps1_path, "").unwrap(); + + let host = abs_path(dir.path().join("powershell.exe")); + let args: Arc<[Str]> = Arc::from(vec![Str::from("--port"), Str::from("3000")]); + + let (program, new_args) = rewrite_with_host(abs_path(cmd_path), args, &host); + + assert_eq!(program.as_path(), host.as_path()); + let as_strs: Vec<&str> = new_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 leaves_cmd_unchanged_when_no_ps1_sibling() { + 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 args: Arc<[Str]> = Arc::from(vec![Str::from("build")]); + let original_program = abs_path(cmd_path); + + let (program, new_args) = + rewrite_with_host(Arc::clone(&original_program), Arc::clone(&args), &host); + + assert_eq!(program.as_path(), original_program.as_path()); + let as_strs: Vec<&str> = new_args.iter().map(Str::as_str).collect(); + assert_eq!(as_strs, vec!["build"]); + } + + #[test] + fn leaves_non_cmd_extensions_unchanged() { + let dir = tempdir().unwrap(); + let exe_path = dir.path().join("node.exe"); + fs::write(&exe_path, "").unwrap(); + // Even with a sibling .ps1, non-cmd/bat programs must not be rewritten. + fs::write(dir.path().join("node.ps1"), "").unwrap(); + + let host = abs_path(dir.path().join("powershell.exe")); + let args: Arc<[Str]> = Arc::from(vec![Str::from("--version")]); + let original_program = abs_path(exe_path); + + let (program, _new_args) = rewrite_with_host(Arc::clone(&original_program), args, &host); + + assert_eq!(program.as_path(), original_program.as_path()); + } +} From 62c9ce48583c25e617d556807bef01f1fb94bc4d Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 18 Apr 2026 14:27:29 +0800 Subject: [PATCH 02/19] refactor(plan): extract powershell prefix flags to const Address review feedback on #345: - Extract the 5 fixed PowerShell prefix flags to a `const POWERSHELL_PREFIX` - Build `new_args` via iterator chain instead of manual Vec pushes - Drop the no-op `let _ = (&program_path, &args);` on non-Windows --- crates/vite_task_plan/src/powershell.rs | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/crates/vite_task_plan/src/powershell.rs b/crates/vite_task_plan/src/powershell.rs index 960bc195..b0f2bee3 100644 --- a/crates/vite_task_plan/src/powershell.rs +++ b/crates/vite_task_plan/src/powershell.rs @@ -35,7 +35,6 @@ pub fn rewrite_cmd_shim_to_powershell( #[cfg(not(windows))] { - let _ = (&program_path, &args); (program_path, args) } } @@ -47,6 +46,12 @@ mod imp { use vite_path::{AbsolutePath, 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`. + const POWERSHELL_PREFIX: &[&str] = + &["-NoProfile", "-NoLogo", "-ExecutionPolicy", "Bypass", "-File"]; + /// 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. @@ -89,16 +94,15 @@ mod imp { ps1_str, ); - let mut new_args: Vec = Vec::with_capacity(6 + args.len()); - new_args.push(Str::from("-NoProfile")); - new_args.push(Str::from("-NoLogo")); - new_args.push(Str::from("-ExecutionPolicy")); - new_args.push(Str::from("Bypass")); - new_args.push(Str::from("-File")); - new_args.push(Str::from(ps1_str)); - new_args.extend(args.iter().cloned()); + 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(); - (Arc::clone(host), Arc::<[Str]>::from(new_args)) + (Arc::clone(host), new_args) } } From c03c631a3ef0010332dd00a3b97c574320b97891 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 18 Apr 2026 15:33:09 +0800 Subject: [PATCH 03/19] fix(spawn): move .cmd->.ps1 rewrite from plan layer to spawn layer Cursor review flagged that applying the rewrite at plan time leaks absolute `.ps1` paths and `powershell.exe` into `SpawnFingerprint`, breaking cache key portability across machines, CI, and repo moves (the cache was designed around relative paths). Move the rewrite to the spawn layer (`session/execute/spawn.rs`) so the fingerprint still keys on the original tool's relative path and args, while the actual spawn still routes through PowerShell. --- crates/vite_task/src/session/execute/mod.rs | 1 + .../src/session/execute}/powershell.rs | 8 ++++++-- crates/vite_task/src/session/execute/spawn.rs | 14 +++++++++++--- crates/vite_task_plan/src/lib.rs | 1 - crates/vite_task_plan/src/plan.rs | 9 +-------- 5 files changed, 19 insertions(+), 14 deletions(-) rename crates/{vite_task_plan/src => vite_task/src/session/execute}/powershell.rs (94%) 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_plan/src/powershell.rs b/crates/vite_task/src/session/execute/powershell.rs similarity index 94% rename from crates/vite_task_plan/src/powershell.rs rename to crates/vite_task/src/session/execute/powershell.rs index b0f2bee3..950da619 100644 --- a/crates/vite_task_plan/src/powershell.rs +++ b/crates/vite_task/src/session/execute/powershell.rs @@ -1,4 +1,4 @@ -//! Windows-specific: prefer `.ps1` shims over `.cmd` shims. +//! Windows-specific: prefer `.ps1` shims over `.cmd` shims at spawn time. //! //! npm/pnpm/yarn install binary shims as `.cmd`, `.ps1`, and POSIX `.sh` triplets. //! Spawning the `.cmd` wrapper from any shell causes `cmd.exe` to prompt @@ -6,6 +6,10 @@ //! the terminal in a corrupt state. Routing through the `.ps1` variant via //! `powershell.exe -File` sidesteps that prompt. //! +//! This lives in the spawn layer — not the plan layer — so the rewrite does +//! not leak absolute `.ps1` paths or `powershell.exe` into `SpawnFingerprint`, +//! keeping cache keys portable across machines and OSes. +//! //! See . use std::sync::Arc; @@ -21,7 +25,7 @@ use vite_str::Str; not(windows), expect(clippy::missing_const_for_fn, reason = "Windows branch has runtime-only logic") )] -pub fn rewrite_cmd_shim_to_powershell( +pub(super) fn rewrite_cmd_shim_to_powershell( program_path: Arc, args: Arc<[Str]>, ) -> (Arc, Arc<[Str]>) { diff --git a/crates/vite_task/src/session/execute/spawn.rs b/crates/vite_task/src/session/execute/spawn.rs index 0f29926b..7c1d7afb 100644 --- a/crates/vite_task/src/session/execute/spawn.rs +++ b/crates/vite_task/src/session/execute/spawn.rs @@ -4,7 +4,7 @@ //! cancellation-aware `wait` future. Draining the pipes is [`super::pipe`]'s //! job; normalizing fspy path accesses is [`super::tracked_accesses`]'s. -use std::{io, process::Stdio}; +use std::{io, process::Stdio, sync::Arc}; use fspy::PathAccessIterable; use futures_util::{FutureExt, future::LocalBoxFuture}; @@ -54,8 +54,16 @@ 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)); + // Re-route `.cmd`/`.bat` shims through PowerShell on Windows when a sibling + // `.ps1` exists. Done here (not at plan time) so cache fingerprints still + // key on the original tool's relative path and args. + let (program_path, args) = super::powershell::rewrite_cmd_shim_to_powershell( + Arc::clone(&cmd.program_path), + Arc::clone(&cmd.args), + ); + + let mut fspy_cmd = fspy::Command::new(program_path.as_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 c0e5cbb8..c749ef29 100644 --- a/crates/vite_task_plan/src/lib.rs +++ b/crates/vite_task_plan/src/lib.rs @@ -7,7 +7,6 @@ mod in_process; mod path_env; mod plan; pub mod plan_request; -mod powershell; 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 256eeda9..8e657d84 100644 --- a/crates/vite_task_plan/src/plan.rs +++ b/crates/vite_task_plan/src/plan.rs @@ -294,11 +294,6 @@ async fn plan_task_as_execution_node( &script_command.envs, &script_command.cwd, )?; - let (program_path, spawn_args) = - crate::powershell::rewrite_cmd_shim_to_powershell( - program_path, - script_command.args, - ); let resolved_options = ResolvedTaskOptions { cwd: Arc::clone(&task_node.resolved_config.resolved_options.cwd), cache_config: effective_cache_config( @@ -314,7 +309,7 @@ async fn plan_task_as_execution_node( &resolved_options, &script_command.envs, program_path, - spawn_args, + script_command.args, )?; ExecutionItemKind::Leaf(LeafExecutionKind::Spawn(spawn_execution)) } @@ -512,8 +507,6 @@ pub fn plan_synthetic_request( let SyntheticPlanRequest { program, args, cache_config, envs } = synthetic_plan_request; let program_path = which(&program, &envs, cwd)?; - let (program_path, args) = - crate::powershell::rewrite_cmd_shim_to_powershell(program_path, args); let resolved_cache_config = resolve_synthetic_cache_config( parent_cache_config, cache_config, From c2738fa709e28f73003c5ffbde0ef754e2fefab3 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 18 Apr 2026 15:37:40 +0800 Subject: [PATCH 04/19] fix(task): add missing `which` dep on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `.cmd`→`.ps1` rewrite moved from `vite_task_plan` (where `which` was already a dep) to `vite_task` without bringing the dep along. Windows CI failed with E0433. Only needed at `cfg(windows)`. --- crates/vite_task/Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/vite_task/Cargo.toml b/crates/vite_task/Cargo.toml index 7ba8f2ff..64db8f01 100644 --- a/crates/vite_task/Cargo.toml +++ b/crates/vite_task/Cargo.toml @@ -49,6 +49,7 @@ tempfile = { workspace = true } nix = { workspace = true } [target.'cfg(windows)'.dependencies] +which = { workspace = true } winapi = { workspace = true, features = ["handleapi", "jobapi2", "winnt"] } [lib] From 89ace994419761ac495717b7d696ddaa83c7aa6d Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 18 Apr 2026 15:45:39 +0800 Subject: [PATCH 05/19] chore: update Cargo.lock for vite_task which dep --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index bf11ea1b..d20563a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3962,6 +3962,7 @@ dependencies = [ "vite_task_plan", "vite_workspace", "wax", + "which", "winapi", "wincode", ] From 9bc5d8e6ec2ce2a74fd1ff240a3b8d1119c14ced Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 18 Apr 2026 15:58:13 +0800 Subject: [PATCH 06/19] refactor(spawn): simplify powershell rewrite - Return Option<(Arc, Arc)>; caller reuses original references on the passthrough path, dropping two unconditional Arc clones per spawn - Inline the `imp` submodule: two cfg-gated definitions replace the `cfg_attr(expect(missing_const_for_fn))` workaround - Use eq_ignore_ascii_case to drop the lowercase String allocation - Gate pure rewrite on `any(windows, test)` so tests run on every OS --- .../src/session/execute/powershell.rs | 187 ++++++++---------- crates/vite_task/src/session/execute/spawn.rs | 20 +- 2 files changed, 93 insertions(+), 114 deletions(-) diff --git a/crates/vite_task/src/session/execute/powershell.rs b/crates/vite_task/src/session/execute/powershell.rs index 950da619..0ee845a2 100644 --- a/crates/vite_task/src/session/execute/powershell.rs +++ b/crates/vite_task/src/session/execute/powershell.rs @@ -1,13 +1,11 @@ //! Windows-specific: prefer `.ps1` shims over `.cmd` shims at spawn time. //! -//! npm/pnpm/yarn install binary shims as `.cmd`, `.ps1`, and POSIX `.sh` triplets. -//! Spawning the `.cmd` wrapper from any shell causes `cmd.exe` to prompt -//! "Terminate batch job (Y/N)?" when the child exits via Ctrl+C, which leaves -//! the terminal in a corrupt state. Routing through the `.ps1` variant via -//! `powershell.exe -File` sidesteps that prompt. +//! Spawning a `.cmd` shim from any shell causes `cmd.exe` to prompt +//! "Terminate batch job (Y/N)?" on Ctrl+C, leaving the terminal corrupt. +//! Routing through the `.ps1` sibling via `powershell.exe -File` sidesteps it. //! -//! This lives in the spawn layer — not the plan layer — so the rewrite does -//! not leak absolute `.ps1` paths or `powershell.exe` into `SpawnFingerprint`, +//! Lives in the spawn layer — not the plan layer — so the rewrite does not +//! leak absolute `.ps1` paths or `powershell.exe` into `SpawnFingerprint`, //! keeping cache keys portable across machines and OSes. //! //! See . @@ -17,100 +15,82 @@ use std::sync::Arc; use vite_path::AbsolutePath; use vite_str::Str; -/// If `program_path` is a `.cmd`/`.bat` shim with a sibling `.ps1`, rewrite the -/// spawn to invoke the `.ps1` via PowerShell. Returns the inputs unchanged on -/// non-Windows platforms, when no sibling `.ps1` exists, or when no PowerShell -/// host (`pwsh.exe` / `powershell.exe`) can be located. -#[cfg_attr( - not(windows), - expect(clippy::missing_const_for_fn, reason = "Windows branch has runtime-only logic") -)] +/// 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 `.cmd`/`.bat` shim with a sibling `.ps1`, return a +/// rewritten `(powershell.exe, [-File , ...args])` invocation. Returns +/// `None` when no rewrite applies, so callers can reuse the original +/// references without cloning. +#[cfg(windows)] pub(super) fn rewrite_cmd_shim_to_powershell( - program_path: Arc, - args: Arc<[Str]>, -) -> (Arc, Arc<[Str]>) { - #[cfg(windows)] - { - let Some(host) = imp::cached_host() else { - return (program_path, args); - }; - imp::rewrite_with_host(program_path, args, host) - } + program_path: &Arc, + args: &Arc<[Str]>, +) -> Option<(Arc, Arc<[Str]>)> { + rewrite_with_host(program_path, args, POWERSHELL_HOST.as_ref()?) +} - #[cfg(not(windows))] - { - (program_path, args) - } +#[cfg(not(windows))] +pub(super) const fn rewrite_cmd_shim_to_powershell( + _program_path: &Arc, + _args: &Arc<[Str]>, +) -> Option<(Arc, Arc<[Str]>)> { + None } +/// 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. #[cfg(windows)] -mod imp { - use std::sync::{Arc, LazyLock}; - - use vite_path::{AbsolutePath, 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`. - const POWERSHELL_PREFIX: &[&str] = - &["-NoProfile", "-NoLogo", "-ExecutionPolicy", "Bypass", "-File"]; - - /// 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. - static POWERSHELL_HOST: LazyLock>> = LazyLock::new(|| { +static POWERSHELL_HOST: std::sync::LazyLock>> = + std::sync::LazyLock::new(|| { let resolved = which::which("pwsh.exe").or_else(|_| which::which("powershell.exe")).ok()?; - AbsolutePathBuf::new(resolved).map(Arc::::from) + vite_path::AbsolutePathBuf::new(resolved).map(Arc::::from) }); - pub(super) fn cached_host() -> Option<&'static Arc> { - 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))] +fn rewrite_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("cmd") || ext.eq_ignore_ascii_case("bat")) { + return None; } - pub(super) fn rewrite_with_host( - program_path: Arc, - args: Arc<[Str]>, - host: &Arc, - ) -> (Arc, Arc<[Str]>) { - let ext = program_path - .as_path() - .extension() - .and_then(|e| e.to_str()) - .map(str::to_ascii_lowercase); - if !matches!(ext.as_deref(), Some("cmd" | "bat")) { - return (program_path, args); - } - - let ps1_path = program_path.as_path().with_extension("ps1"); - if !ps1_path.is_file() { - return (program_path, args); - } - - let Some(ps1_str) = ps1_path.to_str() else { - return (program_path, args); - }; - - tracing::debug!( - "rewriting cmd shim to powershell: {} -> {} -File {}", - program_path.as_path().display(), - host.as_path().display(), - ps1_str, - ); + let ps1_path = program_path.as_path().with_extension("ps1"); + if !ps1_path.is_file() { + return None; + } - 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(); + let ps1_str = ps1_path.to_str()?; - (Arc::clone(host), new_args) - } + tracing::debug!( + "rewriting cmd shim to powershell: {} -> {} -File {}", + program_path.as_path().display(), + 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(all(test, windows))] +#[cfg(test)] mod tests { use std::{fs, sync::Arc}; @@ -118,8 +98,12 @@ mod tests { use vite_path::{AbsolutePath, AbsolutePathBuf}; use vite_str::Str; - use super::imp::rewrite_with_host; + use super::rewrite_with_host; + #[expect( + clippy::disallowed_types, + reason = "tempdir yields std PathBuf; test helper is the narrowest conversion point" + )] fn abs_path(buf: std::path::PathBuf) -> Arc { Arc::::from(AbsolutePathBuf::new(buf).unwrap()) } @@ -133,12 +117,14 @@ mod tests { fs::write(&ps1_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("--port"), Str::from("3000")]); - let (program, new_args) = rewrite_with_host(abs_path(cmd_path), args, &host); + let (rewritten_program, rewritten_args) = + rewrite_with_host(&program, &args, &host).expect("rewrite should apply"); - assert_eq!(program.as_path(), host.as_path()); - let as_strs: Vec<&str> = new_args.iter().map(Str::as_str).collect(); + 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![ @@ -155,25 +141,20 @@ mod tests { } #[test] - fn leaves_cmd_unchanged_when_no_ps1_sibling() { + fn returns_none_when_no_ps1_sibling() { 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")]); - let original_program = abs_path(cmd_path); - let (program, new_args) = - rewrite_with_host(Arc::clone(&original_program), Arc::clone(&args), &host); - - assert_eq!(program.as_path(), original_program.as_path()); - let as_strs: Vec<&str> = new_args.iter().map(Str::as_str).collect(); - assert_eq!(as_strs, vec!["build"]); + assert!(rewrite_with_host(&program, &args, &host).is_none()); } #[test] - fn leaves_non_cmd_extensions_unchanged() { + fn returns_none_for_non_shim_extensions() { let dir = tempdir().unwrap(); let exe_path = dir.path().join("node.exe"); fs::write(&exe_path, "").unwrap(); @@ -181,11 +162,9 @@ mod tests { fs::write(dir.path().join("node.ps1"), "").unwrap(); let host = abs_path(dir.path().join("powershell.exe")); + let program = abs_path(exe_path); let args: Arc<[Str]> = Arc::from(vec![Str::from("--version")]); - let original_program = abs_path(exe_path); - - let (program, _new_args) = rewrite_with_host(Arc::clone(&original_program), args, &host); - assert_eq!(program.as_path(), original_program.as_path()); + assert!(rewrite_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 7c1d7afb..72e92a73 100644 --- a/crates/vite_task/src/session/execute/spawn.rs +++ b/crates/vite_task/src/session/execute/spawn.rs @@ -4,7 +4,7 @@ //! cancellation-aware `wait` future. Draining the pipes is [`super::pipe`]'s //! job; normalizing fspy path accesses is [`super::tracked_accesses`]'s. -use std::{io, process::Stdio, sync::Arc}; +use std::{io, process::Stdio}; use fspy::PathAccessIterable; use futures_util::{FutureExt, future::LocalBoxFuture}; @@ -54,15 +54,15 @@ pub async fn spawn( stdio: SpawnStdio, cancellation_token: CancellationToken, ) -> anyhow::Result { - // Re-route `.cmd`/`.bat` shims through PowerShell on Windows when a sibling - // `.ps1` exists. Done here (not at plan time) so cache fingerprints still - // key on the original tool's relative path and args. - let (program_path, args) = super::powershell::rewrite_cmd_shim_to_powershell( - Arc::clone(&cmd.program_path), - Arc::clone(&cmd.args), - ); - - let mut fspy_cmd = fspy::Command::new(program_path.as_path()); + // Rewrite happens here, not at plan time, so cache fingerprints keep the + // original tool's portable path instead of leaking `powershell.exe` keys. + let rewrite = super::powershell::rewrite_cmd_shim_to_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); From fb1b8d2252dc8470968271d11ac85b611aded7d6 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 18 Apr 2026 22:00:27 +0800 Subject: [PATCH 07/19] fix(spawn): scope .ps1 rewrite to node_modules/.bin shims only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit System or user-installed .cmd/.bat files elsewhere on PATH (e.g., `C:\Windows\System32\where.cmd`) must not be silently rerouted through PowerShell — the triplet shape (.cmd + .ps1 + POSIX) is only guaranteed for npm/pnpm/yarn shims. Check the last two path components before rewriting. Adds a negative test for a .cmd outside node_modules/.bin. --- .../src/session/execute/powershell.rs | 64 +++++++++++++++---- 1 file changed, 53 insertions(+), 11 deletions(-) diff --git a/crates/vite_task/src/session/execute/powershell.rs b/crates/vite_task/src/session/execute/powershell.rs index 0ee845a2..5ed1c3c4 100644 --- a/crates/vite_task/src/session/execute/powershell.rs +++ b/crates/vite_task/src/session/execute/powershell.rs @@ -65,6 +65,19 @@ fn rewrite_with_host( return None; } + // Only rewrite shims under `node_modules/.bin` — the triplet + // (`.cmd` / `.ps1` / POSIX) is a known-safe shape produced by npm / + // pnpm / yarn. Any other `.cmd` / `.bat` on PATH (Windows system tools, + // user-installed tools) is left alone to avoid unintended side effects. + let mut parents = program_path.as_path().components().rev(); + parents.next()?; // shim filename + if parents.next()?.as_os_str() != ".bin" { + return None; + } + if parents.next()?.as_os_str() != "node_modules" { + return None; + } + let ps1_path = program_path.as_path().with_extension("ps1"); if !ps1_path.is_file() { return None; @@ -91,8 +104,16 @@ fn rewrite_with_host( } #[cfg(test)] +#[expect( + clippy::disallowed_types, + reason = "test fixtures bridge tempdir's std paths into AbsolutePath" +)] mod tests { - use std::{fs, sync::Arc}; + use std::{ + fs, + path::{Path, PathBuf}, + sync::Arc, + }; use tempfile::tempdir; use vite_path::{AbsolutePath, AbsolutePathBuf}; @@ -100,19 +121,22 @@ mod tests { use super::rewrite_with_host; - #[expect( - clippy::disallowed_types, - reason = "tempdir yields std PathBuf; test helper is the narrowest conversion point" - )] - fn abs_path(buf: std::path::PathBuf) -> Arc { + fn abs_path(buf: PathBuf) -> Arc { Arc::::from(AbsolutePathBuf::new(buf).unwrap()) } + fn bin_dir(root: &Path) -> PathBuf { + let bin = root.join("node_modules").join(".bin"); + fs::create_dir_all(&bin).unwrap(); + bin + } + #[test] fn rewrites_cmd_to_powershell_when_ps1_sibling_exists() { let dir = tempdir().unwrap(); - let cmd_path = dir.path().join("vite.CMD"); - let ps1_path = dir.path().join("vite.ps1"); + let bin = bin_dir(dir.path()); + let cmd_path = bin.join("vite.CMD"); + let ps1_path = bin.join("vite.ps1"); fs::write(&cmd_path, "").unwrap(); fs::write(&ps1_path, "").unwrap(); @@ -143,7 +167,8 @@ mod tests { #[test] fn returns_none_when_no_ps1_sibling() { let dir = tempdir().unwrap(); - let cmd_path = dir.path().join("vite.cmd"); + let bin = bin_dir(dir.path()); + let cmd_path = bin.join("vite.cmd"); fs::write(&cmd_path, "").unwrap(); let host = abs_path(dir.path().join("powershell.exe")); @@ -156,10 +181,11 @@ mod tests { #[test] fn returns_none_for_non_shim_extensions() { let dir = tempdir().unwrap(); - let exe_path = dir.path().join("node.exe"); + let bin = bin_dir(dir.path()); + let exe_path = bin.join("node.exe"); fs::write(&exe_path, "").unwrap(); // Even with a sibling .ps1, non-cmd/bat programs must not be rewritten. - fs::write(dir.path().join("node.ps1"), "").unwrap(); + fs::write(bin.join("node.ps1"), "").unwrap(); let host = abs_path(dir.path().join("powershell.exe")); let program = abs_path(exe_path); @@ -167,4 +193,20 @@ mod tests { assert!(rewrite_with_host(&program, &args, &host).is_none()); } + + #[test] + fn returns_none_for_cmd_outside_node_modules_bin() { + // System tools like `C:\Windows\System32\where.cmd` or user-installed + // `.cmd` wrappers must not be rerouted through PowerShell. + let dir = tempdir().unwrap(); + let cmd_path = dir.path().join("where.cmd"); + fs::write(&cmd_path, "").unwrap(); + fs::write(dir.path().join("where.ps1"), "").unwrap(); + + let host = abs_path(dir.path().join("powershell.exe")); + let program = abs_path(cmd_path); + let args: Arc<[Str]> = Arc::from(vec![]); + + assert!(rewrite_with_host(&program, &args, &host).is_none()); + } } From 3e1fc38a54eb87447a4456186cb4bdbfb6b416f3 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 18 Apr 2026 22:17:03 +0800 Subject: [PATCH 08/19] refactor(spawn): tighten powershell rewrite review points - Compare `.bin` / `node_modules` path components case-insensitively, matching the existing case-insensitive `.cmd`/`.bat` check and npm's de-facto lowercase naming (robust to symlinks/mounts) - Condense the scope-check comment to one line to match the repo style - Narrow `#[expect(clippy::disallowed_types)]` from the whole test module to just the two helpers that actually bridge std paths --- .../src/session/execute/powershell.rs | 25 ++++++------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/crates/vite_task/src/session/execute/powershell.rs b/crates/vite_task/src/session/execute/powershell.rs index 5ed1c3c4..06b61d85 100644 --- a/crates/vite_task/src/session/execute/powershell.rs +++ b/crates/vite_task/src/session/execute/powershell.rs @@ -65,16 +65,13 @@ fn rewrite_with_host( return None; } - // Only rewrite shims under `node_modules/.bin` — the triplet - // (`.cmd` / `.ps1` / POSIX) is a known-safe shape produced by npm / - // pnpm / yarn. Any other `.cmd` / `.bat` on PATH (Windows system tools, - // user-installed tools) is left alone to avoid unintended side effects. + // Limit to npm/pnpm/yarn `node_modules/.bin` shims; leave system tools alone. let mut parents = program_path.as_path().components().rev(); parents.next()?; // shim filename - if parents.next()?.as_os_str() != ".bin" { + if !parents.next()?.as_os_str().eq_ignore_ascii_case(".bin") { return None; } - if parents.next()?.as_os_str() != "node_modules" { + if !parents.next()?.as_os_str().eq_ignore_ascii_case("node_modules") { return None; } @@ -104,16 +101,8 @@ fn rewrite_with_host( } #[cfg(test)] -#[expect( - clippy::disallowed_types, - reason = "test fixtures bridge tempdir's std paths into AbsolutePath" -)] mod tests { - use std::{ - fs, - path::{Path, PathBuf}, - sync::Arc, - }; + use std::{fs, sync::Arc}; use tempfile::tempdir; use vite_path::{AbsolutePath, AbsolutePathBuf}; @@ -121,11 +110,13 @@ mod tests { use super::rewrite_with_host; - fn abs_path(buf: PathBuf) -> Arc { + #[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()) } - fn bin_dir(root: &Path) -> PathBuf { + #[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 From bbc7fc761df04b2d92a9c45135990f972f983346 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 18 Apr 2026 22:37:54 +0800 Subject: [PATCH 09/19] refactor: move .cmd->.ps1 selection to plan, keep only ps1 wrapping at spawn Record the script that actually runs in task metadata instead of the `.cmd` shim that never spawns: - New `vite_task_plan::ps1_shim::prefer_ps1_sibling` substitutes `node_modules/.bin/*.cmd` (or `.bat`) with the sibling `.ps1` when resolving binaries via `which()`. Applied case-insensitively and only under `node_modules/.bin` so system tools stay untouched. - `SpawnCommand.program_path` / `SpawnFingerprint` now reference the `.ps1` directly; cache keys stay portable because the path is still relative to the workspace. - `vite_task::session::execute::powershell` shrinks to just the powershell wrapping: if a program ends in `.ps1`, wrap the spawn as `powershell.exe -NoProfile -NoLogo -ExecutionPolicy Bypass -File `. No more filesystem stat or path-shape check at spawn time. - Tests split accordingly: sibling selection is covered in ps1_shim; spawn tests only verify the powershell wrap. --- .../src/session/execute/powershell.rs | 118 ++++------------- crates/vite_task/src/session/execute/spawn.rs | 8 +- crates/vite_task_plan/src/lib.rs | 1 + crates/vite_task_plan/src/plan.rs | 6 +- crates/vite_task_plan/src/ps1_shim.rs | 121 ++++++++++++++++++ 5 files changed, 156 insertions(+), 98 deletions(-) create mode 100644 crates/vite_task_plan/src/ps1_shim.rs diff --git a/crates/vite_task/src/session/execute/powershell.rs b/crates/vite_task/src/session/execute/powershell.rs index 06b61d85..e25d12e0 100644 --- a/crates/vite_task/src/session/execute/powershell.rs +++ b/crates/vite_task/src/session/execute/powershell.rs @@ -1,14 +1,7 @@ -//! Windows-specific: prefer `.ps1` shims over `.cmd` shims at spawn time. -//! -//! Spawning a `.cmd` shim from any shell causes `cmd.exe` to prompt -//! "Terminate batch job (Y/N)?" on Ctrl+C, leaving the terminal corrupt. -//! Routing through the `.ps1` sibling via `powershell.exe -File` sidesteps it. -//! -//! Lives in the spawn layer — not the plan layer — so the rewrite does not -//! leak absolute `.ps1` paths or `powershell.exe` into `SpawnFingerprint`, -//! keeping cache keys portable across machines and OSes. -//! -//! See . +//! 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. use std::sync::Arc; @@ -22,29 +15,29 @@ use vite_str::Str; const POWERSHELL_PREFIX: &[&str] = &["-NoProfile", "-NoLogo", "-ExecutionPolicy", "Bypass", "-File"]; -/// If `program_path` is a `.cmd`/`.bat` shim with a sibling `.ps1`, return a -/// rewritten `(powershell.exe, [-File , ...args])` invocation. Returns -/// `None` when no rewrite applies, so callers can reuse the original -/// references without cloning. +/// 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 rewrite_cmd_shim_to_powershell( +pub(super) fn wrap_ps1_with_powershell( program_path: &Arc, args: &Arc<[Str]>, ) -> Option<(Arc, Arc<[Str]>)> { - rewrite_with_host(program_path, args, POWERSHELL_HOST.as_ref()?) + wrap_with_host(program_path, args, POWERSHELL_HOST.as_ref()?) } #[cfg(not(windows))] -pub(super) const fn rewrite_cmd_shim_to_powershell( +pub(super) const fn wrap_ps1_with_powershell( _program_path: &Arc, _args: &Arc<[Str]>, ) -> Option<(Arc, Arc<[Str]>)> { None } -/// 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. +/// Cached location of the PowerShell host. Prefers cross-platform `pwsh.exe` +/// when present, falling back to the Windows built-in `powershell.exe`. +/// `None` means no host was found in PATH. #[cfg(windows)] static POWERSHELL_HOST: std::sync::LazyLock>> = std::sync::LazyLock::new(|| { @@ -55,36 +48,20 @@ static POWERSHELL_HOST: std::sync::LazyLock>> = /// 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 rewrite_with_host( +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("cmd") || ext.eq_ignore_ascii_case("bat")) { + if !ext.eq_ignore_ascii_case("ps1") { return None; } - // Limit to npm/pnpm/yarn `node_modules/.bin` shims; leave system tools alone. - let mut parents = program_path.as_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 = program_path.as_path().with_extension("ps1"); - if !ps1_path.is_file() { - return None; - } - - let ps1_str = ps1_path.to_str()?; + let ps1_str = program_path.as_path().to_str()?; tracing::debug!( - "rewriting cmd shim to powershell: {} -> {} -File {}", - program_path.as_path().display(), + "wrapping .ps1 with powershell: {} -File {}", host.as_path().display(), ps1_str, ); @@ -108,35 +85,25 @@ mod tests { use vite_path::{AbsolutePath, AbsolutePathBuf}; use vite_str::Str; - use super::rewrite_with_host; + 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()) } - #[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 rewrites_cmd_to_powershell_when_ps1_sibling_exists() { + fn wraps_ps1_with_powershell_host() { let dir = tempdir().unwrap(); - let bin = bin_dir(dir.path()); - let cmd_path = bin.join("vite.CMD"); - let ps1_path = bin.join("vite.ps1"); - fs::write(&cmd_path, "").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(cmd_path); + 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) = - rewrite_with_host(&program, &args, &host).expect("rewrite should apply"); + 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(); @@ -156,48 +123,15 @@ mod tests { } #[test] - fn returns_none_when_no_ps1_sibling() { + fn returns_none_for_non_ps1_programs() { let dir = tempdir().unwrap(); - let bin = bin_dir(dir.path()); - let cmd_path = bin.join("vite.cmd"); + 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!(rewrite_with_host(&program, &args, &host).is_none()); - } - - #[test] - fn returns_none_for_non_shim_extensions() { - let dir = tempdir().unwrap(); - let bin = bin_dir(dir.path()); - let exe_path = bin.join("node.exe"); - fs::write(&exe_path, "").unwrap(); - // Even with a sibling .ps1, non-cmd/bat programs must not be rewritten. - fs::write(bin.join("node.ps1"), "").unwrap(); - - let host = abs_path(dir.path().join("powershell.exe")); - let program = abs_path(exe_path); - let args: Arc<[Str]> = Arc::from(vec![Str::from("--version")]); - - assert!(rewrite_with_host(&program, &args, &host).is_none()); - } - - #[test] - fn returns_none_for_cmd_outside_node_modules_bin() { - // System tools like `C:\Windows\System32\where.cmd` or user-installed - // `.cmd` wrappers must not be rerouted through PowerShell. - let dir = tempdir().unwrap(); - let cmd_path = dir.path().join("where.cmd"); - fs::write(&cmd_path, "").unwrap(); - fs::write(dir.path().join("where.ps1"), "").unwrap(); - - let host = abs_path(dir.path().join("powershell.exe")); - let program = abs_path(cmd_path); - let args: Arc<[Str]> = Arc::from(vec![]); - - assert!(rewrite_with_host(&program, &args, &host).is_none()); + 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 72e92a73..2fb43d36 100644 --- a/crates/vite_task/src/session/execute/spawn.rs +++ b/crates/vite_task/src/session/execute/spawn.rs @@ -54,9 +54,11 @@ pub async fn spawn( stdio: SpawnStdio, cancellation_token: CancellationToken, ) -> anyhow::Result { - // Rewrite happens here, not at plan time, so cache fingerprints keep the - // original tool's portable path instead of leaking `powershell.exe` keys. - let rewrite = super::powershell::rewrite_cmd_shim_to_powershell(&cmd.program_path, &cmd.args); + // `.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()), diff --git a/crates/vite_task_plan/src/lib.rs b/crates/vite_task_plan/src/lib.rs index c749ef29..9fb00e8a 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; +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..3d0f2d2d --- /dev/null +++ b/crates/vite_task_plan/src/ps1_shim.rs @@ -0,0 +1,121 @@ +//! Windows-specific: substitute `.cmd`/`.bat` 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 so unrelated `.cmd` / `.bat` files on PATH are left alone. +//! +//! See . + +use vite_path::AbsolutePathBuf; + +/// If `resolved` is a `.cmd`/`.bat` shim under `node_modules/.bin` with a +/// sibling `.ps1`, return the `.ps1` path. Returns the input unchanged on +/// non-Windows platforms and for any path that doesn't match. +#[cfg_attr( + not(windows), + expect(clippy::missing_const_for_fn, reason = "Windows branch has runtime-only logic") +)] +pub fn prefer_ps1_sibling(resolved: AbsolutePathBuf) -> AbsolutePathBuf { + #[cfg(windows)] + if let Some(ps1) = find_ps1_sibling(&resolved) { + return ps1; + } + resolved +} + +#[cfg(any(windows, test))] +fn find_ps1_sibling(resolved: &vite_path::AbsolutePath) -> Option { + let path = resolved.as_path(); + let ext = path.extension().and_then(|e| e.to_str())?; + if !(ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("bat")) { + 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)] +#[expect(clippy::disallowed_types, reason = "tests bridge tempdir's std paths into AbsolutePath")] +mod tests { + use std::{fs, path::Path, sync::Arc}; + + use tempfile::tempdir; + use vite_path::{AbsolutePath, AbsolutePathBuf}; + + use super::find_ps1_sibling; + + fn abs(buf: std::path::PathBuf) -> Arc { + Arc::::from(AbsolutePathBuf::new(buf).unwrap()) + } + + fn bin_dir(root: &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()); + } +} From 26021ca154c9de676b3a37ff415f7a9a821e03a9 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 18 Apr 2026 22:42:49 +0800 Subject: [PATCH 10/19] refactor(ps1_shim): align style with powershell module - Swap `cfg_attr(not(windows), expect(missing_const_for_fn))` for two cfg-gated function definitions, matching the pattern already used in `session::execute::powershell` - Move `#[expect(clippy::disallowed_types)]` from the test module down to the two helper functions that actually bridge tempdir's std paths --- crates/vite_task_plan/src/ps1_shim.rs | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/crates/vite_task_plan/src/ps1_shim.rs b/crates/vite_task_plan/src/ps1_shim.rs index 3d0f2d2d..73e59d4f 100644 --- a/crates/vite_task_plan/src/ps1_shim.rs +++ b/crates/vite_task_plan/src/ps1_shim.rs @@ -17,15 +17,13 @@ use vite_path::AbsolutePathBuf; /// If `resolved` is a `.cmd`/`.bat` shim under `node_modules/.bin` with a /// sibling `.ps1`, return the `.ps1` path. Returns the input unchanged on /// non-Windows platforms and for any path that doesn't match. -#[cfg_attr( - not(windows), - expect(clippy::missing_const_for_fn, reason = "Windows branch has runtime-only logic") -)] +#[cfg(windows)] pub fn prefer_ps1_sibling(resolved: AbsolutePathBuf) -> AbsolutePathBuf { - #[cfg(windows)] - if let Some(ps1) = find_ps1_sibling(&resolved) { - return ps1; - } + find_ps1_sibling(&resolved).unwrap_or(resolved) +} + +#[cfg(not(windows))] +pub const fn prefer_ps1_sibling(resolved: AbsolutePathBuf) -> AbsolutePathBuf { resolved } @@ -57,20 +55,21 @@ fn find_ps1_sibling(resolved: &vite_path::AbsolutePath) -> Option Arc { Arc::::from(AbsolutePathBuf::new(buf).unwrap()) } - fn bin_dir(root: &Path) -> std::path::PathBuf { + #[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 From bed84075fe2f7912f07e545f12cec7257fdc61eb Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 19 Apr 2026 15:29:35 +0800 Subject: [PATCH 11/19] refactor(ps1_shim): drop .bat handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm/pnpm/yarn's cmd-shim only emits `.cmd`/`.ps1`/POSIX triplets — `.bat` never shows up in a `node_modules/.bin` shim. The extra check was noise. --- crates/vite_task_plan/src/ps1_shim.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/vite_task_plan/src/ps1_shim.rs b/crates/vite_task_plan/src/ps1_shim.rs index 73e59d4f..fca72068 100644 --- a/crates/vite_task_plan/src/ps1_shim.rs +++ b/crates/vite_task_plan/src/ps1_shim.rs @@ -1,4 +1,4 @@ -//! Windows-specific: substitute `.cmd`/`.bat` shims in `node_modules/.bin` with +//! 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 @@ -8,15 +8,16 @@ //! Ctrl+C propagates without the `cmd.exe` hop. //! //! The substitution is limited to `node_modules/.bin/` triplets produced by -//! npm/pnpm/yarn so unrelated `.cmd` / `.bat` files on PATH are left alone. +//! npm/pnpm/yarn (via cmd-shim, which only emits `.cmd` — not `.bat`) so +//! unrelated `.cmd` files elsewhere on PATH are left alone. //! //! See . use vite_path::AbsolutePathBuf; -/// If `resolved` is a `.cmd`/`.bat` shim under `node_modules/.bin` with a -/// sibling `.ps1`, return the `.ps1` path. Returns the input unchanged on -/// non-Windows platforms and for any path that doesn't match. +/// If `resolved` is a `.cmd` shim under `node_modules/.bin` with a sibling +/// `.ps1`, return the `.ps1` path. Returns the input unchanged on non-Windows +/// platforms and for any path that doesn't match. #[cfg(windows)] pub fn prefer_ps1_sibling(resolved: AbsolutePathBuf) -> AbsolutePathBuf { find_ps1_sibling(&resolved).unwrap_or(resolved) @@ -31,7 +32,7 @@ pub const fn prefer_ps1_sibling(resolved: AbsolutePathBuf) -> AbsolutePathBuf { fn find_ps1_sibling(resolved: &vite_path::AbsolutePath) -> Option { let path = resolved.as_path(); let ext = path.extension().and_then(|e| e.to_str())?; - if !(ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("bat")) { + if !ext.eq_ignore_ascii_case("cmd") { return None; } From 99493b5ee859af9de58b3a25c53162b94149736e Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 19 Apr 2026 15:45:30 +0800 Subject: [PATCH 12/19] fix(ps1_shim): fall back to .cmd when no PowerShell host is on PATH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor flagged: `prefer_ps1_sibling` was substituting `.cmd` → `.ps1` unconditionally at plan time. If neither `pwsh.exe` nor `powershell.exe` is on PATH, spawn-time `wrap_ps1_with_powershell` returns `None`, and `CreateProcess` then tries to run the raw `.ps1` and fails. Before this PR the `.cmd` would have worked (with the Ctrl+C bug, but at least it ran). Gate the plan-time substitution on PowerShell host availability: - Move `POWERSHELL_HOST` from spawn layer into `ps1_shim` and expose it via `pub fn powershell_host()`. One cached lookup shared by both layers — plan decides only when spawn will actually be able to invoke the `.ps1`. - `prefer_ps1_sibling` returns the input unchanged when `powershell_host()` is `None`, so PowerShell-less Windows systems keep using the `.cmd` path (same behavior as before this PR). - `session::execute::powershell` reuses the shared host, dropping its local `LazyLock` and the now-unused `which` dep on `vite_task`. --- Cargo.lock | 1 - crates/vite_task/Cargo.toml | 1 - .../src/session/execute/powershell.rs | 15 ++----- crates/vite_task_plan/src/lib.rs | 2 +- crates/vite_task_plan/src/ps1_shim.rs | 45 ++++++++++++++++--- 5 files changed, 42 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d20563a5..bf11ea1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3962,7 +3962,6 @@ dependencies = [ "vite_task_plan", "vite_workspace", "wax", - "which", "winapi", "wincode", ] diff --git a/crates/vite_task/Cargo.toml b/crates/vite_task/Cargo.toml index 64db8f01..7ba8f2ff 100644 --- a/crates/vite_task/Cargo.toml +++ b/crates/vite_task/Cargo.toml @@ -49,7 +49,6 @@ tempfile = { workspace = true } nix = { workspace = true } [target.'cfg(windows)'.dependencies] -which = { workspace = true } winapi = { workspace = true, features = ["handleapi", "jobapi2", "winnt"] } [lib] diff --git a/crates/vite_task/src/session/execute/powershell.rs b/crates/vite_task/src/session/execute/powershell.rs index e25d12e0..8ae407b5 100644 --- a/crates/vite_task/src/session/execute/powershell.rs +++ b/crates/vite_task/src/session/execute/powershell.rs @@ -1,7 +1,8 @@ //! 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. +//! 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; @@ -24,7 +25,7 @@ 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.as_ref()?) + wrap_with_host(program_path, args, vite_task_plan::ps1_shim::powershell_host()?) } #[cfg(not(windows))] @@ -35,16 +36,6 @@ pub(super) const fn wrap_ps1_with_powershell( None } -/// Cached location of the PowerShell host. Prefers cross-platform `pwsh.exe` -/// when present, falling back to the Windows built-in `powershell.exe`. -/// `None` means no host was found in PATH. -#[cfg(windows)] -static POWERSHELL_HOST: std::sync::LazyLock>> = - std::sync::LazyLock::new(|| { - let resolved = which::which("pwsh.exe").or_else(|_| which::which("powershell.exe")).ok()?; - vite_path::AbsolutePathBuf::new(resolved).map(Arc::::from) - }); - /// 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))] diff --git a/crates/vite_task_plan/src/lib.rs b/crates/vite_task_plan/src/lib.rs index 9fb00e8a..a20995c0 100644 --- a/crates/vite_task_plan/src/lib.rs +++ b/crates/vite_task_plan/src/lib.rs @@ -7,7 +7,7 @@ mod in_process; mod path_env; mod plan; pub mod plan_request; -mod ps1_shim; +pub mod ps1_shim; use std::{collections::BTreeMap, ffi::OsStr, fmt::Debug, sync::Arc}; diff --git a/crates/vite_task_plan/src/ps1_shim.rs b/crates/vite_task_plan/src/ps1_shim.rs index fca72068..9c6d5527 100644 --- a/crates/vite_task_plan/src/ps1_shim.rs +++ b/crates/vite_task_plan/src/ps1_shim.rs @@ -13,23 +13,55 @@ //! //! See . -use vite_path::AbsolutePathBuf; +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`, return the `.ps1` path. Returns the input unchanged on non-Windows -/// platforms and for any path that doesn't match. +/// `.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: &vite_path::AbsolutePath) -> Option { +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") { @@ -57,12 +89,11 @@ fn find_ps1_sibling(resolved: &vite_path::AbsolutePath) -> Option Arc { From be08530e16712f3f107d1ec936e7a210e79cc379 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 19 Apr 2026 15:48:44 +0800 Subject: [PATCH 13/19] refactor(spawn): use cfg-gated import for powershell_host --- crates/vite_task/src/session/execute/powershell.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/vite_task/src/session/execute/powershell.rs b/crates/vite_task/src/session/execute/powershell.rs index 8ae407b5..8af6ae12 100644 --- a/crates/vite_task/src/session/execute/powershell.rs +++ b/crates/vite_task/src/session/execute/powershell.rs @@ -8,6 +8,8 @@ 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 @@ -25,7 +27,7 @@ pub(super) fn wrap_ps1_with_powershell( program_path: &Arc, args: &Arc<[Str]>, ) -> Option<(Arc, Arc<[Str]>)> { - wrap_with_host(program_path, args, vite_task_plan::ps1_shim::powershell_host()?) + wrap_with_host(program_path, args, powershell_host()?) } #[cfg(not(windows))] From 89b2233823259c68bc920007fc5e10b6cbaaae78 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 20 Apr 2026 10:16:45 +0800 Subject: [PATCH 14/19] refactor: move full .cmd->powershell rewrite into the plan layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything user-visible about the substitution now lives in the task graph. The spawn layer is back to a 1-to-1 copy of `SpawnCommand` — nothing it does could surprise someone reading the plan. - `ps1_shim::rewrite_cmd_shim_with_args` now returns the full `(program, args)` pair: program = args = [-NoProfile, -NoLogo, -ExecutionPolicy, Bypass, -File, , ...original_args] `SpawnCommand.program_path` / `SpawnFingerprint.program_fingerprint` record the PowerShell host itself (as `OutsideWorkspace`, so only the program name enters the fingerprint — the absolute path doesn't). - `plan.rs::which()` hands back a plain resolved path; both call sites now apply the rewrite explicitly right before `plan_spawn_execution`. - Removed `vite_task::session::execute::powershell` and the `wrap_ps1_with_powershell` step from `spawn::spawn`. Nothing in the spawn crate needs the `which` dep anymore. - Unit tests updated to test `rewrite_with_host` returning the full `(program, args)` pair, with fixtures covering: happy path, missing .ps1 sibling, .cmd outside node_modules/.bin, non-.cmd extensions. All run cross-platform via the injected host. --- crates/vite_task/src/session/execute/mod.rs | 1 - .../src/session/execute/powershell.rs | 130 ------------ crates/vite_task/src/session/execute/spawn.rs | 14 +- crates/vite_task_plan/src/plan.rs | 13 +- crates/vite_task_plan/src/ps1_shim.rs | 190 ++++++++++++------ 5 files changed, 142 insertions(+), 206 deletions(-) delete mode 100644 crates/vite_task/src/session/execute/powershell.rs diff --git a/crates/vite_task/src/session/execute/mod.rs b/crates/vite_task/src/session/execute/mod.rs index af844629..8af09888 100644 --- a/crates/vite_task/src/session/execute/mod.rs +++ b/crates/vite_task/src/session/execute/mod.rs @@ -2,7 +2,6 @@ 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 deleted file mode 100644 index 8af6ae12..00000000 --- a/crates/vite_task/src/session/execute/powershell.rs +++ /dev/null @@ -1,130 +0,0 @@ -//! 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 2fb43d36..0f29926b 100644 --- a/crates/vite_task/src/session/execute/spawn.rs +++ b/crates/vite_task/src/session/execute/spawn.rs @@ -54,18 +54,8 @@ pub async fn spawn( stdio: SpawnStdio, cancellation_token: CancellationToken, ) -> anyhow::Result { - // `.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)); + let mut fspy_cmd = fspy::Command::new(cmd.program_path.as_path()); + fspy_cmd.args(cmd.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/plan.rs b/crates/vite_task_plan/src/plan.rs index 189db3be..17650569 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, } })?; - 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()) + Ok(AbsolutePathBuf::new(executable_path) + .expect("path returned by which::which_in should always be absolute") + .into()) } /// Compute the effective cache config for a task, applying the global cache config. @@ -294,6 +294,10 @@ async fn plan_task_as_execution_node( &script_command.envs, &script_command.cwd, )?; + let (program_path, spawn_args) = crate::ps1_shim::rewrite_cmd_shim_with_args( + program_path, + script_command.args, + ); let resolved_options = ResolvedTaskOptions { cwd: Arc::clone(&task_node.resolved_config.resolved_options.cwd), cache_config: effective_cache_config( @@ -309,7 +313,7 @@ async fn plan_task_as_execution_node( &resolved_options, &script_command.envs, program_path, - script_command.args, + spawn_args, )?; ExecutionItemKind::Leaf(LeafExecutionKind::Spawn(spawn_execution)) } @@ -507,6 +511,7 @@ pub fn plan_synthetic_request( let SyntheticPlanRequest { program, args, cache_config, envs } = synthetic_plan_request; let program_path = which(&program, &envs, cwd)?; + let (program_path, args) = crate::ps1_shim::rewrite_cmd_shim_with_args(program_path, args); let resolved_cache_config = resolve_synthetic_cache_config( parent_cache_config, cache_config, diff --git a/crates/vite_task_plan/src/ps1_shim.rs b/crates/vite_task_plan/src/ps1_shim.rs index 9c6d5527..2e972c8b 100644 --- a/crates/vite_task_plan/src/ps1_shim.rs +++ b/crates/vite_task_plan/src/ps1_shim.rs @@ -1,13 +1,15 @@ -//! Windows-specific: substitute `.cmd` shims in `node_modules/.bin` with -//! their sibling `.ps1`. +//! Windows-specific: rewrite `.cmd` shims in `node_modules/.bin` so the plan +//! records a `powershell.exe -File ` invocation in place of the +//! `.cmd` hop. //! //! 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. +//! batch job (Y/N)?" on Ctrl+C, which leaves the terminal corrupt. Rewriting +//! to the `.ps1` sibling, invoked via `powershell.exe -File`, sidesteps that +//! prompt. Doing the rewrite at plan time (rather than at spawn time) means +//! the command shown in the task graph and cache fingerprint is the command +//! that actually runs. //! -//! The substitution is limited to `node_modules/.bin/` triplets produced by +//! The rewrite 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. //! @@ -15,17 +17,52 @@ use std::sync::Arc; -use vite_path::{AbsolutePath, AbsolutePathBuf}; +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"]; + +/// If `resolved` is a `.cmd` shim under `node_modules/.bin` with a sibling +/// `.ps1` and a PowerShell host is available, return a rewritten invocation +/// `(powershell.exe, [-NoProfile, -NoLogo, -ExecutionPolicy, Bypass, -File, , ...args])`. +/// Otherwise return `(resolved, args)` unchanged — so callers that can't use +/// PowerShell fall back to the original `.cmd` instead of producing a path +/// `CreateProcess` can't execute. +#[cfg(windows)] +pub fn rewrite_cmd_shim_with_args( + resolved: Arc, + args: Arc<[Str]>, +) -> (Arc, Arc<[Str]>) { + if let Some(host) = powershell_host() + && let Some(rewritten) = rewrite_with_host(&resolved, &args, host) + { + return rewritten; + } + (resolved, args) +} + +#[cfg(not(windows))] +#[must_use] +pub const fn rewrite_cmd_shim_with_args( + resolved: Arc, + args: Arc<[Str]>, +) -> (Arc, Arc<[Str]>) { + (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 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. +/// aren't on Windows). #[cfg(windows)] -pub fn powershell_host() -> Option<&'static Arc> { +fn powershell_host() -> Option<&'static Arc> { use std::sync::LazyLock; static POWERSHELL_HOST: LazyLock>> = LazyLock::new(|| { @@ -35,29 +72,33 @@ pub fn powershell_host() -> Option<&'static Arc> { 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 +/// 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 rewrite_with_host( + resolved: &Arc, + args: &Arc<[Str]>, + host: &Arc, +) -> Option<(Arc, Arc<[Str]>)> { + let ps1 = find_ps1_sibling(resolved)?; + let ps1_str = ps1.as_path().to_str()?; + + tracing::debug!( + "rewriting .cmd shim to powershell: {} -> {} -File {}", + resolved.as_path().display(), + 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(any(windows, test))] @@ -82,9 +123,7 @@ fn find_ps1_sibling(resolved: &AbsolutePath) -> Option { return None; } - let ps1 = AbsolutePathBuf::new(ps1)?; - tracing::debug!("preferring .ps1 sibling: {} -> {}", path.display(), ps1.as_path().display()); - Some(ps1) + AbsolutePathBuf::new(ps1) } #[cfg(test)] @@ -93,7 +132,7 @@ mod tests { use tempfile::tempdir; - use super::{AbsolutePath, AbsolutePathBuf, Arc, find_ps1_sibling}; + use super::{AbsolutePath, AbsolutePathBuf, Arc, Str, rewrite_with_host}; #[expect(clippy::disallowed_types, reason = "tempdir bridges std PathBuf into AbsolutePath")] fn abs(buf: std::path::PathBuf) -> Arc { @@ -108,45 +147,78 @@ mod tests { } #[test] - fn substitutes_cmd_when_ps1_sibling_exists_in_node_modules_bin() { + fn rewrites_cmd_to_powershell_invocation() { 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")); + let cmd_path = bin.join("vite.CMD"); + let ps1_path = bin.join("vite.ps1"); + fs::write(&cmd_path, "").unwrap(); + fs::write(&ps1_path, "").unwrap(); + + let host = abs(dir.path().join("powershell.exe")); + let resolved = abs(cmd_path); + let args: Arc<[Str]> = Arc::from(vec![Str::from("--port"), Str::from("3000")]); + + let (program, rewritten_args) = + rewrite_with_host(&resolved, &args, &host).expect("should rewrite"); + + assert_eq!(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 leaves_cmd_alone_without_ps1_sibling() { + fn returns_none_when_no_ps1_sibling() { let dir = tempdir().unwrap(); let bin = bin_dir(dir.path()); - fs::write(bin.join("vite.cmd"), "").unwrap(); + let cmd_path = bin.join("vite.cmd"); + fs::write(&cmd_path, "").unwrap(); - let resolved = abs(bin.join("vite.cmd")); - assert!(find_ps1_sibling(&resolved).is_none()); + let host = abs(dir.path().join("powershell.exe")); + let resolved = abs(cmd_path); + let args: Arc<[Str]> = Arc::from(vec![Str::from("build")]); + + assert!(rewrite_with_host(&resolved, &args, &host).is_none()); } #[test] - fn leaves_cmd_alone_outside_node_modules_bin() { + fn returns_none_for_cmd_outside_node_modules_bin() { let dir = tempdir().unwrap(); - fs::write(dir.path().join("where.cmd"), "").unwrap(); + let cmd_path = dir.path().join("where.cmd"); + fs::write(&cmd_path, "").unwrap(); fs::write(dir.path().join("where.ps1"), "").unwrap(); - let resolved = abs(dir.path().join("where.cmd")); - assert!(find_ps1_sibling(&resolved).is_none()); + let host = abs(dir.path().join("powershell.exe")); + let resolved = abs(cmd_path); + let args: Arc<[Str]> = Arc::from(vec![]); + + assert!(rewrite_with_host(&resolved, &args, &host).is_none()); } #[test] - fn leaves_non_shim_extensions_alone() { + fn returns_none_for_non_shim_extensions() { let dir = tempdir().unwrap(); let bin = bin_dir(dir.path()); - fs::write(bin.join("node.exe"), "").unwrap(); + let exe_path = bin.join("node.exe"); + fs::write(&exe_path, "").unwrap(); fs::write(bin.join("node.ps1"), "").unwrap(); - let resolved = abs(bin.join("node.exe")); - assert!(find_ps1_sibling(&resolved).is_none()); + let host = abs(dir.path().join("powershell.exe")); + let resolved = abs(exe_path); + let args: Arc<[Str]> = Arc::from(vec![Str::from("--version")]); + + assert!(rewrite_with_host(&resolved, &args, &host).is_none()); } } From 68ebd5d0e4a40e07b23805b46065f93c61b8d52f Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 20 Apr 2026 10:27:15 +0800 Subject: [PATCH 15/19] refactor(ps1_shim): demote module and add #[must_use] parity - `ps1_shim` only has intra-crate consumers now (the spawn-layer module that used it is gone), so drop the `pub` on the module declaration. `pub fn rewrite_cmd_shim_with_args` stays visible to `plan.rs` because the module is still crate-visible internally. - Add `#[must_use]` to the Windows variant for parity with the non-Windows passthrough. --- crates/vite_task_plan/src/lib.rs | 2 +- crates/vite_task_plan/src/ps1_shim.rs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/vite_task_plan/src/lib.rs b/crates/vite_task_plan/src/lib.rs index a20995c0..9fb00e8a 100644 --- a/crates/vite_task_plan/src/lib.rs +++ b/crates/vite_task_plan/src/lib.rs @@ -7,7 +7,7 @@ mod in_process; mod path_env; mod plan; pub mod plan_request; -pub mod ps1_shim; +mod ps1_shim; use std::{collections::BTreeMap, ffi::OsStr, fmt::Debug, sync::Arc}; diff --git a/crates/vite_task_plan/src/ps1_shim.rs b/crates/vite_task_plan/src/ps1_shim.rs index 2e972c8b..e36efbb3 100644 --- a/crates/vite_task_plan/src/ps1_shim.rs +++ b/crates/vite_task_plan/src/ps1_shim.rs @@ -36,6 +36,7 @@ const POWERSHELL_PREFIX: &[&str] = /// PowerShell fall back to the original `.cmd` instead of producing a path /// `CreateProcess` can't execute. #[cfg(windows)] +#[must_use] pub fn rewrite_cmd_shim_with_args( resolved: Arc, args: Arc<[Str]>, From 9f2aac081679f60747682311b871544b1dc3610c Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 20 Apr 2026 10:36:29 +0800 Subject: [PATCH 16/19] test(plan): add Windows-only plan snapshot for cmd->powershell rewrite Adds a `windows_cmd_shim_rewrite` fixture that exercises the full plan pipeline: given a `node_modules/.bin/vite.cmd` + `vite.ps1` pair in a workspace, the generated `SpawnCommand` should invoke PowerShell with the `.ps1` and the original args appended. - Teaches the plan_snapshots harness a per-fixture `windows_only` flag in `snapshots.toml`. Non-Windows runs return early before calling the planner, so the case never tries to resolve a `.cmd` via Unix-style `which::which_in` (which doesn't honor PATHEXT). - Adds two redactions in `redact.rs`: `` for the program name/path regardless of whether CI finds `pwsh` or `powershell`, so the snapshot doesn't pin a specific Windows runner image layout. - Bumps the `too_many_lines` allow on `redact_snapshot` (it's a linear sequence of passes). - Pre-writes the expected snapshot based on how redactions compose; if a future Windows runner diverges, regenerate with UPDATE_SNAPSHOTS=1. --- .../windows_cmd_shim_rewrite/package.json | 5 + .../windows_cmd_shim_rewrite/snapshots.toml | 9 ++ ...ery_cmd_shim_rewritten_to_powershell.jsonc | 97 +++++++++++++++++++ .../snapshots/task_graph.jsonc | 37 +++++++ .../tests/plan_snapshots/main.rs | 10 ++ .../tests/plan_snapshots/redact.rs | 31 ++++++ 6 files changed, 189 insertions(+) create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/package.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots.toml create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots/query_cmd_shim_rewritten_to_powershell.jsonc create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots/task_graph.jsonc diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/package.json new file mode 100644 index 00000000..1e38c9e6 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/package.json @@ -0,0 +1,5 @@ +{ + "scripts": { + "dev": "vite --port 3000" + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots.toml b/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots.toml new file mode 100644 index 00000000..bfecff20 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots.toml @@ -0,0 +1,9 @@ +# Windows-only: verifies that a `.cmd` shim under `node_modules/.bin` with a +# sibling `.ps1` is rewritten at plan time to a `powershell.exe -File ` +# invocation. Requires Windows to exercise PATHEXT-driven `.cmd` resolution and +# a real PowerShell host. +windows_only = true + +[[plan]] +name = "cmd_shim_rewritten_to_powershell" +args = ["run", "dev"] diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots/query_cmd_shim_rewritten_to_powershell.jsonc b/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots/query_cmd_shim_rewritten_to_powershell.jsonc new file mode 100644 index 00000000..53191bf7 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots/query_cmd_shim_rewritten_to_powershell.jsonc @@ -0,0 +1,97 @@ +// run dev +{ + "graph": [ + { + "key": [ + "/", + "dev" + ], + "node": { + "task_display": { + "package_name": "", + "task_name": "dev", + "package_path": "/" + }, + "items": [ + { + "execution_item_display": { + "task_display": { + "package_name": "", + "task_name": "dev", + "package_path": "/" + }, + "command": "vite --port 3000", + "and_item_index": null, + "cwd": "/" + }, + "kind": { + "Leaf": { + "Spawn": { + "cache_metadata": { + "spawn_fingerprint": { + "cwd": "", + "program_fingerprint": { + "OutsideWorkspace": { + "program_name": "" + } + }, + "args": [ + "-NoProfile", + "-NoLogo", + "-ExecutionPolicy", + "Bypass", + "-File", + "/node_modules/.bin/vite.ps1", + "--port", + "3000" + ], + "env_fingerprints": { + "fingerprinted_envs": {}, + "untracked_env_config": [ + "" + ] + } + }, + "execution_cache_key": { + "UserTask": { + "task_name": "dev", + "and_item_index": 0, + "extra_args": [], + "package_path": "" + } + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + } + }, + "spawn_command": { + "program_path": "", + "args": [ + "-NoProfile", + "-NoLogo", + "-ExecutionPolicy", + "Bypass", + "-File", + "/node_modules/.bin/vite.ps1", + "--port", + "3000" + ], + "all_envs": { + "NO_COLOR": "1", + "PATH": "/node_modules/.bin:" + }, + "cwd": "/" + } + } + } + } + } + ] + }, + "neighbors": [] + } + ], + "concurrency_limit": 4 +} \ No newline at end of file diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots/task_graph.jsonc b/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots/task_graph.jsonc new file mode 100644 index 00000000..471aef7d --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots/task_graph.jsonc @@ -0,0 +1,37 @@ +// task graph +[ + { + "key": [ + "/", + "dev" + ], + "node": { + "task_display": { + "package_name": "", + "task_name": "dev", + "package_path": "/" + }, + "resolved_config": { + "command": "vite --port 3000", + "resolved_options": { + "cwd": "/", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "untracked_env": [ + "" + ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + } + } + } + }, + "source": "PackageJsonScript" + }, + "neighbors": [] + } +] \ No newline at end of file diff --git a/crates/vite_task_plan/tests/plan_snapshots/main.rs b/crates/vite_task_plan/tests/plan_snapshots/main.rs index 6ebf7504..a803730e 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/main.rs +++ b/crates/vite_task_plan/tests/plan_snapshots/main.rs @@ -42,6 +42,12 @@ struct Plan { #[derive(serde::Deserialize, Default)] struct SnapshotsFile { + /// When `true`, the fixture only runs on Windows. Useful for cases whose + /// task-resolution or plan output depends on Windows-specific behavior + /// (e.g. PATHEXT-driven `.cmd` lookups), since those wouldn't resolve at + /// all under Unix-style `which::which_in`. + #[serde(default)] + pub windows_only: bool, #[serde(rename = "plan", default)] // toml usually uses singular for arrays pub plan_cases: Vec, } @@ -163,6 +169,10 @@ fn run_case_inner( Err(err) => panic!("Failed to read cases.toml for fixture {fixture_name}: {err}"), }; + if cases_file.windows_only && !cfg!(windows) { + return Ok(()); + } + let fake_bin_dir = std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()) .join("tests/plan_snapshots/fake-bin"); let combined_path = diff --git a/crates/vite_task_plan/tests/plan_snapshots/redact.rs b/crates/vite_task_plan/tests/plan_snapshots/redact.rs index 01e8ca3d..65beec4f 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/redact.rs +++ b/crates/vite_task_plan/tests/plan_snapshots/redact.rs @@ -29,6 +29,34 @@ fn redact_string_in_json(value: &mut serde_json::Value, redactions: &[(&str, &st }); } +/// Replace the bare name `pwsh` / `powershell` (post-extension-strip) with a +/// stable placeholder. The two hosts are interchangeable for our purposes, +/// so the snapshot shouldn't care which one Windows CI happens to find. +#[expect( + clippy::disallowed_types, + reason = "String mutation required by serde_json::Value::String which stores a String" +)] +fn redact_powershell_program_name(s: &mut String) { + if s == "pwsh" || s == "powershell" { + *s = "".to_string(); + } +} + +/// Replace an absolute PowerShell-host path (post-extension-strip) with a +/// stable placeholder so the snapshot doesn't pin a particular runner image's +/// system layout (e.g. `C:\Windows\System32\WindowsPowerShell\v1.0\powershell` +/// vs `C:\Program Files\PowerShell\7\pwsh`). +#[expect( + clippy::disallowed_types, + reason = "String mutation required by serde_json::Value::String which stores a String" +)] +fn redact_powershell_program_path(s: &mut String) { + let normalized = s.as_str().cow_replace('\\', "/").cow_to_lowercase().into_owned(); + if normalized.ends_with("/powershell") || normalized.ends_with("/pwsh") { + *s = "".to_string(); + } +} + /// Strip Windows executable extensions (case-insensitive) for cross-platform consistency #[expect( clippy::disallowed_types, @@ -60,6 +88,7 @@ fn redact_string(s: &mut String, redactions: &[(&str, &str)]) { } } +#[expect(clippy::too_many_lines, reason = "linear sequence of redaction passes")] pub fn redact_snapshot(value: &impl Serialize, workspace_root: &str) -> serde_json::Value { let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); #[expect(clippy::disallowed_types, reason = "PathBuf needed to build fake-bin path from env")] @@ -109,10 +138,12 @@ pub fn redact_snapshot(value: &impl Serialize, workspace_root: &str) -> serde_js // Normalize program_name field if let Some(serde_json::Value::String(program_name)) = map.get_mut("program_name") { strip_windows_executable_extension(program_name); + redact_powershell_program_name(program_name); } // Normalize program_path field if let Some(serde_json::Value::String(program_path)) = map.get_mut("program_path") { strip_windows_executable_extension(program_path); + redact_powershell_program_path(program_path); } }); From 2f045f7f91e09d0a52bead88a24b91c4c54c3e91 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 20 Apr 2026 10:42:45 +0800 Subject: [PATCH 17/19] perf(redact): short-circuit powershell path check before allocating --- crates/vite_task_plan/tests/plan_snapshots/redact.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/vite_task_plan/tests/plan_snapshots/redact.rs b/crates/vite_task_plan/tests/plan_snapshots/redact.rs index 65beec4f..d280476f 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/redact.rs +++ b/crates/vite_task_plan/tests/plan_snapshots/redact.rs @@ -51,6 +51,11 @@ fn redact_powershell_program_name(s: &mut String) { reason = "String mutation required by serde_json::Value::String which stores a String" )] fn redact_powershell_program_path(s: &mut String) { + // Avoid allocating a normalized copy for paths that clearly aren't a + // PowerShell host. `contains` is case-sensitive; match both casings. + if !(s.contains("powershell") || s.contains("PowerShell") || s.contains("pwsh")) { + return; + } let normalized = s.as_str().cow_replace('\\', "/").cow_to_lowercase().into_owned(); if normalized.ends_with("/powershell") || normalized.ends_with("/pwsh") { *s = "".to_string(); From 56e229039946f9bd239327be1bd13cb5ae9142ab Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 20 Apr 2026 10:44:19 +0800 Subject: [PATCH 18/19] fix(tests): include fixture node_modules/.bin in git The root `.gitignore` has `node_modules`, which silently dropped the snapshot-fixture shims (`vite.cmd`, `vite.ps1`) from the commit that introduced them. Without the shims on disk, Windows CI's `which::which_in("vite")` has nothing to resolve and the plan-layer cmd->powershell rewrite never fires. Negate the `node_modules` ignore specifically for `plan_snapshots/fixtures/*/node_modules` so any future fixture that needs to fake an npm install can do so without further gitignore edits. Other `node_modules` trees stay ignored. --- .gitignore | 3 +++ .../windows_cmd_shim_rewrite/node_modules/.bin/vite.cmd | 0 .../windows_cmd_shim_rewrite/node_modules/.bin/vite.ps1 | 0 3 files changed, 3 insertions(+) create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/node_modules/.bin/vite.cmd create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/node_modules/.bin/vite.ps1 diff --git a/.gitignore b/.gitignore index 666c3d4b..39f96204 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ /target node_modules +# Fixture node_modules that stand in for real npm installs in snapshot tests. +!crates/vite_task_plan/tests/plan_snapshots/fixtures/*/node_modules +!crates/vite_task_plan/tests/plan_snapshots/fixtures/*/node_modules/** dist .claude/settings.local.json *.tsbuildinfo diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/node_modules/.bin/vite.cmd b/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/node_modules/.bin/vite.cmd new file mode 100644 index 00000000..e69de29b diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/node_modules/.bin/vite.ps1 b/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/node_modules/.bin/vite.ps1 new file mode 100644 index 00000000..e69de29b From 8096e0add57c73c6f3834abe1c7d551f39f7bdd5 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 20 Apr 2026 10:45:03 +0800 Subject: [PATCH 19/19] chore(gitignore): drop redundant node_modules/** negation --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 39f96204..de9d227e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,6 @@ node_modules # Fixture node_modules that stand in for real npm installs in snapshot tests. !crates/vite_task_plan/tests/plan_snapshots/fixtures/*/node_modules -!crates/vite_task_plan/tests/plan_snapshots/fixtures/*/node_modules/** dist .claude/settings.local.json *.tsbuildinfo