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
}