Split workspace creation out of the init wizard
process_project_init mixed prompting with the API calls and printed on error, so nothing else could reuse it. create_workspace now does the creating and returns errors, and write_smb_config takes the target directory instead of the hard-coded .smb. Behavior of smb init is unchanged. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Seto Elkahfi committed
Oct 18, 2026 at 10:24 UTC
a62774e3934a598bae7b8ce3fe7ff8cfb7dcabba
1 file changed
+147
-100
crates/cli/src/project/crud_create.rs
+147
-100
@@ -23,17 +23,18 @@ use smbcloud_networking_project::{
23
};
24
use smbcloud_utils::config::Config as DeployConfig;
25
use spinners::Spinner;
26
+use std::path::Path;
27
27
-struct RepoInput {
28
- repository: String,
29
- repo_kind: RepoKind,
30
- runner: Runner,
28
+pub(crate) struct RepoInput {
29
+ pub(crate) repository: String,
30
+ pub(crate) repo_kind: RepoKind,
31
+ pub(crate) runner: Runner,
32
}
33
33
-struct AppInput {
34
- name: String,
35
- source_path: Option<String>,
36
- runner: Runner,
34
+pub(crate) struct AppInput {
35
+ pub(crate) name: String,
36
+ pub(crate) source_path: Option<String>,
37
+ pub(crate) runner: Runner,
38
}
39
40
pub async fn process_project_init(
@@ -138,104 +139,27 @@ pub async fn process_project_init(
139
140
let access_token = get_smb_token(env)?;
141
let tenant_id = crate::session_config::current_tenant_id(env).unwrap_or(None);
141
- let project = match create_project(
142
+ let (project, first_app) = create_workspace(
143
env,
143
- client(),
144
- access_token.clone(),
145
- ProjectCreate {
146
- name: project_name.clone(),
147
- description: description.clone(),
148
- },
144
+ access_token,
145
tenant_id,
146
+ &project_name,
147
+ &description,
148
+ repo.as_ref(),
149
+ &apps,
150
)
151
.await
152
- {
153
- Ok(project) => project,
154
- Err(e) => {
155
- println!("Error: {e:#?}");
156
- return Err(anyhow!(fail_message("Failed to create project.")));
157
- }
158
- };
159
-
160
- let mut first_app: Option<FrontendApp> = None;
161
- let mut created_apps = 0;
162
- if let Some(repo) = &repo {
163
- let deploy_repo = match create_deploy_repo(
164
- env,
165
- client(),
166
- access_token.clone(),
167
- DeployRepoCreate {
168
- project_id: project.id,
169
- name: repo.repository.clone(),
170
- repository: repo.repository.clone(),
171
- repo_kind: repo.repo_kind,
172
- runner: repo.runner,
173
- deployment_method: deployment_method_for(repo.runner),
174
- },
175
- )
176
- .await
177
- {
178
- Ok(deploy_repo) => deploy_repo,
179
- Err(e) => {
180
- println!("Error: {e:#?}");
181
- return Err(anyhow!(fail_message(
182
- "Project created, but failed to create its repo."
183
- )));
184
- }
185
- };
186
-
187
- // A single-app repo ships with its app, created server-side.
188
- if let Some(embedded_apps) = &deploy_repo.frontend_apps {
189
- if let Some(embedded_app) = embedded_apps.first() {
190
- created_apps += 1;
191
- first_app = Some(embedded_app.clone());
192
- }
193
- }
194
-
195
- for app in &apps {
196
- match create_frontend_app(
197
- env,
198
- client(),
199
- access_token.clone(),
200
- FrontendAppCreate {
201
- name: app.name.clone(),
202
- project_id: project.id,
203
- app_type: AppType::Web,
204
- runner: app.runner,
205
- deployment_method: deployment_method_for(app.runner),
206
- repository: Some(repo.repository.clone()),
207
- description: None,
208
- deploy_repo_id: Some(deploy_repo.id),
209
- source_path: app.source_path.clone(),
210
- },
211
- )
212
- .await
213
- {
214
- Ok(frontend_app) => {
215
- created_apps += 1;
216
- if first_app.is_none() {
217
- first_app = Some(frontend_app);
218
- }
219
- }
220
- Err(e) => {
221
- println!("Error: {e:#?}");
222
- return Err(anyhow!(fail_message(&format!(
223
- "Project created, but failed to create app {}.",
224
- app.name
225
- ))));
226
- }
227
- }
228
- }
229
- }
152
+ .map_err(|error| anyhow!(fail_message(&error.to_string())))?;
153
154
if should_init_project {
232
- write_smb_config(&project, first_app.as_ref())?;
155
+ write_smb_config(Path::new("."), &project, first_app.as_ref())?;
156
}
157
158
let msg = match &repo {
159
Some(repo) if repo.repo_kind == RepoKind::Monorepo => format!(
237
- "{project_name} has been created with monorepo {} and {created_apps} app(s).",
238
- repo.repository
160
+ "{project_name} has been created with monorepo {} and {} app(s).",
161
+ repo.repository,
162
+ apps.len()
163
),
164
Some(repo) => {
165
let app_name = first_app
@@ -257,6 +181,99 @@ pub async fn process_project_init(
181
})
182
}
183
184
+/// Creates the project, then its repo and apps. Shared by the interactive
185
+/// `smb init` wizard and the non-interactive MCP `project_init` tool, so it
186
+/// must not print: stdout is the MCP JSON-RPC channel.
187
+///
188
+/// Returns the project and the first app created (the single-app repo's
189
+/// embedded app, or the first monorepo app), which seeds `.smb/config.toml`.
190
+pub(crate) async fn create_workspace(
191
+ env: Environment,
192
+ access_token: String,
193
+ tenant_id: Option<String>,
194
+ name: &str,
195
+ description: &str,
196
+ repo: Option<&RepoInput>,
197
+ apps: &[AppInput],
198
+) -> Result<(Project, Option<FrontendApp>)> {
199
+ let project = create_project(
200
+ env,
201
+ client(),
202
+ access_token.clone(),
203
+ ProjectCreate {
204
+ name: name.to_string(),
205
+ description: description.to_string(),
206
+ },
207
+ tenant_id,
208
+ )
209
+ .await
210
+ .map_err(|error| anyhow!("Failed to create project: {error}"))?;
211
+
212
+ let Some(repo) = repo else {
213
+ return Ok((project, None));
214
+ };
215
+
216
+ let deploy_repo = create_deploy_repo(
217
+ env,
218
+ client(),
219
+ access_token.clone(),
220
+ DeployRepoCreate {
221
+ project_id: project.id,
222
+ name: repo.repository.clone(),
223
+ repository: repo.repository.clone(),
224
+ repo_kind: repo.repo_kind,
225
+ runner: repo.runner,
226
+ deployment_method: deployment_method_for(repo.runner),
227
+ },
228
+ )
229
+ .await
230
+ .map_err(|error| {
231
+ anyhow!(
232
+ "Project {} was created, but creating its repo failed: {error}",
233
+ project.id
234
+ )
235
+ })?;
236
+
237
+ // A single-app repo ships with its app, created server-side.
238
+ let mut first_app = deploy_repo
239
+ .frontend_apps
240
+ .as_ref()
241
+ .and_then(|embedded_apps| embedded_apps.first())
242
+ .cloned();
243
+
244
+ for app in apps {
245
+ let frontend_app = create_frontend_app(
246
+ env,
247
+ client(),
248
+ access_token.clone(),
249
+ FrontendAppCreate {
250
+ name: app.name.clone(),
251
+ project_id: project.id,
252
+ app_type: AppType::Web,
253
+ runner: app.runner,
254
+ deployment_method: deployment_method_for(app.runner),
255
+ repository: Some(repo.repository.clone()),
256
+ description: None,
257
+ deploy_repo_id: Some(deploy_repo.id),
258
+ source_path: app.source_path.clone(),
259
+ },
260
+ )
261
+ .await
262
+ .map_err(|error| {
263
+ anyhow!(
264
+ "Project {} was created, but creating app {} failed: {error}",
265
+ project.id,
266
+ app.name
267
+ )
268
+ })?;
269
+ if first_app.is_none() {
270
+ first_app = Some(frontend_app);
271
+ }
272
+ }
273
+
274
+ Ok((project, first_app))
275
+}
276
+
277
fn prompt_repository_name(project_name: &str) -> Result<String> {
278
match Input::<String>::with_theme(&ColorfulTheme::default())
279
.with_prompt("Repository name")
@@ -314,7 +331,7 @@ fn prompt_monorepo_app() -> Result<AppInput> {
331
332
// Static sites have no build step to run on the server; they always ship via
333
// rsync. Everything else defaults to the git flow.
317
-fn deployment_method_for(runner: Runner) -> DeploymentMethod {
334
+pub(crate) fn deployment_method_for(runner: Runner) -> DeploymentMethod {
335
match runner {
336
Runner::Static => DeploymentMethod::Rsync,
337
_ => DeploymentMethod::Git,
@@ -329,7 +346,11 @@ fn slugify(name: &str) -> String {
346
.collect()
347
}
348
332
-fn write_smb_config(workspace_project: &Project, frontend_app: Option<&FrontendApp>) -> Result<()> {
349
+pub(crate) fn write_smb_config(
350
+ directory: &Path,
351
+ workspace_project: &Project,
352
+ frontend_app: Option<&FrontendApp>,
353
+) -> Result<()> {
354
let deploy_target = match frontend_app {
355
Some(frontend_app) => merge_project_with_frontend_app(workspace_project, frontend_app),
356
None => workspace_project.clone(),
@@ -342,7 +363,33 @@ fn write_smb_config(workspace_project: &Project, frontend_app: Option<&FrontendA
363
projects: None,
364
};
365
345
- std::fs::create_dir_all(".smb")?;
346
- std::fs::write(".smb/config.toml", toml::to_string(&config)?)?;
366
+ let config_directory = directory.join(".smb");
367
+ std::fs::create_dir_all(&config_directory)?;
368
+ std::fs::write(
369
+ config_directory.join("config.toml"),
370
+ toml::to_string(&config)?,
371
+ )?;
372
Ok(())
373
}
374
+
375
+#[cfg(test)]
376
+mod tests {
377
+ use super::*;
378
+
379
+ #[test]
380
+ fn write_smb_config_writes_into_the_given_directory() {
381
+ let directory = tempfile::tempdir().expect("temp dir");
382
+ let project: Project = serde_json::from_value(serde_json::json!({
383
+ "id": 7,
384
+ "name": "demo",
385
+ "description": "A demo",
386
+ }))
387
+ .expect("fixture project");
388
+
389
+ write_smb_config(directory.path(), &project, None).expect("write config");
390
+
391
+ let written = std::fs::read_to_string(directory.path().join(".smb/config.toml"))
392
+ .expect("config written under the target directory");
393
+ assert!(written.contains("name = \"demo\""), "{written}");
394
+ }
395
+}