diff --git a/lib/cli/src/crewai_cli/deploy/main.py b/lib/cli/src/crewai_cli/deploy/main.py index 1049752f05..085c9e1dc5 100644 --- a/lib/cli/src/crewai_cli/deploy/main.py +++ b/lib/cli/src/crewai_cli/deploy/main.py @@ -1,12 +1,15 @@ from pathlib import Path import subprocess from typing import Any +from urllib.parse import quote +import webbrowser from crewai_core.plus_api import CreateCrewPayload from rich.console import Console from crewai_cli import git from crewai_cli.command import BaseCommand, PlusAPIMixin +from crewai_cli.constants import DEFAULT_CREWAI_ENTERPRISE_URL from crewai_cli.deploy.archive import create_project_zip from crewai_cli.deploy.validate import DeployValidator, Severity, render_report from crewai_cli.utils import fetch_and_json_env_file, get_project_name @@ -14,6 +17,8 @@ console = Console() _MISSING_LOCKFILE_ERROR_CODES = {"missing_lockfile"} +_DEPLOYMENT_ID_KEYS = ("deployment_id", "deploymentId") +_DEPLOYMENT_FALLBACK_IDENTIFIER_KEYS = ("id", "uuid") def _run_predeploy_validation( @@ -79,6 +84,39 @@ def _env_summary(env_vars: dict[str, str]) -> str: return f"{len(env_vars)} env vars: {keys}" +def _deployment_identifier(json_response: dict[str, Any]) -> str | None: + """Return the best available identifier for a deployment show URL.""" + deployment = json_response.get("deployment") + + for key in _DEPLOYMENT_ID_KEYS: + value = json_response.get(key) + if value: + return str(value) + + if isinstance(deployment, dict): + for key in _DEPLOYMENT_ID_KEYS + _DEPLOYMENT_FALLBACK_IDENTIFIER_KEYS: + value = deployment.get(key) + if value: + return str(value) + + for key in _DEPLOYMENT_FALLBACK_IDENTIFIER_KEYS: + value = json_response.get(key) + if value: + return str(value) + + return None + + +def _deployment_page_url(base_url: str, json_response: dict[str, Any]) -> str | None: + """Build the CrewAI deployment show URL for a response payload.""" + identifier = _deployment_identifier(json_response) + if not identifier: + return None + return ( + f"{base_url.rstrip('/')}/crewai_plus/deployments/{quote(identifier, safe='')}" + ) + + def _needs_lockfile_for_deploy(project_root: Path | None = None) -> bool: """Return True when deploy should create the project's first lockfile.""" root = project_root or Path.cwd() @@ -165,6 +203,7 @@ def _display_deployment_info(self, json_response: dict[str, Any]) -> None: console.print("crewai deploy status") console.print(" or") console.print(f'crewai deploy status --uuid "{json_response["uuid"]}"') + self._open_deployment_page(json_response) def _display_logs(self, log_messages: list[dict[str, Any]]) -> None: """ @@ -178,6 +217,28 @@ def _display_logs(self, log_messages: list[dict[str, Any]]) -> None: f"{log_message['timestamp']} - {log_message['level']}: {log_message['message']}" ) + def _open_deployment_page(self, json_response: dict[str, Any]) -> None: + """Open the deployment show page in the user's browser when possible.""" + base_url = str( + getattr(self.plus_api_client, "base_url", None) + or DEFAULT_CREWAI_ENTERPRISE_URL + ) + deployment_url = _deployment_page_url(base_url, json_response) + if not deployment_url: + return + + console.print(f"\nOpening deployment page: [blue]{deployment_url}[/blue]") + try: + opened = webbrowser.open(deployment_url) + except Exception: + opened = False + + if not opened: + console.print( + "Could not open the deployment page automatically.", + style="yellow", + ) + def deploy(self, uuid: str | None = None, skip_validate: bool = False) -> None: """ Deploy a crew using either UUID or project name. @@ -438,6 +499,7 @@ def _display_creation_success(self, json_response: dict[str, Any]) -> None: console.print("crewai deploy push") console.print(" or") console.print(f"crewai deploy push --uuid {json_response['uuid']}") + self._open_deployment_page(json_response) def list_crews(self) -> None: """ diff --git a/lib/cli/tests/deploy/test_deploy_main.py b/lib/cli/tests/deploy/test_deploy_main.py index 7d91c77f36..06951f0fec 100644 --- a/lib/cli/tests/deploy/test_deploy_main.py +++ b/lib/cli/tests/deploy/test_deploy_main.py @@ -167,6 +167,36 @@ def fake_install_crew(proxy_options, *, raise_on_error=False): assert validators == [] +def test_deployment_page_url_prefers_deployment_id(): + assert ( + deploy_main._deployment_page_url( + "https://app.crewai.com", + {"uuid": "crew-uuid", "deployment_id": 128687}, + ) + == "https://app.crewai.com/crewai_plus/deployments/128687" + ) + + +def test_deployment_page_url_prefers_nested_deployment_id_over_crew_uuid(): + assert ( + deploy_main._deployment_page_url( + "https://app.crewai.com", + {"uuid": "crew-uuid", "deployment": {"deployment_id": 128687}}, + ) + == "https://app.crewai.com/crewai_plus/deployments/128687" + ) + + +def test_deployment_page_url_falls_back_to_nested_uuid(): + assert ( + deploy_main._deployment_page_url( + "https://app.crewai.com/", + {"deployment": {"uuid": "deployment-uuid"}}, + ) + == "https://app.crewai.com/crewai_plus/deployments/deployment-uuid" + ) + + class TestDeployCommand(unittest.TestCase): @patch("crewai_cli.command.get_auth_token") @patch("crewai_cli.deploy.main.get_project_name") @@ -186,6 +216,12 @@ def setUp( self.deploy_command = deploy_main.DeployCommand() self.mock_client = self.deploy_command.plus_api_client + self.mock_client.base_url = "https://app.crewai.com" + self.mock_browser_open_patcher = patch( + "crewai_cli.deploy.main.webbrowser.open" + ) + self.mock_browser_open = self.mock_browser_open_patcher.start() + self.addCleanup(self.mock_browser_open_patcher.stop) def test_init_success(self): self.assertEqual(self.deploy_command.project_name, "test_project") @@ -272,11 +308,50 @@ def test_standard_no_param_error_message(self): def test_display_deployment_info(self): with patch("sys.stdout", new=StringIO()) as fake_out: self.deploy_command._display_deployment_info( - {"uuid": "test-uuid", "status": "deployed"} + {"uuid": "test-uuid", "id": 128687, "status": "deployed"} ) self.assertIn("Deploying the crew...", fake_out.getvalue()) self.assertIn("test-uuid", fake_out.getvalue()) self.assertIn("deployed", fake_out.getvalue()) + self.assertIn( + "https://app.crewai.com/crewai_plus/deployments/128687", + fake_out.getvalue(), + ) + self.mock_browser_open.assert_called_once_with( + "https://app.crewai.com/crewai_plus/deployments/128687" + ) + + def test_display_deployment_info_warns_when_browser_open_returns_false(self): + self.mock_browser_open.return_value = False + + with patch("sys.stdout", new=StringIO()) as fake_out: + self.deploy_command._display_deployment_info( + {"uuid": "test-uuid", "id": 128687, "status": "deployed"} + ) + self.assertIn( + "Could not open the deployment page automatically.", + fake_out.getvalue(), + ) + + self.mock_browser_open.assert_called_once_with( + "https://app.crewai.com/crewai_plus/deployments/128687" + ) + + def test_display_deployment_info_warns_when_browser_open_raises(self): + self.mock_browser_open.side_effect = RuntimeError("no browser") + + with patch("sys.stdout", new=StringIO()) as fake_out: + self.deploy_command._display_deployment_info( + {"uuid": "test-uuid", "id": 128687, "status": "deployed"} + ) + self.assertIn( + "Could not open the deployment page automatically.", + fake_out.getvalue(), + ) + + self.mock_browser_open.assert_called_once_with( + "https://app.crewai.com/crewai_plus/deployments/128687" + ) def test_display_logs(self): with patch("sys.stdout", new=StringIO()) as fake_out: