From 130fcac5ec8cb54440ad132949022bd5c53e2618 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Thu, 16 Apr 2026 22:38:25 +0200 Subject: [PATCH 1/4] fix(guide): store auto_update per multi-version cycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-update preference was keyed by base tool name, and multi-version tools (python, node, php, ruby, go) also short-circuited the auto-update path in guide.sh with an `is_multi_version` guard. Together this meant pressing "a" on "Python stack 3.14" had no persistent effect — every subsequent `make upgrade` re-prompted for the same cycle. Store the preference under the cycle-qualified name (e.g. python@3.14) and drop the guard. Each cycle now opts in independently, matching the UX the prompt advertises ("Always update"). set_auto_update.sh strips the @cycle suffix when validating against the catalog but keeps the full key for storage. Signed-off-by: Sebastian Mendel --- scripts/guide.sh | 21 +++++++++++++++------ scripts/set_auto_update.sh | 8 +++++--- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/scripts/guide.sh b/scripts/guide.sh index a647dbb..46298b3 100755 --- a/scripts/guide.sh +++ b/scripts/guide.sh @@ -235,7 +235,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 +305,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 @@ -502,9 +510,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 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 From 30f60d86f24a6b9fabf678c9d4a43c14c911b717 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Thu, 16 Apr 2026 22:38:34 +0200 Subject: [PATCH 2/4] fix(audit): refresh multi-version cycle entries in cmd_update_local MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refresh_snapshot() after an install invokes cmd_update_local, which merged through build_legacy_snapshot → merge_for_display. That path only emits base-tool keys (python, node, …), so per-cycle entries like python@3.14 were never updated — they stayed frozen at whatever value the last full audit left behind. The downstream symptom was guide.sh reporting "Upgrade did not succeed (version unchanged)" after a successful uv install, because its re-read of the snapshot still showed the pre-upgrade version. After the existing merge, re-detect multi-version cycles locally using supported-cycle metadata carried on the existing snapshot entries — no network needed. Rebuild the cycle entries in place, preserving fields the local-only path doesn't own (tool_url, latest_url). Signed-off-by: Sebastian Mendel --- audit.py | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) 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) From dd30ce8f641d172571d9dd054881f1470f89d989 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Thu, 16 Apr 2026 22:38:44 +0200 Subject: [PATCH 3/4] fix(collectors): cache endoflife responses with persistent fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit collect_endoflife() made a fresh 5s HTTP request every call with no retry, no process cache, and no persistent cache. A single transient failure (timeout, rate limit, DNS blip) returned an empty list, which made audit_multi_version_tool skip detection for every cycle — leaving the snapshot stale and producing false "upgrade did not succeed" messages for multi-version tools. Add: - In-process memo keyed by "{product}:{max_versions}" so the same product isn't fetched twice within one audit.py run. - Persistent file cache at $XDG_CACHE_HOME/cli-audit/endoflife.json. Successful fetches populate it; HTTP failures fall back to whatever is cached (any age — stale data beats empty data). - Memo negative results too, so a hard failure with no cache doesn't re-hit the network repeatedly. Legacy offline_cache argument still honored as a last resort. Signed-off-by: Sebastian Mendel --- cli_audit/collectors.py | 152 ++++++++++++++++++++++++++++------------ 1 file changed, 106 insertions(+), 46 deletions(-) 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 [] From 8f053869730a3b0dd433c3b82d338798e47efed2 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Thu, 16 Apr 2026 22:38:53 +0200 Subject: [PATCH 4/4] fix(guide): probe binary as upgrade-success fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upgrade success was decided solely by re-reading the snapshot after the install. If the snapshot refresh hit any transient problem (endoflife timeout, audit hiccup), the check would see the pre-install version and print "Upgrade did not succeed (version unchanged)" — even when the new binary was sitting right there on disk. Add probe_installed_version(), a small helper that resolves the binary via the catalog (multi_version.binary_pattern for cycled tools, else binary_name) and runs --version directly. The [Yy] and [Aa] success checks now consult it as a tiebreaker whenever the snapshot still shows the old version: if the binary reports something different, trust the binary. The ground truth beats a flaky round-trip. Signed-off-by: Sebastian Mendel --- scripts/guide.sh | 52 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/scripts/guide.sh b/scripts/guide.sh index 46298b3..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" @@ -475,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="" @@ -537,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=""