@@ -3,24 +3,38 @@ use crate::token::{get_smb_token::get_smb_token, is_logged_in::is_logged_in};
33use crate :: {
44 account:: login:: process_login,
55 cli:: CommandResult ,
6- project:: deploy_target:: {
7- ensure_default_frontend_app_for_project, merge_project_with_frontend_app,
8- } ,
6+ project:: deploy_target:: merge_project_with_frontend_app,
97 ui:: { fail_message, succeed_message, succeed_symbol} ,
108} ;
119use anyhow:: { anyhow, Result } ;
1210use console:: style;
1311use dialoguer:: console:: Term ;
14- use dialoguer:: Select ;
15- use dialoguer:: { theme:: ColorfulTheme , Input } ;
16- use smbcloud_model:: frontend_app:: FrontendApp ;
17- use smbcloud_model:: project:: { Project , ProjectCreate } ;
12+ use dialoguer:: { theme:: ColorfulTheme , Confirm , Input , Select } ;
13+ use smbcloud_model:: frontend_app:: {
14+ AppType , DeployRepoCreate , FrontendApp , FrontendAppCreate , RepoKind ,
15+ } ;
16+ use smbcloud_model:: project:: { DeploymentMethod , Project , ProjectCreate } ;
1817use smbcloud_model:: runner:: Runner ;
1918use smbcloud_network:: environment:: Environment ;
20- use smbcloud_networking_project:: crud_project_create:: create_project;
19+ use smbcloud_networking_project:: {
20+ crud_deploy_repo_create:: create_deploy_repo, crud_frontend_app_create:: create_frontend_app,
21+ crud_project_create:: create_project,
22+ } ;
2123use smbcloud_utils:: config:: Config as DeployConfig ;
2224use spinners:: Spinner ;
2325
26+ struct RepoInput {
27+ repository : String ,
28+ repo_kind : RepoKind ,
29+ runner : Runner ,
30+ }
31+
32+ struct AppInput {
33+ name : String ,
34+ source_path : Option < String > ,
35+ runner : Runner ,
36+ }
37+
2438pub async fn process_project_init (
2539 env : Environment ,
2640 should_init_project : bool ,
@@ -39,44 +53,74 @@ pub async fn process_project_init(
3953 return Err ( anyhow ! ( fail_message( "Invalid project name." ) ) ) ;
4054 }
4155 } ;
56+ let description = match Input :: < String > :: with_theme ( & ColorfulTheme :: default ( ) )
57+ . with_prompt ( "Description" )
58+ . interact ( )
59+ {
60+ Ok ( description) => description,
61+ Err ( _) => {
62+ return Err ( anyhow ! ( fail_message( "Invalid description" ) ) ) ;
63+ }
64+ } ;
4265
43- // Runner and repository describe the project's first app — the deployable
44- // unit inside the workspace — not the workspace itself.
45- let runners = vec ! [
46- Runner :: NodeJs ,
47- Runner :: Static ,
48- Runner :: Ruby ,
49- Runner :: Swift ,
50- Runner :: Rust ,
66+ // A project is just the workspace. A repo — and the apps that ship from
67+ // it — can be added now or later.
68+ let repo_choices = [
69+ "Single-app repo — one deployable app" ,
70+ "Monorepo — several apps in one repo" ,
71+ "Skip — add a repo later" ,
5172 ] ;
52- let runner = match Select :: with_theme ( & ColorfulTheme :: default ( ) )
53- . items ( & runners)
73+ let repo_choice = match Select :: with_theme ( & ColorfulTheme :: default ( ) )
74+ . with_prompt ( "Add a repo to this project?" )
75+ . items ( & repo_choices)
5476 . default ( 0 )
5577 . interact_on_opt ( & Term :: stderr ( ) )
5678 {
57- Ok ( Some ( index) ) => runners [ index] ,
79+ Ok ( Some ( index) ) => index,
5880 _ => {
59- return Err ( anyhow ! ( fail_message( "Invalid runner ." ) ) ) ;
81+ return Err ( anyhow ! ( fail_message( "Invalid selection ." ) ) ) ;
6082 }
6183 } ;
6284
63- let repository = match Input :: < String > :: with_theme ( & ColorfulTheme :: default ( ) )
64- . with_prompt ( "Repository name" )
65- . interact ( )
66- {
67- Ok ( repository) => repository,
68- Err ( _) => {
69- return Err ( anyhow ! ( fail_message( "Invalid repository name." ) ) ) ;
85+ let ( repo, apps) = match repo_choice {
86+ 0 => {
87+ let repository = prompt_repository_name ( & project_name) ?;
88+ let runner = prompt_runner ( ) ?;
89+ // A single-app repo creates its app automatically server-side.
90+ (
91+ Some ( RepoInput {
92+ repository,
93+ repo_kind : RepoKind :: SingleApp ,
94+ runner,
95+ } ) ,
96+ Vec :: new ( ) ,
97+ )
7098 }
71- } ;
72- let description = match Input :: < String > :: with_theme ( & ColorfulTheme :: default ( ) )
73- . with_prompt ( "Description" )
74- . interact ( )
75- {
76- Ok ( description) => description,
77- Err ( _) => {
78- return Err ( anyhow ! ( fail_message( "Invalid description" ) ) ) ;
99+ 1 => {
100+ let repository = prompt_repository_name ( & project_name) ?;
101+ let mut apps = Vec :: new ( ) ;
102+ loop {
103+ apps. push ( prompt_monorepo_app ( ) ?) ;
104+
105+ let add_another = Confirm :: with_theme ( & ColorfulTheme :: default ( ) )
106+ . with_prompt ( "Add another app?" )
107+ . default ( false )
108+ . interact ( )
109+ . unwrap_or ( false ) ;
110+ if !add_another {
111+ break ;
112+ }
113+ }
114+ (
115+ Some ( RepoInput {
116+ repository,
117+ repo_kind : RepoKind :: Monorepo ,
118+ runner : Runner :: Monorepo ,
119+ } ) ,
120+ apps,
121+ )
79122 }
123+ _ => ( None , Vec :: new ( ) ) ,
80124 } ;
81125
82126 let spinner = Spinner :: new (
@@ -103,37 +147,183 @@ pub async fn process_project_init(
103147 }
104148 } ;
105149
106- let frontend_app = match ensure_default_frontend_app_for_project (
107- env,
108- & access_token,
109- & project,
110- runner,
111- Some ( repository) ,
112- )
113- . await
114- {
115- Ok ( frontend_app) => frontend_app,
116- Err ( e) => {
117- println ! ( "Error: {e:#?}" ) ;
118- return Err ( anyhow ! ( fail_message(
119- "Project created, but failed to create its app."
120- ) ) ) ;
150+ let mut first_app: Option < FrontendApp > = None ;
151+ let mut created_apps = 0 ;
152+ if let Some ( repo) = & repo {
153+ let deploy_repo = match create_deploy_repo (
154+ env,
155+ client ( ) ,
156+ access_token. clone ( ) ,
157+ DeployRepoCreate {
158+ project_id : project. id ,
159+ name : repo. repository . clone ( ) ,
160+ repository : repo. repository . clone ( ) ,
161+ repo_kind : repo. repo_kind ,
162+ runner : repo. runner ,
163+ deployment_method : deployment_method_for ( repo. runner ) ,
164+ } ,
165+ )
166+ . await
167+ {
168+ Ok ( deploy_repo) => deploy_repo,
169+ Err ( e) => {
170+ println ! ( "Error: {e:#?}" ) ;
171+ return Err ( anyhow ! ( fail_message(
172+ "Project created, but failed to create its repo."
173+ ) ) ) ;
174+ }
175+ } ;
176+
177+ // A single-app repo ships with its app, created server-side.
178+ if let Some ( embedded_apps) = & deploy_repo. frontend_apps {
179+ if let Some ( embedded_app) = embedded_apps. first ( ) {
180+ created_apps += 1 ;
181+ first_app = Some ( embedded_app. clone ( ) ) ;
182+ }
121183 }
122- } ;
184+
185+ for app in & apps {
186+ match create_frontend_app (
187+ env,
188+ client ( ) ,
189+ access_token. clone ( ) ,
190+ FrontendAppCreate {
191+ name : app. name . clone ( ) ,
192+ project_id : project. id ,
193+ app_type : AppType :: Web ,
194+ runner : app. runner ,
195+ deployment_method : deployment_method_for ( app. runner ) ,
196+ repository : Some ( repo. repository . clone ( ) ) ,
197+ description : None ,
198+ deploy_repo_id : Some ( deploy_repo. id ) ,
199+ source_path : app. source_path . clone ( ) ,
200+ } ,
201+ )
202+ . await
203+ {
204+ Ok ( frontend_app) => {
205+ created_apps += 1 ;
206+ if first_app. is_none ( ) {
207+ first_app = Some ( frontend_app) ;
208+ }
209+ }
210+ Err ( e) => {
211+ println ! ( "Error: {e:#?}" ) ;
212+ return Err ( anyhow ! ( fail_message( & format!(
213+ "Project created, but failed to create app {}." ,
214+ app. name
215+ ) ) ) ) ;
216+ }
217+ }
218+ }
219+ }
123220
124221 if should_init_project {
125- write_smb_config ( & project, & frontend_app ) ?;
222+ write_smb_config ( & project, first_app . as_ref ( ) ) ?;
126223 }
127224
225+ let msg = match & repo {
226+ Some ( repo) if repo. repo_kind == RepoKind :: Monorepo => format ! (
227+ "{project_name} has been created with monorepo {} and {created_apps} app(s)." ,
228+ repo. repository
229+ ) ,
230+ Some ( repo) => {
231+ let app_name = first_app
232+ . as_ref ( )
233+ . map ( |frontend_app| frontend_app. name . clone ( ) )
234+ . unwrap_or_else ( || repo. repository . clone ( ) ) ;
235+ format ! (
236+ "{project_name} has been created with repo {} and app {app_name}." ,
237+ repo. repository
238+ )
239+ }
240+ None => format ! ( "{project_name} has been created." ) ,
241+ } ;
242+
128243 Ok ( CommandResult {
129244 spinner,
130245 symbol : succeed_symbol ( ) ,
131- msg : succeed_message ( & format ! ( "{project_name} has been created." ) ) ,
246+ msg : succeed_message ( & msg ) ,
132247 } )
133248}
134249
135- fn write_smb_config ( workspace_project : & Project , frontend_app : & FrontendApp ) -> Result < ( ) > {
136- let deploy_target = merge_project_with_frontend_app ( workspace_project, frontend_app) ;
250+ fn prompt_repository_name ( project_name : & str ) -> Result < String > {
251+ match Input :: < String > :: with_theme ( & ColorfulTheme :: default ( ) )
252+ . with_prompt ( "Repository name" )
253+ . default ( slugify ( project_name) )
254+ . interact ( )
255+ {
256+ Ok ( repository) => Ok ( repository) ,
257+ Err ( _) => Err ( anyhow ! ( fail_message( "Invalid repository name." ) ) ) ,
258+ }
259+ }
260+
261+ fn prompt_runner ( ) -> Result < Runner > {
262+ let runners = vec ! [
263+ Runner :: NodeJs ,
264+ Runner :: Static ,
265+ Runner :: Ruby ,
266+ Runner :: Swift ,
267+ Runner :: Rust ,
268+ ] ;
269+ match Select :: with_theme ( & ColorfulTheme :: default ( ) )
270+ . with_prompt ( "Runner" )
271+ . items ( & runners)
272+ . default ( 0 )
273+ . interact_on_opt ( & Term :: stderr ( ) )
274+ {
275+ Ok ( Some ( index) ) => Ok ( runners[ index] ) ,
276+ _ => Err ( anyhow ! ( fail_message( "Invalid runner." ) ) ) ,
277+ }
278+ }
279+
280+ fn prompt_monorepo_app ( ) -> Result < AppInput > {
281+ let name = match Input :: < String > :: with_theme ( & ColorfulTheme :: default ( ) )
282+ . with_prompt ( "App name" )
283+ . interact ( )
284+ {
285+ Ok ( name) => name,
286+ Err ( _) => return Err ( anyhow ! ( fail_message( "Invalid app name." ) ) ) ,
287+ } ;
288+ let source_path = match Input :: < String > :: with_theme ( & ColorfulTheme :: default ( ) )
289+ . with_prompt ( "Source path (relative to repo root)" )
290+ . default ( format ! ( "apps/{}" , slugify( & name) ) )
291+ . interact ( )
292+ {
293+ Ok ( source_path) => source_path,
294+ Err ( _) => return Err ( anyhow ! ( fail_message( "Invalid source path." ) ) ) ,
295+ } ;
296+ let runner = prompt_runner ( ) ?;
297+
298+ Ok ( AppInput {
299+ name,
300+ source_path : Some ( source_path) ,
301+ runner,
302+ } )
303+ }
304+
305+ // Static sites have no build step to run on the server; they always ship via
306+ // rsync. Everything else defaults to the git flow.
307+ fn deployment_method_for ( runner : Runner ) -> DeploymentMethod {
308+ match runner {
309+ Runner :: Static => DeploymentMethod :: Rsync ,
310+ _ => DeploymentMethod :: Git ,
311+ }
312+ }
313+
314+ fn slugify ( name : & str ) -> String {
315+ name. to_lowercase ( )
316+ . replace ( ' ' , "-" )
317+ . chars ( )
318+ . filter ( |c| c. is_ascii_alphanumeric ( ) || * c == '-' || * c == '_' )
319+ . collect ( )
320+ }
321+
322+ fn write_smb_config ( workspace_project : & Project , frontend_app : Option < & FrontendApp > ) -> Result < ( ) > {
323+ let deploy_target = match frontend_app {
324+ Some ( frontend_app) => merge_project_with_frontend_app ( workspace_project, frontend_app) ,
325+ None => workspace_project. clone ( ) ,
326+ } ;
137327
138328 let config = DeployConfig {
139329 name : workspace_project. name . clone ( ) ,
0 commit comments