diff --git a/libs/arcade-cli/arcade_cli/authn.py b/libs/arcade-cli/arcade_cli/authn.py index bd9c18e88..43c90efb9 100644 --- a/libs/arcade-cli/arcade_cli/authn.py +++ b/libs/arcade-cli/arcade_cli/authn.py @@ -475,6 +475,7 @@ def save_credentials_from_whoami( tokens: TokenResponse, whoami: WhoAmIResponse, coordinator_url: str, + engine_url: str | None = None, ) -> None: """ Save OAuth credentials to the config file using WhoAmI response. @@ -485,6 +486,10 @@ def save_credentials_from_whoami( Args: tokens: OAuth tokens whoami: Response from /whoami endpoint with user and orgs/projects + coordinator_url: Coordinator URL that was used for the OAuth flow + engine_url: Matching Engine URL for the same environment, persisted so + subsequent CLI commands default to it. ``None`` leaves the field + unset, which causes commands to fall back to the prod default. """ # Ensure config directory exists os.makedirs(ARCADE_CONFIG_PATH, exist_ok=True) @@ -505,6 +510,7 @@ def save_credentials_from_whoami( config = Config( coordinator_url=coordinator_url, + engine_url=engine_url, auth=AuthConfig( access_token=tokens.access_token, refresh_token=tokens.refresh_token, diff --git a/libs/arcade-cli/arcade_cli/deploy.py b/libs/arcade-cli/arcade_cli/deploy.py index 6c554984a..3dd27a2b0 100644 --- a/libs/arcade-cli/arcade_cli/deploy.py +++ b/libs/arcade-cli/arcade_cli/deploy.py @@ -31,9 +31,9 @@ from arcade_cli.console import console from arcade_cli.secret import load_env_file from arcade_cli.utils import ( - compute_base_url, get_auth_headers, get_org_scoped_url, + resolve_engine_url, validate_and_get_config, ) @@ -814,7 +814,7 @@ def deploy_server_logic( server_name: str | None, server_version: str | None, secrets: str, - host: str, + host: str | None, port: int | None, force_tls: bool, force_no_tls: bool, @@ -839,7 +839,7 @@ def deploy_server_logic( # Step 1: Validate user is logged in console.print("\nValidating user is logged in...", style="dim") config = validate_and_get_config() - engine_url = compute_base_url(force_tls, force_no_tls, host, port) + engine_url = resolve_engine_url(host, port, force_tls, force_no_tls) user_email = config.user.email if config.user else "User" console.print(f"āœ“ {user_email} is logged in", style="green") diff --git a/libs/arcade-cli/arcade_cli/main.py b/libs/arcade-cli/arcade_cli/main.py index ca986c513..fb58f9e52 100644 --- a/libs/arcade-cli/arcade_cli/main.py +++ b/libs/arcade-cli/arcade_cli/main.py @@ -34,7 +34,7 @@ from arcade_cli.utils import ( ModelSpec, Provider, - compute_base_url, + derive_engine_url_from_coordinator, expand_provider_configs, get_default_model, get_eval_files, @@ -44,6 +44,7 @@ parse_output_paths, parse_provider_spec, require_dependency, + resolve_engine_url, resolve_provider_api_keys, version_callback, ) @@ -108,6 +109,7 @@ def login( return coordinator_url = build_coordinator_url(host, port) + engine_url = derive_engine_url_from_coordinator(coordinator_url) try: result = perform_oauth_login( @@ -116,16 +118,17 @@ def login( callback_timeout_seconds=timeout, ) - # Save credentials - save_credentials_from_whoami(result.tokens, result.whoami, coordinator_url) + save_credentials_from_whoami(result.tokens, result.whoami, coordinator_url, engine_url) - # Success message console.print(f"\nāœ… Logged in as {result.email}.", style="bold green") if result.selected_org and result.selected_project: console.print( f"\nActive project: {result.selected_org.name} / {result.selected_project.name}", style="dim", ) + console.print(f"Coordinator: {coordinator_url}", style="dim") + if engine_url: + console.print(f"Engine: {engine_url}", style="dim") console.print( "Run 'arcade org list' or 'arcade project list' to see available options.", style="dim", @@ -200,6 +203,19 @@ def whoami( else: console.print("\nNo active organization/project set.", style="yellow") + coordinator_display = config.coordinator_url or f"https://{PROD_COORDINATOR_HOST}" + engine_display = config.engine_url or f"https://{PROD_ENGINE_HOST} (fallback)" + coordinator_style = ( + "bold yellow" + if config.coordinator_url and PROD_COORDINATOR_HOST not in config.coordinator_url + else "bold" + ) + engine_style = ( + "bold yellow" if config.engine_url and PROD_ENGINE_HOST not in config.engine_url else "bold" + ) + console.print(f"\nCoordinator: {coordinator_display}", style=coordinator_style) + console.print(f"Engine: {engine_display}", style=engine_style) + console.print("\nRun 'arcade org list' or 'arcade project list' to see options.", style="dim") @@ -363,11 +379,14 @@ def show( tool: Optional[str] = typer.Option( None, "-t", "--tool", help="The specific tool to show details for" ), - host: str = typer.Option( - PROD_ENGINE_HOST, + host: Optional[str] = typer.Option( + None, "-h", "--host", - help="The Arcade Engine address to show the tools/servers of.", + help=( + "The Arcade Engine address to show the tools/servers of. " + "Defaults to the host from `arcade login`, then `api.arcade.dev`." + ), ), local: bool = typer.Option( False, @@ -946,11 +965,14 @@ def deploy( rich_help_panel="Advanced", click_type=click.Choice(["auto", "all", "skip"], case_sensitive=False), ), - host: str = typer.Option( - PROD_ENGINE_HOST, + host: Optional[str] = typer.Option( + None, "--host", "-h", - help="The Arcade Engine host to deploy to", + help=( + "The Arcade Engine host to deploy to. " + "Defaults to the host from `arcade login`, then `api.arcade.dev`." + ), hidden=True, ), port: Optional[int] = typer.Option( @@ -1039,11 +1061,14 @@ def upgrade( @cli.command(help="Open the Arcade Dashboard in a web browser", rich_help_panel="User") def dashboard( - host: str = typer.Option( - PROD_ENGINE_HOST, + host: Optional[str] = typer.Option( + None, "-h", "--host", - help="The Arcade Engine host that serves the dashboard.", + help=( + "The Arcade Engine host that serves the dashboard. " + "Defaults to the host from `arcade login`, then `api.arcade.dev`." + ), ), port: Optional[int] = typer.Option( None, @@ -1077,8 +1102,7 @@ def dashboard( if local: host = "localhost" - # Construct base URL (for both health check and dashboard) - base_url = compute_base_url(force_tls, force_no_tls, host, port) + base_url = resolve_engine_url(host, port, force_tls, force_no_tls) dashboard_url = f"{base_url}/dashboard" # Try to hit /health endpoint on engine and warn if it is down diff --git a/libs/arcade-cli/arcade_cli/org.py b/libs/arcade-cli/arcade_cli/org.py index bd09cba8c..76a61c74c 100644 --- a/libs/arcade-cli/arcade_cli/org.py +++ b/libs/arcade-cli/arcade_cli/org.py @@ -1,5 +1,6 @@ +from typing import Optional + import typer -from arcade_core.constants import PROD_COORDINATOR_HOST from arcade_cli.authn import ( fetch_organizations, @@ -9,8 +10,8 @@ from arcade_cli.console import console from arcade_cli.usage.command_tracker import TrackedTyper, TrackedTyperGroup from arcade_cli.utils import ( - compute_base_url, handle_cli_error, + resolve_coordinator_url, ) app = TrackedTyper( @@ -22,26 +23,21 @@ pretty_exceptions_short=True, ) -state = { - "coordinator_url": compute_base_url( - force_tls=False, - force_no_tls=False, - host=PROD_COORDINATOR_HOST, - port=None, - default_port=None, - ) -} +state: dict[str, str] = {} @app.callback() def main( - host: str = typer.Option( - PROD_COORDINATOR_HOST, + host: Optional[str] = typer.Option( + None, "--host", "-h", - help="The Arcade Coordinator host.", + help=( + "The Arcade Coordinator host. Defaults to the host from `arcade login`, " + "then `cloud.arcade.dev`." + ), ), - port: int = typer.Option( + port: Optional[int] = typer.Option( None, "--port", "-p", @@ -59,8 +55,7 @@ def main( ), ) -> None: """Configure Coordinator connection options for organization commands.""" - coordinator_url = compute_base_url(force_tls, force_no_tls, host, port, default_port=None) - state["coordinator_url"] = coordinator_url + state["coordinator_url"] = resolve_coordinator_url(host, port, force_tls, force_no_tls) @app.command("list", help="List organizations you belong to") diff --git a/libs/arcade-cli/arcade_cli/project.py b/libs/arcade-cli/arcade_cli/project.py index cc0640889..42a11c670 100644 --- a/libs/arcade-cli/arcade_cli/project.py +++ b/libs/arcade-cli/arcade_cli/project.py @@ -1,12 +1,13 @@ +from typing import Optional + import typer -from arcade_core.constants import PROD_COORDINATOR_HOST from arcade_cli.authn import fetch_projects from arcade_cli.console import console from arcade_cli.usage.command_tracker import TrackedTyper, TrackedTyperGroup from arcade_cli.utils import ( - compute_base_url, handle_cli_error, + resolve_coordinator_url, ) app = TrackedTyper( @@ -18,26 +19,21 @@ pretty_exceptions_short=True, ) -state = { - "coordinator_url": compute_base_url( - force_tls=False, - force_no_tls=False, - host=PROD_COORDINATOR_HOST, - port=None, - default_port=None, - ) -} +state: dict[str, str] = {} @app.callback() def main( - host: str = typer.Option( - PROD_COORDINATOR_HOST, + host: Optional[str] = typer.Option( + None, "--host", "-h", - help="The Arcade Coordinator host.", + help=( + "The Arcade Coordinator host. Defaults to the host from `arcade login`, " + "then `cloud.arcade.dev`." + ), ), - port: int = typer.Option( + port: Optional[int] = typer.Option( None, "--port", "-p", @@ -55,8 +51,7 @@ def main( ), ) -> None: """Configure Coordinator connection options for project commands.""" - coordinator_url = compute_base_url(force_tls, force_no_tls, host, port, default_port=None) - state["coordinator_url"] = coordinator_url + state["coordinator_url"] = resolve_coordinator_url(host, port, force_tls, force_no_tls) @app.command("list", help="List projects in the active organization") diff --git a/libs/arcade-cli/arcade_cli/secret.py b/libs/arcade-cli/arcade_cli/secret.py index d88ec1ea4..118d0999b 100644 --- a/libs/arcade-cli/arcade_cli/secret.py +++ b/libs/arcade-cli/arcade_cli/secret.py @@ -1,14 +1,15 @@ +from typing import Optional + import httpx import typer -from arcade_core.constants import PROD_ENGINE_HOST from rich.table import Table from arcade_cli.console import console from arcade_cli.usage.command_tracker import TrackedTyper, TrackedTyperGroup from arcade_cli.utils import ( - compute_base_url, get_auth_headers, get_org_scoped_url, + resolve_engine_url, ) app = TrackedTyper( @@ -20,22 +21,21 @@ pretty_exceptions_short=True, ) -state = { - "engine_url": compute_base_url( - host=PROD_ENGINE_HOST, port=None, force_tls=False, force_no_tls=False - ) -} +state: dict[str, str] = {} @app.callback() def main( - host: str = typer.Option( - PROD_ENGINE_HOST, + host: Optional[str] = typer.Option( + None, "--host", "-h", - help="The Arcade Engine host.", + help=( + "The Arcade Engine host. Defaults to the host from `arcade login`, " + "then `api.arcade.dev`." + ), ), - port: int = typer.Option( + port: Optional[int] = typer.Option( None, "--port", "-p", @@ -62,8 +62,7 @@ def main( arcade secret list arcade secret unset KEY1 KEY2 KEY3 """ - engine_url = compute_base_url(force_tls, force_no_tls, host, port) - state["engine_url"] = engine_url + state["engine_url"] = resolve_engine_url(host, port, force_tls, force_no_tls) @app.command("set", help="Set tool secret(s) using KEY=VALUE pairs or from .env file") diff --git a/libs/arcade-cli/arcade_cli/server.py b/libs/arcade-cli/arcade_cli/server.py index 03fe900e3..2ae08cdda 100644 --- a/libs/arcade-cli/arcade_cli/server.py +++ b/libs/arcade-cli/arcade_cli/server.py @@ -6,7 +6,6 @@ import httpx import typer -from arcade_core.constants import PROD_ENGINE_HOST from arcadepy import NotFoundError from arcadepy.types import WorkerHealthResponse, WorkerResponse from dateutil import parser @@ -15,11 +14,11 @@ from arcade_cli.console import console from arcade_cli.usage.command_tracker import TrackedTyper, TrackedTyperGroup from arcade_cli.utils import ( - compute_base_url, get_arcade_client, get_auth_headers, get_org_scoped_url, handle_cli_error, + resolve_engine_url, ) @@ -109,22 +108,21 @@ def _parse_time_string(time_str: str) -> datetime: pretty_exceptions_short=True, ) -state = { - "engine_url": compute_base_url( - host=PROD_ENGINE_HOST, port=None, force_tls=False, force_no_tls=False - ) -} +state: dict[str, str] = {} @app.callback() def main( - host: str = typer.Option( - PROD_ENGINE_HOST, + host: Optional[str] = typer.Option( + None, "--host", "-h", - help="The Arcade Engine host.", + help=( + "The Arcade Engine host. Defaults to the host from `arcade login`, " + "then `api.arcade.dev`." + ), ), - port: int = typer.Option( + port: Optional[int] = typer.Option( None, "--port", "-p", @@ -144,8 +142,7 @@ def main( """ Manage users in the system. """ - engine_url = compute_base_url(force_tls, force_no_tls, host, port) - state["engine_url"] = engine_url + state["engine_url"] = resolve_engine_url(host, port, force_tls, force_no_tls) @app.command("list", help="List all servers") diff --git a/libs/arcade-cli/arcade_cli/show.py b/libs/arcade-cli/arcade_cli/show.py index c53658998..a7fbf0b16 100644 --- a/libs/arcade-cli/arcade_cli/show.py +++ b/libs/arcade-cli/arcade_cli/show.py @@ -15,7 +15,7 @@ def show_logic( toolkit: Optional[str], tool: Optional[str], - host: str, + host: Optional[str], local: bool, port: Optional[int], force_tls: bool, diff --git a/libs/arcade-cli/arcade_cli/utils.py b/libs/arcade-cli/arcade_cli/utils.py index 54901538a..d22a65630 100644 --- a/libs/arcade-cli/arcade_cli/utils.py +++ b/libs/arcade-cli/arcade_cli/utils.py @@ -1,5 +1,6 @@ import importlib.util import ipaddress +import logging import os import shlex import sys @@ -14,9 +15,10 @@ from urllib.parse import urlparse import idna +import yaml from arcade_core import ToolCatalog, Toolkit from arcade_core.config_model import Config -from arcade_core.constants import LOCALHOST +from arcade_core.constants import LOCALHOST, PROD_COORDINATOR_HOST, PROD_ENGINE_HOST from arcade_core.discovery import ( analyze_files_for_tools, build_minimal_toolkit, @@ -483,6 +485,162 @@ def create_cli_catalog_local() -> ToolCatalog: return _discover_installed_toolkits(catalog) +_resolution_logger = logging.getLogger(__name__) + + +def _load_config_or_none() -> Config | None: + """Load the credentials config if present, returning None when it isn't. + + URL resolution happens on every CLI command, so this swallows any reason + the credentials file can't be loaded (missing, malformed YAML, validation + failure, permission denied) and falls back to defaults. A broken file + must never crash unrelated commands; a warning is logged so the cause is + discoverable. + """ + try: + return Config.load_from_file() + except FileNotFoundError: + return None + except (ValueError, yaml.YAMLError, OSError) as exc: + _resolution_logger.warning( + "Could not load credentials for host resolution; using defaults: %s", exc + ) + return None + + +def _resolve_url( + host: str | None, + port: int | None, + force_tls: bool, + force_no_tls: bool, + saved_url: str | None, + prod_host: str, + default_port: int | None, +) -> str: + """ + Resolve a service base URL from explicit flags, the URL saved at login, + and a hardcoded production host. + + With no flags at all, the saved URL is returned verbatim (or the prod + default if nothing was saved). When ``--host`` is set, it is a clean + override and the saved URL is ignored entirely. When ``--port``, + ``--tls``, or ``--no-tls`` is set without ``--host``, those flags layer + on top of the saved host so users can tweak the connection (port, TLS) + without losing the environment they logged into. Without a saved URL, + partial flags fall back to the production host. + """ + if host is None and port is None and not force_tls and not force_no_tls: + return saved_url or compute_base_url( + False, False, prod_host, None, default_port=default_port + ) + + if host is None and saved_url: + parsed = urlparse(saved_url) + if parsed.hostname: + host = parsed.hostname + if port is None and parsed.port: + port = parsed.port + # Preserve the saved scheme unless the user is overriding it. + if not force_tls and not force_no_tls: + if parsed.scheme == "https": + force_tls = True + elif parsed.scheme == "http": + force_no_tls = True + + return compute_base_url( + force_tls, + force_no_tls, + host or prod_host, + port, + default_port=default_port, + ) + + +def resolve_engine_url( + host: str | None, + port: int | None, + force_tls: bool, + force_no_tls: bool, +) -> str: + """ + Resolve the Arcade Engine base URL for a CLI command. + + Resolution order: + 1. Explicit per-command flags (``-h``, ``-p``, ``--tls``, ``--no-tls``). + ``-h`` replaces the saved host wholesale; the others layer on top of + the saved host so the connection stays in the same environment. + 2. ``engine_url`` persisted in ``~/.arcade/credentials.yaml`` at login. + 3. Production default (``https://api.arcade.dev``). + """ + config = _load_config_or_none() + return _resolve_url( + host, + port, + force_tls, + force_no_tls, + saved_url=config.engine_url if config else None, + prod_host=PROD_ENGINE_HOST, + default_port=9099, + ) + + +def resolve_coordinator_url( + host: str | None, + port: int | None, + force_tls: bool, + force_no_tls: bool, +) -> str: + """ + Resolve the Arcade Coordinator base URL for a CLI command. + + Mirrors ``resolve_engine_url`` against the saved ``coordinator_url`` and + the production Coordinator host. + """ + config = _load_config_or_none() + return _resolve_url( + host, + port, + force_tls, + force_no_tls, + saved_url=config.coordinator_url if config else None, + prod_host=PROD_COORDINATOR_HOST, + default_port=None, + ) + + +def derive_engine_url_from_coordinator(coordinator_url: str) -> str | None: + """ + Derive the Arcade Engine base URL from a Coordinator URL. + + Convention: every Arcade environment uses ``cloud.X`` for the Coordinator and + ``api.X`` for the Engine. The port (if any) is preserved. Local development + uses ``localhost:8000`` for the Coordinator and ``localhost:9099`` for the + Engine. + + Returns None when the coordinator URL does not match a known convention so + callers can fall back to other sources (saved config, prod default, etc.) + rather than constructing an invalid URL. + """ + try: + parsed = urlparse(coordinator_url) + except ValueError: + return None + + hostname = parsed.hostname + if hostname is None or not parsed.scheme: + return None + + if hostname.startswith("cloud."): + new_hostname = "api." + hostname[len("cloud.") :] + netloc = f"{new_hostname}:{parsed.port}" if parsed.port else new_hostname + return f"{parsed.scheme}://{netloc}" + + if hostname in ("localhost", "127.0.0.1", "0.0.0.0"): # noqa: S104 + return "http://localhost:9099" + + return None + + def compute_base_url( force_tls: bool, force_no_tls: bool, @@ -577,13 +735,13 @@ def compute_base_url( def get_tools_from_engine( - host: str, + host: str | None, port: int | None = None, force_tls: bool = False, force_no_tls: bool = False, toolkit: str | None = None, ) -> list[ToolDefinition]: - base_url = compute_base_url(force_tls, force_no_tls, host, port) + base_url = resolve_engine_url(host, port, force_tls, force_no_tls) client = get_arcade_client(base_url) tools = [] diff --git a/libs/arcade-core/arcade_core/config_model.py b/libs/arcade-core/arcade_core/config_model.py index e6ab32ec7..1eab05cbe 100644 --- a/libs/arcade-core/arcade_core/config_model.py +++ b/libs/arcade-core/arcade_core/config_model.py @@ -124,6 +124,11 @@ class Config(BaseConfig): Base URL of the Arcade Coordinator used for authentication flows. """ + engine_url: str | None = None + """ + Base URL of the Arcade Engine used for tool, server, secret, and deploy operations. + """ + auth: AuthConfig | None = None """ OAuth authentication configuration. diff --git a/libs/tests/cli/test_callback_url_resolution.py b/libs/tests/cli/test_callback_url_resolution.py new file mode 100644 index 000000000..5c43403b4 --- /dev/null +++ b/libs/tests/cli/test_callback_url_resolution.py @@ -0,0 +1,197 @@ +"""Tests that each CLI command callback resolves its target URL via the +``resolve_engine_url`` / ``resolve_coordinator_url`` helpers, so the URL saved +at login is used as the default and explicit flags still override it. +""" + +from datetime import datetime, timedelta + +import pytest +from arcade_core.config_model import AuthConfig, Config +from typer.testing import CliRunner + +from arcade_cli.org import app as org_app +from arcade_cli.project import app as project_app +from arcade_cli.secret import app as secret_app +from arcade_cli.server import app as server_app + + +@pytest.fixture +def isolated_config_dir(tmp_path, monkeypatch): + monkeypatch.setenv("ARCADE_WORK_DIR", str(tmp_path)) + return tmp_path + + +@pytest.fixture +def staged_config(isolated_config_dir): + Config( + coordinator_url="https://cloud.example.dev", + engine_url="https://api.example.dev", + auth=AuthConfig( + access_token="x", + refresh_token="y", + expires_at=datetime.now() + timedelta(hours=1), + ), + ).save_to_file() + + +@pytest.fixture +def runner(): + return CliRunner() + + +# --------------------------------------------------------------------------- +# server +# --------------------------------------------------------------------------- + + +def test_server_callback_uses_saved_engine_url(staged_config, runner) -> None: + import arcade_cli.server as mod + + runner.invoke(server_app, ["list"], catch_exceptions=True) + assert mod.state["engine_url"] == "https://api.example.dev" + + +def test_server_callback_explicit_host_overrides_saved(staged_config, runner) -> None: + import arcade_cli.server as mod + + runner.invoke(server_app, ["-h", "api.arcade.dev", "list"], catch_exceptions=True) + assert mod.state["engine_url"] == "https://api.arcade.dev" + + +def test_server_callback_no_creds_falls_back_to_prod(isolated_config_dir, runner) -> None: + import arcade_cli.server as mod + + runner.invoke(server_app, ["list"], catch_exceptions=True) + assert mod.state["engine_url"] == "https://api.arcade.dev" + + +# --------------------------------------------------------------------------- +# secret +# --------------------------------------------------------------------------- + + +def test_secret_callback_uses_saved_engine_url(staged_config, runner) -> None: + import arcade_cli.secret as mod + + runner.invoke(secret_app, ["list"], catch_exceptions=True) + assert mod.state["engine_url"] == "https://api.example.dev" + + +def test_secret_callback_explicit_host_overrides_saved(staged_config, runner) -> None: + import arcade_cli.secret as mod + + runner.invoke(secret_app, ["-h", "api.arcade.dev", "list"], catch_exceptions=True) + assert mod.state["engine_url"] == "https://api.arcade.dev" + + +def test_secret_callback_no_creds_falls_back_to_prod(isolated_config_dir, runner) -> None: + import arcade_cli.secret as mod + + runner.invoke(secret_app, ["list"], catch_exceptions=True) + assert mod.state["engine_url"] == "https://api.arcade.dev" + + +# --------------------------------------------------------------------------- +# org +# --------------------------------------------------------------------------- + + +def test_org_callback_uses_saved_coordinator_url(staged_config, runner) -> None: + import arcade_cli.org as mod + + runner.invoke(org_app, ["list"], catch_exceptions=True) + assert mod.state["coordinator_url"] == "https://cloud.example.dev" + + +def test_org_callback_explicit_host_overrides_saved(staged_config, runner) -> None: + import arcade_cli.org as mod + + runner.invoke(org_app, ["-h", "cloud.arcade.dev", "list"], catch_exceptions=True) + assert mod.state["coordinator_url"] == "https://cloud.arcade.dev" + + +def test_org_callback_no_creds_falls_back_to_prod(isolated_config_dir, runner) -> None: + import arcade_cli.org as mod + + runner.invoke(org_app, ["list"], catch_exceptions=True) + assert mod.state["coordinator_url"] == "https://cloud.arcade.dev" + + +# --------------------------------------------------------------------------- +# project +# --------------------------------------------------------------------------- + + +def test_project_callback_uses_saved_coordinator_url(staged_config, runner) -> None: + import arcade_cli.project as mod + + runner.invoke(project_app, ["list"], catch_exceptions=True) + assert mod.state["coordinator_url"] == "https://cloud.example.dev" + + +def test_project_callback_explicit_host_overrides_saved(staged_config, runner) -> None: + import arcade_cli.project as mod + + runner.invoke(project_app, ["-h", "cloud.arcade.dev", "list"], catch_exceptions=True) + assert mod.state["coordinator_url"] == "https://cloud.arcade.dev" + + +def test_project_callback_no_creds_falls_back_to_prod(isolated_config_dir, runner) -> None: + import arcade_cli.project as mod + + runner.invoke(project_app, ["list"], catch_exceptions=True) + assert mod.state["coordinator_url"] == "https://cloud.arcade.dev" + + +# --------------------------------------------------------------------------- +# show / deploy / dashboard (commands on the root CLI) +# --------------------------------------------------------------------------- + + +def test_show_uses_saved_engine_url(staged_config, runner) -> None: + from unittest.mock import patch + + from arcade_cli.main import cli + + with patch("arcade_cli.show.get_tools_from_engine", return_value=[]) as mock_get_tools: + runner.invoke(cli, ["show"], catch_exceptions=True) + + assert mock_get_tools.call_count == 1 + kwargs = mock_get_tools.call_args.kwargs + args = mock_get_tools.call_args.args + received_host = kwargs.get("host", args[0] if args else None) + assert received_host is None or received_host == "https://api.example.dev" + + +def test_dashboard_uses_saved_engine_url(staged_config, runner) -> None: + from unittest.mock import MagicMock, patch + + from arcade_cli.main import cli + + with ( + patch("arcade_cli.main._open_browser", return_value=True) as mock_open, + patch("arcade_cli.utils.validate_and_get_config", return_value=MagicMock()), + patch("arcade_cli.main.log_engine_health", return_value=None), + ): + result = runner.invoke(cli, ["dashboard"], catch_exceptions=False) + + assert result.exit_code == 0 + mock_open.assert_called_once_with("https://api.example.dev/dashboard") + + +def test_dashboard_explicit_host_overrides_saved(staged_config, runner) -> None: + from unittest.mock import MagicMock, patch + + from arcade_cli.main import cli + + with ( + patch("arcade_cli.main._open_browser", return_value=True) as mock_open, + patch("arcade_cli.utils.validate_and_get_config", return_value=MagicMock()), + patch("arcade_cli.main.log_engine_health", return_value=None), + ): + result = runner.invoke( + cli, ["dashboard", "-h", "api.arcade.dev"], catch_exceptions=False + ) + + assert result.exit_code == 0 + mock_open.assert_called_once_with("https://api.arcade.dev/dashboard") diff --git a/libs/tests/cli/test_host_derivation.py b/libs/tests/cli/test_host_derivation.py new file mode 100644 index 000000000..5b295d6d5 --- /dev/null +++ b/libs/tests/cli/test_host_derivation.py @@ -0,0 +1,31 @@ +import pytest + +from arcade_cli.utils import derive_engine_url_from_coordinator + + +@pytest.mark.parametrize( + "coordinator,engine", + [ + ("https://cloud.arcade.dev", "https://api.arcade.dev"), + ("https://cloud.example.dev", "https://api.example.dev"), + ("https://cloud.example.dev:4443", "https://api.example.dev:4443"), + ("https://cloud.foo.test", "https://api.foo.test"), + ("http://localhost:8000", "http://localhost:9099"), + ("http://127.0.0.1:8000", "http://localhost:9099"), + ("http://0.0.0.0:8000", "http://localhost:9099"), + ], +) +def test_derives_engine_url(coordinator: str, engine: str) -> None: + assert derive_engine_url_from_coordinator(coordinator) == engine + + +def test_returns_none_for_unknown_convention() -> None: + assert derive_engine_url_from_coordinator("https://otherhost.example") is None + + +def test_returns_none_for_garbage() -> None: + assert derive_engine_url_from_coordinator("not-a-url") is None + + +def test_returns_none_for_empty_string() -> None: + assert derive_engine_url_from_coordinator("") is None diff --git a/libs/tests/cli/test_host_resolution.py b/libs/tests/cli/test_host_resolution.py new file mode 100644 index 000000000..af8f1e92d --- /dev/null +++ b/libs/tests/cli/test_host_resolution.py @@ -0,0 +1,162 @@ +from datetime import datetime, timedelta + +import pytest +from arcade_core.config_model import AuthConfig, Config + +from arcade_cli.utils import resolve_coordinator_url, resolve_engine_url + + +@pytest.fixture +def isolated_config_dir(tmp_path, monkeypatch): + monkeypatch.setenv("ARCADE_WORK_DIR", str(tmp_path)) + return tmp_path + + +@pytest.fixture +def staged_config(isolated_config_dir): + Config( + coordinator_url="https://cloud.example.dev", + engine_url="https://api.example.dev", + auth=AuthConfig( + access_token="x", + refresh_token="y", + expires_at=datetime.now() + timedelta(hours=1), + ), + ).save_to_file() + + +# --- Engine ---------------------------------------------------------------- + + +def test_resolve_engine_url_uses_saved_when_no_flags(staged_config) -> None: + assert resolve_engine_url(None, None, False, False) == "https://api.example.dev" + + +def test_resolve_engine_url_explicit_host_wins(staged_config) -> None: + assert ( + resolve_engine_url("api.arcade.dev", None, False, False) == "https://api.arcade.dev" + ) + + +def test_resolve_engine_url_explicit_port_layers_on_saved_host(staged_config) -> None: + # Only --port is set, so keep the saved host and apply the explicit port. + assert resolve_engine_url(None, 9099, False, False) == "https://api.example.dev:9099" + + +def test_resolve_engine_url_force_tls_keeps_saved_host(staged_config) -> None: + assert resolve_engine_url(None, None, True, False) == "https://api.example.dev" + + +def test_resolve_engine_url_force_no_tls_keeps_saved_host(staged_config) -> None: + assert resolve_engine_url(None, None, False, True) == "http://api.example.dev" + + +def test_resolve_engine_url_explicit_host_does_not_inherit_saved_port( + isolated_config_dir, +) -> None: + # Saved URL has a port; explicit --host is a clean override, so the saved port should NOT carry. + Config( + coordinator_url="https://cloud.example.dev:4443", + engine_url="https://api.example.dev:4443", + auth=AuthConfig( + access_token="x", + refresh_token="y", + expires_at=datetime.now() + timedelta(hours=1), + ), + ).save_to_file() + assert resolve_engine_url("api.arcade.dev", None, False, False) == "https://api.arcade.dev" + + +def test_resolve_engine_url_partial_flags_preserve_saved_port( + isolated_config_dir, +) -> None: + # Saved URL has a custom port; user passes only --no-tls. Should keep both saved host and port. + Config( + coordinator_url="https://cloud.example.dev:4443", + engine_url="https://api.example.dev:4443", + auth=AuthConfig( + access_token="x", + refresh_token="y", + expires_at=datetime.now() + timedelta(hours=1), + ), + ).save_to_file() + assert resolve_engine_url(None, None, False, True) == "http://api.example.dev:4443" + + +def test_resolve_engine_url_partial_flag_no_creds_uses_prod(isolated_config_dir) -> None: + # No saved engine_url; partial flag should fall back to prod default for host. + assert resolve_engine_url(None, 9099, False, False) == "https://api.arcade.dev:9099" + + +def test_resolve_engine_url_no_creds_falls_back_to_prod(isolated_config_dir) -> None: + assert resolve_engine_url(None, None, False, False) == "https://api.arcade.dev" + + +def test_resolve_engine_url_creds_without_engine_url_falls_back_to_prod( + isolated_config_dir, +) -> None: + Config(coordinator_url="https://cloud.arcade.dev").save_to_file() + assert resolve_engine_url(None, None, False, False) == "https://api.arcade.dev" + + +# --- Coordinator ----------------------------------------------------------- + + +def test_resolve_coordinator_url_uses_saved_when_no_flags(staged_config) -> None: + assert resolve_coordinator_url(None, None, False, False) == "https://cloud.example.dev" + + +def test_resolve_coordinator_url_explicit_host_wins(staged_config) -> None: + assert ( + resolve_coordinator_url("cloud.arcade.dev", None, False, False) + == "https://cloud.arcade.dev" + ) + + +def test_resolve_coordinator_url_no_creds_falls_back_to_prod(isolated_config_dir) -> None: + assert resolve_coordinator_url(None, None, False, False) == "https://cloud.arcade.dev" + + +def test_resolve_coordinator_url_force_no_tls_keeps_saved_host(staged_config) -> None: + assert resolve_coordinator_url(None, None, False, True) == "http://cloud.example.dev" + + +def test_resolve_coordinator_url_partial_flag_no_creds_uses_prod( + isolated_config_dir, +) -> None: + assert ( + resolve_coordinator_url(None, 8000, False, False) == "https://cloud.arcade.dev:8000" + ) + + +# --- Robustness against corrupted credentials.yaml -------------------------- + + +def test_resolve_engine_url_falls_back_when_credentials_is_malformed_yaml( + isolated_config_dir, +) -> None: + # A bad YAML file should not crash CLI commands. Fall back to prod default. + creds = isolated_config_dir / "credentials.yaml" + creds.write_text("this: is\n not: valid: yaml: at: all\n", encoding="utf-8") + assert resolve_engine_url(None, None, False, False) == "https://api.arcade.dev" + + +def test_resolve_engine_url_falls_back_when_credentials_is_unreadable( + isolated_config_dir, monkeypatch +) -> None: + creds = isolated_config_dir / "credentials.yaml" + creds.write_text("cloud: {}\n", encoding="utf-8") + + def _raise_oserror(*_args, **_kwargs): + raise OSError("simulated read failure") + + monkeypatch.setattr("pathlib.Path.read_text", _raise_oserror) + assert resolve_engine_url(None, None, False, False) == "https://api.arcade.dev" + + +def test_resolve_coordinator_url_falls_back_when_credentials_is_malformed_yaml( + isolated_config_dir, +) -> None: + creds = isolated_config_dir / "credentials.yaml" + creds.write_text("garbage: [unterminated\n", encoding="utf-8") + assert resolve_coordinator_url(None, None, False, False) == "https://cloud.arcade.dev" diff --git a/libs/tests/cli/test_login_persistence.py b/libs/tests/cli/test_login_persistence.py new file mode 100644 index 000000000..4e8327405 --- /dev/null +++ b/libs/tests/cli/test_login_persistence.py @@ -0,0 +1,54 @@ +import pytest +from arcade_core.auth_tokens import TokenResponse +from arcade_core.config_model import Config + +from arcade_cli.authn import OrgInfo, ProjectInfo, WhoAmIResponse, save_credentials_from_whoami + + +@pytest.fixture +def isolated_config_dir(tmp_path, monkeypatch): + monkeypatch.setenv("ARCADE_WORK_DIR", str(tmp_path)) + return tmp_path + + +def _fake_tokens() -> TokenResponse: + return TokenResponse( + access_token="access", + refresh_token="refresh", + expires_in=3600, + token_type="Bearer", + ) + + +def _fake_whoami() -> WhoAmIResponse: + return WhoAmIResponse( + account_id="acct_1", + email="someone@example.dev", + organizations=[OrgInfo(org_id="org_1", name="Org One", is_default=True)], + projects=[ProjectInfo(project_id="proj_1", name="Proj One", is_default=True)], + ) + + +def test_save_credentials_persists_engine_url(isolated_config_dir) -> None: + save_credentials_from_whoami( + tokens=_fake_tokens(), + whoami=_fake_whoami(), + coordinator_url="https://cloud.example.dev", + engine_url="https://api.example.dev", + ) + + cfg = Config.load_from_file() + assert cfg.coordinator_url == "https://cloud.example.dev" + assert cfg.engine_url == "https://api.example.dev" + + +def test_save_credentials_without_engine_url_leaves_field_unset(isolated_config_dir) -> None: + save_credentials_from_whoami( + tokens=_fake_tokens(), + whoami=_fake_whoami(), + coordinator_url="https://cloud.arcade.dev", + ) + + cfg = Config.load_from_file() + assert cfg.coordinator_url == "https://cloud.arcade.dev" + assert cfg.engine_url is None diff --git a/libs/tests/cli/test_whoami.py b/libs/tests/cli/test_whoami.py new file mode 100644 index 000000000..57de0a349 --- /dev/null +++ b/libs/tests/cli/test_whoami.py @@ -0,0 +1,73 @@ +from datetime import datetime, timedelta + +import pytest +from arcade_core.config_model import AuthConfig, Config, ContextConfig, UserConfig +from typer.testing import CliRunner + +from arcade_cli.main import cli + + +@pytest.fixture +def isolated_config_dir(tmp_path, monkeypatch): + # Config.get_config_file_path() reads ARCADE_WORK_DIR dynamically. + monkeypatch.setenv("ARCADE_WORK_DIR", str(tmp_path)) + # CREDENTIALS_FILE_PATH in arcade_core.constants is frozen at import time + # and does NOT honor ARCADE_WORK_DIR; the main CLI callback uses it to gate + # logged-in commands. Point it at the same file Config writes to so the + # gate sees the staged credentials. + credentials_file = str(tmp_path / "credentials.yaml") + monkeypatch.setattr("arcade_cli.authn.CREDENTIALS_FILE_PATH", credentials_file) + return tmp_path + + +@pytest.fixture +def runner(): + return CliRunner() + + +def _staged_auth() -> AuthConfig: + return AuthConfig( + access_token="x", + refresh_token="y", + expires_at=datetime.now() + timedelta(hours=1), + ) + + +def _staged_context() -> ContextConfig: + return ContextConfig( + org_id="org_1", + org_name="Org One", + project_id="proj_1", + project_name="Proj One", + ) + + +def test_whoami_prints_both_urls_when_engine_url_saved(isolated_config_dir, runner) -> None: + Config( + coordinator_url="https://cloud.example.dev", + engine_url="https://api.example.dev", + auth=_staged_auth(), + user=UserConfig(email="someone@example.dev"), + context=_staged_context(), + ).save_to_file() + + result = runner.invoke(cli, ["whoami"], catch_exceptions=False) + + assert result.exit_code == 0 + assert "https://cloud.example.dev" in result.stdout + assert "https://api.example.dev" in result.stdout + + +def test_whoami_prints_fallback_engine_url_when_unsaved(isolated_config_dir, runner) -> None: + Config( + coordinator_url="https://cloud.arcade.dev", + auth=_staged_auth(), + user=UserConfig(email="someone@example.dev"), + context=_staged_context(), + ).save_to_file() + + result = runner.invoke(cli, ["whoami"], catch_exceptions=False) + + assert result.exit_code == 0 + assert "https://cloud.arcade.dev" in result.stdout + assert "https://api.arcade.dev" in result.stdout diff --git a/libs/tests/core/test_config_engine_url.py b/libs/tests/core/test_config_engine_url.py new file mode 100644 index 000000000..358fb58cc --- /dev/null +++ b/libs/tests/core/test_config_engine_url.py @@ -0,0 +1,36 @@ +import pytest + +from arcade_core.config_model import Config + + +@pytest.fixture +def isolated_config_dir(tmp_path, monkeypatch): + monkeypatch.setenv("ARCADE_WORK_DIR", str(tmp_path)) + return tmp_path + + +def test_config_persists_engine_url(isolated_config_dir) -> None: + cfg = Config( + coordinator_url="https://cloud.example.dev", + engine_url="https://api.example.dev", + ) + cfg.save_to_file() + + loaded = Config.load_from_file() + + assert loaded.engine_url == "https://api.example.dev" + assert loaded.coordinator_url == "https://cloud.example.dev" + + +def test_config_engine_url_optional(isolated_config_dir) -> None: + cfg = Config(coordinator_url="https://cloud.arcade.dev") + cfg.save_to_file() + + loaded = Config.load_from_file() + + assert loaded.engine_url is None + + +def test_config_engine_url_defaults_to_none() -> None: + cfg = Config() + assert cfg.engine_url is None