@@ -129,6 +129,99 @@ def collect_latest_version(tool: Tool, offline_cache: dict[str, tuple[str, str]]
129129 return ("" , "" )
130130
131131
132+ def audit_multi_version_tool (
133+ tool_name : str ,
134+ catalog_data : dict ,
135+ mv_config : dict ,
136+ ) -> list [dict [str , str ]]:
137+ """Audit a multi-version runtime tool.
138+
139+ For tools like PHP, Python, Node.js that support multiple concurrent versions,
140+ this generates separate audit entries for each supported version cycle.
141+
142+ Args:
143+ tool_name: Base tool name (e.g., "php", "python")
144+ catalog_data: Full catalog entry data
145+ mv_config: Multi-version configuration from catalog
146+
147+ Returns:
148+ List of audit result dictionaries, one per version cycle
149+ """
150+ product = mv_config .get ("product" , tool_name )
151+ max_versions = mv_config .get ("max_versions" , 4 )
152+
153+ # Fetch supported versions from endoflife.date
154+ try :
155+ supported = collect_endoflife (product , max_versions = max_versions )
156+ except Exception :
157+ return []
158+
159+ if not supported :
160+ return []
161+
162+ # Detect installed versions
163+ detected = detect_multi_versions (tool_name , mv_config , supported )
164+
165+ results = []
166+ for version_info in detected :
167+ cycle = version_info .get ("cycle" , "" )
168+ latest = version_info .get ("latest_upstream" , "" )
169+ installed = version_info .get ("installed" )
170+ path = version_info .get ("path" , "" )
171+ method = version_info .get ("install_method" , "" )
172+ status_lifecycle = version_info .get ("status" , "unknown" )
173+
174+ # Determine audit status
175+ if installed :
176+ if installed == latest :
177+ status = "UP-TO-DATE"
178+ else :
179+ status = "OUTDATED"
180+ else :
181+ status = "NOT INSTALLED"
182+
183+ # Build versioned tool name
184+ versioned_name = f"{ tool_name } @{ cycle } "
185+
186+ # Classification reason
187+ if method :
188+ classification_reason = f"Detected via path analysis: { method } "
189+ else :
190+ classification_reason = "No installation detected"
191+
192+ # Hint for not installed or outdated
193+ if status == "NOT INSTALLED" :
194+ hint = f"Install { tool_name } { cycle } : check your package manager or version manager"
195+ elif status == "OUTDATED" :
196+ hint = f"Upgrade { tool_name } { cycle } : { installed } → { latest } "
197+ else :
198+ hint = ""
199+
200+ results .append ({
201+ "tool" : versioned_name ,
202+ "category" : catalog_data .get ("category" , tool_name ),
203+ "installed" : installed or "" ,
204+ "installed_method" : method ,
205+ "installed_version" : installed or "" ,
206+ "installed_path_selected" : path ,
207+ "classification_reason_selected" : classification_reason ,
208+ "latest_upstream" : latest ,
209+ "latest_version" : latest ,
210+ "upstream_method" : "endoflife" ,
211+ "status" : status ,
212+ "tool_url" : catalog_data .get ("homepage" , "" ),
213+ "latest_url" : f"https://endoflife.date/{ product } " ,
214+ "hint" : hint ,
215+ # Extra fields for multi-version
216+ "is_multi_version" : True ,
217+ "base_tool" : tool_name ,
218+ "version_cycle" : cycle ,
219+ "lifecycle_status" : status_lifecycle , # active, security, eol
220+ })
221+
222+ return results
223+
224+
132225def audit_tool (tool : Tool , offline_cache : dict [str , tuple [str , str ]] | None = None ) -> dict [str , str ]:
133226 """Audit a single tool.
134227
@@ -405,16 +498,34 @@ def cmd_update(args: argparse.Namespace) -> int:
405498 print (f"# Estimated time: ~{ est_time } s (timeout=3s per tool, { MAX_WORKERS } workers)" , file = sys .stderr )
406499 print ("" , file = sys .stderr )
407500
408- # Group tools by category for organized output
501+ # Identify multi-version tools
502+ from cli_audit .catalog import ToolCatalog
503+ catalog = ToolCatalog ()
504+ multi_version_tools = {} # tool_name -> (catalog_data, mv_config)
505+ regular_tools = []
506+
507+ for tool in tools_list :
508+ if catalog .has_tool (tool .name ):
509+ catalog_data = catalog .get_raw_data (tool .name )
510+ mv_config = catalog_data .get ("multi_version" , {})
511+ if mv_config .get ("enabled" ):
512+ multi_version_tools [tool .name ] = (catalog_data , mv_config )
513+ continue
514+ regular_tools .append (tool )
515+
516+ # Group regular tools by category for organized output
409517 from cli_audit .render import CATEGORY_ORDER , CATEGORY_ICON , CATEGORY_DESC
410518 categorized : dict [str , list ] = {}
411- for tool in tools_list :
519+ for tool in regular_tools :
412520 cat = tool .category or "general"
413521 if cat not in categorized :
414522 categorized [cat ] = []
415523 categorized [cat ].append (tool )
416524 sorted_cats = sorted (categorized .keys (), key = lambda c : CATEGORY_ORDER .get (c , 99 ))
417525
526+ # Total includes both regular tools and multi-version entries
527+ total = len (regular_tools ) + len (multi_version_tools )
528+
418529 # Parallel audit with progress tracking, grouped by category
419530 results = []
420531 completed = 0
@@ -506,6 +617,49 @@ def cmd_update(args: argparse.Namespace) -> int:
506617 "latest_url" : "" ,
507618 "hint" : "" ,
508619 })
620+
621+ # Audit multi-version runtimes
622+ if multi_version_tools :
623+ # ANSI colors
624+ GREEN = "\033 [32m"
625+ BOLD_GREEN = "\033 [1;32m"
626+ YELLOW = "\033 [33m"
627+ BLUE = "\033 [34m"
628+ RESET = "\033 [0m"
629+
630+ print (f"\n # 🔄 Multi-version runtimes ({ len (multi_version_tools )} runtimes)" , file = sys .stderr )
631+
632+ for tool_name , (catalog_data , mv_config ) in multi_version_tools .items ():
633+ completed += 1
634+ print (f"# [{ completed } /{ total } ] { tool_name } (multi-version)..." , file = sys .stderr , flush = True )
635+
636+ mv_results = audit_multi_version_tool (tool_name , catalog_data , mv_config )
637+
638+ for mv_result in mv_results :
639+ results .append (mv_result )
640+
641+ # Progress output for each version
642+ versioned_name = mv_result .get ("tool" , "" )
643+ inst = mv_result .get ("installed" , "" )
644+ latest = mv_result .get ("latest_upstream" , "" )
645+ status = mv_result .get ("status" , "" )
646+
647+ if status == "UP-TO-DATE" :
648+ inst_color = GREEN
649+ op = "==="
650+ elif status == "OUTDATED" :
651+ inst_color = YELLOW
652+ op = "!=="
653+ else :
654+ inst_color = BLUE
655+ op = "?"
656+
657+ inst_display = inst if inst else "n/a"
658+ latest_display = latest if latest else "n/a"
659+ inst_fmt = f"{ inst_color } { inst_display } { RESET } "
660+ latest_fmt = f"{ BOLD_GREEN } { latest_display } { RESET } "
661+ print (f"# → { versioned_name } : { inst_fmt } { op } { latest_fmt } " , file = sys .stderr , flush = True )
662+
509663 except KeyboardInterrupt :
510664 # Shutdown executor immediately without waiting for threads
511665 executor .shutdown (wait = False , cancel_futures = True )
0 commit comments