diff --git a/README.md b/README.md index 92e8f5d8..d443df6f 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,36 @@ uv tool install basic-memory You can view shared context via files in `~/basic-memory` (default directory location). +## Automatic Updates + +Basic Memory includes a default-on auto-update flow for CLI installs. + +- **Auto-install supported:** `uv tool` and Homebrew installs +- **Default check interval:** every 24 hours (`86400` seconds) +- **MCP-safe behavior:** update checks run silently in `basic-memory mcp` mode +- **`uvx` behavior:** skipped (runtime is ephemeral and managed by `uvx`) + +Manual update commands: + +```bash +# Check now and install if supported +bm update + +# Check only, do not install +bm update --check +``` + +Config options in `~/.basic-memory/config.json`: + +```json +{ + "auto_update": true, + "update_check_interval": 86400 +} +``` + +To disable automatic updates, set `"auto_update": false`. + ## Why Basic Memory? Most LLM interactions are ephemeral - you ask a question, get an answer, and everything is forgotten. Each conversation diff --git a/llms-install.md b/llms-install.md index 029c3236..31e4b1e8 100644 --- a/llms-install.md +++ b/llms-install.md @@ -54,6 +54,22 @@ Or for a one-time sync: basic-memory sync ``` +### 4. Updating Basic Memory + +Basic Memory supports automatic updates by default for `uv tool` and Homebrew installs. + +For manual checks and upgrades: + +```bash +# Check now and install if supported +bm update + +# Check only, do not install +bm update --check +``` + +To disable automatic updates, set `"auto_update": false` in `~/.basic-memory/config.json`. + ## Configuration Options ### Custom Directory @@ -125,4 +141,4 @@ If you encounter issues: cat ~/.basic-memory/basic-memory.log ``` -For more detailed information, refer to the [full documentation](https://memory.basicmachines.co/). \ No newline at end of file +For more detailed information, refer to the [full documentation](https://docs.basicmemory.com/). diff --git a/src/basic_memory/cli/app.py b/src/basic_memory/cli/app.py index 781d352a..0e531b83 100644 --- a/src/basic_memory/cli/app.py +++ b/src/basic_memory/cli/app.py @@ -8,6 +8,7 @@ import typer # noqa: E402 +from basic_memory.cli.auto_update import maybe_run_periodic_auto_update # noqa: E402 from basic_memory.cli.container import CliContainer, set_container # noqa: E402 from basic_memory.cli.promo import maybe_show_cloud_promo, maybe_show_init_line # noqa: E402 from basic_memory.config import init_cli_logging # noqa: E402 @@ -52,10 +53,14 @@ def app_callback( # Outcome: one-time plain line printed before the subcommand runs. maybe_show_init_line(ctx.invoked_subcommand) - # Trigger: register promo as a post-command callback. - # Why: promo output should appear after the command's own output, not before. - # Outcome: promo panel renders below the command results (status tree, table, etc.). - ctx.call_on_close(lambda: maybe_show_cloud_promo(ctx.invoked_subcommand)) + # Trigger: register post-command messaging callbacks. + # Why: informational/promo/update output belongs below command results. + # Outcome: command output remains primary, with optional follow-up notices afterwards. + def _post_command_messages() -> None: + maybe_show_cloud_promo(ctx.invoked_subcommand) + maybe_run_periodic_auto_update(ctx.invoked_subcommand) + + ctx.call_on_close(_post_command_messages) # Run initialization for commands that don't use the API # Skip for 'mcp' command - it has its own lifespan that handles initialization @@ -70,6 +75,7 @@ def app_callback( "tool", "reset", "reindex", + "update", "watch", } if ( diff --git a/src/basic_memory/cli/auto_update.py b/src/basic_memory/cli/auto_update.py new file mode 100644 index 00000000..586308e4 --- /dev/null +++ b/src/basic_memory/cli/auto_update.py @@ -0,0 +1,391 @@ +"""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 = 15 +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, + ) + if result.returncode != 0: + stderr = (result.stderr or "").strip() + stdout = (result.stdout or "").strip() + detail = stderr or stdout or "brew outdated failed" + raise RuntimeError(detail) + + is_outdated = bool((result.stdout or "").strip()) + 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 _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 + ) + + 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 diff --git a/src/basic_memory/cli/commands/__init__.py b/src/basic_memory/cli/commands/__init__.py index 285fce32..8a749e22 100644 --- a/src/basic_memory/cli/commands/__init__.py +++ b/src/basic_memory/cli/commands/__init__.py @@ -8,6 +8,7 @@ project, format, schema, + update, ) __all__ = [ @@ -23,4 +24,5 @@ "project", "format", "schema", + "update", ] diff --git a/src/basic_memory/cli/commands/mcp.py b/src/basic_memory/cli/commands/mcp.py index 49655ca8..9a073114 100644 --- a/src/basic_memory/cli/commands/mcp.py +++ b/src/basic_memory/cli/commands/mcp.py @@ -1,12 +1,14 @@ """MCP server command with streamable HTTP transport.""" import os +import threading from typing import Any, Optional import typer from loguru import logger from basic_memory.cli.app import app +from basic_memory.cli.auto_update import AutoUpdateStatus, run_auto_update from basic_memory.config import ConfigManager, init_mcp_logging @@ -80,6 +82,22 @@ def mcp( os.environ["BASIC_MEMORY_MCP_PROJECT"] = project_name logger.info(f"MCP server constrained to project: {project_name}") + def _run_background_auto_update() -> None: + result = run_auto_update(force=False, check_only=False, silent=True) + if result.restart_recommended: + logger.info( + "A newer Basic Memory version was installed and will apply on next restart." + ) + elif result.status == AutoUpdateStatus.FAILED and result.error: + logger.warning(f"MCP background auto-update failed: {result.error}") + + # Trigger: stdio transport corresponds to local user installs. + # Why: server transports (HTTP/SSE) run in managed environments where + # package-manager self-upgrades are inappropriate. + # Outcome: background auto-update runs only for local stdio MCP sessions. + if transport == "stdio": + threading.Thread(target=_run_background_auto_update, daemon=True).start() + # Run the MCP server (blocks) # Lifespan handles: initialization, migrations, file sync, cleanup logger.info(f"Starting MCP server with {transport.upper()} transport") diff --git a/src/basic_memory/cli/commands/project.py b/src/basic_memory/cli/commands/project.py index 1f6d95a9..a6d39bf2 100644 --- a/src/basic_memory/cli/commands/project.py +++ b/src/basic_memory/cli/commands/project.py @@ -227,9 +227,7 @@ async def _list_projects(ws: str | None = None): console.print(table) if cloud_error is not None: - console.print( - f"[yellow]Cloud project discovery failed: {cloud_error}[/yellow]" - ) + console.print(f"[yellow]Cloud project discovery failed: {cloud_error}[/yellow]") console.print( "[dim]Showing local projects only. " "Run 'bm cloud login' or 'bm cloud api-key save ' if this is a credentials issue.[/dim]" diff --git a/src/basic_memory/cli/commands/update.py b/src/basic_memory/cli/commands/update.py new file mode 100644 index 00000000..b0cff27c --- /dev/null +++ b/src/basic_memory/cli/commands/update.py @@ -0,0 +1,40 @@ +"""Manual update command for Basic Memory CLI.""" + +import typer +from rich.console import Console + +from basic_memory.cli.app import app +from basic_memory.cli.auto_update import AutoUpdateStatus, run_auto_update + +console = Console() + + +@app.command("update") +def update( + check: bool = typer.Option( + False, + "--check", + help="Check for updates only (do not install).", + ), +) -> None: + """Check for updates and install when supported.""" + result = run_auto_update(force=True, check_only=check, silent=False) + + if result.status == AutoUpdateStatus.FAILED: + detail = f" {result.error}" if result.error else "" + console.print(f"[red]{result.message or 'Update failed.'}{detail}[/red]") + raise typer.Exit(1) + + if result.status == AutoUpdateStatus.UPDATED: + console.print(f"[green]{result.message or 'Basic Memory updated successfully.'}[/green]") + return + + if result.status == AutoUpdateStatus.UP_TO_DATE: + console.print(f"[green]{result.message or 'Basic Memory is up to date.'}[/green]") + return + + if result.status == AutoUpdateStatus.UPDATE_AVAILABLE: + console.print(f"[cyan]{result.message or 'Update available.'}[/cyan]") + return + + console.print(f"[dim]{result.message or 'No update action was performed.'}[/dim]") diff --git a/src/basic_memory/cli/main.py b/src/basic_memory/cli/main.py index 55c32dae..9a427c7b 100644 --- a/src/basic_memory/cli/main.py +++ b/src/basic_memory/cli/main.py @@ -28,6 +28,7 @@ def _version_only_invocation(argv: list[str]) -> bool: schema, status, tool, + update, ) warnings.filterwarnings("ignore") # pragma: no cover diff --git a/src/basic_memory/config.py b/src/basic_memory/config.py index c38c993c..2b74c110 100644 --- a/src/basic_memory/config.py +++ b/src/basic_memory/config.py @@ -332,6 +332,22 @@ class BasicMemoryConfig(BaseSettings): description="Most recent cloud promo version shown in CLI.", ) + auto_update: bool = Field( + default=True, + description="Enable automatic CLI update checks and installs when supported.", + ) + + update_check_interval: int = Field( + default=86400, + description="Seconds between automatic update checks.", + gt=0, + ) + + auto_update_last_checked_at: Optional[datetime] = Field( + default=None, + description="Timestamp of the last attempted automatic update check.", + ) + cloud_api_key: Optional[str] = Field( default=None, description="API key for cloud access (bmc_ prefixed). Account-level, not per-project.", diff --git a/tests/api/v2/test_schema_router.py b/tests/api/v2/test_schema_router.py index cf96b098..d3cf1c39 100644 --- a/tests/api/v2/test_schema_router.py +++ b/tests/api/v2/test_schema_router.py @@ -668,7 +668,8 @@ async def test_validate_reads_schema_from_file_not_database( # Overwrite the file on disk with validation=strict file_path = Path(file_service.base_path) / schema_entity.file_path - file_path.write_text(dedent("""\ + file_path.write_text( + dedent("""\ --- title: Editable Schema permalink: schemas/editable-schema @@ -685,7 +686,8 @@ async def test_validate_reads_schema_from_file_not_database( ## Observations - [note] Schema that will be edited on disk - """)) + """) + ) # Create a note missing "role" — strict mode should produce errors, not warnings note_entity, _ = await entity_service.create_or_update_entity( @@ -749,7 +751,8 @@ async def test_validate_falls_back_to_db_on_incomplete_frontmatter( # Overwrite file with frontmatter missing the 'schema' key file_path = Path(file_service.base_path) / schema_entity.file_path - file_path.write_text(dedent("""\ + file_path.write_text( + dedent("""\ --- title: Incomplete Schema permalink: schemas/incomplete-schema @@ -761,7 +764,8 @@ async def test_validate_falls_back_to_db_on_incomplete_frontmatter( ## Observations - [note] Mid-edit state - """)) + """) + ) # Create a note to validate against this schema note_entity, _ = await entity_service.create_or_update_entity( diff --git a/tests/cli/test_auto_update.py b/tests/cli/test_auto_update.py new file mode 100644 index 00000000..25009d78 --- /dev/null +++ b/tests/cli/test_auto_update.py @@ -0,0 +1,374 @@ +"""Tests for CLI auto-update behavior.""" + +from __future__ import annotations + +import subprocess +from datetime import datetime, timedelta, timezone +from io import StringIO + +from rich.console import Console + +from basic_memory.cli.auto_update import ( + AutoUpdateResult, + AutoUpdateStatus, + InstallSource, + _is_interactive_session, + detect_install_source, + maybe_run_periodic_auto_update, + run_auto_update, +) +from basic_memory.config import BasicMemoryConfig + + +class StubConfigManager: + """Simple in-memory ConfigManager stub for updater tests.""" + + def __init__(self, config: BasicMemoryConfig): + self._config = config + self.save_calls = 0 + + def load_config(self) -> BasicMemoryConfig: + return self._config + + def save_config(self, config: BasicMemoryConfig) -> None: + self._config = config + self.save_calls += 1 + + +def _capture_console() -> tuple[Console, StringIO]: + """Create a Console that writes to an in-memory buffer.""" + buf = StringIO() + return Console(file=buf, force_terminal=True), buf + + +def _base_config(tmp_path) -> BasicMemoryConfig: + return BasicMemoryConfig(projects={"main": {"path": str(tmp_path / "main")}}) + + +def _result( + status: AutoUpdateStatus, + *, + message: str | None, + error: str | None = None, +) -> AutoUpdateResult: + return AutoUpdateResult( + status=status, + source=InstallSource.UV_TOOL, + checked=True, + update_available=status in {AutoUpdateStatus.UPDATE_AVAILABLE, AutoUpdateStatus.UPDATED}, + updated=status == AutoUpdateStatus.UPDATED, + latest_version="9.9.9", + message=message, + error=error, + restart_recommended=status == AutoUpdateStatus.UPDATED, + ) + + +def test_detect_install_source_variants(): + assert ( + detect_install_source("/opt/homebrew/Cellar/basic-memory/0.18.0/bin/python") + == InstallSource.HOMEBREW + ) + assert ( + detect_install_source("/Users/me/.local/share/uv/tools/basic-memory/bin/python") + == InstallSource.UV_TOOL + ) + assert ( + detect_install_source("/Users/me/.cache/uv/archive-v0/abc123/bin/python") + == InstallSource.UVX + ) + assert ( + detect_install_source("/Users/me/Library/Caches/uv/archive-v0/abc123/bin/python") + == InstallSource.UVX + ) + assert detect_install_source("/usr/local/bin/python3") == InstallSource.UNKNOWN + + +def test_interval_gate_skips_check_when_recent(tmp_path): + config = _base_config(tmp_path) + config.auto_update_last_checked_at = datetime.now() - timedelta(seconds=30) + config.update_check_interval = 3600 + manager = StubConfigManager(config) + + result = run_auto_update(config_manager=manager) + + assert result.status == AutoUpdateStatus.SKIPPED + assert result.checked is False + assert manager.save_calls == 0 + + +def test_auto_update_disabled_skips_periodic(tmp_path): + config = _base_config(tmp_path) + config.auto_update = False + manager = StubConfigManager(config) + + result = run_auto_update(config_manager=manager) + + assert result.status == AutoUpdateStatus.SKIPPED + assert result.checked is False + + +def test_force_bypasses_auto_update_disabled(monkeypatch, tmp_path): + config = _base_config(tmp_path) + config.auto_update = False + manager = StubConfigManager(config) + + monkeypatch.setattr( + "basic_memory.cli.auto_update._check_pypi_update_available", + lambda: (False, "0.0.0"), + ) + + result = run_auto_update( + force=True, + config_manager=manager, + executable="/Users/me/.local/share/uv/tools/basic-memory/bin/python", + ) + + assert result.status == AutoUpdateStatus.UP_TO_DATE + assert result.checked is True + assert manager.save_calls == 1 + + +def test_homebrew_outdated_triggers_upgrade(monkeypatch, tmp_path): + config = _base_config(tmp_path) + manager = StubConfigManager(config) + + monkeypatch.setattr( + "basic_memory.cli.auto_update._check_homebrew_update_available", + lambda silent: (True, None), + ) + calls: list[list[str]] = [] + + def _fake_run_subprocess(command, **kwargs): + calls.append(command) + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + + monkeypatch.setattr("basic_memory.cli.auto_update._run_subprocess", _fake_run_subprocess) + + result = run_auto_update( + config_manager=manager, + executable="/opt/homebrew/Cellar/basic-memory/0.18.0/bin/python", + ) + + assert result.status == AutoUpdateStatus.UPDATED + assert calls == [["brew", "upgrade", "basic-memory"]] + + +def test_uv_tool_pypi_check_triggers_upgrade(monkeypatch, tmp_path): + config = _base_config(tmp_path) + manager = StubConfigManager(config) + + monkeypatch.setattr( + "basic_memory.cli.auto_update._check_pypi_update_available", + lambda: (True, "9.9.9"), + ) + calls: list[list[str]] = [] + + def _fake_run_subprocess(command, **kwargs): + calls.append(command) + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + + monkeypatch.setattr("basic_memory.cli.auto_update._run_subprocess", _fake_run_subprocess) + + result = run_auto_update( + config_manager=manager, + executable="/Users/me/.local/share/uv/tools/basic-memory/bin/python", + ) + + assert result.status == AutoUpdateStatus.UPDATED + assert result.latest_version == "9.9.9" + assert calls == [["uv", "tool", "upgrade", "basic-memory"]] + + +def test_unknown_manager_returns_manual_update_guidance(monkeypatch, tmp_path): + config = _base_config(tmp_path) + manager = StubConfigManager(config) + monkeypatch.setattr( + "basic_memory.cli.auto_update._check_pypi_update_available", + lambda: (True, "9.9.9"), + ) + + result = run_auto_update( + force=True, + config_manager=manager, + executable="/usr/local/bin/python3", + ) + + assert result.status == AutoUpdateStatus.UPDATE_AVAILABLE + assert result.updated is False + assert "Automatic install is not supported" in (result.message or "") + + +def test_uvx_runtime_is_skipped(monkeypatch, tmp_path): + config = _base_config(tmp_path) + manager = StubConfigManager(config) + + result = run_auto_update( + config_manager=manager, + executable="/Users/me/.cache/uv/archive-v0/abc123/bin/python", + ) + + assert result.status == AutoUpdateStatus.SKIPPED + assert result.source == InstallSource.UVX + assert result.checked is False + assert manager.save_calls == 0 + + +def test_mcp_silent_mode_suppresses_subprocess_output(monkeypatch, tmp_path): + config = _base_config(tmp_path) + manager = StubConfigManager(config) + monkeypatch.setattr( + "basic_memory.cli.auto_update._check_pypi_update_available", + lambda: (True, "9.9.9"), + ) + + captured_kwargs: list[dict] = [] + + def _fake_run_subprocess(command, **kwargs): + captured_kwargs.append(kwargs) + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + + monkeypatch.setattr("basic_memory.cli.auto_update._run_subprocess", _fake_run_subprocess) + + result = run_auto_update( + config_manager=manager, + executable="/Users/me/.local/share/uv/tools/basic-memory/bin/python", + silent=True, + ) + + assert result.status == AutoUpdateStatus.UPDATED + assert captured_kwargs + assert captured_kwargs[0]["silent"] is True + assert captured_kwargs[0]["capture_output"] is False + + +def test_subprocess_oserror_is_non_fatal(monkeypatch, tmp_path): + config = _base_config(tmp_path) + manager = StubConfigManager(config) + monkeypatch.setattr( + "basic_memory.cli.auto_update._check_pypi_update_available", + lambda: (True, "9.9.9"), + ) + + def _raise_oserror(command, **kwargs): + raise FileNotFoundError(command[0]) + + monkeypatch.setattr("basic_memory.cli.auto_update._run_subprocess", _raise_oserror) + + result = run_auto_update( + config_manager=manager, + executable="/Users/me/.local/share/uv/tools/basic-memory/bin/python", + ) + + assert result.status == AutoUpdateStatus.FAILED + assert result.checked is True + + +def test_mixed_timezone_timestamp_does_not_crash_interval_gate(monkeypatch, tmp_path): + config = _base_config(tmp_path) + config.auto_update_last_checked_at = datetime.now(timezone.utc) + manager = StubConfigManager(config) + + monkeypatch.setattr( + "basic_memory.cli.auto_update._check_pypi_update_available", + lambda: (False, "0.0.0"), + ) + + result = run_auto_update( + config_manager=manager, + executable="/Users/me/.local/share/uv/tools/basic-memory/bin/python", + ) + + assert result.status == AutoUpdateStatus.UP_TO_DATE + assert result.checked is True + + +def test_maybe_run_periodic_auto_update_non_interactive_has_no_console_output(): + console, buf = _capture_console() + result = maybe_run_periodic_auto_update( + "status", + is_interactive=False, + console=console, + ) + assert result is None + assert buf.getvalue() == "" + + +def test_maybe_run_periodic_auto_update_prints_updated(monkeypatch): + console, buf = _capture_console() + monkeypatch.setattr( + "basic_memory.cli.auto_update.run_auto_update", + lambda **kwargs: _result( + AutoUpdateStatus.UPDATED, + message="Basic Memory was updated successfully.", + ), + ) + + result = maybe_run_periodic_auto_update("status", is_interactive=True, console=console) + assert result is not None + assert result.status == AutoUpdateStatus.UPDATED + assert "updated successfully" in buf.getvalue().lower() + + +def test_maybe_run_periodic_auto_update_prints_available(monkeypatch): + console, buf = _capture_console() + monkeypatch.setattr( + "basic_memory.cli.auto_update.run_auto_update", + lambda **kwargs: _result( + AutoUpdateStatus.UPDATE_AVAILABLE, + message="Update available (latest: 9.9.9).", + ), + ) + + result = maybe_run_periodic_auto_update("status", is_interactive=True, console=console) + assert result is not None + assert result.status == AutoUpdateStatus.UPDATE_AVAILABLE + assert "update available" in buf.getvalue().lower() + + +def test_maybe_run_periodic_auto_update_prints_failed_with_error(monkeypatch): + console, buf = _capture_console() + monkeypatch.setattr( + "basic_memory.cli.auto_update.run_auto_update", + lambda **kwargs: _result( + AutoUpdateStatus.FAILED, + message="Automatic update check failed.", + error="network timeout", + ), + ) + + result = maybe_run_periodic_auto_update("status", is_interactive=True, console=console) + assert result is not None + assert result.status == AutoUpdateStatus.FAILED + output = buf.getvalue().lower() + assert "automatic update check failed" in output + assert "network timeout" in output + + +def test_maybe_run_periodic_auto_update_uses_interactive_probe_when_not_overridden(monkeypatch): + console, buf = _capture_console() + monkeypatch.setattr("basic_memory.cli.auto_update._is_interactive_session", lambda: True) + monkeypatch.setattr( + "basic_memory.cli.auto_update.run_auto_update", + lambda **kwargs: _result( + AutoUpdateStatus.UP_TO_DATE, + message="Basic Memory is up to date.", + ), + ) + + result = maybe_run_periodic_auto_update("status", console=console) + assert result is not None + assert result.status == AutoUpdateStatus.UP_TO_DATE + # UP_TO_DATE is intentionally silent for periodic checks. + assert buf.getvalue() == "" + + +def test_is_interactive_session_handles_closed_stdio(monkeypatch): + class _BrokenStream: + def isatty(self) -> bool: + raise ValueError("I/O operation on closed file") + + monkeypatch.setattr("basic_memory.cli.auto_update.sys.stdin", _BrokenStream()) + monkeypatch.setattr("basic_memory.cli.auto_update.sys.stdout", _BrokenStream()) + + assert _is_interactive_session() is False diff --git a/tests/cli/test_cloud_status.py b/tests/cli/test_cloud_status.py index 7d2ea2f8..a3fbead0 100644 --- a/tests/cli/test_cloud_status.py +++ b/tests/cli/test_cloud_status.py @@ -63,9 +63,7 @@ def is_token_valid(self, t): monkeypatch.setattr( "basic_memory.cli.commands.cloud.core_commands.ConfigManager", FakeConfigManager ) - monkeypatch.setattr( - "basic_memory.cli.commands.cloud.core_commands.CLIAuth", FakeAuth - ) + monkeypatch.setattr("basic_memory.cli.commands.cloud.core_commands.CLIAuth", FakeAuth) monkeypatch.setattr( "basic_memory.cli.commands.cloud.core_commands.get_cloud_config", lambda: ("cid", "domain", "https://cloud.example.com"), diff --git a/tests/cli/test_update_command.py b/tests/cli/test_update_command.py new file mode 100644 index 00000000..c0f36c80 --- /dev/null +++ b/tests/cli/test_update_command.py @@ -0,0 +1,90 @@ +"""Tests for `bm update` command.""" + +from typer.testing import CliRunner + +from basic_memory.cli.app import app +from basic_memory.cli.auto_update import AutoUpdateResult, AutoUpdateStatus, InstallSource + + +def _result( + status: AutoUpdateStatus, + *, + message: str | None, + error: str | None = None, +) -> AutoUpdateResult: + return AutoUpdateResult( + status=status, + source=InstallSource.UV_TOOL, + checked=True, + update_available=status in {AutoUpdateStatus.UPDATE_AVAILABLE, AutoUpdateStatus.UPDATED}, + updated=status == AutoUpdateStatus.UPDATED, + latest_version="9.9.9", + message=message, + error=error, + restart_recommended=status == AutoUpdateStatus.UPDATED, + ) + + +def test_update_command_applies_upgrade(monkeypatch): + runner = CliRunner() + + monkeypatch.setattr( + "basic_memory.cli.commands.update.run_auto_update", + lambda **kwargs: _result( + AutoUpdateStatus.UPDATED, + message="Basic Memory was updated successfully.", + ), + ) + + result = runner.invoke(app, ["update"]) + assert result.exit_code == 0 + assert "updated successfully" in result.stdout.lower() + + +def test_update_command_check_only_shows_available(monkeypatch): + runner = CliRunner() + + monkeypatch.setattr( + "basic_memory.cli.commands.update.run_auto_update", + lambda **kwargs: _result( + AutoUpdateStatus.UPDATE_AVAILABLE, + message="Update available (latest: 9.9.9). Run `uv tool upgrade basic-memory`.", + ), + ) + + result = runner.invoke(app, ["update", "--check"]) + assert result.exit_code == 0 + assert "update available" in result.stdout.lower() + + +def test_update_command_reports_up_to_date(monkeypatch): + runner = CliRunner() + + monkeypatch.setattr( + "basic_memory.cli.commands.update.run_auto_update", + lambda **kwargs: _result( + AutoUpdateStatus.UP_TO_DATE, + message="Basic Memory is up to date.", + ), + ) + + result = runner.invoke(app, ["update"]) + assert result.exit_code == 0 + assert "up to date" in result.stdout.lower() + + +def test_update_command_failure_exits_nonzero(monkeypatch): + runner = CliRunner() + + monkeypatch.setattr( + "basic_memory.cli.commands.update.run_auto_update", + lambda **kwargs: _result( + AutoUpdateStatus.FAILED, + message="Automatic update failed.", + error="network timeout", + ), + ) + + result = runner.invoke(app, ["update"]) + assert result.exit_code == 1 + assert "automatic update failed" in result.stdout.lower() diff --git a/tests/test_config.py b/tests/test_config.py index c663583c..321514be 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -624,7 +624,6 @@ def test_legacy_cloud_mode_key_is_stripped_on_normalization_save(self): raw = json.loads(config_manager.config_file.read_text(encoding="utf-8")) assert "cloud_mode" not in raw - def test_migration_creates_backup_of_old_config(self): """Config migration should create a .bak backup before overwriting.""" with tempfile.TemporaryDirectory() as temp_dir: @@ -1240,3 +1239,47 @@ def test_migrate_handles_mixed_projects(self, tmp_path): assert result["projects"]["local-proj"]["path"] == local_path assert result["projects"]["cloud-only"]["path"] == "cloud-only" assert result["projects"]["cloud-bisync"]["path"] == bisync_path + + +class TestAutoUpdateConfig: + """Test auto-update configuration fields.""" + + def test_auto_update_defaults(self): + """Auto-update should default on with a daily check interval.""" + config = BasicMemoryConfig() + assert config.auto_update is True + assert config.update_check_interval == 86400 + assert config.auto_update_last_checked_at is None + + def test_auto_update_env_overrides(self, monkeypatch): + """Environment variables should override auto-update defaults.""" + monkeypatch.setenv("BASIC_MEMORY_AUTO_UPDATE", "false") + monkeypatch.setenv("BASIC_MEMORY_UPDATE_CHECK_INTERVAL", "3600") + + config = BasicMemoryConfig() + assert config.auto_update is False + assert config.update_check_interval == 3600 + + def test_auto_update_round_trip_persistence(self): + """Auto-update values should survive save/load cycle.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + config_manager = ConfigManager() + config_manager.config_dir = temp_path / "basic-memory" + config_manager.config_file = config_manager.config_dir / "config.json" + config_manager.config_dir.mkdir(parents=True, exist_ok=True) + + checked_at = datetime.now() + test_config = BasicMemoryConfig( + projects={"main": {"path": str(temp_path / "main")}}, + auto_update=False, + update_check_interval=7200, + auto_update_last_checked_at=checked_at, + ) + config_manager.save_config(test_config) + + loaded = config_manager.load_config() + assert loaded.auto_update is False + assert loaded.update_check_interval == 7200 + assert loaded.auto_update_last_checked_at == checked_at