@smbcloud / cli / commits / 98d7dfd

Add server side Swift project deployment

Seto Elkahfi committed Jun 11, 2026 at 23:38 UTC 98d7dfdb9b3b9ea9e29c5778369c8e08965e76b7
5 files changed +853
crates/cli/src/deploy/mod.rs
+1
@@ -6,6 +6,7 @@ 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_swift;
10 pub mod process_deploy_vite_spa;
11 pub mod process_migrate;
12 mod remote_messages;
crates/cli/src/deploy/process_deploy.rs
+6
@@ -10,6 +10,7 @@ use {
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_swift::process_deploy_swift,
14 process_deploy_vite_spa::process_deploy_vite_spa,
15 remote_messages::{build_next_app, start_server},
16 rsync_deploy::rsync_deploy,
@@ -143,6 +144,11 @@ pub async fn process_deploy(
144 return process_deploy_rust(env, config).await;
145 }
146
147 + // Route Swift/Vapor projects: build a Linux binary via Docker, rsync binary + Resources/ + Public/, SSH restart.
148 + if config.project.kind.as_deref() == Some("swift") {
149 + return process_deploy_swift(env, config).await;
150 + }
151 +
152 match config.project.deployment_method {
153 DeploymentMethod::Rsync => {
154 // For rsync deployments the runner is known from config — no framework
crates/cli/src/deploy/process_deploy_swift.rs new
+832
@@ -0,0 +1,832 @@
1 +use {
2 + crate::{
3 + cli::CommandResult,
4 + client,
5 + deploy::known_hosts,
6 + ui::{fail_message, fail_symbol, 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 + fs,
20 + io::Write,
21 + path::{Path, PathBuf},
22 + process::{Command, Output, Stdio},
23 + },
24 + tempfile::NamedTempFile,
25 +};
26 +
27 +/// Default Swift SDK used to cross-compile for Linux. The Static Linux SDK
28 +/// produces a fully static musl binary that runs on any Linux host.
29 +const DEFAULT_SWIFT_SDK: &str = "x86_64-swift-linux-musl";
30 +
31 +/// Deploys a Swift/Vapor app by cross-compiling a Linux binary natively on the
32 +/// host with the Swift Static Linux SDK (no Docker, no emulation), uploading the
33 +/// binary and resource directories via rsync, then restarting the process on the
34 +/// server over SSH.
35 +///
36 +/// Required config fields:
37 +/// - `kind = "swift"`
38 +/// - `path` — remote app directory on the server (relative to git home)
39 +/// - `port` — port Vapor binds to (must match nginx upstream)
40 +///
41 +/// Optional config fields:
42 +/// - `source` — local source directory (defaults to `.`)
43 +/// - `binary_name` — executable name; falls back to Package.swift product name
44 +/// - `swift_sdk` — Swift SDK id (defaults to `x86_64-swift-linux-musl`)
45 +/// - `swift_toolchain` — `TOOLCHAINS` value for the build; needed on macOS
46 +/// where the default `swift` is Apple's Xcode toolchain (no lld)
47 +pub async fn process_deploy_swift(env: Environment, config: Config) -> Result<CommandResult> {
48 + let deploy_start = std::time::Instant::now();
49 +
50 + let source = config.project.source.as_deref().unwrap_or(".");
51 + let remote_path = config.project.path.as_deref().ok_or_else(|| {
52 + anyhow!(fail_message(
53 + "path not set in .smb/config.toml (e.g. path = \"apps/swiftyidwebsite\")"
54 + ))
55 + })?;
56 + let port = config.project.port.ok_or_else(|| {
57 + anyhow!(fail_message(
58 + "port not set in .smb/config.toml (e.g. port = 3010)"
59 + ))
60 + })?;
61 +
62 + let source_dir = Path::new(source);
63 + if !source_dir.exists() {
64 + return Err(anyhow!(fail_message(&format!(
65 + "Source path '{}' does not exist. Check the 'source' field in .smb/config.toml.",
66 + source
67 + ))));
68 + }
69 +
70 + let binary_name = resolve_binary_name(&config, source_dir)?;
71 + let swift_sdk = config
72 + .project
73 + .swift_sdk
74 + .as_deref()
75 + .unwrap_or(DEFAULT_SWIFT_SDK);
76 + let swift_toolchain = config.project.swift_toolchain.as_deref();
77 +
78 + println!();
79 + println!(" {}", console::style(&config.name).white().bold());
80 + println!();
81 +
82 + let binary_path = build_linux_binary(source, &binary_name, swift_sdk, swift_toolchain)?;
83 +
84 + let access_token = crate::token::get_smb_token::get_smb_token(env)?;
85 + let user = me(env, client(), &access_token).await?;
86 + let runner = config.project.runner;
87 + let rsync_host = runner.rsync_host();
88 +
89 + let deploy_ref = git2::Repository::discover(source)
90 + .ok()
91 + .and_then(|repo| {
92 + let head = repo.head().ok()?;
93 + let commit = head.peel_to_commit().ok()?;
94 + Some(commit.id().to_string())
95 + })
96 + .unwrap_or_else(|| Utc::now().format("%Y%m%dT%H%M%SZ").to_string());
97 +
98 + let created_deployment = create_deployment(
99 + env,
100 + client(),
101 + &access_token,
102 + config.project.id,
103 + DeploymentPayload {
104 + commit_hash: deploy_ref.clone(),
105 + status: DeploymentStatus::Started,
106 + frontend_app_id: config.project.frontend_app_id.clone(),
107 + },
108 + )
109 + .await
110 + .ok();
111 +
112 + let home = dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory"))?;
113 + let identity_file = home.join(".ssh").join(format!("id_{}@smbcloud", user.id));
114 + let identity_file_str = identity_file.to_string_lossy().into_owned();
115 +
116 + let mut known_hosts_file = NamedTempFile::new()
117 + .map_err(|e| anyhow!("Failed to create temp known_hosts file: {}", e))?;
118 + writeln!(known_hosts_file, "{}", known_hosts::for_host(&rsync_host))
119 + .map_err(|e| anyhow!("Failed to write known_hosts: {}", e))?;
120 +
121 + // ── Prepare remote directory ─────────────────────────────────────────────
122 +
123 + let mut prepare_spinner = Spinner::new(
124 + Spinners::SimpleDotsScrolling,
125 + format!(
126 + " {} {}",
127 + console::style("◼").cyan(),
128 + console::style("Preparing server…").dim()
129 + ),
130 + );
131 +
132 + let prepare_output = run_remote_script(
133 + &identity_file_str,
134 + &known_hosts_file,
135 + &rsync_host,
136 + &build_remote_prepare_script(remote_path),
137 + )
138 + .map_err(|e| {
139 + anyhow!(fail_message(&format!(
140 + "Failed to prepare remote directory: {}",
141 + e
142 + )))
143 + })?;
144 +
145 + if !prepare_output.status.success() {
146 + prepare_spinner.stop_and_persist(
147 + &fail_symbol(),
148 + format!(
149 + " {} {}",
150 + console::style("✘").red(),
151 + fail_message("Server prepare failed")
152 + ),
153 + );
154 + print_output_details(&prepare_output);
155 + drop(known_hosts_file);
156 + mark_failed(
157 + &deploy_ref,
158 + &created_deployment,
159 + &config,
160 + env,
161 + &access_token,
162 + )
163 + .await;
164 + return Err(anyhow!(fail_message(&format!(
165 + "Failed to prepare remote directory '{}'",
166 + remote_path
167 + ))));
168 + }
169 +
170 + let binary_size = fs::metadata(&binary_path).map(|m| m.len()).unwrap_or(0);
171 + prepare_spinner.stop_and_persist(
172 + &format!(" {}", console::style("◼").cyan()),
173 + format!(
174 + "{} {} → {} ({})",
175 + console::style("Upload").white().bold(),
176 + console::style(&binary_name).dim(),
177 + console::style(format!("{}:{}", &rsync_host, remote_path)).dim(),
178 + console::style(format_size(binary_size)).dim(),
179 + ),
180 + );
181 +
182 + // ── Upload binary + Resources/ + Public/ ─────────────────────────────────
183 +
184 + let mut upload_spinner = Spinner::new(
185 + Spinners::Hamburger,
186 + format!(
187 + " {} {}",
188 + console::style("◼").cyan(),
189 + console::style("Uploading…").dim()
190 + ),
191 + );
192 +
193 + let ssh_command = build_ssh_command(&identity_file_str, &known_hosts_file);
194 + let remote_with_slash = ensure_trailing_slash(remote_path);
195 + let destination = format!("git@{}:{}", rsync_host, remote_with_slash);
196 + let binary_path_str = binary_path.to_string_lossy().into_owned();
197 +
198 + let binary_upload = Command::new("rsync")
199 + .args(["-az", "-e", &ssh_command, &binary_path_str, &destination])
200 + .output()
201 + .map_err(|e| anyhow!(fail_message(&format!("Failed to launch rsync: {}", e))))?;
202 +
203 + if !binary_upload.status.success() {
204 + upload_spinner.stop_and_persist(
205 + &fail_symbol(),
206 + format!(
207 + " {} {}",
208 + console::style("✘").red(),
209 + fail_message("Binary upload failed")
210 + ),
211 + );
212 + print_output_details(&binary_upload);
213 + drop(known_hosts_file);
214 + mark_failed(
215 + &deploy_ref,
216 + &created_deployment,
217 + &config,
218 + env,
219 + &access_token,
220 + )
221 + .await;
222 + return Err(anyhow!(fail_message("Failed to upload Swift binary")));
223 + }
224 +
225 + // Resources/ — required by Leaf for template rendering; cwd of the running
226 + // binary must be the remote_path so Leaf resolves Resources/Views/ correctly.
227 + let resources_dir = source_dir.join("Resources");
228 + if resources_dir.exists() {
229 + let resources_upload = Command::new("rsync")
230 + .args([
231 + "-az",
232 + "--delete",
233 + "-e",
234 + &ssh_command,
235 + &format!("{}/Resources/", source),
236 + &format!("{}Resources/", destination),
237 + ])
238 + .output()
239 + .map_err(|e| anyhow!(fail_message(&format!("Failed to launch rsync: {}", e))))?;
240 +
241 + if !resources_upload.status.success() {
242 + upload_spinner.stop_and_persist(
243 + &fail_symbol(),
244 + format!(
245 + " {} {}",
246 + console::style("✘").red(),
247 + fail_message("Resources/ upload failed")
248 + ),
249 + );
250 + print_output_details(&resources_upload);
251 + drop(known_hosts_file);
252 + mark_failed(
253 + &deploy_ref,
254 + &created_deployment,
255 + &config,
256 + env,
257 + &access_token,
258 + )
259 + .await;
260 + return Err(anyhow!(fail_message("Failed to upload Resources/")));
261 + }
262 + }
263 +
264 + // Public/ — static assets served by Vapor's FileMiddleware.
265 + let public_dir = source_dir.join("Public");
266 + if public_dir.exists() {
267 + let public_upload = Command::new("rsync")
268 + .args([
269 + "-az",
270 + "--delete",
271 + "-e",
272 + &ssh_command,
273 + &format!("{}/Public/", source),
274 + &format!("{}Public/", destination),
275 + ])
276 + .output()
277 + .map_err(|e| anyhow!(fail_message(&format!("Failed to launch rsync: {}", e))))?;
278 +
279 + if !public_upload.status.success() {
280 + upload_spinner.stop_and_persist(
281 + &fail_symbol(),
282 + format!(
283 + " {} {}",
284 + console::style("✘").red(),
285 + fail_message("Public/ upload failed")
286 + ),
287 + );
288 + print_output_details(&public_upload);
289 + drop(known_hosts_file);
290 + mark_failed(
291 + &deploy_ref,
292 + &created_deployment,
293 + &config,
294 + env,
295 + &access_token,
296 + )
297 + .await;
298 + return Err(anyhow!(fail_message("Failed to upload Public/")));
299 + }
300 + }
301 +
302 + drop(known_hosts_file);
303 + upload_spinner.stop_and_persist(
304 + &format!(" {}", console::style("◼").cyan()),
305 + format!(
306 + "{} Starting {}…",
307 + console::style("Launch").white().bold(),
308 + console::style(&binary_name).dim(),
309 + ),
310 + );
311 +
312 + // ── SSH remote start ─────────────────────────────────────────────────────
313 +
314 + let mut ssh_known_hosts_file = NamedTempFile::new()
315 + .map_err(|e| anyhow!("Failed to create temp known_hosts file: {}", e))?;
316 + writeln!(
317 + ssh_known_hosts_file,
318 + "{}",
319 + known_hosts::for_host(&rsync_host)
320 + )
321 + .map_err(|e| anyhow!("Failed to write known_hosts: {}", e))?;
322 +
323 + let ssh_output = run_remote_script(
324 + &identity_file_str,
325 + &ssh_known_hosts_file,
326 + &rsync_host,
327 + &build_remote_start_script(remote_path, &binary_name, port),
328 + )
329 + .map_err(|e| anyhow!(fail_message(&format!("Failed to spawn SSH: {}", e))))?;
330 +
331 + drop(ssh_known_hosts_file);
332 +
333 + if !ssh_output.status.success() {
334 + println!(
335 + " {} {}",
336 + console::style("✘").red(),
337 + fail_message("Launch failed")
338 + );
339 + print_output_details(&ssh_output);
340 + mark_failed(
341 + &deploy_ref,
342 + &created_deployment,
343 + &config,
344 + env,
345 + &access_token,
346 + )
347 + .await;
348 + return Err(anyhow!(fail_message(&format!(
349 + "SSH deploy script exited with status {}",
350 + ssh_output.status
351 + ))));
352 + }
353 +
354 + let stdout_text = String::from_utf8_lossy(&ssh_output.stdout);
355 + let pid_line = stdout_text
356 + .lines()
357 + .find(|line| line.starts_with("Started"))
358 + .unwrap_or("Started");
359 +
360 + println!(
361 + " {} {} {}",
362 + console::style("◼").cyan(),
363 + console::style("Launch").white().bold(),
364 + console::style(pid_line).dim(),
365 + );
366 +
367 + if let Some(ref deployment) = created_deployment {
368 + let _ = update(
369 + env,
370 + client(),
371 + access_token,
372 + config.project.id,
373 + deployment.id,
374 + DeploymentPayload {
375 + commit_hash: deploy_ref,
376 + status: DeploymentStatus::Done,
377 + frontend_app_id: config.project.frontend_app_id.clone(),
378 + },
379 + )
380 + .await;
381 + }
382 +
383 + let elapsed = deploy_start.elapsed().as_secs();
384 + let duration = if elapsed >= 60 {
385 + format!("{}m {}s", elapsed / 60, elapsed % 60)
386 + } else {
387 + format!("{}s", elapsed)
388 + };
389 +
390 + println!();
391 +
392 + Ok(CommandResult {
393 + spinner: Spinner::new(Spinners::Hamburger, String::new()),
394 + symbol: succeed_symbol(),
395 + msg: format!(
396 + "Deployed {} in {}",
397 + console::style(&config.name).white().bold(),
398 + console::style(&duration).cyan(),
399 + ),
400 + })
401 +}
402 +
403 +fn resolve_binary_name(config: &Config, source_dir: &Path) -> Result<String> {
404 + if let Some(binary_name) = config.project.binary_name.as_deref() {
405 + let binary_name = binary_name.trim();
406 + if binary_name.is_empty() {
407 + return Err(anyhow!(fail_message(
408 + "binary_name in .smb/config.toml cannot be empty."
409 + )));
410 + }
411 + return Ok(binary_name.to_owned());
412 + }
413 +
414 + let package_swift = source_dir.join("Package.swift");
415 + if package_swift.exists() {
416 + let contents = fs::read_to_string(&package_swift).map_err(|e| {
417 + anyhow!(fail_message(&format!(
418 + "Failed to read '{}': {}",
419 + package_swift.display(),
420 + e
421 + )))
422 + })?;
423 + if let Some(name) = parse_swift_package_name(&contents) {
424 + return Ok(name);
425 + }
426 + }
427 +
428 + // Last resort: use the project config name.
429 + let project_name = config.project.name.trim();
430 + if !project_name.is_empty() {
431 + return Ok(project_name.to_owned());
432 + }
433 +
434 + Err(anyhow!(fail_message(
435 + "Could not determine Swift binary name. Set 'binary_name' in .smb/config.toml."
436 + )))
437 +}
438 +
439 +/// Extracts the package name from the `Package(name: "X", ...)` call in Package.swift.
440 +fn parse_swift_package_name(contents: &str) -> Option<String> {
441 + let package_idx = contents.find("Package(")?;
442 + let after_package = &contents[package_idx..];
443 + let name_idx = after_package.find("name:")?;
444 + let after_name = &after_package[name_idx + 5..];
445 + let quote_start = after_name.find('"')? + 1;
446 + let after_quote = &after_name[quote_start..];
447 + let quote_end = after_quote.find('"')?;
448 + let name = after_quote[..quote_end].trim();
449 + if name.is_empty() {
450 + None
451 + } else {
452 + Some(name.to_owned())
453 + }
454 +}
455 +
456 +/// Cross-compiles the Swift app for Linux on the host using the Swift Static
457 +/// Linux SDK (`swift build --swift-sdk <id>`). This runs natively — no Docker,
458 +/// no QEMU emulation — and yields a fully static musl binary that runs on any
459 +/// Linux host, so the server needs no Swift runtime installed.
460 +///
461 +/// On macOS the default `swift` is often Apple's Xcode toolchain, which lacks
462 +/// `lld` and fails to link the Linux binary. Set `swift_toolchain` (passed as
463 +/// `TOOLCHAINS`) to a swift.org toolchain in that case. When `swift` is already
464 +/// a swift.org toolchain (e.g. via swiftly on the server), no toolchain override
465 +/// is needed.
466 +fn build_linux_binary(
467 + source: &str,
468 + binary_name: &str,
469 + swift_sdk: &str,
470 + swift_toolchain: Option<&str>,
471 +) -> Result<PathBuf> {
472 + if !command_exists("swift") {
473 + return Err(anyhow!(fail_message(
474 + "`swift` not found on PATH. Install a Swift toolchain (https://swift.org/install)."
475 + )));
476 + }
477 +
478 + println!(
479 + " {} {} {} → {}",
480 + console::style("◼").cyan(),
481 + console::style("Build").white().bold(),
482 + console::style(binary_name).dim(),
483 + console::style(format!("{} (static)", swift_sdk)).dim(),
484 + );
485 +
486 + // Resolve the exact bin directory the SDK build will write to, instead of
487 + // hardcoding `.build/<sdk>/release`. This also surfaces a missing SDK early
488 + // with the real toolchain error rather than a confusing "binary not found".
489 + let bin_path = swift_show_bin_path(source, swift_sdk, swift_toolchain)?;
490 +
491 + // `-Xlinker -s` strips symbols at link time (lld). For a statically-linked
492 + // Swift binary this roughly cuts the size by two-thirds (the static runtime
493 + // and Foundation carry large debug sections), which matters because the whole
494 + // binary is rsynced on every deploy.
495 + let mut command = Command::new("swift");
496 + command
497 + .args([
498 + "build",
499 + "-c",
500 + "release",
501 + "--swift-sdk",
502 + swift_sdk,
503 + "-Xlinker",
504 + "-s",
505 + ])
506 + .current_dir(source)
507 + .stdout(Stdio::inherit())
508 + .stderr(Stdio::inherit());
509 + if let Some(toolchain) = swift_toolchain {
510 + command.env("TOOLCHAINS", toolchain);
511 + }
512 +
513 + let status = command.status().map_err(|e| {
514 + anyhow!(fail_message(&format!(
515 + "Failed to spawn `swift build`: {}",
516 + e
517 + )))
518 + })?;
519 +
520 + if !status.success() {
521 + return Err(anyhow!(fail_message(&format!(
522 + "Swift cross-compile failed (status {}). If the linker errored with \
523 + `-fuse-ld=lld` / `invalid linker name`, your default `swift` is Apple's \
524 + Xcode toolchain (no lld) — set `swift_toolchain` in .smb/config.toml to a \
525 + swift.org toolchain (e.g. swift_toolchain = \"swift\").",
526 + status
527 + ))));
528 + }
529 +
530 + let binary_path = bin_path.join(binary_name);
531 + if !binary_path.exists() {
532 + return Err(anyhow!(fail_message(&format!(
533 + "Built binary not found at '{}'. Verify the executable product name matches \
534 + 'binary_name' in .smb/config.toml.",
535 + binary_path.display()
536 + ))));
537 + }
538 +
539 + let size = fs::metadata(&binary_path).map(|m| m.len()).unwrap_or(0);
540 + println!(
541 + " {} {} {} → {} ({})",
542 + console::style("◼").cyan(),
543 + console::style("Build").white().bold(),
544 + console::style(binary_name).dim(),
545 + console::style(swift_sdk).dim(),
546 + console::style(format_size(size)).dim(),
547 + );
548 +
549 + Ok(binary_path)
550 +}
551 +
552 +/// Asks SwiftPM for the release bin path for the given SDK. Doubles as an early
553 +/// validation that the SDK is installed and the toolchain can target it.
554 +fn swift_show_bin_path(
555 + source: &str,
556 + swift_sdk: &str,
557 + swift_toolchain: Option<&str>,
558 +) -> Result<PathBuf> {
559 + let mut command = Command::new("swift");
560 + command
561 + .args([
562 + "build",
563 + "-c",
564 + "release",
565 + "--swift-sdk",
566 + swift_sdk,
567 + "--show-bin-path",
568 + ])
569 + .current_dir(source);
570 + if let Some(toolchain) = swift_toolchain {
571 + command.env("TOOLCHAINS", toolchain);
572 + }
573 +
574 + let output = command.output().map_err(|e| {
575 + anyhow!(fail_message(&format!(
576 + "Failed to spawn `swift build --show-bin-path`: {}",
577 + e
578 + )))
579 + })?;
580 +
581 + if !output.status.success() {
582 + let stderr = String::from_utf8_lossy(&output.stderr);
583 + return Err(anyhow!(fail_message(&format!(
584 + "Could not resolve the Swift SDK '{}'. Install the Static Linux SDK \
585 + (`swift sdk install <artifactbundle URL>`) and ensure it matches your \
586 + toolchain version.\n{}",
587 + swift_sdk,
588 + stderr.trim()
589 + ))));
590 + }
591 +
592 + let path = String::from_utf8_lossy(&output.stdout).trim().to_owned();
593 + Ok(PathBuf::from(path))
594 +}
595 +
596 +fn build_remote_prepare_script(remote_path: &str) -> String {
597 + format!(
598 + r#"set -e
599 +APP_PATH={remote_path}
600 +case "$APP_PATH" in
601 + /*) ;;
602 + *) APP_PATH="$HOME/$APP_PATH" ;;
603 +esac
604 +mkdir -p "$APP_PATH"
605 +echo "Prepared $APP_PATH"
606 +"#,
607 + remote_path = shell_single_quote(remote_path),
608 + )
609 +}
610 +
611 +fn build_remote_start_script(remote_path: &str, binary_name: &str, port: u16) -> String {
612 + format!(
613 + r#"set -e
614 +APP_PATH={remote_path}
615 +BINARY={binary_name}
616 +PORT={port}
617 +
618 +case "$APP_PATH" in
619 + /*) ;;
620 + *) APP_PATH="$HOME/$APP_PATH" ;;
621 +esac
622 +
623 +if [ ! -d "$APP_PATH" ]; then
624 + echo "Error: $APP_PATH is not a directory."
625 + exit 1
626 +fi
627 +
628 +cd "$APP_PATH"
629 +
630 +if [ ! -f "$BINARY" ]; then
631 + echo "Error: $BINARY does not exist in $APP_PATH."
632 + exit 1
633 +fi
634 +
635 +chmod +x "$BINARY"
636 +
637 +PID=$(pidof "$BINARY" 2>/dev/null || true)
638 +if [ -n "$PID" ]; then
639 + echo "Stopping $BINARY (PID $PID)..."
640 + kill "$PID" 2>/dev/null || true
641 + sleep 2
642 + if kill -0 "$PID" 2>/dev/null; then
643 + kill -9 "$PID" 2>/dev/null || true
644 + fi
645 +fi
646 +
647 +nohup "./$BINARY" serve --env production --hostname 127.0.0.1 --port "$PORT" >> "$APP_PATH/$BINARY.log" 2>&1 < /dev/null &
648 +sleep 2
649 +
650 +NEW_PID=$(pidof "$BINARY" 2>/dev/null || true)
651 +if [ -z "$NEW_PID" ]; then
652 + echo "Error: failed to start $BINARY. Check $APP_PATH/$BINARY.log"
653 + exit 1
654 +fi
655 +
656 +echo "Started $BINARY as $NEW_PID"
657 +echo "Done."
658 +"#,
659 + remote_path = shell_single_quote(remote_path),
660 + binary_name = shell_single_quote(binary_name),
661 + port = port,
662 + )
663 +}
664 +
665 +fn build_ssh_command(identity_file: &str, known_hosts_file: &NamedTempFile) -> String {
666 + format!(
667 + "ssh -i {} \
668 + -o StrictHostKeyChecking=yes \
669 + -o UserKnownHostsFile={} \
670 + -o IdentitiesOnly=yes \
671 + -o PasswordAuthentication=no \
672 + -o BatchMode=yes",
673 + identity_file,
674 + known_hosts_file.path().display(),
675 + )
676 +}
677 +
678 +fn run_remote_script(
679 + identity_file: &str,
680 + known_hosts_file: &NamedTempFile,
681 + rsync_host: &str,
682 + script: &str,
683 +) -> Result<Output> {
684 + let mut child = Command::new("ssh")
685 + .args([
686 + "-i",
687 + identity_file,
688 + "-o",
689 + "StrictHostKeyChecking=yes",
690 + "-o",
691 + &format!("UserKnownHostsFile={}", known_hosts_file.path().display()),
692 + "-o",
693 + "IdentitiesOnly=yes",
694 + "-o",
695 + "PasswordAuthentication=no",
696 + "-o",
697 + "BatchMode=yes",
698 + &format!("git@{}", rsync_host),
699 + "bash",
700 + "-s",
701 + ])
702 + .stdin(Stdio::piped())
703 + .stdout(Stdio::piped())
704 + .stderr(Stdio::piped())
705 + .spawn()
706 + .map_err(|e| anyhow!("Failed to spawn SSH: {}", e))?;
707 +
708 + if let Some(mut stdin) = child.stdin.take() {
709 + stdin
710 + .write_all(script.as_bytes())
711 + .map_err(|e| anyhow!("Failed to write deploy script to SSH stdin: {}", e))?;
712 + }
713 +
714 + child
715 + .wait_with_output()
716 + .map_err(|e| anyhow!("Failed to wait for SSH process: {}", e))
717 +}
718 +
719 +fn print_output_details(output: &Output) {
720 + let stderr = String::from_utf8_lossy(&output.stderr);
721 + let stdout = String::from_utf8_lossy(&output.stdout);
722 + let details = if !stderr.trim().is_empty() {
723 + stderr
724 + } else {
725 + stdout
726 + };
727 + if !details.trim().is_empty() {
728 + eprintln!("{}", details.trim());
729 + }
730 +}
731 +
732 +fn command_exists(command: &str) -> bool {
733 + Command::new(command).arg("--version").output().is_ok()
734 +}
735 +
736 +fn ensure_trailing_slash(path: &str) -> String {
737 + if path.ends_with('/') {
738 + path.to_owned()
739 + } else {
740 + format!("{}/", path)
741 + }
742 +}
743 +
744 +fn format_size(bytes: u64) -> String {
745 + if bytes >= 1_000_000 {
746 + format!("{} MB", bytes / 1_000_000)
747 + } else {
748 + format!("{} KB", bytes / 1_000)
749 + }
750 +}
751 +
752 +fn shell_single_quote(value: &str) -> String {
753 + format!("'{}'", value.replace('\'', "'\"'\"'"))
754 +}
755 +
756 +async fn mark_failed(
757 + deploy_ref: &str,
758 + created_deployment: &Option<smbcloud_model::project::Deployment>,
759 + config: &Config,
760 + env: Environment,
761 + access_token: &str,
762 +) {
763 + if let Some(ref deployment) = created_deployment {
764 + let _ = update(
765 + env,
766 + client(),
767 + access_token.to_owned(),
768 + config.project.id,
769 + deployment.id,
770 + DeploymentPayload {
771 + commit_hash: deploy_ref.to_owned(),
772 + status: DeploymentStatus::Failed,
773 + frontend_app_id: config.project.frontend_app_id.clone(),
774 + },
775 + )
776 + .await;
777 + }
778 +}
779 +
780 +#[cfg(test)]
781 +mod tests {
782 + use super::*;
783 +
784 + #[test]
785 + fn parses_package_name_from_package_swift() {
786 + let contents = r#"
787 +// swift-tools-version:6.1.0
788 +import PackageDescription
789 +
790 +let package = Package(
791 + name: "SwiftyWebsite",
792 + platforms: [
793 + .macOS(.v13)
794 + ],
795 +"#;
796 + assert_eq!(
797 + parse_swift_package_name(contents),
798 + Some("SwiftyWebsite".to_owned())
799 + );
800 + }
801 +
802 + #[test]
803 + fn parses_package_name_inline() {
804 + let contents = r#"let package = Package(name: "MyApp", dependencies: [])"#;
805 + assert_eq!(parse_swift_package_name(contents), Some("MyApp".to_owned()));
806 + }
807 +
808 + #[test]
809 + fn returns_none_for_missing_package_call() {
810 + assert_eq!(parse_swift_package_name("// no Package here"), None);
811 + }
812 +
813 + #[test]
814 + fn ensure_trailing_slash_adds_when_missing() {
815 + assert_eq!(ensure_trailing_slash("apps/web/foo"), "apps/web/foo/");
816 + }
817 +
818 + #[test]
819 + fn ensure_trailing_slash_noop_when_present() {
820 + assert_eq!(ensure_trailing_slash("apps/web/foo/"), "apps/web/foo/");
821 + }
822 +
823 + #[test]
824 + fn format_size_megabytes() {
825 + assert_eq!(format_size(45_000_000), "45 MB");
826 + }
827 +
828 + #[test]
829 + fn format_size_kilobytes() {
830 + assert_eq!(format_size(512_000), "512 KB");
831 + }
832 +}
crates/cli/src/deploy/process_migrate.rs
+2
@@ -202,6 +202,8 @@ fn strip_project(project: &Project) -> Project {
202 install_command: None,
203 binary_name: None,
204 rust_target: None,
205 + swift_sdk: None,
206 + swift_toolchain: None,
207 }
208 }
209
crates/smbcloud-model/src/project.rs
+12
@@ -109,6 +109,18 @@ pub struct Project {
109 /// Rust target triple used for local cross-compilation before upload,
110 /// e.g. "x86_64-unknown-linux-gnu".
111 pub rust_target: Option<String>,
112 + /// Swift SDK identifier used to cross-compile a Swift/Vapor app for Linux,
113 + /// e.g. "x86_64-swift-linux-musl" (the Static Linux SDK). Defaults to
114 + /// "x86_64-swift-linux-musl" when absent. Built natively on the host with
115 + /// `swift build --swift-sdk <id>` — no Docker, no emulation.
116 + pub swift_sdk: Option<String>,
117 + /// Optional toolchain identifier passed via the `TOOLCHAINS` env var when
118 + /// cross-compiling Swift. Needed on macOS where the default `swift` is
119 + /// Apple's Xcode toolchain (which lacks `lld`); point it at an installed
120 + /// swift.org toolchain, e.g. "swift" (resolves to swift-latest) or a bundle
121 + /// id like "org.swift.632202605101a". Unnecessary when `swift` is already a
122 + /// swift.org toolchain (e.g. via swiftly).
123 + pub swift_toolchain: Option<String>,
124 }
125
126 impl Display for Project {