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 -->
271 lines
10 KiB
Python
271 lines
10 KiB
Python
import tempfile
|
|
from io import StringIO
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
from arcade_cli.secret import (
|
|
_delete_secret,
|
|
_get_secrets,
|
|
_remove_inline_comment,
|
|
_upsert_secret,
|
|
load_env_file,
|
|
print_secret_table,
|
|
)
|
|
|
|
|
|
class TestPrintSecretTable:
|
|
"""Tests for print_secret_table function."""
|
|
|
|
def test_print_secret_table_empty(self, capsys):
|
|
"""Test printing a table with no secrets."""
|
|
secrets = []
|
|
print_secret_table(secrets)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "Tool Secrets" in captured.out
|
|
|
|
|
|
class TestLoadEnvFile:
|
|
"""Tests for load_env_file function."""
|
|
|
|
def test_load_env_file_basic(self):
|
|
"""Test loading a basic .env file."""
|
|
env_content = """
|
|
KEY1=value1
|
|
KEY2=value2
|
|
# This is a comment
|
|
KEY3=value3
|
|
"""
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f:
|
|
f.write(env_content)
|
|
f.flush()
|
|
|
|
secrets = load_env_file(f.name)
|
|
|
|
assert secrets == {
|
|
"KEY1": "value1",
|
|
"KEY2": "value2",
|
|
"KEY3": "value3",
|
|
}
|
|
|
|
def test_load_env_file_with_quotes(self):
|
|
"""Test loading .env file with quoted values."""
|
|
env_content = """
|
|
KEY1="quoted value"
|
|
KEY2='single quoted'
|
|
KEY3="value with = sign"
|
|
KEY4="value with # comment inside"
|
|
"""
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f:
|
|
f.write(env_content)
|
|
f.flush()
|
|
|
|
secrets = load_env_file(f.name)
|
|
|
|
assert secrets == {
|
|
"KEY1": "quoted value",
|
|
"KEY2": "single quoted",
|
|
"KEY3": "value with = sign",
|
|
"KEY4": "value with # comment inside",
|
|
}
|
|
|
|
def test_load_env_file_with_inline_comments(self):
|
|
"""Test loading .env file with inline comments."""
|
|
env_content = """
|
|
KEY1=value1 # inline comment
|
|
KEY2="quoted value" # comment after quote
|
|
KEY3=value3# no space before comment
|
|
"""
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f:
|
|
f.write(env_content)
|
|
f.flush()
|
|
|
|
secrets = load_env_file(f.name)
|
|
|
|
assert secrets == {
|
|
"KEY1": "value1",
|
|
"KEY2": "quoted value",
|
|
"KEY3": "value3# no space before comment", # No space, so not treated as comment
|
|
}
|
|
|
|
def test_load_env_file_skip_empty_and_invalid(self):
|
|
"""Test that empty lines, comments, and invalid entries are skipped."""
|
|
env_content = """
|
|
# Comment line
|
|
KEY1=value1
|
|
|
|
KEY2=
|
|
=value_without_key
|
|
KEY3=value3
|
|
invalid_line_without_equals
|
|
KEY4=value4
|
|
"""
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f:
|
|
f.write(env_content)
|
|
f.flush()
|
|
|
|
secrets = load_env_file(f.name)
|
|
|
|
assert secrets == {
|
|
"KEY1": "value1",
|
|
"KEY3": "value3",
|
|
"KEY4": "value4",
|
|
}
|
|
|
|
|
|
class TestRemoveInlineComment:
|
|
"""Tests for _remove_inline_comment function."""
|
|
|
|
def test_remove_inline_comment_unquoted(self):
|
|
"""Test removing inline comments from unquoted values."""
|
|
assert _remove_inline_comment("value # comment") == "value"
|
|
assert _remove_inline_comment("value# no space") == "value# no space"
|
|
assert _remove_inline_comment("value") == "value"
|
|
assert _remove_inline_comment("value with spaces # comment") == "value with spaces"
|
|
|
|
def test_remove_inline_comment_double_quoted(self):
|
|
"""Test removing inline comments from double-quoted values."""
|
|
assert _remove_inline_comment('"quoted value" # comment') == "quoted value"
|
|
assert _remove_inline_comment('"value with # inside"') == "value with # inside"
|
|
assert _remove_inline_comment('"quoted value"') == "quoted value"
|
|
assert _remove_inline_comment('"unclosed quote') == '"unclosed quote'
|
|
|
|
def test_remove_inline_comment_single_quoted(self):
|
|
"""Test removing inline comments from single-quoted values."""
|
|
assert _remove_inline_comment("'quoted value' # comment") == "quoted value"
|
|
assert _remove_inline_comment("'value with # inside'") == "value with # inside"
|
|
assert _remove_inline_comment("'quoted value'") == "quoted value"
|
|
assert _remove_inline_comment("'unclosed quote") == "'unclosed quote"
|
|
|
|
def test_remove_inline_comment_edge_cases(self):
|
|
"""Test edge cases for inline comment removal."""
|
|
assert _remove_inline_comment("") == ""
|
|
|
|
|
|
class TestUpsertSecretToEngine:
|
|
"""Tests for _upsert_secret function."""
|
|
|
|
@patch("arcade_cli.secret.get_auth_headers")
|
|
@patch("arcade_cli.secret.get_org_scoped_url")
|
|
@patch("arcade_cli.secret.httpx.put")
|
|
def test_upsert_secret_success(self, mock_put, mock_get_url, mock_get_headers):
|
|
"""Test successful secret upsert."""
|
|
mock_response = MagicMock()
|
|
mock_response.raise_for_status.return_value = None
|
|
mock_put.return_value = mock_response
|
|
mock_get_url.return_value = "https://api.example.com/v1/org/test-org/secrets/SECRET_KEY"
|
|
mock_get_headers.return_value = {"Authorization": "Bearer test-api-key"}
|
|
|
|
_upsert_secret("SECRET_KEY", "secret-value")
|
|
|
|
mock_put.assert_called_once_with(
|
|
"https://api.example.com/v1/org/test-org/secrets/SECRET_KEY",
|
|
headers={"Authorization": "Bearer test-api-key"},
|
|
json={"description": "Secret set via CLI", "value": "secret-value"},
|
|
)
|
|
mock_response.raise_for_status.assert_called_once()
|
|
|
|
@patch("arcade_cli.secret.get_auth_headers")
|
|
@patch("arcade_cli.secret.get_org_scoped_url")
|
|
@patch("arcade_cli.secret.httpx.put")
|
|
def test_upsert_secret_http_error(self, mock_put, mock_get_url, mock_get_headers):
|
|
"""Test secret upsert with HTTP error."""
|
|
mock_response = MagicMock()
|
|
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
|
"Bad Request", request=MagicMock(), response=MagicMock()
|
|
)
|
|
mock_put.return_value = mock_response
|
|
mock_get_url.return_value = "https://api.example.com/v1/org/test-org/secrets/SECRET_KEY"
|
|
mock_get_headers.return_value = {"Authorization": "Bearer test-api-key"}
|
|
|
|
with pytest.raises(httpx.HTTPStatusError):
|
|
_upsert_secret("SECRET_KEY", "secret-value")
|
|
|
|
|
|
class TestGetSecretsFromEngine:
|
|
"""Tests for _get_secrets function."""
|
|
|
|
@patch("arcade_cli.secret.get_auth_headers")
|
|
@patch("arcade_cli.secret.get_org_scoped_url")
|
|
@patch("arcade_cli.secret.httpx.get")
|
|
def test_get_secrets_success(self, mock_get, mock_get_url, mock_get_headers):
|
|
"""Test successful secrets retrieval."""
|
|
mock_response = MagicMock()
|
|
mock_response.raise_for_status.return_value = None
|
|
mock_response.json.return_value = {
|
|
"items": [
|
|
{"key": "SECRET1", "id": "id1"},
|
|
{"key": "SECRET2", "id": "id2"},
|
|
]
|
|
}
|
|
mock_get.return_value = mock_response
|
|
mock_get_url.return_value = "https://api.example.com/v1/org/test-org/secrets"
|
|
mock_get_headers.return_value = {"Authorization": "Bearer test-api-key"}
|
|
|
|
secrets = _get_secrets()
|
|
|
|
assert secrets == [
|
|
{"key": "SECRET1", "id": "id1"},
|
|
{"key": "SECRET2", "id": "id2"},
|
|
]
|
|
mock_get.assert_called_once_with(
|
|
"https://api.example.com/v1/org/test-org/secrets",
|
|
headers={"Authorization": "Bearer test-api-key"},
|
|
)
|
|
mock_response.raise_for_status.assert_called_once()
|
|
|
|
@patch("arcade_cli.secret.get_auth_headers")
|
|
@patch("arcade_cli.secret.get_org_scoped_url")
|
|
@patch("arcade_cli.secret.httpx.get")
|
|
def test_get_secrets_http_error(self, mock_get, mock_get_url, mock_get_headers):
|
|
"""Test secrets retrieval with HTTP error."""
|
|
mock_response = MagicMock()
|
|
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
|
"Unauthorized", request=MagicMock(), response=MagicMock()
|
|
)
|
|
mock_get.return_value = mock_response
|
|
mock_get_url.return_value = "https://api.example.com/v1/org/test-org/secrets"
|
|
mock_get_headers.return_value = {"Authorization": "Bearer test-api-key"}
|
|
|
|
with pytest.raises(httpx.HTTPStatusError):
|
|
_get_secrets()
|
|
|
|
|
|
class TestDeleteSecretFromEngine:
|
|
"""Tests for _delete_secret function."""
|
|
|
|
@patch("arcade_cli.secret.get_auth_headers")
|
|
@patch("arcade_cli.secret.get_org_scoped_url")
|
|
@patch("arcade_cli.secret.httpx.delete")
|
|
def test_delete_secret_success(self, mock_delete, mock_get_url, mock_get_headers):
|
|
"""Test successful secret deletion."""
|
|
mock_response = MagicMock()
|
|
mock_response.raise_for_status.return_value = None
|
|
mock_delete.return_value = mock_response
|
|
mock_get_url.return_value = "https://api.example.com/v1/org/test-org/secrets/secret-id-123"
|
|
mock_get_headers.return_value = {"Authorization": "Bearer test-api-key"}
|
|
|
|
_delete_secret("secret-id-123")
|
|
|
|
mock_delete.assert_called_once_with(
|
|
"https://api.example.com/v1/org/test-org/secrets/secret-id-123",
|
|
headers={"Authorization": "Bearer test-api-key"},
|
|
)
|
|
mock_response.raise_for_status.assert_called_once()
|
|
|
|
@patch("arcade_cli.secret.get_auth_headers")
|
|
@patch("arcade_cli.secret.get_org_scoped_url")
|
|
@patch("arcade_cli.secret.httpx.delete")
|
|
def test_delete_secret_http_error(self, mock_delete, mock_get_url, mock_get_headers):
|
|
"""Test secret deletion with HTTP error."""
|
|
mock_response = MagicMock()
|
|
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
|
"Not Found", request=MagicMock(), response=MagicMock()
|
|
)
|
|
mock_delete.return_value = mock_response
|
|
mock_get_url.return_value = "https://api.example.com/v1/org/test-org/secrets/secret-id-123"
|
|
mock_get_headers.return_value = {"Authorization": "Bearer test-api-key"}
|
|
|
|
with pytest.raises(httpx.HTTPStatusError):
|
|
_delete_secret("secret-id-123")
|