@smbcloud / cli / commits / 18da3cb

Fix new project structure

Seto Elkahfi committed Jun 12, 2026 at 13:03 UTC 18da3cb37bcc431c1fdbc7caa6f46e20c9c505ea
6 files changed +451 -58
crates/cli/src/project/crud_create.rs
+245 -55
@@ -3,24 +3,38 @@ use crate::token::{get_smb_token::get_smb_token, is_logged_in::is_logged_in};
3 use crate::{
4 account::login::process_login,
5 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,
7 ui::{fail_message, succeed_message, succeed_symbol},
8 };
9 use anyhow::{anyhow, Result};
10 use console::style;
11 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};
17 use smbcloud_model::runner::Runner;
18 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 +};
23 use smbcloud_utils::config::Config as DeployConfig;
24 use spinners::Spinner;
25
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 +
38 pub async fn process_project_init(
39 env: Environment,
40 should_init_project: bool,
@@ -39,44 +53,74 @@ pub async fn process_project_init(
53 return Err(anyhow!(fail_message("Invalid project name.")));
54 }
55 };
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 + };
65
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",
72 ];
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)
76 .default(0)
77 .interact_on_opt(&Term::stderr())
78 {
57 - Ok(Some(index)) => runners[index],
79 + Ok(Some(index)) => index,
80 _ => {
59 - return Err(anyhow!(fail_message("Invalid runner.")));
81 + return Err(anyhow!(fail_message("Invalid selection.")));
82 }
83 };
84
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 + )
98 }
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 + )
122 }
123 + _ => (None, Vec::new()),
124 };
125
126 let spinner = Spinner::new(
@@ -103,37 +147,183 @@ pub async fn process_project_init(
147 }
148 };
149
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 + }
183 }
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 + }
220
221 if should_init_project {
125 - write_smb_config(&project, &frontend_app)?;
222 + write_smb_config(&project, first_app.as_ref())?;
223 }
224
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 +
243 Ok(CommandResult {
244 spinner,
245 symbol: succeed_symbol(),
131 - msg: succeed_message(&format!("{project_name} has been created.")),
246 + msg: succeed_message(&msg),
247 })
248 }
249
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 + };
327
328 let config = DeployConfig {
329 name: workspace_project.name.clone(),
crates/cli/src/project/deploy_target.rs
+2
@@ -77,6 +77,8 @@ pub async fn ensure_default_frontend_app_for_project(
77 deployment_method: project.deployment_method,
78 repository,
79 description: project.description.clone(),
80 + deploy_repo_id: None,
81 + source_path: None,
82 },
83 )
84 .await
crates/smbcloud-model/src/frontend_app.rs
+173 -3
@@ -32,6 +32,25 @@ impl Display for AppType {
32 }
33 }
34
35 +/// Whether a repo holds one deployable app or several (monorepo).
36 +#[derive(Deserialize_repr, Serialize_repr, Debug, Clone, Copy, PartialEq, Eq, Default)]
37 +#[repr(u8)]
38 +#[tsync]
39 +pub enum RepoKind {
40 + #[default]
41 + SingleApp = 0,
42 + Monorepo = 1,
43 +}
44 +
45 +impl Display for RepoKind {
46 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 + match self {
48 + RepoKind::SingleApp => write!(f, "Single-app repo"),
49 + RepoKind::Monorepo => write!(f, "Monorepo"),
50 + }
51 + }
52 +}
53 +
54 /// A deployable frontend application on the smbCloud platform.
55 ///
56 /// A `FrontendApp` is the unit that actually ships.
@@ -43,6 +62,77 @@ impl Display for AppType {
62 /// A `FrontendApp` belongs to a `Tenant`, is associated with an owner workspace,
63 /// and may optionally point at a `DeployRepo` plus a repo-relative `source_path`
64 /// for monorepo deployments.
65 +// Older API versions serialize the repo enums nested in a frontend_app payload
66 +// as Rails string keys ("monorepo", "git") instead of integers. Accept both.
67 +#[derive(Deserialize)]
68 +#[serde(untagged)]
69 +enum EnumWireValue {
70 + Int(u8),
71 + Str(String),
72 +}
73 +
74 +fn deserialize_repo_kind<'de, D>(deserializer: D) -> Result<RepoKind, D::Error>
75 +where
76 + D: serde::Deserializer<'de>,
77 +{
78 + match EnumWireValue::deserialize(deserializer)? {
79 + EnumWireValue::Int(0) => Ok(RepoKind::SingleApp),
80 + EnumWireValue::Int(1) => Ok(RepoKind::Monorepo),
81 + EnumWireValue::Str(value) if value == "single_app" => Ok(RepoKind::SingleApp),
82 + EnumWireValue::Str(value) if value == "monorepo" => Ok(RepoKind::Monorepo),
83 + EnumWireValue::Int(other) => Err(serde::de::Error::custom(format!(
84 + "unknown repo_kind: {other}"
85 + ))),
86 + EnumWireValue::Str(other) => Err(serde::de::Error::custom(format!(
87 + "unknown repo_kind: {other}"
88 + ))),
89 + }
90 +}
91 +
92 +fn deserialize_runner<'de, D>(deserializer: D) -> Result<Runner, D::Error>
93 +where
94 + D: serde::Deserializer<'de>,
95 +{
96 + match EnumWireValue::deserialize(deserializer)? {
97 + EnumWireValue::Int(0) => Ok(Runner::NodeJs),
98 + EnumWireValue::Int(1) => Ok(Runner::Static),
99 + EnumWireValue::Int(2) => Ok(Runner::Ruby),
100 + EnumWireValue::Int(3) => Ok(Runner::Swift),
101 + EnumWireValue::Int(4) => Ok(Runner::Rust),
102 + EnumWireValue::Int(255) => Ok(Runner::Monorepo),
103 + EnumWireValue::Str(value) => match value.as_str() {
104 + "node_js" => Ok(Runner::NodeJs),
105 + "static" => Ok(Runner::Static),
106 + "ruby" => Ok(Runner::Ruby),
107 + "swift" => Ok(Runner::Swift),
108 + "rust" => Ok(Runner::Rust),
109 + "monorepo" => Ok(Runner::Monorepo),
110 + other => Err(serde::de::Error::custom(format!("unknown runner: {other}"))),
111 + },
112 + EnumWireValue::Int(other) => Err(serde::de::Error::custom(format!(
113 + "unknown runner: {other}"
114 + ))),
115 + }
116 +}
117 +
118 +fn deserialize_deployment_method<'de, D>(deserializer: D) -> Result<DeploymentMethod, D::Error>
119 +where
120 + D: serde::Deserializer<'de>,
121 +{
122 + match EnumWireValue::deserialize(deserializer)? {
123 + EnumWireValue::Int(0) => Ok(DeploymentMethod::Git),
124 + EnumWireValue::Int(1) => Ok(DeploymentMethod::Rsync),
125 + EnumWireValue::Str(value) if value == "git" => Ok(DeploymentMethod::Git),
126 + EnumWireValue::Str(value) if value == "rsync" => Ok(DeploymentMethod::Rsync),
127 + EnumWireValue::Int(other) => Err(serde::de::Error::custom(format!(
128 + "unknown deployment_method: {other}"
129 + ))),
130 + EnumWireValue::Str(other) => Err(serde::de::Error::custom(format!(
131 + "unknown deployment_method: {other}"
132 + ))),
133 + }
134 +}
135 +
136 #[derive(Deserialize, Serialize, Debug, Clone)]
137 #[tsync]
138 pub struct DeployRepo {
@@ -50,9 +140,31 @@ pub struct DeployRepo {
140 pub name: String,
141 pub repository: String,
142 pub root_path: String,
53 - pub repo_kind: Option<String>,
54 - pub runner: Option<String>,
55 - pub deployment_method: Option<String>,
143 + // The API serializes these enums as integers (serde_repr), matching Rails.
144 + // Tolerate the legacy string keys still emitted by older deployments.
145 + #[serde(default, deserialize_with = "deserialize_repo_kind")]
146 + pub repo_kind: RepoKind,
147 + #[serde(default, deserialize_with = "deserialize_runner")]
148 + pub runner: Runner,
149 + #[serde(default, deserialize_with = "deserialize_deployment_method")]
150 + pub deployment_method: DeploymentMethod,
151 + /// Embedded in the repo-create response. A single-app repo creates its
152 + /// app automatically server-side; this carries it back to the client.
153 + #[serde(default, skip_serializing_if = "Option::is_none")]
154 + pub frontend_apps: Option<Vec<FrontendApp>>,
155 +}
156 +
157 +/// Payload for creating a new DeployRepo via the API.
158 +#[derive(Serialize, Debug, Deserialize, Clone)]
159 +#[tsync]
160 +pub struct DeployRepoCreate {
161 + pub project_id: i32,
162 + pub name: String,
163 + pub repository: String,
164 + pub repo_kind: RepoKind,
165 + pub runner: Runner,
166 + #[serde(default)]
167 + pub deployment_method: DeploymentMethod,
168 }
169
170 #[derive(Deserialize, Serialize, Debug, Clone)]
@@ -143,6 +255,10 @@ pub struct FrontendAppCreate {
255 pub deployment_method: DeploymentMethod,
256 pub repository: Option<String>,
257 pub description: Option<String>,
258 + /// Repo this app deploys from, when the workspace tracks one.
259 + pub deploy_repo_id: Option<i64>,
260 + /// Repo-relative app path for monorepo targets, e.g. "apps/web".
261 + pub source_path: Option<String>,
262 }
263
264 /// A deployment record tied to a FrontendApp.
@@ -174,11 +290,31 @@ mod tests {
290 deployment_method: DeploymentMethod::Git,
291 repository: Some("my-repo".to_owned()),
292 description: None,
293 + deploy_repo_id: Some(7),
294 + source_path: Some("apps/web".to_owned()),
295 };
296 let value = serde_json::to_value(&create).unwrap();
297 assert_eq!(value["app_type"], json!(0));
298 assert_eq!(value["runner"], json!(0));
299 assert_eq!(value["deployment_method"], json!(0));
300 + assert_eq!(value["deploy_repo_id"], json!(7));
301 + assert_eq!(value["source_path"], json!("apps/web"));
302 + }
303 +
304 + #[test]
305 + fn test_deploy_repo_create_serialization() {
306 + let create = DeployRepoCreate {
307 + project_id: 1,
308 + name: "my-repo".to_owned(),
309 + repository: "my-repo".to_owned(),
310 + repo_kind: RepoKind::Monorepo,
311 + runner: Runner::Monorepo,
312 + deployment_method: DeploymentMethod::Git,
313 + };
314 + let value = serde_json::to_value(&create).unwrap();
315 + assert_eq!(value["repo_kind"], json!(1));
316 + assert_eq!(value["runner"], json!(255));
317 + assert_eq!(value["deployment_method"], json!(0));
318 }
319
320 #[test]
@@ -186,4 +322,38 @@ mod tests {
322 assert_eq!(AppType::Web.to_string(), "Web");
323 assert_eq!(AppType::Tauri.to_string(), "Tauri");
324 }
325 +
326 + #[test]
327 + fn test_deploy_repo_deserializes_integer_enums() {
328 + let deploy_repo: DeployRepo = serde_json::from_value(json!({
329 + "id": 5,
330 + "name": "my-repo",
331 + "repository": "my-repo",
332 + "root_path": ".",
333 + "repo_kind": 1,
334 + "runner": 255,
335 + "deployment_method": 0
336 + }))
337 + .unwrap();
338 + assert_eq!(deploy_repo.repo_kind, RepoKind::Monorepo);
339 + assert_eq!(deploy_repo.runner, Runner::Monorepo);
340 + assert_eq!(deploy_repo.deployment_method, DeploymentMethod::Git);
341 + }
342 +
343 + #[test]
344 + fn test_deploy_repo_deserializes_legacy_string_enums() {
345 + let deploy_repo: DeployRepo = serde_json::from_value(json!({
346 + "id": 5,
347 + "name": "my-repo",
348 + "repository": "my-repo",
349 + "root_path": ".",
350 + "repo_kind": "monorepo",
351 + "runner": "monorepo",
352 + "deployment_method": "git"
353 + }))
354 + .unwrap();
355 + assert_eq!(deploy_repo.repo_kind, RepoKind::Monorepo);
356 + assert_eq!(deploy_repo.runner, Runner::Monorepo);
357 + assert_eq!(deploy_repo.deployment_method, DeploymentMethod::Git);
358 + }
359 }
crates/smbcloud-networking-project/src/crud_deploy_repo_create.rs new
+24
@@ -0,0 +1,24 @@
1 +use {
2 + crate::url_builder::build_deploy_repos_url,
3 + reqwest::Client,
4 + smbcloud_model::{
5 + error_codes::ErrorResponse,
6 + frontend_app::{DeployRepo, DeployRepoCreate},
7 + },
8 + smbcloud_network::{environment::Environment, network::request},
9 + smbcloud_networking::{constants::SMB_USER_AGENT, smb_client::SmbClient},
10 +};
11 +
12 +pub async fn create_deploy_repo(
13 + env: Environment,
14 + client: (&SmbClient, &str),
15 + access_token: String,
16 + payload: DeployRepoCreate,
17 +) -> Result<DeployRepo, ErrorResponse> {
18 + let builder = Client::new()
19 + .post(build_deploy_repos_url(env, client))
20 + .json(&payload)
21 + .header("Authorization", access_token)
22 + .header("User-agent", SMB_USER_AGENT);
23 + request(builder).await
24 +}
crates/smbcloud-networking-project/src/lib.rs
+1
@@ -1,3 +1,4 @@
1 +pub mod crud_deploy_repo_create;
2 pub mod crud_frontend_app_create;
3 pub mod crud_frontend_app_deploy_config;
4 pub mod crud_frontend_app_read;
crates/smbcloud-networking-project/src/url_builder.rs
+6
@@ -3,6 +3,12 @@ use {
3 smbcloud_networking::{smb_base_url_builder, smb_client::SmbClient},
4 };
5
6 +pub(crate) fn build_deploy_repos_url(env: Environment, client: (&SmbClient, &str)) -> String {
7 + let mut url_builder = smb_base_url_builder(env, client);
8 + url_builder.add_route("v1/deploy_repos");
9 + url_builder.build()
10 +}
11 +
12 pub(crate) fn build_frontend_apps_url(env: Environment, client: (&SmbClient, &str)) -> String {
13 let mut url_builder = smb_base_url_builder(env, client);
14 url_builder.add_route("v1/frontend_apps");