diff --git a/src/workato_platform/cli/utils/config/manager.py b/src/workato_platform/cli/utils/config/manager.py index d4f8280..40c5be2 100644 --- a/src/workato_platform/cli/utils/config/manager.py +++ b/src/workato_platform/cli/utils/config/manager.py @@ -188,7 +188,32 @@ async def _setup_non_interactive( if not selected_project: raise click.ClickException("No project selected") - # Always create project subdirectory named after the project + # Check if this specific project already exists locally in the workspace + local_projects = self._find_all_projects(workspace_root) + existing_local_path = None + + for project_path_candidate, _ in local_projects: + try: + project_config_manager = ConfigManager( + project_path_candidate, skip_validation=True + ) + config_data = project_config_manager.load_config() + if config_data.project_id == selected_project.id: + existing_local_path = project_path_candidate + break + except (json.JSONDecodeError, OSError): + continue + + # In non-interactive mode, fail if project already exists + if existing_local_path: + relative_path = existing_local_path.relative_to(workspace_root) + raise click.ClickException( + f"Project '{selected_project.name}' (ID: {selected_project.id}) " + f"already exists locally at: {relative_path}. " + f"Cannot reinitialize in non-interactive mode." + ) + + # Project doesn't exist locally - create new directory current_dir = Path.cwd().resolve() project_path = current_dir / selected_project.name @@ -343,42 +368,6 @@ async def _setup_project(self, profile_name: str, workspace_root: Path) -> None: """Setup project interactively""" click.echo("📁 Step 2: Setup project") - # Check for existing project - existing_config = self.load_config() - if existing_config.project_id: - if not existing_config.project_name: - raise click.ClickException("Project name is required") - click.echo(f"Found existing project: {existing_config.project_name}") - if click.confirm("Use this project?", default=True): - # Ensure project_path is set and create project directory - project_name = existing_config.project_name - if not existing_config.project_path: - existing_config.project_path = project_name - - project_path = workspace_root / existing_config.project_path - project_path.mkdir(parents=True, exist_ok=True) - - # Update workspace config with profile - existing_config.profile = profile_name - self.save_config(existing_config) - - # Create project config - project_config_manager = ConfigManager( - project_path, skip_validation=True - ) - project_config = ConfigData( - project_id=existing_config.project_id, - project_name=existing_config.project_name, - project_path=None, # No project_path in project directory - folder_id=existing_config.folder_id, - profile=profile_name, - ) - project_config_manager.save_config(project_config) - - click.echo(f"✅ Project directory: {existing_config.project_path}") - click.echo(f"✅ Project: {existing_config.project_name}") - return - # Get API client for project operations api_token, api_host = self.profile_manager.resolve_environment_variables( profile_name @@ -433,9 +422,42 @@ async def _setup_project(self, profile_name: str, workspace_root: Path) -> None: click.echo("❌ No project selected") sys.exit(1) - # Always create project subdirectory named after the project - current_dir = Path.cwd().resolve() - project_path = current_dir / selected_project.name + # Check if this specific project already exists locally in the workspace + local_projects = self._find_all_projects(workspace_root) + existing_local_path = None + + for project_path_candidate, _ in local_projects: + try: + project_config_manager = ConfigManager( + project_path_candidate, skip_validation=True + ) + config_data = project_config_manager.load_config() + if config_data.project_id == selected_project.id: + existing_local_path = project_path_candidate + break + except (json.JSONDecodeError, OSError): + continue + + # If project exists locally, prompt for reinitialization + if existing_local_path: + relative_path = existing_local_path.relative_to(workspace_root) + click.echo( + f"Project '{selected_project.name}' (ID: {selected_project.id}) " + f"already exists locally at: {relative_path}" + ) + if not click.confirm( + "Reinitialize this project? " + "This may overwrite or delete local files.", + default=False, + ): + click.echo("❌ Initialization cancelled") + sys.exit(1) + # Use existing path instead of creating new one + project_path = existing_local_path + else: + # Project doesn't exist locally - create new directory + current_dir = Path.cwd().resolve() + project_path = current_dir / selected_project.name # Validate project path try: diff --git a/tests/unit/config/test_manager.py b/tests/unit/config/test_manager.py index bd36a78..47e7209 100644 --- a/tests/unit/config/test_manager.py +++ b/tests/unit/config/test_manager.py @@ -1537,98 +1537,6 @@ async def mock_prompt3(message: str, **_: Any) -> str: assert profile_name == "newprofile" create_mock.assert_awaited_once_with("newprofile") - @pytest.mark.asyncio - async def test_setup_project_reuses_existing_config( - self, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - mock_profile_manager: Mock, - ) -> None: - """Existing config branch should copy metadata and skip API calls.""" - - workspace_root = tmp_path - project_dir = workspace_root / "Existing" - project_dir.mkdir() - workspace_config = { - "project_id": 1, - "project_name": "Existing", - "project_path": "Existing", - "folder_id": 9, - } - (workspace_root / ".workatoenv").write_text( - json.dumps(workspace_config), encoding="utf-8" - ) - - monkeypatch.setattr( - ConfigManager.__module__ + ".ProfileManager", - lambda: mock_profile_manager, - ) - monkeypatch.setattr( - ConfigManager.__module__ + ".click.confirm", - lambda *a, **k: True, - ) - - outputs: list[str] = [] - monkeypatch.setattr( - ConfigManager.__module__ + ".click.echo", - lambda msg="": outputs.append(str(msg)), - ) - - config_manager = ConfigManager(config_dir=workspace_root, skip_validation=True) - await config_manager._setup_project("dev", workspace_root) - - assert any("Project directory" in msg for msg in outputs) - assert (project_dir / ".workatoenv").exists() - - @pytest.mark.asyncio - async def test_setup_project_existing_without_project_path( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Existing config without project_path should set it automatically.""" - - workspace_root = tmp_path - project_dir = workspace_root / "Existing" - - project_info = { - "project_id": 1, - "project_name": "Existing", - "folder_id": 9, - } - (workspace_root / ".workatoenv").write_text( - json.dumps(project_info), encoding="utf-8" - ) - - monkeypatch.setattr( - ConfigManager.__module__ + ".ProfileManager", - lambda: mock_profile_manager, - ) - monkeypatch.setattr( - ConfigManager.__module__ + ".click.confirm", - lambda *a, **k: True, - ) - - config_manager = ConfigManager(config_dir=workspace_root, skip_validation=True) - await config_manager._setup_project("dev", workspace_root) - - assert (project_dir / ".workatoenv").exists() - - @pytest.mark.asyncio - async def test_setup_project_existing_missing_name( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Existing configs without a name should raise an explicit error.""" - - manager = ConfigManager(config_dir=tmp_path, skip_validation=True) - with ( - patch.object( - manager, - "load_config", - return_value=ConfigData(project_id=1, project_name=None), - ), - pytest.raises(click.ClickException), - ): - await manager._setup_project("dev", tmp_path) - @pytest.mark.asyncio async def test_setup_project_selects_existing_remote( self, @@ -1791,6 +1699,12 @@ async def test_setup_project_reconfigures_existing_directory( lambda qs: {"project": "ExistingProj (ID: 42)"}, ) + # User confirms reinitialization when project exists + monkeypatch.setattr( + ConfigManager.__module__ + ".click.confirm", + lambda *args, **kwargs: True, + ) + outputs: list[str] = [] monkeypatch.setattr( ConfigManager.__module__ + ".click.echo", @@ -2269,3 +2183,421 @@ def test_validate_region_invalid(self, tmp_path: Path) -> None: """Test validate_region with invalid region.""" config_manager = ConfigManager(config_dir=tmp_path, skip_validation=True) assert config_manager.validate_region("invalid") is False + + @pytest.mark.asyncio + async def test_setup_project_no_premature_prompt_with_old_workatoenv( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + mock_profile_manager: Mock, + ) -> None: + """Old .workatoenv should NOT prompt before user selects project.""" + workspace_root = tmp_path + monkeypatch.chdir(workspace_root) + + # Create old .workatoenv with different project + # (use ID 888 to ensure it's different from StubProjectManager's ID 999) + old_config = { + "project_id": 888, + "project_name": "OldProject", + "folder_id": 1, + } + (workspace_root / ".workatoenv").write_text( + json.dumps(old_config), encoding="utf-8" + ) + + mock_profile_manager.set_profile( + "dev", + ProfileData( + region="us", region_url="https://www.workato.com", workspace_id=1 + ), + "token", + ) + + monkeypatch.setattr( + ConfigManager.__module__ + ".ProfileManager", + lambda: mock_profile_manager, + ) + monkeypatch.setattr( + ConfigManager.__module__ + ".Workato", + StubWorkato, + ) + StubProjectManager.available_projects = [] + StubProjectManager.created_projects = [] + monkeypatch.setattr( + ConfigManager.__module__ + ".ProjectManager", + StubProjectManager, + ) + + answers = { + "Select a project": {"project": "Create new project"}, + } + + monkeypatch.setattr( + ConfigManager.__module__ + ".inquirer.prompt", + lambda qs: answers[qs[0].message], + ) + + async def mock_prompt(message: str, **_: Any) -> str: + return "NewProject" + + monkeypatch.setattr( + ConfigManager.__module__ + ".click.prompt", + mock_prompt, + ) + + # Should not call confirm, but if it does, return True + # (which means detection found old project incorrectly) + confirm_called = [] + + def mock_confirm(*args: Any, **kwargs: Any) -> bool: + confirm_called.append(True) + return True + + monkeypatch.setattr( + ConfigManager.__module__ + ".click.confirm", + mock_confirm, + ) + + outputs: list[str] = [] + monkeypatch.setattr( + ConfigManager.__module__ + ".click.echo", + lambda msg="": outputs.append(str(msg)), + ) + + manager = ConfigManager(config_dir=workspace_root, skip_validation=True) + await manager._setup_project("dev", workspace_root) + + # Confirm should NOT have been called (no premature prompt) + assert len(confirm_called) == 0 + + # Should NOT see "Found existing project" or "Use this project" + assert not any("Found existing project" in msg for msg in outputs) + assert not any("Use this project" in msg for msg in outputs) + + # Should create new project directory + new_project_dir = workspace_root / "NewProject" + assert new_project_dir.exists() + + @pytest.mark.asyncio + async def test_setup_project_detects_existing_after_selection( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + mock_profile_manager: Mock, + ) -> None: + """After user selects project, detect if it exists locally.""" + workspace_root = tmp_path + monkeypatch.chdir(workspace_root) + + # Create existing project in subdirectory + existing_project_dir = workspace_root / "projects" / "ExistingProj" + existing_project_dir.mkdir(parents=True) + existing_config = { + "project_id": 42, + "project_name": "ExistingProj", + "folder_id": 5, + } + (existing_project_dir / ".workatoenv").write_text( + json.dumps(existing_config), encoding="utf-8" + ) + + mock_profile_manager.set_profile( + "dev", + ProfileData( + region="us", region_url="https://www.workato.com", workspace_id=1 + ), + "token", + ) + + monkeypatch.setattr( + ConfigManager.__module__ + ".ProfileManager", + lambda: mock_profile_manager, + ) + monkeypatch.setattr( + ConfigManager.__module__ + ".Workato", + StubWorkato, + ) + StubProjectManager.available_projects = [StubProject(42, "ExistingProj", 5)] + monkeypatch.setattr( + ConfigManager.__module__ + ".ProjectManager", + StubProjectManager, + ) + + monkeypatch.setattr( + ConfigManager.__module__ + ".inquirer.prompt", + lambda qs: {"project": "ExistingProj (ID: 42)"}, + ) + + # User confirms reinitialization + monkeypatch.setattr( + ConfigManager.__module__ + ".click.confirm", + lambda *args, **kwargs: True, + ) + + outputs: list[str] = [] + monkeypatch.setattr( + ConfigManager.__module__ + ".click.echo", + lambda msg="": outputs.append(str(msg)), + ) + + manager = ConfigManager(config_dir=workspace_root, skip_validation=True) + await manager._setup_project("dev", workspace_root) + + # Should see project exists message AFTER selection + assert any( + "already exists locally at" in msg and "projects/ExistingProj" in msg + for msg in outputs + ) + + @pytest.mark.asyncio + async def test_setup_project_user_declines_reinitialization( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + mock_profile_manager: Mock, + ) -> None: + """User declining reinitialization should cancel setup.""" + workspace_root = tmp_path + monkeypatch.chdir(workspace_root) + + # Create existing project + existing_project_dir = workspace_root / "ExistingProj" + existing_project_dir.mkdir() + existing_config = { + "project_id": 42, + "project_name": "ExistingProj", + "folder_id": 5, + } + (existing_project_dir / ".workatoenv").write_text( + json.dumps(existing_config), encoding="utf-8" + ) + + mock_profile_manager.set_profile( + "dev", + ProfileData( + region="us", region_url="https://www.workato.com", workspace_id=1 + ), + "token", + ) + + monkeypatch.setattr( + ConfigManager.__module__ + ".ProfileManager", + lambda: mock_profile_manager, + ) + monkeypatch.setattr( + ConfigManager.__module__ + ".Workato", + StubWorkato, + ) + StubProjectManager.available_projects = [StubProject(42, "ExistingProj", 5)] + monkeypatch.setattr( + ConfigManager.__module__ + ".ProjectManager", + StubProjectManager, + ) + + monkeypatch.setattr( + ConfigManager.__module__ + ".inquirer.prompt", + lambda qs: {"project": "ExistingProj (ID: 42)"}, + ) + + # User declines reinitialization + monkeypatch.setattr( + ConfigManager.__module__ + ".click.confirm", + lambda *args, **kwargs: False, + ) + + outputs: list[str] = [] + monkeypatch.setattr( + ConfigManager.__module__ + ".click.echo", + lambda msg="": outputs.append(str(msg)), + ) + + manager = ConfigManager(config_dir=workspace_root, skip_validation=True) + + with pytest.raises(SystemExit): + await manager._setup_project("dev", workspace_root) + + # Should see cancellation message + assert any("Initialization cancelled" in msg for msg in outputs) + + @pytest.mark.asyncio + async def test_setup_non_interactive_fails_when_project_exists( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + mock_profile_manager: Mock, + ) -> None: + """Non-interactive mode should fail if project already exists locally.""" + workspace_root = tmp_path + monkeypatch.chdir(workspace_root) + + # Create existing project + existing_project_dir = workspace_root / "ExistingProj" + existing_project_dir.mkdir() + existing_config = { + "project_id": 42, + "project_name": "ExistingProj", + "folder_id": 5, + } + (existing_project_dir / ".workatoenv").write_text( + json.dumps(existing_config), encoding="utf-8" + ) + + monkeypatch.setattr( + ConfigManager.__module__ + ".ProfileManager", + lambda: mock_profile_manager, + ) + monkeypatch.setattr( + ConfigManager.__module__ + ".Workato", + StubWorkato, + ) + StubProjectManager.available_projects = [StubProject(42, "ExistingProj", 5)] + monkeypatch.setattr( + ConfigManager.__module__ + ".ProjectManager", + StubProjectManager, + ) + + manager = ConfigManager(config_dir=workspace_root, skip_validation=True) + manager.profile_manager = mock_profile_manager + manager.workspace_manager = WorkspaceManager(start_path=workspace_root) + + with pytest.raises(click.ClickException) as excinfo: + await manager._setup_non_interactive( + profile_name="dev", + region="us", + api_token="token", + project_id=42, + ) + + # Should see error about project existing + assert "already exists locally" in str(excinfo.value) + assert "ExistingProj" in str(excinfo.value) + assert "non-interactive" in str(excinfo.value) + + @pytest.mark.asyncio + async def test_setup_project_skips_corrupted_configs( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + mock_profile_manager: Mock, + ) -> None: + """Corrupted .workatoenv files should be skipped during detection.""" + workspace_root = tmp_path + monkeypatch.chdir(workspace_root) + + # Create directory with corrupted .workatoenv + corrupted_dir = workspace_root / "corrupted" + corrupted_dir.mkdir() + (corrupted_dir / ".workatoenv").write_text("invalid json{", encoding="utf-8") + + mock_profile_manager.set_profile( + "dev", + ProfileData( + region="us", region_url="https://www.workato.com", workspace_id=1 + ), + "token", + ) + + monkeypatch.setattr( + ConfigManager.__module__ + ".ProfileManager", + lambda: mock_profile_manager, + ) + monkeypatch.setattr( + ConfigManager.__module__ + ".Workato", + StubWorkato, + ) + StubProjectManager.available_projects = [StubProject(42, "TestProj", 5)] + monkeypatch.setattr( + ConfigManager.__module__ + ".ProjectManager", + StubProjectManager, + ) + + monkeypatch.setattr( + ConfigManager.__module__ + ".inquirer.prompt", + lambda qs: {"project": "TestProj (ID: 42)"}, + ) + + outputs: list[str] = [] + monkeypatch.setattr( + ConfigManager.__module__ + ".click.echo", + lambda msg="": outputs.append(str(msg)), + ) + + manager = ConfigManager(config_dir=workspace_root, skip_validation=True) + + # Should not raise error, just skip corrupted config + await manager._setup_project("dev", workspace_root) + + # Should create new project successfully + test_project_dir = workspace_root / "TestProj" + assert test_project_dir.exists() + + @pytest.mark.asyncio + async def test_setup_project_matches_by_project_id( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + mock_profile_manager: Mock, + ) -> None: + """Project detection should match by project_id, not by name.""" + workspace_root = tmp_path + monkeypatch.chdir(workspace_root) + + # Create existing project with different name but same ID + existing_project_dir = workspace_root / "OldName" + existing_project_dir.mkdir() + existing_config = { + "project_id": 42, + "project_name": "OldName", + "folder_id": 5, + } + (existing_project_dir / ".workatoenv").write_text( + json.dumps(existing_config), encoding="utf-8" + ) + + mock_profile_manager.set_profile( + "dev", + ProfileData( + region="us", region_url="https://www.workato.com", workspace_id=1 + ), + "token", + ) + + monkeypatch.setattr( + ConfigManager.__module__ + ".ProfileManager", + lambda: mock_profile_manager, + ) + monkeypatch.setattr( + ConfigManager.__module__ + ".Workato", + StubWorkato, + ) + # Project renamed on remote to "NewName" + StubProjectManager.available_projects = [StubProject(42, "NewName", 5)] + monkeypatch.setattr( + ConfigManager.__module__ + ".ProjectManager", + StubProjectManager, + ) + + monkeypatch.setattr( + ConfigManager.__module__ + ".inquirer.prompt", + lambda qs: {"project": "NewName (ID: 42)"}, + ) + + # User confirms reinitialization + monkeypatch.setattr( + ConfigManager.__module__ + ".click.confirm", + lambda *args, **kwargs: True, + ) + + outputs: list[str] = [] + monkeypatch.setattr( + ConfigManager.__module__ + ".click.echo", + lambda msg="": outputs.append(str(msg)), + ) + + manager = ConfigManager(config_dir=workspace_root, skip_validation=True) + await manager._setup_project("dev", workspace_root) + + # Should detect existing project by ID even though name changed + assert any("already exists locally" in msg for msg in outputs) + assert any("OldName" in msg for msg in outputs)