@smbcloud / cli / commits / a62774e

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 +}