"""Client-side version awareness. Two jobs: * ``fetch_report`` — the explicit ``helix version`` check: query the server's published manifest and classify the local build as up-to-date / behind / below-the-supported-floor. * ``warn_if_outdated`` — a passive nudge printed to stderr on normal commands so a user running a stale (possibly buggy) build is told to upgrade *before* the server-side 426 kill-switch cuts them off. Both are strictly best-effort: a network blip, an old server without the manifest endpoint, or a parse error must NEVER block or fail the user's actual command. The hard enforcement lives server-side (app/mcp/middleware.py); this is the friendly early warning. A 24h on-disk cache (next to the config file, mode 0600) keeps the passive path off the network on all but ~one command per day. """ from __future__ import annotations import json import re import time from pathlib import Path from typing import Any from helix_cli import __version__, http_client, output from helix_cli import config as cli_config _CACHE_TTL_SECONDS = 24 * 3600 _MANIFEST_PATH = "/api/v1/cli/manifest" _UPGRADE_HINT = "Help → AI 助手集成 → helix CLI" def _release(v: str | None) -> tuple[int, int, int] | None: m = re.match(r"\s*(\d+)\.(\d+)\.(\d+)", v or "") return (int(m.group(1)), int(m.group(2)), int(m.group(3))) if m else None def _cache_file() -> Path: return cli_config.config_path().parent / "version_cache.json" def _read_cache() -> dict[str, Any] | None: try: d = json.loads(_cache_file().read_text(encoding="utf-8")) if time.time() - float(d.get("checked_at", 0)) <= _CACHE_TTL_SECONDS: return d except Exception: return None return None def _write_cache(latest: str | None, min_supported: str | None) -> None: try: f = _cache_file() f.parent.mkdir(parents=True, exist_ok=True) f.write_text( json.dumps( { "latest": latest, "min_supported": min_supported, "checked_at": time.time(), } ), encoding="utf-8", ) try: f.chmod(0o600) except OSError: pass except Exception: pass def _fetch(cfg: cli_config.CliConfig, timeout: float) -> tuple[str | None, str | None]: resp = http_client.get(cfg, _MANIFEST_PATH, token=cfg.token, timeout=timeout) data = http_client.envelope_data(resp) or {} return data.get("version"), data.get("min_supported") def classify(local: str, latest: str | None, min_supported: str | None) -> dict[str, Any]: lr, ltr, mr = _release(local), _release(latest), _release(min_supported) status = "unknown" if mr and lr and lr < mr: status = "below_min" elif ltr and lr and lr < ltr: status = "behind" elif ltr and lr and lr >= ltr: status = "up_to_date" return { "local": local, "latest": latest, "min_supported": min_supported, "status": status, } def fetch_report(cfg: cli_config.CliConfig, *, timeout: float) -> dict[str, Any]: """Always-network classification for ``helix version``.""" try: latest, min_supported = _fetch(cfg, timeout) _write_cache(latest, min_supported) except Exception: return classify(__version__, None, None) return classify(__version__, latest, min_supported) def warn_if_outdated(cfg: cli_config.CliConfig, *, timeout: float) -> None: """Print a one-line stderr nudge when the local build is behind or below the supported floor. Never raises; cheap (cached) on the hot path. Skips the network when no token is configured (offline / probe commands).""" try: cached = _read_cache() if cached is None and cfg.token: try: latest, min_supported = _fetch(cfg, min(timeout, 4.0)) _write_cache(latest, min_supported) cached = {"latest": latest, "min_supported": min_supported} except Exception: return if not cached: return rep = classify(__version__, cached.get("latest"), cached.get("min_supported")) if rep["status"] == "below_min": output.write_err( f"helix CLI {rep['local']} is BELOW the minimum supported " f"version {rep['min_supported']} — old builds may have known " f"bugs/security issues. Update now: {_UPGRADE_HINT}." ) elif rep["status"] == "behind": output.write_err( f"a newer helix CLI is available ({rep['latest']}; you have " f"{rep['local']}). Update: {_UPGRADE_HINT}." ) except Exception: return