33use std:: {
44 collections:: HashSet ,
55 io:: { Read , Write } ,
6+ path:: Path ,
67 process:: Stdio ,
78} ;
89
@@ -113,6 +114,8 @@ pub async fn install(
113114
114115 let binary_infos = extract_binaries ( & package_json) ;
115116
117+ materialize_symlinked_package_dir ( & node_modules_dir) . await ?;
118+
116119 // Detect which binaries are JavaScript files
117120 let mut bin_names = Vec :: new ( ) ;
118121 let mut js_bins = HashSet :: new ( ) ;
@@ -142,7 +145,7 @@ pub async fn install(
142145 let packages_to_remove: HashSet < _ > =
143146 conflicts. iter ( ) . map ( |( _, pkg) | pkg. clone ( ) ) . collect ( ) ;
144147 for pkg in packages_to_remove {
145- output:: raw ( & format ! ( "Uninstalling {} (conflicts with {})..." , pkg, package_name ) ) ;
148+ output:: raw ( & format ! ( "Uninstalling {} (conflicts with {})..." , pkg, package_spec ) ) ;
146149 // Use Box::pin to avoid recursive async type issues
147150 Box :: pin ( uninstall ( & pkg, false ) ) . await ?;
148151 }
@@ -153,7 +156,7 @@ pub async fn install(
153156 return Err ( Error :: BinaryConflict {
154157 bin_name : conflicts[ 0 ] . 0 . clone ( ) ,
155158 existing_package : conflicts[ 0 ] . 1 . clone ( ) ,
156- new_package : package_name . clone ( ) ,
159+ new_package : package_spec . to_string ( ) ,
157160 } ) ;
158161 }
159162 }
@@ -188,7 +191,7 @@ pub async fn install(
188191 // 8. Create shims for binaries and save per-binary configs
189192 let bin_dir = get_bin_dir ( ) ?;
190193 for bin_name in & bin_names {
191- create_package_shim ( & bin_dir, bin_name, & package_name ) . await ?;
194+ create_package_shim ( & bin_dir, bin_name, package_spec ) . await ?;
192195
193196 // Write per-binary config
194197 let bin_config = BinConfig :: new (
@@ -200,7 +203,7 @@ pub async fn install(
200203 bin_config. save ( ) . await ?;
201204 }
202205
203- output:: raw ( & format ! ( "Installed {} v{}" , package_name , installed_version) ) ;
206+ output:: raw ( & format ! ( "Installed {} v{}" , package_spec , installed_version) ) ;
204207 if !bin_names. is_empty ( ) {
205208 output:: raw ( & format ! ( "Binaries: {}" , bin_names. join( ", " ) ) ) ;
206209 }
@@ -269,6 +272,10 @@ pub async fn uninstall(package_name: &str, dry_run: bool) -> Result<(), Error> {
269272
270273/// Parse package spec into name and optional version.
271274fn parse_package_spec ( spec : & str ) -> ( String , Option < String > ) {
275+ if is_local_path ( spec) {
276+ return ( resolve_local_package_name ( spec) . unwrap_or_else ( || spec. to_string ( ) ) , None ) ;
277+ }
278+
272279 // Handle scoped packages: @scope/name@version
273280 if spec. starts_with ( '@' ) {
274281 // Find the second @ for version
@@ -287,6 +294,89 @@ fn parse_package_spec(spec: &str) -> (String, Option<String>) {
287294 ( spec. to_string ( ) , None )
288295}
289296
297+ fn is_local_path ( spec : & str ) -> bool {
298+ spec == "."
299+ || spec == ".."
300+ || spec. starts_with ( "./" )
301+ || spec. starts_with ( "../" )
302+ || spec. starts_with ( '/' )
303+ || ( cfg ! ( windows)
304+ && spec. len ( ) >= 3
305+ && spec. as_bytes ( ) [ 1 ] == b':'
306+ && ( spec. as_bytes ( ) [ 2 ] == b'\\' || spec. as_bytes ( ) [ 2 ] == b'/' ) )
307+ }
308+
309+ fn resolve_local_package_name ( spec : & str ) -> Option < String > {
310+ let pkg_json_path = current_dir ( ) . ok ( ) ?. join ( spec) . join ( "package.json" ) ;
311+ let content = std:: fs:: read_to_string ( pkg_json_path. as_path ( ) ) . ok ( ) ?;
312+ let json: serde_json:: Value = serde_json:: from_str ( & content) . ok ( ) ?;
313+ json. get ( "name" ) . and_then ( |name| name. as_str ( ) ) . map ( str:: to_string)
314+ }
315+
316+ async fn materialize_symlinked_package_dir ( node_modules_dir : & AbsolutePath ) -> Result < ( ) , Error > {
317+ let metadata = tokio:: fs:: symlink_metadata ( node_modules_dir. as_path ( ) ) . await ?;
318+ if !metadata. file_type ( ) . is_symlink ( ) {
319+ return Ok ( ( ) ) ;
320+ }
321+
322+ let symlink_target = tokio:: fs:: read_link ( node_modules_dir. as_path ( ) ) . await ?;
323+ let resolved_target = if symlink_target. is_absolute ( ) {
324+ symlink_target
325+ } else {
326+ node_modules_dir
327+ . parent ( )
328+ . expect ( "package dir should have a parent" )
329+ . as_path ( )
330+ . join ( & symlink_target)
331+ } ;
332+
333+ let package_dir_name =
334+ node_modules_dir. as_path ( ) . file_name ( ) . and_then ( |name| name. to_str ( ) ) . unwrap_or ( "package" ) ;
335+ let temp_dir = node_modules_dir. parent ( ) . expect ( "package dir should have a parent" ) . as_path ( ) . join (
336+ format ! ( ".{package_dir_name}-materialized" ) ,
337+ ) ;
338+
339+ if temp_dir. as_path ( ) . exists ( ) {
340+ std:: fs:: remove_dir_all ( & temp_dir) ?;
341+ }
342+
343+ copy_dir_recursive ( & resolved_target, & temp_dir) ?;
344+ tokio:: fs:: remove_file ( node_modules_dir. as_path ( ) ) . await ?;
345+ tokio:: fs:: rename ( & temp_dir, node_modules_dir. as_path ( ) ) . await ?;
346+
347+ Ok ( ( ) )
348+ }
349+
350+ fn copy_dir_recursive ( source : & Path , destination : & Path ) -> Result < ( ) , Error > {
351+ std:: fs:: create_dir_all ( destination) ?;
352+
353+ for entry in std:: fs:: read_dir ( source) ? {
354+ let entry = entry?;
355+ let source_path = entry. path ( ) ;
356+ let destination_path = destination. join ( entry. file_name ( ) ) ;
357+ let file_type = entry. file_type ( ) ?;
358+
359+ if file_type. is_dir ( ) {
360+ copy_dir_recursive ( & source_path, & destination_path) ?;
361+ continue ;
362+ }
363+
364+ if file_type. is_symlink ( ) {
365+ let resolved_path = std:: fs:: canonicalize ( & source_path) ?;
366+ if resolved_path. is_dir ( ) {
367+ copy_dir_recursive ( & resolved_path, & destination_path) ?;
368+ } else {
369+ std:: fs:: copy ( & resolved_path, & destination_path) ?;
370+ }
371+ continue ;
372+ }
373+
374+ std:: fs:: copy ( & source_path, & destination_path) ?;
375+ }
376+
377+ Ok ( ( ) )
378+ }
379+
290380/// Binary info extracted from package.json.
291381struct BinaryInfo {
292382 /// Binary name (the command users will run)
@@ -372,13 +462,13 @@ pub(crate) const CORE_SHIMS: &[&str] = &["node", "npm", "npx", "vp"];
372462async fn create_package_shim (
373463 bin_dir : & vite_path:: AbsolutePath ,
374464 bin_name : & str ,
375- package_name : & str ,
465+ package_display_name : & str ,
376466) -> Result < ( ) , Error > {
377467 // Check for conflicts with core shims
378468 if CORE_SHIMS . contains ( & bin_name) {
379469 output:: warn ( & format ! (
380470 "Package '{}' provides '{}' binary, but it conflicts with a core shim. Skipping." ,
381- package_name , bin_name
471+ package_display_name , bin_name
382472 ) ) ;
383473 return Ok ( ( ) ) ;
384474 }
@@ -729,6 +819,55 @@ mod tests {
729819 assert_eq ! ( version, Some ( "20.0.0" . to_string( ) ) ) ;
730820 }
731821
822+ #[ test]
823+ fn test_parse_package_spec_local_path_uses_package_name ( ) {
824+ use tempfile:: TempDir ;
825+
826+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
827+ let package_dir = temp_dir. path ( ) . join ( "local-pkg" ) ;
828+ std:: fs:: create_dir_all ( & package_dir) . unwrap ( ) ;
829+ std:: fs:: write (
830+ package_dir. join ( "package.json" ) ,
831+ r#"{"name":"resolved-local-pkg","version":"1.0.0"}"# ,
832+ )
833+ . unwrap ( ) ;
834+
835+ let ( name, version) = parse_package_spec ( package_dir. to_str ( ) . unwrap ( ) ) ;
836+ assert_eq ! ( name, "resolved-local-pkg" ) ;
837+ assert_eq ! ( version, None ) ;
838+ }
839+
840+ #[ test]
841+ #[ cfg( unix) ]
842+ fn test_materialize_symlinked_package_dir ( ) {
843+ use tempfile:: TempDir ;
844+ use vite_path:: AbsolutePathBuf ;
845+
846+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
847+ let source_dir = temp_dir. path ( ) . join ( "source-pkg" ) ;
848+ std:: fs:: create_dir_all ( & source_dir) . unwrap ( ) ;
849+ std:: fs:: write (
850+ source_dir. join ( "package.json" ) ,
851+ r#"{"name":"materialized-pkg","version":"1.0.0"}"# ,
852+ )
853+ . unwrap ( ) ;
854+ std:: fs:: write ( source_dir. join ( "cli.js" ) , "console.log('ok')" ) . unwrap ( ) ;
855+
856+ let node_modules_parent = temp_dir. path ( ) . join ( "prefix/lib/node_modules" ) ;
857+ std:: fs:: create_dir_all ( & node_modules_parent) . unwrap ( ) ;
858+ let symlink_path = node_modules_parent. join ( "materialized-pkg" ) ;
859+ std:: os:: unix:: fs:: symlink ( & source_dir, & symlink_path) . unwrap ( ) ;
860+
861+ let symlink_path = AbsolutePathBuf :: new ( symlink_path) . unwrap ( ) ;
862+ let runtime = tokio:: runtime:: Runtime :: new ( ) . unwrap ( ) ;
863+ runtime. block_on ( materialize_symlinked_package_dir ( & symlink_path) ) . unwrap ( ) ;
864+
865+ let metadata = std:: fs:: symlink_metadata ( symlink_path. as_path ( ) ) . unwrap ( ) ;
866+ assert ! ( !metadata. file_type( ) . is_symlink( ) ) ;
867+ assert ! ( symlink_path. as_path( ) . join( "package.json" ) . exists( ) ) ;
868+ assert ! ( symlink_path. as_path( ) . join( "cli.js" ) . exists( ) ) ;
869+ }
870+
732871 #[ test]
733872 fn test_is_javascript_binary_with_js_extension ( ) {
734873 use tempfile:: TempDir ;
0 commit comments