Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +862 to +866
Copy link

Copilot AI Apr 16, 2026

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@cycle snapshot entries is user-visible behavior but currently appears untested. Consider adding an integration test that runs audit.py update-local <tool@cycle> with a fixture snapshot containing stale cycle entries and asserts they get updated.

Copilot uses AI. Check for mistakes.
try:
from cli_audit.catalog import ToolCatalog
_catalog = ToolCatalog()
Comment on lines +868 to +869
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The ToolCatalog is imported and instantiated locally within multiple functions (cmd_audit, cmd_update, cmd_update_local, _detect_local_only, cmd_versions). Since this class reads and parses all JSON files in the catalog/ directory, it is relatively expensive to initialize repeatedly. It should be imported at the top level and ideally instantiated once or lazily cached.

except Exception:
_catalog = None
Comment on lines +867 to +871
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This block swallows any exception while constructing ToolCatalog and silently skips refreshing multi-version cycle entries. If this fails (e.g., catalog parse error), users may still see stale python@X.Y results with no indication why. Consider logging the exception (at least under CLI_AUDIT_DEBUG) so failures here are diagnosable.

Copilot uses AI. Check for mistakes.
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The cmd_update_local function (and similarly cmd_audit at line 332) does not handle the @cycle suffix when looking up tools in the catalog. If a user or script (like guide.sh at line 337) passes a specific versioned tool name (e.g., python@3.14), _catalog.has_tool(tool.name) will return False because the catalog entry is named python. This causes the multi-version detection logic to be skipped for that tool, leaving the snapshot stale and potentially causing the "Upgrade did not succeed" warning to persist.

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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The logic for constructing the snapshot entry for multi-version tools is duplicated here from audit_multi_version_tool (lines 212-232). This duplication is prone to drift; for example, the upstream_method, tool_url, and latest_url fields are missing in this block. If a new cycle is detected that wasn't in the previous snapshot, these fields will be absent. Consider refactoring this into a shared helper function.

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)
Expand Down
152 changes: 106 additions & 46 deletions cli_audit/collectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If CLI_AUDIT_ENDOFLIFE_CACHE is set to a relative filename without a directory component, os.path.dirname(...) becomes '' and os.makedirs('', ...) will raise. Consider guarding for empty dirname (or using Path(...).parent with a check) so cache writes work for relative paths too.

Suggested change
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 uses AI. Check for mistakes.
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."""
Expand Down Expand Up @@ -597,69 +635,91 @@ 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"
data = json.loads(http_get(url, timeout=5))

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):
Comment on lines +705 to +709
Copy link

Copilot AI Apr 16, 2026

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.

Copilot uses AI. Check for mistakes.
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 []


Expand Down
73 changes: 67 additions & 6 deletions scripts/guide.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probe_installed_version falls back to ${catalog_tool}{cycle} when multi_version.binary_pattern is missing. That won’t work for multi-version tools that use multi_version.version_manager_dir (e.g. node/ruby), where there is no node22/ruby3.4 binary on PATH. Consider probing via the snapshot’s installed_path_selected for the specific cycle, or resolving the cycle’s binary path from version_manager_dir (similar to detect_multi_versions).

Suggested change
# 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 uses AI. Check for mistakes.
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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment on lines +343 to +345
Copy link

Copilot AI Apr 16, 2026

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).

Suggested change
# 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

Copilot uses AI. Check for mistakes.
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 <unknown>
Expand Down Expand Up @@ -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
Comment on lines +515 to +521
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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
  1. Extract duplicated blocks of code into reusable functions to improve maintainability and reduce redundancy in SHELL scripts.

# 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=""
Expand Down Expand Up @@ -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
Expand All @@ -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=""
Expand Down
Loading
Loading