mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 17:27:26 +02:00
397 lines
13 KiB
Python
397 lines
13 KiB
Python
"""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():
|
|
with requests.get(
|
|
f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest",
|
|
timeout=REQUEST_TIMEOUT_SECONDS,
|
|
) as response:
|
|
response.raise_for_status()
|
|
tag = response.json().get("tag_name", "")
|
|
return tag.lstrip("v") or None
|
|
with requests.get(
|
|
f"https://pypi.org/pypi/{PYPI_PACKAGE}/json",
|
|
timeout=REQUEST_TIMEOUT_SECONDS,
|
|
) as response:
|
|
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:
|
|
with requests.get(
|
|
f"https://api.github.com/repos/{GITHUB_REPO}/releases/tags/v{version}",
|
|
timeout=REQUEST_TIMEOUT_SECONDS,
|
|
) as response:
|
|
response.raise_for_status()
|
|
assets = response.json().get("assets", [])
|
|
for asset in 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
|