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 {