Skip to content

Commit 2da60ef

Browse files
committed
fix(command): route Windows .cmd shims through PowerShell for pm commands
Apply the same .cmd → .ps1 rewrite to `vite_command::run_command` (and `run_command_with_fspy`) so package-manager-routed commands (`vp dlx`, `vp add`, `vp remove`, `vp update`, `vp install`, …) stop spawning `cmd.exe` on Windows and Ctrl+C no longer leaves the terminal in a broken state. The task-layer fix (`vite_task_plan::ps1_shim`) only covered `node_modules/.bin/*.cmd` — too narrow for pm shims like `npm.cmd` / `pnpm.cmd` / `yarn.cmd` which live in `~/.vite-plus/js_runtime/...` and system PATH. The new module drops the `node_modules/.bin` check: if a `.cmd` has a sibling `.ps1`, route through PowerShell. Closes #1489
1 parent b83605b commit 2da60ef

2 files changed

Lines changed: 217 additions & 10 deletions

File tree

crates/vite_command/src/lib.rs

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ use tokio_util::sync::CancellationToken;
1212
use vite_error::Error;
1313
use vite_path::{AbsolutePath, AbsolutePathBuf, RelativePathBuf};
1414

15+
mod ps1_shim;
16+
1517
/// Result of running a command with fspy tracking.
1618
#[derive(Debug)]
1719
pub struct FspyCommandResult {
@@ -142,8 +144,12 @@ where
142144
let cwd = cwd.as_ref();
143145
let paths = envs.get("PATH");
144146
let bin_path = resolve_bin(bin_name, paths.map(|p| OsStr::new(p.as_str())), cwd)?;
145-
let mut cmd = build_command(&bin_path, cwd);
146-
cmd.args(args).envs(envs);
147+
let (program, prefix_args) = match ps1_shim::rewrite_cmd_to_powershell(&bin_path) {
148+
Some(rewritten) => rewritten,
149+
None => (bin_path, Vec::new()),
150+
};
151+
let mut cmd = build_command(&program, cwd);
152+
cmd.args(&prefix_args).args(args).envs(envs);
147153
let status = cmd.status().await?;
148154
Ok(status)
149155
}
@@ -171,8 +177,15 @@ where
171177
S: AsRef<OsStr>,
172178
{
173179
let cwd = cwd.as_ref();
174-
let mut cmd = fspy::Command::new(bin_name);
175-
cmd.args(args)
180+
let bin_path = resolve_bin(bin_name, envs.get("PATH").map(|p| OsStr::new(p.as_str())), cwd)?;
181+
let (program, prefix_args) = match ps1_shim::rewrite_cmd_to_powershell(&bin_path) {
182+
Some(rewritten) => rewritten,
183+
None => (bin_path, Vec::new()),
184+
};
185+
186+
let mut cmd = fspy::Command::new(program.as_path());
187+
cmd.args(&prefix_args)
188+
.args(args)
176189
// set system environment variables first
177190
.envs(std::env::vars_os())
178191
// then set custom environment variables
@@ -454,12 +467,9 @@ mod tests {
454467
run_command_with_fspy("npm-not-exists", &["--version"], &envs, &temp_dir_path)
455468
.await;
456469
assert!(result.is_err(), "Should not find binary path, but got: {:?}", result);
457-
assert!(
458-
result
459-
.err()
460-
.unwrap()
461-
.to_string()
462-
.contains("could not resolve the full path of program '\"npm-not-exists\"'")
470+
assert_eq!(
471+
result.unwrap_err().to_string(),
472+
"Cannot find binary path for command 'npm-not-exists'"
463473
);
464474
}
465475
}
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
//! Windows-specific: when a resolved binary is a `.cmd` shim with a sibling
2+
//! `.ps1`, rewrite the spawn to go through `powershell.exe -File <sibling.ps1>`.
3+
//!
4+
//! Running a `.cmd` from any shell makes `cmd.exe` prompt "Terminate batch
5+
//! job (Y/N)?" on Ctrl+C, which leaves the terminal corrupt. Routing through
6+
//! `PowerShell` sidesteps the prompt and lets Ctrl+C propagate cleanly.
7+
//!
8+
//! Unlike the task-layer rewrite (`vite_task_plan::ps1_shim`, scoped to
9+
//! `node_modules/.bin/*.cmd` inside the workspace), this one applies to any
10+
//! `.cmd` whose `.ps1` sibling exists. Package manager shims (`npm.cmd`,
11+
//! `pnpm.cmd`, `yarn.cmd`, `npx.cmd`) live in `~/.vite-plus/js_runtime/...`
12+
//! or system PATH — outside any `node_modules/.bin` — so the structural
13+
//! check there is too narrow for this code path. If a `.ps1` sibling exists,
14+
//! the same tool that emitted the `.cmd` (npm cmd-shim, pnpm, yarn) emitted
15+
//! the `.ps1` with equivalent semantics.
16+
//!
17+
//! See <https://github.com/voidzero-dev/vite-plus/issues/1489>
18+
//! and <https://github.com/voidzero-dev/vite-plus/issues/1176>.
19+
20+
use std::ffi::OsString;
21+
22+
use vite_path::{AbsolutePath, AbsolutePathBuf};
23+
24+
/// Fixed arguments prepended before the `.ps1` path. `-NoProfile`/`-NoLogo`
25+
/// skip user profile loading; `-ExecutionPolicy Bypass` allows running the
26+
/// unsigned shims that npm/pnpm/yarn install.
27+
#[cfg(any(windows, test))]
28+
const POWERSHELL_PREFIX: &[&str] =
29+
&["-NoProfile", "-NoLogo", "-ExecutionPolicy", "Bypass", "-File"];
30+
31+
/// Rewrite a resolved `.cmd` invocation to go through PowerShell.
32+
///
33+
/// Returns `Some((powershell_host, prefix_args))` when the rewrite applies,
34+
/// where `prefix_args` is `["-NoProfile", "-NoLogo", "-ExecutionPolicy",
35+
/// "Bypass", "-File", <abs ps1 path>]`. Caller prepends `prefix_args` to the
36+
/// user args and spawns `powershell_host`.
37+
///
38+
/// Returns `None` when:
39+
/// - not on Windows,
40+
/// - no PowerShell host (`pwsh.exe` or `powershell.exe`) is on PATH,
41+
/// - the resolved path is not a `.cmd` (case-insensitive),
42+
/// - the `.cmd` has no sibling `.ps1`.
43+
#[cfg(windows)]
44+
#[must_use]
45+
pub fn rewrite_cmd_to_powershell(
46+
resolved: &AbsolutePath,
47+
) -> Option<(AbsolutePathBuf, Vec<OsString>)> {
48+
let host = powershell_host()?;
49+
rewrite_with_host(resolved, host)
50+
}
51+
52+
#[cfg(not(windows))]
53+
#[must_use]
54+
pub const fn rewrite_cmd_to_powershell(
55+
_resolved: &AbsolutePath,
56+
) -> Option<(AbsolutePathBuf, Vec<OsString>)> {
57+
None
58+
}
59+
60+
/// Cached location of the PowerShell host. Prefers cross-platform `pwsh.exe`
61+
/// when present, falling back to the Windows built-in `powershell.exe`.
62+
#[cfg(windows)]
63+
fn powershell_host() -> Option<&'static AbsolutePathBuf> {
64+
use std::sync::LazyLock;
65+
66+
static POWERSHELL_HOST: LazyLock<Option<AbsolutePathBuf>> = LazyLock::new(|| {
67+
let resolved = which::which("pwsh.exe").or_else(|_| which::which("powershell.exe")).ok()?;
68+
AbsolutePathBuf::new(resolved)
69+
});
70+
POWERSHELL_HOST.as_ref()
71+
}
72+
73+
/// Pure rewrite logic. Factored out so tests can drive it on any platform
74+
/// without depending on a real `powershell.exe`.
75+
#[cfg(any(windows, test))]
76+
fn rewrite_with_host(
77+
resolved: &AbsolutePath,
78+
host: &AbsolutePathBuf,
79+
) -> Option<(AbsolutePathBuf, Vec<OsString>)> {
80+
let ps1 = find_ps1_sibling(resolved)?;
81+
82+
tracing::debug!(
83+
"rewriting .cmd to powershell: {} -> {} -File {}",
84+
resolved.as_path().display(),
85+
host.as_path().display(),
86+
ps1.as_path().display(),
87+
);
88+
89+
let mut prefix_args: Vec<OsString> =
90+
POWERSHELL_PREFIX.iter().copied().map(OsString::from).collect();
91+
prefix_args.push(ps1.as_path().as_os_str().to_owned());
92+
93+
Some((host.clone(), prefix_args))
94+
}
95+
96+
#[cfg(any(windows, test))]
97+
fn find_ps1_sibling(resolved: &AbsolutePath) -> Option<AbsolutePathBuf> {
98+
let ext = resolved.as_path().extension().and_then(|e| e.to_str())?;
99+
if !ext.eq_ignore_ascii_case("cmd") {
100+
return None;
101+
}
102+
103+
let ps1 = resolved.with_extension("ps1");
104+
if !ps1.as_path().is_file() {
105+
return None;
106+
}
107+
108+
Some(ps1)
109+
}
110+
111+
#[cfg(test)]
112+
mod tests {
113+
use std::fs;
114+
115+
use tempfile::tempdir;
116+
117+
use super::*;
118+
119+
#[expect(clippy::disallowed_types, reason = "tempdir bridges std PathBuf into AbsolutePath")]
120+
fn abs(buf: std::path::PathBuf) -> AbsolutePathBuf {
121+
AbsolutePathBuf::new(buf).unwrap()
122+
}
123+
124+
#[test]
125+
fn rewrites_cmd_to_powershell_with_sibling_ps1() {
126+
let dir = tempdir().unwrap();
127+
let root = abs(dir.path().canonicalize().unwrap());
128+
fs::write(root.as_path().join("npm.cmd"), "").unwrap();
129+
fs::write(root.as_path().join("npm.ps1"), "").unwrap();
130+
131+
let host = abs(root.as_path().join("powershell.exe"));
132+
let resolved = abs(root.as_path().join("npm.cmd"));
133+
134+
let (program, prefix_args) = rewrite_with_host(&resolved, &host).expect("should rewrite");
135+
136+
assert_eq!(program.as_path(), host.as_path());
137+
let as_strs: Vec<&str> = prefix_args.iter().filter_map(|a| a.to_str()).collect();
138+
let ps1_path = root.as_path().join("npm.ps1");
139+
let ps1_str = ps1_path.to_str().unwrap();
140+
assert_eq!(
141+
as_strs,
142+
vec!["-NoProfile", "-NoLogo", "-ExecutionPolicy", "Bypass", "-File", ps1_str]
143+
);
144+
}
145+
146+
#[test]
147+
fn rewrites_uppercase_cmd_extension() {
148+
let dir = tempdir().unwrap();
149+
let root = abs(dir.path().canonicalize().unwrap());
150+
fs::write(root.as_path().join("pnpm.CMD"), "").unwrap();
151+
fs::write(root.as_path().join("pnpm.ps1"), "").unwrap();
152+
153+
let host = abs(root.as_path().join("powershell.exe"));
154+
let resolved = abs(root.as_path().join("pnpm.CMD"));
155+
156+
let result = rewrite_with_host(&resolved, &host);
157+
assert!(result.is_some(), "case-insensitive .CMD should still rewrite");
158+
}
159+
160+
#[test]
161+
fn returns_none_when_no_ps1_sibling() {
162+
let dir = tempdir().unwrap();
163+
let root = abs(dir.path().canonicalize().unwrap());
164+
fs::write(root.as_path().join("npm.cmd"), "").unwrap();
165+
166+
let host = abs(root.as_path().join("powershell.exe"));
167+
let resolved = abs(root.as_path().join("npm.cmd"));
168+
169+
assert!(rewrite_with_host(&resolved, &host).is_none());
170+
}
171+
172+
#[test]
173+
fn returns_none_for_exe() {
174+
let dir = tempdir().unwrap();
175+
let root = abs(dir.path().canonicalize().unwrap());
176+
fs::write(root.as_path().join("bun.exe"), "").unwrap();
177+
fs::write(root.as_path().join("bun.ps1"), "").unwrap();
178+
179+
let host = abs(root.as_path().join("powershell.exe"));
180+
let resolved = abs(root.as_path().join("bun.exe"));
181+
182+
assert!(rewrite_with_host(&resolved, &host).is_none());
183+
}
184+
185+
#[test]
186+
fn returns_none_for_no_extension() {
187+
let dir = tempdir().unwrap();
188+
let root = abs(dir.path().canonicalize().unwrap());
189+
fs::write(root.as_path().join("node"), "").unwrap();
190+
fs::write(root.as_path().join("node.ps1"), "").unwrap();
191+
192+
let host = abs(root.as_path().join("powershell.exe"));
193+
let resolved = abs(root.as_path().join("node"));
194+
195+
assert!(rewrite_with_host(&resolved, &host).is_none());
196+
}
197+
}

0 commit comments

Comments
 (0)