From 6b76530ce8e4e7b5e3b7c94d80b385bbeb4165b9 Mon Sep 17 00:00:00 2001 From: Robert Fujara Date: Mon, 27 Oct 2025 12:52:04 +0100 Subject: [PATCH] Fix workato init to detect existing projects after user selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed the project detection logic in workato init to check for existing projects AFTER the user selects which project they want to initialize, rather than prematurely prompting based on any .workatoenv in the current directory. Key changes: - Removed premature check that looked for .workatoenv before user selection - Added proper check after project selection using _find_all_projects() - Detection now matches by project_id across entire workspace - Interactive mode prompts for reinitialization if project exists - Non-interactive mode fails with clear error if project exists - Corrupted configs are gracefully skipped during detection Added comprehensive test coverage (6 new tests) for: - No premature prompts with old .workatoenv files - Detection after user selection - User declining reinitialization - Non-interactive mode error handling - Corrupted config handling - Matching by project_id instead of name All 899 tests passing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../cli/utils/config/manager.py | 102 ++-- tests/unit/config/test_manager.py | 516 ++++++++++++++---- 2 files changed, 486 insertions(+), 132 deletions(-) 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)