mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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.",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
@@ -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}
|
||||
@@ -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}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user