@smbcloud / cli / commits / fab5f1e

Add Rust runner support for project detection and deployment

- Add Runner::Rust to runner enum and CLI selection menus - Detect Cargo.toml as Rust project in runner detection - Implement process_deploy_rust handler for Rust service deploys - Route Rust projects to new deploy handler in process_deploy - Update project init to write correct runner value to config - Show Rust runner in project creation and update prompts

keypair34 committed May 3, 2026 at 16:46 UTC fab5f1e65bc2fb79b05509456c61a46c56e8e861
8 files changed +359 -10
crates/cli/src/deploy/detect_runner.rs
+11 -3
@@ -21,7 +21,7 @@ pub(crate) async fn detect_runner(config: &Config) -> Result<Runner> {
21 fail_message("Could not get the current path."),
22 );
23 anyhow::bail!(
24 - "Could not detect project runner: no package.json, Gemfile, or Package.swift found"
24 + "Could not detect project runner: no package.json, Gemfile, Package.swift, or Cargo.toml found"
25 );
26 }
27 };
@@ -36,11 +36,11 @@ pub(crate) async fn detect_runner(config: &Config) -> Result<Runner> {
36 spinner.stop_and_persist(
37 &fail_symbol(),
38 fail_message(
39 - "Could not detect project runner: no package.json, Gemfile, or Package.swift found",
39 + "Could not detect project runner: no package.json, Gemfile, Package.swift, or Cargo.toml found",
40 ),
41 );
42 anyhow::bail!(
43 - "Could not detect project runner: no package.json, Gemfile, or Package.swift found"
43 + "Could not detect project runner: no package.json, Gemfile, Package.swift, or Cargo.toml found"
44 );
45 }
46 };
@@ -80,6 +80,14 @@ pub(crate) async fn detect_runner(config: &Config) -> Result<Runner> {
80 );
81 }
82 }
83 + Runner::Rust => {
84 + if Path::new("Cargo.toml").exists() {
85 + spinner.stop_and_persist(
86 + &succeed_symbol(),
87 + succeed_message("Rust 🦀 runner with Cargo project detected"),
88 + );
89 + }
90 + }
91 };
92
93 Ok(runner)
crates/cli/src/deploy/mod.rs
+1
@@ -5,6 +5,7 @@ mod known_hosts;
5 pub mod process_deploy;
6 pub mod process_deploy_nextjs_ssr;
7 pub mod process_deploy_rails;
8 +pub mod process_deploy_rust;
9 pub mod process_deploy_vite_spa;
10 mod remote_messages;
11 pub(crate) mod rsync_deploy;
crates/cli/src/deploy/process_deploy.rs
+6
@@ -9,6 +9,7 @@ use {
9 git::remote_deployment_setup,
10 process_deploy_nextjs_ssr::process_deploy_nextjs_ssr,
11 process_deploy_rails::process_deploy_rails,
12 + process_deploy_rust::process_deploy_rust,
13 process_deploy_vite_spa::process_deploy_vite_spa,
14 remote_messages::{build_next_app, start_server},
15 rsync_deploy::rsync_deploy,
@@ -135,6 +136,11 @@ pub async fn process_deploy(
136 return process_deploy_rails(env, config).await;
137 }
138
139 + // Route Rust service projects: rsync source tree, then run a remote Cargo build script.
140 + if config.project.kind.as_deref() == Some("rust") {
141 + return process_deploy_rust(env, config).await;
142 + }
143 +
144 match config.project.deployment_method {
145 DeploymentMethod::Rsync => {
146 // For rsync deployments the runner is known from config — no framework
crates/cli/src/deploy/process_deploy_rust.rs new
+310
@@ -0,0 +1,310 @@
1 +use {
2 + crate::{
3 + cli::CommandResult,
4 + client,
5 + deploy::known_hosts,
6 + ui::{fail_message, fail_symbol, succeed_message, succeed_symbol},
7 + },
8 + anyhow::{anyhow, Result},
9 + chrono::Utc,
10 + smbcloud_auth::me::me,
11 + smbcloud_model::project::{DeploymentPayload, DeploymentStatus},
12 + smbcloud_network::environment::Environment,
13 + smbcloud_networking_project::{
14 + crud_project_deployment_create::create_deployment, crud_project_deployment_update::update,
15 + },
16 + smbcloud_utils::config::Config,
17 + spinners::{Spinner, Spinners},
18 + std::{
19 + io::Write,
20 + path::Path,
21 + process::{Command, Stdio},
22 + },
23 + tempfile::NamedTempFile,
24 +};
25 +
26 +/// Deploys a Rust service by syncing source to the server and running a remote
27 +/// Cargo build step.
28 +///
29 +/// Required config fields:
30 +/// - `kind = "rust"`
31 +/// - `path` — remote app directory on the server
32 +///
33 +/// Optional config fields:
34 +/// - `source` — local source directory (defaults to current directory)
35 +/// - `compile_cmd` — remote command sequence to run inside `path` after the
36 +/// upload. When omitted, the CLI runs `cargo build --release`.
37 +///
38 +/// Typical `compile_cmd` for a systemd-managed service:
39 +/// cargo build --release && sudo systemctl restart smbcloud-mail-imap
40 +pub async fn process_deploy_rust(env: Environment, config: Config) -> Result<CommandResult> {
41 + let source = config.project.source.as_deref().unwrap_or(".");
42 + let remote_path = config.project.path.as_deref().ok_or_else(|| {
43 + anyhow!(fail_message(
44 + "path not set in .smb/config.toml (e.g. path = \"apps/mail/smbcloud-mail-imap\")"
45 + ))
46 + })?;
47 +
48 + let source_dir = Path::new(source);
49 + if !source_dir.exists() {
50 + return Err(anyhow!(fail_message(&format!(
51 + "Source path '{}' does not exist. Check the 'source' field in .smb/config.toml.",
52 + source
53 + ))));
54 + }
55 +
56 + let access_token = crate::token::get_smb_token::get_smb_token(env)?;
57 + let user = me(env, client(), &access_token).await?;
58 + let runner = config.project.runner;
59 + let rsync_host = runner.rsync_host();
60 +
61 + let home = dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory"))?;
62 + let identity_file = home.join(".ssh").join(format!("id_{}@smbcloud", user.id));
63 + let identity_file_str = identity_file.to_string_lossy().into_owned();
64 +
65 + let deploy_ref = Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
66 +
67 + let created_deployment = create_deployment(
68 + env,
69 + client(),
70 + &access_token,
71 + config.project.id,
72 + DeploymentPayload {
73 + commit_hash: deploy_ref.clone(),
74 + status: DeploymentStatus::Started,
75 + },
76 + )
77 + .await
78 + .ok();
79 +
80 + let mut known_hosts_file = NamedTempFile::new()
81 + .map_err(|error| anyhow!("Failed to create temp known_hosts file: {}", error))?;
82 + writeln!(known_hosts_file, "{}", known_hosts::for_host(&rsync_host))
83 + .map_err(|error| anyhow!("Failed to write known_hosts: {}", error))?;
84 +
85 + let ssh_command = build_ssh_command(&identity_file_str, &known_hosts_file);
86 +
87 + let source_with_slash = if source.ends_with('/') {
88 + source.to_owned()
89 + } else {
90 + format!("{}/", source)
91 + };
92 +
93 + let remote_with_slash = if remote_path.ends_with('/') {
94 + remote_path.to_owned()
95 + } else {
96 + format!("{}/", remote_path)
97 + };
98 +
99 + let destination = format!("git@{}:{}", rsync_host, remote_with_slash);
100 +
101 + let mut upload_spinner = Spinner::new(
102 + Spinners::Hamburger,
103 + succeed_message(&format!("Uploading Rust project {}…", source)),
104 + );
105 +
106 + let upload_output = Command::new("rsync")
107 + .args([
108 + "-az",
109 + "--delete",
110 + "--exclude=.git",
111 + "--exclude=.smb",
112 + "--exclude=target",
113 + "-e",
114 + &ssh_command,
115 + &source_with_slash,
116 + &destination,
117 + ])
118 + .output()
119 + .map_err(|error| anyhow!(fail_message(&format!("Failed to launch rsync: {}", error))))?;
120 +
121 + if !upload_output.status.success() {
122 + drop(known_hosts_file);
123 + upload_spinner.stop_and_persist(&fail_symbol(), fail_message("Upload failed."));
124 + let stderr = String::from_utf8_lossy(&upload_output.stderr);
125 + mark_failed(
126 + &deploy_ref,
127 + &created_deployment,
128 + &config,
129 + env,
130 + &access_token,
131 + )
132 + .await;
133 + return Err(anyhow!(fail_message(&format!(
134 + "rsync exited with status {}: {}",
135 + upload_output.status.code().unwrap_or(-1),
136 + stderr.trim()
137 + ))));
138 + }
139 +
140 + upload_spinner.stop_and_persist(&succeed_symbol(), succeed_message("Upload complete."));
141 +
142 + let remote_script_body = match config.project.compile_cmd.as_deref() {
143 + Some(command) => command.to_owned(),
144 + None => "cargo build --release".to_owned(),
145 + };
146 +
147 + let deploy_script = format!(
148 + r#"set -e
149 +source ~/.profile 2>/dev/null || true
150 +source ~/.bashrc 2>/dev/null || true
151 +APP_PATH=\"{remote_path}\"
152 +
153 +if [ ! -d \"$APP_PATH\" ]; then
154 + echo \"Error: $APP_PATH is not a directory.\"
155 + exit 1
156 +fi
157 +
158 +cd \"$APP_PATH\"
159 +{remote_script_body}
160 +echo \"Done.\"
161 +"#,
162 + remote_path = remote_path,
163 + remote_script_body = remote_script_body,
164 + );
165 +
166 + let mut remote_spinner = Spinner::new(
167 + Spinners::SimpleDotsScrolling,
168 + succeed_message("Running remote Rust deploy script…"),
169 + );
170 +
171 + let mut child = Command::new("ssh")
172 + .args(build_ssh_args(
173 + &identity_file_str,
174 + &known_hosts_file,
175 + &rsync_host,
176 + ))
177 + .stdin(Stdio::piped())
178 + .stdout(Stdio::piped())
179 + .stderr(Stdio::piped())
180 + .spawn()
181 + .map_err(|error| anyhow!(fail_message(&format!("Failed to spawn SSH: {}", error))))?;
182 +
183 + if let Some(mut stdin) = child.stdin.take() {
184 + stdin
185 + .write_all(deploy_script.as_bytes())
186 + .map_err(|error| anyhow!("Failed to write deploy script to SSH stdin: {}", error))?;
187 + }
188 +
189 + let ssh_output = child
190 + .wait_with_output()
191 + .map_err(|error| anyhow!("Failed to wait for SSH process: {}", error))?;
192 +
193 + drop(known_hosts_file);
194 +
195 + if !ssh_output.status.success() {
196 + remote_spinner.stop_and_persist(&fail_symbol(), fail_message("Remote deploy failed."));
197 + let stderr = String::from_utf8_lossy(&ssh_output.stderr);
198 + let stdout = String::from_utf8_lossy(&ssh_output.stdout);
199 + let details = if !stderr.trim().is_empty() {
200 + stderr
201 + } else {
202 + stdout
203 + };
204 + if !details.trim().is_empty() {
205 + eprintln!("{}", details.trim());
206 + }
207 + mark_failed(
208 + &deploy_ref,
209 + &created_deployment,
210 + &config,
211 + env,
212 + &access_token,
213 + )
214 + .await;
215 + return Err(anyhow!(fail_message(&format!(
216 + "SSH deploy script exited with status {}",
217 + ssh_output.status
218 + ))));
219 + }
220 +
221 + remote_spinner.stop_and_persist(
222 + &succeed_symbol(),
223 + succeed_message("Remote Rust deploy script completed."),
224 + );
225 +
226 + if let Some(ref deployment) = created_deployment {
227 + match update(
228 + env,
229 + client(),
230 + access_token,
231 + config.project.id,
232 + deployment.id,
233 + DeploymentPayload {
234 + commit_hash: deploy_ref,
235 + status: DeploymentStatus::Done,
236 + },
237 + )
238 + .await
239 + {
240 + Ok(_) => println!("App is running {}", succeed_symbol()),
241 + Err(error) => eprintln!("Error updating deployment status to Done: {}", error),
242 + }
243 + }
244 +
245 + Ok(CommandResult {
246 + spinner: Spinner::new(Spinners::Hamburger, String::new()),
247 + symbol: succeed_symbol(),
248 + msg: succeed_message("Deployment complete."),
249 + })
250 +}
251 +
252 +fn build_ssh_command(identity_file: &str, known_hosts_file: &NamedTempFile) -> String {
253 + format!(
254 + "ssh -i {identity} \\
255 + -o StrictHostKeyChecking=yes \\
256 + -o UserKnownHostsFile={known_hosts} \\
257 + -o IdentitiesOnly=yes \\
258 + -o PasswordAuthentication=no \\
259 + -o BatchMode=yes",
260 + identity = identity_file,
261 + known_hosts = known_hosts_file.path().display(),
262 + )
263 +}
264 +
265 +fn build_ssh_args<'a>(
266 + identity_file: &'a str,
267 + known_hosts_file: &'a NamedTempFile,
268 + rsync_host: &'a str,
269 +) -> Vec<String> {
270 + vec![
271 + "-i".to_owned(),
272 + identity_file.to_owned(),
273 + "-o".to_owned(),
274 + "StrictHostKeyChecking=yes".to_owned(),
275 + "-o".to_owned(),
276 + format!("UserKnownHostsFile={}", known_hosts_file.path().display()),
277 + "-o".to_owned(),
278 + "IdentitiesOnly=yes".to_owned(),
279 + "-o".to_owned(),
280 + "PasswordAuthentication=no".to_owned(),
281 + "-o".to_owned(),
282 + "BatchMode=yes".to_owned(),
283 + format!("git@{}", rsync_host),
284 + "bash".to_owned(),
285 + "-s".to_owned(),
286 + ]
287 +}
288 +
289 +async fn mark_failed(
290 + deploy_ref: &str,
291 + created_deployment: &Option<smbcloud_model::project::Deployment>,
292 + config: &Config,
293 + env: Environment,
294 + access_token: &str,
295 +) {
296 + if let Some(ref deployment) = created_deployment {
297 + let _ = update(
298 + env,
299 + client(),
300 + access_token.to_owned(),
301 + config.project.id,
302 + deployment.id,
303 + DeploymentPayload {
304 + commit_hash: deploy_ref.to_owned(),
305 + status: DeploymentStatus::Failed,
306 + },
307 + )
308 + .await;
309 + }
310 +}
crates/cli/src/deploy/setup_create_new_project.rs
+7 -1
@@ -74,7 +74,13 @@ pub(crate) async fn create_new_project(
74 }
75 };
76
77 - let runners = vec![Runner::NodeJs, Runner::Static, Runner::Ruby, Runner::Swift];
77 + let runners = vec![
78 + Runner::NodeJs,
79 + Runner::Static,
80 + Runner::Ruby,
81 + Runner::Swift,
82 + Runner::Rust,
83 + ];
84 let runner = Select::with_theme(&ColorfulTheme::default())
85 .items(&runners)
86 .default(0)
crates/cli/src/project/crud_create.rs
+11 -4
@@ -36,7 +36,13 @@ pub async fn process_project_init(
36 }
37 };
38
39 - let runners = vec![Runner::NodeJs, Runner::Static, Runner::Ruby, Runner::Swift];
39 + let runners = vec![
40 + Runner::NodeJs,
41 + Runner::Static,
42 + Runner::Ruby,
43 + Runner::Swift,
44 + Runner::Rust,
45 + ];
46 let runner = Select::with_theme(&ColorfulTheme::default())
47 .items(&runners)
48 .default(0)
@@ -64,7 +70,7 @@ pub async fn process_project_init(
70 };
71
72 if should_init_project {
67 - setup_smb_folder(&project_name, &description).await?;
73 + setup_smb_folder(&project_name, &description, runner).await?;
74 }
75
76 let spinner = Spinner::new(
@@ -99,7 +105,7 @@ pub async fn process_project_init(
105 }
106 }
107
102 -async fn setup_smb_folder(name: &str, description: &str) -> Result<()> {
108 +async fn setup_smb_folder(name: &str, description: &str, runner: Runner) -> Result<()> {
109 // Create .smb folder in the current directory
110 std::fs::create_dir(".smb")?;
111 // Create config.toml file in the .smb folder
@@ -114,13 +120,14 @@ description = "{description}"
120 [project]
121 id = 1
122 name = "{repository_name}"
117 -runner = 0
123 +runner = {runner}
124 deployment_method = 0
125 repository = "{repository_name}"
126 description = "{description}"
127 created_at = "{now}"
128 updated_at = "{now}"
129 "#,
130 + runner = runner as u8,
131 ),
132 )?;
133 Ok(())
crates/cli/src/project/crud_update.rs
+7 -1
@@ -41,7 +41,13 @@ pub async fn process_project_update(env: Environment, project_id: String) -> Res
41 Err(_) => return Err(anyhow!("Invalid description.")),
42 };
43
44 - let runners = vec![Runner::NodeJs, Runner::Static, Runner::Ruby, Runner::Swift];
44 + let runners = vec![
45 + Runner::NodeJs,
46 + Runner::Static,
47 + Runner::Ruby,
48 + Runner::Swift,
49 + Runner::Rust,
50 + ];
51 let runner = Select::with_theme(&ColorfulTheme::default())
52 .items(&runners)
53 .default(0)
crates/smbcloud-model/src/runner.rs
+6 -1
@@ -19,6 +19,7 @@ pub enum Runner {
19 Static = 1,
20 Ruby = 2,
21 Swift = 3,
22 + Rust = 4,
23 Monorepo = 255,
24 }
25
@@ -29,6 +30,7 @@ impl Display for Runner {
30 Runner::Static => write!(f, "Static"),
31 Runner::Ruby => write!(f, "Ruby"),
32 Runner::Swift => write!(f, "Swift"),
33 + Runner::Rust => write!(f, "Rust"),
34 Runner::Monorepo => write!(f, "Monorepo"),
35 }
36 }
@@ -68,6 +70,9 @@ impl Runner {
70 if repo_path.join("Package.swift").exists() {
71 return Ok(Runner::Swift);
72 }
73 + if repo_path.join("Cargo.toml").exists() {
74 + return Ok(Runner::Rust);
75 + }
76 // See if we have a monorepo setup.
77 non_framework_runner()
78 }
@@ -87,7 +92,7 @@ impl Runner {
92 Runner::Monorepo => "monorepo",
93 // Static sites and NodeJs projects share the same lightweight tier
94 Runner::NodeJs | Runner::Static => "api",
90 - Runner::Ruby | Runner::Swift => "api-1",
95 + Runner::Ruby | Runner::Swift | Runner::Rust => "api-1",
96 }
97 }
98 }