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}
@@ -1079,6 +1128,142 @@ mod tests {
10791128 assert ! ( fresh_home. join( "env.ps1" ) . exists( ) , "env.ps1 file should be created" ) ;
10801129 }
10811130
1131+ #[ tokio:: test]
1132+ #[ cfg( unix) ]
1133+ async fn test_unix_vp_shim_target_prefers_standalone_layout_for_current_exe ( ) {
1134+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
1135+ let home = AbsolutePathBuf :: new ( temp_dir. path ( ) . join ( ".vite-plus" ) ) . unwrap ( ) ;
1136+ let bin_dir = home. join ( "bin" ) ;
1137+ let standalone_vp = home. join ( "current" ) . join ( "bin" ) . join ( "vp" ) ;
1138+
1139+ tokio:: fs:: create_dir_all ( standalone_vp. parent ( ) . unwrap ( ) ) . await . unwrap ( ) ;
1140+ tokio:: fs:: create_dir_all ( & bin_dir) . await . unwrap ( ) ;
1141+ tokio:: fs:: write ( & standalone_vp, b"vp" ) . await . unwrap ( ) ;
1142+
1143+ let target = resolve_unix_vp_shim_target ( standalone_vp. as_path ( ) , & bin_dir) . await . unwrap ( ) ;
1144+
1145+ assert_eq ! ( target, std:: path:: Path :: new( "../current/bin/vp" ) ) ;
1146+ }
1147+
1148+ #[ tokio:: test]
1149+ #[ cfg( unix) ]
1150+ async fn test_unix_vp_shim_target_uses_current_exe_when_standalone_is_stale ( ) {
1151+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
1152+ let home = AbsolutePathBuf :: new ( temp_dir. path ( ) . join ( ".vite-plus" ) ) . unwrap ( ) ;
1153+ let bin_dir = home. join ( "bin" ) ;
1154+ let standalone_vp = home. join ( "current" ) . join ( "bin" ) . join ( "vp" ) ;
1155+ let external_vp = temp_dir. path ( ) . join ( "external-vp" ) ;
1156+
1157+ tokio:: fs:: create_dir_all ( standalone_vp. parent ( ) . unwrap ( ) ) . await . unwrap ( ) ;
1158+ tokio:: fs:: create_dir_all ( & bin_dir) . await . unwrap ( ) ;
1159+ tokio:: fs:: write ( & standalone_vp, b"stale-vp" ) . await . unwrap ( ) ;
1160+ tokio:: fs:: write ( & external_vp, b"active-vp" ) . await . unwrap ( ) ;
1161+
1162+ let target = resolve_unix_vp_shim_target ( & external_vp, & bin_dir) . await . unwrap ( ) ;
1163+
1164+ assert_eq ! ( target, external_vp) ;
1165+ }
1166+
1167+ #[ tokio:: test]
1168+ #[ cfg( unix) ]
1169+ async fn test_unix_vp_shim_target_uses_current_exe_without_standalone_layout ( ) {
1170+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
1171+ let home = AbsolutePathBuf :: new ( temp_dir. path ( ) . join ( ".vite-plus" ) ) . unwrap ( ) ;
1172+ let bin_dir = home. join ( "bin" ) ;
1173+ let external_vp = temp_dir. path ( ) . join ( "external-vp" ) ;
1174+
1175+ tokio:: fs:: create_dir_all ( & bin_dir) . await . unwrap ( ) ;
1176+ tokio:: fs:: write ( & external_vp, b"vp" ) . await . unwrap ( ) ;
1177+
1178+ let target = resolve_unix_vp_shim_target ( & external_vp, & bin_dir) . await . unwrap ( ) ;
1179+
1180+ assert_eq ! ( target, external_vp) ;
1181+ }
1182+
1183+ #[ tokio:: test]
1184+ #[ cfg( unix) ]
1185+ async fn test_create_shim_replaces_stale_unix_symlink_without_refresh ( ) {
1186+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
1187+ let home = AbsolutePathBuf :: new ( temp_dir. path ( ) . join ( ".vite-plus" ) ) . unwrap ( ) ;
1188+ let bin_dir = home. join ( "bin" ) ;
1189+ let standalone_vp = home. join ( "current" ) . join ( "bin" ) . join ( "vp" ) ;
1190+ let external_vp = temp_dir. path ( ) . join ( "external-vp" ) ;
1191+ let node_shim = bin_dir. join ( "node" ) ;
1192+
1193+ tokio:: fs:: create_dir_all ( standalone_vp. parent ( ) . unwrap ( ) ) . await . unwrap ( ) ;
1194+ tokio:: fs:: create_dir_all ( & bin_dir) . await . unwrap ( ) ;
1195+ tokio:: fs:: write ( & standalone_vp, b"stale-vp" ) . await . unwrap ( ) ;
1196+ tokio:: fs:: write ( & external_vp, b"active-vp" ) . await . unwrap ( ) ;
1197+ tokio:: fs:: symlink ( "../current/bin/vp" , & node_shim) . await . unwrap ( ) ;
1198+
1199+ let created = create_shim ( & external_vp, & bin_dir, "node" , false ) . await . unwrap ( ) ;
1200+ let target = tokio:: fs:: read_link ( & node_shim) . await . unwrap ( ) ;
1201+
1202+ assert ! ( created, "stale shims should be recreated" ) ;
1203+ assert_eq ! ( target, external_vp) ;
1204+ }
1205+
1206+ #[ tokio:: test]
1207+ #[ cfg( unix) ]
1208+ async fn test_create_shim_replaces_broken_unix_symlink_without_refresh ( ) {
1209+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
1210+ let home = AbsolutePathBuf :: new ( temp_dir. path ( ) . join ( ".vite-plus" ) ) . unwrap ( ) ;
1211+ let bin_dir = home. join ( "bin" ) ;
1212+ let external_vp = temp_dir. path ( ) . join ( "external-vp" ) ;
1213+ let node_shim = bin_dir. join ( "node" ) ;
1214+
1215+ tokio:: fs:: create_dir_all ( & bin_dir) . await . unwrap ( ) ;
1216+ tokio:: fs:: write ( & external_vp, b"vp" ) . await . unwrap ( ) ;
1217+ tokio:: fs:: symlink ( "../current/bin/vp" , & node_shim) . await . unwrap ( ) ;
1218+
1219+ let created = create_shim ( & external_vp, & bin_dir, "node" , false ) . await . unwrap ( ) ;
1220+ let target = tokio:: fs:: read_link ( & node_shim) . await . unwrap ( ) ;
1221+
1222+ assert ! ( created, "broken shims should be recreated" ) ;
1223+ assert_eq ! ( target, external_vp) ;
1224+ }
1225+
1226+ #[ tokio:: test]
1227+ #[ cfg( unix) ]
1228+ async fn test_setup_vp_wrapper_replaces_stale_unix_symlink_without_refresh ( ) {
1229+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
1230+ let home = AbsolutePathBuf :: new ( temp_dir. path ( ) . join ( ".vite-plus" ) ) . unwrap ( ) ;
1231+ let bin_dir = home. join ( "bin" ) ;
1232+ let standalone_vp = home. join ( "current" ) . join ( "bin" ) . join ( "vp" ) ;
1233+ let external_vp = temp_dir. path ( ) . join ( "external-vp" ) ;
1234+ let vp_shim = bin_dir. join ( "vp" ) ;
1235+
1236+ tokio:: fs:: create_dir_all ( standalone_vp. parent ( ) . unwrap ( ) ) . await . unwrap ( ) ;
1237+ tokio:: fs:: create_dir_all ( & bin_dir) . await . unwrap ( ) ;
1238+ tokio:: fs:: write ( & standalone_vp, b"stale-vp" ) . await . unwrap ( ) ;
1239+ tokio:: fs:: write ( & external_vp, b"active-vp" ) . await . unwrap ( ) ;
1240+ tokio:: fs:: symlink ( "../current/bin/vp" , & vp_shim) . await . unwrap ( ) ;
1241+
1242+ setup_vp_wrapper ( & external_vp, & bin_dir, false ) . await . unwrap ( ) ;
1243+ let target = tokio:: fs:: read_link ( & vp_shim) . await . unwrap ( ) ;
1244+
1245+ assert_eq ! ( target, external_vp) ;
1246+ }
1247+
1248+ #[ tokio:: test]
1249+ #[ cfg( unix) ]
1250+ async fn test_setup_vp_wrapper_replaces_broken_unix_symlink_without_refresh ( ) {
1251+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
1252+ let home = AbsolutePathBuf :: new ( temp_dir. path ( ) . join ( ".vite-plus" ) ) . unwrap ( ) ;
1253+ let bin_dir = home. join ( "bin" ) ;
1254+ let external_vp = temp_dir. path ( ) . join ( "external-vp" ) ;
1255+ let vp_shim = bin_dir. join ( "vp" ) ;
1256+
1257+ tokio:: fs:: create_dir_all ( & bin_dir) . await . unwrap ( ) ;
1258+ tokio:: fs:: write ( & external_vp, b"vp" ) . await . unwrap ( ) ;
1259+ tokio:: fs:: symlink ( "../current/bin/vp" , & vp_shim) . await . unwrap ( ) ;
1260+
1261+ setup_vp_wrapper ( & external_vp, & bin_dir, false ) . await . unwrap ( ) ;
1262+ let target = tokio:: fs:: read_link ( & vp_shim) . await . unwrap ( ) ;
1263+
1264+ assert_eq ! ( target, external_vp) ;
1265+ }
1266+
10821267 #[ tokio:: test]
10831268 async fn test_create_env_files_contains_dynamic_completion ( ) {
10841269 let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
0 commit comments