|
| 1 | +"""Restore CLI commands for Basic Memory Cloud. |
| 2 | +
|
| 3 | +SPEC-29 Phase 3: CLI commands for restoring files from Tigris bucket snapshots. |
| 4 | +""" |
| 5 | + |
| 6 | +import asyncio |
| 7 | + |
| 8 | +import typer |
| 9 | +from rich.console import Console |
| 10 | + |
| 11 | +from basic_memory.cli.app import cloud_app |
| 12 | +from basic_memory.cli.commands.cloud.api_client import ( |
| 13 | + CloudAPIError, |
| 14 | + SubscriptionRequiredError, |
| 15 | + make_api_request, |
| 16 | +) |
| 17 | +from basic_memory.cli.commands.cloud.schemas import BucketSnapshotBrowseResponse |
| 18 | +from basic_memory.config import ConfigManager |
| 19 | + |
| 20 | +console = Console() |
| 21 | + |
| 22 | + |
| 23 | +@cloud_app.command("restore") |
| 24 | +def restore( |
| 25 | + path: str = typer.Argument( |
| 26 | + ..., |
| 27 | + help="Path to restore (file or folder, e.g., 'notes/project.md' or 'research/')", |
| 28 | + ), |
| 29 | + snapshot_id: str = typer.Option( |
| 30 | + ..., |
| 31 | + "--snapshot", |
| 32 | + "-s", |
| 33 | + help="ID of the snapshot to restore from", |
| 34 | + ), |
| 35 | + force: bool = typer.Option( |
| 36 | + False, |
| 37 | + "--force", |
| 38 | + "-f", |
| 39 | + help="Skip confirmation prompt", |
| 40 | + ), |
| 41 | +) -> None: |
| 42 | + """Restore a file or folder from a snapshot. |
| 43 | +
|
| 44 | + This command restores files from a previous snapshot to the current bucket. |
| 45 | + The restored files will overwrite any existing files at the same path. |
| 46 | +
|
| 47 | + Examples: |
| 48 | + bm cloud restore notes/project.md --snapshot abc123 |
| 49 | + bm cloud restore research/ --snapshot abc123 |
| 50 | + bm cloud restore notes/project.md --snapshot abc123 --force |
| 51 | + """ |
| 52 | + |
| 53 | + async def _restore(): |
| 54 | + try: |
| 55 | + config_manager = ConfigManager() |
| 56 | + config = config_manager.config |
| 57 | + host_url = config.cloud_host.rstrip("/") |
| 58 | + |
| 59 | + # Normalize path - remove leading slash if present |
| 60 | + normalized_path = path.lstrip("/") |
| 61 | + |
| 62 | + if not force: |
| 63 | + # Show what will be restored |
| 64 | + console.print(f"[blue]Preparing to restore from snapshot {snapshot_id}[/blue]") |
| 65 | + console.print(f" Path: {normalized_path}") |
| 66 | + |
| 67 | + # Try to browse the snapshot to show what files will be affected |
| 68 | + try: |
| 69 | + browse_url = f"{host_url}/api/bucket-snapshots/{snapshot_id}/browse" |
| 70 | + if normalized_path: |
| 71 | + browse_url += f"?prefix={normalized_path}" |
| 72 | + |
| 73 | + response = await make_api_request( |
| 74 | + method="GET", |
| 75 | + url=browse_url, |
| 76 | + ) |
| 77 | + browse_response = BucketSnapshotBrowseResponse.model_validate(response.json()) |
| 78 | + |
| 79 | + if browse_response.files: |
| 80 | + if len(browse_response.files) <= 10: |
| 81 | + console.print("\n Files to restore:") |
| 82 | + for file_info in browse_response.files: |
| 83 | + console.print(f" - {file_info.key}") |
| 84 | + else: |
| 85 | + console.print( |
| 86 | + f"\n {len(browse_response.files)} files will be restored" |
| 87 | + ) |
| 88 | + console.print(" First 5 files:") |
| 89 | + for file_info in browse_response.files[:5]: |
| 90 | + console.print(f" - {file_info.key}") |
| 91 | + console.print(f" ... and {len(browse_response.files) - 5} more") |
| 92 | + else: |
| 93 | + console.print( |
| 94 | + f"\n[yellow]No files found matching '{normalized_path}' " |
| 95 | + f"in snapshot[/yellow]" |
| 96 | + ) |
| 97 | + raise typer.Exit(0) |
| 98 | + |
| 99 | + except CloudAPIError as browse_error: |
| 100 | + if browse_error.status_code == 404: |
| 101 | + console.print(f"[red]Snapshot not found: {snapshot_id}[/red]") |
| 102 | + raise typer.Exit(1) |
| 103 | + # If browse fails for other reasons, proceed with confirmation anyway |
| 104 | + pass |
| 105 | + |
| 106 | + console.print( |
| 107 | + "\n[yellow]Warning: Restored files will overwrite existing files![/yellow]" |
| 108 | + ) |
| 109 | + confirmed = typer.confirm("\nProceed with restore?") |
| 110 | + if not confirmed: |
| 111 | + console.print("[yellow]Restore cancelled[/yellow]") |
| 112 | + raise typer.Exit(0) |
| 113 | + |
| 114 | + console.print(f"[blue]Restoring from snapshot {snapshot_id}...[/blue]") |
| 115 | + |
| 116 | + response = await make_api_request( |
| 117 | + method="POST", |
| 118 | + url=f"{host_url}/api/bucket-snapshots/{snapshot_id}/restore", |
| 119 | + json_data={"path": normalized_path}, |
| 120 | + ) |
| 121 | + |
| 122 | + data = response.json() |
| 123 | + restored_files = data.get("restored", []) |
| 124 | + returned_snapshot_id = data.get("snapshot_id", snapshot_id) |
| 125 | + |
| 126 | + if restored_files: |
| 127 | + console.print(f"[green]Successfully restored {len(restored_files)} file(s)[/green]") |
| 128 | + if len(restored_files) <= 10: |
| 129 | + for file_path in restored_files: |
| 130 | + console.print(f" - {file_path}") |
| 131 | + else: |
| 132 | + console.print(" First 5 restored files:") |
| 133 | + for file_path in restored_files[:5]: |
| 134 | + console.print(f" - {file_path}") |
| 135 | + console.print(f" ... and {len(restored_files) - 5} more") |
| 136 | + console.print(f"\n[dim]Snapshot ID: {returned_snapshot_id}[/dim]") |
| 137 | + else: |
| 138 | + console.print("[yellow]No files were restored[/yellow]") |
| 139 | + console.print(f"[dim]No files matching '{normalized_path}' found in snapshot[/dim]") |
| 140 | + |
| 141 | + except typer.Exit: |
| 142 | + # Re-raise typer.Exit without modification - it's used for clean exits |
| 143 | + raise |
| 144 | + except SubscriptionRequiredError as e: |
| 145 | + console.print("\n[red]Subscription Required[/red]\n") |
| 146 | + console.print(f"[yellow]{e.args[0]}[/yellow]\n") |
| 147 | + console.print(f"Subscribe at: [blue underline]{e.subscribe_url}[/blue underline]\n") |
| 148 | + raise typer.Exit(1) |
| 149 | + except CloudAPIError as e: |
| 150 | + if e.status_code == 404: |
| 151 | + console.print(f"[red]Snapshot not found: {snapshot_id}[/red]") |
| 152 | + else: |
| 153 | + console.print(f"[red]Failed to restore: {e}[/red]") |
| 154 | + raise typer.Exit(1) |
| 155 | + except Exception as e: |
| 156 | + console.print(f"[red]Unexpected error: {e}[/red]") |
| 157 | + raise typer.Exit(1) |
| 158 | + |
| 159 | + asyncio.run(_restore()) |
0 commit comments