From 8bf7bdbc0dfdfea06b2ef680a96117f8000a4b68 Mon Sep 17 00:00:00 2001 From: Drew Cain Date: Wed, 27 May 2026 19:12:55 -0500 Subject: [PATCH] fix(cli): limit team workspace guard to bisync Signed-off-by: Drew Cain --- .../cli/commands/cloud/project_sync.py | 20 +++---- tests/cli/cloud/test_project_sync_command.py | 60 ++++++++++++++++--- 2 files changed, 60 insertions(+), 20 deletions(-) diff --git a/src/basic_memory/cli/commands/cloud/project_sync.py b/src/basic_memory/cli/commands/cloud/project_sync.py index 602318e9..698b8c8a 100644 --- a/src/basic_memory/cli/commands/cloud/project_sync.py +++ b/src/basic_memory/cli/commands/cloud/project_sync.py @@ -33,12 +33,9 @@ from basic_memory.utils import generate_permalink, normalize_project_path console = Console() -TEAM_WORKSPACE_SYNC_UNSUPPORTED = ( - "Mirror-style rclone sync/bisync is supported only for Personal workspaces.\n" - "Team workspaces should not use local mirror workflows because they can " - "overwrite or delete shared cloud files.\n" - "Use cloud API/MCP routing for Team workspace edits, or inspect projects with " - "`bm project list --workspace `." +TEAM_WORKSPACE_BISYNC_UNSUPPORTED = ( + "The bisync operation is only supported on Personal workspaces.\n" + "Use `bm cloud sync --name {name}` instead." ) @@ -96,7 +93,7 @@ async def _get_workspace_for_project(name: str, config: BasicMemoryConfig) -> Wo def _require_personal_workspace(name: str, config: BasicMemoryConfig) -> WorkspaceInfo: - """Exit before rclone work when the target workspace is not personal.""" + """Exit before bisync work when the target workspace is not personal.""" try: workspace = run_with_cleanup(_get_workspace_for_project(name, config)) except Exception as exc: @@ -104,7 +101,7 @@ def _require_personal_workspace(name: str, config: BasicMemoryConfig) -> Workspa raise typer.Exit(1) if workspace.workspace_type != "personal": - console.print(f"[red]{TEAM_WORKSPACE_SYNC_UNSUPPORTED}[/red]") + console.print(f"[red]{TEAM_WORKSPACE_BISYNC_UNSUPPORTED.format(name=name)}[/red]") raise typer.Exit(1) return workspace @@ -161,7 +158,6 @@ def sync_project_command( """ config = ConfigManager().config _require_cloud_credentials(config) - _require_personal_workspace(name, config) try: # Get tenant info for bucket name @@ -270,7 +266,6 @@ def check_project_command( """ config = ConfigManager().config _require_cloud_credentials(config) - _require_personal_workspace(name, config) try: # Get tenant info for bucket name @@ -353,7 +348,6 @@ def setup_project_sync( config_manager = ConfigManager() config = config_manager.config _require_cloud_credentials(config) - _require_personal_workspace(name, config) async def _verify_project_exists(): """Verify the project exists on cloud by listing all projects.""" @@ -402,8 +396,8 @@ def setup_project_sync( console.print(f"[green]Sync configured for project '{name}'[/green]") console.print(f"\nLocal sync path: {resolved_path}") console.print("\nNext steps:") - console.print(f" 1. Preview: bm cloud bisync --name {name} --resync --dry-run") - console.print(f" 2. Sync: bm cloud bisync --name {name} --resync") + console.print(f" 1. Preview: bm cloud sync --name {name} --dry-run") + console.print(f" 2. Sync: bm cloud sync --name {name}") except Exception as e: console.print(f"[red]Error configuring sync: {str(e)}[/red]") raise typer.Exit(1) diff --git a/tests/cli/cloud/test_project_sync_command.py b/tests/cli/cloud/test_project_sync_command.py index d5360fbc..c0dbcc01 100644 --- a/tests/cli/cloud/test_project_sync_command.py +++ b/tests/cli/cloud/test_project_sync_command.py @@ -99,15 +99,12 @@ def test_cloud_bisync_fails_fast_when_sync_entry_disappears(monkeypatch, config_ @pytest.mark.parametrize( "argv", [ - ["cloud", "sync", "--name", "research"], ["cloud", "bisync", "--name", "research"], - ["cloud", "check", "--name", "research"], ["cloud", "bisync-reset", "research"], - ["cloud", "sync-setup", "research", "/tmp/research"], ], ) -def test_cloud_sync_commands_block_organization_workspace(monkeypatch, argv, config_manager): - """Rclone sync commands should fail before setup/execution for Team workspaces.""" +def test_cloud_bisync_commands_block_organization_workspace(monkeypatch, argv, config_manager): + """Bisync commands should fail before setup/execution for Team workspaces.""" project_sync_command = importlib.import_module("basic_memory.cli.commands.cloud.project_sync") config = config_manager.load_config() @@ -130,13 +127,62 @@ def test_cloud_sync_commands_block_organization_workspace(monkeypatch, argv, con "get_mount_info", lambda: pytest.fail("workspace guard should run before mount lookup"), ) + monkeypatch.setattr( + project_sync_command, + "get_project_bisync_state", + lambda _name: pytest.fail("workspace guard should run before bisync state lookup"), + ) result = runner.invoke(app, argv) assert result.exit_code == 1, result.output output = " ".join(result.output.split()) - assert "Mirror-style rclone sync/bisync is supported only for Personal workspaces" in output - assert "overwrite or delete shared cloud files" in output + assert "The bisync operation is only supported on Personal workspaces" in output + assert "Use `bm cloud sync --name research` instead" in output + + +def test_cloud_sync_allows_organization_workspace(monkeypatch, config_manager): + """Team workspaces can still use the one-way sync command.""" + project_sync_command = importlib.import_module("basic_memory.cli.commands.cloud.project_sync") + + config = config_manager.load_config() + config.cloud_api_key = "bmc_test" + config.projects["research"] = ProjectEntry( + path="/tmp/research", + mode=ProjectMode.CLOUD, + workspace_id="team-tenant", + local_sync_path="/tmp/research", + ) + config_manager.save_config(config) + + monkeypatch.setattr( + project_sync_command, + "get_available_workspaces", + lambda: pytest.fail("sync should not require a personal workspace"), + ) + monkeypatch.setattr( + project_sync_command, + "get_mount_info", + lambda: _async_value(SimpleNamespace(bucket_name="tenant-bucket")), + ) + monkeypatch.setattr( + project_sync_command, + "_get_cloud_project", + lambda _name: _async_value( + SimpleNamespace(name="research", external_id="external-project-id", path="research") + ), + ) + monkeypatch.setattr( + project_sync_command, + "_get_sync_project", + lambda _name, _config, _project_data: (SimpleNamespace(name="research"), "/tmp/research"), + ) + monkeypatch.setattr(project_sync_command, "project_sync", lambda *args, **kwargs: True) + + result = runner.invoke(app, ["cloud", "sync", "--name", "research"]) + + assert result.exit_code == 0, result.output + assert "research synced successfully" in result.output def test_require_personal_workspace_allows_personal_workspace(monkeypatch, config_manager):