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");