22import re
33import shutil
44import subprocess
5+ from datetime import datetime , timezone
56from pathlib import Path
6- from typing import Annotated
7+ from typing import Annotated , Any
78
89import typer
10+ from pydantic import BaseModel
911
1012from fastapi_cloud_cli .utils .api import APIClient
1113from fastapi_cloud_cli .utils .apps import resolve_app_id_or_fail
1214from 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
1518logger = logging .getLogger (__name__ )
1619
1720TOKEN_EXPIRES_DAYS = 365
1821DEFAULT_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+
2150class 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+
160227def 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 )
0 commit comments