Skip to content

Commit 3f8aad3

Browse files
committed
✨ Add cloud ci setup command
Shortcake-Parent: main
1 parent df82cd4 commit 3f8aad3

5 files changed

Lines changed: 355 additions & 104 deletions

File tree

src/fastapi_cloud_cli/commands/ci/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
from fastapi_cloud_cli.commands.ci.print_workflow import (
44
print_workflow as print_workflow_command,
55
)
6+
from fastapi_cloud_cli.commands.setup_ci import setup_ci
67

78
ci_app = typer.Typer(
89
no_args_is_help=True,
910
help="Manage CI integration helpers.",
1011
)
1112
ci_app.command("print-workflow")(print_workflow_command)
13+
ci_app.command("setup")(setup_ci)
1214

1315
__all__ = ["ci_app"]

src/fastapi_cloud_cli/commands/setup_ci.py

Lines changed: 170 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,51 @@
22
import re
33
import shutil
44
import subprocess
5+
from datetime import datetime, timezone
56
from pathlib import Path
6-
from typing import Annotated
7+
from typing import Annotated, Any
78

89
import typer
10+
from pydantic import BaseModel
911

1012
from fastapi_cloud_cli.utils.api import APIClient
1113
from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail
1214
from fastapi_cloud_cli.utils.auth import Identity
13-
from fastapi_cloud_cli.utils.cli import get_rich_toolkit
15+
from fastapi_cloud_cli.utils.cli import FastAPIRichToolkit, get_rich_toolkit
16+
from fastapi_cloud_cli.utils.execution import JsonOutputOption
1417

1518
logger = logging.getLogger(__name__)
1619

1720
TOKEN_EXPIRES_DAYS = 365
1821
DEFAULT_WORKFLOW_PATH = Path(".github/workflows/deploy.yml")
1922

2023

24+
class CISetupOutput(BaseModel):
25+
app_id: str
26+
repo: str
27+
branch: str
28+
workflow_path: str
29+
created_token: bool
30+
set_github_secrets: bool
31+
wrote_workflow: bool
32+
token_expired_at: str | None = None
33+
34+
35+
def _render_ci_setup_output(data: CISetupOutput, toolkit: FastAPIRichToolkit) -> None:
36+
toolkit.print("Done — commit and push to start deploying.", emoji="✅")
37+
38+
if data.token_expired_at:
39+
toolkit.print_line()
40+
toolkit.print(
41+
f"Your deploy token expires on [bold]{data.token_expired_at[:10]}[/bold]. "
42+
"Regenerate it from the dashboard or re-run this command before then.",
43+
)
44+
45+
46+
def _render_noop_output(_data: CISetupOutput, _toolkit: FastAPIRichToolkit) -> None:
47+
pass
48+
49+
2150
class GitHubSecretError(Exception):
2251
"""Raised when setting a GitHub Actions secret fails."""
2352

@@ -157,6 +186,44 @@ def _write_workflow_file(branch: str, workflow_path: Path) -> None:
157186
workflow_path.write_text(workflow_content)
158187

159188

189+
def _get_workflow_path(file: str | None) -> Path:
190+
if file:
191+
return Path(f".github/workflows/{file}")
192+
193+
return DEFAULT_WORKFLOW_PATH
194+
195+
196+
def _resolve_existing_workflow_path(
197+
toolkit: FastAPIRichToolkit, workflow_path: Path
198+
) -> Path | None:
199+
if toolkit.mode == "json":
200+
toolkit.fail(
201+
"invalid_input",
202+
f"Workflow file {workflow_path} already exists.",
203+
hint="Pass --file to choose another workflow file or remove the existing file.",
204+
)
205+
206+
overwrite = toolkit.confirm(
207+
f"Workflow file [bold]{workflow_path}[/bold] already exists. Overwrite?",
208+
default=False,
209+
)
210+
if overwrite:
211+
toolkit.print_line()
212+
return workflow_path
213+
214+
new_name = toolkit.input(
215+
"Enter a new filename (without path) or leave blank to skip writing the workflow file:",
216+
).strip()
217+
if new_name:
218+
toolkit.print_line()
219+
return Path(f".github/workflows/{new_name}")
220+
221+
toolkit.print("Skipped writing workflow file.")
222+
toolkit.print_line()
223+
toolkit.print_line()
224+
return None
225+
226+
160227
def setup_ci(
161228
path: Annotated[
162229
Path | None,
@@ -185,6 +252,12 @@ def setup_ci(
185252
help="Provisions token and sets secrets, skips writing the workflow file",
186253
show_default=True,
187254
),
255+
workflow_only: bool = typer.Option(
256+
False,
257+
"--workflow-only",
258+
help="Writes the workflow file without creating a token or setting secrets",
259+
show_default=True,
260+
),
188261
dry_run: bool = typer.Option(
189262
False,
190263
"--dry-run",
@@ -198,25 +271,34 @@ def setup_ci(
198271
"-f",
199272
help="Custom workflow filename (written to .github/workflows/)",
200273
),
201-
) -> None:
274+
json_output: JsonOutputOption = False,
275+
) -> Any:
202276
"""Configures a GitHub Actions workflow for deploying the app on push to the specified branch.
203277
204278
Examples:
205279
fastapi cloud setup-ci # Provisions token, sets secrets, and writes workflow file for the 'main' branch
206280
fastapi cloud setup-ci --branch develop # Same as above but for the 'develop' branch
207281
fastapi cloud setup-ci --secrets-only # Only provisions token and sets secrets, does not write workflow file
282+
fastapi cloud setup-ci --workflow-only # Only writes the workflow file
208283
fastapi cloud setup-ci --dry-run # Prints the steps that would be taken without performing them
209284
fastapi cloud setup-ci --file ci.yml # Writes workflow to .github/workflows/ci.yml
210285
"""
211286

212287
identity = Identity()
213288

214-
with get_rich_toolkit() as toolkit:
289+
with get_rich_toolkit(json_output=json_output) as toolkit:
215290
if not identity.is_logged_in():
216-
toolkit.print(
217-
"No credentials found. Use [blue]`fastapi login`[/] to login.",
291+
toolkit.fail(
292+
"not_logged_in",
293+
"No credentials found.",
294+
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
295+
)
296+
297+
if secrets_only and workflow_only:
298+
toolkit.fail(
299+
"invalid_input",
300+
"--secrets-only and --workflow-only cannot be used together.",
218301
)
219-
raise typer.Exit(1)
220302

221303
target_app_id = resolve_app_id_or_fail(
222304
toolkit,
@@ -247,12 +329,21 @@ def setup_ci(
247329
)
248330

249331
repo_slug = _repo_slug_from_origin(origin) or origin
250-
github_host = _get_github_host(origin)
251-
has_gh = _check_gh_cli_installed()
252332

253333
if not branch:
254334
branch = _get_default_branch()
255335

336+
workflow_path = _get_workflow_path(file)
337+
needs_secrets = not workflow_only
338+
needs_workflow = not secrets_only
339+
340+
if needs_secrets and not dry_run and not _check_gh_cli_installed():
341+
toolkit.fail(
342+
"dependency_missing",
343+
"GitHub CLI (`gh`) is required to set GitHub Actions secrets.",
344+
hint="Install gh or use --workflow-only to write only the workflow file.",
345+
)
346+
256347
if dry_run:
257348
toolkit.print(
258349
"[yellow]This is a dry run — no changes will be made[/yellow]"
@@ -267,104 +358,104 @@ def setup_ci(
267358

268359
msg_token = "Created deploy token"
269360
msg_secrets = (
270-
"Set [bold]FASTAPI_CLOUD_TOKEN[/bold] and [bold]FASTAPI_CLOUD_APP_ID[/bold]"
271-
)
272-
workflow_file = file or DEFAULT_WORKFLOW_PATH.name
273-
msg_workflow = (
274-
f"Wrote [bold].github/workflows/{workflow_file}[/bold] (branch: {branch})"
361+
"Set GitHub Actions secrets [bold]FASTAPI_CLOUD_TOKEN[/bold] "
362+
"and [bold]FASTAPI_CLOUD_APP_ID[/bold]"
275363
)
276-
msg_done = "Done — commit and push to start deploying."
364+
msg_workflow = f"Wrote [bold]{workflow_path}[/bold] (branch: {branch})"
277365

278366
if dry_run:
279-
toolkit.print(msg_token)
280-
toolkit.print(msg_secrets)
281-
if not secrets_only:
367+
if needs_secrets:
368+
toolkit.print(msg_token)
369+
toolkit.print(msg_secrets)
370+
if needs_workflow:
282371
toolkit.print(msg_workflow)
372+
toolkit.success(
373+
CISetupOutput(
374+
app_id=target_app_id,
375+
repo=repo_slug,
376+
branch=branch,
377+
workflow_path=str(workflow_path),
378+
created_token=False,
379+
set_github_secrets=False,
380+
wrote_workflow=False,
381+
),
382+
render_output=_render_noop_output,
383+
)
283384
return
284385

285-
from datetime import datetime, timezone
286-
287-
# Create unique token name with timestamp to avoid duplicates
288-
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
289-
token_name = f"GitHub Actions — {repo_slug} ({timestamp})"
290-
291-
with (
292-
APIClient() as client,
293-
toolkit.progress(
294-
title="Generating deploy token...", done_emoji="🔑"
295-
) as progress,
296-
client.handle_http_errors(
297-
progress, default_message="Error creating deploy token."
298-
),
299-
):
300-
token_data = _create_token(
301-
client=client, app_id=target_app_id, token_name=token_name
302-
)
303-
progress.log(msg_token)
386+
token_expired_at: str | None = None
387+
created_token = False
388+
set_github_secrets = False
389+
wrote_workflow = False
390+
391+
if needs_secrets:
392+
# Create unique token name with timestamp to avoid duplicates
393+
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
394+
token_name = f"GitHub Actions — {repo_slug} ({timestamp})"
395+
396+
with (
397+
APIClient() as client,
398+
toolkit.progress(
399+
title="Generating deploy token...", done_emoji="🔑"
400+
) as progress,
401+
client.handle_http_errors(
402+
progress, default_message="Error creating deploy token."
403+
),
404+
):
405+
token_data = _create_token(
406+
client=client, app_id=target_app_id, token_name=token_name
407+
)
408+
token_expired_at = token_data["expired_at"]
409+
created_token = True
410+
progress.log(msg_token)
304411

305-
toolkit.print_line()
412+
toolkit.print_line()
306413

307-
if has_gh:
308414
with toolkit.progress(
309415
title="Setting repo secrets...", done_emoji="🔒"
310416
) as progress:
311417
try:
312418
_set_github_secret("FASTAPI_CLOUD_TOKEN", token_data["value"])
313419
_set_github_secret("FASTAPI_CLOUD_APP_ID", target_app_id)
420+
progress.log(msg_secrets)
314421
except GitHubSecretError:
315422
progress.set_error("Failed to set GitHub secrets via gh CLI.")
316-
raise typer.Exit(1) from None
317-
progress.log(msg_secrets)
318-
else:
319-
secrets_url = f"https://{github_host}/{repo_slug}/settings/secrets/actions"
320-
toolkit.print(
321-
"[yellow]gh CLI not found. Set these secrets manually:[/yellow]",
322-
)
323-
toolkit.print_line()
324-
toolkit.print(f" Repository: [blue]{secrets_url}[/]")
325-
toolkit.print_line()
326-
toolkit.print(f" [bold]FASTAPI_CLOUD_TOKEN[/bold] = {token_data['value']}")
327-
toolkit.print(f" [bold]FASTAPI_CLOUD_APP_ID[/bold] = {target_app_id}")
423+
toolkit.fail(
424+
"api_error",
425+
"Failed to set GitHub secrets via gh CLI.",
426+
)
427+
set_github_secrets = True
328428

329429
toolkit.print_line()
330430

331-
if not secrets_only:
332-
if file:
333-
workflow_path = Path(f".github/workflows/{file}")
334-
else:
335-
workflow_path = DEFAULT_WORKFLOW_PATH
336-
337-
write_workflow = True
431+
if needs_workflow:
432+
workflow_path_to_write: Path | None = workflow_path
338433
if not file and workflow_path.exists():
339-
overwrite = toolkit.confirm(
340-
f"Workflow file [bold]{workflow_path}[/bold] already exists. Overwrite?",
341-
default=False,
434+
workflow_path_to_write = _resolve_existing_workflow_path(
435+
toolkit, workflow_path
342436
)
343-
if not overwrite:
344-
new_name = toolkit.input(
345-
"Enter a new filename (without path) or leave blank to skip writing the workflow file:",
346-
).strip()
347-
if new_name:
348-
workflow_path = Path(f".github/workflows/{new_name}")
349-
else:
350-
toolkit.print("Skipped writing workflow file.")
351-
toolkit.print_line()
352-
write_workflow = False
353-
toolkit.print_line()
354-
if write_workflow:
437+
438+
if workflow_path_to_write is not None:
439+
workflow_path = workflow_path_to_write
355440
msg_workflow = f"Wrote [bold]{workflow_path}[/bold] (branch: {branch})"
356441
with toolkit.progress(
357442
title="Writing workflow file...", done_emoji="📄"
358443
) as progress:
359444
_write_workflow_file(branch, workflow_path)
445+
wrote_workflow = True
360446
progress.log(msg_workflow)
361447

362448
toolkit.print_line()
363449

364-
toolkit.print(msg_done, emoji="✅")
365-
toolkit.print_line()
366-
# Token expiration date is in ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ), extract date portion
367-
toolkit.print(
368-
f"Your deploy token expires on [bold]{token_data['expired_at'][:10]}[/bold]. "
369-
"Regenerate it from the dashboard or re-run this command before then.",
450+
output = CISetupOutput(
451+
app_id=target_app_id,
452+
repo=repo_slug,
453+
branch=branch,
454+
workflow_path=str(workflow_path),
455+
created_token=created_token,
456+
set_github_secrets=set_github_secrets,
457+
wrote_workflow=wrote_workflow,
458+
token_expired_at=token_expired_at,
370459
)
460+
461+
toolkit.success(output, render_output=_render_ci_setup_output)

src/fastapi_cloud_cli/utils/errors.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"already_linked",
55
"api_error",
66
"cancelled",
7+
"dependency_missing",
78
"invalid_token",
89
"invalid_input",
910
"missing_required_input",

0 commit comments

Comments
 (0)