Skip to content

Commit ed71eb6

Browse files
committed
feat(env): use trampoline exe instead of .cmd wrappers on Windows
Replace Windows .cmd shim wrappers with lightweight trampoline .exe binaries to eliminate the "Terminate batch job (Y/N)?" prompt on Ctrl+C. The trampoline binary detects its tool name from its own filename, sets VITE_PLUS_SHIM_TOOL env var, and spawns vp.exe. It installs a Ctrl+C handler that ignores the signal (the child process handles it), avoiding the batch file prompt entirely. - Add crates/vite_trampoline/ with minimal Windows trampoline binary - Update shim detection to check env var before argv[0] - Replace .cmd/.sh wrapper creation with trampoline .exe copying - Add legacy .cmd cleanup during setup --refresh - Update CI to build and distribute vp-shim.exe for Windows targets - Add RFC document with feasibility study Closes #835
1 parent e3607ec commit ed71eb6

21 files changed

Lines changed: 993 additions & 284 deletions

File tree

.github/actions/build-upstream/action.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ runs:
4040
packages/cli/binding/index.d.cts
4141
target/${{ inputs.target }}/release/vp
4242
target/${{ inputs.target }}/release/vp.exe
43+
target/${{ inputs.target }}/release/vp-shim.exe
4344
key: ${{ steps.cache-key.outputs.key }}
4445

4546
# Apply Vite+ branding patches to rolldown-vite source (CI checks out
@@ -111,6 +112,11 @@ runs:
111112
shell: bash
112113
run: cargo build --release --target ${{ inputs.target }} -p vite_global_cli
113114

115+
- name: Build trampoline shim binary (Windows only)
116+
if: steps.cache-restore.outputs.cache-hit != 'true' && contains(inputs.target, 'windows')
117+
shell: bash
118+
run: cargo build --release --target ${{ inputs.target }} -p vite_trampoline
119+
114120
- name: Save NAPI binding cache
115121
if: steps.cache-restore.outputs.cache-hit != 'true'
116122
uses: actions/cache/save@94b89442628ad1d101e352b7ee38f30e1bef108e # v5
@@ -123,6 +129,7 @@ runs:
123129
packages/cli/binding/index.d.cts
124130
target/${{ inputs.target }}/release/vp
125131
target/${{ inputs.target }}/release/vp.exe
132+
target/${{ inputs.target }}/release/vp-shim.exe
126133
key: ${{ steps.cache-key.outputs.key }}
127134

128135
# Build vite-plus TypeScript after native bindings are ready

.github/workflows/release.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ jobs:
124124
path: |
125125
./target/${{ matrix.settings.target }}/release/vp
126126
./target/${{ matrix.settings.target }}/release/vp.exe
127+
./target/${{ matrix.settings.target }}/release/vp-shim.exe
127128
if-no-files-found: error
128129

129130
- name: Remove .node files before upload dist

.github/workflows/test-standalone-install.yml

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ jobs:
233233
exit 1
234234
}
235235
236-
$expectedShims = @("node.cmd", "npm.cmd", "npx.cmd")
236+
$expectedShims = @("node.exe", "npm.exe", "npx.exe")
237237
foreach ($shim in $expectedShims) {
238238
$shimFile = Join-Path $binPath $shim
239239
if (-not (Test-Path $shimFile)) {
@@ -300,7 +300,7 @@ jobs:
300300
exit 1
301301
}
302302
303-
$expectedShims = @("node.cmd", "npm.cmd", "npx.cmd")
303+
$expectedShims = @("node.exe", "npm.exe", "npx.exe")
304304
foreach ($shim in $expectedShims) {
305305
$shimFile = Join-Path $binPath $shim
306306
if (-not (Test-Path $shimFile)) {
@@ -380,8 +380,8 @@ jobs:
380380
exit 1
381381
}
382382
383-
# Verify shim executables exist (all use .cmd wrappers on Windows)
384-
$expectedShims = @("node.cmd", "npm.cmd", "npx.cmd")
383+
# Verify shim executables exist (trampoline .exe files on Windows)
384+
$expectedShims = @("node.exe", "npm.exe", "npx.exe")
385385
foreach ($shim in $expectedShims) {
386386
$shimFile = Join-Path $binPath $shim
387387
if (-not (Test-Path $shimFile)) {
@@ -419,8 +419,8 @@ jobs:
419419
set "BIN_PATH=%USERPROFILE%\.vite-plus\bin"
420420
dir "%BIN_PATH%"
421421
422-
REM Verify shim executables exist (Windows uses .cmd wrappers)
423-
for %%s in (node.cmd npm.cmd npx.cmd vp.cmd) do (
422+
REM Verify shim executables exist (Windows uses trampoline .exe files)
423+
for %%s in (node.exe npm.exe npx.exe vp.exe) do (
424424
if not exist "%BIN_PATH%\%%s" (
425425
echo Error: Shim not found: %BIN_PATH%\%%s
426426
exit /b 1
@@ -462,22 +462,13 @@ jobs:
462462
exit 1
463463
fi
464464
465-
# Verify .cmd wrappers exist (for cmd.exe/PowerShell)
466-
for shim in node.cmd npm.cmd npx.cmd vp.cmd; do
465+
# Verify trampoline .exe files exist
466+
for shim in node.exe npm.exe npx.exe vp.exe; do
467467
if [ ! -f "$BIN_PATH/$shim" ]; then
468-
echo "Error: .cmd wrapper not found: $BIN_PATH/$shim"
468+
echo "Error: Trampoline shim not found: $BIN_PATH/$shim"
469469
exit 1
470470
fi
471-
echo "Found .cmd wrapper: $BIN_PATH/$shim"
472-
done
473-
474-
# Verify shell scripts exist (for Git Bash)
475-
for shim in node npm npx vp; do
476-
if [ ! -f "$BIN_PATH/$shim" ]; then
477-
echo "Error: Shell script not found: $BIN_PATH/$shim"
478-
exit 1
479-
fi
480-
echo "Found shell script: $BIN_PATH/$shim"
471+
echo "Found trampoline shim: $BIN_PATH/$shim"
481472
done
482473
483474
# Verify vp env doctor works

Cargo.lock

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

crates/vite_global_cli/src/commands/env/doctor.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -239,8 +239,8 @@ async fn check_bin_dir() -> bool {
239239
fn shim_filename(tool: &str) -> String {
240240
#[cfg(windows)]
241241
{
242-
// All tools use .cmd wrappers on Windows (including node)
243-
format!("{tool}.cmd")
242+
// All tools use trampoline .exe files on Windows
243+
format!("{tool}.exe")
244244
}
245245

246246
#[cfg(not(windows))]
@@ -739,10 +739,10 @@ mod tests {
739739

740740
#[cfg(windows)]
741741
{
742-
// All shims should use .cmd on Windows (matching setup.rs)
743-
assert_eq!(node, "node.cmd");
744-
assert_eq!(npm, "npm.cmd");
745-
assert_eq!(npx, "npx.cmd");
742+
// All shims should use .exe on Windows (trampoline executables)
743+
assert_eq!(node, "node.exe");
744+
assert_eq!(npm, "npm.exe");
745+
assert_eq!(npx, "npx.exe");
746746
}
747747

748748
#[cfg(not(windows))]

crates/vite_global_cli/src/commands/env/global_install.rs

Lines changed: 68 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,7 @@ pub(crate) const CORE_SHIMS: &[&str] = &["node", "npm", "npx", "vp"];
368368
/// Create a shim for a package binary.
369369
///
370370
/// On Unix: Creates a symlink to ../current/bin/vp
371-
/// On Windows: Creates a .cmd wrapper that calls `vp env exec <bin_name>`
371+
/// On Windows: Creates a trampoline .exe that forwards to vp.exe
372372
async fn create_package_shim(
373373
bin_dir: &vite_path::AbsolutePath,
374374
bin_name: &str,
@@ -406,40 +406,25 @@ async fn create_package_shim(
406406

407407
#[cfg(windows)]
408408
{
409-
let cmd_path = bin_dir.join(format!("{}.cmd", bin_name));
409+
let shim_path = bin_dir.join(format!("{}.exe", bin_name));
410410

411411
// Skip if already exists (e.g., re-installing the same package)
412-
if tokio::fs::try_exists(&cmd_path).await.unwrap_or(false) {
412+
if tokio::fs::try_exists(&shim_path).await.unwrap_or(false) {
413413
return Ok(());
414414
}
415415

416-
// Create .cmd wrapper that calls vp env exec <bin_name>.
417-
// Use `--` so args like `--help` are forwarded to the package binary,
418-
// not consumed by clap while parsing `vp env exec`.
419-
// Set VITE_PLUS_HOME using %~dp0.. which resolves to the parent of bin/
420-
// This ensures the vp binary knows its home directory
421-
let wrapper_content = format!(
422-
"@echo off\r\nset VITE_PLUS_HOME=%~dp0..\r\nset VITE_PLUS_SHIM_WRAPPER=1\r\n\"%VITE_PLUS_HOME%\\current\\bin\\vp.exe\" env exec {} -- %*\r\nexit /b %ERRORLEVEL%\r\n",
423-
bin_name
424-
);
425-
tokio::fs::write(&cmd_path, wrapper_content).await?;
426-
427-
// Also create shell script for Git Bash (bin_name without extension)
428-
// Uses explicit "vp env exec <bin_name>" instead of symlink+argv[0] because
429-
// Windows symlinks require admin privileges
430-
let sh_path = bin_dir.join(bin_name);
431-
let sh_content = format!(
432-
r#"#!/bin/sh
433-
VITE_PLUS_HOME="$(dirname "$(dirname "$(readlink -f "$0" 2>/dev/null || echo "$0")")")"
434-
export VITE_PLUS_HOME
435-
export VITE_PLUS_SHIM_WRAPPER=1
436-
exec "$VITE_PLUS_HOME/current/bin/vp.exe" env exec {} -- "$@"
437-
"#,
438-
bin_name
439-
);
440-
tokio::fs::write(&sh_path, sh_content).await?;
416+
// Copy the trampoline binary as <bin_name>.exe.
417+
// The trampoline detects the tool name from its own filename and sets
418+
// VITE_PLUS_SHIM_TOOL env var before spawning vp.exe.
419+
let trampoline_src = super::setup::get_trampoline_path()?;
420+
tokio::fs::copy(trampoline_src.as_path(), &shim_path).await?;
421+
422+
// Remove legacy .cmd and shell script wrappers from previous versions.
423+
// In Git Bash/MSYS, the extensionless script takes precedence over .exe,
424+
// so leftover wrappers would bypass the trampoline.
425+
super::setup::cleanup_legacy_windows_shim(bin_dir, bin_name).await;
441426

442-
tracing::debug!("Created package shim wrappers for {} (.cmd and shell script)", bin_name);
427+
tracing::debug!("Created package trampoline shim {:?}", shim_path);
443428
}
444429

445430
Ok(())
@@ -466,16 +451,15 @@ async fn remove_package_shim(
466451

467452
#[cfg(windows)]
468453
{
469-
// Remove .cmd wrapper
470-
let cmd_path = bin_dir.join(format!("{}.cmd", bin_name));
471-
if tokio::fs::try_exists(&cmd_path).await.unwrap_or(false) {
472-
tokio::fs::remove_file(&cmd_path).await?;
473-
}
474-
475-
// Also remove shell script (for Git Bash)
476-
let sh_path = bin_dir.join(bin_name);
477-
if tokio::fs::try_exists(&sh_path).await.unwrap_or(false) {
478-
tokio::fs::remove_file(&sh_path).await?;
454+
// Remove trampoline .exe shim and legacy .cmd / shell script wrappers.
455+
// Best-effort: ignore NotFound errors for files that don't exist.
456+
for suffix in &[".exe", ".cmd", ""] {
457+
let path = if suffix.is_empty() {
458+
bin_dir.join(bin_name)
459+
} else {
460+
bin_dir.join(format!("{bin_name}{suffix}"))
461+
};
462+
let _ = tokio::fs::remove_file(&path).await;
479463
}
480464
}
481465

@@ -486,13 +470,42 @@ async fn remove_package_shim(
486470
mod tests {
487471
use super::*;
488472

473+
/// RAII guard that sets `VITE_PLUS_TRAMPOLINE_PATH` to a fake binary on creation
474+
/// and clears it on drop. Ensures cleanup even on test panics.
475+
#[cfg(windows)]
476+
struct FakeTrampolineGuard;
477+
478+
#[cfg(windows)]
479+
impl FakeTrampolineGuard {
480+
fn new(dir: &std::path::Path) -> Self {
481+
let trampoline = dir.join("vp-shim.exe");
482+
std::fs::write(&trampoline, b"fake-trampoline").unwrap();
483+
unsafe {
484+
std::env::set_var(vite_shared::env_vars::VITE_PLUS_TRAMPOLINE_PATH, &trampoline);
485+
}
486+
Self
487+
}
488+
}
489+
490+
#[cfg(windows)]
491+
impl Drop for FakeTrampolineGuard {
492+
fn drop(&mut self) {
493+
unsafe {
494+
std::env::remove_var(vite_shared::env_vars::VITE_PLUS_TRAMPOLINE_PATH);
495+
}
496+
}
497+
}
498+
489499
#[tokio::test]
500+
#[cfg_attr(windows, serial_test::serial)]
490501
async fn test_create_package_shim_creates_bin_dir() {
491502
use tempfile::TempDir;
492503
use vite_path::AbsolutePathBuf;
493504

494505
// Create a temp directory but don't create the bin subdirectory
495506
let temp_dir = TempDir::new().unwrap();
507+
#[cfg(windows)]
508+
let _guard = FakeTrampolineGuard::new(temp_dir.path());
496509
let bin_dir = temp_dir.path().join("bin");
497510
let bin_dir = AbsolutePathBuf::new(bin_dir).unwrap();
498511

@@ -505,7 +518,7 @@ mod tests {
505518
// Verify bin directory was created
506519
assert!(bin_dir.as_path().exists());
507520

508-
// Verify shim file was created (on Windows, shims have .cmd extension)
521+
// Verify shim file was created (on Windows, shims have .exe extension)
509522
// On Unix, symlinks may be broken (target doesn't exist), so use symlink_metadata
510523
#[cfg(unix)]
511524
{
@@ -517,7 +530,7 @@ mod tests {
517530
}
518531
#[cfg(windows)]
519532
{
520-
let shim_path = bin_dir.join("test-shim.cmd");
533+
let shim_path = bin_dir.join("test-shim.exe");
521534
assert!(shim_path.as_path().exists());
522535
}
523536
}
@@ -537,16 +550,19 @@ mod tests {
537550
#[cfg(unix)]
538551
let shim_path = bin_dir.join("node");
539552
#[cfg(windows)]
540-
let shim_path = bin_dir.join("node.cmd");
553+
let shim_path = bin_dir.join("node.exe");
541554
assert!(!shim_path.as_path().exists());
542555
}
543556

544557
#[tokio::test]
558+
#[cfg_attr(windows, serial_test::serial)]
545559
async fn test_remove_package_shim_removes_shim() {
546560
use tempfile::TempDir;
547561
use vite_path::AbsolutePathBuf;
548562

549563
let temp_dir = TempDir::new().unwrap();
564+
#[cfg(windows)]
565+
let _guard = FakeTrampolineGuard::new(temp_dir.path());
550566
let bin_dir = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
551567

552568
// Create a shim
@@ -573,7 +589,7 @@ mod tests {
573589
}
574590
#[cfg(windows)]
575591
{
576-
let shim_path = bin_dir.join("tsc.cmd");
592+
let shim_path = bin_dir.join("tsc.exe");
577593
assert!(shim_path.as_path().exists(), "Shim should exist after creation");
578594

579595
// Remove the shim
@@ -597,12 +613,15 @@ mod tests {
597613
}
598614

599615
#[tokio::test]
616+
#[cfg_attr(windows, serial_test::serial)]
600617
async fn test_uninstall_removes_shims_from_metadata() {
601618
use tempfile::TempDir;
602619
use vite_path::AbsolutePathBuf;
603620

604621
let temp_dir = TempDir::new().unwrap();
605622
let temp_path = temp_dir.path().to_path_buf();
623+
#[cfg(windows)]
624+
let _guard = FakeTrampolineGuard::new(&temp_path);
606625
let _guard = vite_shared::EnvConfig::test_guard(
607626
vite_shared::EnvConfig::for_test_with_home(&temp_path),
608627
);
@@ -630,10 +649,10 @@ mod tests {
630649
}
631650
#[cfg(windows)]
632651
{
633-
assert!(bin_dir.join("tsc.cmd").as_path().exists(), "tsc.cmd shim should exist");
652+
assert!(bin_dir.join("tsc.exe").as_path().exists(), "tsc.exe shim should exist");
634653
assert!(
635-
bin_dir.join("tsserver.cmd").as_path().exists(),
636-
"tsserver.cmd shim should exist"
654+
bin_dir.join("tsserver.exe").as_path().exists(),
655+
"tsserver.exe shim should exist"
637656
);
638657
}
639658

@@ -674,10 +693,10 @@ mod tests {
674693
}
675694
#[cfg(windows)]
676695
{
677-
assert!(!bin_dir.join("tsc.cmd").as_path().exists(), "tsc.cmd shim should be removed");
696+
assert!(!bin_dir.join("tsc.exe").as_path().exists(), "tsc.exe shim should be removed");
678697
assert!(
679-
!bin_dir.join("tsserver.cmd").as_path().exists(),
680-
"tsserver.cmd shim should be removed"
698+
!bin_dir.join("tsserver.exe").as_path().exists(),
699+
"tsserver.exe shim should be removed"
681700
);
682701
}
683702
}

0 commit comments

Comments
 (0)