From 35d58501df06c7a80845e9770e6a4db4df3339fa Mon Sep 17 00:00:00 2001 From: Joao Moura Date: Mon, 15 Jun 2026 01:47:08 -0700 Subject: [PATCH] fix(cli): preserve remote deploy on git setup warnings --- lib/cli/src/crewai_cli/deploy/archive.py | 13 +++++ lib/cli/src/crewai_cli/deploy/main.py | 35 ++++++++++++- lib/cli/src/crewai_cli/git.py | 2 + lib/cli/src/crewai_cli/plus_api.py | 3 ++ lib/cli/tests/deploy/test_archive.py | 15 +++--- lib/cli/tests/deploy/test_deploy_main.py | 57 +++++++++++++++++++++ lib/cli/tests/test_git.py | 2 + lib/crewai-core/src/crewai_core/plus_api.py | 3 ++ 8 files changed, 123 insertions(+), 7 deletions(-) diff --git a/lib/cli/src/crewai_cli/deploy/archive.py b/lib/cli/src/crewai_cli/deploy/archive.py index b64b7e162..eb651c32b 100644 --- a/lib/cli/src/crewai_cli/deploy/archive.py +++ b/lib/cli/src/crewai_cli/deploy/archive.py @@ -72,6 +72,7 @@ def create_project_zip( def _project_files(root: Path, repository: git.Repository | None = None) -> list[Path]: + """Return project-relative files to include in the archive.""" if repository is not None: files = [Path(path) for path in repository.deployable_files()] return [ @@ -88,16 +89,19 @@ def _project_files(root: Path, repository: git.Repository | None = None) -> list def _walk_files(root: Path) -> list[Path]: + """List regular files below root as project-relative paths.""" return [ path.relative_to(root) for path in root.rglob("*") if _is_regular_file(path) ] def _is_regular_file(path: Path) -> bool: + """Return True for regular files, excluding symlinks to files.""" return path.is_file() and not path.is_symlink() def _is_excluded(path: Path) -> bool: + """Return True when a file should be omitted from deployment ZIPs.""" parts = set(path.parts) if parts.intersection(_EXCLUDED_DIRS): return True @@ -111,6 +115,7 @@ def _is_excluded(path: Path) -> bool: def _stage_project(root: Path, files: list[Path]) -> Path: + """Copy archive files into a temporary staging directory.""" staging_root = Path(tempfile.mkdtemp(prefix="crewai-deploy-")) try: @@ -132,6 +137,7 @@ def _stage_project(root: Path, files: list[Path]) -> Path: def _is_json_crew_project(root: Path) -> bool: + """Return True for JSON crew projects that need a Python deploy wrapper.""" if not ((root / "crew.jsonc").is_file() or (root / "crew.json").is_file()): return False @@ -156,6 +162,7 @@ def _is_json_crew_project(root: Path) -> bool: def _read_pyproject(root: Path) -> dict[str, Any]: + """Read pyproject.toml, returning an empty mapping on missing or invalid data.""" pyproject_path = root / "pyproject.toml" if not pyproject_path.is_file(): return {} @@ -166,6 +173,7 @@ def _read_pyproject(root: Path) -> dict[str, Any]: def _package_name(root: Path) -> str | None: + """Return the normalized Python package name for the project.""" project = _read_pyproject(root).get("project") if not isinstance(project, dict): return None @@ -179,6 +187,7 @@ def _package_name(root: Path) -> str | None: def _class_name(package_name: str) -> str: + """Return the generated wrapper class name for a package.""" parts = [part for part in re.split(r"[^a-zA-Z0-9]+", package_name) if part] class_name = "".join(part[:1].upper() + part[1:] for part in parts) if not class_name: @@ -189,6 +198,7 @@ def _class_name(package_name: str) -> str: def _add_json_crew_deploy_wrapper(root: Path) -> None: + """Add Python wrapper files required to deploy a JSON crew project.""" package_name = _package_name(root) if package_name is None: return @@ -215,6 +225,7 @@ def _add_json_crew_deploy_wrapper(root: Path) -> None: def _json_crew_py(class_name: str, crew_filename: str) -> str: + """Render the generated crew.py module for a JSON crew.""" return f'''from pathlib import Path from crewai import Crew @@ -239,6 +250,7 @@ class {class_name}: def _json_main_py(package_name: str, class_name: str) -> str: + """Render the generated main.py entrypoints for a JSON crew.""" return f"""#!/usr/bin/env python import json import sys @@ -293,6 +305,7 @@ def run_with_trigger(): def _ensure_project_scripts(root: Path, package_name: str) -> None: + """Ensure generated wrappers have project script entrypoints.""" pyproject_path = root / "pyproject.toml" if not pyproject_path.is_file(): return diff --git a/lib/cli/src/crewai_cli/deploy/main.py b/lib/cli/src/crewai_cli/deploy/main.py index 9f609b145..3a98f91df 100644 --- a/lib/cli/src/crewai_cli/deploy/main.py +++ b/lib/cli/src/crewai_cli/deploy/main.py @@ -54,6 +54,7 @@ def _display_git_remote_help() -> None: def _env_summary(env_vars: dict[str, str]) -> str: + """Return a compact description of environment variables for prompts.""" if not env_vars: return "0 env vars" keys = ", ".join(sorted(env_vars)) @@ -167,6 +168,7 @@ class DeployCommand(BaseCommand, PlusAPIMixin): self._display_deployment_info(response.json()) def _deployment_uuid_by_name(self) -> str: + """Resolve the current project's deployment UUID by project name.""" if not self.project_name: raise ValueError("project_name is required to find a deployment") @@ -207,8 +209,9 @@ class DeployCommand(BaseCommand, PlusAPIMixin): self._display_creation_success(response.json()) def _prepare_git_repository(self) -> git.Repository | None: + """Prepare Git for deploy while preserving remote deploy when possible.""" try: - repository = git.Repository() + repository = git.Repository(fetch=False) except ValueError as exc: if "not a Git repository" not in str(exc): console.print( @@ -230,6 +233,33 @@ class DeployCommand(BaseCommand, PlusAPIMixin): _display_git_repository_help() return repository + remote_repo_url = repository.origin_url() + if remote_repo_url: + try: + repository.fetch() + except ValueError as fetch_error: + console.print( + "Could not fetch from origin. Continuing with remote deployment.", + style="yellow", + ) + console.print(str(fetch_error), style="dim") + + try: + if repository.create_initial_commit_if_needed(): + console.print( + "Created an initial Git commit for this project.", + style="green", + ) + except Exception as commit_error: + console.print( + "Could not create an initial Git commit. " + "Continuing with remote deployment.", + style="yellow", + ) + console.print(str(commit_error), style="dim") + + return repository + try: if repository.create_initial_commit_if_needed(): console.print( @@ -252,6 +282,7 @@ class DeployCommand(BaseCommand, PlusAPIMixin): repository: git.Repository | None, confirm: bool, ) -> Any: + """Create a deployment by uploading a project ZIP archive.""" if not self.project_name: raise ValueError("project_name is required to create a ZIP deployment") @@ -273,6 +304,7 @@ class DeployCommand(BaseCommand, PlusAPIMixin): uuid: str, repository: git.Repository | None, ) -> Any: + """Update an existing deployment by uploading a project ZIP archive.""" if not self.project_name: raise ValueError("project_name is required to update a ZIP deployment") @@ -302,6 +334,7 @@ class DeployCommand(BaseCommand, PlusAPIMixin): ) def _confirm_zip_input(self, env_vars: dict[str, str], confirm: bool) -> None: + """Prompt before ZIP upload unless confirmation was already supplied.""" if not confirm: input(f"Press Enter to continue with {_env_summary(env_vars)}") diff --git a/lib/cli/src/crewai_cli/git.py b/lib/cli/src/crewai_cli/git.py index 47f933573..6961313e5 100644 --- a/lib/cli/src/crewai_cli/git.py +++ b/lib/cli/src/crewai_cli/git.py @@ -9,6 +9,8 @@ _INITIAL_COMMIT_EXCLUDE_PATTERNS = [ ".crewai/", ".env", ".env.*", + "!.env.example", + "!.env.sample", ".mypy_cache/", ".pytest_cache/", ".ruff_cache/", diff --git a/lib/cli/src/crewai_cli/plus_api.py b/lib/cli/src/crewai_cli/plus_api.py index fe19d4e89..6f94d96d3 100644 --- a/lib/cli/src/crewai_cli/plus_api.py +++ b/lib/cli/src/crewai_cli/plus_api.py @@ -31,6 +31,7 @@ class PlusAPI(_CorePlusAPI): timeout: float | None = None, verify: bool = True, ) -> httpx.Response: + """Send an authenticated multipart request containing a project ZIP.""" url = urljoin(self.base_url, endpoint) headers = dict(cast(dict[str, str], self.headers)) headers.pop("Content-Type", None) @@ -57,6 +58,7 @@ class PlusAPI(_CorePlusAPI): name: str | None = None, env: dict[str, str] | None = None, ) -> httpx.Response: + """Create a crew deployment from a local project ZIP archive.""" data: dict[str, str] = {} if name: data["name"] = name @@ -77,6 +79,7 @@ class PlusAPI(_CorePlusAPI): *, env: dict[str, str] | None = None, ) -> httpx.Response: + """Update an existing crew deployment from a local project ZIP archive.""" data: dict[str, str] = {} if env: data.update({f"env[{key}]": value for key, value in env.items()}) diff --git a/lib/cli/tests/deploy/test_archive.py b/lib/cli/tests/deploy/test_archive.py index 8ae915f9b..68ec15770 100644 --- a/lib/cli/tests/deploy/test_archive.py +++ b/lib/cli/tests/deploy/test_archive.py @@ -77,17 +77,20 @@ def test_create_project_zip_excludes_symlinked_files(tmp_path: Path): (tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n") outside_file = tmp_path.parent / f"{tmp_path.name}-secret.txt" outside_file.write_text("secret\n") + archive_path: Path | None = None try: - (tmp_path / "external-secret.txt").symlink_to(outside_file) - except OSError as exc: - pytest.skip(f"symlinks are not supported in this environment: {exc}") + try: + (tmp_path / "external-secret.txt").symlink_to(outside_file) + except OSError as exc: + pytest.skip(f"symlinks are not supported in this environment: {exc}") - archive_path = create_project_zip("demo", project_dir=tmp_path) - try: + archive_path = create_project_zip("demo", project_dir=tmp_path) with zipfile.ZipFile(archive_path) as archive: names = set(archive.namelist()) finally: - archive_path.unlink(missing_ok=True) + if archive_path is not None: + archive_path.unlink(missing_ok=True) + outside_file.unlink(missing_ok=True) assert names == {"pyproject.toml"} diff --git a/lib/cli/tests/deploy/test_deploy_main.py b/lib/cli/tests/deploy/test_deploy_main.py index 61fb399d3..6de8da2d1 100644 --- a/lib/cli/tests/deploy/test_deploy_main.py +++ b/lib/cli/tests/deploy/test_deploy_main.py @@ -236,6 +236,63 @@ class TestDeployCommand(unittest.TestCase): self.mock_client.deploy_by_name.assert_called_once_with("test_project") mock_display.assert_called_once_with({"uuid": "test-uuid"}) + @patch("crewai_cli.deploy.main.create_project_zip") + @patch("crewai_cli.deploy.main.git.Repository") + @patch("crewai_cli.deploy.main.DeployCommand._display_deployment_info") + def test_deploy_with_remote_keeps_remote_path_when_fetch_fails( + self, mock_display, mock_repository, mock_create_project_zip + ): + repository = mock_repository.return_value + repository.origin_url.return_value = "https://github.com/test/repo.git" + repository.fetch.side_effect = ValueError("fetch failed") + repository.create_initial_commit_if_needed.return_value = False + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.is_success = True + mock_response.json.return_value = {"uuid": "test-uuid"} + self.mock_client.deploy_by_name.return_value = mock_response + + with patch("sys.stdout", new=StringIO()) as fake_out: + self.deploy_command.deploy(skip_validate=True) + output = fake_out.getvalue() + + mock_repository.assert_called_once_with(fetch=False) + repository.fetch.assert_called_once_with() + self.assertIn("Continuing with remote deployment", output) + self.mock_client.deploy_by_name.assert_called_once_with("test_project") + self.mock_client.update_crew_from_zip.assert_not_called() + mock_create_project_zip.assert_not_called() + mock_display.assert_called_once_with({"uuid": "test-uuid"}) + + @patch("crewai_cli.deploy.main.create_project_zip") + @patch("crewai_cli.deploy.main.git.Repository") + @patch("crewai_cli.deploy.main.DeployCommand._display_deployment_info") + def test_deploy_with_remote_keeps_remote_path_when_initial_commit_fails( + self, mock_display, mock_repository, mock_create_project_zip + ): + repository = mock_repository.return_value + repository.origin_url.return_value = "https://github.com/test/repo.git" + repository.create_initial_commit_if_needed.side_effect = RuntimeError( + "commit failed" + ) + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.is_success = True + mock_response.json.return_value = {"uuid": "test-uuid"} + self.mock_client.deploy_by_name.return_value = mock_response + + with patch("sys.stdout", new=StringIO()) as fake_out: + self.deploy_command.deploy(skip_validate=True) + output = fake_out.getvalue() + + mock_repository.assert_called_once_with(fetch=False) + repository.fetch.assert_called_once_with() + self.assertIn("Continuing with remote deployment", output) + self.mock_client.deploy_by_name.assert_called_once_with("test_project") + self.mock_client.update_crew_from_zip.assert_not_called() + mock_create_project_zip.assert_not_called() + mock_display.assert_called_once_with({"uuid": "test-uuid"}) + @patch("crewai_cli.deploy.main.create_project_zip") @patch("crewai_cli.deploy.main.git.Repository") @patch("crewai_cli.deploy.main.DeployCommand._display_deployment_info") diff --git a/lib/cli/tests/test_git.py b/lib/cli/tests/test_git.py index 41ffbb222..9654b502b 100644 --- a/lib/cli/tests/test_git.py +++ b/lib/cli/tests/test_git.py @@ -141,6 +141,8 @@ def test_initialize_creates_initial_commit(fp, tmp_path): exclude_file = tmp_path / ".git" / "info" / "exclude" exclude_text = exclude_file.read_text() assert ".env" in exclude_text + assert "!.env.example" in exclude_text + assert "!.env.sample" in exclude_text assert "__pycache__/" in exclude_text diff --git a/lib/crewai-core/src/crewai_core/plus_api.py b/lib/crewai-core/src/crewai_core/plus_api.py index cc82f042e..f652b0109 100644 --- a/lib/crewai-core/src/crewai_core/plus_api.py +++ b/lib/crewai-core/src/crewai_core/plus_api.py @@ -201,6 +201,7 @@ class PlusAPI: timeout: float | None = None, verify: bool = True, ) -> httpx.Response: + """Send an authenticated multipart request containing a project ZIP.""" url = urljoin(self.base_url, endpoint) headers = dict(cast(dict[str, str], self.headers)) headers.pop("Content-Type", None) @@ -349,6 +350,7 @@ class PlusAPI: name: str | None = None, env: dict[str, str] | None = None, ) -> httpx.Response: + """Create a crew deployment from a local project ZIP archive.""" data: dict[str, str] = {} if name: data["name"] = name @@ -369,6 +371,7 @@ class PlusAPI: *, env: dict[str, str] | None = None, ) -> httpx.Response: + """Update an existing crew deployment from a local project ZIP archive.""" data: dict[str, str] = {} if env: data.update({f"env[{key}]": value for key, value in env.items()})