Skip to content

Commit 11c5c3b

Browse files
committed
feat(env): implement symlinks (Unix) and vp env run wrappers (Windows)
- Unix: Replace hardlinks with symlinks to ../current/bin/vp - Symlinks preserve argv[0] for tool detection - Same pattern as Volta - Windows: Replace node.exe copy + VITE_PLUS_SHIM_TOOL with .cmd wrappers - All shims (node, npm, npx) use .cmd files calling `vp env run <tool>` - No binary copies needed - Package shims: Same changes for global package binaries (e.g., tsc) - Unix: Symlink to ../current/bin/vp - Windows: .cmd wrapper calling `vp env run <bin_name>` - Update tests to handle symlink detection (use symlink_metadata) - Keep VITE_PLUS_SHIM_TOOL detection for backward compatibility
1 parent e973ecc commit 11c5c3b

4 files changed

Lines changed: 103 additions & 67 deletions

File tree

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

Lines changed: 57 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,9 @@ fn is_javascript_binary(path: &AbsolutePath) -> bool {
289289
const CORE_SHIMS: &[&str] = &["node", "npm", "npx", "vp"];
290290

291291
/// Create a shim for a package binary.
292+
///
293+
/// On Unix: Creates a symlink to ../current/bin/vp
294+
/// On Windows: Creates a .cmd wrapper that calls `vp env run <bin_name>`
292295
async fn create_package_shim(
293296
bin_dir: &vite_path::AbsolutePath,
294297
bin_name: &str,
@@ -308,22 +311,16 @@ async fn create_package_shim(
308311

309312
#[cfg(unix)]
310313
{
311-
let current_exe = std::env::current_exe().map_err(|e| {
312-
Error::ConfigError(format!("Cannot find current executable: {e}").into())
313-
})?;
314-
315314
let shim_path = bin_dir.join(bin_name);
316315

317316
// Skip if already exists (e.g., re-installing the same package)
318317
if tokio::fs::try_exists(&shim_path).await.unwrap_or(false) {
319318
return Ok(());
320319
}
321320

322-
// Create hardlink
323-
if tokio::fs::hard_link(&current_exe, &shim_path).await.is_err() {
324-
// Fallback to copy
325-
tokio::fs::copy(&current_exe, &shim_path).await?;
326-
}
321+
// Create symlink to ../current/bin/vp
322+
tokio::fs::symlink("../current/bin/vp", &shim_path).await?;
323+
tracing::debug!("Created package shim symlink {:?} -> ../current/bin/vp", shim_path);
327324
}
328325

329326
#[cfg(windows)]
@@ -335,12 +332,13 @@ async fn create_package_shim(
335332
return Ok(());
336333
}
337334

338-
// Create .cmd wrapper
335+
// Create .cmd wrapper that calls vp env run <bin_name>
339336
let wrapper_content = format!(
340-
"@echo off\r\nsetlocal\r\nset \"VITE_PLUS_SHIM_TOOL={}\"\r\n\"%~dp0node.exe\" %*\r\nexit /b %ERRORLEVEL%\r\n",
337+
"@echo off\r\n\"%~dp0..\\current\\bin\\vp.exe\" env run {} %*\r\nexit /b %ERRORLEVEL%\r\n",
341338
bin_name
342339
);
343340
tokio::fs::write(&shim_path, wrapper_content).await?;
341+
tracing::debug!("Created package shim wrapper {:?} -> vp env run {}", shim_path, bin_name);
344342
}
345343

346344
Ok(())
@@ -359,7 +357,8 @@ async fn remove_package_shim(
359357
#[cfg(unix)]
360358
{
361359
let shim_path = bin_dir.join(bin_name);
362-
if tokio::fs::try_exists(&shim_path).await.unwrap_or(false) {
360+
// Use symlink_metadata to detect symlinks (even broken ones)
361+
if tokio::fs::symlink_metadata(&shim_path).await.is_ok() {
363362
tokio::fs::remove_file(&shim_path).await?;
364363
}
365364
}
@@ -399,11 +398,20 @@ mod tests {
399398
assert!(bin_dir.as_path().exists());
400399

401400
// Verify shim file was created (on Windows, shims have .cmd extension)
401+
// On Unix, symlinks may be broken (target doesn't exist), so use symlink_metadata
402402
#[cfg(unix)]
403-
let shim_path = bin_dir.join("test-shim");
403+
{
404+
let shim_path = bin_dir.join("test-shim");
405+
assert!(
406+
std::fs::symlink_metadata(shim_path.as_path()).is_ok(),
407+
"Symlink shim should exist"
408+
);
409+
}
404410
#[cfg(windows)]
405-
let shim_path = bin_dir.join("test-shim.cmd");
406-
assert!(shim_path.as_path().exists());
411+
{
412+
let shim_path = bin_dir.join("test-shim.cmd");
413+
assert!(shim_path.as_path().exists());
414+
}
407415
}
408416

409417
#[tokio::test]
@@ -437,17 +445,35 @@ mod tests {
437445
create_package_shim(&bin_dir, "tsc", "typescript").await.unwrap();
438446

439447
// Verify the shim was created
448+
// On Unix, symlinks may be broken (target doesn't exist), so use symlink_metadata
440449
#[cfg(unix)]
441-
let shim_path = bin_dir.join("tsc");
450+
{
451+
let shim_path = bin_dir.join("tsc");
452+
assert!(
453+
std::fs::symlink_metadata(shim_path.as_path()).is_ok(),
454+
"Shim should exist after creation"
455+
);
456+
457+
// Remove the shim
458+
remove_package_shim(&bin_dir, "tsc").await.unwrap();
459+
460+
// Verify the shim was removed
461+
assert!(
462+
std::fs::symlink_metadata(shim_path.as_path()).is_err(),
463+
"Shim should be removed"
464+
);
465+
}
442466
#[cfg(windows)]
443-
let shim_path = bin_dir.join("tsc.cmd");
444-
assert!(shim_path.as_path().exists(), "Shim should exist after creation");
467+
{
468+
let shim_path = bin_dir.join("tsc.cmd");
469+
assert!(shim_path.as_path().exists(), "Shim should exist after creation");
445470

446-
// Remove the shim
447-
remove_package_shim(&bin_dir, "tsc").await.unwrap();
471+
// Remove the shim
472+
remove_package_shim(&bin_dir, "tsc").await.unwrap();
448473

449-
// Verify the shim was removed
450-
assert!(!shim_path.as_path().exists(), "Shim should be removed");
474+
// Verify the shim was removed
475+
assert!(!shim_path.as_path().exists(), "Shim should be removed");
476+
}
451477
}
452478

453479
#[tokio::test]
@@ -486,10 +512,17 @@ mod tests {
486512
create_package_shim(&bin_dir, "tsserver", "typescript").await.unwrap();
487513

488514
// Verify shims exist
515+
// On Unix, symlinks may be broken (target doesn't exist), so use symlink_metadata
489516
#[cfg(unix)]
490517
{
491-
assert!(bin_dir.join("tsc").as_path().exists(), "tsc shim should exist");
492-
assert!(bin_dir.join("tsserver").as_path().exists(), "tsserver shim should exist");
518+
assert!(
519+
std::fs::symlink_metadata(bin_dir.join("tsc").as_path()).is_ok(),
520+
"tsc shim should exist"
521+
);
522+
assert!(
523+
std::fs::symlink_metadata(bin_dir.join("tsserver").as_path()).is_ok(),
524+
"tsserver shim should exist"
525+
);
493526
}
494527
#[cfg(windows)]
495528
{

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

Lines changed: 34 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,16 @@
22
//!
33
//! Creates the following structure:
44
//! - ~/.vite-plus/bin/ - Contains vp symlink and node/npm/npx shims
5-
//! - ~/.vite-plus/current/ - Symlink to the installed version directory
5+
//! - ~/.vite-plus/current/ - Contains the actual vp CLI binary
66
//!
7-
//! On Unix: bin/vp is a symlink to ../current/bin/vp
8-
//! On Windows: bin/vp.cmd is a wrapper script that calls ..\current\bin\vp.exe
7+
//! On Unix:
8+
//! - bin/vp is a symlink to ../current/bin/vp
9+
//! - bin/node, bin/npm, bin/npx are symlinks to ../current/bin/vp
10+
//! - Symlinks preserve argv[0], allowing tool detection via the symlink name
11+
//!
12+
//! On Windows:
13+
//! - bin/vp.cmd is a wrapper script that calls ..\current\bin\vp.exe
14+
//! - bin/node.cmd, bin/npm.cmd, bin/npx.cmd are wrappers calling `vp env run <tool>`
915
1016
use std::process::ExitStatus;
1117

@@ -160,7 +166,8 @@ async fn create_shim(
160166
fn shim_filename(tool: &str) -> String {
161167
#[cfg(windows)]
162168
{
163-
if tool == "node" { format!("{tool}.exe") } else { format!("{tool}.cmd") }
169+
// All tools use .cmd wrappers on Windows (including node)
170+
format!("{tool}.cmd")
164171
}
165172

166173
#[cfg(not(windows))]
@@ -169,57 +176,45 @@ fn shim_filename(tool: &str) -> String {
169176
}
170177
}
171178

172-
/// Create a Unix shim using hardlink, falling back to copy.
179+
/// Create a Unix shim using symlink to ../current/bin/vp.
180+
///
181+
/// Symlinks preserve argv[0], allowing the vp binary to detect which tool
182+
/// was invoked. This is the same pattern used by Volta.
173183
#[cfg(unix)]
174184
async fn create_unix_shim(
175-
source: &std::path::Path,
185+
_source: &std::path::Path,
176186
shim_path: &vite_path::AbsolutePath,
177187
_tool: &str,
178188
) -> Result<(), Error> {
179-
// Try hardlink first
180-
match tokio::fs::hard_link(source, shim_path).await {
181-
Ok(()) => {
182-
tracing::debug!("Created hardlink shim at {:?}", shim_path);
183-
}
184-
Err(e) => {
185-
tracing::debug!("Hardlink failed ({e}), falling back to copy");
186-
tokio::fs::copy(source, shim_path).await?;
187-
}
188-
}
189+
// Create symlink to ../current/bin/vp (relative path)
190+
tokio::fs::symlink("../current/bin/vp", shim_path).await?;
191+
tracing::debug!("Created symlink shim at {:?} -> ../current/bin/vp", shim_path);
189192

190193
Ok(())
191194
}
192195

193-
/// Create Windows shims.
194-
/// - node.exe: Copy of vp.exe
195-
/// - npm.cmd, npx.cmd: Wrapper scripts that set VITE_PLUS_SHIM_TOOL
196+
/// Create Windows shims using .cmd wrappers that call `vp env run <tool>`.
197+
///
198+
/// All tools (node, npm, npx) get .cmd wrappers that invoke `vp env run`.
199+
/// This is consistent with Volta's Windows approach.
196200
#[cfg(windows)]
197201
async fn create_windows_shim(
198-
source: &std::path::Path,
202+
_source: &std::path::Path,
199203
bin_dir: &vite_path::AbsolutePath,
200204
tool: &str,
201205
) -> Result<(), Error> {
202-
if tool == "node" {
203-
// Copy vp.exe as node.exe
204-
let node_exe = bin_dir.join("node.exe");
205-
tokio::fs::copy(source, &node_exe).await?;
206-
} else {
207-
// Create .cmd wrapper script
208-
let cmd_path = bin_dir.join(format!("{tool}.cmd"));
209-
let node_exe_path = bin_dir.join("node.exe");
210-
211-
let cmd_content = format!(
212-
r#"@echo off
213-
setlocal
214-
set "VITE_PLUS_SHIM_TOOL={tool}"
215-
"{}" %*
206+
let cmd_path = bin_dir.join(format!("{tool}.cmd"));
207+
208+
// Create .cmd wrapper that calls vp env run <tool>
209+
let cmd_content = format!(
210+
r#"@echo off
211+
"%~dp0..\current\bin\vp.exe" env run {tool} %*
216212
exit /b %ERRORLEVEL%
217-
"#,
218-
node_exe_path.as_path().display()
219-
);
213+
"#
214+
);
220215

221-
tokio::fs::write(&cmd_path, cmd_content).await?;
222-
}
216+
tokio::fs::write(&cmd_path, cmd_content).await?;
217+
tracing::debug!("Created Windows wrapper {:?} -> vp env run {}", cmd_path, tool);
223218

224219
Ok(())
225220
}

crates/vite_global_cli/src/shim/mod.rs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22
//!
33
//! This module provides the functionality for the vp binary to act as a shim
44
//! when invoked as `node`, `npm`, `npx`, or any globally installed package binary.
5-
//! It detects the invocation mode via argv[0] or the VITE_PLUS_SHIM_TOOL environment variable.
5+
//!
6+
//! Detection methods:
7+
//! - Unix: Symlinks to vp binary preserve argv[0], allowing tool detection
8+
//! - Windows: .cmd wrappers call `vp env run <tool>` directly
9+
//! - Legacy: VITE_PLUS_SHIM_TOOL env var (kept for backward compatibility)
610
711
mod cache;
812
mod dispatch;
@@ -99,10 +103,12 @@ fn is_potential_package_binary(tool: &str) -> bool {
99103

100104
/// Detect the shim tool from environment and argv.
101105
///
102-
/// Checks `VITE_PLUS_SHIM_TOOL` first (set by Windows .cmd wrappers),
103-
/// then falls back to argv[0] detection.
106+
/// Checks `VITE_PLUS_SHIM_TOOL` first (legacy, for backward compatibility),
107+
/// then falls back to argv[0] detection (primary method on Unix).
108+
///
109+
/// Note: Modern Windows wrappers use `vp env run <tool>` instead of env vars.
104110
pub fn detect_shim_tool(argv0: &str) -> Option<String> {
105-
// Check VITE_PLUS_SHIM_TOOL env var first (set by Windows .cmd wrappers)
111+
// Check VITE_PLUS_SHIM_TOOL env var first (legacy backward compatibility)
106112
if let Ok(tool) = std::env::var("VITE_PLUS_SHIM_TOOL") {
107113
if !tool.is_empty() {
108114
let tool_lower = tool.to_lowercase();

rfcs/env-command.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1522,6 +1522,7 @@ VITE_PLUS_HOME/
15221522
### How argv[0] Detection Works
15231523

15241524
When a user runs `node`:
1525+
15251526
1. Shell finds `~/.vite-plus/bin/node` in PATH
15261527
2. This is a symlink to `../current/bin/vp`
15271528
3. Kernel resolves symlink and executes `vp` binary
@@ -1580,6 +1581,7 @@ exit /b %ERRORLEVEL%
15801581
```
15811582

15821583
For npm:
1584+
15831585
```batch
15841586
@echo off
15851587
"%~dp0..\current\bin\vp.exe" env run npm %*

0 commit comments

Comments
 (0)