mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 148e07c580 | |||
| 21334cc29b | |||
| db60942267 | |||
| 7bfac158df | |||
| 87616924ff | |||
| 5cb0502ed2 | |||
| a94a717b1b |
@@ -2,6 +2,32 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
## v0.20.1 (2026-03-10)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **#661**: Fix `bm project list` MCP column to show transport type (stdio/https) instead of DB presence
|
||||
- Renamed "MCP (stdio)" column to "MCP"
|
||||
- Shows actual routing mode: `stdio` for local, `https` for cloud projects
|
||||
- Clears local path display for cloud-mode projects
|
||||
- **#662**: Invalidate config cache when file is modified by another process
|
||||
- Adds mtime-based cache validation to `ConfigManager.load_config()`
|
||||
- Long-lived processes (MCP stdio server) now detect external config changes
|
||||
- Fixes `bm project set-cloud` having no effect on running MCP server
|
||||
|
||||
## v0.20.0 (2026-03-10)
|
||||
|
||||
### Features
|
||||
|
||||
- **#643**: Default-on auto-update system and `bm update` command
|
||||
- Automatic background update checks for CLI installs (uv tool, Homebrew)
|
||||
- Install-source detection (homebrew, uv_tool, uvx, unknown) with uvx skip behavior
|
||||
- Periodic check gating via `auto_update_last_checked_at` + `update_check_interval` config
|
||||
- Manager-specific update flows: Homebrew (`brew upgrade`) and uv tool (`uv tool upgrade`)
|
||||
- Silent, non-blocking MCP behavior via daemon thread before server run
|
||||
- Manual commands: `bm update` (force check + apply) and `bm update --check` (check only)
|
||||
- New config fields: `auto_update`, `update_check_interval`, `auto_update_last_checked_at`
|
||||
|
||||
## v0.19.2 (2026-03-09)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -75,6 +75,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
|
||||
|
||||
+17
-1
@@ -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/).
|
||||
For more detailed information, refer to the [full documentation](https://docs.basicmemory.com/).
|
||||
|
||||
+2
-2
@@ -6,12 +6,12 @@
|
||||
"url": "https://github.com/basicmachines-co/basic-memory.git",
|
||||
"source": "github"
|
||||
},
|
||||
"version": "0.19.2",
|
||||
"version": "0.20.1",
|
||||
"packages": [
|
||||
{
|
||||
"registryType": "pypi",
|
||||
"identifier": "basic-memory",
|
||||
"version": "0.19.2",
|
||||
"version": "0.20.1",
|
||||
"runtimeHint": "uvx",
|
||||
"runtimeArguments": [
|
||||
{"type": "positional", "value": "basic-memory"},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
|
||||
|
||||
# Package version - updated by release automation
|
||||
__version__ = "0.19.2"
|
||||
__version__ = "0.20.1"
|
||||
|
||||
# API version for FastAPI - independent of package version
|
||||
__api_version__ = "v0"
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Optional # noqa: E402
|
||||
|
||||
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 (
|
||||
|
||||
@@ -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
|
||||
@@ -8,6 +8,7 @@ from . import (
|
||||
project,
|
||||
format,
|
||||
schema,
|
||||
update,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -23,4 +24,5 @@ __all__ = [
|
||||
"project",
|
||||
"format",
|
||||
"schema",
|
||||
"update",
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -128,7 +128,7 @@ def list_projects(
|
||||
table.add_column("Cloud Path", style="green")
|
||||
table.add_column("Workspace", style="green")
|
||||
table.add_column("CLI Route", style="blue")
|
||||
table.add_column("MCP (stdio)", style="blue")
|
||||
table.add_column("MCP", style="blue")
|
||||
table.add_column("Sync", style="green")
|
||||
table.add_column("Default", style="magenta")
|
||||
|
||||
@@ -164,6 +164,11 @@ def list_projects(
|
||||
elif entry and entry.mode == ProjectMode.LOCAL and entry.path:
|
||||
local_path = format_path(normalize_project_path(entry.path))
|
||||
|
||||
# Clear local path for cloud-mode projects — only local projects
|
||||
# should display a local path
|
||||
if entry and entry.mode == ProjectMode.CLOUD:
|
||||
local_path = ""
|
||||
|
||||
cloud_path = ""
|
||||
if cloud_project is not None:
|
||||
cloud_path = normalize_project_path(cloud_project.path)
|
||||
@@ -182,7 +187,13 @@ def list_projects(
|
||||
is_default = config.default_project == project_name
|
||||
|
||||
has_sync = bool(entry and entry.local_sync_path)
|
||||
mcp_stdio_target = "local" if local_project is not None else "n/a"
|
||||
# Determine MCP transport based on project routing mode
|
||||
if entry and entry.mode == ProjectMode.CLOUD:
|
||||
mcp_transport = "https"
|
||||
elif entry is None and cloud_project is not None:
|
||||
mcp_transport = "https"
|
||||
else:
|
||||
mcp_transport = "stdio"
|
||||
|
||||
# Show workspace name (type) for cloud-sourced projects
|
||||
ws_label = ""
|
||||
@@ -195,7 +206,7 @@ def list_projects(
|
||||
"local_path": local_path,
|
||||
"cloud_path": cloud_path,
|
||||
"cli_route": cli_route,
|
||||
"mcp_stdio": mcp_stdio_target,
|
||||
"mcp_stdio": mcp_transport,
|
||||
"sync": has_sync,
|
||||
"is_default": is_default,
|
||||
}
|
||||
|
||||
@@ -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]")
|
||||
@@ -28,6 +28,7 @@ if not _version_only_invocation(sys.argv[1:]):
|
||||
schema,
|
||||
status,
|
||||
tool,
|
||||
update,
|
||||
)
|
||||
|
||||
warnings.filterwarnings("ignore") # pragma: no cover
|
||||
|
||||
@@ -351,6 +351,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.",
|
||||
@@ -629,6 +645,12 @@ class BasicMemoryConfig(BaseSettings):
|
||||
|
||||
# Module-level cache for configuration
|
||||
_CONFIG_CACHE: Optional[BasicMemoryConfig] = None
|
||||
# Track config file mtime+size so cross-process changes (e.g. `bm project set-cloud`
|
||||
# in a separate terminal) invalidate the cache in long-lived processes like the
|
||||
# MCP stdio server. Using both mtime and size guards against coarse-granularity
|
||||
# filesystems where two writes within the same second share the same mtime.
|
||||
_CONFIG_MTIME: Optional[float] = None
|
||||
_CONFIG_SIZE: Optional[int] = None
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
@@ -662,13 +684,38 @@ class ConfigManager:
|
||||
Environment variables take precedence over file config values,
|
||||
following Pydantic Settings best practices.
|
||||
|
||||
Uses module-level cache for performance across ConfigManager instances.
|
||||
Uses module-level cache with file mtime validation so that
|
||||
cross-process config changes (e.g. `bm project set-cloud` in a
|
||||
separate terminal) are picked up by long-lived processes like
|
||||
the MCP stdio server.
|
||||
"""
|
||||
global _CONFIG_CACHE
|
||||
global _CONFIG_CACHE, _CONFIG_MTIME, _CONFIG_SIZE
|
||||
|
||||
# Return cached config if available
|
||||
# Trigger: cached config exists but the on-disk file may have been
|
||||
# modified by another process (CLI command in a different terminal).
|
||||
# Why: the MCP server is long-lived; without this check it would
|
||||
# serve stale project routing forever.
|
||||
# Outcome: cheap os.stat() per access; re-read only when mtime or size differs.
|
||||
if _CONFIG_CACHE is not None:
|
||||
return _CONFIG_CACHE
|
||||
try:
|
||||
st = self.config_file.stat()
|
||||
current_mtime = st.st_mtime
|
||||
current_size = st.st_size
|
||||
except OSError:
|
||||
current_mtime = None
|
||||
current_size = None
|
||||
|
||||
if (
|
||||
current_mtime is not None
|
||||
and current_mtime == _CONFIG_MTIME
|
||||
and current_size == _CONFIG_SIZE
|
||||
):
|
||||
return _CONFIG_CACHE
|
||||
|
||||
# mtime/size changed or file gone — invalidate and fall through to re-read
|
||||
_CONFIG_CACHE = None
|
||||
_CONFIG_MTIME = None
|
||||
_CONFIG_SIZE = None
|
||||
|
||||
if self.config_file.exists():
|
||||
try:
|
||||
@@ -723,6 +770,15 @@ class ConfigManager:
|
||||
|
||||
_CONFIG_CACHE = BasicMemoryConfig(**merged_data)
|
||||
|
||||
# Record mtime+size so subsequent calls detect cross-process changes
|
||||
try:
|
||||
st = self.config_file.stat()
|
||||
_CONFIG_MTIME = st.st_mtime
|
||||
_CONFIG_SIZE = st.st_size
|
||||
except OSError:
|
||||
_CONFIG_MTIME = None
|
||||
_CONFIG_SIZE = None
|
||||
|
||||
# Re-save to normalize legacy config into current format
|
||||
if needs_resave:
|
||||
# Create backup before overwriting so users can revert if needed
|
||||
@@ -753,10 +809,12 @@ class ConfigManager:
|
||||
|
||||
def save_config(self, config: BasicMemoryConfig) -> None:
|
||||
"""Save configuration to file and invalidate cache."""
|
||||
global _CONFIG_CACHE
|
||||
global _CONFIG_CACHE, _CONFIG_MTIME, _CONFIG_SIZE
|
||||
save_basic_memory_config(self.config_file, config)
|
||||
# Invalidate cache so next load_config() reads fresh data
|
||||
_CONFIG_CACHE = None
|
||||
_CONFIG_MTIME = None
|
||||
_CONFIG_SIZE = None
|
||||
|
||||
@property
|
||||
def projects(self) -> Dict[str, str]:
|
||||
|
||||
@@ -258,6 +258,8 @@ def config_manager(app_config: BasicMemoryConfig, config_home) -> ConfigManager:
|
||||
from basic_memory import config as config_module
|
||||
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
|
||||
config_manager = ConfigManager()
|
||||
# Update its paths to use the test directory
|
||||
|
||||
@@ -25,6 +25,8 @@ def isolated_home(tmp_path, monkeypatch) -> Path:
|
||||
from basic_memory import config as config_module
|
||||
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
if os.name == "nt":
|
||||
|
||||
@@ -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
|
||||
@@ -350,6 +350,8 @@ def write_config(tmp_path, monkeypatch):
|
||||
from basic_memory import config as config_module
|
||||
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
|
||||
config_dir = tmp_path / ".basic-memory"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -27,6 +27,8 @@ def mock_config(tmp_path, monkeypatch):
|
||||
from basic_memory import config as config_module
|
||||
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
|
||||
config_dir = tmp_path / ".basic-memory"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -29,6 +29,8 @@ def write_config(tmp_path, monkeypatch):
|
||||
from basic_memory import config as config_module
|
||||
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
|
||||
config_dir = tmp_path / ".basic-memory"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -122,15 +124,16 @@ def test_project_list_shows_local_cloud_presence_and_routes(
|
||||
assert "Local Path" in result.stdout
|
||||
assert "Cloud Path" in result.stdout
|
||||
assert "CLI Route" in result.stdout
|
||||
assert "MCP (stdio)" in result.stdout
|
||||
assert "MCP" in result.stdout
|
||||
|
||||
lines = result.stdout.splitlines()
|
||||
alpha_line = next(line for line in lines if "│ alpha" in line)
|
||||
beta_line = next(line for line in lines if "│ beta" in line)
|
||||
|
||||
assert "local" in alpha_line # CLI route for alpha
|
||||
assert "stdio" in alpha_line # Local projects use stdio transport
|
||||
assert "cloud" in beta_line # CLI route for beta
|
||||
assert "n/a" in beta_line # MCP stdio route is unavailable for cloud-only projects
|
||||
assert "https" in beta_line # Cloud projects use HTTPS transport
|
||||
assert "alpha-local" in result.stdout
|
||||
assert "/alpha" in result.stdout
|
||||
assert "/beta" in result.stdout
|
||||
|
||||
@@ -22,6 +22,8 @@ def mock_config(tmp_path, monkeypatch):
|
||||
from basic_memory import config as config_module
|
||||
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
|
||||
config_dir = tmp_path / ".basic-memory"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -68,6 +70,8 @@ class TestSetCloud:
|
||||
from basic_memory import config as config_module
|
||||
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
|
||||
config_dir = tmp_path / ".basic-memory"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -91,6 +95,8 @@ class TestSetCloud:
|
||||
from basic_memory import config as config_module
|
||||
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
|
||||
config_dir = tmp_path / ".basic-memory"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -161,11 +167,15 @@ class TestSetLocal:
|
||||
|
||||
# Manually set workspace_id on the project
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
config_data = json.loads(mock_config.read_text())
|
||||
config_data["projects"]["research"]["mode"] = "cloud"
|
||||
config_data["projects"]["research"]["workspace_id"] = "11111111-1111-1111-1111-111111111111"
|
||||
mock_config.write_text(json.dumps(config_data, indent=2))
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
|
||||
# Set back to local
|
||||
result = runner.invoke(app, ["project", "set-local", "research"])
|
||||
@@ -173,6 +183,8 @@ class TestSetLocal:
|
||||
|
||||
# Verify workspace_id was cleared
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
updated_data = json.loads(mock_config.read_text())
|
||||
assert updated_data["projects"]["research"]["workspace_id"] is None
|
||||
assert updated_data["projects"]["research"]["mode"] == "local"
|
||||
@@ -187,6 +199,8 @@ class TestSetCloudWithWorkspace:
|
||||
from basic_memory.schemas.cloud import WorkspaceInfo
|
||||
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
|
||||
async def fake_get_available_workspaces():
|
||||
return [
|
||||
@@ -210,6 +224,8 @@ class TestSetCloudWithWorkspace:
|
||||
|
||||
# Verify workspace_id was persisted
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
updated_data = json.loads(mock_config.read_text())
|
||||
assert (
|
||||
updated_data["projects"]["research"]["workspace_id"]
|
||||
@@ -222,6 +238,8 @@ class TestSetCloudWithWorkspace:
|
||||
from basic_memory.schemas.cloud import WorkspaceInfo
|
||||
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
|
||||
async def fake_get_available_workspaces():
|
||||
return [
|
||||
@@ -249,17 +267,23 @@ class TestSetCloudWithWorkspace:
|
||||
from basic_memory import config as config_module
|
||||
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
|
||||
# Set default_workspace in config
|
||||
config_data = json.loads(mock_config.read_text())
|
||||
config_data["default_workspace"] = "global-default-tenant-id"
|
||||
mock_config.write_text(json.dumps(config_data, indent=2))
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
|
||||
result = runner.invoke(app, ["project", "set-cloud", "research"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Verify workspace_id was set from default
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
updated_data = json.loads(mock_config.read_text())
|
||||
assert updated_data["projects"]["research"]["workspace_id"] == "global-default-tenant-id"
|
||||
|
||||
@@ -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()
|
||||
@@ -76,6 +76,8 @@ class TestWorkspaceSetDefault:
|
||||
monkeypatch.setenv("HOME", str(temp_path))
|
||||
monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(config_dir))
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
basic_memory.config._CONFIG_MTIME = None
|
||||
basic_memory.config._CONFIG_SIZE = None
|
||||
|
||||
config_manager = ConfigManager()
|
||||
test_config = BasicMemoryConfig(
|
||||
@@ -106,6 +108,8 @@ class TestWorkspaceSetDefault:
|
||||
|
||||
# Verify config was updated
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
basic_memory.config._CONFIG_MTIME = None
|
||||
basic_memory.config._CONFIG_SIZE = None
|
||||
config = ConfigManager().config
|
||||
assert config.default_workspace == "11111111-1111-1111-1111-111111111111"
|
||||
|
||||
|
||||
@@ -138,6 +138,8 @@ def config_manager(app_config: BasicMemoryConfig, config_home: Path, monkeypatch
|
||||
from basic_memory import config as config_module
|
||||
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
|
||||
# Create a new ConfigManager that uses the test home directory
|
||||
config_manager = ConfigManager()
|
||||
|
||||
@@ -1257,6 +1257,11 @@ class TestWriteNoteOverwriteGuard:
|
||||
# Set config to allow overwrites by default
|
||||
app_config.write_note_overwrite_default = True
|
||||
config_module._CONFIG_CACHE = app_config
|
||||
# Pin mtime+size to the on-disk file so the cache guard sees a match
|
||||
# and keeps our injected config instead of re-reading from disk.
|
||||
_st = config_manager.config_file.stat()
|
||||
config_module._CONFIG_MTIME = _st.st_mtime
|
||||
config_module._CONFIG_SIZE = _st.st_size
|
||||
|
||||
try:
|
||||
await write_note(
|
||||
@@ -1281,6 +1286,9 @@ class TestWriteNoteOverwriteGuard:
|
||||
# Restore config
|
||||
app_config.write_note_overwrite_default = False
|
||||
config_module._CONFIG_CACHE = app_config
|
||||
_st = config_manager.config_file.stat()
|
||||
config_module._CONFIG_MTIME = _st.st_mtime
|
||||
config_module._CONFIG_SIZE = _st.st_size
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_note_new_note_unaffected(self, app, test_project):
|
||||
|
||||
@@ -778,6 +778,8 @@ async def test_add_project_with_project_root_sanitizes_paths(
|
||||
from basic_memory import config as config_module
|
||||
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
|
||||
test_cases = [
|
||||
# (project_name, user_path, expected_sanitized_name)
|
||||
@@ -845,6 +847,8 @@ async def test_add_project_with_project_root_rejects_escape_attempts(
|
||||
from basic_memory import config as config_module
|
||||
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
|
||||
# All of these should succeed by being sanitized to paths under project_root
|
||||
# The sanitization removes dangerous patterns, so they don't escape
|
||||
@@ -931,6 +935,8 @@ async def test_add_project_with_project_root_normalizes_case(
|
||||
from basic_memory import config as config_module
|
||||
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
|
||||
test_cases = [
|
||||
# (input_path, expected_normalized_path)
|
||||
@@ -985,6 +991,8 @@ async def test_add_project_with_project_root_detects_case_collisions(
|
||||
from basic_memory import config as config_module
|
||||
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
|
||||
# First, create a project with lowercase path
|
||||
first_project = "documents-project"
|
||||
@@ -1159,6 +1167,8 @@ async def test_add_project_nested_validation_with_project_root(
|
||||
from basic_memory import config as config_module
|
||||
|
||||
config_module._CONFIG_CACHE = None
|
||||
config_module._CONFIG_MTIME = None
|
||||
config_module._CONFIG_SIZE = None
|
||||
|
||||
parent_project_name = f"cloud-parent-{os.urandom(4).hex()}"
|
||||
child_project_name = f"cloud-child-{os.urandom(4).hex()}"
|
||||
|
||||
@@ -213,6 +213,8 @@ class TestBasicMemoryConfig:
|
||||
}
|
||||
config_manager.config_file.write_text(json.dumps(config_data, indent=2))
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
basic_memory.config._CONFIG_MTIME = None
|
||||
basic_memory.config._CONFIG_SIZE = None
|
||||
|
||||
loaded = config_manager.load_config()
|
||||
assert loaded.default_project == "research"
|
||||
@@ -238,6 +240,8 @@ class TestBasicMemoryConfig:
|
||||
}
|
||||
config_manager.config_file.write_text(json.dumps(config_data, indent=2))
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
basic_memory.config._CONFIG_MTIME = None
|
||||
basic_memory.config._CONFIG_SIZE = None
|
||||
|
||||
loaded = config_manager.load_config()
|
||||
assert loaded.default_project == "work"
|
||||
@@ -545,6 +549,8 @@ class TestConfigManager:
|
||||
import basic_memory.config
|
||||
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
basic_memory.config._CONFIG_MTIME = None
|
||||
basic_memory.config._CONFIG_SIZE = None
|
||||
|
||||
# Should load successfully with migration to ProjectEntry
|
||||
config = config_manager.load_config()
|
||||
@@ -585,6 +591,8 @@ class TestConfigManager:
|
||||
import basic_memory.config
|
||||
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
basic_memory.config._CONFIG_MTIME = None
|
||||
basic_memory.config._CONFIG_SIZE = None
|
||||
|
||||
config = config_manager.load_config()
|
||||
|
||||
@@ -617,6 +625,8 @@ class TestConfigManager:
|
||||
import basic_memory.config
|
||||
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
basic_memory.config._CONFIG_MTIME = None
|
||||
basic_memory.config._CONFIG_SIZE = None
|
||||
|
||||
loaded = config_manager.load_config()
|
||||
assert isinstance(loaded, BasicMemoryConfig)
|
||||
@@ -647,6 +657,8 @@ class TestConfigManager:
|
||||
import basic_memory.config
|
||||
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
basic_memory.config._CONFIG_MTIME = None
|
||||
basic_memory.config._CONFIG_SIZE = None
|
||||
|
||||
config_manager.load_config()
|
||||
|
||||
@@ -678,6 +690,8 @@ class TestConfigManager:
|
||||
import basic_memory.config
|
||||
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
basic_memory.config._CONFIG_MTIME = None
|
||||
basic_memory.config._CONFIG_SIZE = None
|
||||
|
||||
config_manager.load_config()
|
||||
|
||||
@@ -1099,6 +1113,8 @@ class TestProjectMode:
|
||||
import basic_memory.config
|
||||
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
basic_memory.config._CONFIG_MTIME = None
|
||||
basic_memory.config._CONFIG_SIZE = None
|
||||
|
||||
# Should load successfully with migration
|
||||
config = config_manager.load_config()
|
||||
@@ -1174,6 +1190,116 @@ class TestProjectMode:
|
||||
assert loaded.projects["main"].workspace_id is None
|
||||
|
||||
|
||||
class TestConfigCacheMtimeInvalidation:
|
||||
"""Test that config cache is invalidated when file is modified externally."""
|
||||
|
||||
def test_cache_returns_same_config_when_file_unchanged(self, config_home):
|
||||
"""Verify cache hit when config file mtime has not changed."""
|
||||
import basic_memory.config
|
||||
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
basic_memory.config._CONFIG_MTIME = None
|
||||
basic_memory.config._CONFIG_SIZE = None
|
||||
|
||||
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)
|
||||
|
||||
test_config = BasicMemoryConfig(
|
||||
projects={"main": {"path": str(temp_path / "main")}},
|
||||
default_project="main",
|
||||
)
|
||||
config_manager.save_config(test_config)
|
||||
|
||||
# First load populates cache
|
||||
config1 = config_manager.load_config()
|
||||
assert config1.default_project == "main"
|
||||
|
||||
# Second load should return cached config (same object)
|
||||
config2 = config_manager.load_config()
|
||||
assert config1 is config2
|
||||
|
||||
def test_cache_invalidated_when_file_modified(self, config_home):
|
||||
"""Verify cache miss when config file is modified by another process."""
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
import basic_memory.config
|
||||
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
basic_memory.config._CONFIG_MTIME = None
|
||||
basic_memory.config._CONFIG_SIZE = None
|
||||
|
||||
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)
|
||||
|
||||
test_config = BasicMemoryConfig(
|
||||
projects={"main": {"path": str(temp_path / "main")}},
|
||||
default_project="main",
|
||||
)
|
||||
config_manager.save_config(test_config)
|
||||
|
||||
# First load populates cache
|
||||
config1 = config_manager.load_config()
|
||||
assert config1.get_project_mode("main") == ProjectMode.LOCAL
|
||||
|
||||
# Simulate external process modifying the config file
|
||||
config_data = json.loads(config_manager.config_file.read_text())
|
||||
config_data["projects"]["main"]["mode"] = "cloud"
|
||||
|
||||
# Ensure mtime actually changes (some filesystems have 1s granularity)
|
||||
time.sleep(0.05)
|
||||
config_manager.config_file.write_text(json.dumps(config_data, indent=2))
|
||||
# Force mtime change on filesystems with coarse granularity
|
||||
new_mtime = os.path.getmtime(config_manager.config_file) + 1
|
||||
os.utime(config_manager.config_file, (new_mtime, new_mtime))
|
||||
|
||||
# Next load should detect mtime change and re-read
|
||||
config2 = config_manager.load_config()
|
||||
assert config2.get_project_mode("main") == ProjectMode.CLOUD
|
||||
assert config1 is not config2
|
||||
|
||||
def test_save_config_resets_mtime(self, config_home):
|
||||
"""Verify save_config clears both cache and mtime."""
|
||||
import basic_memory.config
|
||||
|
||||
basic_memory.config._CONFIG_CACHE = None
|
||||
basic_memory.config._CONFIG_MTIME = None
|
||||
basic_memory.config._CONFIG_SIZE = None
|
||||
|
||||
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)
|
||||
|
||||
test_config = BasicMemoryConfig(
|
||||
projects={"main": {"path": str(temp_path / "main")}},
|
||||
)
|
||||
config_manager.save_config(test_config)
|
||||
|
||||
# Load to populate cache
|
||||
config_manager.load_config()
|
||||
assert basic_memory.config._CONFIG_CACHE is not None
|
||||
assert basic_memory.config._CONFIG_MTIME is not None
|
||||
assert basic_memory.config._CONFIG_SIZE is not None
|
||||
|
||||
# Save should clear all cache state
|
||||
config_manager.save_config(test_config)
|
||||
assert basic_memory.config._CONFIG_CACHE is None
|
||||
assert basic_memory.config._CONFIG_MTIME is None
|
||||
assert basic_memory.config._CONFIG_SIZE is None
|
||||
|
||||
|
||||
class TestLocalSyncPathMigration:
|
||||
"""Test migration that promotes local_sync_path into path for cloud projects."""
|
||||
|
||||
@@ -1237,3 +1363,47 @@ class TestLocalSyncPathMigration:
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user