feat: Cloud CLI cloud sync via rclone bisync (#322)

Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Paul Hernandez
2025-10-03 10:18:43 -05:00
committed by GitHub
parent ea2e93d926
commit 99a35a7fb4
47 changed files with 5020 additions and 2795 deletions
-2
View File
@@ -19,7 +19,6 @@ from basic_memory.api.routers import (
resource,
search,
prompt_router,
webdav,
)
from basic_memory.config import ConfigManager
from basic_memory.services.initialization import initialize_file_sync, initialize_app
@@ -77,7 +76,6 @@ app.include_router(project.project_router, prefix="/{project}")
app.include_router(directory_router.router, prefix="/{project}")
app.include_router(prompt_router.router, prefix="/{project}")
app.include_router(importer_router.router, prefix="/{project}")
app.include_router(webdav.router, prefix="/{project}")
# Project resource router works accross projects
app.include_router(project.project_resource_router)
+1 -2
View File
@@ -7,6 +7,5 @@ from . import project_router as project
from . import resource_router as resource
from . import search_router as search
from . import prompt_router as prompt
from . import webdav_router as webdav
__all__ = ["knowledge", "management", "memory", "project", "resource", "search", "prompt", "webdav"]
__all__ = ["knowledge", "management", "memory", "project", "resource", "search", "prompt"]
+58 -4
View File
@@ -1,11 +1,17 @@
"""Router for project management."""
import os
from fastapi import APIRouter, HTTPException, Path, Body
from fastapi import APIRouter, HTTPException, Path, Body, BackgroundTasks
from typing import Optional
from loguru import logger
from basic_memory.deps import ProjectServiceDep, ProjectPathDep
from basic_memory.schemas import ProjectInfoResponse
from basic_memory.deps import (
ProjectConfigDep,
ProjectServiceDep,
ProjectPathDep,
SyncServiceDep,
)
from basic_memory.schemas import ProjectInfoResponse, SyncReportResponse
from basic_memory.schemas.project_info import (
ProjectList,
ProjectItem,
@@ -97,6 +103,54 @@ async def update_project(
raise HTTPException(status_code=400, detail=str(e))
# Sync project filesystem
@project_router.post("/sync")
async def sync_project(
background_tasks: BackgroundTasks,
sync_service: SyncServiceDep,
project_config: ProjectConfigDep,
):
"""Force project filesystem sync to database.
Scans the project directory and updates the database with any new or modified files.
Args:
background_tasks: FastAPI background tasks
sync_service: Sync service for this project
project_config: Project configuration
Returns:
Response confirming sync was initiated
"""
background_tasks.add_task(sync_service.sync, project_config.home, project_config.name)
logger.info(f"Filesystem sync initiated for project: {project_config.name}")
return {
"status": "sync_started",
"message": f"Filesystem sync initiated for project '{project_config.name}'",
}
@project_router.post("/status", response_model=SyncReportResponse)
async def project_sync_status(
sync_service: SyncServiceDep,
project_config: ProjectConfigDep,
) -> SyncReportResponse:
"""Scan directory for changes compared to database state.
Args:
sync_service: Sync service for this project
project_config: Project configuration
Returns:
Scan report with details on files that need syncing
"""
logger.info(f"Scanning filesystem for project: {project_config.name}")
sync_report = await sync_service.scan(project_config.home)
return SyncReportResponse.from_sync_report(sync_report)
# List all available projects
@project_resource_router.get("/projects", response_model=ProjectList)
async def list_projects(
@@ -259,7 +313,7 @@ async def get_default_project(
# Synchronize projects between config and database
@project_resource_router.post("/sync", response_model=ProjectStatusResponse)
@project_resource_router.post("/config/sync", response_model=ProjectStatusResponse)
async def synchronize_projects(
project_service: ProjectServiceDep,
) -> ProjectStatusResponse:
@@ -1,353 +0,0 @@
"""WebDAV router for basic-memory project uploads."""
import os
import shutil
from datetime import datetime
from pathlib import Path
import aiofiles
from loguru import logger
from basic_memory.deps import ProjectPathDep, ProjectServiceDep
from fastapi import APIRouter, Request, Response, HTTPException
from fastapi.responses import StreamingResponse
router = APIRouter(
prefix="/webdav",
tags=["webdav"],
)
async def get_project_path_or_404(project_service: ProjectServiceDep, project: str) -> Path:
found_project = await project_service.get_project(project)
if not found_project:
raise HTTPException(status_code=404, detail=f"Project: '{project}' does not exist")
return Path(found_project.path)
async def get_project_file_path_or_404(project_path: Path, path: str) -> Path:
file_path = Path(project_path / path)
if not file_path.exists():
raise HTTPException(status_code=404, detail=f"File: '{path}' does not exist")
return file_path
@router.api_route("/{path:path}", methods=["OPTIONS"])
async def webdav_options(
path: str,
project: ProjectPathDep,
project_service: ProjectServiceDep,
):
"""WebDAV OPTIONS endpoint."""
project_path = await get_project_path_or_404(project_service, project)
file_path = await get_project_file_path_or_404(project_path, path)
return await _webdav_options(file_path)
@router.api_route("/{path:path}", methods=["PROPFIND"])
async def webdav_propfind(
path: str,
project: ProjectPathDep,
project_service: ProjectServiceDep,
):
"""WebDAV PROPFIND endpoint."""
project_path = await get_project_path_or_404(project_service, project)
file_path = await get_project_file_path_or_404(project_path, path)
return await _webdav_propfind(project, project_path, file_path)
@router.api_route("/{path:path}", methods=["GET"])
async def webdav_get(
path: str,
project: ProjectPathDep,
project_service: ProjectServiceDep,
):
"""WebDAV GET endpoint."""
project_path = await get_project_path_or_404(project_service, project)
file_path = await get_project_file_path_or_404(project_path, path)
return await _webdav_get(file_path)
@router.api_route("/{path:path}", methods=["PUT"])
async def webdav_put(
request: Request,
path: str,
project: ProjectPathDep,
project_service: ProjectServiceDep,
):
"""WebDAV PUT endpoint."""
project_path = await get_project_path_or_404(project_service, project)
file_path = Path(project_path / path)
return await _webdav_put(request, project, file_path)
@router.api_route("/{path:path}", methods=["DELETE"])
async def webdav_delete(
path: str,
project: ProjectPathDep,
project_service: ProjectServiceDep,
):
"""WebDAV DELETE endpoint."""
project_path = await get_project_path_or_404(project_service, project)
file_path = await get_project_file_path_or_404(project_path, path)
return await _webdav_delete(project, file_path)
@router.api_route("/{path:path}", methods=["MKCOL"])
async def webdav_mkcol(
path: str,
project: ProjectPathDep,
project_service: ProjectServiceDep,
):
"""WebDAV MKCOL endpoint."""
project_path = await get_project_path_or_404(project_service, project)
dir_path = Path(project_path / path)
return await _webdav_mkcol(project, dir_path)
# Handle WebDAV root
@router.api_route("/", methods=["OPTIONS", "PROPFIND"])
async def webdav_root(
request: Request,
project: ProjectPathDep,
project_service: ProjectServiceDep,
):
"""WebDAV root endpoint."""
project_path = await get_project_path_or_404(project_service, project)
method = request.method
if method == "OPTIONS":
return await _webdav_options(project_path)
else:
return await _webdav_propfind(project, project_path, project_path)
async def _webdav_options(file_path: Path) -> Response:
"""Handle WebDAV OPTIONS request."""
file_size = file_path.stat().st_size
return Response(
status_code=204,
headers={
"DAV": "1,2",
"MS-Author-Via": "DAV",
"Allow": "OPTIONS,GET,HEAD,POST,DELETE,TRACE,PROPFIND,PROPPATCH,COPY,MOVE,LOCK,UNLOCK,PUT",
"Content-Length": f"{file_size}",
},
)
async def _webdav_propfind(project: str, project_path: Path, file_path: Path) -> Response:
"""Handle WebDAV PROPFIND request to list directory contents."""
# Calculate relative path from project root
try:
relative_path = file_path.relative_to(project_path)
relative_path_str = str(relative_path).replace("\\", "/")
if relative_path_str == ".":
relative_path_str = ""
except ValueError:
# file_path is not under project_path
relative_path_str = ""
# Build minimal PROPFIND response
if file_path.is_dir():
# Directory listing
href_path = (
f"/{project}/webdav/{relative_path_str}/"
if relative_path_str
else f"/{project}/webdav/"
)
xml_response = f"""<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:">
<D:response>
<D:href>{href_path}</D:href>
<D:propstat>
<D:prop>
<D:resourcetype><D:collection/></D:resourcetype>
<D:displayname>{file_path.name if file_path.name else project}</D:displayname>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
"""
# Add child items
for child in file_path.iterdir():
# Calculate child relative path
child_relative = child.relative_to(project_path)
child_relative_str = str(child_relative).replace("\\", "/")
if child.is_dir():
child_href = f"/{project}/webdav/{child_relative_str}/"
xml_response += f"""<D:response>
<D:href>{child_href}</D:href>
<D:propstat>
<D:prop>
<D:resourcetype><D:collection/></D:resourcetype>
<D:displayname>{child.name}</D:displayname>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
"""
else:
child_href = f"/{project}/webdav/{child_relative_str}"
file_size = child.stat().st_size
xml_response += f"""<D:response>
<D:href>{child_href}</D:href>
<D:propstat>
<D:prop>
<D:resourcetype/>
<D:displayname>{child.name}</D:displayname>
<D:getcontentlength>{file_size}</D:getcontentlength>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
"""
xml_response += "</D:multistatus>"
else:
# File properties
href_path = f"/{project}/webdav/{relative_path_str}"
file_size = file_path.stat().st_size
xml_response = f"""<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:">
<D:response>
<D:href>{href_path}</D:href>
<D:propstat>
<D:prop>
<D:resourcetype/>
<D:displayname>{file_path.name}</D:displayname>
<D:getcontentlength>{file_size}</D:getcontentlength>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>"""
return Response(content=xml_response, status_code=207, media_type="text/xml; charset=utf-8")
async def _webdav_get(file_path: Path) -> Response:
"""Handle WebDAV GET request to download file."""
async def file_generator():
async with aiofiles.open(file_path, "rb") as file:
while chunk := await file.read(8192):
yield chunk
file_size = file_path.stat().st_size
headers = {"Content-Length": str(file_size), "Content-Type": "application/octet-stream"}
return StreamingResponse(file_generator(), status_code=200, headers=headers)
async def _webdav_put(request: Request, project: str, file_path: Path) -> Response:
"""Handle WebDAV PUT request to upload file."""
# Check if file exists before writing (for correct HTTP status)
file_existed = file_path.exists()
# Ensure parent directory exists
file_path.parent.mkdir(parents=True, exist_ok=True)
# Write file content
try:
async with aiofiles.open(file_path, "wb") as file:
async for chunk in request.stream():
await file.write(chunk)
# Preserve timestamps if provided in headers
await _preserve_file_timestamps(request, file_path)
logger.info(f"WebDAV: Uploaded file {file_path} to project {project}.")
return Response(status_code=204 if file_existed else 201)
except Exception as e:
logger.error(f"WebDAV: Failed to upload file {file_path}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to upload file: {e}") from e
async def _preserve_file_timestamps(request: Request, file_path: Path) -> None:
"""Preserve file timestamps from WebDAV headers if provided.
Supports multiple timestamp header formats:
- X-OC-Mtime: Unix timestamp (ownCloud/Nextcloud format)
- X-Timestamp: Unix timestamp
- X-Mtime: Unix timestamp
- Last-Modified: HTTP date format
"""
# Try different header formats for modification time
mtime_timestamp = None
# Check for custom timestamp headers (Unix timestamp)
for header_name in ["X-OC-Mtime", "X-Timestamp", "X-Mtime"]:
if header_name in request.headers:
try:
mtime_timestamp = float(request.headers[header_name])
logger.debug(f"Using {header_name} timestamp: {mtime_timestamp}")
break
except (ValueError, TypeError) as e:
logger.warning(f"Invalid timestamp in {header_name} header: {e}")
continue
# Fall back to Last-Modified header if no custom timestamp found
if mtime_timestamp is None and "Last-Modified" in request.headers:
try:
# Parse HTTP date format
last_modified_str = request.headers["Last-Modified"]
dt = datetime.strptime(last_modified_str, "%a, %d %b %Y %H:%M:%S GMT")
# Replace with UTC timezone to ensure correct timestamp calculation
from datetime import timezone
dt = dt.replace(tzinfo=timezone.utc)
mtime_timestamp = dt.timestamp()
logger.debug(f"Using Last-Modified timestamp: {mtime_timestamp}")
except (ValueError, TypeError) as e:
logger.warning(f"Invalid Last-Modified header format: {e}")
# Apply timestamp if we found one
if mtime_timestamp is not None:
try:
# Use os.utime to set both access and modification times
# Set access time to modification time to keep them consistent
os.utime(file_path, (mtime_timestamp, mtime_timestamp))
logger.debug(f"Set file timestamps for {file_path} to {mtime_timestamp}")
except OSError as e:
logger.warning(f"Failed to set timestamps for {file_path}: {e}")
else:
logger.debug(f"No timestamp headers found, using current time for {file_path}")
async def _webdav_delete(project: str, file_path: Path) -> Response:
"""Handle WebDAV DELETE request to delete file or directory."""
try:
if file_path.is_dir():
shutil.rmtree(file_path)
else:
file_path.unlink()
logger.info(f"WebDAV: Deleted {file_path} for project {project}")
return Response(status_code=204)
except Exception as e:
logger.error(f"WebDAV: Failed to delete {file_path}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to delete: {e}") from e
async def _webdav_mkcol(project: str, dir_path: Path) -> Response:
"""Handle WebDAV MKCOL request to create directory."""
if dir_path.exists():
raise HTTPException(status_code=405, detail="Directory already exists")
try:
dir_path.mkdir(parents=True, exist_ok=False)
logger.info(f"WebDAV: Created directory {dir_path} for project {project}")
return Response(status_code=201)
except Exception as e:
logger.error(f"WebDAV: Failed to create directory {dir_path}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to create directory: {e}") from e
+30 -2
View File
@@ -1,7 +1,10 @@
"""WorkOS OAuth Device Authorization for CLI."""
import base64
import hashlib
import json
import os
import secrets
import time
import webbrowser
@@ -22,12 +25,36 @@ class CLIAuth:
app_config = ConfigManager().config
# Store tokens in data dir
self.token_file = app_config.data_dir_path / "basic-memory-cloud.json"
# PKCE parameters
self.code_verifier = None
self.code_challenge = None
def generate_pkce_pair(self) -> tuple[str, str]:
"""Generate PKCE code verifier and challenge."""
# Generate code verifier (43-128 characters)
code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode("utf-8")
code_verifier = code_verifier.rstrip("=")
# Generate code challenge (SHA256 hash of verifier)
challenge_bytes = hashlib.sha256(code_verifier.encode("utf-8")).digest()
code_challenge = base64.urlsafe_b64encode(challenge_bytes).decode("utf-8")
code_challenge = code_challenge.rstrip("=")
return code_verifier, code_challenge
async def request_device_authorization(self) -> dict | None:
"""Request device authorization from WorkOS."""
"""Request device authorization from WorkOS with PKCE."""
device_auth_url = f"{self.authkit_domain}/oauth2/device_authorization"
data = {"client_id": self.client_id, "scope": "openid profile email offline_access"}
# Generate PKCE pair
self.code_verifier, self.code_challenge = self.generate_pkce_pair()
data = {
"client_id": self.client_id,
"scope": "openid profile email offline_access",
"code_challenge": self.code_challenge,
"code_challenge_method": "S256",
}
try:
async with httpx.AsyncClient() as client:
@@ -76,6 +103,7 @@ class CLIAuth:
"client_id": self.client_id,
"device_code": device_code,
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"code_verifier": self.code_verifier,
}
max_attempts = 60 # 5 minutes with 5-second intervals
@@ -2,3 +2,4 @@
# Import all commands to register them with typer
from basic_memory.cli.commands.cloud.core_commands import * # noqa: F401,F403
from basic_memory.cli.commands.cloud.api_client import get_authenticated_headers # noqa: F401
@@ -26,7 +26,10 @@ def get_cloud_config() -> tuple[str, str, str]:
async def get_authenticated_headers() -> dict[str, str]:
"""Get authentication headers with JWT token."""
"""
Get authentication headers with JWT token.
handles jwt refresh if needed.
"""
client_id, domain, _ = get_cloud_config()
auth = CLIAuth(client_id=client_id, authkit_domain=domain)
token = await auth.get_valid_token()
@@ -54,12 +57,12 @@ async def make_api_request(
async with httpx.AsyncClient(timeout=timeout) as client:
try:
console.print(f"[dim]Making {method} request to {url}[/dim]")
console.print(f"[dim]Headers: {dict(headers)}[/dim]")
# console.print(f"[dim]Headers: {dict(headers)}[/dim]")
response = await client.request(method=method, url=url, headers=headers, json=json_data)
console.print(f"[dim]Response status: {response.status_code}[/dim]")
console.print(f"[dim]Response headers: {dict(response.headers)}[/dim]")
# console.print(f"[dim]Response headers: {dict(response.headers)}[/dim]")
response.raise_for_status()
return response
@@ -0,0 +1,818 @@
"""Cloud bisync commands for Basic Memory CLI."""
import asyncio
import subprocess
import time
from datetime import datetime
from pathlib import Path
from typing import Optional
import typer
from rich.console import Console
from rich.table import Table
from basic_memory.cli.commands.cloud.api_client import CloudAPIError, make_api_request
from basic_memory.cli.commands.cloud.rclone_config import (
add_tenant_to_rclone_config,
)
from basic_memory.cli.commands.cloud.rclone_installer import RcloneInstallError, install_rclone
from basic_memory.config import ConfigManager
from basic_memory.ignore_utils import get_bmignore_path, create_default_bmignore
from basic_memory.schemas.cloud import (
TenantMountInfo,
MountCredentials,
CloudProjectList,
CloudProjectCreateRequest,
CloudProjectCreateResponse,
)
from basic_memory.utils import generate_permalink
console = Console()
class BisyncError(Exception):
"""Exception raised for bisync-related errors."""
pass
class RcloneBisyncProfile:
"""Bisync profile with safety settings."""
def __init__(
self,
name: str,
conflict_resolve: str,
max_delete: int,
check_access: bool,
description: str,
extra_args: Optional[list[str]] = None,
):
self.name = name
self.conflict_resolve = conflict_resolve
self.max_delete = max_delete
self.check_access = check_access
self.description = description
self.extra_args = extra_args or []
# Bisync profiles based on SPEC-9 Phase 2.1
BISYNC_PROFILES = {
"safe": RcloneBisyncProfile(
name="safe",
conflict_resolve="none",
max_delete=10,
check_access=False,
description="Safe mode with conflict preservation (keeps both versions)",
),
"balanced": RcloneBisyncProfile(
name="balanced",
conflict_resolve="newer",
max_delete=25,
check_access=False,
description="Balanced mode - auto-resolve to newer file (recommended)",
),
"fast": RcloneBisyncProfile(
name="fast",
conflict_resolve="newer",
max_delete=50,
check_access=False,
description="Fast mode for rapid iteration (skip verification)",
),
}
async def get_mount_info() -> TenantMountInfo:
"""Get current tenant information from cloud API."""
try:
config_manager = ConfigManager()
config = config_manager.config
host_url = config.cloud_host.rstrip("/")
response = await make_api_request(method="GET", url=f"{host_url}/tenant/mount/info")
return TenantMountInfo.model_validate(response.json())
except Exception as e:
raise BisyncError(f"Failed to get tenant info: {e}") from e
async def generate_mount_credentials(tenant_id: str) -> MountCredentials:
"""Generate scoped credentials for syncing."""
try:
config_manager = ConfigManager()
config = config_manager.config
host_url = config.cloud_host.rstrip("/")
response = await make_api_request(method="POST", url=f"{host_url}/tenant/mount/credentials")
return MountCredentials.model_validate(response.json())
except Exception as e:
raise BisyncError(f"Failed to generate credentials: {e}") from e
async def fetch_cloud_projects() -> CloudProjectList:
"""Fetch list of projects from cloud API.
Returns:
CloudProjectList with projects from cloud
"""
try:
config_manager = ConfigManager()
config = config_manager.config
host_url = config.cloud_host.rstrip("/")
response = await make_api_request(method="GET", url=f"{host_url}/proxy/projects/projects")
return CloudProjectList.model_validate(response.json())
except Exception as e:
raise BisyncError(f"Failed to fetch cloud projects: {e}") from e
def scan_local_directories(sync_dir: Path) -> list[str]:
"""Scan local sync directory for project folders.
Args:
sync_dir: Path to bisync directory
Returns:
List of directory names (project names)
"""
if not sync_dir.exists():
return []
directories = []
for item in sync_dir.iterdir():
if item.is_dir() and not item.name.startswith("."):
directories.append(item.name)
return directories
async def create_cloud_project(project_name: str) -> CloudProjectCreateResponse:
"""Create a new project on cloud.
Args:
project_name: Name of project to create
Returns:
CloudProjectCreateResponse with project details from API
"""
try:
config_manager = ConfigManager()
config = config_manager.config
host_url = config.cloud_host.rstrip("/")
# Use generate_permalink to ensure consistent naming
project_path = generate_permalink(project_name)
project_data = CloudProjectCreateRequest(
name=project_name,
path=project_path,
set_default=False,
)
response = await make_api_request(
method="POST",
url=f"{host_url}/proxy/projects/projects",
headers={"Content-Type": "application/json"},
json_data=project_data.model_dump(),
)
return CloudProjectCreateResponse.model_validate(response.json())
except Exception as e:
raise BisyncError(f"Failed to create cloud project '{project_name}': {e}") from e
def get_bisync_state_path(tenant_id: str) -> Path:
"""Get path to bisync state directory."""
return Path.home() / ".basic-memory" / "bisync-state" / tenant_id
def get_bisync_directory() -> Path:
"""Get bisync directory from config.
Returns:
Path to bisync directory (default: ~/basic-memory-cloud-sync)
"""
config_manager = ConfigManager()
config = config_manager.config
sync_dir = config.bisync_config.get("sync_dir", str(Path.home() / "basic-memory-cloud-sync"))
return Path(sync_dir).expanduser().resolve()
def validate_bisync_directory(bisync_dir: Path) -> None:
"""Validate bisync directory doesn't conflict with mount.
Raises:
BisyncError: If bisync directory conflicts with mount directory
"""
# Get fixed mount directory
mount_dir = (Path.home() / "basic-memory-cloud").resolve()
# Check if bisync dir is the same as mount dir
if bisync_dir == mount_dir:
raise BisyncError(
f"Cannot use {bisync_dir} for bisync - it's the mount directory!\n"
f"Mount and bisync must use different directories.\n\n"
f"Options:\n"
f" 1. Use default: ~/basic-memory-cloud-sync/\n"
f" 2. Specify different directory: --dir ~/my-sync-folder"
)
# Check if mount is active at this location
result = subprocess.run(["mount"], capture_output=True, text=True)
if str(bisync_dir) in result.stdout and "rclone" in result.stdout:
raise BisyncError(
f"{bisync_dir} is currently mounted via 'bm cloud mount'\n"
f"Cannot use mounted directory for bisync.\n\n"
f"Either:\n"
f" 1. Unmount first: bm cloud unmount\n"
f" 2. Use different directory for bisync"
)
def convert_bmignore_to_rclone_filters() -> Path:
"""Convert .bmignore patterns to rclone filter format.
Reads ~/.basic-memory/.bmignore (gitignore-style) and converts to
~/.basic-memory/.bmignore.rclone (rclone filter format).
Only regenerates if .bmignore has been modified since last conversion.
Returns:
Path to converted rclone filter file
"""
# Ensure .bmignore exists
create_default_bmignore()
bmignore_path = get_bmignore_path()
# Create rclone filter path: ~/.basic-memory/.bmignore -> ~/.basic-memory/.bmignore.rclone
rclone_filter_path = bmignore_path.parent / f"{bmignore_path.name}.rclone"
# Skip regeneration if rclone file is newer than bmignore
if rclone_filter_path.exists():
bmignore_mtime = bmignore_path.stat().st_mtime
rclone_mtime = rclone_filter_path.stat().st_mtime
if rclone_mtime >= bmignore_mtime:
return rclone_filter_path
# Read .bmignore patterns
patterns = []
try:
with bmignore_path.open("r", encoding="utf-8") as f:
for line in f:
line = line.strip()
# Keep comments and empty lines
if not line or line.startswith("#"):
patterns.append(line)
continue
# Convert gitignore pattern to rclone filter syntax
# gitignore: node_modules → rclone: - node_modules/**
# gitignore: *.pyc → rclone: - *.pyc
if "*" in line:
# Pattern already has wildcard, just add exclude prefix
patterns.append(f"- {line}")
else:
# Directory pattern - add /** for recursive exclude
patterns.append(f"- {line}/**")
except Exception:
# If we can't read the file, create a minimal filter
patterns = ["# Error reading .bmignore, using minimal filters", "- .git/**"]
# Write rclone filter file
rclone_filter_path.write_text("\n".join(patterns) + "\n")
return rclone_filter_path
def get_bisync_filter_path() -> Path:
"""Get path to bisync filter file.
Uses ~/.basic-memory/.bmignore (converted to rclone format).
The file is automatically created with default patterns on first use.
Returns:
Path to rclone filter file
"""
return convert_bmignore_to_rclone_filters()
def bisync_state_exists(tenant_id: str) -> bool:
"""Check if bisync state exists (has been initialized)."""
state_path = get_bisync_state_path(tenant_id)
return state_path.exists() and any(state_path.iterdir())
def build_bisync_command(
tenant_id: str,
bucket_name: str,
local_path: Path,
profile: RcloneBisyncProfile,
dry_run: bool = False,
resync: bool = False,
verbose: bool = False,
) -> list[str]:
"""Build rclone bisync command with profile settings."""
# Sync with the entire bucket root (all projects)
rclone_remote = f"basic-memory-{tenant_id}:{bucket_name}"
filter_path = get_bisync_filter_path()
state_path = get_bisync_state_path(tenant_id)
# Ensure state directory exists
state_path.mkdir(parents=True, exist_ok=True)
cmd = [
"rclone",
"bisync",
str(local_path),
rclone_remote,
"--create-empty-src-dirs",
"--resilient",
f"--conflict-resolve={profile.conflict_resolve}",
f"--max-delete={profile.max_delete}",
"--filters-file",
str(filter_path),
"--workdir",
str(state_path),
]
# Add verbosity flags
if verbose:
cmd.append("--verbose") # Full details with file-by-file output
else:
# Show progress bar during transfers
cmd.append("--progress")
if profile.check_access:
cmd.append("--check-access")
if dry_run:
cmd.append("--dry-run")
if resync:
cmd.append("--resync")
cmd.extend(profile.extra_args)
return cmd
def setup_cloud_bisync(sync_dir: Optional[str] = None) -> None:
"""Set up cloud bisync with rclone installation and configuration.
Args:
sync_dir: Optional custom sync directory path. If not provided, uses config default.
"""
console.print("[bold blue]Basic Memory Cloud Bisync Setup[/bold blue]")
console.print("Setting up bidirectional sync to your cloud tenant...\n")
try:
# Step 1: Install rclone
console.print("[blue]Step 1: Installing rclone...[/blue]")
install_rclone()
# Step 2: Get mount info (for tenant_id, bucket)
console.print("\n[blue]Step 2: Getting tenant information...[/blue]")
tenant_info = asyncio.run(get_mount_info())
tenant_id = tenant_info.tenant_id
bucket_name = tenant_info.bucket_name
console.print(f"[green]✓ Found tenant: {tenant_id}[/green]")
console.print(f"[green]✓ Bucket: {bucket_name}[/green]")
# Step 3: Generate credentials
console.print("\n[blue]Step 3: Generating sync credentials...[/blue]")
creds = asyncio.run(generate_mount_credentials(tenant_id))
access_key = creds.access_key
secret_key = creds.secret_key
console.print("[green]✓ Generated secure credentials[/green]")
# Step 4: Configure rclone
console.print("\n[blue]Step 4: Configuring rclone...[/blue]")
add_tenant_to_rclone_config(
tenant_id=tenant_id,
bucket_name=bucket_name,
access_key=access_key,
secret_key=secret_key,
)
# Step 5: Configure and create local directory
console.print("\n[blue]Step 5: Configuring sync directory...[/blue]")
# If custom sync_dir provided, save to config
if sync_dir:
config_manager = ConfigManager()
config = config_manager.load_config()
config.bisync_config["sync_dir"] = sync_dir
config_manager.save_config(config)
console.print("[green]✓ Saved custom sync directory to config[/green]")
# Get bisync directory (from config or default)
local_path = get_bisync_directory()
# Validate bisync directory
validate_bisync_directory(local_path)
# Create directory
local_path.mkdir(parents=True, exist_ok=True)
console.print(f"[green]✓ Created sync directory: {local_path}[/green]")
# Step 6: Perform initial resync
console.print("\n[blue]Step 6: Performing initial sync...[/blue]")
console.print("[yellow]This will establish the baseline for bidirectional sync.[/yellow]")
run_bisync(
tenant_id=tenant_id,
bucket_name=bucket_name,
local_path=local_path,
profile_name="balanced",
resync=True,
)
console.print("\n[bold green]✓ Bisync setup completed successfully![/bold green]")
console.print("\nYour local files will now sync bidirectionally with the cloud!")
console.print(f"\nLocal directory: {local_path}")
console.print("\nUseful commands:")
console.print(" bm sync # Run sync (recommended)")
console.print(" bm sync --watch # Start watch mode")
console.print(" bm cloud status # Check sync status")
console.print(" bm cloud check # Verify file integrity")
console.print(" bm cloud bisync --dry-run # Preview changes (advanced)")
except (RcloneInstallError, BisyncError, CloudAPIError) as e:
console.print(f"\n[red]Setup failed: {e}[/red]")
raise typer.Exit(1)
except Exception as e:
console.print(f"\n[red]Unexpected error during setup: {e}[/red]")
raise typer.Exit(1)
def run_bisync(
tenant_id: Optional[str] = None,
bucket_name: Optional[str] = None,
local_path: Optional[Path] = None,
profile_name: str = "balanced",
dry_run: bool = False,
resync: bool = False,
verbose: bool = False,
) -> bool:
"""Run rclone bisync with specified profile."""
try:
# Get tenant info if not provided
if not tenant_id or not bucket_name:
tenant_info = asyncio.run(get_mount_info())
tenant_id = tenant_info.tenant_id
bucket_name = tenant_info.bucket_name
# Set default local path if not provided
if not local_path:
local_path = get_bisync_directory()
# Validate bisync directory
validate_bisync_directory(local_path)
# Check if local path exists
if not local_path.exists():
raise BisyncError(
f"Local directory {local_path} does not exist. Run 'basic-memory cloud bisync-setup' first."
)
# Get bisync profile
if profile_name not in BISYNC_PROFILES:
raise BisyncError(
f"Unknown profile: {profile_name}. Available: {list(BISYNC_PROFILES.keys())}"
)
profile = BISYNC_PROFILES[profile_name]
# Auto-register projects before sync (unless dry-run or resync)
if not dry_run and not resync:
try:
console.print("[dim]Checking for new projects...[/dim]")
# Fetch cloud projects and extract directory names from paths
cloud_data = asyncio.run(fetch_cloud_projects())
cloud_projects = cloud_data.projects
# Extract directory names from cloud project paths
# Compare directory names, not project names
# Cloud path /app/data/basic-memory -> directory name "basic-memory"
cloud_dir_names = set()
for p in cloud_projects:
path = p.path
# Strip /app/data/ prefix if present (cloud mode)
if path.startswith("/app/data/"):
path = path[len("/app/data/") :]
# Get the last segment (directory name)
dir_name = Path(path).name
cloud_dir_names.add(dir_name)
# Scan local directories
local_dirs = scan_local_directories(local_path)
# Create missing cloud projects
new_projects = []
for dir_name in local_dirs:
if dir_name not in cloud_dir_names:
new_projects.append(dir_name)
if new_projects:
console.print(
f"[blue]Found {len(new_projects)} new local project(s), creating on cloud...[/blue]"
)
for project_name in new_projects:
try:
asyncio.run(create_cloud_project(project_name))
console.print(f"[green] ✓ Created project: {project_name}[/green]")
except BisyncError as e:
console.print(
f"[yellow] ⚠ Could not create {project_name}: {e}[/yellow]"
)
else:
console.print("[dim]All local projects already registered on cloud[/dim]")
except Exception as e:
console.print(f"[yellow]Warning: Project auto-registration failed: {e}[/yellow]")
console.print("[yellow]Continuing with sync anyway...[/yellow]")
# Check if first run and require resync
if not resync and not bisync_state_exists(tenant_id) and not dry_run:
raise BisyncError(
"First bisync requires --resync to establish baseline. "
"Run: basic-memory cloud bisync --resync"
)
# Build and execute bisync command
bisync_cmd = build_bisync_command(
tenant_id,
bucket_name,
local_path,
profile,
dry_run=dry_run,
resync=resync,
verbose=verbose,
)
if dry_run:
console.print("[yellow]DRY RUN MODE - No changes will be made[/yellow]")
console.print(
f"[blue]Running bisync with profile '{profile_name}' ({profile.description})...[/blue]"
)
console.print(f"[dim]Command: {' '.join(bisync_cmd)}[/dim]")
console.print() # Blank line before output
# Stream output in real-time so user sees progress
result = subprocess.run(bisync_cmd, text=True)
if result.returncode != 0:
raise BisyncError(f"Bisync command failed with code {result.returncode}")
console.print() # Blank line after output
if dry_run:
console.print("[green]✓ Dry run completed successfully[/green]")
elif resync:
console.print("[green]✓ Initial sync baseline established[/green]")
else:
console.print("[green]✓ Sync completed successfully[/green]")
# Notify container to refresh cache (if not dry run)
if not dry_run:
try:
asyncio.run(notify_container_sync(tenant_id))
except Exception as e:
console.print(f"[yellow]Warning: Could not notify container: {e}[/yellow]")
return True
except BisyncError:
raise
except Exception as e:
raise BisyncError(f"Unexpected error during bisync: {e}") from e
async def notify_container_sync(tenant_id: str) -> None:
"""Sync all projects after bisync completes."""
try:
from basic_memory.cli.commands.command_utils import run_sync
# Fetch all projects and sync each one
cloud_data = await fetch_cloud_projects()
projects = cloud_data.projects
if not projects:
console.print("[dim]No projects to sync[/dim]")
return
console.print(f"[blue]Notifying cloud to index {len(projects)} project(s)...[/blue]")
for project in projects:
project_name = project.name
if project_name:
try:
await run_sync(project=project_name)
except Exception as e:
# Non-critical, log and continue
console.print(f"[yellow] ⚠ Sync failed for {project_name}: {e}[/yellow]")
console.print("[dim]Note: Cloud indexing has started and may take a few moments[/dim]")
except Exception as e:
# Non-critical, don't fail the bisync
console.print(f"[yellow]Warning: Post-sync failed: {e}[/yellow]")
def run_bisync_watch(
tenant_id: Optional[str] = None,
bucket_name: Optional[str] = None,
local_path: Optional[Path] = None,
profile_name: str = "balanced",
interval_seconds: int = 60,
) -> None:
"""Run bisync in watch mode with periodic syncs."""
console.print("[bold blue]Starting bisync watch mode[/bold blue]")
console.print(f"Sync interval: {interval_seconds} seconds")
console.print("Press Ctrl+C to stop\n")
try:
while True:
try:
start_time = time.time()
run_bisync(
tenant_id=tenant_id,
bucket_name=bucket_name,
local_path=local_path,
profile_name=profile_name,
)
elapsed = time.time() - start_time
console.print(f"[dim]Sync completed in {elapsed:.1f}s[/dim]")
# Wait for next interval
time.sleep(interval_seconds)
except BisyncError as e:
console.print(f"[red]Sync error: {e}[/red]")
console.print(f"[yellow]Retrying in {interval_seconds} seconds...[/yellow]")
time.sleep(interval_seconds)
except KeyboardInterrupt:
console.print("\n[yellow]Watch mode stopped[/yellow]")
def show_bisync_status() -> None:
"""Show current bisync status and configuration."""
try:
# Get tenant info
tenant_info = asyncio.run(get_mount_info())
tenant_id = tenant_info.tenant_id
local_path = get_bisync_directory()
state_path = get_bisync_state_path(tenant_id)
# Create status table
table = Table(title="Cloud Bisync Status", show_header=True, header_style="bold blue")
table.add_column("Property", style="green", min_width=20)
table.add_column("Value", style="dim", min_width=30)
# Check initialization status
is_initialized = bisync_state_exists(tenant_id)
init_status = (
"[green]✓ Initialized[/green]" if is_initialized else "[red]✗ Not initialized[/red]"
)
table.add_row("Tenant ID", tenant_id)
table.add_row("Local Directory", str(local_path))
table.add_row("Status", init_status)
table.add_row("State Directory", str(state_path))
# Check for last sync info
if is_initialized:
# Look for most recent state file
state_files = list(state_path.glob("*.lst"))
if state_files:
latest = max(state_files, key=lambda p: p.stat().st_mtime)
last_sync = datetime.fromtimestamp(latest.stat().st_mtime)
table.add_row("Last Sync", last_sync.strftime("%Y-%m-%d %H:%M:%S"))
console.print(table)
# Show bisync profiles
console.print("\n[bold]Available bisync profiles:[/bold]")
for name, profile in BISYNC_PROFILES.items():
console.print(f" {name}: {profile.description}")
console.print(f" - Conflict resolution: {profile.conflict_resolve}")
console.print(f" - Max delete: {profile.max_delete} files")
console.print("\n[dim]To use a profile: bm cloud bisync --profile <name>[/dim]")
# Show setup instructions if not initialized
if not is_initialized:
console.print("\n[yellow]To initialize bisync, run:[/yellow]")
console.print(" bm cloud setup")
console.print(" or")
console.print(" bm cloud bisync --resync")
except Exception as e:
console.print(f"[red]Error getting bisync status: {e}[/red]")
raise typer.Exit(1)
def run_check(
tenant_id: Optional[str] = None,
bucket_name: Optional[str] = None,
local_path: Optional[Path] = None,
one_way: bool = False,
) -> bool:
"""Check file integrity between local and cloud using rclone check.
Args:
tenant_id: Cloud tenant ID (auto-detected if not provided)
bucket_name: S3 bucket name (auto-detected if not provided)
local_path: Local bisync directory (uses config default if not provided)
one_way: If True, only check for missing files on destination (faster)
Returns:
True if check passed (files match), False if differences found
"""
try:
# Check if rclone is installed
from basic_memory.cli.commands.cloud.rclone_installer import is_rclone_installed
if not is_rclone_installed():
raise BisyncError(
"rclone is not installed. Run 'bm cloud bisync-setup' first to set up cloud sync."
)
# Get tenant info if not provided
if not tenant_id or not bucket_name:
tenant_info = asyncio.run(get_mount_info())
tenant_id = tenant_id or tenant_info.tenant_id
bucket_name = bucket_name or tenant_info.bucket_name
# Get local path from config
if not local_path:
local_path = get_bisync_directory()
# Check if bisync is initialized
if not bisync_state_exists(tenant_id):
raise BisyncError(
"Bisync not initialized. Run 'bm cloud bisync --resync' to establish baseline."
)
# Build rclone check command
rclone_remote = f"basic-memory-{tenant_id}:{bucket_name}"
filter_path = get_bisync_filter_path()
cmd = [
"rclone",
"check",
str(local_path),
rclone_remote,
"--filter-from",
str(filter_path),
]
if one_way:
cmd.append("--one-way")
console.print("[bold blue]Checking file integrity between local and cloud[/bold blue]")
console.print(f"[dim]Local: {local_path}[/dim]")
console.print(f"[dim]Remote: {rclone_remote}[/dim]")
console.print(f"[dim]Command: {' '.join(cmd)}[/dim]")
console.print()
# Run check command
result = subprocess.run(cmd, capture_output=True, text=True)
# rclone check returns:
# 0 = success (all files match)
# non-zero = differences found or error
if result.returncode == 0:
console.print("[green]✓ All files match between local and cloud[/green]")
return True
else:
console.print("[yellow]⚠ Differences found:[/yellow]")
if result.stderr:
console.print(result.stderr)
if result.stdout:
console.print(result.stdout)
console.print("\n[dim]To sync differences, run: bm sync[/dim]")
return False
except BisyncError:
raise
except Exception as e:
raise BisyncError(f"Check failed: {e}") from e
@@ -1,21 +1,18 @@
"""Core cloud commands for Basic Memory CLI."""
import asyncio
from pathlib import Path
from typing import Optional
import httpx
import typer
from rich.console import Console
from rich.table import Table
from basic_memory.cli.app import cloud_app
from basic_memory.cli.auth import CLIAuth
from basic_memory.config import ConfigManager
from basic_memory.cli.commands.cloud.api_client import (
CloudAPIError,
get_cloud_config,
make_api_request,
get_authenticated_headers,
)
from basic_memory.cli.commands.cloud.mount_commands import (
mount_cloud_files,
@@ -23,19 +20,25 @@ from basic_memory.cli.commands.cloud.mount_commands import (
show_mount_status,
unmount_cloud_files,
)
from basic_memory.cli.commands.cloud.bisync_commands import (
run_bisync,
run_bisync_watch,
run_check,
setup_cloud_bisync,
show_bisync_status,
)
from basic_memory.cli.commands.cloud.rclone_config import MOUNT_PROFILES
from basic_memory.ignore_utils import load_gitignore_patterns, should_ignore_path
from basic_memory.utils import generate_permalink
from basic_memory.cli.commands.cloud.bisync_commands import BISYNC_PROFILES
console = Console()
@cloud_app.command()
def login():
"""Authenticate with WorkOS using OAuth Device Authorization flow."""
"""Authenticate with WorkOS using OAuth Device Authorization flow and enable cloud mode."""
async def _login():
client_id, domain, _ = get_cloud_config()
client_id, domain, host_url = get_cloud_config()
auth = CLIAuth(client_id=client_id, authkit_domain=domain)
success = await auth.login()
@@ -43,283 +46,59 @@ def login():
console.print("[red]Login failed[/red]")
raise typer.Exit(1)
# Enable cloud mode after successful login
config_manager = ConfigManager()
config = config_manager.load_config()
config.cloud_mode = True
config_manager.save_config(config)
console.print("[green]✓ Cloud mode enabled[/green]")
console.print(f"[dim]All CLI commands now work against {host_url}[/dim]")
asyncio.run(_login())
# Project commands
@cloud_app.command()
def logout():
"""Disable cloud mode and return to local mode."""
project_app = typer.Typer(help="Manage Basic Memory Cloud Projects")
cloud_app.add_typer(project_app, name="project")
# Disable cloud mode
config_manager = ConfigManager()
config = config_manager.load_config()
config.cloud_mode = False
config_manager.save_config(config)
@project_app.command("list")
def list_projects() -> None:
"""List projects on the cloud instance."""
try:
# Get cloud configuration
_, _, host_url = get_cloud_config()
host_url = host_url.rstrip("/")
console.print(f"[blue]Fetching projects from {host_url}...[/blue]")
# Make API request to list projects
response = asyncio.run(
make_api_request(method="GET", url=f"{host_url}/proxy/projects/projects")
)
projects_data = response.json()
if not projects_data.get("projects"):
console.print("[yellow]No projects found on the cloud instance.[/yellow]")
return
# Create table for display
table = Table(
title="Cloud Projects", show_header=True, header_style="bold blue", min_width=60
)
table.add_column("Name", style="green", min_width=20)
table.add_column("Path", style="dim", min_width=30)
for project in projects_data["projects"]:
# Format the path for display
path = project.get("path", "")
if path.startswith("/"):
path = f"~{path}" if path.startswith(str(Path.home())) else path
table.add_row(
project.get("name", "unnamed"),
path,
)
console.print(table)
console.print(f"\n[green]Found {len(projects_data['projects'])} project(s)[/green]")
except CloudAPIError as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Unexpected error: {e}[/red]")
raise typer.Exit(1)
@project_app.command("add")
def add_project(
name: str = typer.Argument(..., help="Name of the project to add"),
set_default: bool = typer.Option(False, "--default", "-d", help="Set as default project"),
) -> None:
"""Create a new project on the cloud instance."""
# Get cloud configuration
_, _, host_url = get_cloud_config()
host_url = host_url.rstrip("/")
# Prepare headers
headers = {"Content-Type": "application/json"}
project_path = generate_permalink(name)
# Prepare project data
project_data = {
"name": name,
"path": project_path,
"set_default": set_default,
}
console.print(project_data)
try:
console.print(f"[blue]Creating project '{name}' on {host_url}...[/blue]")
# Make API request to create project
response = asyncio.run(
make_api_request(
method="POST",
url=f"{host_url}/proxy/projects/projects",
headers=headers,
json_data=project_data,
)
)
result = response.json()
console.print(f"[green]Project '{name}' created successfully![/green]")
# Display project details
if "project" in result:
project = result["project"]
console.print(f" Name: {project.get('name', name)}")
console.print(f" Path: {project.get('path', 'unknown')}")
if project.get("id"):
console.print(f" ID: {project['id']}")
except CloudAPIError as e:
console.print(f"[red]Error creating project: {e}[/red]")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Unexpected error: {e}[/red]")
raise typer.Exit(1)
@cloud_app.command("upload")
def upload_files(
project: str = typer.Argument(..., help="Project name to upload to"),
path_to_files: str = typer.Argument(..., help="Local path to files or directory to upload"),
preserve_timestamps: bool = typer.Option(
True,
"--preserve-timestamps/--no-preserve-timestamps",
help="Preserve file modification times",
),
respect_gitignore: bool = typer.Option(
True,
"--respect-gitignore/--no-gitignore",
help="Respect .gitignore patterns and skip common development artifacts",
),
) -> None:
"""Upload files to a cloud project using WebDAV."""
# Get cloud configuration
_, _, host_url = get_cloud_config()
host_url = host_url.rstrip("/")
# Validate local path
local_path = Path(path_to_files).expanduser().resolve()
if not local_path.exists():
console.print(f"[red]Error: Path '{path_to_files}' does not exist[/red]")
raise typer.Exit(1)
# Prepare headers
headers = {}
try:
# Load gitignore patterns (only if enabled)
ignore_patterns = load_gitignore_patterns(local_path) if respect_gitignore else set()
# Collect files to upload
files_to_upload = []
ignored_count = 0
if local_path.is_file():
# Single file upload - check if it should be ignored
if not respect_gitignore or not should_ignore_path(
local_path, local_path.parent, ignore_patterns
):
files_to_upload.append(local_path)
else:
ignored_count += 1
else:
# Recursively collect all files
for file_path in local_path.rglob("*"):
if file_path.is_file():
if not respect_gitignore or not should_ignore_path(
file_path, local_path, ignore_patterns
):
files_to_upload.append(file_path)
else:
ignored_count += 1
# Show summary
if ignored_count > 0 and respect_gitignore:
console.print(
f"[dim]Ignored {ignored_count} file(s) based on .gitignore and default patterns[/dim]"
)
if not files_to_upload:
console.print("[yellow]No files found to upload[/yellow]")
return
console.print(
f"[blue]Uploading {len(files_to_upload)} file(s) to project '{project}' on {host_url}...[/blue]"
)
# Upload files using WebDAV
asyncio.run(
_upload_files_webdav(
files_to_upload=files_to_upload,
local_base_path=local_path,
project=project,
host_url=host_url,
headers=headers,
preserve_timestamps=preserve_timestamps,
)
)
console.print(f"[green]Successfully uploaded {len(files_to_upload)} file(s)![/green]")
except CloudAPIError as e:
console.print(f"[red]Error uploading files: {e}[/red]")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Unexpected error: {e}[/red]")
raise typer.Exit(1)
async def _upload_files_webdav(
files_to_upload: list[Path],
local_base_path: Path,
project: str,
host_url: str,
headers: dict,
preserve_timestamps: bool,
) -> None:
"""Upload files using WebDAV protocol."""
# Get authentication headers for WebDAV uploads
auth_headers = await get_authenticated_headers()
async with httpx.AsyncClient(timeout=300.0) as client:
for file_path in files_to_upload:
# Calculate relative path for WebDAV outside try block
if local_base_path.is_file():
# Single file upload - use just the filename
relative_path = file_path.name
else:
# Directory upload - preserve structure
relative_path = file_path.relative_to(local_base_path)
try:
# WebDAV URL
webdav_url = f"{host_url}/proxy/{project}/webdav/{relative_path}"
# Prepare upload headers
upload_headers = dict(headers)
upload_headers.update(auth_headers)
# Add timestamp preservation header if requested
if preserve_timestamps:
mtime = file_path.stat().st_mtime
upload_headers["X-OC-Mtime"] = str(mtime)
# Disable compression for WebDAV as well
upload_headers.setdefault("Accept-Encoding", "identity")
# Read file content
file_content = file_path.read_bytes()
# console.print(f"[dim]Uploading {relative_path} to {webdav_url}[/dim]")
# Upload file
response = await client.put(
webdav_url, content=file_content, headers=upload_headers
)
# console.print(f"[dim]WebDAV response status: {response.status_code}[/dim]")
response.raise_for_status()
# Show file upload progress
console.print(f"{relative_path}")
except httpx.HTTPError as e:
console.print(f"{relative_path} - {e}")
if hasattr(e, "response") and e.response is not None: # pyright: ignore [reportAttributeAccessIssue]
response = e.response # type: ignore
console.print(f"[red]WebDAV Response status: {response.status_code}[/red]")
console.print(f"[red]WebDAV Response headers: {dict(response.headers)}[/red]")
raise CloudAPIError(f"Failed to upload {file_path.name}: {e}") from e
console.print("[green]✓ Cloud mode disabled[/green]")
console.print("[dim]All CLI commands now work locally[/dim]")
@cloud_app.command("status")
def status() -> None:
"""Check the status of the cloud instance."""
def status(
bisync: bool = typer.Option(
True,
"--bisync/--mount",
help="Show bisync status (default) or mount status",
),
) -> None:
"""Check cloud mode status and cloud instance health.
Shows cloud mode status, instance health, and sync/mount status.
Use --bisync (default) to show bisync status or --mount for mount status.
"""
# Check cloud mode
config_manager = ConfigManager()
config = config_manager.load_config()
console.print("[bold blue]Cloud Mode Status[/bold blue]")
if config.cloud_mode:
console.print(" Mode: [green]Cloud (enabled)[/green]")
console.print(f" Host: {config.cloud_host}")
console.print(" [dim]All CLI commands work against cloud[/dim]")
else:
console.print(" Mode: [yellow]Local (disabled)[/yellow]")
console.print(" [dim]All CLI commands work locally[/dim]")
console.print("\n[dim]To enable cloud mode, run: bm cloud login[/dim]")
return
# Get cloud configuration
_, _, host_url = get_cloud_config()
@@ -329,7 +108,7 @@ def status() -> None:
headers = {}
try:
console.print(f"[blue]Checking status of {host_url}...[/blue]")
console.print("\n[blue]Checking cloud instance health...[/blue]")
# Make API request to check health
response = asyncio.run(
@@ -348,8 +127,15 @@ def status() -> None:
if "timestamp" in health_data:
console.print(f" Timestamp: {health_data['timestamp']}")
# Show sync/mount status based on flag
console.print()
if bisync:
show_bisync_status()
else:
show_mount_status()
except CloudAPIError as e:
console.print(f"[red]Error checking status: {e}[/red]")
console.print(f"[red]Error checking cloud health: {e}[/red]")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Unexpected error: {e}[/red]")
@@ -360,9 +146,32 @@ def status() -> None:
@cloud_app.command("setup")
def setup() -> None:
"""Set up local file access with automatic rclone installation and configuration."""
setup_cloud_mount()
def setup(
bisync: bool = typer.Option(
True,
"--bisync/--mount",
help="Use bidirectional sync (recommended) or mount as network drive",
),
sync_dir: Optional[str] = typer.Option(
None,
"--dir",
help="Custom sync directory for bisync (default: ~/basic-memory-cloud-sync)",
),
) -> None:
"""Set up cloud file access with automatic rclone installation and configuration.
Default: Sets up bidirectional sync (recommended).\n
Use --mount: Sets up mount as network drive (alternative workflow).\n
Examples:\n
bm cloud setup # Setup bisync (default)\n
bm cloud setup --mount # Setup mount instead\n
bm cloud setup --dir ~/sync # Custom bisync directory\n
"""
if bisync:
setup_cloud_bisync(sync_dir=sync_dir)
else:
setup_cloud_mount()
@cloud_app.command("mount")
@@ -392,7 +201,73 @@ def unmount() -> None:
raise typer.Exit(1)
@cloud_app.command("mount-status")
def mount_status() -> None:
"""Show current mount status."""
show_mount_status()
# Bisync commands
@cloud_app.command("bisync")
def bisync(
profile: str = typer.Option(
"balanced", help=f"Bisync profile: {', '.join(BISYNC_PROFILES.keys())}"
),
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without syncing"),
resync: bool = typer.Option(False, "--resync", help="Force resync to establish new baseline"),
watch: bool = typer.Option(False, "--watch", help="Run continuous sync in watch mode"),
interval: int = typer.Option(60, "--interval", help="Sync interval in seconds for watch mode"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed sync output"),
) -> None:
"""Run bidirectional sync between local files and cloud storage.
Examples:
basic-memory cloud bisync # Manual sync with balanced profile
basic-memory cloud bisync --dry-run # Preview what would be synced
basic-memory cloud bisync --resync # Establish new baseline
basic-memory cloud bisync --watch # Continuous sync every 60s
basic-memory cloud bisync --watch --interval 30 # Continuous sync every 30s
basic-memory cloud bisync --profile safe # Use safe profile (keep conflicts)
basic-memory cloud bisync --verbose # Show detailed file sync output
"""
try:
if watch:
run_bisync_watch(profile_name=profile, interval_seconds=interval)
else:
run_bisync(profile_name=profile, dry_run=dry_run, resync=resync, verbose=verbose)
except Exception as e:
console.print(f"[red]Bisync failed: {e}[/red]")
raise typer.Exit(1)
@cloud_app.command("bisync-status")
def bisync_status() -> None:
"""Show current bisync status and configuration.
DEPRECATED: Use 'bm cloud status' instead (bisync is now the default).
"""
console.print(
"[yellow]Note: 'bisync-status' is deprecated. Use 'bm cloud status' instead.[/yellow]"
)
console.print("[dim]Showing bisync status...[/dim]\n")
show_bisync_status()
@cloud_app.command("check")
def check(
one_way: bool = typer.Option(
False,
"--one-way",
help="Only check for missing files on destination (faster)",
),
) -> None:
"""Check file integrity between local and cloud storage using rclone check.
Verifies that files match between your local bisync directory and cloud storage
without transferring any data. This is useful for validating sync integrity.
Examples:
bm cloud check # Full integrity check
bm cloud check --one-way # Faster check (missing files only)
"""
try:
run_check(one_way=one_way)
except Exception as e:
console.print(f"[red]Check failed: {e}[/red]")
raise typer.Exit(1)
@@ -107,7 +107,7 @@ def setup_cloud_mount() -> None:
# Step 5: Perform initial mount
console.print("\n[blue]Step 5: Mounting cloud files...[/blue]")
mount_path = get_default_mount_path(tenant_id)
mount_path = get_default_mount_path()
MOUNT_PROFILES["balanced"]
mount_cloud_files(
@@ -154,7 +154,7 @@ def mount_cloud_files(
# Set default mount path if not provided
if not mount_path:
mount_path = get_default_mount_path(tenant_id)
mount_path = get_default_mount_path()
# Get mount profile
if profile_name not in MOUNT_PROFILES:
@@ -215,7 +215,7 @@ def unmount_cloud_files(tenant_id: Optional[str] = None) -> None:
if not tenant_id:
raise MountError("Could not determine tenant ID")
mount_path = get_default_mount_path(tenant_id)
mount_path = get_default_mount_path()
if not is_path_mounted(mount_path):
console.print(f"[yellow]Path {mount_path} is not mounted[/yellow]")
@@ -255,7 +255,7 @@ def show_mount_status() -> None:
console.print("[red]Could not determine tenant ID[/red]")
return
mount_path = get_default_mount_path(tenant_id)
mount_path = get_default_mount_path()
# Create status table
table = Table(title="Cloud Mount Status", show_header=True, header_style="bold blue")
@@ -168,9 +168,13 @@ def remove_tenant_from_rclone_config(tenant_id: str) -> bool:
return False
def get_default_mount_path(tenant_id: str) -> Path:
"""Get default mount path for a tenant."""
return Path.home() / f"basic-memory-{tenant_id}"
def get_default_mount_path() -> Path:
"""Get default mount path (fixed location per SPEC-9).
Returns:
Fixed mount path: ~/basic-memory-cloud/
"""
return Path.home() / "basic-memory-cloud"
def build_mount_command(
@@ -0,0 +1,60 @@
"""utility functions for commands"""
from typing import Optional
from mcp.server.fastmcp.exceptions import ToolError
import typer
from rich.console import Console
from basic_memory.cli.commands.cloud import get_authenticated_headers
from basic_memory.mcp.async_client import client
from basic_memory.mcp.tools.utils import call_post, call_get
from basic_memory.mcp.project_context import get_active_project
from basic_memory.schemas import ProjectInfoResponse
console = Console()
async def run_sync(project: Optional[str] = None):
"""Run sync operation via API endpoint."""
try:
from basic_memory.config import ConfigManager
config = ConfigManager().config
auth_headers = {}
if config.cloud_mode_enabled:
auth_headers = await get_authenticated_headers()
project_item = await get_active_project(client, project, None, headers=auth_headers)
response = await call_post(
client, f"{project_item.project_url}/project/sync", headers=auth_headers
)
data = response.json()
console.print(f"[green]✓ {data['message']}[/green]")
except (ToolError, ValueError) as e:
console.print(f"[red]✗ Sync failed: {e}[/red]")
raise typer.Exit(1)
async def get_project_info(project: str):
"""Run sync operation via API endpoint."""
try:
from basic_memory.config import ConfigManager
config = ConfigManager().config
auth_headers = {}
if config.cloud_mode_enabled:
auth_headers = await get_authenticated_headers()
project_item = await get_active_project(client, project, None, headers=auth_headers)
response = await call_get(
client, f"{project_item.project_url}/project/info", headers=auth_headers
)
return ProjectInfoResponse.model_validate(response.json())
except (ToolError, ValueError) as e:
console.print(f"[red]✗ Sync failed: {e}[/red]")
raise typer.Exit(1)
+130 -132
View File
@@ -9,12 +9,13 @@ from rich.console import Console
from rich.table import Table
from basic_memory.cli.app import app
from basic_memory.mcp.resources.project_info import project_info
from basic_memory.cli.commands.cloud import get_authenticated_headers
from basic_memory.cli.commands.command_utils import get_project_info
from basic_memory.config import ConfigManager
import json
from datetime import datetime
from rich.panel import Panel
from rich.tree import Tree
from basic_memory.mcp.async_client import client
from basic_memory.mcp.tools.utils import call_get
from basic_memory.schemas.project_info import ProjectList
@@ -31,6 +32,8 @@ console = Console()
project_app = typer.Typer(help="Manage multiple Basic Memory projects")
app.add_typer(project_app, name="project")
config = ConfigManager().config
def format_path(path: str) -> str:
"""Format a path for display, using ~ for home directory."""
@@ -42,10 +45,14 @@ def format_path(path: str) -> str:
@project_app.command("list")
def list_projects() -> None:
"""List all configured projects."""
"""List all Basic Memory projects."""
# Use API to list projects
try:
response = asyncio.run(call_get(client, "/projects/projects"))
auth_headers = {}
if config.cloud_mode_enabled:
auth_headers = asyncio.run(get_authenticated_headers())
response = asyncio.run(call_get(client, "/projects/projects", headers=auth_headers))
result = ProjectList.model_validate(response.json())
table = Table(title="Basic Memory Projects")
@@ -63,42 +70,75 @@ def list_projects() -> None:
raise typer.Exit(1)
@project_app.command("add")
def add_project(
name: str = typer.Argument(..., help="Name of the project"),
path: str = typer.Argument(..., help="Path to the project directory"),
set_default: bool = typer.Option(False, "--default", help="Set as default project"),
) -> None:
"""Add a new project."""
# Resolve to absolute path
resolved_path = Path(os.path.abspath(os.path.expanduser(path))).as_posix()
if config.cloud_mode_enabled:
try:
data = {"name": name, "path": resolved_path, "set_default": set_default}
@project_app.command("add")
def add_project_cloud(
name: str = typer.Argument(..., help="Name of the project"),
set_default: bool = typer.Option(False, "--default", help="Set as default project"),
) -> None:
"""Add a new project to Basic Memory Cloud"""
response = asyncio.run(call_post(client, "/projects/projects", json=data))
result = ProjectStatusResponse.model_validate(response.json())
try:
auth_headers = asyncio.run(get_authenticated_headers())
console.print(f"[green]{result.message}[/green]")
except Exception as e:
console.print(f"[red]Error adding project: {str(e)}[/red]")
raise typer.Exit(1)
data = {"name": name, "path": generate_permalink(name), "set_default": set_default}
# Display usage hint
console.print("\nTo use this project:")
console.print(f" basic-memory --project={name} <command>")
console.print(" # or")
console.print(f" basic-memory project default {name}")
response = asyncio.run(
call_post(client, "/projects/projects", json=data, headers=auth_headers)
)
result = ProjectStatusResponse.model_validate(response.json())
console.print(f"[green]{result.message}[/green]")
except Exception as e:
console.print(f"[red]Error adding project: {str(e)}[/red]")
raise typer.Exit(1)
# Display usage hint
console.print("\nTo use this project:")
console.print(f" basic-memory --project={name} <command>")
else:
@project_app.command("add")
def add_project(
name: str = typer.Argument(..., help="Name of the project"),
path: str = typer.Argument(..., help="Path to the project directory"),
set_default: bool = typer.Option(False, "--default", help="Set as default project"),
) -> None:
"""Add a new project."""
# Resolve to absolute path
resolved_path = Path(os.path.abspath(os.path.expanduser(path))).as_posix()
try:
data = {"name": name, "path": resolved_path, "set_default": set_default}
response = asyncio.run(call_post(client, "/projects/projects", json=data))
result = ProjectStatusResponse.model_validate(response.json())
console.print(f"[green]{result.message}[/green]")
except Exception as e:
console.print(f"[red]Error adding project: {str(e)}[/red]")
raise typer.Exit(1)
# Display usage hint
console.print("\nTo use this project:")
console.print(f" basic-memory --project={name} <command>")
@project_app.command("remove")
def remove_project(
name: str = typer.Argument(..., help="Name of the project to remove"),
) -> None:
"""Remove a project from configuration."""
"""Remove a project."""
try:
auth_headers = {}
if config.cloud_mode_enabled:
auth_headers = asyncio.run(get_authenticated_headers())
project_permalink = generate_permalink(name)
response = asyncio.run(call_delete(client, f"/projects/{project_permalink}"))
response = asyncio.run(
call_delete(client, f"/projects/{project_permalink}", headers=auth_headers)
)
result = ProjectStatusResponse.model_validate(response.json())
console.print(f"[green]{result.message}[/green]")
@@ -110,102 +150,96 @@ def remove_project(
console.print("[yellow]Note: The project files have not been deleted from disk.[/yellow]")
@project_app.command("default")
def set_default_project(
name: str = typer.Argument(..., help="Name of the project to set as CLI default"),
) -> None:
"""Set the default project for CLI operations (when no --project flag is specified)."""
try:
project_permalink = generate_permalink(name)
response = asyncio.run(call_put(client, f"/projects/{project_permalink}/default"))
result = ProjectStatusResponse.model_validate(response.json())
if not config.cloud_mode_enabled:
console.print(f"[green]{result.message}[/green]")
except Exception as e:
console.print(f"[red]Error setting default project: {str(e)}[/red]")
raise typer.Exit(1)
@project_app.command("default")
def set_default_project(
name: str = typer.Argument(..., help="Name of the project to set as CLI default"),
) -> None:
"""Set the default project when 'config.default_project_mode' is set."""
try:
project_permalink = generate_permalink(name)
response = asyncio.run(call_put(client, f"/projects/{project_permalink}/default"))
result = ProjectStatusResponse.model_validate(response.json())
# The API call above updates the config file default
console.print(
f"[green]CLI commands will now use '{name}' when no --project flag is specified[/green]"
)
console.print(f"[green]{result.message}[/green]")
except Exception as e:
console.print(f"[red]Error setting default project: {str(e)}[/red]")
raise typer.Exit(1)
@project_app.command("sync-config")
def synchronize_projects() -> None:
"""Synchronize project config between configuration file and database."""
# Call the API to synchronize projects
@project_app.command("sync-config")
def synchronize_projects() -> None:
"""Synchronize project config between configuration file and database."""
# Call the API to synchronize projects
try:
response = asyncio.run(call_post(client, "/projects/config/sync"))
result = ProjectStatusResponse.model_validate(response.json())
try:
response = asyncio.run(call_post(client, "/projects/sync"))
result = ProjectStatusResponse.model_validate(response.json())
console.print(f"[green]{result.message}[/green]")
except Exception as e: # pragma: no cover
console.print(f"[red]Error synchronizing projects: {str(e)}[/red]")
raise typer.Exit(1)
console.print(f"[green]{result.message}[/green]")
except Exception as e: # pragma: no cover
console.print(f"[red]Error synchronizing projects: {str(e)}[/red]")
raise typer.Exit(1)
@project_app.command("move")
def move_project(
name: str = typer.Argument(..., help="Name of the project to move"),
new_path: str = typer.Argument(..., help="New absolute path for the project"),
) -> None:
"""Move a project to a new location."""
# Resolve to absolute path
resolved_path = Path(os.path.abspath(os.path.expanduser(new_path))).as_posix()
try:
data = {"path": resolved_path}
@project_app.command("move")
def move_project(
name: str = typer.Argument(..., help="Name of the project to move"),
new_path: str = typer.Argument(..., help="New absolute path for the project"),
) -> None:
"""Move a project to a new location."""
# Resolve to absolute path
resolved_path = Path(os.path.abspath(os.path.expanduser(new_path))).as_posix()
project_permalink = generate_permalink(name)
try:
data = {"path": resolved_path}
project_permalink = generate_permalink(name)
# TODO fix route to use ProjectPathDep
response = asyncio.run(
call_patch(client, f"/{name}/project/{project_permalink}", json=data)
)
result = ProjectStatusResponse.model_validate(response.json())
console.print(f"[green]{result.message}[/green]")
# Show important file movement reminder
console.print() # Empty line for spacing
console.print(
Panel(
"[bold red]IMPORTANT:[/bold red] Project configuration updated successfully.\n\n"
"[yellow]You must manually move your project files from the old location to:[/yellow]\n"
f"[cyan]{resolved_path}[/cyan]\n\n"
"[dim]Basic Memory has only updated the configuration - your files remain in their original location.[/dim]",
title="⚠️ Manual File Movement Required",
border_style="yellow",
expand=False,
# TODO fix route to use ProjectPathDep
response = asyncio.run(
call_patch(client, f"/{name}/project/{project_permalink}", json=data)
)
)
result = ProjectStatusResponse.model_validate(response.json())
except Exception as e:
console.print(f"[red]Error moving project: {str(e)}[/red]")
raise typer.Exit(1)
console.print(f"[green]{result.message}[/green]")
# Show important file movement reminder
console.print() # Empty line for spacing
console.print(
Panel(
"[bold red]IMPORTANT:[/bold red] Project configuration updated successfully.\n\n"
"[yellow]You must manually move your project files from the old location to:[/yellow]\n"
f"[cyan]{resolved_path}[/cyan]\n\n"
"[dim]Basic Memory has only updated the configuration - your files remain in their original location.[/dim]",
title="⚠️ Manual File Movement Required",
border_style="yellow",
expand=False,
)
)
except Exception as e:
console.print(f"[red]Error moving project: {str(e)}[/red]")
raise typer.Exit(1)
@project_app.command("info")
def display_project_info(
name: str = typer.Argument(..., help="Name of the project"),
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
):
"""Display detailed information and statistics about the current project."""
try:
# Get project info
info = asyncio.run(project_info.fn()) # type: ignore # pyright: ignore [reportAttributeAccessIssue]
info = asyncio.run(get_project_info(name))
if json_output:
# Convert to JSON and print
print(json.dumps(info.model_dump(), indent=2, default=str))
else:
# Create rich display
console = Console()
# Project configuration section
console.print(
Panel(
f"Basic Memory version: [bold green]{info.system.version}[/bold green]\n"
f"[bold]Project:[/bold] {info.project_name}\n"
f"[bold]Path:[/bold] {info.project_path}\n"
f"[bold]Default Project:[/bold] {info.default_project}\n",
@@ -275,42 +309,6 @@ def display_project_info(
console.print(recent_table)
# System status
system_tree = Tree("🖥️ System Status")
system_tree.add(f"Basic Memory version: [bold green]{info.system.version}[/bold green]")
system_tree.add(
f"Database: [cyan]{info.system.database_path}[/cyan] ([green]{info.system.database_size}[/green])"
)
# Watch status
if info.system.watch_status: # pragma: no cover
watch_branch = system_tree.add("Watch Service")
running = info.system.watch_status.get("running", False)
status_color = "green" if running else "red"
watch_branch.add(
f"Status: [bold {status_color}]{'Running' if running else 'Stopped'}[/bold {status_color}]"
)
if running:
start_time = (
datetime.fromisoformat(info.system.watch_status.get("start_time", ""))
if isinstance(info.system.watch_status.get("start_time"), str)
else info.system.watch_status.get("start_time")
)
watch_branch.add(
f"Running since: [cyan]{start_time.strftime('%Y-%m-%d %H:%M')}[/cyan]"
)
watch_branch.add(
f"Files synced: [green]{info.system.watch_status.get('synced_files', 0)}[/green]"
)
watch_branch.add(
f"Errors: [{'red' if info.system.watch_status.get('error_count', 0) > 0 else 'green'}]{info.system.watch_status.get('error_count', 0)}[/{'red' if info.system.watch_status.get('error_count', 0) > 0 else 'green'}]"
)
else:
system_tree.add("[yellow]Watch service not running[/yellow]")
console.print(system_tree)
# Available projects
projects_table = Table(title="📁 Available Projects")
projects_table.add_column("Name", style="blue")
+34 -22
View File
@@ -2,19 +2,21 @@
import asyncio
from typing import Set, Dict
from typing import Annotated, Optional
from mcp.server.fastmcp.exceptions import ToolError
import typer
from loguru import logger
from rich.console import Console
from rich.panel import Panel
from rich.tree import Tree
from basic_memory import db
from basic_memory.cli.app import app
from basic_memory.cli.commands.sync import get_sync_service
from basic_memory.config import ConfigManager, get_project_config
from basic_memory.repository import ProjectRepository
from basic_memory.sync.sync_service import SyncReport
from basic_memory.cli.commands.cloud import get_authenticated_headers
from basic_memory.mcp.async_client import client
from basic_memory.mcp.tools.utils import call_post
from basic_memory.schemas import SyncReportResponse
from basic_memory.mcp.project_context import get_active_project
# Create rich console
console = Console()
@@ -47,7 +49,7 @@ def add_files_to_tree(
branch.add(f"[{style}]{file_name}[/{style}]")
def group_changes_by_directory(changes: SyncReport) -> Dict[str, Dict[str, int]]:
def group_changes_by_directory(changes: SyncReportResponse) -> Dict[str, Dict[str, int]]:
"""Group changes by directory for summary view."""
by_dir = {}
for change_type, paths in [
@@ -87,7 +89,9 @@ def build_directory_summary(counts: Dict[str, int]) -> str:
return " ".join(parts)
def display_changes(project_name: str, title: str, changes: SyncReport, verbose: bool = False):
def display_changes(
project_name: str, title: str, changes: SyncReportResponse, verbose: bool = False
):
"""Display changes using Rich for better visualization."""
tree = Tree(f"{project_name}: {title}")
@@ -122,33 +126,41 @@ def display_changes(project_name: str, title: str, changes: SyncReport, verbose:
console.print(Panel(tree, expand=False))
async def run_status(verbose: bool = False): # pragma: no cover
async def run_status(project: Optional[str] = None, verbose: bool = False): # pragma: no cover
"""Check sync status of files vs database."""
# Check knowledge/ directory
app_config = ConfigManager().config
config = get_project_config()
try:
from basic_memory.config import ConfigManager
_, session_maker = await db.get_or_create_db(
db_path=app_config.database_path, db_type=db.DatabaseType.FILESYSTEM
)
project_repository = ProjectRepository(session_maker)
project = await project_repository.get_by_name(config.project)
if not project: # pragma: no cover
raise Exception(f"Project '{config.project}' not found")
config = ConfigManager().config
auth_headers = {}
if config.cloud_mode_enabled:
auth_headers = await get_authenticated_headers()
sync_service = await get_sync_service(project)
knowledge_changes = await sync_service.scan(config.home)
display_changes(project.name, "Status", knowledge_changes, verbose)
project_item = await get_active_project(client, project, None)
response = await call_post(
client, f"{project_item.project_url}/project/status", headers=auth_headers
)
sync_report = SyncReportResponse.model_validate(response.json())
display_changes(project_item.name, "Status", sync_report, verbose)
except (ValueError, ToolError) as e:
console.print(f"[red]✗ Error: {e}[/red]")
raise typer.Exit(1)
@app.command()
def status(
project: Annotated[
Optional[str],
typer.Option(help="The project name."),
] = None,
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed file information"),
):
"""Show sync status between files and database."""
try:
asyncio.run(run_status(verbose)) # pragma: no cover
asyncio.run(run_status(project, verbose)) # pragma: no cover
except Exception as e:
logger.error(f"Error checking status: {e}")
typer.echo(f"Error checking status: {e}", err=True)
+43 -226
View File
@@ -1,242 +1,59 @@
"""Command module for basic-memory sync operations."""
import asyncio
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
from typing import List, Dict
from typing import Annotated, Optional
import typer
from loguru import logger
from rich.console import Console
from rich.tree import Tree
from basic_memory import db
from basic_memory.cli.app import app
from basic_memory.config import ConfigManager, get_project_config
from basic_memory.markdown import EntityParser
from basic_memory.markdown.markdown_processor import MarkdownProcessor
from basic_memory.models import Project
from basic_memory.repository import (
EntityRepository,
ObservationRepository,
RelationRepository,
ProjectRepository,
)
from basic_memory.repository.search_repository import SearchRepository
from basic_memory.services import EntityService, FileService
from basic_memory.services.link_resolver import LinkResolver
from basic_memory.services.search_service import SearchService
from basic_memory.sync import SyncService
from basic_memory.sync.sync_service import SyncReport
console = Console()
@dataclass
class ValidationIssue:
file_path: str
error: str
async def get_sync_service(project: Project) -> SyncService: # pragma: no cover
"""Get sync service instance with all dependencies."""
app_config = ConfigManager().config
_, session_maker = await db.get_or_create_db(
db_path=app_config.database_path, db_type=db.DatabaseType.FILESYSTEM
)
project_path = Path(project.path)
entity_parser = EntityParser(project_path)
markdown_processor = MarkdownProcessor(entity_parser)
file_service = FileService(project_path, markdown_processor)
# Initialize repositories
entity_repository = EntityRepository(session_maker, project_id=project.id)
observation_repository = ObservationRepository(session_maker, project_id=project.id)
relation_repository = RelationRepository(session_maker, project_id=project.id)
search_repository = SearchRepository(session_maker, project_id=project.id)
# Initialize services
search_service = SearchService(search_repository, entity_repository, file_service)
link_resolver = LinkResolver(entity_repository, search_service)
# Initialize services
entity_service = EntityService(
entity_parser,
entity_repository,
observation_repository,
relation_repository,
file_service,
link_resolver,
)
# Create sync service
sync_service = SyncService(
app_config=app_config,
entity_service=entity_service,
entity_parser=entity_parser,
entity_repository=entity_repository,
relation_repository=relation_repository,
search_service=search_service,
file_service=file_service,
)
return sync_service
def group_issues_by_directory(issues: List[ValidationIssue]) -> Dict[str, List[ValidationIssue]]:
"""Group validation issues by directory."""
grouped = defaultdict(list)
for issue in issues:
dir_name = Path(issue.file_path).parent.name
grouped[dir_name].append(issue)
return dict(grouped)
def display_sync_summary(knowledge: SyncReport):
"""Display a one-line summary of sync changes."""
config = get_project_config()
total_changes = knowledge.total
project_name = config.project
if total_changes == 0:
console.print(f"[green]Project '{project_name}': Everything up to date[/green]")
return
# Format as: "Synced X files (A new, B modified, C moved, D deleted)"
changes = []
new_count = len(knowledge.new)
mod_count = len(knowledge.modified)
move_count = len(knowledge.moves)
del_count = len(knowledge.deleted)
if new_count:
changes.append(f"[green]{new_count} new[/green]")
if mod_count:
changes.append(f"[yellow]{mod_count} modified[/yellow]")
if move_count:
changes.append(f"[blue]{move_count} moved[/blue]")
if del_count:
changes.append(f"[red]{del_count} deleted[/red]")
console.print(f"Project '{project_name}': Synced {total_changes} files ({', '.join(changes)})")
def display_detailed_sync_results(knowledge: SyncReport):
"""Display detailed sync results with trees."""
config = get_project_config()
project_name = config.project
if knowledge.total == 0:
console.print(f"\n[green]Project '{project_name}': Everything up to date[/green]")
return
console.print(f"\n[bold]Sync Results for Project '{project_name}'[/bold]")
if knowledge.total > 0:
knowledge_tree = Tree("[bold]Knowledge Files[/bold]")
if knowledge.new:
created = knowledge_tree.add("[green]Created[/green]")
for path in sorted(knowledge.new):
checksum = knowledge.checksums.get(path, "")
created.add(f"[green]{path}[/green] ({checksum[:8]})")
if knowledge.modified:
modified = knowledge_tree.add("[yellow]Modified[/yellow]")
for path in sorted(knowledge.modified):
checksum = knowledge.checksums.get(path, "")
modified.add(f"[yellow]{path}[/yellow] ({checksum[:8]})")
if knowledge.moves:
moved = knowledge_tree.add("[blue]Moved[/blue]")
for old_path, new_path in sorted(knowledge.moves.items()):
checksum = knowledge.checksums.get(new_path, "")
moved.add(f"[blue]{old_path}[/blue] → [blue]{new_path}[/blue] ({checksum[:8]})")
if knowledge.deleted:
deleted = knowledge_tree.add("[red]Deleted[/red]")
for path in sorted(knowledge.deleted):
deleted.add(f"[red]{path}[/red]")
console.print(knowledge_tree)
async def run_sync(verbose: bool = False):
"""Run sync operation."""
app_config = ConfigManager().config
config = get_project_config()
_, session_maker = await db.get_or_create_db(
db_path=app_config.database_path, db_type=db.DatabaseType.FILESYSTEM
)
project_repository = ProjectRepository(session_maker)
project = await project_repository.get_by_name(config.project)
if not project: # pragma: no cover
raise Exception(f"Project '{config.project}' not found")
import time
start_time = time.time()
logger.info(
"Sync command started",
project=config.project,
verbose=verbose,
directory=str(config.home),
)
sync_service = await get_sync_service(project)
logger.info("Running one-time sync")
knowledge_changes = await sync_service.sync(config.home, project_name=project.name)
# Log results
duration_ms = int((time.time() - start_time) * 1000)
logger.info(
"Sync command completed",
project=config.project,
total_changes=knowledge_changes.total,
new_files=len(knowledge_changes.new),
modified_files=len(knowledge_changes.modified),
deleted_files=len(knowledge_changes.deleted),
moved_files=len(knowledge_changes.moves),
duration_ms=duration_ms,
)
# Display results
if verbose:
display_detailed_sync_results(knowledge_changes)
else:
display_sync_summary(knowledge_changes) # pragma: no cover
from basic_memory.cli.commands.command_utils import run_sync
from basic_memory.config import ConfigManager
@app.command()
def sync(
verbose: bool = typer.Option(
False,
"--verbose",
"-v",
help="Show detailed sync information.",
),
project: Annotated[
Optional[str],
typer.Option(help="The project name."),
] = None,
watch: Annotated[
bool,
typer.Option("--watch", help="Run continuous sync (cloud mode only)"),
] = False,
interval: Annotated[
int,
typer.Option("--interval", help="Sync interval in seconds for watch mode (default: 60)"),
] = 60,
) -> None:
"""Sync knowledge files with the database."""
config = get_project_config()
"""Sync knowledge files with the database.
try:
# Show which project we're syncing
typer.echo(f"Syncing project: {config.project}")
typer.echo(f"Project path: {config.home}")
In local mode: Scans filesystem and updates database.
In cloud mode: Runs bidirectional file sync (bisync) then updates database.
# Run sync
asyncio.run(run_sync(verbose=verbose))
Examples:
bm sync # One-time sync
bm sync --watch # Continuous sync every 60s
bm sync --watch --interval 30 # Continuous sync every 30s
"""
config = ConfigManager().config
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
logger.exception(
"Sync command failed",
f"project={config.project},"
f"error={str(e)},"
f"error_type={type(e).__name__},"
f"directory={str(config.home)}",
)
typer.echo(f"Error during sync: {e}", err=True)
if config.cloud_mode_enabled:
# Cloud mode: run bisync which includes database sync
from basic_memory.cli.commands.cloud.bisync_commands import run_bisync, run_bisync_watch
try:
if watch:
run_bisync_watch(interval_seconds=interval)
else:
run_bisync()
except Exception:
raise typer.Exit(1)
raise
else:
# Local mode: just database sync
if watch:
typer.echo(
"Error: --watch is only available in cloud mode. Run 'bm cloud login' first."
)
raise typer.Exit(1)
asyncio.run(run_sync(project))
+34 -9
View File
@@ -98,15 +98,9 @@ class BasicMemoryConfig(BaseSettings):
description="Skip expensive initialization synchronization. Useful for cloud/stateless deployments where project reconciliation is not needed.",
)
# API connection configuration
api_url: Optional[str] = Field(
default=None,
description="URL of remote Basic Memory API. If set, MCP will connect to this API instead of using local ASGI transport.",
)
# Cloud configuration
cloud_client_id: str = Field(
default="client_01K4DGBWAZWP83N3H8VVEMRX6W",
default="client_01K6KWQPW6J1M8VV7R3TZP5A6M",
description="OAuth client ID for Basic Memory Cloud",
)
@@ -116,8 +110,39 @@ class BasicMemoryConfig(BaseSettings):
)
cloud_host: str = Field(
default="https://cloud.basicmemory.com",
description="Basic Memory Cloud proxy host URL",
default_factory=lambda: os.getenv(
"BASIC_MEMORY_CLOUD_HOST", "https://cloud.basicmemory.com"
),
description="Basic Memory Cloud host URL",
)
cloud_mode: bool = Field(
default=False,
description="Enable cloud mode - all requests go to cloud instead of local (config file value)",
)
@property
def cloud_mode_enabled(self) -> bool:
"""Check if cloud mode is enabled.
Priority:
1. BASIC_MEMORY_CLOUD_MODE environment variable
2. Config file value (cloud_mode)
"""
env_value = os.environ.get("BASIC_MEMORY_CLOUD_MODE", "").lower()
if env_value in ("true", "1", "yes"):
return True
elif env_value in ("false", "0", "no"):
return False
# Fall back to config file value
return self.cloud_mode
bisync_config: Dict[str, Any] = Field(
default_factory=lambda: {
"profile": "balanced",
"sync_dir": str(Path.home() / "basic-memory-cloud-sync"),
},
description="Bisync configuration for cloud sync",
)
model_config = SettingsConfigDict(
+166 -18
View File
@@ -6,37 +6,178 @@ from typing import Set
# Common directories and patterns to ignore by default
# These are used as fallback if .bmignore doesn't exist
DEFAULT_IGNORE_PATTERNS = {
# Hidden files (files starting with dot)
".*",
# Basic Memory internal files
"memory.db",
"memory.db-shm",
"memory.db-wal",
"config.json",
# Version control
".git",
".svn",
# Python
"__pycache__",
"*.pyc",
"*.pyo",
"*.pyd",
".pytest_cache",
".coverage",
"*.egg-info",
".tox",
".mypy_cache",
".ruff_cache",
# Virtual environments
".venv",
"venv",
"env",
".env",
# Node.js
"node_modules",
"__pycache__",
".pytest_cache",
".coverage",
"*.pyc",
"*.pyo",
"*.pyd",
".DS_Store",
"Thumbs.db",
".idea",
".vscode",
"*.egg-info",
# Build artifacts
"build",
"dist",
".tox",
".cache",
".mypy_cache",
".ruff_cache",
".obsidian",
# IDE
".idea",
".vscode",
# OS files
".DS_Store",
"Thumbs.db",
"desktop.ini",
# Obsidian
".obsidian",
# Temporary files
"*.tmp",
"*.swp",
"*.swo",
"*~",
}
def get_bmignore_path() -> Path:
"""Get path to .bmignore file.
Returns:
Path to ~/.basic-memory/.bmignore
"""
return Path.home() / ".basic-memory" / ".bmignore"
def create_default_bmignore() -> None:
"""Create default .bmignore file if it doesn't exist.
This ensures users have a file they can customize for all Basic Memory operations.
"""
bmignore_path = get_bmignore_path()
if bmignore_path.exists():
return
bmignore_path.parent.mkdir(parents=True, exist_ok=True)
bmignore_path.write_text("""# Basic Memory Ignore Patterns
# This file is used by both 'bm cloud upload', 'bm cloud bisync', and file sync
# Patterns use standard gitignore-style syntax
# Hidden files (files starting with dot)
.*
# Basic Memory internal files
memory.db
memory.db-shm
memory.db-wal
config.json
# Version control
.git
.svn
# Python
__pycache__
*.pyc
*.pyo
*.pyd
.pytest_cache
.coverage
*.egg-info
.tox
.mypy_cache
.ruff_cache
# Virtual environments
.venv
venv
env
.env
# Node.js
node_modules
# Build artifacts
build
dist
.cache
# IDE
.idea
.vscode
# OS files
.DS_Store
Thumbs.db
desktop.ini
# Obsidian
.obsidian
# Temporary files
*.tmp
*.swp
*.swo
*~
""")
def load_bmignore_patterns() -> Set[str]:
"""Load patterns from .bmignore file.
Returns:
Set of patterns from .bmignore, or DEFAULT_IGNORE_PATTERNS if file doesn't exist
"""
bmignore_path = get_bmignore_path()
# Create default file if it doesn't exist
if not bmignore_path.exists():
create_default_bmignore()
patterns = set()
try:
with bmignore_path.open("r", encoding="utf-8") as f:
for line in f:
line = line.strip()
# Skip empty lines and comments
if line and not line.startswith("#"):
patterns.add(line)
except Exception:
# If we can't read .bmignore, fall back to defaults
return set(DEFAULT_IGNORE_PATTERNS)
# If no patterns were loaded, use defaults
if not patterns:
return set(DEFAULT_IGNORE_PATTERNS)
return patterns
def load_gitignore_patterns(base_path: Path) -> Set[str]:
"""Load gitignore patterns from .gitignore file and add default patterns.
"""Load gitignore patterns from .gitignore file and .bmignore.
Combines patterns from:
1. ~/.basic-memory/.bmignore (user's global ignore patterns)
2. {base_path}/.gitignore (project-specific patterns)
Args:
base_path: The base directory to search for .gitignore file
@@ -44,7 +185,8 @@ def load_gitignore_patterns(base_path: Path) -> Set[str]:
Returns:
Set of patterns to ignore
"""
patterns = set(DEFAULT_IGNORE_PATTERNS)
# Start with patterns from .bmignore
patterns = load_bmignore_patterns()
gitignore_file = base_path / ".gitignore"
if gitignore_file.exists():
@@ -109,7 +251,13 @@ def should_ignore_path(file_path: Path, base_path: Path, ignore_patterns: Set[st
if pattern in relative_path.parts:
return True
# Glob pattern match
# Check if any individual path part matches the glob pattern
# This handles cases like ".*" matching ".hidden.md" in "concept/.hidden.md"
for part in relative_path.parts:
if fnmatch.fnmatch(part, pattern):
return True
# Glob pattern match on full path
if fnmatch.fnmatch(relative_posix, pattern) or fnmatch.fnmatch(relative_str, pattern):
return True
+6 -5
View File
@@ -1,8 +1,8 @@
import os
from httpx import ASGITransport, AsyncClient, Timeout
from loguru import logger
from basic_memory.api.app import app as fastapi_app
from basic_memory.config import ConfigManager
def create_client() -> AsyncClient:
@@ -11,8 +11,8 @@ def create_client() -> AsyncClient:
Returns:
AsyncClient configured for either local ASGI or remote proxy
"""
proxy_base_url = os.getenv("BASIC_MEMORY_PROXY_URL", None)
logger.info(f"BASIC_MEMORY_PROXY_URL: {proxy_base_url}")
config_manager = ConfigManager()
config = config_manager.config
# Configure timeout for longer operations like write_note
# Default httpx timeout is 5 seconds which is too short for file operations
@@ -23,13 +23,14 @@ def create_client() -> AsyncClient:
pool=30.0, # 30 seconds for connection pool
)
if proxy_base_url:
if config.cloud_mode_enabled:
# Use HTTP transport to proxy endpoint
proxy_base_url = f"{config.cloud_host}/proxy"
logger.info(f"Creating HTTP client for proxy at: {proxy_base_url}")
return AsyncClient(base_url=proxy_base_url, timeout=timeout)
else:
# Default: use ASGI transport for local API (development mode)
logger.debug("Creating ASGI client for local Basic Memory API")
logger.info("Creating ASGI client for local Basic Memory API")
return AsyncClient(
transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout
)
+37 -17
View File
@@ -5,24 +5,30 @@ Handles project validation and context management in one place.
"""
import os
from typing import Optional
from typing import Optional, List
from httpx import AsyncClient
from httpx._types import (
HeaderTypes,
)
from loguru import logger
from fastmcp import Context
from basic_memory.config import ConfigManager
from basic_memory.mcp.tools.utils import call_get
from basic_memory.schemas.project_info import ProjectItem
from basic_memory.schemas.project_info import ProjectItem, ProjectList
from basic_memory.utils import generate_permalink
async def resolve_project_parameter(project: Optional[str] = None) -> Optional[str]:
"""Resolve project parameter using three-tier hierarchy.
Resolution order:
1. Single Project Mode (--project cli arg, or BASIC_MEMORY_MCP_PROJECT env var) - highest priority
2. Explicit project parameter - medium priority
3. Default project if default_project_mode=true - lowest priority
if config.cloud_mode:
project is required
else:
Resolution order:
1. Single Project Mode (--project cli arg, or BASIC_MEMORY_MCP_PROJECT env var) - highest priority
2. Explicit project parameter - medium priority
3. Default project if default_project_mode=true - lowest priority
Args:
project: Optional explicit project parameter
@@ -30,6 +36,16 @@ async def resolve_project_parameter(project: Optional[str] = None) -> Optional[s
Returns:
Resolved project name or None if no resolution possible
"""
config = ConfigManager().config
# if cloud_mode, project is required
if config.cloud_mode:
if project:
logger.debug(f"project: {project}, cloud_mode: {config.cloud_mode}")
return project
else:
raise ValueError("No project specified. Project is required for cloud mode.")
# Priority 1: CLI constraint overrides everything (--project arg sets env var)
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
if constrained_project:
@@ -42,7 +58,6 @@ async def resolve_project_parameter(project: Optional[str] = None) -> Optional[s
return project
# Priority 3: Default project mode
config = ConfigManager().config
if config.default_project_mode:
logger.debug(f"Using default project from config: {config.default_project}")
return config.default_project
@@ -51,16 +66,20 @@ async def resolve_project_parameter(project: Optional[str] = None) -> Optional[s
return None
async def get_project_names(client: AsyncClient, headers: HeaderTypes | None = None) -> List[str]:
response = await call_get(client, "/projects/projects", headers=headers)
project_list = ProjectList.model_validate(response.json())
return [project.name for project in project_list.projects]
async def get_active_project(
client: AsyncClient, project: Optional[str] = None, context: Optional[Context] = None
client: AsyncClient,
project: Optional[str] = None,
context: Optional[Context] = None,
headers: HeaderTypes | None = None,
) -> ProjectItem:
"""Get and validate project, setting it in context if available.
Uses three-tier resolution:
1. CLI constraint (BASIC_MEMORY_MCP_PROJECT env var)
2. Explicit project parameter
3. Default project if default_project_mode=true
Args:
client: HTTP client for API calls
project: Optional project name (resolved using hierarchy)
@@ -73,12 +92,13 @@ async def get_active_project(
ValueError: If no project can be resolved
HTTPError: If project doesn't exist or is inaccessible
"""
# Resolve project using three-tier hierarchy
resolved_project = await resolve_project_parameter(project)
if not resolved_project:
project_names = await get_project_names(client, headers)
raise ValueError(
"No project specified. Either provide project parameter, "
"set default_project_mode=true in config, or use --project constraint."
"No project specified. "
"Either set 'default_project_mode=true' in config, or use 'project' argument.\n"
f"Available projects: {project_names}"
)
project = resolved_project
@@ -93,7 +113,7 @@ async def get_active_project(
# Validate project exists by calling API
logger.debug(f"Validating project: {project}")
permalink = generate_permalink(project)
response = await call_get(client, f"/{permalink}/project/item")
response = await call_get(client, f"/{permalink}/project/item", headers=headers)
active_project = ProjectItem.model_validate(response.json())
# Cache in context if available
@@ -222,7 +222,6 @@ async def sync_status(project: Optional[str] = None, context: Context | None = N
[
"",
"**Note**: All configured projects will be automatically synced during startup.",
"You don't need to manually switch projects - Basic Memory handles this for you.",
]
)
+6
View File
@@ -48,6 +48,10 @@ from basic_memory.schemas.directory import (
DirectoryNode,
)
from basic_memory.schemas.sync_report import (
SyncReportResponse,
)
# For convenient imports, export all models
__all__ = [
# Base
@@ -77,4 +81,6 @@ __all__ = [
"ProjectInfoResponse",
# Directory
"DirectoryNode",
# Sync
"SyncReportResponse",
]
+46
View File
@@ -0,0 +1,46 @@
"""Schemas for cloud-related API responses."""
from pydantic import BaseModel, Field
class TenantMountInfo(BaseModel):
"""Response from /tenant/mount/info endpoint."""
tenant_id: str = Field(..., description="Unique identifier for the tenant")
bucket_name: str = Field(..., description="S3 bucket name for the tenant")
class MountCredentials(BaseModel):
"""Response from /tenant/mount/credentials endpoint."""
access_key: str = Field(..., description="S3 access key for mount")
secret_key: str = Field(..., description="S3 secret key for mount")
class CloudProject(BaseModel):
"""Representation of a cloud project."""
name: str = Field(..., description="Project name")
path: str = Field(..., description="Project path on cloud")
class CloudProjectList(BaseModel):
"""Response from /proxy/projects/projects endpoint."""
projects: list[CloudProject] = Field(default_factory=list, description="List of cloud projects")
class CloudProjectCreateRequest(BaseModel):
"""Request to create a new cloud project."""
name: str = Field(..., description="Project name")
path: str = Field(..., description="Project path (permalink)")
set_default: bool = Field(default=False, description="Set as default project")
class CloudProjectCreateResponse(BaseModel):
"""Response from creating a cloud project."""
name: str = Field(..., description="Created project name")
path: str = Field(..., description="Created project path")
message: str = Field(default="", description="Success message")
+48
View File
@@ -0,0 +1,48 @@
"""Pydantic schemas for sync report responses."""
from typing import TYPE_CHECKING, Dict, Set
from pydantic import BaseModel, Field
# avoid cirular imports
if TYPE_CHECKING:
from basic_memory.sync.sync_service import SyncReport
class SyncReportResponse(BaseModel):
"""Report of file changes found compared to database state.
Used for API responses when scanning or syncing files.
"""
new: Set[str] = Field(default_factory=set, description="Files on disk but not in database")
modified: Set[str] = Field(default_factory=set, description="Files with different checksums")
deleted: Set[str] = Field(default_factory=set, description="Files in database but not on disk")
moves: Dict[str, str] = Field(
default_factory=dict, description="Files moved (old_path -> new_path)"
)
checksums: Dict[str, str] = Field(
default_factory=dict, description="Current file checksums (path -> checksum)"
)
total: int = Field(description="Total number of changes")
@classmethod
def from_sync_report(cls, report: "SyncReport") -> "SyncReportResponse":
"""Convert SyncReport dataclass to Pydantic model.
Args:
report: SyncReport dataclass from sync service
Returns:
SyncReportResponse with same data
"""
return cls(
new=report.new,
modified=report.modified,
deleted=report.deleted,
moves=report.moves,
checksums=report.checksums,
total=report.total,
)
model_config = {"from_attributes": True}
+15 -3
View File
@@ -11,7 +11,10 @@ from loguru import logger
from basic_memory import db
from basic_memory.config import BasicMemoryConfig
from basic_memory.repository import ProjectRepository
from basic_memory.models import Project
from basic_memory.repository import (
ProjectRepository,
)
async def initialize_database(app_config: BasicMemoryConfig) -> None:
@@ -102,14 +105,16 @@ async def initialize_file_sync(
active_projects = await project_repository.get_active_projects()
# Start sync for all projects as background tasks (non-blocking)
async def sync_project_background(project):
async def sync_project_background(project: Project):
"""Sync a single project in the background."""
# avoid circular imports
from basic_memory.cli.commands.sync import get_sync_service
from basic_memory.sync.sync_service import get_sync_service
logger.info(f"Starting background sync for project: {project.name}")
try:
# Create sync service
sync_service = await get_sync_service(project)
sync_dir = Path(project.path)
await sync_service.sync(sync_dir, project_name=project.name)
logger.info(f"Background sync completed successfully for project: {project.name}")
@@ -176,9 +181,16 @@ def ensure_initialization(app_config: BasicMemoryConfig) -> None:
This is a wrapper for the async initialize_app function that can be
called from synchronous code like CLI entry points.
No-op if app_config.cloud_mode == True. Cloud basic memory manages it's own projects
Args:
app_config: The Basic Memory project configuration
"""
# Skip initialization in cloud mode - cloud manages its own projects
if app_config.cloud_mode_enabled:
logger.debug("Skipping initialization in cloud mode - projects managed by cloud")
return
try:
result = asyncio.run(initialize_app(app_config))
logger.info(f"Initialization completed successfully: result={result}")
+12 -4
View File
@@ -21,6 +21,9 @@ from basic_memory.config import WATCH_STATUS_JSON, ConfigManager, get_project_co
from basic_memory.utils import generate_permalink
config = ConfigManager().config
class ProjectService:
"""Service for managing Basic Memory projects."""
@@ -96,11 +99,16 @@ class ProjectService:
Raises:
ValueError: If the project already exists
"""
if not self.repository: # pragma: no cover
raise ValueError("Repository is required for add_project")
# in cloud mode, don't allow arbitrary paths.
if config.cloud_mode:
basic_memory_home = os.getenv("BASIC_MEMORY_HOME")
assert basic_memory_home is not None
base_path = Path(basic_memory_home)
# Resolve to absolute path
resolved_path = Path(os.path.abspath(os.path.expanduser(path))).as_posix()
# Resolve to absolute path
resolved_path = Path(os.path.abspath(os.path.expanduser(base_path / path))).as_posix()
else:
resolved_path = Path(os.path.abspath(os.path.expanduser(path))).as_posix()
# First add to config file (this will validate the project doesn't exist)
project_config = self.config_manager.add_project(name, resolved_path)
+79 -9
View File
@@ -12,12 +12,16 @@ from typing import Dict, Optional, Set, Tuple
from loguru import logger
from sqlalchemy.exc import IntegrityError
from basic_memory.config import BasicMemoryConfig
from basic_memory import db
from basic_memory.config import BasicMemoryConfig, ConfigManager
from basic_memory.file_utils import has_frontmatter
from basic_memory.markdown import EntityParser
from basic_memory.models import Entity
from basic_memory.repository import EntityRepository, RelationRepository
from basic_memory.ignore_utils import load_bmignore_patterns, should_ignore_path
from basic_memory.markdown import EntityParser, MarkdownProcessor
from basic_memory.models import Entity, Project
from basic_memory.repository import EntityRepository, RelationRepository, ObservationRepository
from basic_memory.repository.search_repository import SearchRepository
from basic_memory.services import EntityService, FileService
from basic_memory.services.link_resolver import LinkResolver
from basic_memory.services.search_service import SearchService
from basic_memory.services.sync_status_service import sync_status_tracker, SyncStatus
@@ -83,6 +87,8 @@ class SyncService:
self.search_service = search_service
self.file_service = file_service
self._thread_pool = ThreadPoolExecutor(max_workers=app_config.sync_thread_pool_size)
# Load ignore patterns once at initialization for performance
self._ignore_patterns = load_bmignore_patterns()
async def _read_file_async(self, file_path: Path) -> str:
"""Read file content in thread pool to avoid blocking the event loop."""
@@ -660,17 +666,33 @@ class SyncService:
logger.debug(f"Scanning directory {directory}")
result = ScanResult()
ignored_count = 0
for root, dirnames, filenames in os.walk(str(directory)):
# Skip dot directories in-place
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
# Convert root to Path for easier manipulation
root_path = Path(root)
# Filter out ignored directories in-place
dirnames_to_remove = []
for dirname in dirnames:
dir_path = root_path / dirname
if should_ignore_path(dir_path, directory, self._ignore_patterns):
dirnames_to_remove.append(dirname)
ignored_count += 1
# Remove ignored directories from dirnames to prevent os.walk from descending
for dirname in dirnames_to_remove:
dirnames.remove(dirname)
for filename in filenames:
# Skip dot files
if filename.startswith("."):
path = root_path / filename
# Check if file should be ignored
if should_ignore_path(path, directory, self._ignore_patterns):
ignored_count += 1
logger.trace(f"Ignoring file per .bmignore: {path.relative_to(directory)}")
continue
path = Path(root) / filename
rel_path = path.relative_to(directory).as_posix()
checksum = await self._compute_checksum_async(rel_path)
result.files[rel_path] = checksum
@@ -683,7 +705,55 @@ class SyncService:
f"{directory} scan completed "
f"directory={str(directory)} "
f"files_found={len(result.files)} "
f"files_ignored={ignored_count} "
f"duration_ms={duration_ms}"
)
return result
async def get_sync_service(project: Project) -> SyncService: # pragma: no cover
"""Get sync service instance with all dependencies."""
app_config = ConfigManager().config
_, session_maker = await db.get_or_create_db(
db_path=app_config.database_path, db_type=db.DatabaseType.FILESYSTEM
)
project_path = Path(project.path)
entity_parser = EntityParser(project_path)
markdown_processor = MarkdownProcessor(entity_parser)
file_service = FileService(project_path, markdown_processor)
# Initialize repositories
entity_repository = EntityRepository(session_maker, project_id=project.id)
observation_repository = ObservationRepository(session_maker, project_id=project.id)
relation_repository = RelationRepository(session_maker, project_id=project.id)
search_repository = SearchRepository(session_maker, project_id=project.id)
# Initialize services
search_service = SearchService(search_repository, entity_repository, file_service)
link_resolver = LinkResolver(entity_repository, search_service)
# Initialize services
entity_service = EntityService(
entity_parser,
entity_repository,
observation_repository,
relation_repository,
file_service,
link_resolver,
)
# Create sync service
sync_service = SyncService(
app_config=app_config,
entity_service=entity_service,
entity_parser=entity_parser,
entity_repository=entity_repository,
relation_repository=relation_repository,
search_service=search_service,
file_service=file_service,
)
return sync_service
+3 -5
View File
@@ -15,6 +15,7 @@ from pydantic import BaseModel
from rich.console import Console
from watchfiles import awatch
from watchfiles.main import FileChange, Change
import time
class WatchEvent(BaseModel):
@@ -210,11 +211,8 @@ class WatchService:
async def handle_changes(self, project: Project, changes: Set[FileChange]) -> None:
"""Process a batch of file changes"""
import time
from typing import List, Set
# Lazily initialize sync service for project changes
from basic_memory.cli.commands.sync import get_sync_service
# avoid circular imports
from basic_memory.sync.sync_service import get_sync_service
sync_service = await get_sync_service(project)
file_service = sync_service.file_service
+11 -2
View File
@@ -331,8 +331,8 @@ def detect_potential_file_conflicts(file_path: str, existing_paths: List[str]) -
return conflicts
def validate_project_path(path: str, project_path: Path) -> bool:
"""Ensure path stays within project boundaries."""
def valid_project_path_value(path: str):
"""Ensure project path is valid."""
# Allow empty strings as they resolve to the project root
if not path:
return True
@@ -353,6 +353,15 @@ def validate_project_path(path: str, project_path: Path) -> bool:
if path.strip() and any(ord(c) < 32 and c not in [" ", "\t"] for c in path):
return False
return True
def validate_project_path(path: str, project_path: Path) -> bool:
"""Ensure path is valid and stays within project boundaries."""
if not valid_project_path_value(path):
return False
try:
resolved = (project_path / path).resolve()
return resolved.is_relative_to(project_path.resolve())