Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/vite_task/src/session/execute/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
130 changes: 130 additions & 0 deletions crates/vite_task/src/session/execute/powershell.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
//! On Windows, `CreateProcess` cannot execute a `.ps1` script directly.
//! When the planner has already picked a `.ps1` (see `vite_task_plan::ps1_shim`),
//! this module wraps the spawn as `powershell.exe -File <ps1> <args…>`,
//! 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 <ps1>, ...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<AbsolutePath>,
args: &Arc<[Str]>,
) -> Option<(Arc<AbsolutePath>, Arc<[Str]>)> {
wrap_with_host(program_path, args, powershell_host()?)
}

#[cfg(not(windows))]
pub(super) const fn wrap_ps1_with_powershell(
_program_path: &Arc<AbsolutePath>,
_args: &Arc<[Str]>,
) -> Option<(Arc<AbsolutePath>, 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<AbsolutePath>,
args: &Arc<[Str]>,
host: &Arc<AbsolutePath>,
) -> Option<(Arc<AbsolutePath>, 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<AbsolutePath> {
Arc::<AbsolutePath>::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());
}
}
14 changes: 12 additions & 2 deletions crates/vite_task/src/session/execute/spawn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,18 @@ pub async fn spawn(
stdio: SpawnStdio,
cancellation_token: CancellationToken,
) -> anyhow::Result<ChildHandle> {
let mut fspy_cmd = fspy::Command::new(cmd.program_path.as_path());
fspy_cmd.args(cmd.args.iter().map(vite_str::Str::as_str));
// `.ps1` scripts can't be exec'd directly on Windows; wrap them in
// `powershell.exe -File` when we see one. Selection of the `.ps1` target
// happens at plan time (see `vite_task_plan::ps1_shim`) so fingerprints
// key on the real script path.
let rewrite = super::powershell::wrap_ps1_with_powershell(&cmd.program_path, &cmd.args);
let (program_path, args) = match rewrite.as_ref() {
Some((p, a)) => (p.as_path(), a.as_ref()),
None => (cmd.program_path.as_path(), cmd.args.as_ref()),
};

let mut fspy_cmd = fspy::Command::new(program_path);
fspy_cmd.args(args.iter().map(vite_str::Str::as_str));
fspy_cmd.envs(cmd.all_envs.iter());
fspy_cmd.current_dir(&*cmd.cwd);

Expand Down
1 change: 1 addition & 0 deletions crates/vite_task_plan/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ mod in_process;
mod path_env;
mod plan;
pub mod plan_request;
pub mod ps1_shim;

use std::{collections::BTreeMap, ffi::OsStr, fmt::Debug, sync::Arc};

Expand Down
6 changes: 3 additions & 3 deletions crates/vite_task_plan/src/plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
152 changes: 152 additions & 0 deletions crates/vite_task_plan/src/ps1_shim.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
//! Windows-specific: substitute `.cmd` shims in `node_modules/.bin` with
//! their sibling `.ps1`.
//!
//! Running a `.cmd` shim from any shell causes `cmd.exe` to prompt "Terminate
//! batch job (Y/N)?" on Ctrl+C, which leaves the terminal corrupt. Picking the
//! `.ps1` sibling at plan time makes the task graph record the script that
//! actually runs; spawn-time logic wraps it with `powershell.exe -File` so
//! Ctrl+C propagates without the `cmd.exe` hop.
//!
//! The substitution is limited to `node_modules/.bin/` triplets produced by
//! npm/pnpm/yarn (via cmd-shim, which only emits `.cmd` — not `.bat`) so
//! unrelated `.cmd` files elsewhere on PATH are left alone.
//!
//! See <https://github.com/voidzero-dev/vite-plus/issues/1176>.

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<AbsolutePath>> {
use std::sync::LazyLock;

static POWERSHELL_HOST: LazyLock<Option<Arc<AbsolutePath>>> = LazyLock::new(|| {
let resolved = which::which("pwsh.exe").or_else(|_| which::which("powershell.exe")).ok()?;
AbsolutePathBuf::new(resolved).map(Arc::<AbsolutePath>::from)
});
POWERSHELL_HOST.as_ref()
}

#[cfg(not(windows))]
#[must_use]
pub const fn powershell_host() -> Option<&'static Arc<AbsolutePath>> {
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)
}
Comment thread
cursor[bot] marked this conversation as resolved.

#[cfg(not(windows))]
#[must_use]
pub const fn prefer_ps1_sibling(resolved: AbsolutePathBuf) -> AbsolutePathBuf {
resolved
}

#[cfg(any(windows, test))]
fn find_ps1_sibling(resolved: &AbsolutePath) -> Option<AbsolutePathBuf> {
let path = resolved.as_path();
let ext = path.extension().and_then(|e| e.to_str())?;
if !ext.eq_ignore_ascii_case("cmd") {
return None;
}

let mut parents = path.components().rev();
parents.next()?; // shim filename
if !parents.next()?.as_os_str().eq_ignore_ascii_case(".bin") {
return None;
}
if !parents.next()?.as_os_str().eq_ignore_ascii_case("node_modules") {
return None;
}

let ps1 = path.with_extension("ps1");
if !ps1.is_file() {
return None;
}

let ps1 = AbsolutePathBuf::new(ps1)?;
tracing::debug!("preferring .ps1 sibling: {} -> {}", path.display(), ps1.as_path().display());
Some(ps1)
}

#[cfg(test)]
mod tests {
use std::fs;

use tempfile::tempdir;

use super::{AbsolutePath, AbsolutePathBuf, Arc, find_ps1_sibling};

#[expect(clippy::disallowed_types, reason = "tempdir bridges std PathBuf into AbsolutePath")]
fn abs(buf: std::path::PathBuf) -> Arc<AbsolutePath> {
Arc::<AbsolutePath>::from(AbsolutePathBuf::new(buf).unwrap())
}

#[expect(clippy::disallowed_types, reason = "tempdir hands out std Path for the test root")]
fn bin_dir(root: &std::path::Path) -> std::path::PathBuf {
let bin = root.join("node_modules").join(".bin");
fs::create_dir_all(&bin).unwrap();
bin
}

#[test]
fn substitutes_cmd_when_ps1_sibling_exists_in_node_modules_bin() {
let dir = tempdir().unwrap();
let bin = bin_dir(dir.path());
fs::write(bin.join("vite.CMD"), "").unwrap();
fs::write(bin.join("vite.ps1"), "").unwrap();

let resolved = abs(bin.join("vite.CMD"));
let result = find_ps1_sibling(&resolved).expect("should substitute");
assert_eq!(result.as_path(), bin.join("vite.ps1"));
}

#[test]
fn leaves_cmd_alone_without_ps1_sibling() {
let dir = tempdir().unwrap();
let bin = bin_dir(dir.path());
fs::write(bin.join("vite.cmd"), "").unwrap();

let resolved = abs(bin.join("vite.cmd"));
assert!(find_ps1_sibling(&resolved).is_none());
}

#[test]
fn leaves_cmd_alone_outside_node_modules_bin() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("where.cmd"), "").unwrap();
fs::write(dir.path().join("where.ps1"), "").unwrap();

let resolved = abs(dir.path().join("where.cmd"));
assert!(find_ps1_sibling(&resolved).is_none());
}

#[test]
fn leaves_non_shim_extensions_alone() {
let dir = tempdir().unwrap();
let bin = bin_dir(dir.path());
fs::write(bin.join("node.exe"), "").unwrap();
fs::write(bin.join("node.ps1"), "").unwrap();

let resolved = abs(bin.join("node.exe"));
assert!(find_ps1_sibling(&resolved).is_none());
}
}
Loading