mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
497a4e0a43
brew upgrade / uv tool upgrade delete the running install's files while the old process is still alive. rich defers importing rich._emoji_codes until print time and typer defers typer.rich_utils until excepthook time, so printing the post-upgrade status message crashed the exiting process with ModuleNotFoundError (and the traceback renderer crashed too). Import both before launching the upgrade subprocess, while the files still exist. Signed-off-by: Drew Cain <groksrc@gmail.com>
405 lines
14 KiB
Python
405 lines
14 KiB
Python
"""Automatic update checks and upgrades for the Basic Memory CLI."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta
|
|
from enum import Enum
|
|
|
|
from loguru import logger
|
|
from packaging.version import InvalidVersion, Version
|
|
from rich.console import Console
|
|
|
|
import basic_memory
|
|
from basic_memory.config import ConfigManager
|
|
|
|
PACKAGE_NAME = "basic-memory"
|
|
PYPI_JSON_URL = "https://pypi.org/pypi/basic-memory/json"
|
|
|
|
PYPI_TIMEOUT_SECONDS = 5
|
|
BREW_OUTDATED_TIMEOUT_SECONDS = 60
|
|
UV_UPGRADE_TIMEOUT_SECONDS = 180
|
|
BREW_UPGRADE_TIMEOUT_SECONDS = 600
|
|
|
|
|
|
class InstallSource(str, Enum):
|
|
"""How the running CLI appears to have been installed."""
|
|
|
|
HOMEBREW = "homebrew"
|
|
UV_TOOL = "uv_tool"
|
|
UVX = "uvx"
|
|
UNKNOWN = "unknown"
|
|
|
|
|
|
class AutoUpdateStatus(str, Enum):
|
|
"""Result classification for update checks and installs."""
|
|
|
|
SKIPPED = "skipped"
|
|
UP_TO_DATE = "up_to_date"
|
|
UPDATE_AVAILABLE = "update_available"
|
|
UPDATED = "updated"
|
|
FAILED = "failed"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AutoUpdateResult:
|
|
"""Structured result for update checks/install attempts."""
|
|
|
|
status: AutoUpdateStatus
|
|
source: InstallSource
|
|
checked: bool
|
|
update_available: bool
|
|
updated: bool
|
|
latest_version: str | None = None
|
|
message: str | None = None
|
|
error: str | None = None
|
|
restart_recommended: bool = False
|
|
|
|
|
|
def detect_install_source(executable: str | None = None) -> InstallSource:
|
|
"""Infer installation source from the active interpreter path."""
|
|
active_executable = executable or sys.executable
|
|
normalized = active_executable.lower().replace("\\", "/")
|
|
|
|
if "cellar/basic-memory" in normalized:
|
|
return InstallSource.HOMEBREW
|
|
if "uv/tools/basic-memory" in normalized:
|
|
return InstallSource.UV_TOOL
|
|
if "/uv/archive-" in normalized:
|
|
return InstallSource.UVX
|
|
return InstallSource.UNKNOWN
|
|
|
|
|
|
def _is_interactive_session() -> bool:
|
|
"""Return whether stdin/stdout are interactive terminals."""
|
|
try:
|
|
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
except ValueError:
|
|
# Trigger: stdin/stdout may be closed during transport teardown.
|
|
# Why: isatty() raises ValueError on closed descriptors.
|
|
# Outcome: treat as non-interactive and suppress periodic output.
|
|
return False
|
|
|
|
|
|
def _run_subprocess(
|
|
command: list[str],
|
|
*,
|
|
timeout_seconds: int,
|
|
silent: bool,
|
|
capture_output: bool,
|
|
) -> subprocess.CompletedProcess[str]:
|
|
"""Run a subprocess with explicit stdio behavior for protocol safety."""
|
|
# Trigger: silent operation (MCP/background) with no need for subprocess output.
|
|
# Why: prevent protocol/terminal pollution from child process output.
|
|
# Outcome: stdout/stderr are discarded unless explicit capture is requested.
|
|
use_devnull = silent and not capture_output
|
|
stdout_target = subprocess.DEVNULL if use_devnull else subprocess.PIPE
|
|
stderr_target = subprocess.DEVNULL if use_devnull else subprocess.PIPE
|
|
|
|
return subprocess.run(
|
|
command,
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=stdout_target,
|
|
stderr=stderr_target,
|
|
text=True,
|
|
timeout=timeout_seconds,
|
|
check=False,
|
|
)
|
|
|
|
|
|
def _version_from_pypi() -> str:
|
|
"""Fetch the latest published package version from PyPI."""
|
|
request = urllib.request.Request(
|
|
PYPI_JSON_URL,
|
|
headers={"User-Agent": f"basic-memory-cli/{basic_memory.__version__}"},
|
|
)
|
|
with urllib.request.urlopen(request, timeout=PYPI_TIMEOUT_SECONDS) as response:
|
|
payload = json.loads(response.read().decode("utf-8"))
|
|
latest = payload.get("info", {}).get("version")
|
|
if not latest:
|
|
raise RuntimeError("PyPI JSON response did not include info.version")
|
|
return str(latest)
|
|
|
|
|
|
def _check_homebrew_update_available(silent: bool) -> tuple[bool, str | None]:
|
|
"""Check whether Homebrew reports an outdated basic-memory formula."""
|
|
result = _run_subprocess(
|
|
["brew", "outdated", "--quiet", PACKAGE_NAME],
|
|
timeout_seconds=BREW_OUTDATED_TIMEOUT_SECONDS,
|
|
silent=silent,
|
|
capture_output=True,
|
|
)
|
|
# Trigger: brew outdated exits 1 when the formula IS outdated (with name on stdout).
|
|
# Why: non-zero exit here means "outdated", not "error".
|
|
# Outcome: check stdout for the package name to determine outdated status.
|
|
stdout = (result.stdout or "").strip()
|
|
is_outdated = PACKAGE_NAME in stdout
|
|
return is_outdated, None
|
|
|
|
|
|
def _check_pypi_update_available() -> tuple[bool, str]:
|
|
"""Compare installed package version with PyPI latest version."""
|
|
latest = _version_from_pypi()
|
|
try:
|
|
current_version = Version(basic_memory.__version__)
|
|
latest_version = Version(latest)
|
|
except InvalidVersion as exc:
|
|
raise RuntimeError(
|
|
f"Could not compare versions (current={basic_memory.__version__}, latest={latest})"
|
|
) from exc
|
|
|
|
return latest_version > current_version, latest
|
|
|
|
|
|
def _manual_update_hint(source: InstallSource) -> str:
|
|
"""Return manager-appropriate manual update instructions."""
|
|
if source == InstallSource.UV_TOOL:
|
|
return "Run `uv tool upgrade basic-memory`."
|
|
if source == InstallSource.HOMEBREW:
|
|
return "Run `brew upgrade basic-memory`."
|
|
return (
|
|
"Automatic install is not supported for this environment. "
|
|
"Update with your package manager (for pip: `python3 -m pip install -U basic-memory`)."
|
|
)
|
|
|
|
|
|
def _preload_lazy_console_modules() -> None:
|
|
"""Import modules the post-upgrade output path defers until print time.
|
|
|
|
Trigger: an in-place upgrade is about to replace this installation on disk.
|
|
Why: rich and typer defer some imports until print/excepthook time; once
|
|
`brew upgrade` / `uv tool upgrade` removes the running version's files,
|
|
those imports raise ModuleNotFoundError and the final status message
|
|
crashes the exiting process.
|
|
Outcome: import the deferred modules now, while the files still exist.
|
|
"""
|
|
import rich._emoji_codes # noqa: F401
|
|
import typer.rich_utils # noqa: F401
|
|
|
|
|
|
def _save_last_checked_timestamp(config_manager: ConfigManager, checked_at: datetime) -> None:
|
|
"""Persist the timestamp for the most recent attempted update check."""
|
|
config = config_manager.load_config()
|
|
config.auto_update_last_checked_at = checked_at
|
|
config_manager.save_config(config)
|
|
|
|
|
|
def run_auto_update(
|
|
*,
|
|
force: bool = False,
|
|
check_only: bool = False,
|
|
silent: bool = False,
|
|
config_manager: ConfigManager | None = None,
|
|
now: datetime | None = None,
|
|
executable: str | None = None,
|
|
) -> AutoUpdateResult:
|
|
"""Run update check/install flow and return a structured result."""
|
|
manager = config_manager or ConfigManager()
|
|
config = manager.load_config()
|
|
source = detect_install_source(executable)
|
|
checked_at = now or datetime.now()
|
|
|
|
if source == InstallSource.UVX:
|
|
return AutoUpdateResult(
|
|
status=AutoUpdateStatus.SKIPPED,
|
|
source=source,
|
|
checked=False,
|
|
update_available=False,
|
|
updated=False,
|
|
message="uvx runtime detected; updates are managed by uvx cache resolution.",
|
|
)
|
|
|
|
if not force and not config.auto_update:
|
|
return AutoUpdateResult(
|
|
status=AutoUpdateStatus.SKIPPED,
|
|
source=source,
|
|
checked=False,
|
|
update_available=False,
|
|
updated=False,
|
|
message="Auto-update is disabled in config.",
|
|
)
|
|
|
|
if not force and config.auto_update_last_checked_at is not None:
|
|
try:
|
|
elapsed = checked_at - config.auto_update_last_checked_at
|
|
except TypeError:
|
|
# Trigger: mixed naive/aware datetimes from manual config edits.
|
|
# Why: datetime subtraction fails for mixed tz-awareness.
|
|
# Outcome: ignore the gate once and continue with a forced check path.
|
|
logger.warning("Auto-update interval gate skipped due to incompatible timestamp format")
|
|
else:
|
|
if elapsed < timedelta(seconds=config.update_check_interval):
|
|
return AutoUpdateResult(
|
|
status=AutoUpdateStatus.SKIPPED,
|
|
source=source,
|
|
checked=False,
|
|
update_available=False,
|
|
updated=False,
|
|
message="Update check interval has not elapsed.",
|
|
)
|
|
|
|
try:
|
|
# --- Availability check ---
|
|
latest_version: str | None = None
|
|
if source == InstallSource.HOMEBREW:
|
|
update_available, latest_version = _check_homebrew_update_available(silent=silent)
|
|
else:
|
|
update_available, latest_version = _check_pypi_update_available()
|
|
|
|
if not update_available:
|
|
return AutoUpdateResult(
|
|
status=AutoUpdateStatus.UP_TO_DATE,
|
|
source=source,
|
|
checked=True,
|
|
update_available=False,
|
|
updated=False,
|
|
latest_version=latest_version,
|
|
message=f"Basic Memory is up to date ({basic_memory.__version__}).",
|
|
)
|
|
|
|
if check_only:
|
|
return AutoUpdateResult(
|
|
status=AutoUpdateStatus.UPDATE_AVAILABLE,
|
|
source=source,
|
|
checked=True,
|
|
update_available=True,
|
|
updated=False,
|
|
latest_version=latest_version,
|
|
message=(
|
|
f"Update available (latest: {latest_version or 'unknown'}). "
|
|
f"{_manual_update_hint(source)}"
|
|
),
|
|
)
|
|
|
|
if source == InstallSource.UNKNOWN:
|
|
return AutoUpdateResult(
|
|
status=AutoUpdateStatus.UPDATE_AVAILABLE,
|
|
source=source,
|
|
checked=True,
|
|
update_available=True,
|
|
updated=False,
|
|
latest_version=latest_version,
|
|
message=(
|
|
f"Update available (latest: {latest_version or 'unknown'}). "
|
|
f"{_manual_update_hint(source)}"
|
|
),
|
|
)
|
|
|
|
# --- Automatic install ---
|
|
command = (
|
|
["uv", "tool", "upgrade", PACKAGE_NAME]
|
|
if source == InstallSource.UV_TOOL
|
|
else ["brew", "upgrade", PACKAGE_NAME]
|
|
)
|
|
timeout = (
|
|
UV_UPGRADE_TIMEOUT_SECONDS
|
|
if source == InstallSource.UV_TOOL
|
|
else BREW_UPGRADE_TIMEOUT_SECONDS
|
|
)
|
|
|
|
_preload_lazy_console_modules()
|
|
install_result = _run_subprocess(
|
|
command,
|
|
timeout_seconds=timeout,
|
|
silent=silent,
|
|
capture_output=not silent,
|
|
)
|
|
if install_result.returncode != 0:
|
|
stderr = (install_result.stderr or "").strip() if install_result.stderr else ""
|
|
stdout = (install_result.stdout or "").strip() if install_result.stdout else ""
|
|
detail = stderr or stdout or "update command failed"
|
|
return AutoUpdateResult(
|
|
status=AutoUpdateStatus.FAILED,
|
|
source=source,
|
|
checked=True,
|
|
update_available=True,
|
|
updated=False,
|
|
latest_version=latest_version,
|
|
message="Automatic update failed.",
|
|
error=detail,
|
|
)
|
|
|
|
return AutoUpdateResult(
|
|
status=AutoUpdateStatus.UPDATED,
|
|
source=source,
|
|
checked=True,
|
|
update_available=True,
|
|
updated=True,
|
|
latest_version=latest_version,
|
|
message=(
|
|
"Basic Memory was updated successfully. "
|
|
"Restart running sessions to use the new version."
|
|
),
|
|
restart_recommended=True,
|
|
)
|
|
|
|
except (
|
|
RuntimeError,
|
|
urllib.error.URLError,
|
|
ValueError,
|
|
TimeoutError,
|
|
subprocess.SubprocessError,
|
|
OSError,
|
|
) as exc:
|
|
logger.warning(f"Auto-update check failed: {exc}")
|
|
return AutoUpdateResult(
|
|
status=AutoUpdateStatus.FAILED,
|
|
source=source,
|
|
checked=True,
|
|
update_available=False,
|
|
updated=False,
|
|
message="Automatic update check failed.",
|
|
error=str(exc),
|
|
)
|
|
finally:
|
|
# Trigger: we attempted a check path (including failures).
|
|
# Why: repeated failing checks on every command create noise and unnecessary network load.
|
|
# Outcome: next periodic check is gated by update_check_interval.
|
|
try:
|
|
_save_last_checked_timestamp(manager, checked_at)
|
|
except Exception as exc: # pragma: no cover
|
|
logger.warning(f"Failed to persist auto-update timestamp: {exc}")
|
|
|
|
|
|
def maybe_run_periodic_auto_update(
|
|
invoked_subcommand: str | None,
|
|
*,
|
|
config_manager: ConfigManager | None = None,
|
|
is_interactive: bool | None = None,
|
|
console: Console | None = None,
|
|
) -> AutoUpdateResult | None:
|
|
"""Run a periodic auto-update check for interactive CLI sessions."""
|
|
interactive = _is_interactive_session() if is_interactive is None else is_interactive
|
|
if not interactive:
|
|
return None
|
|
if invoked_subcommand in {None, "mcp", "update"}:
|
|
return None
|
|
|
|
result = run_auto_update(
|
|
force=False,
|
|
check_only=False,
|
|
silent=False,
|
|
config_manager=config_manager,
|
|
)
|
|
|
|
if result.status in {
|
|
AutoUpdateStatus.UPDATE_AVAILABLE,
|
|
AutoUpdateStatus.UPDATED,
|
|
AutoUpdateStatus.FAILED,
|
|
}:
|
|
out = console or Console()
|
|
if result.status == AutoUpdateStatus.UPDATED:
|
|
out.print(f"[green]{result.message}[/green]")
|
|
elif result.status == AutoUpdateStatus.FAILED:
|
|
error_detail = f" {result.error}" if result.error else ""
|
|
out.print(f"[yellow]{result.message}{error_detail}[/yellow]")
|
|
elif result.message:
|
|
out.print(f"[cyan]{result.message}[/cyan]")
|
|
|
|
return result
|