arcade-mcp/libs/arcade-cli/arcade_cli/server.py
Nate Barbettini aae9b3a49c
feat: Support multiple orgs & projects in Arcade CLI (#717)
Fixes [PLT-720: Refactor CLI to support multiple orgs +
projects](https://linear.app/arcadedev/issue/PLT-720/refactor-cli-to-support-multiple-orgs-projects)

This PR removes the legacy login flow (login to get an API key) from
Arcade CLI. Believe it or not, this flow predates the ability to get an
API key from the Dashboard, or even the Dashboard itself!

Notable changes:

**Legacy handling** - When a user with an existing `credentials.yaml`
updates the CLI, they will get instructions on fixing their old
credentials:
<img width="978" height="146" alt="Screenshot 2025-12-08 at 10 10 37"
src="https://github.com/user-attachments/assets/5aeaef2c-bef7-4642-a2f7-f917b257c94b"
/>

Any commands that require login (non-public commands) will be blocked
with the above message until `arcade logout / arcade login` is performed
again.

**New login flow**

```sh
arcade login
Opening a browser to log you in...

 Logged in as nate@arcade.dev.

Active project: Nate Barbettini's organization / Default project
Run 'arcade org list' or 'arcade project list' to see available options.
```

**List and set the active organization**
```sh
arcade org list
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━┓
┃ Name                           ┃ ID                                   ┃ Default ┃ Active ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━┩
│ Nate Barbettini's organization │ 1c64968e-fdc5-4c55-8612-2ce46cd7881b │ ✓       │ ✓      │
│ Sergio 743                     │ 1f1f6184-58dc-4bac-bdde-b9184e43fdf3 │         │        │
└────────────────────────────────┴──────────────────────────────────────┴─────────┴────────┘

Use 'arcade org set <org_id>' to switch organizations.
```
```sh
arcade org set 1c64968e-fdc5-4c55-8612-2ce46cd7881b 

✓ Switched to organization: Nate Barbettini's organization
  Active project: Default project
```

**List and set the active project**
```sh
arcade project list

Active organization: Nate Barbettini's organization
Use 'arcade org list' and 'arcade org set <org_id>' to switch organizations.

┏━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━┓
┃ Name            ┃ ID                                   ┃ Default ┃ Active ┃
┡━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━┩
│ Default project │ 35166bf3-6e68-481e-bf16-f747fadc6c22 │ ✓       │ ✓      │
│ Second project  │ 62963205-31ea-4fda-9fc4-af10db89c06f │         │        │
└─────────────────┴──────────────────────────────────────┴─────────┴────────┘

Use 'arcade project set <project_id>' to switch projects.
```
```sh
arcade project set 35166bf3-6e68-481e-bf16-f747fadc6c22
✓ Switched to project: Default project
```

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Migrates CLI to OAuth2 (PKCE) with saved org/project context, adds
org/project commands, rewrites Engine calls to org-scoped endpoints, and
bumps core packages.
> 
> - **Auth & Config**
> - Implement OAuth2 Authorization Code + PKCE (`arcade_cli/authn.py`)
with local callback server and Jinja templates.
> - Persist tokens and active `context` (org/project) in
`credentials.yaml` via updated config models
(`arcade_core/config_model.py`).
> - Add token refresh and CLI config fetch utilities
(`arcade_core/auth_tokens.py`).
> - Detect legacy API-key credentials and block protected commands until
re-login; add `whoami` command.
> - **Org/Project Management**
> - New subcommands: `arcade org list|set`, `arcade project list|set`
(fetch via Coordinator).
> - **Engine API usage (org-scoped)**
> - Introduce org/project URL rewriting transports
(`arcade_core/network/org_transport.py`) and helpers
(`get_org_scoped_url`, `get_arcade_client`, `get_auth_headers`).
> - Update `deploy`, `server`, and `secret` commands to use Bearer
tokens and org-scoped paths; adjust log streaming/status, secrets CRUD,
and deployment workflows.
> - **CLI UX**
> - Replace legacy login URLs/constants; add success/failure HTML
templates for browser callback.
>   - Tweak `dashboard` to health-check without credentials.
>   - Usage tracking now includes `org_id`/`project_id` properties.
> - **Tests**
> - Update tests for dashboard, secrets, utils, and usage identity
(OAuth `/whoami`).
> - **Dependencies & Versions**
> - Bump packages: `arcade-core@4.0.0`, `arcade-mcp-server@1.12.0`,
`arcade-serve@3.2.0`, `arcade-tdk@3.3.0`; add `authlib`.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
49702c2f74b9db15bb286d3ec71179b4e74a9134. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2025-12-11 12:58:55 -08:00

408 lines
12 KiB
Python

import asyncio
import json
import re
from datetime import datetime, timedelta, timezone
from typing import Optional
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
from rich.console import Console
from rich.table import Table
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,
)
console = Console()
def _format_timestamp_to_local(timestamp_str: str) -> str:
"""
Convert a UTC timestamp string to local timezone format.
Args:
timestamp_str: UTC timestamp in format "2025-10-22T21:08:23.508906574Z"
Returns:
Formatted timestamp string in local timezone
"""
try:
# Parse the UTC timestamp and convert to local timezone
utc_dt = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
local_dt = utc_dt.astimezone()
return local_dt.strftime("%Y-%m-%d %H:%M:%S %Z")
except (ValueError, TypeError):
# If parsing fails, return the original timestamp
return timestamp_str
def _parse_time_string(time_str: str) -> datetime:
"""
Parse a time string that can be either relative (e.g., "1h", "42m", "2d")
or absolute (e.g., "2025-10-03T12:24:36Z").
Args:
time_str: Time string in relative or absolute format
Returns:
datetime object in UTC timezone
Raises:
ValueError: If the time string cannot be parsed
"""
if not time_str:
raise ValueError("Time string cannot be empty")
# Handle relative time formats (e.g., "1h", "42m", "2d", "0m")
relative_pattern = r"^(\d+)([smhd])$"
match = re.match(relative_pattern, time_str.lower())
if match:
value = int(match.group(1))
unit = match.group(2)
now = datetime.now(timezone.utc)
if unit == "s":
delta = timedelta(seconds=value)
elif unit == "m":
delta = timedelta(minutes=value)
elif unit == "h":
delta = timedelta(hours=value)
elif unit == "d":
delta = timedelta(days=value)
else:
raise ValueError(f"Unsupported time unit: {unit}")
return now - delta
# Handle absolute time formats using dateutil parser
try:
parsed_dt = parser.parse(time_str)
# Ensure timezone awareness
if parsed_dt.tzinfo is None:
# Assume UTC if no timezone info
parsed_dt = parsed_dt.replace(tzinfo=timezone.utc)
else:
# Convert to UTC
parsed_dt = parsed_dt.astimezone(timezone.utc)
except (ValueError, TypeError) as e:
raise ValueError(f"Unable to parse time string '{time_str}': {e}")
return parsed_dt
app = TrackedTyper(
cls=TrackedTyperGroup,
add_completion=False,
no_args_is_help=True,
pretty_exceptions_enable=False,
pretty_exceptions_show_locals=False,
pretty_exceptions_short=True,
)
state = {
"engine_url": compute_base_url(
host=PROD_ENGINE_HOST, port=None, force_tls=False, force_no_tls=False
)
}
@app.callback()
def main(
host: str = typer.Option(
PROD_ENGINE_HOST,
"--host",
"-h",
help="The Arcade Engine host.",
),
port: int = typer.Option(
None,
"--port",
"-p",
help="The port of the Arcade Engine host.",
),
force_tls: bool = typer.Option(
False,
"--tls",
help="Whether to force TLS for the connection to the Arcade Engine.",
),
force_no_tls: bool = typer.Option(
False,
"--no-tls",
help="Whether to disable TLS for the connection to the Arcade Engine.",
),
) -> None:
"""
Manage users in the system.
"""
engine_url = compute_base_url(force_tls, force_no_tls, host, port)
state["engine_url"] = engine_url
@app.command("list", help="List all servers")
def list_servers(
debug: bool = typer.Option(
False,
"--debug",
"-d",
help="Show debug information",
),
) -> None:
base_url = state["engine_url"]
client = get_arcade_client(base_url)
try:
servers = client.workers.list(limit=100)
_print_servers_table(servers.items)
except Exception as e:
handle_cli_error("Failed to list servers", e, debug=debug)
@app.command("get", help="Get a server's details")
def get_server(
server_name: str,
debug: bool = typer.Option(
False,
"--debug",
"-d",
help="Show debug information",
),
) -> None:
base_url = state["engine_url"]
client = get_arcade_client(base_url)
try:
server = client.workers.get(server_name)
server_health = client.workers.health(server_name)
_print_server_details(server, server_health)
except Exception as e:
handle_cli_error(f"Failed to get server '{server_name}'", e, debug=debug)
@app.command("enable", help="Enable a server")
def enable_server(
server_name: str,
debug: bool = typer.Option(
False,
"--debug",
"-d",
help="Show debug information",
),
) -> None:
engine_url = state["engine_url"]
arcade = get_arcade_client(engine_url)
try:
arcade.workers.update(server_name, enabled=True)
except Exception as e:
handle_cli_error(f"Failed to enable worker '{server_name}'", e, debug=debug)
@app.command("disable", help="Disable a server")
def disable_server(
server_name: str,
debug: bool = typer.Option(
False,
"--debug",
"-d",
help="Show debug information",
),
) -> None:
engine_url = state["engine_url"]
arcade = get_arcade_client(engine_url)
try:
arcade.workers.update(server_name, enabled=False)
except Exception as e:
handle_cli_error(f"Failed to disable worker '{server_name}'", e, debug=debug)
@app.command("delete", help="Delete a server that is managed by Arcade")
def delete_server(
server_name: str,
debug: bool = typer.Option(
False,
"--debug",
"-d",
help="Show debug information",
),
) -> None:
engine_url = state["engine_url"]
try:
arcade = get_arcade_client(engine_url)
arcade.workers.delete(server_name)
console.print(f"✓ Server '{server_name}' deleted successfully", style="green")
except NotFoundError as e:
handle_cli_error(
f"Server '{server_name}' doesn't exist or cannot be deleted", e, debug=debug
)
except Exception as e:
handle_cli_error(
f"Server '{server_name}' doesn't exist or cannot be deleted", e, debug=debug
)
@app.command("logs", help="Get logs for a server that is managed by Arcade")
def get_server_logs(
server_name: str,
follow: bool = typer.Option(
False,
"--follow",
"-f",
is_flag=True,
help="Follow (stream) the log output in real-time",
rich_help_panel="Streaming Options",
),
since: Optional[str] = typer.Option(
None,
"--since",
"-s",
help="Show logs since timestamp (e.g., 2025-10-03T12:24:36Z) or relative (e.g., 42m for 42 minutes ago). Defaults to 1h (1 hour ago) for non-streaming, 0s (now) for streaming.",
rich_help_panel="Time Range Options",
),
until: Optional[str] = typer.Option(
None,
"--until",
"-u",
help="Show logs until timestamp (e.g., 2025-10-03T12:24:36Z) or relative (e.g., 42m for 42 minutes ago). Defaults to 0s (now).",
rich_help_panel="Time Range Options",
),
debug: bool = typer.Option(
False,
"--debug",
"-d",
help="Show debug information",
),
) -> None:
auth_headers = get_auth_headers()
headers = {**auth_headers, "Content-Type": "application/json"}
# Set defaults based on whether we're following or not
if since is None:
since = "0s" if follow else "1h"
if until is None:
until = "0s"
try:
# Parse time strings to UTC datetime objects
since_dt = _parse_time_string(since)
until_dt = _parse_time_string(until)
# Validate that since is before until
if since_dt >= until_dt:
raise ValueError(f"'since' time ({since}) must be before 'until' time ({until})") # noqa: TRY301
except ValueError as e:
handle_cli_error(f"Invalid time format: {e}", debug=debug)
base_url = state["engine_url"]
if follow:
# Use the streaming endpoint
logs_url = get_org_scoped_url(base_url, f"/deployments/{server_name}/logs/stream")
asyncio.run(_stream_deployment_logs(logs_url, headers, since_dt, until_dt, debug=debug))
else:
# Use the non-streaming endpoint
logs_url = get_org_scoped_url(base_url, f"/deployments/{server_name}/logs")
_display_deployment_logs(logs_url, headers, since_dt, until_dt, debug=debug)
def _display_deployment_logs(
engine_url: str, headers: dict, since: datetime, until: datetime, debug: bool
) -> None:
try:
with httpx.Client() as client:
params = {"start_time_utc": since.isoformat(), "end_time_utc": until.isoformat()}
response = client.get(engine_url, headers=headers, params=params)
response.raise_for_status()
logs = response.json()
for log in logs:
formatted_timestamp = _format_timestamp_to_local(log["timestamp"])
print(f"[{formatted_timestamp}] {log['line']}")
except httpx.HTTPStatusError as e:
handle_cli_error(
f"Failed to fetch logs: {e.response.status_code} {e.response.text}", debug=debug
)
except Exception as e:
handle_cli_error(f"Error fetching logs: {e}", debug=debug)
async def _stream_deployment_logs(
engine_url: str, headers: dict, since: datetime, until: datetime, debug: bool
) -> None:
try:
async with (
httpx.AsyncClient(timeout=None) as client, # noqa: S113 - expected indefinite log stream
client.stream(
"GET",
engine_url,
headers=headers,
params={"start_time_utc": since.isoformat(), "end_time_utc": until.isoformat()},
) as response,
):
response.raise_for_status()
async for line in response.aiter_lines():
if not line:
continue
# Handle SSE format: "data: {json}"
if line.startswith("data: "):
try:
data = json.loads(line[6:])
timestamp_str = data.get("Timestamp", "")
log_line = data.get("Line", "")
formatted_timestamp = _format_timestamp_to_local(timestamp_str)
print(f"[{formatted_timestamp}] {log_line}")
except (json.JSONDecodeError, KeyError, IndexError):
print(line)
else:
print(line)
except httpx.HTTPStatusError as e:
handle_cli_error(f"Failed to stream logs: {e.response.status_code}", debug=debug)
except Exception as e:
handle_cli_error(f"Error streaming logs: {e}", debug=debug)
def _print_servers_table(servers: list[WorkerResponse]) -> None:
if not servers:
console.print("No servers found", style="bold red")
return
table = Table(title="Servers")
table.add_column("Name")
table.add_column("Enabled")
table.add_column("Host")
table.add_column("Managed by Arcade")
for server in servers:
if server.id is None:
continue
uri = server.http.uri if server.http and server.http.uri else "N/A"
table.add_row(
server.id,
str(server.enabled),
uri,
str(server.managed),
)
console.print(table)
def _print_server_details(server: WorkerResponse, server_health: WorkerHealthResponse) -> None:
table = Table(title="Server Details")
table.add_column("Name")
table.add_column("Enabled")
table.add_column("Is Healthy")
table.add_column("Host")
table.add_column("Managed by Arcade")
uri = server.http.uri if server.http and server.http.uri else "N/A"
table.add_row(
server.id, str(server.enabled), str(server_health.healthy), uri, str(server.managed)
)
console.print(table)