55//! - ~/.vite-plus/current/ - Contains the actual vp CLI binary
66//!
77//! 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
8+ //! - bin/vp is a symlink to the active vp binary
9+ //! - bin/node, bin/npm, bin/npx are symlinks to the active vp binary
1010//! - Symlinks preserve argv[0], allowing tool detection via the symlink name
1111//!
1212//! On Windows:
@@ -88,7 +88,7 @@ pub async fn execute(refresh: bool, env_only: bool) -> Result<ExitStatus, Error>
8888 . map_err ( |e| Error :: ConfigError ( format ! ( "Cannot find current executable: {e}" ) . into ( ) ) ) ?;
8989
9090 // Create wrapper script in bin/
91- setup_vp_wrapper ( & bin_dir, refresh) . await ?;
91+ setup_vp_wrapper ( & current_exe , & bin_dir, refresh) . await ?;
9292
9393 // Create shims for node, npm, npx
9494 let mut created = Vec :: new ( ) ;
@@ -144,30 +144,44 @@ pub async fn execute(refresh: bool, env_only: bool) -> Result<ExitStatus, Error>
144144 Ok ( ExitStatus :: default ( ) )
145145}
146146
147- /// Create symlink in bin/ that points to current/bin/vp.
148- async fn setup_vp_wrapper ( bin_dir : & vite_path:: AbsolutePath , refresh : bool ) -> Result < ( ) , Error > {
147+ /// Create symlink in bin/ that points to the active vp binary.
148+ async fn setup_vp_wrapper (
149+ current_exe : & std:: path:: Path ,
150+ bin_dir : & vite_path:: AbsolutePath ,
151+ refresh : bool ,
152+ ) -> Result < ( ) , Error > {
149153 #[ cfg( unix) ]
150154 {
151155 let bin_vp = bin_dir. join ( "vp" ) ;
152-
153- // Create symlink bin/vp -> ../current/bin/vp
154- let should_create_symlink = refresh
155- || !tokio:: fs:: try_exists ( & bin_vp) . await . unwrap_or ( false )
156- || !is_symlink ( & bin_vp) . await ; // Replace non-symlink with symlink
156+ let target = resolve_unix_vp_shim_target ( current_exe, bin_dir) . await ?;
157+ let existing = tokio:: fs:: symlink_metadata ( & bin_vp) . await . ok ( ) ;
158+
159+ let should_create_symlink = match existing. as_ref ( ) {
160+ Some ( metadata) if refresh || !metadata. file_type ( ) . is_symlink ( ) => true ,
161+ Some ( _) => {
162+ let broken_symlink = !std:: fs:: exists ( bin_vp. as_path ( ) ) . unwrap_or ( false ) ;
163+ let wrong_target = tokio:: fs:: read_link ( & bin_vp)
164+ . await
165+ . map ( |existing_target| existing_target != target)
166+ . unwrap_or ( true ) ;
167+ broken_symlink || wrong_target
168+ }
169+ None => true ,
170+ } ;
157171
158172 if should_create_symlink {
159173 // Remove existing if present (could be old wrapper script or file)
160- if tokio :: fs :: try_exists ( & bin_vp ) . await . unwrap_or ( false ) {
174+ if existing . is_some ( ) {
161175 tokio:: fs:: remove_file ( & bin_vp) . await ?;
162176 }
163- // Create relative symlink
164- tokio:: fs:: symlink ( "../current/bin/vp" , & bin_vp) . await ?;
165- tracing:: debug!( "Created symlink {:?} -> ../current/bin/vp" , bin_vp) ;
177+ tokio:: fs:: symlink ( & target, & bin_vp) . await ?;
178+ tracing:: debug!( "Created symlink {:?} -> {:?}" , bin_vp, target) ;
166179 }
167180 }
168181
169182 #[ cfg( windows) ]
170183 {
184+ let _ = current_exe;
171185 let bin_vp_exe = bin_dir. join ( "vp.exe" ) ;
172186
173187 // Create trampoline bin/vp.exe that forwards to current\bin\vp.exe
@@ -195,13 +209,23 @@ async fn setup_vp_wrapper(bin_dir: &vite_path::AbsolutePath, refresh: bool) -> R
195209 Ok ( ( ) )
196210}
197211
198- /// Check if a path is a symlink.
199212#[ cfg( unix) ]
200- async fn is_symlink ( path : & vite_path:: AbsolutePath ) -> bool {
201- match tokio:: fs:: symlink_metadata ( path) . await {
202- Ok ( m) => m. file_type ( ) . is_symlink ( ) ,
203- Err ( _) => false ,
213+ pub ( crate ) async fn resolve_unix_vp_shim_target (
214+ current_exe : & std:: path:: Path ,
215+ bin_dir : & vite_path:: AbsolutePath ,
216+ ) -> Result < std:: path:: PathBuf , Error > {
217+ if let Some ( vite_plus_home) = bin_dir. parent ( ) {
218+ let standalone_vp = vite_plus_home. join ( "current" ) . join ( "bin" ) . join ( "vp" ) ;
219+ if tokio:: fs:: try_exists ( & standalone_vp) . await . unwrap_or ( false ) {
220+ let standalone_vp = tokio:: fs:: canonicalize ( & standalone_vp) . await . ok ( ) ;
221+ let current_exe = tokio:: fs:: canonicalize ( current_exe) . await . ok ( ) ;
222+ if standalone_vp. is_some ( ) && standalone_vp == current_exe {
223+ return Ok ( std:: path:: PathBuf :: from ( "../current/bin/vp" ) ) ;
224+ }
225+ }
204226 }
227+
228+ Ok ( current_exe. to_path_buf ( ) )
205229}
206230
207231/// Create a single shim for node/npm/npx.
@@ -215,9 +239,31 @@ async fn create_shim(
215239) -> Result < bool , Error > {
216240 let shim_path = bin_dir. join ( shim_filename ( tool) ) ;
217241
218- // Check if shim already exists
219- if tokio:: fs:: try_exists ( & shim_path) . await . unwrap_or ( false ) {
220- if !refresh {
242+ #[ cfg( unix) ]
243+ let desired_target = resolve_unix_vp_shim_target ( source, bin_dir) . await ?;
244+
245+ let existing = tokio:: fs:: symlink_metadata ( & shim_path) . await . ok ( ) ;
246+ if existing. is_some ( ) {
247+ let should_replace = if refresh {
248+ true
249+ } else {
250+ #[ cfg( unix) ]
251+ {
252+ existing. as_ref ( ) . is_some_and ( |metadata| metadata. file_type ( ) . is_symlink ( ) )
253+ && ( !std:: fs:: exists ( shim_path. as_path ( ) ) . unwrap_or ( false )
254+ || tokio:: fs:: read_link ( & shim_path)
255+ . await
256+ . map ( |existing_target| existing_target != desired_target)
257+ . unwrap_or ( true ) )
258+ }
259+
260+ #[ cfg( windows) ]
261+ {
262+ false
263+ }
264+ } ;
265+
266+ if !should_replace {
221267 return Ok ( false ) ;
222268 }
223269 #[ cfg( windows) ]
@@ -255,19 +301,22 @@ fn shim_filename(tool: &str) -> String {
255301 }
256302}
257303
258- /// Create a Unix shim using symlink to ../current/bin/vp .
304+ /// Create a Unix shim using symlink to the active vp binary .
259305///
260306/// Symlinks preserve argv[0], allowing the vp binary to detect which tool
261307/// was invoked. This is the same pattern used by Volta.
262308#[ cfg( unix) ]
263309async fn create_unix_shim (
264- _source : & std:: path:: Path ,
310+ source : & std:: path:: Path ,
265311 shim_path : & vite_path:: AbsolutePath ,
266- _tool : & str ,
312+ tool : & str ,
267313) -> Result < ( ) , Error > {
268- // Create symlink to ../current/bin/vp (relative path)
269- tokio:: fs:: symlink ( "../current/bin/vp" , shim_path) . await ?;
270- tracing:: debug!( "Created symlink shim at {:?} -> ../current/bin/vp" , shim_path) ;
314+ let bin_dir = shim_path. parent ( ) . ok_or_else ( || {
315+ Error :: ConfigError ( format ! ( "Cannot find parent directory for {tool} shim" ) . into ( ) )
316+ } ) ?;
317+ let target = resolve_unix_vp_shim_target ( source, bin_dir) . await ?;
318+ tokio:: fs:: symlink ( & target, shim_path) . await ?;
319+ tracing:: debug!( "Created symlink shim at {:?} -> {:?}" , shim_path, target) ;
271320
272321 Ok ( ( ) )
273322}
@@ -1086,6 +1135,142 @@ mod tests {
10861135 assert ! ( fresh_home. join( "env.ps1" ) . exists( ) , "env.ps1 file should be created" ) ;
10871136 }
10881137
1138+ #[ tokio:: test]
1139+ #[ cfg( unix) ]
1140+ async fn test_unix_vp_shim_target_prefers_standalone_layout_for_current_exe ( ) {
1141+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
1142+ let home = AbsolutePathBuf :: new ( temp_dir. path ( ) . join ( ".vite-plus" ) ) . unwrap ( ) ;
1143+ let bin_dir = home. join ( "bin" ) ;
1144+ let standalone_vp = home. join ( "current" ) . join ( "bin" ) . join ( "vp" ) ;
1145+
1146+ tokio:: fs:: create_dir_all ( standalone_vp. parent ( ) . unwrap ( ) ) . await . unwrap ( ) ;
1147+ tokio:: fs:: create_dir_all ( & bin_dir) . await . unwrap ( ) ;
1148+ tokio:: fs:: write ( & standalone_vp, b"vp" ) . await . unwrap ( ) ;
1149+
1150+ let target = resolve_unix_vp_shim_target ( standalone_vp. as_path ( ) , & bin_dir) . await . unwrap ( ) ;
1151+
1152+ assert_eq ! ( target, std:: path:: Path :: new( "../current/bin/vp" ) ) ;
1153+ }
1154+
1155+ #[ tokio:: test]
1156+ #[ cfg( unix) ]
1157+ async fn test_unix_vp_shim_target_uses_current_exe_when_standalone_is_stale ( ) {
1158+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
1159+ let home = AbsolutePathBuf :: new ( temp_dir. path ( ) . join ( ".vite-plus" ) ) . unwrap ( ) ;
1160+ let bin_dir = home. join ( "bin" ) ;
1161+ let standalone_vp = home. join ( "current" ) . join ( "bin" ) . join ( "vp" ) ;
1162+ let external_vp = temp_dir. path ( ) . join ( "external-vp" ) ;
1163+
1164+ tokio:: fs:: create_dir_all ( standalone_vp. parent ( ) . unwrap ( ) ) . await . unwrap ( ) ;
1165+ tokio:: fs:: create_dir_all ( & bin_dir) . await . unwrap ( ) ;
1166+ tokio:: fs:: write ( & standalone_vp, b"stale-vp" ) . await . unwrap ( ) ;
1167+ tokio:: fs:: write ( & external_vp, b"active-vp" ) . await . unwrap ( ) ;
1168+
1169+ let target = resolve_unix_vp_shim_target ( & external_vp, & bin_dir) . await . unwrap ( ) ;
1170+
1171+ assert_eq ! ( target, external_vp) ;
1172+ }
1173+
1174+ #[ tokio:: test]
1175+ #[ cfg( unix) ]
1176+ async fn test_unix_vp_shim_target_uses_current_exe_without_standalone_layout ( ) {
1177+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
1178+ let home = AbsolutePathBuf :: new ( temp_dir. path ( ) . join ( ".vite-plus" ) ) . unwrap ( ) ;
1179+ let bin_dir = home. join ( "bin" ) ;
1180+ let external_vp = temp_dir. path ( ) . join ( "external-vp" ) ;
1181+
1182+ tokio:: fs:: create_dir_all ( & bin_dir) . await . unwrap ( ) ;
1183+ tokio:: fs:: write ( & external_vp, b"vp" ) . await . unwrap ( ) ;
1184+
1185+ let target = resolve_unix_vp_shim_target ( & external_vp, & bin_dir) . await . unwrap ( ) ;
1186+
1187+ assert_eq ! ( target, external_vp) ;
1188+ }
1189+
1190+ #[ tokio:: test]
1191+ #[ cfg( unix) ]
1192+ async fn test_create_shim_replaces_stale_unix_symlink_without_refresh ( ) {
1193+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
1194+ let home = AbsolutePathBuf :: new ( temp_dir. path ( ) . join ( ".vite-plus" ) ) . unwrap ( ) ;
1195+ let bin_dir = home. join ( "bin" ) ;
1196+ let standalone_vp = home. join ( "current" ) . join ( "bin" ) . join ( "vp" ) ;
1197+ let external_vp = temp_dir. path ( ) . join ( "external-vp" ) ;
1198+ let node_shim = bin_dir. join ( "node" ) ;
1199+
1200+ tokio:: fs:: create_dir_all ( standalone_vp. parent ( ) . unwrap ( ) ) . await . unwrap ( ) ;
1201+ tokio:: fs:: create_dir_all ( & bin_dir) . await . unwrap ( ) ;
1202+ tokio:: fs:: write ( & standalone_vp, b"stale-vp" ) . await . unwrap ( ) ;
1203+ tokio:: fs:: write ( & external_vp, b"active-vp" ) . await . unwrap ( ) ;
1204+ tokio:: fs:: symlink ( "../current/bin/vp" , & node_shim) . await . unwrap ( ) ;
1205+
1206+ let created = create_shim ( & external_vp, & bin_dir, "node" , false ) . await . unwrap ( ) ;
1207+ let target = tokio:: fs:: read_link ( & node_shim) . await . unwrap ( ) ;
1208+
1209+ assert ! ( created, "stale shims should be recreated" ) ;
1210+ assert_eq ! ( target, external_vp) ;
1211+ }
1212+
1213+ #[ tokio:: test]
1214+ #[ cfg( unix) ]
1215+ async fn test_create_shim_replaces_broken_unix_symlink_without_refresh ( ) {
1216+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
1217+ let home = AbsolutePathBuf :: new ( temp_dir. path ( ) . join ( ".vite-plus" ) ) . unwrap ( ) ;
1218+ let bin_dir = home. join ( "bin" ) ;
1219+ let external_vp = temp_dir. path ( ) . join ( "external-vp" ) ;
1220+ let node_shim = bin_dir. join ( "node" ) ;
1221+
1222+ tokio:: fs:: create_dir_all ( & bin_dir) . await . unwrap ( ) ;
1223+ tokio:: fs:: write ( & external_vp, b"vp" ) . await . unwrap ( ) ;
1224+ tokio:: fs:: symlink ( "../current/bin/vp" , & node_shim) . await . unwrap ( ) ;
1225+
1226+ let created = create_shim ( & external_vp, & bin_dir, "node" , false ) . await . unwrap ( ) ;
1227+ let target = tokio:: fs:: read_link ( & node_shim) . await . unwrap ( ) ;
1228+
1229+ assert ! ( created, "broken shims should be recreated" ) ;
1230+ assert_eq ! ( target, external_vp) ;
1231+ }
1232+
1233+ #[ tokio:: test]
1234+ #[ cfg( unix) ]
1235+ async fn test_setup_vp_wrapper_replaces_stale_unix_symlink_without_refresh ( ) {
1236+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
1237+ let home = AbsolutePathBuf :: new ( temp_dir. path ( ) . join ( ".vite-plus" ) ) . unwrap ( ) ;
1238+ let bin_dir = home. join ( "bin" ) ;
1239+ let standalone_vp = home. join ( "current" ) . join ( "bin" ) . join ( "vp" ) ;
1240+ let external_vp = temp_dir. path ( ) . join ( "external-vp" ) ;
1241+ let vp_shim = bin_dir. join ( "vp" ) ;
1242+
1243+ tokio:: fs:: create_dir_all ( standalone_vp. parent ( ) . unwrap ( ) ) . await . unwrap ( ) ;
1244+ tokio:: fs:: create_dir_all ( & bin_dir) . await . unwrap ( ) ;
1245+ tokio:: fs:: write ( & standalone_vp, b"stale-vp" ) . await . unwrap ( ) ;
1246+ tokio:: fs:: write ( & external_vp, b"active-vp" ) . await . unwrap ( ) ;
1247+ tokio:: fs:: symlink ( "../current/bin/vp" , & vp_shim) . await . unwrap ( ) ;
1248+
1249+ setup_vp_wrapper ( & external_vp, & bin_dir, false ) . await . unwrap ( ) ;
1250+ let target = tokio:: fs:: read_link ( & vp_shim) . await . unwrap ( ) ;
1251+
1252+ assert_eq ! ( target, external_vp) ;
1253+ }
1254+
1255+ #[ tokio:: test]
1256+ #[ cfg( unix) ]
1257+ async fn test_setup_vp_wrapper_replaces_broken_unix_symlink_without_refresh ( ) {
1258+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
1259+ let home = AbsolutePathBuf :: new ( temp_dir. path ( ) . join ( ".vite-plus" ) ) . unwrap ( ) ;
1260+ let bin_dir = home. join ( "bin" ) ;
1261+ let external_vp = temp_dir. path ( ) . join ( "external-vp" ) ;
1262+ let vp_shim = bin_dir. join ( "vp" ) ;
1263+
1264+ tokio:: fs:: create_dir_all ( & bin_dir) . await . unwrap ( ) ;
1265+ tokio:: fs:: write ( & external_vp, b"vp" ) . await . unwrap ( ) ;
1266+ tokio:: fs:: symlink ( "../current/bin/vp" , & vp_shim) . await . unwrap ( ) ;
1267+
1268+ setup_vp_wrapper ( & external_vp, & bin_dir, false ) . await . unwrap ( ) ;
1269+ let target = tokio:: fs:: read_link ( & vp_shim) . await . unwrap ( ) ;
1270+
1271+ assert_eq ! ( target, external_vp) ;
1272+ }
1273+
10891274 #[ tokio:: test]
10901275 async fn test_create_env_files_contains_dynamic_completion ( ) {
10911276 let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
0 commit comments