From 22d1a8b6c3c633e0d2c676815134663d31101cd2 Mon Sep 17 00:00:00 2001 From: phernandez Date: Tue, 28 Oct 2025 12:50:22 -0500 Subject: [PATCH] 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 bm project sync --name bm project check --name Signed-off-by: phernandez --- .../cli/commands/cloud/bisync_commands.py | 664 +----------------- .../cli/commands/cloud/core_commands.py | 214 ++---- .../cli/commands/cloud/mount_commands.py | 295 -------- src/basic_memory/cli/commands/db.py | 6 +- src/basic_memory/cli/commands/sync.py | 59 -- src/basic_memory/config.py | 8 - 6 files changed, 64 insertions(+), 1182 deletions(-) delete mode 100644 src/basic_memory/cli/commands/cloud/mount_commands.py delete mode 100644 src/basic_memory/cli/commands/sync.py diff --git a/src/basic_memory/cli/commands/cloud/bisync_commands.py b/src/basic_memory/cli/commands/cloud/bisync_commands.py index 6178a4e2..169f4bd8 100644 --- a/src/basic_memory/cli/commands/cloud/bisync_commands.py +++ b/src/basic_memory/cli/commands/cloud/bisync_commands.py @@ -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 [/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 diff --git a/src/basic_memory/cli/commands/cloud/core_commands.py b/src/basic_memory/cli/commands/cloud/core_commands.py index 5ab636fc..c2cdf18c 100644 --- a/src/basic_memory/cli/commands/cloud/core_commands.py +++ b/src/basic_memory/cli/commands/cloud/core_commands.py @@ -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 [/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 --local-path ~/projects/ + bm project bisync --name --resync # First time + bm project bisync --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) diff --git a/src/basic_memory/cli/commands/cloud/mount_commands.py b/src/basic_memory/cli/commands/cloud/mount_commands.py deleted file mode 100644 index 01e11569..00000000 --- a/src/basic_memory/cli/commands/cloud/mount_commands.py +++ /dev/null @@ -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) diff --git a/src/basic_memory/cli/commands/db.py b/src/basic_memory/cli/commands/db.py index 3457f1b0..93a421cd 100644 --- a/src/basic_memory/cli/commands/db.py +++ b/src/basic_memory/cli/commands/db.py @@ -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)) diff --git a/src/basic_memory/cli/commands/sync.py b/src/basic_memory/cli/commands/sync.py deleted file mode 100644 index 6a5557ab..00000000 --- a/src/basic_memory/cli/commands/sync.py +++ /dev/null @@ -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)) diff --git a/src/basic_memory/config.py b/src/basic_memory/config.py index fb731db9..1e380347 100644 --- a/src/basic_memory/config.py +++ b/src/basic_memory/config.py @@ -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",