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 -->
408 lines
12 KiB
Python
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)
|