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,14 @@ def _get_teams() -> list[Team]:
113147class AppResponse (BaseModel ):
114148 id : str
115149 slug : str
150+ directory : Optional [str ]
116151
117152
118- def _create_app (team_id : str , app_name : str ) -> AppResponse :
153+ def _create_app (team_id : str , app_name : str , directory : Optional [ str ] ) -> AppResponse :
119154 with APIClient () as client :
120155 response = client .post (
121156 "/apps/" ,
122- json = {"name" : app_name , "team_id" : team_id },
157+ json = {"name" : app_name , "team_id" : team_id , "directory" : directory },
123158 )
124159
125160 response .raise_for_status ()
@@ -332,10 +367,26 @@ def _configure_app(toolkit: RichToolkit, path_to_deploy: Path) -> AppConfig:
332367
333368 toolkit .print_line ()
334369
370+ initial_directory = selected_app .directory if selected_app else ""
371+
372+ directory_input = toolkit .input (
373+ title = "Path to the directory containing your app (e.g. src, backend):" ,
374+ tag = "dir" ,
375+ value = initial_directory or "" ,
376+ placeholder = "[italic]Leave empty if it's the current directory[/italic]" ,
377+ validator = TypeAdapter (AppDirectory ),
378+ )
379+
380+ directory : Optional [str ] = directory_input if directory_input else None
381+
382+ toolkit .print_line ()
383+
335384 toolkit .print ("Deployment configuration:" , tag = "summary" )
336385 toolkit .print_line ()
337386 toolkit .print (f"Team: [bold]{ team .name } [/bold]" )
338387 toolkit .print (f"App name: [bold]{ app_name } [/bold]" )
388+ toolkit .print (f"Directory: [bold]{ directory or '.' } [/bold]" )
389+
339390 toolkit .print_line ()
340391
341392 choice = toolkit .ask (
@@ -357,7 +408,7 @@ def _configure_app(toolkit: RichToolkit, path_to_deploy: Path) -> AppConfig:
357408 else :
358409 with toolkit .progress (title = "Creating app..." ) as progress :
359410 with handle_http_errors (progress ):
360- app = _create_app (team .id , app_name )
411+ app = _create_app (team .id , app_name , directory = directory )
361412
362413 progress .log (f"App created successfully! App slug: { app .slug } " )
363414
0 commit comments