Skip to content

Commit 88bacaa

Browse files
fengmk2branchseer
andauthored
refactor: extract shared primitives into vite_powershell crate (#368)
## Summary - New `vite_powershell` crate owns the platform-shared primitives previously inlined in `vite_task_plan::ps1_shim`: `POWERSHELL_PREFIX`, `powershell_host()` (LazyLock-cached `pwsh.exe`/`powershell.exe` lookup), and `find_ps1_sibling()` (case-insensitive `.cmd` → sibling `.ps1` resolution). - `vite_task_plan::ps1_shim` keeps its task-specific layers — workspace + `node_modules/.bin` scope check, cwd-relative arg conversion, `Arc<AbsolutePath>` / `Arc<[Str]>` return types — and now imports the primitives from `vite_powershell`. - No behavior change. All existing snapshot/unit/integration tests pass. ## Why vite-plus is adding `vite_command::ps1_shim` (see voidzero-dev/vite-plus#1489) to fix the same Ctrl+C terminal-corruption symptom for package-manager-routed commands (`vp dlx`, `vp add`, `vp update`, etc.). Without this extraction it would have to copy the constant, the host-discovery LazyLock, and the sibling-`.ps1` check. With this extraction both task scripts (vite-task) and pm commands (vp) compose the same primitives with their own scope rules. The two callers diverge intentionally and don't share enough to merge into one helper: - vite-task's wrapper produces `(Arc<AbsolutePath>, Arc<[Str]>)` with **cwd-relative** `.ps1` paths because the args become part of the task graph's spawn fingerprint and must stay portable across machines. - vite-plus's wrapper (in vp's `vite_command`) produces `(AbsolutePathBuf, Vec<OsString>)` with **absolute** `.ps1` paths because there's no cache fingerprint at spawn time. ## Test plan - [x] `cargo test -p vite_powershell` — 5 new tests pass (sibling found, case-insensitive `.CMD`, no-sibling, non-`.cmd`, no-extension) - [x] `cargo test -p vite_task_plan` — 39 unit + 45 integration tests pass; the existing rewrite snapshot tests are preserved - [x] `cargo clippy -p vite_powershell -p vite_task_plan --all-targets` — clean - [x] Companion vp PR to switch `vite_command::ps1_shim` to depend on `vite_powershell` once this lands and a new vite-task SHA is pinned <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Mostly a dependency extraction/refactor with tests; behavior should remain the same aside from potential edge-case differences in `.cmd`/`.ps1` detection on Windows. > > **Overview** > Extracts the Windows `.cmd`→`.ps1` PowerShell-shim primitives into a new `vite_powershell` crate, including the `POWERSHELL_PREFIX` args, cached `pwsh.exe`/`powershell.exe` host discovery, and sibling-`.ps1` lookup. > > Updates `vite_task_plan::ps1_shim` to depend on and call these shared helpers while keeping its workspace/`node_modules/.bin` scoping and cwd-relative argument rewrite logic, and adds unit tests for the new crate. Workspace `Cargo.toml`/`Cargo.lock` are updated to include the new crate and dependency. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 1e2b791. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: branchseer <3612422+branchseer@users.noreply.github.com>
1 parent bf0753e commit 88bacaa

6 files changed

Lines changed: 215 additions & 58 deletions

File tree

Cargo.lock

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ vec1 = "1.12.1"
145145
vite_glob = { path = "crates/vite_glob" }
146146
vite_graph_ser = { path = "crates/vite_graph_ser" }
147147
vite_path = { path = "crates/vite_path" }
148+
vite_powershell = { path = "crates/vite_powershell" }
148149
vite_select = { path = "crates/vite_select" }
149150
vite_shell = { path = "crates/vite_shell" }
150151
vite_str = { path = "crates/vite_str" }

crates/vite_powershell/Cargo.toml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
[package]
2+
name = "vite_powershell"
3+
version = "0.1.0"
4+
authors.workspace = true
5+
edition.workspace = true
6+
license.workspace = true
7+
publish = false
8+
rust-version.workspace = true
9+
10+
[dependencies]
11+
vite_path = { workspace = true }
12+
which = { workspace = true }
13+
14+
[dev-dependencies]
15+
tempfile = { workspace = true }
16+
17+
[lints]
18+
workspace = true
19+
20+
[lib]
21+
doctest = false

crates/vite_powershell/src/lib.rs

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
//! Windows-specific helpers for routing `.cmd` invocations through
2+
//! `PowerShell` so spawning never goes through `cmd.exe`.
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
6+
//! through `PowerShell` against the sibling `.ps1` shim sidesteps the prompt
7+
//! and lets Ctrl+C propagate cleanly.
8+
//!
9+
//! This crate carries only the platform-shared primitives (the
10+
//! `PowerShell` host lookup, the fixed argument prefix, and the
11+
//! sibling-`.ps1` discovery). Higher-level wrappers in
12+
//! `vite_task_plan::ps1_shim` (cwd-relative arg rewrite, scoped to
13+
//! `node_modules/.bin`) and `vite_command::ps1_shim` (absolute-path
14+
//! arg rewrite, applied to any `.cmd`) compose these primitives with
15+
//! their own scope rules and return-type conventions.
16+
//!
17+
//! See <https://github.com/voidzero-dev/vite-plus/issues/1176> and
18+
//! <https://github.com/voidzero-dev/vite-plus/issues/1489>.
19+
20+
use std::sync::Arc;
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+
pub const POWERSHELL_PREFIX: &[&str] =
28+
&["-NoProfile", "-NoLogo", "-ExecutionPolicy", "Bypass", "-File"];
29+
30+
/// Cached location of the `PowerShell` host. Prefers cross-platform
31+
/// `pwsh.exe` when present, falling back to the Windows built-in
32+
/// `powershell.exe`. Returns `None` on non-Windows or when neither host
33+
/// is on `PATH`.
34+
///
35+
/// Cached as `Arc<AbsolutePath>` so callers that want shared ownership
36+
/// (e.g. `vite_task_plan`'s plan-time rewrite) can do `Arc::clone(host)`
37+
/// without copying the path.
38+
#[cfg(windows)]
39+
#[must_use]
40+
pub fn powershell_host() -> Option<&'static Arc<AbsolutePath>> {
41+
use std::sync::LazyLock;
42+
43+
static POWERSHELL_HOST: LazyLock<Option<Arc<AbsolutePath>>> = LazyLock::new(|| {
44+
let resolved = which::which("pwsh.exe").or_else(|_| which::which("powershell.exe")).ok()?;
45+
AbsolutePathBuf::new(resolved).map(Arc::<AbsolutePath>::from)
46+
});
47+
POWERSHELL_HOST.as_ref()
48+
}
49+
50+
#[cfg(not(windows))]
51+
#[must_use]
52+
pub const fn powershell_host() -> Option<&'static Arc<AbsolutePath>> {
53+
None
54+
}
55+
56+
/// Given a resolved `.cmd` path, return its sibling `.ps1` if one exists
57+
/// on disk. The extension match is case-insensitive (matches `.cmd`,
58+
/// `.CMD`, `.Cmd`).
59+
///
60+
/// Returns `None` when the path is not a `.cmd` or no `.ps1` sibling
61+
/// exists. Callers that need additional scope checks (e.g. "must live
62+
/// inside the workspace's `node_modules/.bin`") should layer those on
63+
/// top of this primitive.
64+
#[must_use]
65+
pub fn find_ps1_sibling(resolved: &AbsolutePath) -> Option<AbsolutePathBuf> {
66+
let ext = resolved.as_path().extension().and_then(|e| e.to_str())?;
67+
if !ext.eq_ignore_ascii_case("cmd") {
68+
return None;
69+
}
70+
71+
let ps1 = resolved.with_extension("ps1");
72+
if !ps1.as_path().is_file() {
73+
return None;
74+
}
75+
76+
Some(ps1)
77+
}
78+
79+
#[cfg(test)]
80+
mod tests {
81+
use std::fs;
82+
83+
use tempfile::tempdir;
84+
85+
use super::*;
86+
87+
#[expect(clippy::disallowed_types, reason = "tempdir bridges std PathBuf into AbsolutePath")]
88+
fn abs(buf: std::path::PathBuf) -> AbsolutePathBuf {
89+
AbsolutePathBuf::new(buf).unwrap()
90+
}
91+
92+
#[test]
93+
fn find_ps1_sibling_returns_path_when_both_present() {
94+
let dir = tempdir().unwrap();
95+
let root = abs(dir.path().canonicalize().unwrap());
96+
fs::write(root.as_path().join("npm.cmd"), "").unwrap();
97+
fs::write(root.as_path().join("npm.ps1"), "").unwrap();
98+
99+
let resolved = abs(root.as_path().join("npm.cmd"));
100+
let sibling = find_ps1_sibling(&resolved).expect("should find sibling");
101+
assert_eq!(sibling.as_path(), root.as_path().join("npm.ps1"));
102+
}
103+
104+
#[test]
105+
fn find_ps1_sibling_is_case_insensitive_on_extension() {
106+
let dir = tempdir().unwrap();
107+
let root = abs(dir.path().canonicalize().unwrap());
108+
fs::write(root.as_path().join("pnpm.CMD"), "").unwrap();
109+
fs::write(root.as_path().join("pnpm.ps1"), "").unwrap();
110+
111+
let resolved = abs(root.as_path().join("pnpm.CMD"));
112+
assert!(find_ps1_sibling(&resolved).is_some());
113+
}
114+
115+
#[test]
116+
fn find_ps1_sibling_returns_none_when_sibling_missing() {
117+
let dir = tempdir().unwrap();
118+
let root = abs(dir.path().canonicalize().unwrap());
119+
fs::write(root.as_path().join("npm.cmd"), "").unwrap();
120+
121+
let resolved = abs(root.as_path().join("npm.cmd"));
122+
assert!(find_ps1_sibling(&resolved).is_none());
123+
}
124+
125+
#[test]
126+
fn find_ps1_sibling_returns_none_for_non_cmd() {
127+
let dir = tempdir().unwrap();
128+
let root = abs(dir.path().canonicalize().unwrap());
129+
fs::write(root.as_path().join("bun.exe"), "").unwrap();
130+
fs::write(root.as_path().join("bun.ps1"), "").unwrap();
131+
132+
let resolved = abs(root.as_path().join("bun.exe"));
133+
assert!(find_ps1_sibling(&resolved).is_none());
134+
}
135+
136+
#[test]
137+
fn find_ps1_sibling_returns_none_for_no_extension() {
138+
let dir = tempdir().unwrap();
139+
let root = abs(dir.path().canonicalize().unwrap());
140+
fs::write(root.as_path().join("node"), "").unwrap();
141+
fs::write(root.as_path().join("node.ps1"), "").unwrap();
142+
143+
let resolved = abs(root.as_path().join("node"));
144+
assert!(find_ps1_sibling(&resolved).is_none());
145+
}
146+
}

crates/vite_task_plan/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ tracing = { workspace = true }
2828
vite_glob = { workspace = true }
2929
vite_graph_ser = { workspace = true }
3030
vite_path = { workspace = true }
31+
vite_powershell = { workspace = true }
3132
vite_shell = { workspace = true }
3233
vite_str = { workspace = true }
3334
vite_task_graph = { workspace = true }

0 commit comments

Comments
 (0)