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>`.
1+ //! Windows-specific: when a vp-managed package-manager `.cmd` shim has a
2+ //! sibling `.ps1`, rewrite the spawn to go through
3+ //! `powershell.exe -File <sibling.ps1>`.
34//!
45//! Running a `.cmd` from any shell makes `cmd.exe` prompt "Terminate batch
56//! job (Y/N)?" on Ctrl+C, which leaves the terminal corrupt. Routing through
67//! `PowerShell` sidesteps the prompt and lets Ctrl+C propagate cleanly.
78//!
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 .
9+ //! The rewrite is intentionally ** scoped to paths inside `$VP_HOME`**
10+ //! (`~/.vite-plus` by default). vp's managed shims live there:
11+ //! - `$VP_HOME/js_runtime/node/<ver>/{ npm,npx} .cmd` (npm/npx shipped with
12+ //! the managed Node.js),
13+ //! - `$VP_HOME/package_manager/<pm>/<ver>/<pm>/ bin/<pm>.cmd` (downloaded
14+ //! pnpm/yarn/bun) .
1415//!
15- //! The cross-platform primitives (`POWERSHELL_PREFIX`, `powershell_host`,
16- //! `find_ps1_sibling`) live in the `vite_powershell` crate and are shared
17- //! with `vite_task_plan::ps1_shim`. This module composes them with vp's
18- //! own conventions: absolute `.ps1` path in args (no cache fingerprint to
19- //! keep portable) and `Vec<OsString>` return type (matches the spawn API).
16+ //! Anything outside `$VP_HOME` — system tools, globally-installed npm
17+ //! shims, third-party CLIs whose `.cmd` and `.ps1` wrappers may not be
18+ //! semantically equivalent, hosts that intentionally block unsigned
19+ //! `.ps1` execution — is left alone. Stricter scoping means the prior
20+ //! `.cmd` path still runs there, but that matches the pre-fix status quo
21+ //! and avoids broadening execution semantics for unrelated commands.
22+ //!
23+ //! The task-layer rewrite (`vite_task_plan::ps1_shim`) is scoped
24+ //! differently — to `node_modules/.bin/*.cmd` inside the workspace — and
25+ //! covers `vp run <script>`. The two scopes don't overlap.
2026//!
2127//! See <https://github.com/voidzero-dev/vite-plus/issues/1489>
2228//! and <https://github.com/voidzero-dev/vite-plus/issues/1176>.
@@ -26,32 +32,51 @@ use std::{ffi::OsString, sync::Arc};
2632use vite_path:: { AbsolutePath , AbsolutePathBuf } ;
2733use vite_powershell:: { POWERSHELL_PREFIX , find_ps1_sibling, powershell_host} ;
2834
29- /// Rewrite a resolved `.cmd` invocation to go through PowerShell.
35+ /// Rewrite a vp-managed `.cmd` invocation to go through PowerShell.
3036///
31- /// Returns `Some((powershell_host, prefix_args))` when the rewrite applies,
32- /// where `prefix_args` is `["-NoProfile", "-NoLogo", "-ExecutionPolicy",
33- /// "Bypass", " -File", <abs ps1 path>]`. Caller prepends `prefix_args` to the
34- /// user args and spawns `powershell_host`.
37+ /// Returns `Some((powershell_host, prefix_args))` when the rewrite applies.
38+ /// `prefix_args` is `["-NoProfile", "-NoLogo", "-ExecutionPolicy", "Bypass ",
39+ /// "-File", <abs ps1 path>]`; callers prepend it to the user args and spawn
40+ /// `powershell_host`.
3541///
3642/// Returns `None` when:
3743/// - not on Windows,
3844/// - no PowerShell host (`pwsh.exe` or `powershell.exe`) is on PATH,
45+ /// - `$VP_HOME` could not be resolved,
46+ /// - the resolved path is **outside** `$VP_HOME`,
3947/// - the resolved path is not a `.cmd` (case-insensitive),
4048/// - the `.cmd` has no sibling `.ps1`.
4149#[ must_use]
4250pub fn rewrite_cmd_to_powershell (
4351 resolved : & AbsolutePath ,
4452) -> Option < ( AbsolutePathBuf , Vec < OsString > ) > {
53+ let vp_home = vp_home ( ) ?;
4554 let host = powershell_host ( ) ?;
46- rewrite_with_host ( resolved, host)
55+ rewrite_in_scope ( resolved, vp_home, host)
56+ }
57+
58+ /// Cached `$VP_HOME` (`~/.vite-plus` by default; overridable via env var).
59+ /// `None` only if `vite_shared::get_vp_home()` failed to resolve a home —
60+ /// in that case we conservatively skip the rewrite rather than retarget
61+ /// arbitrary PATH commands.
62+ fn vp_home ( ) -> Option < & ' static AbsolutePathBuf > {
63+ use std:: sync:: LazyLock ;
64+
65+ static VP_HOME : LazyLock < Option < AbsolutePathBuf > > =
66+ LazyLock :: new ( || vite_shared:: get_vp_home ( ) . ok ( ) ) ;
67+ VP_HOME . as_ref ( )
4768}
4869
4970/// Pure rewrite logic. Factored out so tests can drive it on any platform
50- /// without depending on a real `powershell.exe`.
51- fn rewrite_with_host (
71+ /// without depending on a real `powershell.exe` or a real `$VP_HOME` .
72+ fn rewrite_in_scope (
5273 resolved : & AbsolutePath ,
74+ vp_home : & AbsolutePath ,
5375 host : & Arc < AbsolutePath > ,
5476) -> Option < ( AbsolutePathBuf , Vec < OsString > ) > {
77+ if !resolved. as_path ( ) . starts_with ( vp_home. as_path ( ) ) {
78+ return None ;
79+ }
5580 let ps1 = find_ps1_sibling ( resolved) ?;
5681
5782 tracing:: debug!(
@@ -86,36 +111,70 @@ mod tests {
86111 }
87112
88113 #[ test]
89- fn rewrites_cmd_to_powershell_with_sibling_ps1 ( ) {
114+ fn rewrites_cmd_inside_vp_home_to_powershell ( ) {
90115 let dir = tempdir ( ) . unwrap ( ) ;
91- let root = abs ( dir. path ( ) . canonicalize ( ) . unwrap ( ) ) ;
92- fs:: write ( root. as_path ( ) . join ( "npm.cmd" ) , "" ) . unwrap ( ) ;
93- fs:: write ( root. as_path ( ) . join ( "npm.ps1" ) , "" ) . unwrap ( ) ;
116+ let vp_home = abs ( dir. path ( ) . canonicalize ( ) . unwrap ( ) ) ;
117+ // Mimic the real layout: $VP_HOME/js_runtime/node/<ver>/npm.cmd.
118+ let bin_dir = vp_home. as_path ( ) . join ( "js_runtime" ) . join ( "node" ) . join ( "24.0.0" ) ;
119+ fs:: create_dir_all ( & bin_dir) . unwrap ( ) ;
120+ fs:: write ( bin_dir. join ( "npm.cmd" ) , "" ) . unwrap ( ) ;
121+ fs:: write ( bin_dir. join ( "npm.ps1" ) , "" ) . unwrap ( ) ;
94122
95- let host = host_arc ( & root ) ;
96- let resolved = abs ( root . as_path ( ) . join ( "npm.cmd" ) ) ;
123+ let host = host_arc ( & vp_home ) ;
124+ let resolved = abs ( bin_dir . join ( "npm.cmd" ) ) ;
97125
98- let ( program, prefix_args) = rewrite_with_host ( & resolved, & host) . expect ( "should rewrite" ) ;
126+ let ( program, prefix_args) =
127+ rewrite_in_scope ( & resolved, & vp_home, & host) . expect ( "should rewrite" ) ;
99128
100129 assert_eq ! ( program. as_path( ) , host. as_path( ) ) ;
101130 let as_strs: Vec < & str > = prefix_args. iter ( ) . filter_map ( |a| a. to_str ( ) ) . collect ( ) ;
102- let ps1_path = root . as_path ( ) . join ( "npm.ps1" ) ;
103- let ps1_str = ps1_path . to_str ( ) . unwrap ( ) ;
131+ let ps1_str = bin_dir . join ( "npm.ps1" ) ;
132+ let ps1_str = ps1_str . to_str ( ) . unwrap ( ) ;
104133 assert_eq ! (
105134 as_strs,
106135 vec![ "-NoProfile" , "-NoLogo" , "-ExecutionPolicy" , "Bypass" , "-File" , ps1_str]
107136 ) ;
108137 }
109138
139+ /// Regression for the Codex review: the rewrite must NOT retarget
140+ /// `.cmd` files that live outside `$VP_HOME` even when a sibling
141+ /// `.ps1` exists. Otherwise unrelated PATH commands (system tools,
142+ /// globally installed npm wrappers, third-party CLIs whose `.cmd`
143+ /// and `.ps1` wrappers diverge) would silently switch to PowerShell
144+ /// with `-ExecutionPolicy Bypass`.
110145 #[ test]
111- fn returns_none_when_no_ps1_sibling ( ) {
146+ fn returns_none_for_cmd_outside_vp_home ( ) {
112147 let dir = tempdir ( ) . unwrap ( ) ;
113148 let root = abs ( dir. path ( ) . canonicalize ( ) . unwrap ( ) ) ;
114- fs:: write ( root. as_path ( ) . join ( "npm.cmd" ) , "" ) . unwrap ( ) ;
149+ let vp_home_path = root. as_path ( ) . join ( "vite-plus" ) ;
150+ fs:: create_dir_all ( & vp_home_path) . unwrap ( ) ;
151+ let vp_home = abs ( vp_home_path) ;
152+
153+ // A `.cmd`+`.ps1` pair *outside* vp_home — e.g. a global install at
154+ // `%AppData%\npm\node_modules\.bin\foo.cmd` or any third-party tool.
155+ let outside_bin = root. as_path ( ) . join ( "global" ) . join ( "bin" ) ;
156+ fs:: create_dir_all ( & outside_bin) . unwrap ( ) ;
157+ fs:: write ( outside_bin. join ( "foo.cmd" ) , "" ) . unwrap ( ) ;
158+ fs:: write ( outside_bin. join ( "foo.ps1" ) , "" ) . unwrap ( ) ;
115159
116160 let host = host_arc ( & root) ;
117- let resolved = abs ( root. as_path ( ) . join ( "npm.cmd" ) ) ;
161+ let resolved = abs ( outside_bin. join ( "foo.cmd" ) ) ;
162+
163+ assert ! (
164+ rewrite_in_scope( & resolved, & vp_home, & host) . is_none( ) ,
165+ "rewrite must stay hands-off for .cmd outside $VP_HOME"
166+ ) ;
167+ }
168+
169+ #[ test]
170+ fn returns_none_when_no_ps1_sibling ( ) {
171+ let dir = tempdir ( ) . unwrap ( ) ;
172+ let vp_home = abs ( dir. path ( ) . canonicalize ( ) . unwrap ( ) ) ;
173+ fs:: write ( vp_home. as_path ( ) . join ( "npm.cmd" ) , "" ) . unwrap ( ) ;
174+
175+ let host = host_arc ( & vp_home) ;
176+ let resolved = abs ( vp_home. as_path ( ) . join ( "npm.cmd" ) ) ;
118177
119- assert ! ( rewrite_with_host ( & resolved, & host) . is_none( ) ) ;
178+ assert ! ( rewrite_in_scope ( & resolved, & vp_home , & host) . is_none( ) ) ;
120179 }
121180}
0 commit comments