mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
feat(SPEC-20): Phase 5 cleanup - Remove tenant-wide sync operations
- Removed mount_commands.py (mount functionality deprecated) - Removed BISYNC_PROFILES and all tenant-wide bisync functions - Removed bisync_config from config schema - Simplified core_commands.py setup command (project-scoped workflow) - Removed top-level sync command (confusing in cloud mode, automatic in local mode) - Updated db.py to use run_sync() directly instead of sync command - Cleaned up unused imports throughout All operations now project-scoped via: bm project bisync --name <project> bm project sync --name <project> bm project check --name <project> Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
@@ -1,33 +1,16 @@
|
||||
"""Cloud bisync commands for Basic Memory CLI."""
|
||||
"""Cloud bisync utility functions for Basic Memory CLI.
|
||||
|
||||
SPEC-20: Simplified to project-scoped operations only.
|
||||
Tenant-wide bisync functions have been removed in favor of project-scoped commands.
|
||||
"""
|
||||
|
||||
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.cloud_utils import (
|
||||
create_cloud_project,
|
||||
fetch_cloud_projects,
|
||||
)
|
||||
from basic_memory.cli.commands.cloud.rclone_config import (
|
||||
configure_rclone_remote,
|
||||
)
|
||||
from basic_memory.cli.commands.cloud.rclone_installer import RcloneInstallError, install_rclone
|
||||
from basic_memory.cli.commands.cloud.api_client import make_api_request
|
||||
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,
|
||||
)
|
||||
|
||||
console = Console()
|
||||
from basic_memory.ignore_utils import create_default_bmignore, get_bmignore_path
|
||||
from basic_memory.schemas.cloud import MountCredentials, TenantMountInfo
|
||||
|
||||
|
||||
class BisyncError(Exception):
|
||||
@@ -36,52 +19,6 @@ class BisyncError(Exception):
|
||||
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:
|
||||
@@ -110,75 +47,6 @@ async def generate_mount_credentials(tenant_id: str) -> MountCredentials:
|
||||
raise BisyncError(f"Failed to generate credentials: {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
|
||||
|
||||
|
||||
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.
|
||||
|
||||
@@ -245,519 +113,3 @@ def get_bisync_filter_path() -> Path:
|
||||
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]")
|
||||
configure_rclone_remote(
|
||||
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,7 +1,6 @@
|
||||
"""Core cloud commands for Basic Memory CLI."""
|
||||
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
@@ -15,20 +14,15 @@ from basic_memory.cli.commands.cloud.api_client import (
|
||||
get_cloud_config,
|
||||
make_api_request,
|
||||
)
|
||||
# Temporarily disabled - will be removed in Phase 5
|
||||
# from basic_memory.cli.commands.cloud.mount_commands import (
|
||||
# mount_cloud_files,
|
||||
# setup_cloud_mount,
|
||||
# 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,
|
||||
BISYNC_PROFILES,
|
||||
BisyncError,
|
||||
generate_mount_credentials,
|
||||
get_mount_info,
|
||||
)
|
||||
from basic_memory.cli.commands.cloud.rclone_config import configure_rclone_remote
|
||||
from basic_memory.cli.commands.cloud.rclone_installer import (
|
||||
RcloneInstallError,
|
||||
install_rclone,
|
||||
)
|
||||
|
||||
console = Console()
|
||||
@@ -88,18 +82,8 @@ def logout():
|
||||
|
||||
|
||||
@cloud_app.command("status")
|
||||
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.
|
||||
"""
|
||||
def status() -> None:
|
||||
"""Check cloud mode status and cloud instance health."""
|
||||
# Check cloud mode
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.load_config()
|
||||
@@ -142,14 +126,7 @@ def status(
|
||||
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:
|
||||
# Temporarily disabled - will be removed in Phase 5
|
||||
console.print("[yellow]Mount status temporarily disabled[/yellow]")
|
||||
# show_mount_status()
|
||||
console.print("\n[dim]To sync projects, use: bm project bisync --name <project>[/dim]")
|
||||
|
||||
except CloudAPIError as e:
|
||||
console.print(f"[red]Error checking cloud health: {e}[/red]")
|
||||
@@ -159,138 +136,53 @@ def status(
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
# Mount commands
|
||||
|
||||
|
||||
@cloud_app.command("setup")
|
||||
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.
|
||||
def setup() -> None:
|
||||
"""Set up cloud sync by installing rclone and configuring credentials.
|
||||
|
||||
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
|
||||
SPEC-20: Simplified to project-scoped workflow.
|
||||
After setup, use project commands for syncing:
|
||||
bm project add <name> <path> --local-path ~/projects/<name>
|
||||
bm project bisync --name <name> --resync # First time
|
||||
bm project bisync --name <name> # Subsequent syncs
|
||||
"""
|
||||
if bisync:
|
||||
setup_cloud_bisync(sync_dir=sync_dir)
|
||||
else:
|
||||
# Temporarily disabled - will be removed in Phase 5
|
||||
console.print("[red]Mount setup temporarily disabled[/red]")
|
||||
# setup_cloud_mount()
|
||||
console.print("[bold blue]Basic Memory Cloud Setup[/bold blue]")
|
||||
console.print("Setting up cloud sync with rclone...\n")
|
||||
|
||||
|
||||
@cloud_app.command("mount")
|
||||
def mount(
|
||||
profile: str = typer.Option(
|
||||
"balanced", help="Mount profile: balanced, safe, or fast"
|
||||
),
|
||||
path: Optional[str] = typer.Option(
|
||||
None, help="Custom mount path (default: ~/basic-memory-{tenant-id})"
|
||||
),
|
||||
) -> None:
|
||||
"""Mount cloud files locally for editing."""
|
||||
# Temporarily disabled - will be removed in Phase 5
|
||||
console.print("[red]Mount command temporarily disabled[/red]")
|
||||
# try:
|
||||
# mount_cloud_files(profile_name=profile)
|
||||
# except Exception as e:
|
||||
# console.print(f"[red]Mount failed: {e}[/red]")
|
||||
# raise typer.Exit(1)
|
||||
|
||||
|
||||
@cloud_app.command("unmount")
|
||||
def unmount() -> None:
|
||||
"""Unmount cloud files."""
|
||||
# Temporarily disabled - will be removed in Phase 5
|
||||
console.print("[red]Unmount command temporarily disabled[/red]")
|
||||
# try:
|
||||
# unmount_cloud_files()
|
||||
# except Exception as e:
|
||||
# console.print(f"[red]Unmount failed: {e}[/red]")
|
||||
# raise typer.Exit(1)
|
||||
|
||||
|
||||
# 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]")
|
||||
# Step 1: Install rclone
|
||||
console.print("[blue]Step 1: Installing rclone...[/blue]")
|
||||
install_rclone()
|
||||
|
||||
# Step 2: Get tenant info
|
||||
console.print("\n[blue]Step 2: Getting tenant information...[/blue]")
|
||||
tenant_info = asyncio.run(get_mount_info())
|
||||
console.print(f"[green]✓ Found tenant: {tenant_info.tenant_id}[/green]")
|
||||
|
||||
# Step 3: Generate credentials
|
||||
console.print("\n[blue]Step 3: Generating sync credentials...[/blue]")
|
||||
creds = asyncio.run(generate_mount_credentials(tenant_info.tenant_id))
|
||||
console.print("[green]✓ Generated secure credentials[/green]")
|
||||
|
||||
# Step 4: Configure rclone remote
|
||||
console.print("\n[blue]Step 4: Configuring rclone remote...[/blue]")
|
||||
configure_rclone_remote(
|
||||
access_key=creds.access_key,
|
||||
secret_key=creds.secret_key,
|
||||
)
|
||||
|
||||
console.print("\n[bold green]✓ Cloud setup completed successfully![/bold green]")
|
||||
console.print("\n[bold]Next steps:[/bold]")
|
||||
console.print("1. Add a project with local sync path:")
|
||||
console.print(" bm project add research ~/projects/research --local-path ~/sync/research")
|
||||
console.print("\n2. Sync your project:")
|
||||
console.print(" bm project bisync --name research --resync # First time")
|
||||
console.print(" bm project bisync --name research # Subsequent syncs")
|
||||
console.print("\n[dim]Use 'bm project --help' for more commands[/dim]")
|
||||
|
||||
except (RcloneInstallError, BisyncError, CloudAPIError) as e:
|
||||
console.print(f"\n[red]Setup 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]")
|
||||
console.print(f"\n[red]Unexpected error during setup: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
@@ -1,295 +0,0 @@
|
||||
"""Cloud mount commands for Basic Memory CLI."""
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
import time
|
||||
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 (
|
||||
MOUNT_PROFILES,
|
||||
add_tenant_to_rclone_config,
|
||||
build_mount_command,
|
||||
cleanup_orphaned_rclone_processes,
|
||||
get_default_mount_path,
|
||||
get_rclone_processes,
|
||||
is_path_mounted,
|
||||
unmount_path,
|
||||
)
|
||||
from basic_memory.cli.commands.cloud.rclone_installer import RcloneInstallError, install_rclone
|
||||
from basic_memory.config import ConfigManager
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
class MountError(Exception):
|
||||
"""Exception raised for mount-related errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
async def get_tenant_info() -> dict:
|
||||
"""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 response.json()
|
||||
except Exception as e:
|
||||
raise MountError(f"Failed to get tenant info: {e}") from e
|
||||
|
||||
|
||||
async def generate_mount_credentials(tenant_id: str) -> dict:
|
||||
"""Generate scoped credentials for mounting."""
|
||||
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 response.json()
|
||||
except Exception as e:
|
||||
raise MountError(f"Failed to generate mount credentials: {e}") from e
|
||||
|
||||
|
||||
def setup_cloud_mount() -> None:
|
||||
"""Set up cloud mount with rclone installation and configuration."""
|
||||
console.print("[bold blue]Basic Memory Cloud Setup[/bold blue]")
|
||||
console.print("Setting up local file access to your cloud tenant...\n")
|
||||
|
||||
try:
|
||||
# Step 1: Install rclone
|
||||
console.print("[blue]Step 1: Installing rclone...[/blue]")
|
||||
install_rclone()
|
||||
|
||||
# Step 2: Get tenant info
|
||||
console.print("\n[blue]Step 2: Getting tenant information...[/blue]")
|
||||
tenant_info = asyncio.run(get_tenant_info())
|
||||
|
||||
tenant_id = tenant_info.get("tenant_id")
|
||||
bucket_name = tenant_info.get("bucket_name")
|
||||
|
||||
if not tenant_id or not bucket_name:
|
||||
raise MountError("Invalid tenant information received from cloud API")
|
||||
|
||||
console.print(f"[green]✓ Found tenant: {tenant_id}[/green]")
|
||||
console.print(f"[green]✓ Bucket: {bucket_name}[/green]")
|
||||
|
||||
# Step 3: Generate mount credentials
|
||||
console.print("\n[blue]Step 3: Generating mount credentials...[/blue]")
|
||||
creds = asyncio.run(generate_mount_credentials(tenant_id))
|
||||
|
||||
access_key = creds.get("access_key")
|
||||
secret_key = creds.get("secret_key")
|
||||
|
||||
if not access_key or not secret_key:
|
||||
raise MountError("Failed to generate mount credentials")
|
||||
|
||||
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: Perform initial mount
|
||||
console.print("\n[blue]Step 5: Mounting cloud files...[/blue]")
|
||||
mount_path = get_default_mount_path()
|
||||
MOUNT_PROFILES["balanced"]
|
||||
|
||||
mount_cloud_files(
|
||||
tenant_id=tenant_id,
|
||||
bucket_name=bucket_name,
|
||||
mount_path=mount_path,
|
||||
profile_name="balanced",
|
||||
)
|
||||
|
||||
console.print("\n[bold green]✓ Cloud setup completed successfully![/bold green]")
|
||||
console.print("\nYour cloud files are now accessible at:")
|
||||
console.print(f" {mount_path}")
|
||||
console.print("\nYou can now edit files locally and they will sync to the cloud!")
|
||||
console.print("\nUseful commands:")
|
||||
console.print(" basic-memory cloud mount-status # Check mount status")
|
||||
console.print(" basic-memory cloud unmount # Unmount files")
|
||||
console.print(" basic-memory cloud mount --profile fast # Remount with faster sync")
|
||||
|
||||
except (RcloneInstallError, MountError, 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 mount_cloud_files(
|
||||
tenant_id: Optional[str] = None,
|
||||
bucket_name: Optional[str] = None,
|
||||
mount_path: Optional[Path] = None,
|
||||
profile_name: str = "balanced",
|
||||
) -> None:
|
||||
"""Mount cloud files with specified profile."""
|
||||
|
||||
try:
|
||||
# Get tenant info if not provided
|
||||
if not tenant_id or not bucket_name:
|
||||
tenant_info = asyncio.run(get_tenant_info())
|
||||
tenant_id = tenant_info.get("tenant_id")
|
||||
bucket_name = tenant_info.get("bucket_name")
|
||||
|
||||
if not tenant_id or not bucket_name:
|
||||
raise MountError("Could not determine tenant information")
|
||||
|
||||
# Set default mount path if not provided
|
||||
if not mount_path:
|
||||
mount_path = get_default_mount_path()
|
||||
|
||||
# Get mount profile
|
||||
if profile_name not in MOUNT_PROFILES:
|
||||
raise MountError(
|
||||
f"Unknown profile: {profile_name}. Available: {list(MOUNT_PROFILES.keys())}"
|
||||
)
|
||||
|
||||
profile = MOUNT_PROFILES[profile_name]
|
||||
|
||||
# Check if already mounted
|
||||
if is_path_mounted(mount_path):
|
||||
console.print(f"[yellow]Path {mount_path} is already mounted[/yellow]")
|
||||
console.print("Use 'basic-memory cloud unmount' first, or mount to a different path")
|
||||
return
|
||||
|
||||
# Create mount directory
|
||||
mount_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Build and execute mount command
|
||||
mount_cmd = build_mount_command(tenant_id, bucket_name, mount_path, profile)
|
||||
|
||||
console.print(
|
||||
f"[blue]Mounting with profile '{profile_name}' ({profile.description})...[/blue]"
|
||||
)
|
||||
console.print(f"[dim]Command: {' '.join(mount_cmd)}[/dim]")
|
||||
|
||||
result = subprocess.run(mount_cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
error_msg = result.stderr or "Unknown error"
|
||||
raise MountError(f"Mount command failed: {error_msg}")
|
||||
|
||||
# Wait a moment for mount to establish
|
||||
time.sleep(2)
|
||||
|
||||
# Verify mount
|
||||
if is_path_mounted(mount_path):
|
||||
console.print(f"[green]✓ Successfully mounted to {mount_path}[/green]")
|
||||
console.print(f"[green]✓ Sync profile: {profile.description}[/green]")
|
||||
else:
|
||||
raise MountError("Mount command succeeded but path is not mounted")
|
||||
|
||||
except MountError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise MountError(f"Unexpected error during mount: {e}") from e
|
||||
|
||||
|
||||
def unmount_cloud_files(tenant_id: Optional[str] = None) -> None:
|
||||
"""Unmount cloud files."""
|
||||
|
||||
try:
|
||||
# Get tenant info if not provided
|
||||
if not tenant_id:
|
||||
tenant_info = asyncio.run(get_tenant_info())
|
||||
tenant_id = tenant_info.get("tenant_id")
|
||||
|
||||
if not tenant_id:
|
||||
raise MountError("Could not determine 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]")
|
||||
return
|
||||
|
||||
console.print(f"[blue]Unmounting {mount_path}...[/blue]")
|
||||
|
||||
# Unmount the path
|
||||
if unmount_path(mount_path):
|
||||
console.print(f"[green]✓ Successfully unmounted {mount_path}[/green]")
|
||||
|
||||
# Clean up any orphaned rclone processes
|
||||
killed_count = cleanup_orphaned_rclone_processes()
|
||||
if killed_count > 0:
|
||||
console.print(
|
||||
f"[green]✓ Cleaned up {killed_count} orphaned rclone process(es)[/green]"
|
||||
)
|
||||
else:
|
||||
console.print(f"[red]✗ Failed to unmount {mount_path}[/red]")
|
||||
console.print("You may need to manually unmount or restart your system")
|
||||
|
||||
except MountError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise MountError(f"Unexpected error during unmount: {e}") from e
|
||||
|
||||
|
||||
def show_mount_status() -> None:
|
||||
"""Show current mount status and running processes."""
|
||||
|
||||
try:
|
||||
# Get tenant info
|
||||
tenant_info = asyncio.run(get_tenant_info())
|
||||
tenant_id = tenant_info.get("tenant_id")
|
||||
|
||||
if not tenant_id:
|
||||
console.print("[red]Could not determine tenant ID[/red]")
|
||||
return
|
||||
|
||||
mount_path = get_default_mount_path()
|
||||
|
||||
# Create status table
|
||||
table = Table(title="Cloud Mount Status", show_header=True, header_style="bold blue")
|
||||
table.add_column("Property", style="green", min_width=15)
|
||||
table.add_column("Value", style="dim", min_width=30)
|
||||
|
||||
# Check mount status
|
||||
is_mounted = is_path_mounted(mount_path)
|
||||
mount_status = "[green]✓ Mounted[/green]" if is_mounted else "[red]✗ Not mounted[/red]"
|
||||
|
||||
table.add_row("Tenant ID", tenant_id)
|
||||
table.add_row("Mount Path", str(mount_path))
|
||||
table.add_row("Status", mount_status)
|
||||
|
||||
# Get rclone processes
|
||||
processes = get_rclone_processes()
|
||||
if processes:
|
||||
table.add_row("rclone Processes", f"{len(processes)} running")
|
||||
else:
|
||||
table.add_row("rclone Processes", "None")
|
||||
|
||||
console.print(table)
|
||||
|
||||
# Show running processes details
|
||||
if processes:
|
||||
console.print("\n[bold]Running rclone processes:[/bold]")
|
||||
for proc in processes:
|
||||
console.print(f" PID {proc['pid']}: {proc['command'][:80]}...")
|
||||
|
||||
# Show mount profiles
|
||||
console.print("\n[bold]Available mount profiles:[/bold]")
|
||||
for name, profile in MOUNT_PROFILES.items():
|
||||
console.print(f" {name}: {profile.description}")
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error getting mount status: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
@@ -37,8 +37,8 @@ def reset(
|
||||
logger.info("Database reset complete")
|
||||
|
||||
if reindex:
|
||||
# Import and run sync
|
||||
from basic_memory.cli.commands.sync import sync
|
||||
# Run database sync directly
|
||||
from basic_memory.cli.commands.command_utils import run_sync
|
||||
|
||||
logger.info("Rebuilding search index from filesystem...")
|
||||
sync(watch=False) # pyright: ignore
|
||||
asyncio.run(run_sync(project=None))
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
"""Command module for basic-memory sync operations."""
|
||||
|
||||
import asyncio
|
||||
from typing import Annotated, Optional
|
||||
|
||||
import typer
|
||||
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.cli.commands.command_utils import run_sync
|
||||
from basic_memory.config import ConfigManager
|
||||
|
||||
|
||||
@app.command()
|
||||
def sync(
|
||||
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.
|
||||
|
||||
In local mode: Scans filesystem and updates database.
|
||||
In cloud mode: Runs bidirectional file sync (bisync) then updates database.
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
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))
|
||||
@@ -176,14 +176,6 @@ class BasicMemoryConfig(BaseSettings):
|
||||
# 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(
|
||||
env_prefix="BASIC_MEMORY_",
|
||||
extra="ignore",
|
||||
|
||||
Reference in New Issue
Block a user