diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9abbc54377..6b56737fab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -663,9 +663,29 @@ jobs: with: drive-size: 12GB drive-format: ReFS + # Route TEMP/TMP onto the Dev Drive (ReFS) so `tmpdir()` from + # `packages/tools/src/snap-test.ts` lives on a filesystem that + # cleanly resolves pnpm's junction reparse points. NTFS-on-C: + # preserves the junction-target backslashes during resolution, + # which produces mixed `\` / `/` paths that break Node's ESM + # subpath-import walk-up (`#module-sync-enabled` inside + # `@voidzero-dev/vite-plus-core` → `ERR_PACKAGE_IMPORT_NOT_DEFINED` + # during `vp fmt --write` in the + # `new-create-vite-migrates-eslint-prettier` snap test). env-mapping: | CARGO_HOME,{{ DEV_DRIVE }}/.cargo RUSTUP_HOME,{{ DEV_DRIVE }}/.rustup + TEMP,{{ DEV_DRIVE }}/Temp + TMP,{{ DEV_DRIVE }}/Temp + + - name: Create TEMP/TMP on Dev Drive + if: runner.os == 'Windows' + # `setup-dev-drive` only mounts the drive; it doesn't create the + # dir we point TEMP/TMP at. Anything that calls `os.tmpdir()` / + # `lstat($TEMP)` before this dir exists fails (e.g. the bootstrap + # CLI installer's `lstat 'E:\Temp'` ENOENT). + shell: bash + run: mkdir -p "$TEMP" "$TMP" - uses: oxc-project/setup-rust@23f38cfb0c04af97a055f76acee94d5be71c7c82 # v1.0.16 with: diff --git a/Cargo.lock b/Cargo.lock index 1ce7604faa..ead75a326b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1872,7 +1872,7 @@ dependencies = [ [[package]] name = "fspy" version = "0.1.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" dependencies = [ "allocator-api2", "anyhow", @@ -1908,7 +1908,7 @@ dependencies = [ [[package]] name = "fspy_detours_sys" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" dependencies = [ "cc", "winapi", @@ -1917,7 +1917,7 @@ dependencies = [ [[package]] name = "fspy_preload_unix" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" dependencies = [ "anyhow", "bstr", @@ -1932,7 +1932,7 @@ dependencies = [ [[package]] name = "fspy_preload_windows" version = "0.1.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" dependencies = [ "constcat", "fspy_detours_sys", @@ -1948,7 +1948,7 @@ dependencies = [ [[package]] name = "fspy_seccomp_unotify" version = "0.1.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" dependencies = [ "futures-util", "libc", @@ -1965,7 +1965,7 @@ dependencies = [ [[package]] name = "fspy_shared" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" dependencies = [ "allocator-api2", "bitflags 2.11.0", @@ -1984,7 +1984,7 @@ dependencies = [ [[package]] name = "fspy_shared_unix" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" dependencies = [ "anyhow", "base64 0.22.1", @@ -3124,7 +3124,7 @@ dependencies = [ [[package]] name = "materialized_artifact" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" dependencies = [ "tempfile", ] @@ -3132,7 +3132,7 @@ dependencies = [ [[package]] name = "materialized_artifact_build" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" dependencies = [ "xxhash-rust", ] @@ -3346,7 +3346,7 @@ dependencies = [ [[package]] name = "native_str" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" dependencies = [ "allocator-api2", "bytemuck", @@ -4805,7 +4805,7 @@ dependencies = [ [[package]] name = "pty_terminal_test_client" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" [[package]] name = "quote" @@ -7452,6 +7452,8 @@ dependencies = [ "tracing", "vite_error", "vite_path", + "vite_powershell", + "vite_shared", "which", ] @@ -7481,7 +7483,7 @@ dependencies = [ [[package]] name = "vite_glob" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" dependencies = [ "thiserror 2.0.18", "vite_path", @@ -7522,7 +7524,7 @@ dependencies = [ [[package]] name = "vite_graph_ser" version = "0.1.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" dependencies = [ "petgraph 0.8.3", "serde", @@ -7621,7 +7623,7 @@ dependencies = [ [[package]] name = "vite_path" version = "0.1.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" dependencies = [ "diff-struct", "path-clean", @@ -7649,10 +7651,19 @@ dependencies = [ "vite_workspace", ] +[[package]] +name = "vite_powershell" +version = "0.1.0" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" +dependencies = [ + "vite_path", + "which", +] + [[package]] name = "vite_select" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" dependencies = [ "anyhow", "crossterm", @@ -7702,7 +7713,7 @@ dependencies = [ [[package]] name = "vite_shell" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" dependencies = [ "brush-parser 0.3.0 (git+https://github.com/reubeno/brush?rev=dcb760933b10ee0433d7b740a5709b06f5c67c6b)", "diff-struct", @@ -7729,7 +7740,7 @@ dependencies = [ [[package]] name = "vite_str" version = "0.1.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" dependencies = [ "compact_str", "diff-struct", @@ -7740,7 +7751,7 @@ dependencies = [ [[package]] name = "vite_task" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" dependencies = [ "anyhow", "async-trait", @@ -7778,7 +7789,7 @@ dependencies = [ [[package]] name = "vite_task_graph" version = "0.1.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" dependencies = [ "anyhow", "async-trait", @@ -7800,7 +7811,7 @@ dependencies = [ [[package]] name = "vite_task_plan" version = "0.1.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" dependencies = [ "anyhow", "async-trait", @@ -7818,6 +7829,7 @@ dependencies = [ "vite_glob", "vite_graph_ser", "vite_path", + "vite_powershell", "vite_shell", "vite_str", "vite_task_graph", @@ -7832,7 +7844,7 @@ version = "0.0.0" [[package]] name = "vite_workspace" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef#d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=88bacaa770200ddab151dea252e04ba8cdcc4ade#88bacaa770200ddab151dea252e04ba8cdcc4ade" dependencies = [ "clap", "petgraph 0.8.3", diff --git a/Cargo.toml b/Cargo.toml index 2ee44c0d29..df9fcd2f3c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -89,7 +89,7 @@ dunce = "1.0.5" fast-glob = "1.0.0" flate2 = { version = "=1.1.9", features = ["zlib-rs"] } form_urlencoded = "1.2.1" -fspy = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" } +fspy = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "88bacaa770200ddab151dea252e04ba8cdcc4ade" } futures = "0.3.31" futures-util = "0.3.31" glob = "0.3.2" @@ -194,17 +194,18 @@ vfs = "0.13.0" vite_command = { path = "crates/vite_command" } vite_error = { path = "crates/vite_error" } vite_js_runtime = { path = "crates/vite_js_runtime" } -vite_glob = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" } +vite_glob = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "88bacaa770200ddab151dea252e04ba8cdcc4ade" } vite_install = { path = "crates/vite_install" } vite_migration = { path = "crates/vite_migration" } vite_pm_cli = { path = "crates/vite_pm_cli" } vite_setup = { path = "crates/vite_setup" } vite_shared = { path = "crates/vite_shared" } vite_static_config = { path = "crates/vite_static_config" } -vite_path = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" } -vite_str = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" } -vite_task = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" } -vite_workspace = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "d1b8cdae8b6df5eab8b9f1143ceb4fb13933a5ef" } +vite_path = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "88bacaa770200ddab151dea252e04ba8cdcc4ade" } +vite_powershell = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "88bacaa770200ddab151dea252e04ba8cdcc4ade" } +vite_str = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "88bacaa770200ddab151dea252e04ba8cdcc4ade" } +vite_task = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "88bacaa770200ddab151dea252e04ba8cdcc4ade" } +vite_workspace = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "88bacaa770200ddab151dea252e04ba8cdcc4ade" } walkdir = "2.5.0" wax = "0.6.0" which = "8.0.0" @@ -315,6 +316,7 @@ string_wizard = { path = "./rolldown/crates/string_wizard", features = ["serde"] # fspy = { path = "../vite-task/crates/fspy" } # vite_glob = { path = "../vite-task/crates/vite_glob" } # vite_path = { path = "../vite-task/crates/vite_path" } +# vite_powershell = { path = "../vite-task/crates/vite_powershell" } # vite_str = { path = "../vite-task/crates/vite_str" } # vite_task = { path = "../vite-task/crates/vite_task" } # vite_workspace = { path = "../vite-task/crates/vite_workspace" } diff --git a/crates/vite_command/Cargo.toml b/crates/vite_command/Cargo.toml index 4d031bfe42..b2bc76aead 100644 --- a/crates/vite_command/Cargo.toml +++ b/crates/vite_command/Cargo.toml @@ -14,6 +14,8 @@ tokio-util = { workspace = true } tracing = { workspace = true } vite_error = { workspace = true } vite_path = { workspace = true } +vite_powershell = { workspace = true } +vite_shared = { workspace = true } which = { workspace = true, features = ["tracing"] } [target.'cfg(not(target_os = "windows"))'.dependencies] diff --git a/crates/vite_command/src/lib.rs b/crates/vite_command/src/lib.rs index 9f724e3aac..64da37182f 100644 --- a/crates/vite_command/src/lib.rs +++ b/crates/vite_command/src/lib.rs @@ -2,7 +2,7 @@ use std::os::fd::{BorrowedFd, RawFd}; use std::{ collections::HashMap, - ffi::OsStr, + ffi::{OsStr, OsString}, process::{ExitStatus, Stdio}, }; @@ -12,6 +12,8 @@ use tokio_util::sync::CancellationToken; use vite_error::Error; use vite_path::{AbsolutePath, AbsolutePathBuf, RelativePathBuf}; +mod ps1_shim; + /// Result of running a command with fspy tracking. #[derive(Debug)] pub struct FspyCommandResult { @@ -43,6 +45,22 @@ pub fn resolve_bin( AbsolutePathBuf::new(path).ok_or_else(|| Error::CannotFindBinaryPath(bin_name.into())) } +/// Resolve `bin_name` to a path and apply the Windows `.cmd` → PowerShell +/// rewrite. Returns the program to spawn and the arg prefix to prepend +/// before the user args (empty when no rewrite applies). +fn resolve_program( + bin_name: &str, + envs: &HashMap, + cwd: &AbsolutePath, +) -> Result<(AbsolutePathBuf, Vec), Error> { + let path_env = envs.get("PATH").map(|p| OsStr::new(p.as_str())); + let bin_path = resolve_bin(bin_name, path_env, cwd)?; + Ok(match ps1_shim::rewrite_cmd_to_powershell(&bin_path) { + Some(rewritten) => rewritten, + None => (bin_path, Vec::new()), + }) +} + /// Build a `tokio::process::Command` for a pre-resolved binary path. /// Sets inherited stdio and `fix_stdio_streams` (Unix pre_exec). /// Callers can further customize (add args, envs, override stdio, etc.). @@ -140,10 +158,9 @@ where S: AsRef, { let cwd = cwd.as_ref(); - let paths = envs.get("PATH"); - let bin_path = resolve_bin(bin_name, paths.map(|p| OsStr::new(p.as_str())), cwd)?; - let mut cmd = build_command(&bin_path, cwd); - cmd.args(args).envs(envs); + let (program, prefix_args) = resolve_program(bin_name, envs, cwd)?; + let mut cmd = build_command(&program, cwd); + cmd.args(&prefix_args).args(args).envs(envs); let status = cmd.status().await?; Ok(status) } diff --git a/crates/vite_command/src/ps1_shim.rs b/crates/vite_command/src/ps1_shim.rs new file mode 100644 index 0000000000..79f636c7d8 --- /dev/null +++ b/crates/vite_command/src/ps1_shim.rs @@ -0,0 +1,301 @@ +//! Windows-specific: when a vp-managed package-manager `.cmd` shim has a +//! sibling `.ps1`, rewrite the spawn to go through +//! `powershell.exe -File `. +//! +//! Running a `.cmd` from any shell makes `cmd.exe` prompt "Terminate batch +//! job (Y/N)?" on Ctrl+C, which leaves the terminal corrupt. Routing through +//! `PowerShell` sidesteps the prompt and lets Ctrl+C propagate cleanly. +//! +//! The rewrite is scoped to two patterns: +//! - Inside `$VP_HOME` (`~/.vite-plus` by default) — vp's managed shims: +//! - `$VP_HOME/js_runtime/node//{npm,npx}.cmd`, +//! - `$VP_HOME/package_manager////bin/.cmd`. +//! - Any `<...>/node_modules/.bin/*.cmd` — the canonical layout for +//! npm/pnpm/yarn-emitted shims (cmd-shim writes both `.cmd` and `.ps1` +//! so the wrappers stay equivalent). +//! +//! Anything outside both patterns — system tools, third-party CLIs whose +//! `.cmd` and `.ps1` wrappers may diverge — keeps its existing `.cmd` +//! path (Ctrl+C corruption included), so we don't silently change +//! execution semantics for unrelated commands or bypass execution +//! policies on locked-down hosts. +//! +//! The rewrite is also skipped when stdin is not a terminal. The +//! `pnpm`/`npm`/`yarn` `.ps1` wrappers introspect stdin (e.g. +//! `$MyInvocation.ExpectingInput`) and hang when stdin is piped or +//! null; in that environment there is no terminal to corrupt with the +//! Ctrl+C prompt anyway, so falling back to `.cmd` is strictly safer. +//! +//! See +//! and . + +use std::ffi::OsString; + +use vite_path::{AbsolutePath, AbsolutePathBuf}; +use vite_powershell::{POWERSHELL_PREFIX, find_ps1_sibling, powershell_host}; + +/// Rewrite a vp-managed `.cmd` invocation to go through PowerShell. +/// +/// Returns `Some((powershell_host, prefix_args))` when the rewrite applies. +/// `prefix_args` is `["-NoProfile", "-NoLogo", "-ExecutionPolicy", "Bypass", +/// "-File", ]`; callers prepend it to the user args and spawn +/// `powershell_host`. +/// +/// Returns `None` when: +/// - not on Windows, +/// - no PowerShell host (`pwsh.exe` or `powershell.exe`) is on PATH, +/// - stdin is not a terminal (the `.ps1` wrappers hang on piped/null +/// stdin and the Ctrl+C concern doesn't apply without a TTY), +/// - the resolved path is outside `$VP_HOME` (or `$VP_HOME` is +/// unresolvable) AND not under any `node_modules/.bin/`, +/// - the resolved path is not a `.cmd` (case-insensitive), +/// - the `.cmd` has no sibling `.ps1`. +#[must_use] +pub fn rewrite_cmd_to_powershell( + resolved: &AbsolutePath, +) -> Option<(AbsolutePathBuf, Vec)> { + let host = powershell_host()?; + rewrite_in_scope(resolved, vp_home().map(AsRef::as_ref), host, is_stdin_terminal()) +} + +/// Cached `stdin.is_terminal()`. The TTY-ness of the parent's stdin +/// is fixed for the process lifetime, and `build_command` always +/// inherits stdin into spawned children — so a TTY here means a TTY +/// in the child too. +fn is_stdin_terminal() -> bool { + use std::{io::IsTerminal, sync::LazyLock}; + + static IS_TTY: LazyLock = LazyLock::new(|| std::io::stdin().is_terminal()); + *IS_TTY +} + +/// Cached `$VP_HOME` (`~/.vite-plus` by default; overridable via env var). +/// Returns `None` if `vite_shared::get_vp_home()` failed; the rewrite still +/// applies to `node_modules/.bin/*.cmd` paths in that case (the two scopes +/// are independent). +fn vp_home() -> Option<&'static AbsolutePathBuf> { + use std::sync::LazyLock; + + static VP_HOME: LazyLock> = + LazyLock::new(|| vite_shared::get_vp_home().ok()); + VP_HOME.as_ref() +} + +/// Pure rewrite logic. Factored out so tests can drive it on any platform +/// without depending on a real `powershell.exe` or a real `$VP_HOME`. +fn rewrite_in_scope( + resolved: &AbsolutePath, + vp_home: Option<&AbsolutePath>, + host: &AbsolutePath, + is_interactive: bool, +) -> Option<(AbsolutePathBuf, Vec)> { + if !is_interactive { + return None; + } + if !is_in_managed_scope(resolved, vp_home) { + return None; + } + let ps1 = find_ps1_sibling(resolved)?; + + tracing::debug!( + "rewriting .cmd to powershell: {} -> {} -File {}", + resolved.as_path().display(), + host.as_path().display(), + ps1.as_path().display(), + ); + + let mut prefix_args: Vec = + POWERSHELL_PREFIX.iter().copied().map(OsString::from).collect(); + prefix_args.push(ps1.as_path().as_os_str().to_owned()); + + Some((host.to_absolute_path_buf(), prefix_args)) +} + +fn is_in_managed_scope(resolved: &AbsolutePath, vp_home: Option<&AbsolutePath>) -> bool { + let in_vp_home = vp_home.is_some_and(|home| resolved.as_path().starts_with(home.as_path())); + in_vp_home || is_in_node_modules_bin(resolved) +} + +/// `true` when `resolved` is `<...>/node_modules/.bin/` (matched +/// case-insensitively on the `.bin`/`node_modules` components — Windows +/// is case-insensitive, and pnpm's hoisted layouts can vary in casing). +fn is_in_node_modules_bin(resolved: &AbsolutePath) -> bool { + let mut parents = resolved.as_path().components().rev(); + parents.next(); // shim filename + let Some(bin) = parents.next() else { return false }; + if !bin.as_os_str().eq_ignore_ascii_case(".bin") { + return false; + } + let Some(node_modules) = parents.next() else { return false }; + node_modules.as_os_str().eq_ignore_ascii_case("node_modules") +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::tempdir; + + use super::*; + + #[expect(clippy::disallowed_types, reason = "tempdir bridges std PathBuf into AbsolutePath")] + fn abs(buf: std::path::PathBuf) -> AbsolutePathBuf { + AbsolutePathBuf::new(buf).unwrap() + } + + fn host_buf(root: &AbsolutePath) -> AbsolutePathBuf { + abs(root.as_path().join("powershell.exe")) + } + + #[test] + fn rewrites_cmd_inside_vp_home_to_powershell() { + let dir = tempdir().unwrap(); + let vp_home = abs(dir.path().canonicalize().unwrap()); + // Mimic the real layout: $VP_HOME/js_runtime/node//npm.cmd. + let bin_dir = vp_home.as_path().join("js_runtime").join("node").join("24.0.0"); + fs::create_dir_all(&bin_dir).unwrap(); + fs::write(bin_dir.join("npm.cmd"), "").unwrap(); + fs::write(bin_dir.join("npm.ps1"), "").unwrap(); + + let host = host_buf(&vp_home); + let resolved = abs(bin_dir.join("npm.cmd")); + + let (program, prefix_args) = + rewrite_in_scope(&resolved, Some(&vp_home), &host, true).expect("should rewrite"); + + assert_eq!(program.as_path(), host.as_path()); + let as_strs: Vec<&str> = prefix_args.iter().filter_map(|a| a.to_str()).collect(); + let ps1_path = bin_dir.join("npm.ps1"); + let ps1_str = ps1_path.to_str().unwrap(); + assert_eq!( + as_strs, + vec!["-NoProfile", "-NoLogo", "-ExecutionPolicy", "Bypass", "-File", ps1_str] + ); + } + + /// Any `<...>/node_modules/.bin/*.cmd` rewrites, regardless of where + /// the project root sits — covers single-package projects, hoisted + /// monorepos, and globally-installed shims uniformly. + #[test] + fn rewrites_cmd_in_node_modules_bin() { + let dir = tempdir().unwrap(); + let root = abs(dir.path().canonicalize().unwrap()); + // vp_home points elsewhere — this scope is the node_modules path. + let vp_home_path = root.as_path().join("vite-plus"); + fs::create_dir_all(&vp_home_path).unwrap(); + let vp_home = abs(vp_home_path); + + let bin = root.as_path().join("my-project").join("node_modules").join(".bin"); + fs::create_dir_all(&bin).unwrap(); + fs::write(bin.join("vite.cmd"), "").unwrap(); + fs::write(bin.join("vite.ps1"), "").unwrap(); + + let host = host_buf(&root); + let resolved = abs(bin.join("vite.cmd")); + + let result = rewrite_in_scope(&resolved, Some(&vp_home), &host, true); + assert!(result.is_some(), "any node_modules/.bin/*.cmd must rewrite"); + } + + /// `pnpm`/`npm`/`yarn` `.ps1` wrappers introspect stdin and hang + /// when stdin is piped or null (CI, snap tests, scripted invocations). + /// In that environment the rewrite is unwanted; the spawn falls back + /// to `.cmd` directly. + #[test] + fn skips_rewrite_when_not_interactive() { + let dir = tempdir().unwrap(); + let root = abs(dir.path().canonicalize().unwrap()); + let bin = root.as_path().join("my-project").join("node_modules").join(".bin"); + fs::create_dir_all(&bin).unwrap(); + fs::write(bin.join("vite.cmd"), "").unwrap(); + fs::write(bin.join("vite.ps1"), "").unwrap(); + + let host = host_buf(&root); + let resolved = abs(bin.join("vite.cmd")); + + assert!( + rewrite_in_scope(&resolved, None, &host, false).is_none(), + "non-interactive spawns must not be rewritten through PowerShell" + ); + } + + /// `vp_home` may be unresolvable in unusual environments (CI containers + /// missing $HOME, sandboxed shells); when that happens the + /// `node_modules/.bin` scope must still rewrite, since it is + /// architecturally independent from the `$VP_HOME` scope. + #[test] + fn rewrites_cmd_in_node_modules_bin_when_vp_home_unresolved() { + let dir = tempdir().unwrap(); + let root = abs(dir.path().canonicalize().unwrap()); + let bin = root.as_path().join("my-project").join("node_modules").join(".bin"); + fs::create_dir_all(&bin).unwrap(); + fs::write(bin.join("vite.cmd"), "").unwrap(); + fs::write(bin.join("vite.ps1"), "").unwrap(); + + let host = host_buf(&root); + let resolved = abs(bin.join("vite.cmd")); + + assert!( + rewrite_in_scope(&resolved, None, &host, true).is_some(), + "node_modules/.bin must rewrite even without a resolvable vp_home" + ); + } + + /// The `.bin`/`node_modules` component check is case-insensitive so + /// a `.CMD` shim under `Node_Modules\.Bin\` (or any casing variant) + /// still matches. + #[test] + fn rewrites_cmd_in_node_modules_bin_case_insensitive() { + let dir = tempdir().unwrap(); + let root = abs(dir.path().canonicalize().unwrap()); + let vp_home = abs(root.as_path().join("vite-plus")); + fs::create_dir_all(vp_home.as_path()).unwrap(); + + let bin = root.as_path().join("Node_Modules").join(".Bin"); + fs::create_dir_all(&bin).unwrap(); + fs::write(bin.join("vite.cmd"), "").unwrap(); + fs::write(bin.join("vite.ps1"), "").unwrap(); + + let host = host_buf(&root); + let resolved = abs(bin.join("vite.cmd")); + + assert!(rewrite_in_scope(&resolved, Some(&vp_home), &host, true).is_some()); + } + + /// A `.cmd`+`.ps1` pair outside `$VP_HOME` AND outside any + /// `node_modules/.bin/` (e.g. a system tool living at `/global/bin/foo.cmd`) + /// must NOT be retargeted. + #[test] + fn returns_none_for_cmd_outside_managed_scope() { + let dir = tempdir().unwrap(); + let root = abs(dir.path().canonicalize().unwrap()); + let vp_home_path = root.as_path().join("vite-plus"); + fs::create_dir_all(&vp_home_path).unwrap(); + let vp_home = abs(vp_home_path); + + let outside_bin = root.as_path().join("global").join("bin"); + fs::create_dir_all(&outside_bin).unwrap(); + fs::write(outside_bin.join("foo.cmd"), "").unwrap(); + fs::write(outside_bin.join("foo.ps1"), "").unwrap(); + + let host = host_buf(&root); + let resolved = abs(outside_bin.join("foo.cmd")); + + assert!( + rewrite_in_scope(&resolved, Some(&vp_home), &host, true).is_none(), + "rewrite must stay hands-off for .cmd outside both vp_home and node_modules/.bin" + ); + } + + #[test] + fn returns_none_when_no_ps1_sibling() { + let dir = tempdir().unwrap(); + let vp_home = abs(dir.path().canonicalize().unwrap()); + fs::write(vp_home.as_path().join("npm.cmd"), "").unwrap(); + + let host = host_buf(&vp_home); + let resolved = abs(vp_home.as_path().join("npm.cmd")); + + assert!(rewrite_in_scope(&resolved, Some(&vp_home), &host, true).is_none()); + } +} diff --git a/packages/cli/src/create/bin.ts b/packages/cli/src/create/bin.ts index a1152fcc08..323a59b87a 100644 --- a/packages/cli/src/create/bin.ts +++ b/packages/cli/src/create/bin.ts @@ -46,7 +46,7 @@ import { updatePackageJsonWithDeps, updateWorkspaceConfig, } from '../utils/workspace.ts'; -import type { ExecutionResult } from './command.ts'; +import type { ExecutionWithProjectDir } from './command.ts'; import { discoverTemplate, inferGitHubRepoName, inferParentDir, isGitHubUrl } from './discovery.ts'; import { getInitialTemplateOptions } from './initial-template-options.ts'; import { @@ -935,7 +935,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h // #region Handle single project template - let result: ExecutionResult; + let result: ExecutionWithProjectDir; if (templateInfo.type === TemplateType.bundled) { pauseCreateProgress(); await checkProjectDirExists(path.join(workspaceInfo.rootDir, targetDir), options.interactive); diff --git a/packages/cli/src/create/command.ts b/packages/cli/src/create/command.ts index 39fe68318e..0083c60b55 100644 --- a/packages/cli/src/create/command.ts +++ b/packages/cli/src/create/command.ts @@ -1,28 +1,20 @@ import fs from 'node:fs/promises'; import path from 'node:path'; -import spawn from 'cross-spawn'; - import { runCommand as runCommandWithFspy } from '../../binding/index.js'; import type { WorkspaceInfo } from '../types/index.ts'; +import type { ExecutionResult, RunCommandOptions } from '../utils/command.ts'; -export interface ExecutionResult { - exitCode: number; +/** Set by `runCommandAndDetectProjectDir` and the template executors + * that call it; plain `runCommand` / `runCommandSilently` don't. */ +export interface ExecutionWithProjectDir extends ExecutionResult { projectDir?: string; } -export interface RunCommandOptions { - command: string; - args: string[]; - cwd: string; - envs: NodeJS.ProcessEnv; -} - -// Run a command and detect the project directory export async function runCommandAndDetectProjectDir( options: RunCommandOptions, parentDir?: string, -): Promise { +): Promise { const cwd = parentDir ? path.join(options.cwd, parentDir) : options.cwd; const existingDirs = new Set(); if (parentDir) { @@ -89,57 +81,6 @@ export async function runCommandAndDetectProjectDir( }; } -export interface RunCommandResult extends ExecutionResult { - stdout: Buffer; - stderr: Buffer; -} - -export async function runCommandSilently(options: RunCommandOptions): Promise { - const child = spawn(options.command, options.args, { - stdio: 'pipe', - cwd: options.cwd, - env: options.envs, - }); - const promise = new Promise((resolve, reject) => { - const stdout: Buffer[] = []; - const stderr: Buffer[] = []; - child.stdout?.on('data', (data) => { - stdout.push(data); - }); - child.stderr?.on('data', (data) => { - stderr.push(data); - }); - child.on('close', (code) => { - resolve({ - exitCode: code ?? 0, - stdout: Buffer.concat(stdout), - stderr: Buffer.concat(stderr), - }); - }); - child.on('error', (err) => { - reject(err); - }); - }); - return await promise; -} - -export async function runCommand(options: RunCommandOptions): Promise { - const child = spawn(options.command, options.args, { - stdio: 'inherit', - cwd: options.cwd, - env: options.envs, - }); - const promise = new Promise((resolve, reject) => { - child.on('close', (code) => { - resolve({ exitCode: code ?? 0 }); - }); - child.on('error', (err) => { - reject(err); - }); - }); - return await promise; -} - // Get the package runner command for each package manager export function getPackageRunner(workspaceInfo: WorkspaceInfo) { switch (workspaceInfo.packageManager) { diff --git a/packages/cli/src/create/templates/builtin.ts b/packages/cli/src/create/templates/builtin.ts index d30b415953..3766f65bc3 100644 --- a/packages/cli/src/create/templates/builtin.ts +++ b/packages/cli/src/create/templates/builtin.ts @@ -5,7 +5,7 @@ import * as prompts from '@voidzero-dev/vite-plus-prompts'; import colors from 'picocolors'; import type { WorkspaceInfo } from '../../types/index.ts'; -import type { ExecutionResult } from '../command.ts'; +import type { ExecutionWithProjectDir } from '../command.ts'; import { discoverTemplate } from '../discovery.ts'; import { setPackageName } from '../utils.ts'; import { executeGeneratorScaffold } from './generator.ts'; @@ -16,7 +16,7 @@ export async function executeBuiltinTemplate( workspaceInfo: WorkspaceInfo, templateInfo: BuiltinTemplateInfo, options?: { silent?: boolean }, -): Promise { +): Promise { assert(templateInfo.targetDir, 'targetDir is required'); assert(templateInfo.packageName, 'packageName is required'); diff --git a/packages/cli/src/create/templates/bundled.ts b/packages/cli/src/create/templates/bundled.ts index 03e3541878..316f32dfb5 100644 --- a/packages/cli/src/create/templates/bundled.ts +++ b/packages/cli/src/create/templates/bundled.ts @@ -2,7 +2,7 @@ import assert from 'node:assert'; import path from 'node:path'; import type { WorkspaceInfo } from '../../types/index.ts'; -import type { ExecutionResult } from '../command.ts'; +import type { ExecutionWithProjectDir } from '../command.ts'; import { copyDir, setPackageName } from '../utils.ts'; import type { BuiltinTemplateInfo } from './types.ts'; @@ -13,7 +13,7 @@ import type { BuiltinTemplateInfo } from './types.ts'; export async function executeBundledTemplate( workspaceInfo: WorkspaceInfo, templateInfo: BuiltinTemplateInfo, -): Promise { +): Promise { assert(templateInfo.localPath, 'localPath is required for bundled templates'); assert(templateInfo.targetDir, 'targetDir is required'); assert(templateInfo.packageName, 'packageName is required'); diff --git a/packages/cli/src/create/templates/generator.ts b/packages/cli/src/create/templates/generator.ts index d550331b85..a87d651db6 100644 --- a/packages/cli/src/create/templates/generator.ts +++ b/packages/cli/src/create/templates/generator.ts @@ -6,7 +6,7 @@ import * as prompts from '@voidzero-dev/vite-plus-prompts'; import type { WorkspaceInfo } from '../../types/index.ts'; import { editJsonFile } from '../../utils/json.ts'; import { templatesDir } from '../../utils/path.ts'; -import type { ExecutionResult } from '../command.ts'; +import type { ExecutionWithProjectDir } from '../command.ts'; import { copyDir } from '../utils.ts'; import type { BuiltinTemplateInfo } from './types.ts'; @@ -15,7 +15,7 @@ export async function executeGeneratorScaffold( workspaceInfo: WorkspaceInfo, templateInfo: BuiltinTemplateInfo, options?: { silent?: boolean }, -): Promise { +): Promise { if (!options?.silent) { prompts.log.step('Creating generator scaffold...'); } diff --git a/packages/cli/src/create/templates/monorepo.ts b/packages/cli/src/create/templates/monorepo.ts index edc57a290f..3d72da0d47 100644 --- a/packages/cli/src/create/templates/monorepo.ts +++ b/packages/cli/src/create/templates/monorepo.ts @@ -8,7 +8,7 @@ import { rewriteMonorepoProject } from '../../migration/migrator.ts'; import { PackageManager, type WorkspaceInfo } from '../../types/index.ts'; import { editJsonFile } from '../../utils/json.ts'; import { templatesDir } from '../../utils/path.ts'; -import type { ExecutionResult } from '../command.ts'; +import type { ExecutionWithProjectDir } from '../command.ts'; import { discoverTemplate } from '../discovery.ts'; import { copyDir, formatDisplayTargetDir, setPackageName } from '../utils.ts'; import { runRemoteTemplateCommand } from './remote.ts'; @@ -21,7 +21,7 @@ export async function executeMonorepoTemplate( workspaceInfo: WorkspaceInfo, templateInfo: BuiltinTemplateInfo, options?: { silent?: boolean }, -): Promise { +): Promise { assert(templateInfo.packageName, 'packageName is required'); assert(templateInfo.targetDir, 'targetDir is required'); diff --git a/packages/cli/src/create/templates/remote.ts b/packages/cli/src/create/templates/remote.ts index 77c84fbe02..a6800c7a9c 100644 --- a/packages/cli/src/create/templates/remote.ts +++ b/packages/cli/src/create/templates/remote.ts @@ -2,13 +2,12 @@ import * as prompts from '@voidzero-dev/vite-plus-prompts'; import colors from 'picocolors'; import type { WorkspaceInfo } from '../../types/index.ts'; +import { runCommand, runCommandSilently } from '../../utils/command.ts'; import { checkNpmPackageExists } from '../../utils/package.ts'; import { - type ExecutionResult, + type ExecutionWithProjectDir, formatDlxCommand, - runCommand, runCommandAndDetectProjectDir, - runCommandSilently, } from '../command.ts'; import type { TemplateInfo } from './types.ts'; @@ -18,14 +17,14 @@ export async function executeRemoteTemplate( workspaceInfo: WorkspaceInfo, templateInfo: TemplateInfo, options?: { silent?: boolean }, -): Promise { +): Promise { const silent = options?.silent ?? false; if (!silent) { prompts.log.step('Generating project…'); } let isGitHubTemplate = templateInfo.command === 'degit'; - let result: ExecutionResult; + let result: ExecutionWithProjectDir; if (templateInfo.command === 'node') { // Template found locally - execute directly const command = templateInfo.command; @@ -84,7 +83,7 @@ export async function runRemoteTemplateCommand( templateInfo: TemplateInfo, detectCreatedProjectDir?: boolean, silent = false, -): Promise { +): Promise { autoFixRemoteTemplateCommand(templateInfo, workspaceInfo); const remotePackageName = templateInfo.command; const execArgs = [...templateInfo.args]; diff --git a/packages/cli/src/utils/command.ts b/packages/cli/src/utils/command.ts index df58a49543..27bed9900a 100644 --- a/packages/cli/src/utils/command.ts +++ b/packages/cli/src/utils/command.ts @@ -7,15 +7,21 @@ export interface RunCommandOptions { envs: NodeJS.ProcessEnv; } -export interface RunCommandResult { +export interface ExecutionResult { exitCode: number; +} + +export interface RunCommandResult extends ExecutionResult { stdout: Buffer; stderr: Buffer; } export async function runCommandSilently(options: RunCommandOptions): Promise { const child = spawn(options.command, options.args, { - stdio: 'pipe', + // No stdin pipe: leaving one open would deadlock any descendant `.ps1` + // shim whose `$MyInvocation.ExpectingInput` branch waits for EOF on + // stdin before invoking `node`. + stdio: ['ignore', 'pipe', 'pipe'], cwd: options.cwd, env: options.envs, }); @@ -42,15 +48,15 @@ export async function runCommandSilently(options: RunCommandOptions): Promise { +export async function runCommand(options: RunCommandOptions): Promise { const child = spawn(options.command, options.args, { stdio: 'inherit', cwd: options.cwd, env: options.envs, }); - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { child.on('close', (code) => { - resolve(code ?? 0); + resolve({ exitCode: code ?? 0 }); }); child.on('error', (err) => { reject(err); diff --git a/packages/tools/src/snap-test.ts b/packages/tools/src/snap-test.ts index ee16cb6206..5e22cdddbd 100755 --- a/packages/tools/src/snap-test.ts +++ b/packages/tools/src/snap-test.ts @@ -110,8 +110,16 @@ export async function snapTest() { // On macOS, `tmpdir()` is a symlink. Resolve it so that we can replace the resolved cwd in outputs. // Remove hyphens from UUID to avoid npm's @npmcli/redact treating the path as containing // secrets (it matches UUID patterns like `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`). - const systemTmpDir = fs.realpathSync(tmpdir()); - const tempTmpDir = `${systemTmpDir}/vite-plus-test-${randomUUID().replaceAll('-', '')}`; + // Use `realpathSync.native` (libuv `uv_fs_realpath`) instead of the JS + // legacy form: on Windows the JS form can return paths with mixed + // separators (`C:\Users/.../Temp`) while the native form returns the + // canonical backslash path. The mixed form propagates downstream and + // confuses Node's ESM package walk-up — `#module-sync-enabled` subpath + // imports inside pnpm-nested deps then fail with + // `ERR_PACKAGE_IMPORT_NOT_DEFINED`. Also use `path.join` (not string + // concat with `/`) so the suffix matches. + const systemTmpDir = fs.realpathSync.native(tmpdir()); + const tempTmpDir = path.join(systemTmpDir, `vite-plus-test-${randomUUID().replaceAll('-', '')}`); fs.mkdirSync(tempTmpDir, { recursive: true }); // Pre-create the npm global prefix directory so tests using npm global // operations (link, outdated -g, etc.) don't fail with ENOENT. @@ -168,7 +176,13 @@ export async function snapTest() { ); // Clean up the temporary directory on exit - process.on('exit', () => fs.rmSync(tempTmpDir, { recursive: true, force: true })); + process.on('exit', () => { + try { + fs.rmSync(tempTmpDir, { recursive: true, force: true }); + } catch (error) { + console.error('Error cleaning up temporary directory: %s, %s', tempTmpDir, error); + } + }); const casesDir = path.resolve(values.dir || 'snap-tests'); @@ -333,8 +347,8 @@ async function runTestCase(name: string, tempTmpDir: string, casesDir: string, b } console.log('%s started', name); - const caseTmpDir = `${tempTmpDir}/${name}`; - await fsPromises.cp(`${casesDir}/${name}`, caseTmpDir, { + const caseTmpDir = path.join(tempTmpDir, name); + await fsPromises.cp(path.join(casesDir, name), caseTmpDir, { recursive: true, errorOnExist: true, });