diff --git a/audit.py b/audit.py index 882a72f..ed5ad4b 100755 --- a/audit.py +++ b/audit.py @@ -859,6 +859,84 @@ def cmd_update_local(args: argparse.Namespace) -> int: if tool_name in updated_tool_names: tools_by_name[tool_name] = updated_tool + # Multi-version tools (python@3.14, node@22, php@8.3, …) have one + # snapshot entry per cycle. build_legacy_snapshot/merge_for_display + # only emits the base-tool key, so without this block the cycle + # entries would stay stale after an upgrade — masking successful + # installs as "version unchanged" in the guide. + try: + from cli_audit.catalog import ToolCatalog + _catalog = ToolCatalog() + except Exception: + _catalog = None + if _catalog is not None: + for tool in tools_list: + if not _catalog.has_tool(tool.name): + continue + catalog_data = _catalog.get_raw_data(tool.name) + mv_config = catalog_data.get("multi_version", {}) + if not mv_config.get("enabled"): + continue + # Reuse supported-cycle metadata from existing snapshot so this + # fast-path stays network-free. First full audit populates it; + # subsequent refreshes just re-detect local installs. + supported: list[dict] = [] + for t in existing_tools: + if t.get("base_tool") == tool.name and t.get("version_cycle"): + supported.append({ + "cycle": t["version_cycle"], + "latest": t.get("latest_upstream", ""), + "status": t.get("lifecycle_status", "unknown"), + "eol": None, + "support": None, + "release_date": None, + "lts": False, + }) + if not supported: + continue + detected = detect_multi_versions(tool.name, mv_config, supported) + for info in detected: + cycle = str(info.get("cycle", "")) + if not cycle: + continue + installed_v = info.get("installed") + latest_v = info.get("latest_upstream", "") + if installed_v and installed_v == latest_v: + status_v = "UP-TO-DATE" + elif installed_v: + status_v = "OUTDATED" + else: + status_v = "NOT INSTALLED" + method = info.get("install_method") + versioned = f"{tool.name}@{cycle}" + entry = dict(tools_by_name.get(versioned, {})) + entry.update({ + "tool": versioned, + "category": catalog_data.get("category", tool.name), + "installed": installed_v or "", + "installed_method": method, + "installed_version": installed_v or "", + "installed_path_selected": info.get("path"), + "classification_reason_selected": ( + f"Detected via path analysis: {method}" if method + else "No installation detected" + ), + "latest_upstream": latest_v, + "latest_version": latest_v, + "status": status_v, + "is_multi_version": True, + "base_tool": tool.name, + "version_cycle": cycle, + "lifecycle_status": info.get("status", "unknown"), + }) + if status_v == "OUTDATED": + entry["hint"] = f"Upgrade {tool.name} {cycle}: {installed_v} \u2192 {latest_v}" + elif status_v == "NOT INSTALLED": + entry["hint"] = f"Install {tool.name} {cycle}: check your package manager or version manager" + else: + entry["hint"] = "" + tools_by_name[versioned] = entry + # Write merged snapshot merged_tools = list(tools_by_name.values()) write_snapshot(merged_tools, offline=OFFLINE_MODE) diff --git a/cli_audit/collectors.py b/cli_audit/collectors.py index 59c4352..166e233 100644 --- a/cli_audit/collectors.py +++ b/cli_audit/collectors.py @@ -11,12 +11,50 @@ import json import logging +import os import re +import time import urllib.request from typing import Any logger = logging.getLogger(__name__) +# Persistent cache for endoflife.date responses. Acts as a fallback when the +# live API fetch fails (timeout, rate limit, network blip) so a transient +# failure doesn't silently produce an empty supported-versions list — which +# would cause downstream multi-version detection to skip every cycle. +_ENDOFLIFE_CACHE_PATH = os.environ.get( + "CLI_AUDIT_ENDOFLIFE_CACHE", + os.path.join( + os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache"), + "cli-audit", + "endoflife.json", + ), +) +# In-process memoization, keyed by f"{product}:{max_versions}". Stays valid +# for the lifetime of a single audit.py run. +_endoflife_memo: dict[str, list[dict[str, Any]]] = {} + + +def _load_endoflife_cache() -> dict[str, Any]: + try: + with open(_ENDOFLIFE_CACHE_PATH, "r", encoding="utf-8") as f: + data = json.load(f) + return data if isinstance(data, dict) else {} + except (OSError, json.JSONDecodeError): + return {} + + +def _save_endoflife_cache(data: dict[str, Any]) -> None: + try: + os.makedirs(os.path.dirname(_ENDOFLIFE_CACHE_PATH), exist_ok=True) + tmp = _ENDOFLIFE_CACHE_PATH + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(data, f) + os.replace(tmp, _ENDOFLIFE_CACHE_PATH) + except OSError as e: + logger.debug(f"Failed to write endoflife cache: {e}") + class CollectionError(Exception): """Raised when version collection fails.""" @@ -597,7 +635,13 @@ def collect_endoflife( """ from datetime import datetime + memo_key = f"{product}:{max_versions}" + if memo_key in _endoflife_memo: + return _endoflife_memo[memo_key] + today = datetime.now().strftime("%Y-%m-%d") + supported_versions: list[dict[str, Any]] = [] + fetched_ok = False try: url = f"https://endoflife.date/api/{product}.json" @@ -605,61 +649,77 @@ def collect_endoflife( if not isinstance(data, list): logger.warning(f"endoflife.date {product}: Unexpected response format") - return [] - - supported_versions = [] - - for entry in data: - cycle = entry.get("cycle", "") - eol = entry.get("eol") - support = entry.get("support") - latest = entry.get("latest", "") - - # Determine if version is still supported - # eol can be False (still supported) or a date string - if eol is False: - is_supported = True - elif isinstance(eol, str): - is_supported = eol > today - else: - is_supported = False - - if not is_supported: - continue - - # Determine status: active (full support) vs security (security fixes only) - if support is None or support is False: - status = "active" - elif isinstance(support, str) and support > today: - status = "active" - else: - status = "security" - - supported_versions.append({ - "cycle": str(cycle), - "latest": latest, - "status": status, - "eol": eol, - "support": support, - "release_date": entry.get("releaseDate"), - "lts": entry.get("lts", False), - }) - - if len(supported_versions) >= max_versions: - break - - logger.debug(f"endoflife.date {product}: Found {len(supported_versions)} supported versions") - return supported_versions + else: + for entry in data: + cycle = entry.get("cycle", "") + eol = entry.get("eol") + support = entry.get("support") + latest = entry.get("latest", "") + + # Determine if version is still supported + # eol can be False (still supported) or a date string + if eol is False: + is_supported = True + elif isinstance(eol, str): + is_supported = eol > today + else: + is_supported = False + + if not is_supported: + continue + + # Determine status: active (full support) vs security (security fixes only) + if support is None or support is False: + status = "active" + elif isinstance(support, str) and support > today: + status = "active" + else: + status = "security" + + supported_versions.append({ + "cycle": str(cycle), + "latest": latest, + "status": status, + "eol": eol, + "support": support, + "release_date": entry.get("releaseDate"), + "lts": entry.get("lts", False), + }) + + if len(supported_versions) >= max_versions: + break + + fetched_ok = True + logger.debug(f"endoflife.date {product}: Found {len(supported_versions)} supported versions") except Exception as e: logger.debug(f"endoflife.date failed for {product}: {e}") - # Use offline cache if available + if fetched_ok: + _endoflife_memo[memo_key] = supported_versions + cache = _load_endoflife_cache() + cache[memo_key] = {"at": int(time.time()), "entries": supported_versions} + _save_endoflife_cache(cache) + return supported_versions + + # HTTP failed (or response was malformed). Try persistent file cache next — + # stale data beats silently pretending the product has no supported cycles. + cache = _load_endoflife_cache() + cached = cache.get(memo_key) + if isinstance(cached, dict) and isinstance(cached.get("entries"), list): + age = int(time.time()) - int(cached.get("at", 0)) + logger.debug(f"endoflife.date {product}: using file cache (age {age}s)") + _endoflife_memo[memo_key] = cached["entries"] + return cached["entries"] + + # Legacy offline_cache argument (from write_upstream_cache dumps). if offline_cache and product in offline_cache: logger.debug(f"endoflife.date {product}: Using offline cache") + _endoflife_memo[memo_key] = offline_cache[product] return offline_cache[product] logger.warning(f"endoflife.date {product}: No versions found") + _endoflife_memo[memo_key] = [] return [] diff --git a/scripts/guide.sh b/scripts/guide.sh index a647dbb..d49b463 100755 --- a/scripts/guide.sh +++ b/scripts/guide.sh @@ -165,6 +165,40 @@ osc8() { [ -n "$url" ] && printf '\e]8;;%s\e\\%s\e]8;;\e\\' "$url" "$text" || printf '%s' "$text" } +# Probe the installed version directly from the binary — bypasses the +# snapshot round-trip. Used as a fallback in upgrade-success checks so a +# stale snapshot (e.g. after a transient endoflife failure) doesn't mask a +# genuinely successful install. +# +# Args: catalog_tool [version_cycle] +# Echoes version number (e.g. "3.14.4") on success, empty on failure. +probe_installed_version() { + local catalog_tool="$1" + local version_cycle="${2:-}" + local binary pattern bin_path ver + + if [ -n "$version_cycle" ]; then + pattern="$(catalog_get_property "$catalog_tool" "multi_version.binary_pattern" 2>/dev/null)" + [ -z "$pattern" ] && pattern="${catalog_tool}{cycle}" + binary="${pattern//\{cycle\}/$version_cycle}" + else + binary="$(catalog_get_property "$catalog_tool" "binary_name" 2>/dev/null)" + [ -z "$binary" ] && binary="$catalog_tool" + fi + + if [[ "$binary" == /* ]]; then + [ -x "$binary" ] || return 1 + bin_path="$binary" + else + bin_path="$(command -v "$binary" 2>/dev/null)" || return 1 + fi + + # Try --version first, then -v, capture both stdout and stderr. Extract + # the first dotted version number we see. + ver="$("$bin_path" --version 2>&1 || "$bin_path" -v 2>&1 || true)" + printf '%s\n' "$ver" | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -n1 +} + # Print installed status line (reusable for auto-update and interactive prompts) print_installed_status() { local installed="$1" @@ -235,7 +269,14 @@ process_tool() { local install_action="$(catalog_get_guide_property "$catalog_tool" install_action "")" local description="$(catalog_get_property "$catalog_tool" description)" local homepage="$(catalog_get_property "$catalog_tool" homepage)" - local auto_update="$(config_get_auto_update "$catalog_tool")" + # Multi-version tools (python@3.13, php@8.3, etc.) store auto-update per cycle, + # so 'a' on one cycle doesn't silently apply to other cycles. Non-multi-version + # tools use the bare catalog name. + local auto_update_key="$catalog_tool" + if [ -n "$is_multi_version" ]; then + auto_update_key="$tool" + fi + local auto_update="$(config_get_auto_update "$auto_update_key")" # Check if runtime requirements are satisfied (e.g., npm requires node) local missing_req @@ -298,9 +339,10 @@ process_tool() { return 0 fi - # Check if auto_update is enabled - install without prompting - # BUT: multi-version tools always prompt (more significant operation) - if [ "$auto_update" = "true" ] && [ -z "$is_multi_version" ]; then + # Check if auto_update is enabled - install without prompting. + # For multi-version tools the key is cycle-qualified (e.g. python@3.13), so + # each cycle opts in independently. + if [ "$auto_update" = "true" ]; then printf "\n==> %s %s [auto-update]\n" "$icon" "$display" print_installed_status "$installed" "$method" # Show target; for self-managed tools (skip_upstream) show "self-managed" instead of @@ -467,6 +509,16 @@ process_tool() { # Check if upgrade succeeded by comparing versions local new_installed="$(json_field "$tool" installed)" + # If the snapshot still reports the pre-install version, the refresh + # may have hit a transient failure (endoflife timeout, flaky audit). + # Probe the binary directly as a tiebreaker — it's the ground truth. + if [ -z "$new_installed" ] || [ "$new_installed" = "$installed" ]; then + local probed_y + probed_y="$(probe_installed_version "$catalog_tool" "$version_cycle" 2>/dev/null || true)" + if [ -n "$probed_y" ] && [ "$probed_y" != "$installed" ]; then + new_installed="$probed_y" + fi + fi # Check if installer flagged binary as already at target (hash match) local already_current_marker="/tmp/.cli-audit/${catalog_tool}.already-current" local binary_already_current="" @@ -502,9 +554,10 @@ process_tool() { fi ;; [Aa]) - # Install/upgrade AND enable auto-update for future (use catalog_tool for settings) + # Install/upgrade AND enable auto-update for future. Use the cycle-qualified + # key for multi-version tools so other cycles still prompt. printf " Enabling auto-update for future upgrades...\n" - "$ROOT"/scripts/set_auto_update.sh "$catalog_tool" true >/dev/null 2>&1 || true + "$ROOT"/scripts/set_auto_update.sh "$auto_update_key" true >/dev/null 2>&1 || true # Handle tool-specific version environment variables local upgrade_success_a=0 @@ -528,6 +581,14 @@ process_tool() { # Check if upgrade succeeded local new_installed_a="$(json_field "$tool" installed)" + # Binary-probe fallback (see [Yy] branch for rationale). + if [ -z "$new_installed_a" ] || [ "$new_installed_a" = "$installed" ]; then + local probed_a + probed_a="$(probe_installed_version "$catalog_tool" "$version_cycle" 2>/dev/null || true)" + if [ -n "$probed_a" ] && [ "$probed_a" != "$installed" ]; then + new_installed_a="$probed_a" + fi + fi # Check if installer flagged binary as already at target (hash match) local already_current_marker_a="/tmp/.cli-audit/${catalog_tool}.already-current" local binary_already_current_a="" diff --git a/scripts/set_auto_update.sh b/scripts/set_auto_update.sh index d74a54a..5d1660a 100755 --- a/scripts/set_auto_update.sh +++ b/scripts/set_auto_update.sh @@ -26,10 +26,12 @@ if [ -z "$TOOL" ]; then exit 1 fi -# Validate tool exists in catalog -CATALOG_FILE="$ROOT/catalog/$TOOL.json" +# Validate tool exists in catalog (strip @cycle suffix for multi-version tools +# like python@3.13 — the catalog file is the base tool, python.json) +BASE_TOOL="${TOOL%%@*}" +CATALOG_FILE="$ROOT/catalog/$BASE_TOOL.json" if [ ! -f "$CATALOG_FILE" ]; then - echo "Error: Tool '$TOOL' not found in catalog" >&2 + echo "Error: Tool '$BASE_TOOL' not found in catalog" >&2 exit 1 fi