@@ -346,6 +346,47 @@ def audit_tool_installation(
346346 return (version_num , version_line , path , install_method )
347347
348348
349+ def scan_version_manager_dir (
350+ base_dir : str ,
351+ version_prefix : str = "" ,
352+ binary_subpath : str = "bin" ,
353+ binary_name : str = "" ,
354+ ) -> list [tuple [str , str ]]:
355+ """Scan a version manager directory for installed versions.
356+
357+ Args:
358+ base_dir: Base directory (e.g., "~/.nvm/versions/node")
359+ version_prefix: Prefix to strip (e.g., "v" for node versions like "v22.0.0")
360+ binary_subpath: Subdirectory containing the binary (e.g., "bin")
361+ binary_name: Name of the binary to look for (e.g., "node", "ruby")
362+
363+ Returns:
364+ List of (version, binary_path) tuples for installed versions
365+ """
366+ base_dir = os .path .expanduser (base_dir )
367+ if not os .path .isdir (base_dir ):
368+ return []
369+
370+ results = []
371+ for version_dir in sorted (os .listdir (base_dir ), reverse = True ):
372+ version_path = os .path .join (base_dir , version_dir )
373+ if not os .path .isdir (version_path ):
374+ continue
375+
376+ # Get version string (strip prefix if present)
377+ version = version_dir
378+ if version_prefix and version .startswith (version_prefix ):
379+ version = version [len (version_prefix ):]
380+
381+ # Find the binary
382+ if binary_name :
383+ binary_path = os .path .join (version_path , binary_subpath , binary_name )
384+ if os .path .isfile (binary_path ) and os .access (binary_path , os .X_OK ):
385+ results .append ((version , binary_path ))
386+
387+ return results
388+
389+
349390def detect_multi_versions (
350391 tool_name : str ,
351392 multi_version_config : dict ,
@@ -354,15 +395,18 @@ def detect_multi_versions(
354395 """Detect multiple installed versions of a runtime.
355396
356397 This function checks for version-specific binaries (e.g., php8.4, php8.3)
357- based on the supported versions from endoflife.date .
398+ or scans version manager directories (e.g., ~/.nvm/versions/node/) .
358399
359400 Args:
360- tool_name: Base tool name (e.g., "php", "python", "go")
401+ tool_name: Base tool name (e.g., "php", "python", "go", "node" )
361402 multi_version_config: Multi-version configuration from catalog, containing:
362403 - binary_pattern: Pattern like "php{cycle}" or "python{cycle}"
363404 - candidates: List of patterns to try
405+ - version_manager_dir: Directory to scan (e.g., "~/.nvm/versions/node")
406+ - version_prefix: Prefix to strip from directory names (e.g., "v")
407+ - binary_subpath: Subdirectory containing binary (default: "bin")
364408 supported_versions: List of supported versions from endoflife.date, each with:
365- - cycle: Version cycle (e.g., "8.4", "3.12")
409+ - cycle: Version cycle (e.g., "8.4", "3.12", "22" )
366410 - latest: Latest patch version
367411 - status: "active" or "security"
368412
@@ -381,48 +425,135 @@ def detect_multi_versions(
381425 [{"cycle": "8.4", "installed": "8.4.16", "path": "/usr/bin/php8.4", ...}]
382426 """
383427 results = []
384- binary_pattern = multi_version_config .get ("binary_pattern" , f"{ tool_name } {{cycle}}" )
385- candidate_patterns = multi_version_config .get ("candidates" , [binary_pattern ])
386428
387- for version_info in supported_versions :
388- cycle = version_info .get ("cycle" , "" )
389- if not cycle :
390- continue
429+ # Check if using version manager directory scanning
430+ version_manager_dir = multi_version_config .get ("version_manager_dir" )
391431
392- # Try each candidate pattern
393- found_path = None
394- installed_version = None
395-
396- for pattern in candidate_patterns :
397- # Replace {cycle} with actual version cycle
398- binary_name = pattern .replace ("{cycle}" , cycle )
399-
400- # Check if absolute path
401- if binary_name .startswith ("/" ):
402- if os .path .isfile (binary_name ) and os .access (binary_name , os .X_OK ):
403- found_path = binary_name
404- else :
405- # Search in PATH
406- path = shutil .which (binary_name )
407- if path :
408- found_path = path
409-
410- if found_path :
411- # Get version info
412- version_line = get_version_line (found_path , tool_name )
413- installed_version = extract_version_number (version_line )
414- break
415-
416- result = {
417- "cycle" : cycle ,
418- "latest_upstream" : version_info .get ("latest" , "" ),
419- "installed" : installed_version ,
420- "path" : found_path ,
421- "install_method" : detect_install_method (found_path , tool_name ) if found_path else None ,
422- "status" : version_info .get ("status" , "unknown" ),
423- "eol" : version_info .get ("eol" ),
424- "lts" : version_info .get ("lts" , False ),
425- }
426- results .append (result )
432+ if version_manager_dir :
433+ # Scan version manager directory for installed versions
434+ version_prefix = multi_version_config .get ("version_prefix" , "" )
435+ binary_subpath = multi_version_config .get ("binary_subpath" , "bin" )
436+ binary_name = multi_version_config .get ("binary_name" , tool_name )
437+
438+ installed_versions = scan_version_manager_dir (
439+ version_manager_dir , version_prefix , binary_subpath , binary_name
440+ )
441+
442+ # Create a lookup map: major.minor -> (full_version, path)
443+ installed_map : dict [str , tuple [str , str ]] = {}
444+ for version , path in installed_versions :
445+ # Extract major.minor from full version (e.g., "22.12.0" -> "22")
446+ parts = version .split ("." )
447+ if parts :
448+ major = parts [0 ]
449+ # For some runtimes, use major.minor (e.g., Python 3.12)
450+ if len (parts ) > 1 and tool_name in ("python" , "ruby" ):
451+ key = f"{ parts [0 ]} .{ parts [1 ]} "
452+ else :
453+ key = major
454+ # Keep the highest patch version for each major/minor
455+ if key not in installed_map or version > installed_map [key ][0 ]:
456+ installed_map [key ] = (version , path )
457+
458+ for version_info in supported_versions :
459+ cycle = str (version_info .get ("cycle" , "" ))
460+ if not cycle :
461+ continue
462+
463+ installed_version = None
464+ found_path = None
465+
466+ if cycle in installed_map :
467+ installed_version , found_path = installed_map [cycle ]
468+
469+ result = {
470+ "cycle" : cycle ,
471+ "latest_upstream" : version_info .get ("latest" , "" ),
472+ "installed" : installed_version ,
473+ "path" : found_path ,
474+ "install_method" : detect_install_method (found_path , tool_name ) if found_path else None ,
475+ "status" : version_info .get ("status" , "unknown" ),
476+ "eol" : version_info .get ("eol" ),
477+ "lts" : version_info .get ("lts" , False ),
478+ }
479+ results .append (result )
480+
481+ else :
482+ # Use binary pattern matching (original behavior)
483+ binary_pattern = multi_version_config .get ("binary_pattern" , f"{ tool_name } {{cycle}}" )
484+ candidate_patterns = multi_version_config .get ("candidates" , [binary_pattern ])
485+
486+ # For Go: also check the default 'go' binary and map to cycle
487+ if tool_name == "go" :
488+ default_go = shutil .which ("go" )
489+ if default_go :
490+ version_line = get_version_line (default_go , "go" , version_flag = "version" )
491+ default_version = extract_version_number (version_line )
492+ if default_version :
493+ # Extract major.minor from version (e.g., "1.25.6" -> "1.25")
494+ parts = default_version .split ("." )
495+ if len (parts ) >= 2 :
496+ default_cycle = f"{ parts [0 ]} .{ parts [1 ]} "
497+ # Pre-populate with the default go binary
498+ for version_info in supported_versions :
499+ if str (version_info .get ("cycle" , "" )) == default_cycle :
500+ # Found matching cycle, add early result
501+ results .append ({
502+ "cycle" : default_cycle ,
503+ "latest_upstream" : version_info .get ("latest" , "" ),
504+ "installed" : default_version ,
505+ "path" : default_go ,
506+ "install_method" : detect_install_method (default_go , tool_name ),
507+ "status" : version_info .get ("status" , "unknown" ),
508+ "eol" : version_info .get ("eol" ),
509+ "lts" : version_info .get ("lts" , False ),
510+ })
511+ # Remove this cycle from further processing
512+ supported_versions = [
513+ v for v in supported_versions
514+ if str (v .get ("cycle" , "" )) != default_cycle
515+ ]
516+ break
517+
518+ for version_info in supported_versions :
519+ cycle = version_info .get ("cycle" , "" )
520+ if not cycle :
521+ continue
522+
523+ # Try each candidate pattern
524+ found_path = None
525+ installed_version = None
526+
527+ for pattern in candidate_patterns :
528+ # Replace {cycle} with actual version cycle
529+ binary_name = pattern .replace ("{cycle}" , str (cycle ))
530+
531+ # Check if absolute path
532+ if binary_name .startswith ("/" ):
533+ if os .path .isfile (binary_name ) and os .access (binary_name , os .X_OK ):
534+ found_path = binary_name
535+ else :
536+ # Search in PATH
537+ path = shutil .which (binary_name )
538+ if path :
539+ found_path = path
540+
541+ if found_path :
542+ # Get version info
543+ version_line = get_version_line (found_path , tool_name )
544+ installed_version = extract_version_number (version_line )
545+ break
546+
547+ result = {
548+ "cycle" : cycle ,
549+ "latest_upstream" : version_info .get ("latest" , "" ),
550+ "installed" : installed_version ,
551+ "path" : found_path ,
552+ "install_method" : detect_install_method (found_path , tool_name ) if found_path else None ,
553+ "status" : version_info .get ("status" , "unknown" ),
554+ "eol" : version_info .get ("eol" ),
555+ "lts" : version_info .get ("lts" , False ),
556+ }
557+ results .append (result )
427558
428559 return results
0 commit comments