11import contextlib
22import logging
3+ import re
34import subprocess
45import tempfile
56import time
67from enum import Enum
78from itertools import cycle
8- from pathlib import Path
9+ from pathlib import Path , PurePosixPath
910from textwrap import dedent
1011from typing import Annotated , Any , Optional , Union
1112
1213import fastar
1314import rignore
1415import typer
1516from httpx import Client
16- from pydantic import BaseModel , EmailStr , TypeAdapter , ValidationError
17+ from pydantic import AfterValidator , BaseModel , EmailStr , TypeAdapter , ValidationError
1718from rich .text import Text
1819from rich_toolkit import RichToolkit
1920from rich_toolkit .menu import Option
2728logger = logging .getLogger (__name__ )
2829
2930
31+ def validate_app_directory (v : Optional [str ]) -> Optional [str ]:
32+ if v is None :
33+ return None
34+
35+ v = v .strip ()
36+
37+ if not v :
38+ return None
39+
40+ if v .startswith ("~" ):
41+ raise ValueError ("cannot start with '~'" )
42+
43+ path = PurePosixPath (v )
44+
45+ if path .is_absolute ():
46+ raise ValueError ("must be a relative path, not absolute" )
47+
48+ if ".." in path .parts :
49+ raise ValueError ("cannot contain '..' path segments" )
50+
51+ normalized = path .as_posix ()
52+
53+ if not re .fullmatch (r"[A-Za-z0-9._/ -]+" , normalized ):
54+ raise ValueError (
55+ "contains invalid characters (allowed: letters, numbers, space, / . _ -)"
56+ )
57+
58+ return normalized
59+
60+
61+ AppDirectory = Annotated [Optional [str ], AfterValidator (validate_app_directory )]
62+
63+
3064def _cancel_upload (deployment_id : str ) -> None :
3165 logger .debug ("Cancelling upload for deployment: %s" , deployment_id )
3266
@@ -113,13 +147,26 @@ def _get_teams() -> list[Team]:
113147class AppResponse (BaseModel ):
114148 id : str
115149 slug : str
150+ directory : Optional [str ]
151+
152+
153+ def _update_app (app_id : str , directory : Optional [str ]) -> AppResponse :
154+ with APIClient () as client :
155+ response = client .patch (
156+ f"/apps/{ app_id } " ,
157+ json = {"directory" : directory },
158+ )
159+
160+ response .raise_for_status ()
161+
162+ return AppResponse .model_validate (response .json ())
116163
117164
118- def _create_app (team_id : str , app_name : str ) -> AppResponse :
165+ def _create_app (team_id : str , app_name : str , directory : Optional [ str ] ) -> AppResponse :
119166 with APIClient () as client :
120167 response = client .post (
121168 "/apps/" ,
122- json = {"name" : app_name , "team_id" : team_id },
169+ json = {"name" : app_name , "team_id" : team_id , "directory" : directory },
123170 )
124171
125172 response .raise_for_status ()
@@ -332,10 +379,26 @@ def _configure_app(toolkit: RichToolkit, path_to_deploy: Path) -> AppConfig:
332379
333380 toolkit .print_line ()
334381
382+ initial_directory = selected_app .directory if selected_app else ""
383+
384+ directory_input = toolkit .input (
385+ title = "Path to the directory containing your app (e.g. src, backend):" ,
386+ tag = "dir" ,
387+ value = initial_directory or "" ,
388+ placeholder = "[italic]Leave empty if it's the current directory[/italic]" ,
389+ validator = TypeAdapter (AppDirectory ),
390+ )
391+
392+ directory : Optional [str ] = directory_input if directory_input else None
393+
394+ toolkit .print_line ()
395+
335396 toolkit .print ("Deployment configuration:" , tag = "summary" )
336397 toolkit .print_line ()
337398 toolkit .print (f"Team: [bold]{ team .name } [/bold]" )
338399 toolkit .print (f"App name: [bold]{ app_name } [/bold]" )
400+ toolkit .print (f"Directory: [bold]{ directory or '.' } [/bold]" )
401+
339402 toolkit .print_line ()
340403
341404 choice = toolkit .ask (
@@ -352,12 +415,21 @@ def _configure_app(toolkit: RichToolkit, path_to_deploy: Path) -> AppConfig:
352415 toolkit .print ("Deployment cancelled." )
353416 raise typer .Exit (0 )
354417
355- if selected_app : # pragma: no cover
356- app = selected_app
418+ if selected_app :
419+ if directory != selected_app .directory :
420+ with (
421+ toolkit .progress (title = "Updating app directory..." ) as progress ,
422+ handle_http_errors (progress ),
423+ ):
424+ app = _update_app (selected_app .id , directory = directory )
425+
426+ progress .log (f"App directory updated to '{ directory or '.' } '" )
427+ else :
428+ app = selected_app
357429 else :
358430 with toolkit .progress (title = "Creating app..." ) as progress :
359431 with handle_http_errors (progress ):
360- app = _create_app (team .id , app_name )
432+ app = _create_app (team .id , app_name , directory = directory )
361433
362434 progress .log (f"App created successfully! App slug: { app .slug } " )
363435
0 commit comments