Skip to content

Commit de53e0e

Browse files
authored
feat(cli): per-workspace rclone remotes for Team push/pull (#920)
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 4128cac commit de53e0e

8 files changed

Lines changed: 502 additions & 81 deletions

File tree

src/basic_memory/cli/commands/cloud/bisync_commands.py

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,28 +32,58 @@ def _rclone_exclude_filters(pattern: str) -> list[str]:
3232
return [f"- {path_pattern}", f"- {path_pattern}/**"]
3333

3434

35-
async def get_mount_info() -> TenantMountInfo:
36-
"""Get current tenant information from cloud API."""
35+
def _workspace_id_header(workspace_id: str | None) -> dict[str, str]:
36+
"""Header that routes a /tenant/mount/* request to a specific tenant.
37+
38+
The mount endpoints resolve the workspace from X-Workspace-ID (validating
39+
membership + subscription) and fall back to the user's default tenant when
40+
it is absent — so omitting it preserves the original default-tenant behavior.
41+
"""
42+
return {"X-Workspace-ID": workspace_id} if workspace_id else {}
43+
44+
45+
async def get_mount_info(*, workspace_id: str | None = None) -> TenantMountInfo:
46+
"""Get tenant mount info (bucket name + tenant id) from the cloud API.
47+
48+
Args:
49+
workspace_id: Tenant id of the target workspace. When omitted, the API
50+
uses the authenticated user's default tenant.
51+
"""
3752
try:
3853
config_manager = ConfigManager()
3954
config = config_manager.config
4055
host_url = config.cloud_host.rstrip("/")
4156

42-
response = await make_api_request(method="GET", url=f"{host_url}/tenant/mount/info")
57+
response = await make_api_request(
58+
method="GET",
59+
url=f"{host_url}/tenant/mount/info",
60+
headers=_workspace_id_header(workspace_id),
61+
)
4362

4463
return TenantMountInfo.model_validate(response.json())
4564
except Exception as e:
4665
raise BisyncError(f"Failed to get tenant info: {e}") from e
4766

4867

4968
async def generate_mount_credentials(tenant_id: str) -> MountCredentials:
50-
"""Generate scoped credentials for syncing."""
69+
"""Generate scoped S3 credentials for syncing a specific tenant's bucket.
70+
71+
Args:
72+
tenant_id: Tenant id whose bucket-scoped credentials to mint. Routed via
73+
X-Workspace-ID so team workspaces get their own bucket's credentials.
74+
"""
5175
try:
5276
config_manager = ConfigManager()
5377
config = config_manager.config
5478
host_url = config.cloud_host.rstrip("/")
5579

56-
response = await make_api_request(method="POST", url=f"{host_url}/tenant/mount/credentials")
80+
# The mount endpoints resolve X-Workspace-ID by matching the workspace's
81+
# tenant_id, so passing a tenant_id here is the correct routing key.
82+
response = await make_api_request(
83+
method="POST",
84+
url=f"{host_url}/tenant/mount/credentials",
85+
headers=_workspace_id_header(tenant_id),
86+
)
5787

5888
return MountCredentials.model_validate(response.json())
5989
except Exception as e:

src/basic_memory/cli/commands/cloud/core_commands.py

Lines changed: 78 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,51 @@
2626
generate_mount_credentials,
2727
get_mount_info,
2828
)
29-
from basic_memory.cli.commands.cloud.rclone_config import configure_rclone_remote
29+
from basic_memory.cli.commands.cloud.rclone_config import (
30+
configure_rclone_remote,
31+
remote_name_for_workspace,
32+
)
3033
from basic_memory.cli.commands.cloud.rclone_installer import (
3134
RcloneInstallError,
3235
install_rclone,
3336
)
37+
from basic_memory.mcp.project_context import get_available_workspaces
38+
from basic_memory.schemas.cloud import (
39+
WorkspaceInfo,
40+
format_workspace_choices,
41+
format_workspace_selection_choices,
42+
workspace_matches_exact_identifier,
43+
)
3444

3545
console = Console()
3646

3747

48+
def _resolve_setup_workspace(identifier: str) -> WorkspaceInfo:
49+
"""Resolve a workspace identifier (slug, name, or tenant_id) for setup.
50+
51+
Errors with copyable choices when the identifier matches zero or multiple
52+
workspaces, so the user can disambiguate.
53+
"""
54+
workspaces = run_with_cleanup(get_available_workspaces())
55+
if not workspaces:
56+
console.print("[red]No accessible cloud workspaces found for this account[/red]")
57+
raise typer.Exit(1)
58+
59+
matches = [ws for ws in workspaces if workspace_matches_exact_identifier(ws, identifier)]
60+
if len(matches) == 1:
61+
return matches[0]
62+
63+
if not matches:
64+
console.print(f"[red]No workspace matches '{identifier}'[/red]")
65+
console.print("\nAvailable workspaces:")
66+
console.print(format_workspace_choices(workspaces))
67+
else:
68+
console.print(f"[red]'{identifier}' matches multiple workspaces[/red]")
69+
console.print("\nDisambiguate with the workspace slug or tenant_id:")
70+
console.print(format_workspace_selection_choices(matches))
71+
raise typer.Exit(1)
72+
73+
3874
@cloud_app.command()
3975
def login():
4076
"""Authenticate with WorkOS using OAuth Device Authorization flow."""
@@ -164,13 +200,23 @@ def status() -> None:
164200

165201

166202
@cloud_app.command("setup")
167-
def setup() -> None:
203+
def setup(
204+
workspace: str | None = typer.Option(
205+
None,
206+
"--workspace",
207+
help="Set up sync for a specific workspace (slug, name, or tenant_id). "
208+
"Omit for your default workspace.",
209+
),
210+
) -> None:
168211
"""Set up cloud sync by installing rclone and configuring credentials.
169212
170-
After setup, use project commands for syncing:
171-
bm project add <name> --cloud --local-path ~/projects/<name>
172-
bm project bisync --name <name> --resync # First time
173-
bm project bisync --name <name> # Subsequent syncs
213+
Run once per workspace you sync. The default workspace uses the
214+
'basic-memory-cloud' remote; other (e.g. Team) workspaces each get their own
215+
tenant-scoped remote, since Tigris credentials are bucket-scoped.
216+
217+
After setup, use the cloud sync commands:
218+
bm cloud pull --name <name> # fetch cloud changes (Team-safe)
219+
bm cloud push --name <name> # upload local changes (Team-safe)
174220
"""
175221
console.print("[bold blue]Basic Memory Cloud Setup[/bold blue]")
176222
console.print("Setting up cloud sync with rclone...\n")
@@ -180,42 +226,55 @@ def setup() -> None:
180226
console.print("[blue]Step 1: Installing rclone...[/blue]")
181227
install_rclone()
182228

183-
# Step 2: Get tenant info
229+
# --- Resolve target workspace ---
230+
# Trigger: --workspace given. Why: Tigris keys are tenant-scoped, so a
231+
# non-default workspace needs its own bucket + remote. Outcome: scope the
232+
# mount-info/credentials calls and name the remote after the workspace.
233+
if workspace is not None:
234+
target = _resolve_setup_workspace(workspace)
235+
workspace_id: str | None = target.tenant_id
236+
remote_name = remote_name_for_workspace(target.slug, is_default=target.is_default)
237+
console.print(f"[dim]Workspace: {target.name} ({target.slug})[/dim]")
238+
else:
239+
workspace_id = None # default tenant
240+
remote_name = remote_name_for_workspace(None, is_default=True)
241+
242+
# Step 2: Get tenant info (scoped to the target workspace when given)
184243
console.print("\n[blue]Step 2: Getting tenant information...[/blue]")
185-
tenant_info = run_with_cleanup(get_mount_info())
244+
tenant_info = run_with_cleanup(get_mount_info(workspace_id=workspace_id))
186245
console.print(f"[green]Found tenant: {tenant_info.tenant_id}[/green]")
187246

188-
# Step 3: Generate credentials
247+
# Step 3: Generate credentials for that tenant's bucket
189248
console.print("\n[blue]Step 3: Generating sync credentials...[/blue]")
190249
creds = run_with_cleanup(generate_mount_credentials(tenant_info.tenant_id))
191250
console.print("[green]Generated secure credentials[/green]")
192251

193-
# Step 4: Configure rclone remote
252+
# Step 4: Configure the tenant's rclone remote
194253
console.print("\n[blue]Step 4: Configuring rclone remote...[/blue]")
195254
configure_rclone_remote(
196255
access_key=creds.access_key,
197256
secret_key=creds.secret_key,
257+
remote_name=remote_name,
198258
)
199259

200260
console.print("\n[bold green]Cloud setup completed successfully![/bold green]")
201261
console.print("\n[bold]Next steps:[/bold]")
202-
console.print("1. Add a project with local sync path:")
203-
console.print(" bm project add research --cloud --local-path ~/Documents/research")
204-
console.print("\n Or configure sync for an existing project:")
262+
console.print("1. Configure sync for a project:")
205263
console.print(" bm cloud sync-setup research ~/Documents/research")
206-
console.print("\n2. Preview the initial sync (recommended):")
207-
console.print(" bm project bisync --name research --resync --dry-run")
208-
console.print("\n3. If all looks good, run the actual sync:")
209-
console.print(" bm project bisync --name research --resync")
210-
console.print("\n4. Subsequent syncs (no --resync needed):")
211-
console.print(" bm project bisync --name research")
264+
console.print("\n2. Preview a pull (recommended):")
265+
console.print(" bm cloud pull --name research --dry-run")
266+
console.print("\n3. Fetch cloud changes / upload local changes:")
267+
console.print(" bm cloud pull --name research")
268+
console.print(" bm cloud push --name research")
212269
console.print(
213270
"\n[dim]Tip: Always use --dry-run first to preview changes before syncing[/dim]"
214271
)
215272

216273
except (RcloneInstallError, BisyncError, CloudAPIError) as e:
217274
console.print(f"\n[red]Setup failed: {e}[/red]")
218275
raise typer.Exit(1)
276+
except typer.Exit:
277+
raise
219278
except Exception as e:
220279
console.print(f"\n[red]Unexpected error during setup: {e}[/red]")
221280
raise typer.Exit(1)

0 commit comments

Comments
 (0)