Skip to content

Commit 18da3cb

Browse files
committed
Fix new project structure
1 parent 19eca5a commit 18da3cb

6 files changed

Lines changed: 451 additions & 58 deletions

File tree

crates/cli/src/project/crud_create.rs

Lines changed: 245 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,38 @@ use crate::token::{get_smb_token::get_smb_token, is_logged_in::is_logged_in};
33
use 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
};
119
use anyhow::{anyhow, Result};
1210
use console::style;
1311
use 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};
1817
use smbcloud_model::runner::Runner;
1918
use 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+
};
2123
use smbcloud_utils::config::Config as DeployConfig;
2224
use 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+
2438
pub 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(),

crates/cli/src/project/deploy_target.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ pub async fn ensure_default_frontend_app_for_project(
7777
deployment_method: project.deployment_method,
7878
repository,
7979
description: project.description.clone(),
80+
deploy_repo_id: None,
81+
source_path: None,
8082
},
8183
)
8284
.await

0 commit comments

Comments
 (0)