Skip to content

Commit 762fa90

Browse files
committed
feat(detection): add version manager directory scanning
- Add scan_version_manager_dir() for nvm/rbenv/goenv directories - Support version_manager_dir, version_prefix, binary_subpath in config - Add special handling for Go's default binary version mapping - Detect installed versions across major.minor cycles
1 parent 955a3f8 commit 762fa90

1 file changed

Lines changed: 175 additions & 44 deletions

File tree

cli_audit/detection.py

Lines changed: 175 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
349390
def 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

Comments
 (0)