Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/fastapi_cloud_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions src/fastapi_cloud_cli/commands/tokens/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
115 changes: 115 additions & 0 deletions src/fastapi_cloud_cli/commands/tokens/list.py
Original file line number Diff line number Diff line change
@@ -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,
)
169 changes: 169 additions & 0 deletions tests/test_cli_tokens.py
Original file line number Diff line number Diff line change
@@ -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