diff --git a/src/fastapi_cloud_cli/cli.py b/src/fastapi_cloud_cli/cli.py index 44b10de..29c1a90 100644 --- a/src/fastapi_cloud_cli/cli.py +++ b/src/fastapi_cloud_cli/cli.py @@ -16,6 +16,7 @@ from .commands.logs import logs from .commands.setup_ci import setup_ci from .commands.teams import teams_app +from .commands.tokens import tokens_app from .commands.whoami import whoami from .logging import setup_logging from .utils.sentry import init_sentry @@ -71,6 +72,7 @@ def cloud_main( cloud_app.add_typer(apps_app, name="apps") cloud_app.add_typer(deployments_app, name="deployments") cloud_app.add_typer(teams_app, name="teams") +cloud_app.add_typer(tokens_app, name="tokens") # fastapi [command] app.command()(deploy) diff --git a/src/fastapi_cloud_cli/commands/tokens/__init__.py b/src/fastapi_cloud_cli/commands/tokens/__init__.py new file mode 100644 index 0000000..3e5357d --- /dev/null +++ b/src/fastapi_cloud_cli/commands/tokens/__init__.py @@ -0,0 +1,11 @@ +import typer + +from fastapi_cloud_cli.commands.tokens.list import list_tokens + +tokens_app = typer.Typer( + no_args_is_help=True, + help="Manage deploy tokens for your app.", +) +tokens_app.command("list")(list_tokens) + +__all__ = ["tokens_app"] diff --git a/src/fastapi_cloud_cli/commands/tokens/list.py b/src/fastapi_cloud_cli/commands/tokens/list.py new file mode 100644 index 0000000..a6bc391 --- /dev/null +++ b/src/fastapi_cloud_cli/commands/tokens/list.py @@ -0,0 +1,115 @@ +from typing import Annotated, Any + +import typer +from pydantic import BaseModel +from rich.table import Table +from rich.text import Text +from rich_toolkit import RichToolkit + +from fastapi_cloud_cli.utils.api import APIClient +from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail +from fastapi_cloud_cli.utils.auth import Identity +from fastapi_cloud_cli.utils.cli import get_rich_toolkit +from fastapi_cloud_cli.utils.execution import JsonOutputOption + + +class DeployToken(BaseModel): + id: str + name: str + created_at: str + expired_at: str + + +class DeployTokensListAPIResponse(BaseModel): + data: list[DeployToken] + + +class DeployTokensListOutput(BaseModel): + app_id: str + tokens: list[DeployToken] + + +def _get_deploy_tokens(client: APIClient, app_id: str) -> DeployTokensListAPIResponse: + response = client.get(f"/apps/{app_id}/tokens") + response.raise_for_status() + + return DeployTokensListAPIResponse.model_validate(response.json()) + + +def _get_deploy_tokens_table(tokens: list[DeployToken]) -> Table: + table = Table.grid(padding=(0, 2), pad_edge=False) + table.add_column("Name", no_wrap=True) + table.add_column("Expiration", no_wrap=True) + table.add_column("ID", no_wrap=True, overflow="ignore") + table.add_row( + Text("Name", style="bold"), + Text("Expiration", style="bold"), + Text("ID", style="bold"), + ) + table.add_row("", "", "") + + for token in tokens: + table.add_row( + Text(token.name), + Text(token.expired_at[:10], style="dim"), + Text(token.id), + ) + + return table + + +def _render_deploy_tokens_list_output( + data: DeployTokensListOutput, toolkit: RichToolkit +) -> None: + toolkit.print_title("deploy tokens") + toolkit.print_line() + + if not data.tokens: + toolkit.print("No deploy tokens found.", bullet=False) + return + + toolkit.print(_get_deploy_tokens_table(data.tokens), bullet=False) + + +def list_tokens( + app_id: Annotated[ + str | None, + typer.Option( + "--app-id", + help="ID of the app whose deploy tokens should be listed.", + ), + ] = None, + json_output: JsonOutputOption = False, +) -> Any: + """ + List deploy tokens for an app. + """ + identity = Identity() + + with get_rich_toolkit(json_output=json_output) as toolkit: + if not identity.is_logged_in(): + toolkit.fail( + "not_logged_in", + "No credentials found.", + hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.", + ) + + target_app_id = resolve_app_id_or_fail(toolkit, app_id=app_id) + + with APIClient() as client: + with toolkit.progress( + title="Fetching deploy tokens", + transient=True, + ) as progress: + with client.handle_http_errors( + progress, + default_message="Error fetching deploy tokens. Please try again later.", + not_found_message="App not found.", + toolkit=toolkit, + ): + tokens = _get_deploy_tokens(client=client, app_id=target_app_id) + + toolkit.success( + DeployTokensListOutput(app_id=target_app_id, tokens=tokens.data), + render_output=_render_deploy_tokens_list_output, + ) diff --git a/tests/test_cli_tokens.py b/tests/test_cli_tokens.py new file mode 100644 index 0000000..8ef942e --- /dev/null +++ b/tests/test_cli_tokens.py @@ -0,0 +1,169 @@ +import json + +import pytest +import respx +from httpx import Response +from typer.testing import CliRunner + +from fastapi_cloud_cli.cli import cloud_app as app +from tests.conftest import ConfiguredApp +from tests.utils import changing_dir + +runner = CliRunner() + + +def test_lists_tokens_json_returns_not_logged_in_when_logged_out( + logged_out_cli: None, +) -> None: + result = runner.invoke( + app, + [ + "tokens", + "list", + "--app-id", + "00000000-0000-4000-8000-000000000002", + "--json", + ], + ) + + assert result.exit_code == 1 + assert json.loads(result.stdout) == { + "error": { + "code": "not_logged_in", + "message": "No credentials found.", + "hint": "Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.", + } + } + assert result.stderr == "" + + +def test_lists_tokens_json_returns_missing_required_input_without_app_context( + logged_in_cli: None, +) -> None: + result = runner.invoke(app, ["tokens", "list", "--json"]) + + assert result.exit_code == 1 + assert json.loads(result.stdout) == { + "error": { + "code": "missing_required_input", + "message": "App ID is required.", + "hint": "Pass --app-id or run `fastapi cloud apps create --link` first.", + } + } + assert result.stderr == "" + + +@pytest.mark.respx +def test_lists_tokens_as_json_with_app_id_without_secret_values( + logged_in_cli: None, + respx_mock: respx.MockRouter, +) -> None: + app_id = "00000000-0000-4000-8000-000000000002" + token = { + "id": "00000000-0000-4000-8000-000000000004", + "name": "GitHub Actions", + "created_at": "2026-05-22T10:00:00Z", + "expired_at": "2027-05-22T10:00:00Z", + "value": "fcp_secret_token_value", + } + respx_mock.get(f"/apps/{app_id}/tokens").mock( + return_value=Response(200, json={"data": [token]}) + ) + + result = runner.invoke(app, ["tokens", "list", "--app-id", app_id, "--json"]) + + assert result.exit_code == 0 + assert json.loads(result.stdout) == { + "data": { + "app_id": app_id, + "tokens": [ + { + "id": "00000000-0000-4000-8000-000000000004", + "name": "GitHub Actions", + "created_at": "2026-05-22T10:00:00Z", + "expired_at": "2027-05-22T10:00:00Z", + } + ], + } + } + assert "fcp_secret_token_value" not in result.stdout + assert result.stderr == "" + + +@pytest.mark.respx +def test_lists_tokens_as_json_uses_linked_app( + logged_in_cli: None, + respx_mock: respx.MockRouter, + configured_app: ConfiguredApp, +) -> None: + respx_mock.get(f"/apps/{configured_app.app_id}/tokens").mock( + return_value=Response(200, json={"data": []}) + ) + + with changing_dir(configured_app.path): + result = runner.invoke(app, ["tokens", "list", "--json"]) + + assert result.exit_code == 0 + assert json.loads(result.stdout) == { + "data": { + "app_id": configured_app.app_id, + "tokens": [], + } + } + assert result.stderr == "" + + +@pytest.mark.respx +def test_lists_tokens_in_human_output_empty( + logged_in_cli: None, + respx_mock: respx.MockRouter, +) -> None: + app_id = "00000000-0000-4000-8000-000000000002" + respx_mock.get(f"/apps/{app_id}/tokens").mock( + return_value=Response(200, json={"data": []}) + ) + + result = runner.invoke(app, ["tokens", "list", "--app-id", app_id]) + + assert result.exit_code == 0 + assert "deploy tokens" in result.output + assert "No deploy tokens found." in result.output + assert "Name" not in result.output + assert "Expiration" not in result.output + + +@pytest.mark.respx +def test_lists_tokens_human_output_without_secret_values( + logged_in_cli: None, + respx_mock: respx.MockRouter, +) -> None: + app_id = "00000000-0000-4000-8000-000000000002" + token_id = "00000000-0000-4000-8000-000000000004" + respx_mock.get(f"/apps/{app_id}/tokens").mock( + return_value=Response( + 200, + json={ + "data": [ + { + "id": token_id, + "name": "GitHub Actions", + "created_at": "2026-05-22T10:00:00Z", + "expired_at": "2027-05-22T10:00:00Z", + "value": "fcp_secret_token_value", + } + ] + }, + ) + ) + + result = runner.invoke(app, ["tokens", "list", "--app-id", app_id]) + + assert result.exit_code == 0 + assert "deploy tokens" in result.output + assert "Name" in result.output + assert "Expiration" in result.output + assert "GitHub Actions" in result.output + assert "2027-05-22" in result.output + assert "expires 2027-05-22" not in result.output + assert token_id in result.output + assert "fcp_secret_token_value" not in result.output