Skip to content

Commit 21e0c54

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 21e0c54

16 files changed

Lines changed: 937 additions & 169 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: 8 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: 65 additions & 41 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?;
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?;
426421

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?;
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,13 +451,17 @@ async fn remove_package_shim(
466451

467452
#[cfg(windows)]
468453
{
469-
// Remove .cmd wrapper
454+
// Remove trampoline .exe shim
455+
let exe_path = bin_dir.join(format!("{}.exe", bin_name));
456+
if tokio::fs::try_exists(&exe_path).await.unwrap_or(false) {
457+
tokio::fs::remove_file(&exe_path).await?;
458+
}
459+
460+
// Also remove legacy .cmd wrapper and shell script from previous versions
470461
let cmd_path = bin_dir.join(format!("{}.cmd", bin_name));
471462
if tokio::fs::try_exists(&cmd_path).await.unwrap_or(false) {
472463
tokio::fs::remove_file(&cmd_path).await?;
473464
}
474-
475-
// Also remove shell script (for Git Bash)
476465
let sh_path = bin_dir.join(bin_name);
477466
if tokio::fs::try_exists(&sh_path).await.unwrap_or(false) {
478467
tokio::fs::remove_file(&sh_path).await?;
@@ -486,13 +475,42 @@ async fn remove_package_shim(
486475
mod tests {
487476
use super::*;
488477

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

494510
// Create a temp directory but don't create the bin subdirectory
495511
let temp_dir = TempDir::new().unwrap();
512+
#[cfg(windows)]
513+
let _guard = FakeTrampolineGuard::new(temp_dir.path());
496514
let bin_dir = temp_dir.path().join("bin");
497515
let bin_dir = AbsolutePathBuf::new(bin_dir).unwrap();
498516

@@ -505,7 +523,7 @@ mod tests {
505523
// Verify bin directory was created
506524
assert!(bin_dir.as_path().exists());
507525

508-
// Verify shim file was created (on Windows, shims have .cmd extension)
526+
// Verify shim file was created (on Windows, shims have .exe extension)
509527
// On Unix, symlinks may be broken (target doesn't exist), so use symlink_metadata
510528
#[cfg(unix)]
511529
{
@@ -517,7 +535,7 @@ mod tests {
517535
}
518536
#[cfg(windows)]
519537
{
520-
let shim_path = bin_dir.join("test-shim.cmd");
538+
let shim_path = bin_dir.join("test-shim.exe");
521539
assert!(shim_path.as_path().exists());
522540
}
523541
}
@@ -537,16 +555,19 @@ mod tests {
537555
#[cfg(unix)]
538556
let shim_path = bin_dir.join("node");
539557
#[cfg(windows)]
540-
let shim_path = bin_dir.join("node.cmd");
558+
let shim_path = bin_dir.join("node.exe");
541559
assert!(!shim_path.as_path().exists());
542560
}
543561

544562
#[tokio::test]
563+
#[cfg_attr(windows, serial_test::serial)]
545564
async fn test_remove_package_shim_removes_shim() {
546565
use tempfile::TempDir;
547566
use vite_path::AbsolutePathBuf;
548567

549568
let temp_dir = TempDir::new().unwrap();
569+
#[cfg(windows)]
570+
let _guard = FakeTrampolineGuard::new(temp_dir.path());
550571
let bin_dir = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
551572

552573
// Create a shim
@@ -573,7 +594,7 @@ mod tests {
573594
}
574595
#[cfg(windows)]
575596
{
576-
let shim_path = bin_dir.join("tsc.cmd");
597+
let shim_path = bin_dir.join("tsc.exe");
577598
assert!(shim_path.as_path().exists(), "Shim should exist after creation");
578599

579600
// Remove the shim
@@ -597,12 +618,15 @@ mod tests {
597618
}
598619

599620
#[tokio::test]
621+
#[cfg_attr(windows, serial_test::serial)]
600622
async fn test_uninstall_removes_shims_from_metadata() {
601623
use tempfile::TempDir;
602624
use vite_path::AbsolutePathBuf;
603625

604626
let temp_dir = TempDir::new().unwrap();
605627
let temp_path = temp_dir.path().to_path_buf();
628+
#[cfg(windows)]
629+
let _guard = FakeTrampolineGuard::new(&temp_path);
606630
let _guard = vite_shared::EnvConfig::test_guard(
607631
vite_shared::EnvConfig::for_test_with_home(&temp_path),
608632
);
@@ -630,10 +654,10 @@ mod tests {
630654
}
631655
#[cfg(windows)]
632656
{
633-
assert!(bin_dir.join("tsc.cmd").as_path().exists(), "tsc.cmd shim should exist");
657+
assert!(bin_dir.join("tsc.exe").as_path().exists(), "tsc.exe shim should exist");
634658
assert!(
635-
bin_dir.join("tsserver.cmd").as_path().exists(),
636-
"tsserver.cmd shim should exist"
659+
bin_dir.join("tsserver.exe").as_path().exists(),
660+
"tsserver.exe shim should exist"
637661
);
638662
}
639663

@@ -674,10 +698,10 @@ mod tests {
674698
}
675699
#[cfg(windows)]
676700
{
677-
assert!(!bin_dir.join("tsc.cmd").as_path().exists(), "tsc.cmd shim should be removed");
701+
assert!(!bin_dir.join("tsc.exe").as_path().exists(), "tsc.exe shim should be removed");
678702
assert!(
679-
!bin_dir.join("tsserver.cmd").as_path().exists(),
680-
"tsserver.cmd shim should be removed"
703+
!bin_dir.join("tsserver.exe").as_path().exists(),
704+
"tsserver.exe shim should be removed"
681705
);
682706
}
683707
}

0 commit comments

Comments
 (0)