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 -->
379 lines
14 KiB
Python
379 lines
14 KiB
Python
import functools
|
|
import os
|
|
import platform
|
|
import sys
|
|
import time
|
|
from importlib import metadata
|
|
from typing import Any
|
|
|
|
import typer
|
|
from arcade_cli.usage.constants import (
|
|
EVENT_CLI_COMMAND_EXECUTED,
|
|
EVENT_CLI_COMMAND_FAILED,
|
|
PROP_CLI_VERSION,
|
|
PROP_COMMAND_NAME,
|
|
PROP_ORG_ID,
|
|
PROP_PROJECT_ID,
|
|
)
|
|
from arcade_core.constants import ARCADE_CONFIG_PATH
|
|
from arcade_core.usage import UsageIdentity, UsageService, is_tracking_enabled
|
|
from arcade_core.usage.constants import (
|
|
PROP_DEVICE_MONOTONIC_END,
|
|
PROP_DEVICE_MONOTONIC_START,
|
|
PROP_DURATION_MS,
|
|
PROP_ERROR_MESSAGE,
|
|
PROP_OS_RELEASE,
|
|
PROP_OS_TYPE,
|
|
PROP_RUNTIME_LANGUAGE,
|
|
PROP_RUNTIME_VERSION,
|
|
)
|
|
from rich.console import Console
|
|
from typer.core import TyperCommand, TyperGroup
|
|
from typer.models import Context
|
|
|
|
console = Console()
|
|
|
|
|
|
class CommandTracker:
|
|
"""Tracks CLI command execution for usage analytics."""
|
|
|
|
def __init__(self) -> None:
|
|
self.usage_service = UsageService()
|
|
self.identity = UsageIdentity()
|
|
self._cli_version: str | None = None
|
|
self._runtime_version: str | None = None
|
|
|
|
@property
|
|
def cli_version(self) -> str:
|
|
"""Get CLI version, cached after first access."""
|
|
if self._cli_version is None:
|
|
try:
|
|
self._cli_version = metadata.version("arcade-mcp")
|
|
except Exception:
|
|
self._cli_version = "unknown"
|
|
return self._cli_version
|
|
|
|
@property
|
|
def runtime_language(self) -> str:
|
|
"""Get the runtime language (always 'python' for this CLI)."""
|
|
return "python"
|
|
|
|
@property
|
|
def runtime_version(self) -> str:
|
|
"""Get runtime version, cached after first access."""
|
|
if self._runtime_version is None:
|
|
version_info = sys.version_info
|
|
self._runtime_version = (
|
|
f"{version_info.major}.{version_info.minor}.{version_info.micro}"
|
|
)
|
|
return self._runtime_version
|
|
|
|
@property
|
|
def user_id(self) -> str:
|
|
"""Get distinct_id based on authentication state."""
|
|
return self.identity.get_distinct_id()
|
|
|
|
def get_full_command_path(self, ctx: typer.Context) -> str:
|
|
"""Get the full command path by traversing the context hierarchy."""
|
|
command_parts = []
|
|
current_ctx: Any = ctx
|
|
while current_ctx and current_ctx.parent:
|
|
if current_ctx.command.name:
|
|
command_parts.append(current_ctx.command.name)
|
|
current_ctx = current_ctx.parent
|
|
return ".".join(reversed(command_parts))
|
|
|
|
def _get_org_project_context(self) -> tuple[str | None, str | None]:
|
|
"""Get org_id and project_id from config if available."""
|
|
try:
|
|
from arcade_core.config_model import Config
|
|
|
|
config = Config.load_from_file()
|
|
if config.context:
|
|
return config.context.org_id, config.context.project_id
|
|
except FileNotFoundError:
|
|
# No config file - user isn't logged in, which is fine
|
|
pass
|
|
except Exception as e:
|
|
console.print(
|
|
f"[yellow]Warning: Failed to load Arcade config: {e}[/yellow]\n"
|
|
"[yellow]Run 'arcade logout' then 'arcade login' to fix this.[/yellow]"
|
|
)
|
|
return None, None
|
|
|
|
def _handle_successful_login(self) -> None:
|
|
"""Handle a successful login event.
|
|
|
|
Upon a successful login, we retrieve and persist the principal_id for the logged in user.
|
|
We then alias the persisted anon_id to the known person with principal_id.
|
|
As a result, the previous anonymous events will be attributed to the known person with principal_id.
|
|
"""
|
|
principal_id = self.identity.get_principal_id()
|
|
if principal_id:
|
|
if self.identity.should_alias():
|
|
# Alias the anon_id to the known person with principal_id
|
|
self.usage_service.alias(
|
|
previous_id=self.identity.anon_id, distinct_id=principal_id
|
|
)
|
|
# Always update the linked principal_id on successful login
|
|
self.identity.set_linked_principal_id(principal_id)
|
|
|
|
def _handle_successful_logout(
|
|
self,
|
|
command_name: str,
|
|
duration_ms: float | None = None,
|
|
monotonic_start: float | None = None,
|
|
monotonic_end: float | None = None,
|
|
) -> None:
|
|
"""Handle a successful logout event.
|
|
|
|
Upon a successful logout, we rotate the anon_id and clear the linked principal_id.
|
|
"""
|
|
# Check if user was authenticated before logout (has linked_principal_id)
|
|
data = self.identity.load_or_create()
|
|
was_authenticated = data.get("linked_principal_id") is not None
|
|
|
|
# Send logout event as the authenticated user before resetting to anonymous
|
|
properties: dict[str, Any] = {
|
|
PROP_COMMAND_NAME: command_name,
|
|
PROP_CLI_VERSION: self.cli_version,
|
|
PROP_RUNTIME_LANGUAGE: self.runtime_language,
|
|
PROP_RUNTIME_VERSION: self.runtime_version,
|
|
PROP_OS_TYPE: platform.system(),
|
|
PROP_OS_RELEASE: platform.release(),
|
|
}
|
|
if duration_ms:
|
|
properties[PROP_DURATION_MS] = round(duration_ms)
|
|
|
|
if monotonic_start is not None:
|
|
properties[PROP_DEVICE_MONOTONIC_START] = monotonic_start
|
|
|
|
if monotonic_end is not None:
|
|
properties[PROP_DEVICE_MONOTONIC_END] = monotonic_end
|
|
|
|
# Check if using anon_id
|
|
is_anon = self.user_id == self.identity.anon_id
|
|
self.usage_service.capture(
|
|
EVENT_CLI_COMMAND_EXECUTED, self.user_id, properties=properties, is_anon=is_anon
|
|
)
|
|
|
|
# Only rotate anon_id if user was actually authenticated
|
|
if was_authenticated:
|
|
self.identity.reset_to_anonymous()
|
|
|
|
def track_command_execution(
|
|
self,
|
|
command_name: str,
|
|
success: bool,
|
|
duration_ms: float | None = None,
|
|
error_message: str | None = None,
|
|
is_login: bool = False,
|
|
is_logout: bool = False,
|
|
monotonic_start: float | None = None,
|
|
monotonic_end: float | None = None,
|
|
) -> None:
|
|
"""Track command execution event.
|
|
|
|
Args:
|
|
command_name: The name of the CLI command that was executed.
|
|
success: Whether the command was successfully executed.
|
|
duration_ms: The duration of the command execution in milliseconds.
|
|
error_message: The error message if the command failed.
|
|
is_login: Whether this is a login command.
|
|
is_logout: Whether this is a logout command.
|
|
monotonic_start: Monotonic clock timestamp at command start.
|
|
monotonic_end: Monotonic clock timestamp at command end.
|
|
"""
|
|
if not is_tracking_enabled():
|
|
return
|
|
|
|
if is_login and success:
|
|
self._handle_successful_login()
|
|
|
|
elif is_logout and success:
|
|
self._handle_successful_logout(
|
|
command_name, duration_ms, monotonic_start, monotonic_end
|
|
)
|
|
return
|
|
|
|
# Edge case: Lazy alias check for other commands (if user authenticated via side path)
|
|
elif not is_login and not is_logout and self.identity.should_alias():
|
|
principal_id = self.identity.get_principal_id()
|
|
if principal_id:
|
|
self.usage_service.alias(
|
|
previous_id=self.identity.anon_id, distinct_id=principal_id
|
|
)
|
|
self.identity.set_linked_principal_id(principal_id)
|
|
|
|
event_name = EVENT_CLI_COMMAND_EXECUTED if success else EVENT_CLI_COMMAND_FAILED
|
|
|
|
properties: dict[str, Any] = {
|
|
PROP_COMMAND_NAME: command_name,
|
|
PROP_CLI_VERSION: self.cli_version,
|
|
PROP_RUNTIME_LANGUAGE: self.runtime_language,
|
|
PROP_RUNTIME_VERSION: self.runtime_version,
|
|
PROP_OS_TYPE: platform.system(),
|
|
PROP_OS_RELEASE: platform.release(),
|
|
}
|
|
|
|
# Add org/project context when available (many commands operate within a project)
|
|
org_id, project_id = self._get_org_project_context()
|
|
if org_id:
|
|
properties[PROP_ORG_ID] = org_id
|
|
if project_id:
|
|
properties[PROP_PROJECT_ID] = project_id
|
|
|
|
if not success and error_message:
|
|
properties[PROP_ERROR_MESSAGE] = error_message
|
|
|
|
if duration_ms:
|
|
properties[PROP_DURATION_MS] = round(duration_ms)
|
|
|
|
if monotonic_start is not None:
|
|
properties[PROP_DEVICE_MONOTONIC_START] = monotonic_start
|
|
|
|
if monotonic_end is not None:
|
|
properties[PROP_DEVICE_MONOTONIC_END] = monotonic_end
|
|
|
|
# Check if using anon_id (not authenticated)
|
|
is_anon = self.user_id == self.identity.anon_id
|
|
self.usage_service.capture(event_name, self.user_id, properties=properties, is_anon=is_anon)
|
|
|
|
|
|
# Global tracker instance
|
|
command_tracker = CommandTracker()
|
|
|
|
|
|
class TrackedTyperCommand(TyperCommand):
|
|
"""Custom TyperCommand that tracks individual command execution."""
|
|
|
|
def invoke(self, ctx: Any) -> Any:
|
|
"""Override invoke to track command execution."""
|
|
if not os.path.exists(ARCADE_CONFIG_PATH):
|
|
console.print(
|
|
"[yellow]Arcade collects CLI usage data to help us debug and improve the service. "
|
|
"By continuing to use the Arcade CLI, you agree to the terms of our Privacy Policy. "
|
|
"To opt out, set the ARCADE_USAGE_TRACKING environment variable to 0.[/yellow]"
|
|
)
|
|
|
|
command_name = ctx.command.name
|
|
is_login = command_name == "login"
|
|
is_logout = command_name == "logout"
|
|
try:
|
|
start_time = time.time()
|
|
start_monotonic = time.monotonic()
|
|
result = super().invoke(ctx)
|
|
end_time = time.time()
|
|
end_monotonic = time.monotonic()
|
|
duration = end_time - start_time
|
|
command_tracker.track_command_execution(
|
|
command_tracker.get_full_command_path(ctx),
|
|
success=True,
|
|
duration_ms=duration * 1000,
|
|
is_login=is_login,
|
|
is_logout=is_logout,
|
|
monotonic_start=start_monotonic,
|
|
monotonic_end=end_monotonic,
|
|
)
|
|
except Exception as e:
|
|
end_time = time.time()
|
|
end_monotonic = time.monotonic()
|
|
duration = end_time - start_time
|
|
|
|
from arcade_cli.utils import CLIError
|
|
|
|
error_msg = str(e)[:300]
|
|
command_tracker.track_command_execution(
|
|
command_tracker.get_full_command_path(ctx),
|
|
success=False,
|
|
duration_ms=duration * 1000,
|
|
error_message=error_msg,
|
|
is_login=is_login,
|
|
is_logout=is_logout,
|
|
monotonic_start=start_monotonic,
|
|
monotonic_end=end_monotonic,
|
|
)
|
|
|
|
if isinstance(e, CLIError):
|
|
raise typer.Exit(code=1)
|
|
else:
|
|
raise
|
|
else:
|
|
return result
|
|
|
|
|
|
class TrackedTyperGroup(TyperGroup):
|
|
"""Custom TyperGroup that creates tracked commands."""
|
|
|
|
def command(self, *args: Any, **kwargs: Any) -> Any:
|
|
"""Override command decorator to use TrackedTyperCommand."""
|
|
# Set the custom command class
|
|
kwargs["cls"] = TrackedTyperCommand
|
|
result: Any = super().command(*args, **kwargs)
|
|
return result
|
|
|
|
def list_commands(self, ctx: Context) -> list[str]: # type: ignore[override]
|
|
"""Return list of commands in the order appear."""
|
|
return list(self.commands)
|
|
|
|
|
|
class TrackedTyper(typer.Typer):
|
|
"""Custom Typer that creates tracked commands."""
|
|
|
|
def command(
|
|
self, name: str | None = None, *, cls: type[TyperCommand] | None = None, **kwargs: Any
|
|
) -> Any:
|
|
"""Override command decorator to use TrackedTyperCommand."""
|
|
if cls is None:
|
|
cls = TrackedTyperCommand
|
|
|
|
result: Any = super().command(name, cls=cls, **kwargs)
|
|
return result
|
|
|
|
def callback(self, name: str | None = None, **kwargs: Any) -> Any:
|
|
"""Override callback decorator to track callback execution."""
|
|
original_callback_decorator: Any = super().callback(name, **kwargs)
|
|
|
|
def decorator(func: Any) -> Any:
|
|
@functools.wraps(func)
|
|
def tracked_callback(*args: Any, **cb_kwargs: Any) -> Any:
|
|
"""Wrapper that tracks callback execution."""
|
|
# Get the context from kwargs (Typer passes it)
|
|
ctx = cb_kwargs.get("ctx") or (
|
|
args[0] if args and isinstance(args[0], typer.Context) else None
|
|
)
|
|
|
|
command_name = ctx.invoked_subcommand if ctx and ctx.invoked_subcommand else "root"
|
|
start_time = time.time()
|
|
start_monotonic = time.monotonic()
|
|
|
|
try:
|
|
result = func(*args, **cb_kwargs)
|
|
except Exception as e:
|
|
# Track callback failure (auth failures, version checks, etc.)
|
|
end_time = time.time()
|
|
end_monotonic = time.monotonic()
|
|
duration = (end_time - start_time) * 1000
|
|
|
|
from arcade_cli.utils import CLIError
|
|
|
|
command_tracker.track_command_execution(
|
|
command_name,
|
|
success=False,
|
|
duration_ms=duration,
|
|
error_message=str(e)[:300],
|
|
monotonic_start=start_monotonic,
|
|
monotonic_end=end_monotonic,
|
|
)
|
|
|
|
if isinstance(e, CLIError):
|
|
raise typer.Exit(code=1)
|
|
else:
|
|
raise
|
|
else:
|
|
return result
|
|
|
|
result: Any = original_callback_decorator(tracked_callback)
|
|
return result
|
|
|
|
return decorator
|