diff --git a/lib/cli/src/crewai_cli/deploy/archive.py b/lib/cli/src/crewai_cli/deploy/archive.py index eb651c32b..bdd4b0d47 100644 --- a/lib/cli/src/crewai_cli/deploy/archive.py +++ b/lib/cli/src/crewai_cli/deploy/archive.py @@ -8,6 +8,7 @@ from typing import Any import zipfile from crewai_cli import git +from crewai_cli.deploy.validate import normalize_package_name from crewai_cli.utils import parse_toml @@ -156,7 +157,9 @@ def _is_json_crew_project(root: Path) -> bool: package_name = _package_name(root) if package_name is None: - return False + raise ValueError( + "Could not derive a valid Python package name from [project].name." + ) return not (root / "src" / package_name / "crew.py").is_file() @@ -167,9 +170,10 @@ def _read_pyproject(root: Path) -> dict[str, Any]: if not pyproject_path.is_file(): return {} try: - return parse_toml(pyproject_path.read_text()) + pyproject = parse_toml(pyproject_path.read_text()) except Exception: return {} + return pyproject if isinstance(pyproject, dict) else {} def _package_name(root: Path) -> str | None: @@ -182,8 +186,8 @@ def _package_name(root: Path) -> str | None: if not isinstance(name, str) or not name.strip(): return None - folder = name.replace(" ", "_").replace("-", "_").lower() - return re.sub(r"[^a-zA-Z0-9_]", "", folder) + package_name = normalize_package_name(name) + return package_name or None def _class_name(package_name: str) -> str: @@ -201,7 +205,9 @@ 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 + raise ValueError( + "Could not derive a valid Python package name from [project].name." + ) package_dir = root / "src" / package_name config_dir = package_dir / "config" diff --git a/lib/cli/src/crewai_cli/deploy/main.py b/lib/cli/src/crewai_cli/deploy/main.py index 3a98f91df..3dedb7d98 100644 --- a/lib/cli/src/crewai_cli/deploy/main.py +++ b/lib/cli/src/crewai_cli/deploy/main.py @@ -268,11 +268,12 @@ class DeployCommand(BaseCommand, PlusAPIMixin): ) except Exception as commit_error: console.print( - "Could not create an initial Git commit. Continuing with ZIP deployment.", + "Could not create an initial Git commit. " + "Continuing with ZIP deployment using Git file listing.", style="yellow", ) console.print(str(commit_error), style="dim") - return None + return repository return repository diff --git a/lib/cli/src/crewai_cli/install_crew.py b/lib/cli/src/crewai_cli/install_crew.py index 52877e605..75f4a35bd 100644 --- a/lib/cli/src/crewai_cli/install_crew.py +++ b/lib/cli/src/crewai_cli/install_crew.py @@ -3,6 +3,7 @@ import subprocess import click +from crewai_cli.deploy.validate import normalize_package_name from crewai_cli.utils import build_env_with_all_tool_credentials, parse_toml @@ -30,10 +31,23 @@ def _is_json_crew_project(project_root: Path | None = None) -> bool: pyproject = parse_toml(pyproject_path.read_text()) except Exception: return True + if not isinstance(pyproject, dict): + return True - declared_type: str | None = ( - (pyproject.get("tool") or {}).get("crewai", {}).get("type") + tool_config = pyproject.get("tool") or {} + crewai_config = tool_config.get("crewai") if isinstance(tool_config, dict) else None + declared_type = ( + crewai_config.get("type") if isinstance(crewai_config, dict) else None ) + project_config = pyproject.get("project") or {} + project_name = ( + project_config.get("name") if isinstance(project_config, dict) else None + ) + if isinstance(project_name, str): + package_name = normalize_package_name(project_name) + if package_name and (root / "src" / package_name / "crew.py").is_file(): + return False + return declared_type != "flow" diff --git a/lib/cli/tests/deploy/test_archive.py b/lib/cli/tests/deploy/test_archive.py index 68ec15770..a40cfdd88 100644 --- a/lib/cli/tests/deploy/test_archive.py +++ b/lib/cli/tests/deploy/test_archive.py @@ -138,3 +138,24 @@ type = "crew" assert "JsonCrew" in crew_py assert "from json_crew.crew import JsonCrew" in main_py assert "run_crew = \"json_crew.main:run\"" in pyproject + + +def test_create_project_zip_rejects_empty_normalized_package_name(tmp_path: Path): + (tmp_path / "pyproject.toml").write_text( + """ +[project] +name = "!!!" +version = "0.1.0" + +[tool.crewai] +type = "crew" +""".strip() + + "\n" + ) + (tmp_path / "crew.jsonc").write_text("{}\n") + + with pytest.raises( + ValueError, + match=r"Could not derive a valid Python package name", + ): + create_project_zip("invalid", project_dir=tmp_path) diff --git a/lib/cli/tests/deploy/test_deploy_main.py b/lib/cli/tests/deploy/test_deploy_main.py index 84a033f3e..9d6d73b1b 100644 --- a/lib/cli/tests/deploy/test_deploy_main.py +++ b/lib/cli/tests/deploy/test_deploy_main.py @@ -440,6 +440,41 @@ class TestDeployCommand(unittest.TestCase): ) self.mock_client.create_crew.assert_not_called() + @patch("crewai_cli.deploy.main.create_project_zip") + @patch("crewai_cli.deploy.main.fetch_and_json_env_file") + @patch("crewai_cli.deploy.main.git.Repository") + def test_create_crew_without_remote_uses_git_file_list_when_commit_fails( + self, mock_repository, mock_fetch_env, mock_create_project_zip + ): + mock_fetch_env.return_value = {"ENV_VAR": "value"} + repository = mock_repository.return_value + repository.origin_url.return_value = None + repository.create_initial_commit_if_needed.side_effect = RuntimeError( + "commit failed" + ) + mock_create_project_zip.return_value = Path("/tmp/test_project.zip") + mock_response = MagicMock() + mock_response.status_code = 201 + mock_response.is_success = True + mock_response.json.return_value = {"uuid": "zip-uuid", "status": "created"} + self.mock_client.create_crew_from_zip.return_value = mock_response + + with patch("sys.stdout", new=StringIO()) as fake_out: + self.deploy_command.create_crew(confirm=True, skip_validate=True) + output = fake_out.getvalue() + + self.assertIn("Continuing with ZIP deployment using Git", output) + self.assertIn("file listing", output) + mock_create_project_zip.assert_called_once_with( + "test_project", repository=repository + ) + self.mock_client.create_crew_from_zip.assert_called_once_with( + Path("/tmp/test_project.zip"), + name="test_project", + env={"ENV_VAR": "value"}, + ) + self.mock_client.create_crew.assert_not_called() + def test_list_crews(self): mock_response = MagicMock() mock_response.status_code = 200 diff --git a/lib/cli/tests/test_install_crew.py b/lib/cli/tests/test_install_crew.py index d15d8229d..6d61f4598 100644 --- a/lib/cli/tests/test_install_crew.py +++ b/lib/cli/tests/test_install_crew.py @@ -1,4 +1,5 @@ from pathlib import Path +import subprocess import pytest @@ -33,6 +34,28 @@ type = "crew" install_crew_module.install_crew([]) +def test_install_crew_json_project_with_python_package_installs_project( + fp, monkeypatch, tmp_path: Path +): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text( + """ +[project] +name = "hybrid-crew" + +[tool.crewai] +type = "crew" +""".strip() + ) + (tmp_path / "crew.jsonc").write_text("{}\n") + package_dir = tmp_path / "src" / "hybrid_crew" + package_dir.mkdir(parents=True) + (package_dir / "crew.py").write_text("class HybridCrew: ...\n") + fp.register(["uv", "sync"], stdout="") + + install_crew_module.install_crew([]) + + def test_install_crew_flow_project_installs_project(fp, monkeypatch, tmp_path: Path): monkeypatch.chdir(tmp_path) (tmp_path / "pyproject.toml").write_text( @@ -64,3 +87,16 @@ def test_install_crew_install_project_false_adds_no_install_project(fp): fp.register(["uv", "sync", "--no-install-project", "--frozen"], stdout="") install_crew_module.install_crew(["--frozen"], install_project=False) + + +def test_install_crew_reraises_sync_failure_when_requested(fp): + fp.register(["uv", "sync"], returncode=1, stderr="sync failed\n") + + with pytest.raises(subprocess.CalledProcessError): + install_crew_module.install_crew([], raise_on_error=True) + + +def test_install_crew_swallows_sync_failure_by_default(fp): + fp.register(["uv", "sync"], returncode=1, stderr="sync failed\n") + + install_crew_module.install_crew([])