fix(cli): preserve remote deploy on git setup warnings

This commit is contained in:
Joao Moura
2026-06-15 01:47:08 -07:00
parent 2c0b127ff2
commit 35d58501df
8 changed files with 123 additions and 7 deletions

View File

@@ -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

View File

@@ -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)}")

View File

@@ -9,6 +9,8 @@ _INITIAL_COMMIT_EXCLUDE_PATTERNS = [
".crewai/",
".env",
".env.*",
"!.env.example",
"!.env.sample",
".mypy_cache/",
".pytest_cache/",
".ruff_cache/",

View File

@@ -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()})

View File

@@ -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"}

View File

@@ -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")

View File

@@ -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

View File

@@ -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()})