"""Update notifications and self-update for the strix CLI. Follows the pattern used by tools like gh, uv, and pip: a background, rate-limited (once per 24h) check against the release source, a cached result in ``~/.strix``, a non-intrusive notice with the upgrade command for the detected install method, and a ``strix --update`` self-update path for the standalone binary install. """ from __future__ import annotations import hashlib import json import logging import os import platform import shutil import stat import subprocess import sys import tarfile import tempfile import threading import time import zipfile from pathlib import Path from typing import cast import requests from rich.console import Console from rich.prompt import Prompt from strix.telemetry._common import get_version logger = logging.getLogger(__name__) GITHUB_REPO = "usestrix/strix" PYPI_PACKAGE = "strix-agent" CHECK_INTERVAL_SECONDS = 24 * 60 * 60 REQUEST_TIMEOUT_SECONDS = 5 _CACHE_PATH = Path.home() / ".strix" / "update-check.json" _background_thread: threading.Thread | None = None def _is_disabled() -> bool: return bool(os.environ.get("STRIX_NO_UPDATE_CHECK")) or any( os.environ.get(key) for key in ("CI", "GITHUB_ACTIONS", "GITLAB_CI", "JENKINS_URL", "BUILDKITE", "CIRCLECI") ) def is_binary_install() -> bool: return bool(getattr(sys, "frozen", False)) def get_install_method() -> str: if is_binary_install(): return "binary" prefix = str(Path(sys.prefix)).replace("\\", "/") if "/pipx/" in prefix or prefix.endswith("/pipx"): return "pipx" if "/uv/tools/" in prefix: return "uv" return "pip" def get_upgrade_command(method: str | None = None) -> str: method = method or get_install_method() commands = { "binary": "strix --update", "pipx": "pipx upgrade strix-agent", "uv": "uv tool upgrade strix-agent", "pip": "pip install --upgrade strix-agent", } return commands[method] def _parse_version(value: str) -> tuple[int, ...] | None: parts = value.strip().lstrip("v").split(".") try: return tuple(int(part) for part in parts) except ValueError: return None def _is_newer(latest: str, current: str) -> bool: latest_parts = _parse_version(latest) current_parts = _parse_version(current) if latest_parts is None or current_parts is None: return False return latest_parts > current_parts def _fetch_latest_version() -> str | None: try: if is_binary_install(): response = requests.get( f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest", timeout=REQUEST_TIMEOUT_SECONDS, ) response.raise_for_status() tag = response.json().get("tag_name", "") return tag.lstrip("v") or None response = requests.get( f"https://pypi.org/pypi/{PYPI_PACKAGE}/json", timeout=REQUEST_TIMEOUT_SECONDS, ) response.raise_for_status() version = response.json().get("info", {}).get("version") return str(version) if version else None except Exception: # noqa: BLE001 logger.debug("update check failed", exc_info=True) return None def _fetch_asset_digest(version: str, filename: str) -> str | None: """Return the expected sha256 (hex) for a release asset, if the API provides one.""" try: response = requests.get( f"https://api.github.com/repos/{GITHUB_REPO}/releases/tags/v{version}", timeout=REQUEST_TIMEOUT_SECONDS, ) response.raise_for_status() for asset in response.json().get("assets", []): if asset.get("name") == filename: digest = asset.get("digest") or "" if digest.startswith("sha256:"): return digest.removeprefix("sha256:") except Exception: # noqa: BLE001 logger.debug("release asset digest lookup failed", exc_info=True) return None def _sha256_file(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as f: for chunk in iter(lambda: f.read(1 << 20), b""): digest.update(chunk) return digest.hexdigest() def _read_cache() -> dict[str, object]: try: with _CACHE_PATH.open(encoding="utf-8") as f: data = json.load(f) if isinstance(data, dict): return cast("dict[str, object]", data) except Exception: # noqa: BLE001, S110 pass # nosec B110 return {} def _write_cache(**fields: object) -> None: try: cache = _read_cache() cache.update(fields) _CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) _CACHE_PATH.write_text(json.dumps(cache), encoding="utf-8") except Exception: # noqa: BLE001, S110 pass # nosec B110 def skip_version(version: str) -> None: """Remember not to prompt again for this version (newer releases still notify).""" _write_cache(skipped_version=version) def _refresh_cache() -> None: latest = _fetch_latest_version() if latest: _write_cache(latest_version=latest, checked_at=time.time()) def start_background_check() -> None: """Refresh the cached latest-version info in a daemon thread (at most once per 24h).""" global _background_thread # noqa: PLW0603 if _is_disabled(): return cache = _read_cache() checked_at = cache.get("checked_at") if isinstance(checked_at, int | float) and time.time() - checked_at < CHECK_INTERVAL_SECONDS: return _background_thread = threading.Thread(target=_refresh_cache, daemon=True) _background_thread.start() def get_available_update(*, respect_skip: bool = True) -> str | None: """Return the newer version from the cache, or None if up to date / unknown.""" if _is_disabled(): return None if _background_thread is not None: _background_thread.join(timeout=0.2) cache = _read_cache() latest = cache.get("latest_version") current = get_version() if not isinstance(latest, str) or current == "unknown" or not _is_newer(latest, current): return None if respect_skip and cache.get("skipped_version") == latest: return None return latest def notify_update(console: Console) -> None: latest = get_available_update() if not latest: return console.print( f"[#eab308]A new version of strix is available:[/] " f"[dim]{get_version()}[/] [dim]→[/] [bold #22c55e]{latest}[/]" f" [dim]·[/] [#60a5fa]{get_upgrade_command()}[/]" ) console.print() def run_package_upgrade(console: Console, method: str) -> bool: """Upgrade a package-manager install by running its upgrade command.""" command = get_upgrade_command(method).split() console.print(f"[dim]Running[/] [#60a5fa]{' '.join(command)}[/]") try: result = subprocess.run(command, check=False) # noqa: S603 except OSError as e: console.print(f"[bold red]Update failed:[/] {e}") return False if result.returncode != 0: console.print( f"[bold red]Update failed[/] [dim](exit code {result.returncode}).[/] " f"Run it manually: [#60a5fa]{get_upgrade_command(method)}[/]" ) return False console.print("[#22c55e]✓ strix updated — restart the scan to use the new version[/]") return True def prompt_update_if_available(console: Console) -> bool: """Offer an interactive update before a scan starts. Returns True if strix was updated (caller should re-exec / exit). """ latest = get_available_update() if not latest or not sys.stdin.isatty() or not sys.stdout.isatty(): return False console.print() console.print( f"[#eab308]A new version of strix is available:[/] " f"[dim]{get_version()}[/] [dim]→[/] [bold #22c55e]{latest}[/]" ) console.print( "[dim] y — update now n — not now (ask again next run) s — skip this version[/]" ) choice = Prompt.ask("Update strix?", choices=["y", "n", "s"], default="n") console.print() if choice == "s": skip_version(latest) return False if choice != "y": return False method = get_install_method() if method == "binary": return self_update(console, version=latest) return run_package_upgrade(console, method) def _release_target() -> str | None: raw_os = platform.system().lower() os_name = {"darwin": "macos", "linux": "linux", "windows": "windows"}.get(raw_os) arch = platform.machine().lower() arch = {"aarch64": "arm64", "amd64": "x86_64"}.get(arch, arch) if os_name is None: return None target = f"{os_name}-{arch}" supported = { "linux-x86_64", "linux-arm64", "macos-x86_64", "macos-arm64", "windows-x86_64", } return target if target in supported else None def _download_and_replace(version: str, target: str, console: Console) -> bool: is_windows = target.startswith("windows") archive_ext = ".zip" if is_windows else ".tar.gz" filename = f"strix-{version}-{target}{archive_ext}" url = f"https://github.com/{GITHUB_REPO}/releases/download/v{version}/{filename}" binary_name = f"strix-{version}-{target}" + (".exe" if is_windows else "") current_exe = Path(sys.executable).resolve() with tempfile.TemporaryDirectory() as tmp: tmp_dir = Path(tmp) archive_path = tmp_dir / filename console.print(f"[dim]Downloading[/] {url}") with requests.get( # nosec B113 url, stream=True, timeout=REQUEST_TIMEOUT_SECONDS * 12, ) as response: response.raise_for_status() with archive_path.open("wb") as f: for chunk in response.iter_content(chunk_size=1 << 20): f.write(chunk) expected_digest = _fetch_asset_digest(version, filename) if expected_digest: actual_digest = _sha256_file(archive_path) if actual_digest != expected_digest: raise RuntimeError( f"checksum mismatch for {filename}: " f"expected sha256 {expected_digest}, got {actual_digest}" ) else: console.print("[dim yellow]No published checksum available; skipping verification[/]") if is_windows: with zipfile.ZipFile(archive_path) as zf: zf.extract(binary_name, tmp_dir) else: with tarfile.open(archive_path, "r:gz") as tf: tf.extract(binary_name, tmp_dir, filter="data") new_binary = tmp_dir / binary_name new_binary.chmod(new_binary.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) staged = current_exe.with_name(current_exe.name + ".new") try: shutil.copy2(new_binary, staged) if is_windows: # Windows can't replace a running executable in place; move it aside first. old = current_exe.with_name(current_exe.name + ".old") old.unlink(missing_ok=True) current_exe.rename(old) try: staged.replace(current_exe) except Exception: old.rename(current_exe) raise else: staged.replace(current_exe) except Exception: staged.unlink(missing_ok=True) raise return True def self_update(console: Console | None = None, version: str | None = None) -> bool: """Replace the running standalone binary with the latest release. Returns True on success. For package-manager installs this only prints the right upgrade command and returns False. """ console = console or Console() if not is_binary_install(): method = get_install_method() console.print( f"[#eab308]This strix was installed via {method};[/] " f"upgrade it with: [#60a5fa]{get_upgrade_command(method)}[/]" ) return False latest = version or _fetch_latest_version() if not latest: console.print("[bold red]Could not determine the latest strix version.[/]") return False current = get_version() if current != "unknown" and not _is_newer(latest, current): console.print(f"[#22c55e]strix {current} is already the latest version.[/]") return True target = _release_target() if not target: console.print( f"[bold red]No prebuilt binary for this platform " f"({platform.system()}/{platform.machine()}).[/]" ) return False try: _download_and_replace(latest, target, console) except Exception as e: # noqa: BLE001 logger.debug("self-update failed", exc_info=True) console.print(f"[bold red]Update failed:[/] {e}") console.print( "[dim]You can reinstall manually with:[/] " "[#60a5fa]curl -sSL https://strix.ai/install | bash[/]" ) return False _write_cache(latest_version=latest, checked_at=time.time()) console.print(f"[#22c55e]✓ Updated strix to {latest}[/]") return True