-
Notifications
You must be signed in to change notification settings - Fork 0
fix: upgrade-verification and per-cycle auto_update for multi-version tools #74
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
130fcac
30f60d8
dd30ce8
8f05386
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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() | ||
|
Comment on lines
+868
to
+869
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
| except Exception: | ||
| _catalog = None | ||
|
Comment on lines
+867
to
+871
|
||
| 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", {}) | ||
|
Comment on lines
+873
to
+877
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
| 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"), | ||
| }) | ||
|
Comment on lines
+913
to
+931
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The logic for constructing the snapshot entry for multi-version tools is duplicated here from |
||
| 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) | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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) | ||||||||||
|
||||||||||
| os.makedirs(os.path.dirname(_ENDOFLIFE_CACHE_PATH), exist_ok=True) | |
| cache_dir = os.path.dirname(_ENDOFLIFE_CACHE_PATH) | |
| if cache_dir: | |
| os.makedirs(cache_dir, exist_ok=True) |
Copilot
AI
Apr 16, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The file-cache fallback path in collect_endoflife is behaviorally important but doesn’t appear to be covered by tests (there are tests for other cli_audit.collectors helpers). Adding a unit test that writes a cache file, forces http_get to fail, and asserts cached entries are returned would help prevent regressions.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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}" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+173
to
+183
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # 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}" | |
| # Resolve a cycle-specific binary. Prefer an explicit binary pattern, then | |
| # version-manager install roots, and only then fall back to the historical | |
| # `${catalog_tool}{cycle}` PATH-based name. | |
| resolve_cycle_binary() { | |
| local catalog_tool="$1" | |
| local version_cycle="$2" | |
| local binary_name pattern version_manager_dir version_manager_root candidate | |
| binary_name="$(catalog_get_property "$catalog_tool" "binary_name" 2>/dev/null)" | |
| [ -z "$binary_name" ] && binary_name="$catalog_tool" | |
| pattern="$(catalog_get_property "$catalog_tool" "multi_version.binary_pattern" 2>/dev/null)" | |
| if [ -n "$pattern" ]; then | |
| printf '%s\n' "${pattern//\{cycle\}/$version_cycle}" | |
| return 0 | |
| fi | |
| version_manager_dir="$(catalog_get_property "$catalog_tool" "multi_version.version_manager_dir" 2>/dev/null)" | |
| if [ -n "$version_manager_dir" ]; then | |
| if [[ "$version_manager_dir" == /* ]]; then | |
| version_manager_root="$version_manager_dir" | |
| else | |
| version_manager_root="$HOME/$version_manager_dir" | |
| fi | |
| if [ -d "$version_manager_root" ]; then | |
| shopt -s nullglob | |
| for candidate in \ | |
| "$version_manager_root/$version_cycle/bin/$binary_name" \ | |
| "$version_manager_root/$version_cycle"*/bin/"$binary_name" | |
| do | |
| if [ -x "$candidate" ]; then | |
| printf '%s\n' "$candidate" | |
| shopt -u nullglob | |
| return 0 | |
| fi | |
| done | |
| shopt -u nullglob | |
| fi | |
| fi | |
| printf '%s\n' "${catalog_tool}${version_cycle}" | |
| } | |
| # 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 bin_path ver | |
| if [ -n "$version_cycle" ]; then | |
| binary="$(resolve_cycle_binary "$catalog_tool" "$version_cycle")" |
Copilot
AI
Apr 16, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Removing the is_multi_version guard means multi-version cycles will now auto-update whenever config_get_auto_update returns true. Since config_get_auto_update inherits from global preferences.auto_upgrade, this can enable unattended upgrades for all cycles even when the user never opted in per-cycle. If the intent is “per-cycle opt-in”, consider treating cycle-qualified keys as auto-update only when an explicit per-tool auto_update setting exists (not the global default).
| # 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 | |
| # Do not auto-update multi-version tools here: auto_update may inherit from | |
| # a global default, but cycle-qualified installs require an explicit per-cycle opt-in. | |
| if [ "$auto_update" = "true" ] && [ -z "${is_multi_version:-}" ]; then |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The binary probe fallback logic is duplicated exactly in the [Aa] branch (lines 585-591). While small, this repetition makes the script harder to maintain. In accordance with the rule to extract duplicated blocks of code into reusable functions in SHELL scripts to improve maintainability, consider moving this logic into a local variable or a small helper function since it is used in multiple places within process_tool.
References
- Extract duplicated blocks of code into reusable functions to improve maintainability and reduce redundancy in SHELL scripts.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This new merge-mode refresh for multi-version
tool@cyclesnapshot entries is user-visible behavior but currently appears untested. Consider adding an integration test that runsaudit.py update-local <tool@cycle>with a fixture snapshot containing stale cycle entries and asserts they get updated.