Skip to content

Commit 169ce72

Browse files
committed
✨ Add tokens delete command
Shortcake-Parent: 2026-06-17-add-tokens-create-command
1 parent 795cdaf commit 169ce72

3 files changed

Lines changed: 222 additions & 0 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import typer
22

33
from fastapi_cloud_cli.commands.tokens.create import create_token
4+
from fastapi_cloud_cli.commands.tokens.delete import delete_token
45
from fastapi_cloud_cli.commands.tokens.list import list_tokens
56

67
tokens_app = typer.Typer(
78
no_args_is_help=True,
89
help="Manage deploy tokens for your app.",
910
)
1011
tokens_app.command("create")(create_token)
12+
tokens_app.command("delete")(delete_token)
1113
tokens_app.command("list")(list_tokens)
1214

1315
__all__ = ["tokens_app"]
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
from typing import Annotated, Any
2+
3+
import typer
4+
from pydantic import BaseModel
5+
from rich_toolkit import RichToolkit
6+
7+
from fastapi_cloud_cli.utils.api import APIClient
8+
from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail
9+
from fastapi_cloud_cli.utils.auth import Identity
10+
from fastapi_cloud_cli.utils.cli import get_rich_toolkit
11+
from fastapi_cloud_cli.utils.execution import JsonOutputOption
12+
13+
14+
class DeployTokenDeleteOutput(BaseModel):
15+
token_id: str
16+
deleted: bool = True
17+
18+
19+
def _delete_deploy_token(client: APIClient, *, app_id: str, token_id: str) -> bool:
20+
response = client.delete(f"/apps/{app_id}/tokens/{token_id}")
21+
22+
if response.status_code == 404:
23+
return False
24+
25+
response.raise_for_status()
26+
27+
return True
28+
29+
30+
def _render_deploy_token_delete_output(
31+
data: DeployTokenDeleteOutput, toolkit: RichToolkit
32+
) -> None:
33+
toolkit.print(
34+
f"Deleted deploy token [bold]{data.token_id}[/bold]",
35+
bullet=False,
36+
)
37+
38+
39+
def delete_token(
40+
token_id: Annotated[
41+
str,
42+
typer.Argument(
43+
help="ID of the deploy token to delete.",
44+
),
45+
],
46+
app_id: Annotated[
47+
str | None,
48+
typer.Option(
49+
"--app-id",
50+
help="ID of the app that owns the deploy token.",
51+
),
52+
] = None,
53+
json_output: JsonOutputOption = False,
54+
) -> Any:
55+
"""
56+
Delete a deploy token for an app.
57+
"""
58+
identity = Identity()
59+
60+
with get_rich_toolkit(json_output=json_output) as toolkit:
61+
if not identity.is_logged_in():
62+
toolkit.fail(
63+
"not_logged_in",
64+
"No credentials found.",
65+
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
66+
)
67+
68+
target_app_id = resolve_app_id_or_fail(toolkit, app_id=app_id)
69+
70+
with APIClient() as client:
71+
with toolkit.progress(
72+
title="Deleting deploy token",
73+
transient=True,
74+
) as progress:
75+
with client.handle_http_errors(
76+
progress,
77+
default_message="Error deleting deploy token. Please try again later.",
78+
not_found_message="Deploy token not found.",
79+
toolkit=toolkit,
80+
):
81+
deleted = _delete_deploy_token(
82+
client,
83+
app_id=target_app_id,
84+
token_id=token_id,
85+
)
86+
87+
if not deleted:
88+
message = (
89+
f"Deploy token {token_id} not found."
90+
if toolkit.mode == "json"
91+
else "Deploy token not found."
92+
)
93+
toolkit.fail(
94+
"not_found",
95+
message,
96+
hint="Run `fastapi cloud tokens list` to see available deploy tokens.",
97+
)
98+
99+
toolkit.success(
100+
DeployTokenDeleteOutput(token_id=token_id),
101+
render_output=_render_deploy_token_delete_output,
102+
)

tests/test_cli_tokens.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,124 @@
1414
runner = CliRunner()
1515

1616

17+
@pytest.mark.respx
18+
def test_deletes_token_as_json_with_app_id(
19+
logged_in_cli: None,
20+
respx_mock: respx.MockRouter,
21+
) -> None:
22+
app_id = "00000000-0000-4000-8000-000000000002"
23+
token_id = "00000000-0000-4000-8000-000000000004"
24+
respx_mock.delete(f"/apps/{app_id}/tokens/{token_id}").mock(
25+
return_value=Response(204)
26+
)
27+
28+
result = runner.invoke(
29+
app,
30+
["tokens", "delete", token_id, "--app-id", app_id, "--json"],
31+
)
32+
33+
assert result.exit_code == 0
34+
assert json.loads(result.stdout) == {
35+
"data": {
36+
"token_id": token_id,
37+
"deleted": True,
38+
}
39+
}
40+
assert result.stderr == ""
41+
42+
43+
@pytest.mark.respx
44+
def test_deletes_token_as_json_uses_linked_app(
45+
logged_in_cli: None,
46+
respx_mock: respx.MockRouter,
47+
configured_app: ConfiguredApp,
48+
) -> None:
49+
token_id = "00000000-0000-4000-8000-000000000004"
50+
respx_mock.delete(f"/apps/{configured_app.app_id}/tokens/{token_id}").mock(
51+
return_value=Response(204)
52+
)
53+
54+
with changing_dir(configured_app.path):
55+
result = runner.invoke(app, ["tokens", "delete", token_id, "--json"])
56+
57+
assert result.exit_code == 0
58+
assert json.loads(result.stdout) == {
59+
"data": {
60+
"token_id": token_id,
61+
"deleted": True,
62+
}
63+
}
64+
assert result.stderr == ""
65+
66+
67+
def test_deletes_token_json_returns_missing_required_input_without_app_context(
68+
logged_in_cli: None,
69+
) -> None:
70+
result = runner.invoke(
71+
app,
72+
[
73+
"tokens",
74+
"delete",
75+
"00000000-0000-4000-8000-000000000004",
76+
"--json",
77+
],
78+
)
79+
80+
assert result.exit_code == 1
81+
assert json.loads(result.stdout) == {
82+
"error": {
83+
"code": "missing_required_input",
84+
"message": "App ID is required.",
85+
"hint": "Pass --app-id or run `fastapi cloud apps create --link` first.",
86+
}
87+
}
88+
assert result.stderr == ""
89+
90+
91+
@pytest.mark.respx
92+
def test_delete_token_json_returns_not_found_when_token_is_missing(
93+
logged_in_cli: None,
94+
respx_mock: respx.MockRouter,
95+
) -> None:
96+
app_id = "00000000-0000-4000-8000-000000000002"
97+
token_id = "00000000-0000-4000-8000-000000000004"
98+
respx_mock.delete(f"/apps/{app_id}/tokens/{token_id}").mock(
99+
return_value=Response(404)
100+
)
101+
102+
result = runner.invoke(
103+
app,
104+
["tokens", "delete", token_id, "--app-id", app_id, "--json"],
105+
)
106+
107+
assert result.exit_code == 1
108+
assert json.loads(result.stdout) == {
109+
"error": {
110+
"code": "not_found",
111+
"message": f"Deploy token {token_id} not found.",
112+
"hint": "Run `fastapi cloud tokens list` to see available deploy tokens.",
113+
}
114+
}
115+
assert result.stderr == ""
116+
117+
118+
@pytest.mark.respx
119+
def test_delete_token_human_output(
120+
logged_in_cli: None,
121+
respx_mock: respx.MockRouter,
122+
) -> None:
123+
app_id = "00000000-0000-4000-8000-000000000002"
124+
token_id = "00000000-0000-4000-8000-000000000004"
125+
respx_mock.delete(f"/apps/{app_id}/tokens/{token_id}").mock(
126+
return_value=Response(204)
127+
)
128+
129+
result = runner.invoke(app, ["tokens", "delete", token_id, "--app-id", app_id])
130+
131+
assert result.exit_code == 0
132+
assert f"Deleted deploy token {token_id}" in result.output
133+
134+
17135
def test_creates_token_json_returns_missing_required_input_without_name(
18136
logged_in_cli: None,
19137
) -> None:

0 commit comments

Comments
 (0)