33 files changed
+497
-278
Cargo.lock
+13
@@ -2528,6 +2528,7 @@ dependencies = [
2528
"serde_json",
2529
"serde_repr",
2530
"smbcloud-auth",
2531
+ "smbcloud-deploy",
2532
"smbcloud-mail",
2533
"smbcloud-model",
2534
"smbcloud-network",
@@ -2546,6 +2547,18 @@ dependencies = [
2547
"url-builder",
2548
]
2549
2550
+[[package]]
2551
+name = "smbcloud-deploy"
2552
+version = "0.4.7"
2553
+dependencies = [
2554
+ "anyhow",
2555
+ "smbcloud-model",
2556
+ "smbcloud-utils",
2557
+ "tempfile",
2558
+ "thiserror 2.0.18",
2559
+ "tracing",
2560
+]
2561
+
2562
[[package]]
2563
name = "smbcloud-email-sdk"
2564
version = "0.4.7"
Cargo.toml
+1
@@ -28,6 +28,7 @@ serde_json = "1.0.82"
28
serde_repr = "0.1"
29
smbcloud-auth = { version = "0.4.7", path = "crates/smbcloud-auth" }
30
smbcloud-auth-sdk = { version = "0.4.7", path = "crates/smbcloud-auth-sdk" }
31
+smbcloud-deploy = { version = "0.4.7", path = "crates/smbcloud-deploy" }
32
smbcloud-email-sdk = { version = "0.4.7", path = "crates/smbcloud-email-sdk" }
33
smbcloud-gresiq-sdk = { version = "0.4.7", path = "crates/smbcloud-gresiq-sdk" }
34
smbcloud-mail = { version = "0.4.7", path = "crates/smbcloud-mail" }
README.MD
+1
-1
@@ -118,4 +118,4 @@ For the broader product, see [smbCloud](https://smbcloud.xyz/) and the [deployme
118
119
## Copyright
120
121
-© 2026 [smbCloud](https://smbcloud.xyz) (Splitfire AB).
121
+© 2026 [Splitfire AB](https://5mb.app) ([smbCloud](https://smbcloud.xyz)).
crates/cli/Cargo.toml
+1
@@ -44,6 +44,7 @@ serde = { workspace = true, features = ["derive"] }
44
serde_json = { workspace = true }
45
serde_repr = { workspace = true }
46
smbcloud-auth = { workspace = true }
47
+smbcloud-deploy = { workspace = true }
48
smbcloud-mail = { workspace = true }
49
smbcloud-model = { workspace = true }
50
smbcloud-network = { workspace = true }
crates/cli/README.md
+1
-1
@@ -99,4 +99,4 @@ More CLI and deployment guides live in the [smbCloud docs](https://smbcloud.xyz/
99
100
## Copyright
101
102
-© 2026 [smbCloud](https://smbcloud.xyz) (Splitfire AB).
102
+© 2026 [Splitfire AB](https://5mb.app) ([smbCloud](https://smbcloud.xyz)).
crates/cli/src/deploy/detect_runner.rs
deleted
-94
@@ -1,94 +0,0 @@
1
-use {
2
- crate::ui::{fail_message, fail_symbol, succeed_message, succeed_symbol},
3
- anyhow::Result,
4
- smbcloud_model::runner::Runner,
5
- smbcloud_utils::config::Config,
6
- spinners::Spinner,
7
- std::{env::current_dir, path::Path},
8
-};
9
-
10
-pub(crate) async fn detect_runner(config: &Config) -> Result<Runner> {
11
- let mut spinner: Spinner = Spinner::new(
12
- spinners::Spinners::SimpleDotsScrolling,
13
- succeed_message("Checking runner"),
14
- );
15
-
16
- let path = match current_dir() {
17
- Ok(path) => path,
18
- Err(_) => {
19
- spinner.stop_and_persist(
20
- &fail_symbol(),
21
- fail_message("Could not get the current path."),
22
- );
23
- anyhow::bail!(
24
- "Could not detect project runner: no package.json, Gemfile, Package.swift, or Cargo.toml found"
25
- );
26
- }
27
- };
28
-
29
- // See if we have a monorepo setup.
30
- if config.project.runner == Runner::Monorepo && config.projects.is_some() {
31
- return Ok(Runner::Monorepo);
32
- }
33
- let runner = match Runner::from(&path) {
34
- Ok(runner) => runner,
35
- Err(_) => {
36
- spinner.stop_and_persist(
37
- &fail_symbol(),
38
- fail_message(
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, Package.swift, or Cargo.toml found"
44
- );
45
- }
46
- };
47
-
48
- match runner {
49
- Runner::Monorepo => {
50
- spinner.stop_and_persist(
51
- &succeed_symbol(),
52
- succeed_message("Monorepo universal runner"),
53
- );
54
- }
55
- Runner::NodeJs => {
56
- spinner.stop_and_persist(
57
- &succeed_symbol(),
58
- succeed_message("NodeJs 🟩 runner detected"),
59
- );
60
- }
61
- Runner::Static => {
62
- spinner.stop_and_persist(
63
- &succeed_symbol(),
64
- succeed_message("Static 🌐 site — no build step required"),
65
- );
66
- }
67
- Runner::Ruby => {
68
- if Path::new("Gemfile").exists() {
69
- spinner.stop_and_persist(
70
- &succeed_symbol(),
71
- succeed_message("Ruby 🟥 runner with Rails app detected"),
72
- );
73
- }
74
- }
75
- Runner::Swift => {
76
- if Path::new("Package.swift").exists() {
77
- spinner.stop_and_persist(
78
- &succeed_symbol(),
79
- succeed_message("Swift 🟧 runner with Vapor app detected"),
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)
94
-}
crates/cli/src/deploy/mod.rs
+37
-3
@@ -1,7 +1,5 @@
1
pub mod config;
2
-mod detect_runner;
2
mod git;
4
-mod known_hosts;
3
pub mod process_deploy;
4
pub mod process_deploy_nextjs_ssr;
5
pub mod process_deploy_rails;
@@ -10,7 +8,43 @@ pub mod process_deploy_swift;
8
pub mod process_deploy_vite_spa;
9
pub mod process_migrate;
10
mod remote_messages;
13
-pub(crate) mod rsync_deploy;
11
pub(crate) mod setup_create_new_project;
12
pub(crate) mod setup_project;
13
pub(crate) mod setup_select_project;
14
+
15
+use {
16
+ anyhow::{anyhow, Result},
17
+ smbcloud_deploy::RsyncTransport,
18
+ smbcloud_model::runner::Runner,
19
+ smbcloud_utils::config::Config,
20
+};
21
+
22
+// The pinned host keys live in the engine crate. Re-export them here so the
23
+// strategy modules that build their own pinned-SSH commands can keep using
24
+// `deploy::known_hosts`.
25
+pub(crate) use smbcloud_deploy::known_hosts;
26
+
27
+/// Build the rsync transport for the current project.
28
+///
29
+/// This resolves the front-end-specific bits the engine deliberately doesn't
30
+/// know about: the server host (from the runner), the remote path (from config,
31
+/// defaulting to `apps/web/<name>`), and the user's local SSH identity file.
32
+pub(crate) fn rsync_transport(
33
+ config: &Config,
34
+ runner: &Runner,
35
+ user_id: i32,
36
+) -> Result<RsyncTransport> {
37
+ let home = dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory"))?;
38
+ let identity_file = home.join(".ssh").join(format!("id_{user_id}@smbcloud"));
39
+
40
+ let remote_path = match &config.project.path {
41
+ Some(path) => path.clone(),
42
+ None => format!("apps/web/{}", config.project.name),
43
+ };
44
+
45
+ Ok(RsyncTransport::new(
46
+ runner.rsync_host(),
47
+ remote_path,
48
+ identity_file,
49
+ ))
50
+}
crates/cli/src/deploy/process_deploy.rs
+22
-4
@@ -5,7 +5,6 @@ use {
5
client,
6
deploy::{
7
config::{check_project, credentials, get_config, overlay_server_config},
8
- detect_runner::detect_runner,
8
git::remote_deployment_setup,
9
process_deploy_nextjs_ssr::process_deploy_nextjs_ssr,
10
process_deploy_rails::process_deploy_rails,
@@ -13,7 +12,6 @@ use {
12
process_deploy_swift::process_deploy_swift,
13
process_deploy_vite_spa::process_deploy_vite_spa,
14
remote_messages::{build_next_app, start_server},
16
- rsync_deploy::rsync_deploy,
15
},
16
token::{get_smb_token::get_smb_token, is_logged_in::is_logged_in},
17
ui::{fail_message, succeed_message, succeed_symbol},
@@ -22,6 +20,7 @@ use {
20
dialoguer::{console::Term, theme::ColorfulTheme, Select},
21
git2::{PushOptions, RemoteCallbacks, Repository},
22
smbcloud_auth::me::me,
23
+ smbcloud_deploy::Transport,
24
smbcloud_model::{
25
project::{DeploymentMethod, DeploymentPayload, DeploymentStatus},
26
runner::Runner,
@@ -171,7 +170,25 @@ pub async fn process_deploy(
170
// detection needed, the source tree may have no package.json/Gemfile/etc.
171
let runner = config.project.runner;
172
let user = me(env, client(), &access_token).await?;
174
- rsync_deploy(&config, &runner, user.id, ".")
173
+ let transport = crate::deploy::rsync_transport(&config, &runner, user.id)?;
174
+
175
+ // The engine ships silently; this command owns the spinner and the
176
+ // final line it hands back to `main` to persist.
177
+ let spinner = Spinner::new(
178
+ spinners::Spinners::Hamburger,
179
+ succeed_message(&format!("Syncing to {}…", runner.rsync_host())),
180
+ );
181
+ match transport.ship(std::path::Path::new("."), &smbcloud_deploy::NoopReporter) {
182
+ Ok(()) => Ok(CommandResult {
183
+ spinner,
184
+ symbol: succeed_symbol(),
185
+ msg: succeed_message("Deployment complete via rsync."),
186
+ }),
187
+ Err(e) => {
188
+ drop(spinner);
189
+ Err(anyhow!(fail_message(&format!("{e}"))))
190
+ }
191
+ }
192
}
193
DeploymentMethod::Git => git_deploy(env, &access_token, config).await,
194
}
@@ -184,7 +201,8 @@ async fn git_deploy(
201
) -> Result<CommandResult> {
202
// Runner detection requires framework files (package.json, Gemfile, etc.) —
203
// only needed for the git push path where the server builds the project.
187
- let runner = detect_runner(&config).await?;
204
+ let reporter = crate::ui::reporter::SpinnerReporter::new();
205
+ let runner = smbcloud_deploy::detect_runner(&config, &reporter)?;
206
// Check remote repository setup.
207
let repo = match Repository::open(".") {
208
Ok(repo) => repo,
crates/cli/src/deploy/process_deploy_vite_spa.rs
+6
-4
@@ -2,7 +2,6 @@ use {
2
crate::{
3
cli::CommandResult,
4
client,
5
- deploy::rsync_deploy::rsync_deploy,
5
ui::{fail_message, fail_symbol, succeed_message, succeed_symbol},
6
},
7
anyhow::{anyhow, Result},
@@ -10,6 +9,7 @@ use {
9
smbcloud_auth::me::me,
10
smbcloud_model::project::{DeploymentPayload, DeploymentStatus},
11
smbcloud_network::environment::Environment,
12
+ smbcloud_deploy::Transport,
13
smbcloud_networking_project::{
14
crud_project_deployment_create::create_deployment, crud_project_deployment_update::update,
15
},
@@ -116,8 +116,10 @@ pub async fn process_deploy_vite_spa(env: Environment, config: Config) -> Result
116
let local_dist = format!("{}/{}", project_path, output_dir);
117
let runner = config.project.runner;
118
119
- match rsync_deploy(&config, &runner, user.id, &local_dist) {
120
- Ok(_) => {}
119
+ let transport = crate::deploy::rsync_transport(&config, &runner, user.id)?;
120
+ let reporter = crate::ui::reporter::SpinnerReporter::new();
121
+ match transport.ship(std::path::Path::new(&local_dist), &reporter) {
122
+ Ok(()) => {}
123
Err(error) => {
124
if let Some(ref deployment) = created_deployment {
125
let failed_payload = DeploymentPayload {
@@ -135,7 +137,7 @@ pub async fn process_deploy_vite_spa(env: Environment, config: Config) -> Result
137
)
138
.await;
139
}
138
- return Err(error);
140
+ return Err(error.into());
141
}
142
}
143
crates/cli/src/deploy/rsync_deploy.rs
deleted
-158
@@ -1,158 +0,0 @@
1
-use {
2
- crate::{
3
- cli::CommandResult,
4
- deploy::known_hosts,
5
- ui::{fail_message, succeed_message, succeed_symbol},
6
- },
7
- anyhow::{anyhow, Result},
8
- smbcloud_model::runner::Runner,
9
- smbcloud_utils::config::Config,
10
- spinners::{Spinner, Spinners},
11
- std::{io::Write, process::Command},
12
- tempfile::NamedTempFile,
13
-};
14
-
15
-/// Runs an rsync transfer replicating:
16
-/// rsync -a --exclude='.git' --exclude='.smb' \
17
-/// -e "ssh -i ~/.ssh/id_<user_id>@smbcloud
18
-/// -o StrictHostKeyChecking=yes
19
-/// -o UserKnownHostsFile=<temp_pinned_known_hosts>
20
-/// -o IdentitiesOnly=yes
21
-/// -o PasswordAuthentication=no
22
-/// -o BatchMode=yes" \
23
-/// <source>/ git@<api-host>.smbcloud.xyz:<remote_path>/
24
-///
25
-/// # Why std::process::Command instead of embedding::run_client
26
-///
27
-/// `embedding::run_client` drives the oc-rsync client, which sends extension
28
-/// message codes (e.g. code 90, wire tag 97) that standard rsync 3.2.7 on the
29
-/// server does not recognise, causing it to abort with:
30
-///
31
-/// unexpected tag 97 [Receiver]
32
-/// rsync error: error in rsync protocol data stream (code 12)
33
-///
34
-/// oc-rsync is designed for oc-rsync ↔ oc-rsync transfers. Using it against a
35
-/// stock rsync server is outside its design envelope without patching the
36
-/// protocol layer. System rsync speaks exactly what the server expects.
37
-///
38
-/// # Host key pinning
39
-///
40
-/// The server's ed25519 public key is embedded in `known_hosts.rs` and written
41
-/// to a temp file at deploy time. SSH is told to use that file exclusively
42
-/// (`UserKnownHostsFile`) and to refuse any key that doesn't match
43
-/// (`StrictHostKeyChecking=yes`). This protects every user — not just the
44
-/// developer — against DNS/BGP hijacking, even on untrusted networks. The temp
45
-/// file is deleted automatically when this function returns.
46
-pub fn rsync_deploy(
47
- config: &Config,
48
- runner: &Runner,
49
- user_id: i32,
50
- source: &str,
51
-) -> Result<CommandResult> {
52
- // Identity file: ~/.ssh/id_<user_id>@smbcloud
53
- let home = dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory"))?;
54
- let identity_file = home.join(".ssh").join(format!("id_{}@smbcloud", user_id));
55
- let identity_file_str = identity_file.to_string_lossy().into_owned();
56
-
57
- // Write the pinned host key to a temp known_hosts file.
58
- // NamedTempFile is deleted automatically when it drops at end of this fn.
59
- let rsync_host = runner.rsync_host();
60
- let mut known_hosts_file = NamedTempFile::new()
61
- .map_err(|e| anyhow!("Failed to create temp known_hosts file: {}", e))?;
62
- writeln!(known_hosts_file, "{}", known_hosts::for_host(&rsync_host))
63
- .map_err(|e| anyhow!("Failed to write known_hosts: {}", e))?;
64
-
65
- // Fully hardened SSH command:
66
- // StrictHostKeyChecking=yes — refuse any host not in our pinned known_hosts
67
- // UserKnownHostsFile=<temp> — only trust our pinned key, not ~/.ssh/known_hosts
68
- // IdentitiesOnly=yes — use only the specified key, ignore ssh-agent
69
- // PasswordAuthentication=no — no fallback to password auth
70
- // BatchMode=yes — never prompt; fail fast
71
- let ssh_command = format!(
72
- "ssh -i {identity} \
73
- -o StrictHostKeyChecking=yes \
74
- -o UserKnownHostsFile={known_hosts} \
75
- -o IdentitiesOnly=yes \
76
- -o PasswordAuthentication=no \
77
- -o BatchMode=yes",
78
- identity = identity_file_str,
79
- known_hosts = known_hosts_file.path().display(),
80
- );
81
-
82
- // Remote destination path: use project.path if set, otherwise derive from name.
83
- let remote_path = match &config.project.path {
84
- Some(path) => path.clone(),
85
- None => format!("apps/web/{}", config.project.name),
86
- };
87
-
88
- // rsync -a src/ syncs contents, not the directory itself —
89
- // both source and destination must have a trailing slash.
90
- let source_with_slash = if source.ends_with('/') {
91
- source.to_owned()
92
- } else {
93
- format!("{}/", source)
94
- };
95
-
96
- let remote_with_slash = if remote_path.ends_with('/') {
97
- remote_path
98
- } else {
99
- format!("{}/", remote_path)
100
- };
101
-
102
- let destination = format!("git@{}:{}", rsync_host, remote_with_slash);
103
-
104
- let spinner = Spinner::new(
105
- Spinners::Hamburger,
106
- succeed_message(&format!("Syncing {} -> {}", source_with_slash, destination)),
107
- );
108
-
109
- // known_hosts_file must remain alive until rsync exits so SSH can read it.
110
- let output = Command::new("rsync")
111
- .args([
112
- "-a",
113
- "--exclude=.git",
114
- "--exclude=.smb",
115
- "-e",
116
- &ssh_command,
117
- &source_with_slash,
118
- &destination,
119
- ])
120
- .output();
121
-
122
- // Drop the temp known_hosts file now that the process has finished.
123
- drop(known_hosts_file);
124
-
125
- match output {
126
- Ok(result) if result.status.success() => {
127
- let stderr = String::from_utf8_lossy(&result.stderr);
128
- if !stderr.is_empty() {
129
- for line in stderr.lines() {
130
- println!("{}", line);
131
- }
132
- }
133
- Ok(CommandResult {
134
- spinner,
135
- symbol: succeed_symbol(),
136
- msg: succeed_message("Deployment complete via rsync."),
137
- })
138
- }
139
- Ok(result) => {
140
- drop(spinner);
141
- let stderr = String::from_utf8_lossy(&result.stderr);
142
- if !stderr.is_empty() {
143
- eprintln!("{}", stderr);
144
- }
145
- Err(anyhow!(fail_message(&format!(
146
- "rsync exited with status {}",
147
- result.status.code().unwrap_or(-1)
148
- ))))
149
- }
150
- Err(e) => {
151
- drop(spinner);
152
- Err(anyhow!(fail_message(&format!(
153
- "Failed to launch rsync: {}. Is rsync installed?",
154
- e
155
- ))))
156
- }
157
- }
158
-}
crates/cli/src/ui/mod.rs
+1
@@ -5,6 +5,7 @@ pub mod me_view;
5
pub mod project_detail_view;
6
pub mod project_table;
7
pub mod prompt;
8
+pub mod reporter;
9
pub mod theme;
10
11
use console::style;
crates/cli/src/ui/reporter.rs
new
+65
@@ -0,0 +1,65 @@
1
+//! The CLI's [`Reporter`] implementation.
2
+//!
3
+//! This is the one place the deploy engine's progress turns into terminal
4
+//! output. The engine calls `step_start` / `step_done` / `step_fail`; here we
5
+//! render them as `spinners` lines with the shared themed symbols. CI and the
6
+//! server-side receiver supply their own `Reporter` instead.
7
+
8
+use crate::ui::{fail_message, fail_symbol, succeed_message, succeed_symbol};
9
+use smbcloud_deploy::Reporter;
10
+use spinners::{Spinner, Spinners};
11
+use std::sync::Mutex;
12
+
13
+/// Renders engine progress as spinner lines. One spinner is live at a time; a
14
+/// `step_*` completion persists it with a symbol, and the next `step_start`
15
+/// begins a fresh one.
16
+pub struct SpinnerReporter {
17
+ current: Mutex<Option<Spinner>>,
18
+}
19
+
20
+impl SpinnerReporter {
21
+ pub fn new() -> Self {
22
+ Self {
23
+ current: Mutex::new(None),
24
+ }
25
+ }
26
+
27
+ /// Persist the active spinner (if any) with `symbol` + `message`, or print a
28
+ /// standalone line when no spinner is running.
29
+ fn finish(&self, symbol: String, message: String) {
30
+ match self.current.lock().unwrap().take() {
31
+ Some(mut spinner) => spinner.stop_and_persist(&symbol, message),
32
+ None => println!("{symbol} {message}"),
33
+ }
34
+ }
35
+}
36
+
37
+impl Default for SpinnerReporter {
38
+ fn default() -> Self {
39
+ Self::new()
40
+ }
41
+}
42
+
43
+impl Reporter for SpinnerReporter {
44
+ fn step_start(&self, msg: &str) {
45
+ // Starting a new step supersedes any spinner left running.
46
+ *self.current.lock().unwrap() =
47
+ Some(Spinner::new(Spinners::SimpleDotsScrolling, succeed_message(msg)));
48
+ }
49
+
50
+ fn step_done(&self, msg: &str) {
51
+ self.finish(succeed_symbol(), succeed_message(msg));
52
+ }
53
+
54
+ fn step_fail(&self, msg: &str) {
55
+ self.finish(fail_symbol(), fail_message(msg));
56
+ }
57
+
58
+ fn info(&self, msg: &str) {
59
+ println!("{}", succeed_message(msg));
60
+ }
61
+
62
+ fn remote_line(&self, line: &str) {
63
+ println!("{line}");
64
+ }
65
+}
crates/smbcloud-auth-sdk-py/README.md
+1
-1
@@ -10,4 +10,4 @@ Apache-2.0
10
11
## Copyright
12
13
-© 2026 [smbCloud](https://smbcloud.xyz) (Splitfire AB).
13
+© 2026 [Splitfire AB](https://5mb.app) ([smbCloud](https://smbcloud.xyz)).
crates/smbcloud-auth-sdk-wasm/README.md
+1
-1
@@ -47,4 +47,4 @@ Apache-2.0
47
48
## Copyright
49
50
-© 2026 [smbCloud](https://smbcloud.xyz) (Splitfire AB).
50
+© 2026 [Splitfire AB](https://5mb.app) ([smbCloud](https://smbcloud.xyz)).
crates/smbcloud-auth-sdk/README.md
+1
-1
@@ -36,4 +36,4 @@ Apache-2.0
36
37
## Copyright
38
39
-© 2026 [smbCloud](https://smbcloud.xyz) (Splitfire AB).
39
+© 2026 [Splitfire AB](https://5mb.app) ([smbCloud](https://smbcloud.xyz)).
crates/smbcloud-auth/README.md
+1
-1
@@ -14,4 +14,4 @@ Apache-2.0
14
15
## Copyright
16
17
-© 2026 [smbCloud](https://smbcloud.xyz) (Splitfire AB).
17
+© 2026 [Splitfire AB](https://5mb.app) ([smbCloud](https://smbcloud.xyz)).
crates/smbcloud-deploy/Cargo.toml
new
+21
@@ -0,0 +1,21 @@
1
+[package]
2
+name = "smbcloud-deploy"
3
+version = "0.4.7"
4
+edition = "2021"
5
+authors = ["keypair34 <216233964+keypair34@users.noreply.github.com>, Seto Elkahfi <hej@setoelkahfi.se>"]
6
+description = "smbCloud build and deploy engine: UI-agnostic, reusable by the CLI, CI, and the server-side git receiver."
7
+license = "Apache-2.0"
8
+homepage = "https://smbcloud.xyz"
9
+repository = "https://github.com/smbcloudXYZ/smbcloud-cli"
10
+documentation = "https://docs.rs/smbcloud-deploy"
11
+readme = "README.md"
12
+keywords = ["deploy", "cloud", "smbcloud", "rsync", "ci"]
13
+categories = ["development-tools", "development-tools::build-utils"]
14
+
15
+[dependencies]
16
+anyhow = { workspace = true }
17
+tempfile = { workspace = true }
18
+thiserror = { workspace = true }
19
+tracing = { workspace = true }
20
+smbcloud-model = { workspace = true }
21
+smbcloud-utils = { workspace = true }
crates/smbcloud-deploy/README.md
new
+45
@@ -0,0 +1,45 @@
1
+# smbcloud-deploy
2
+
3
+The build and deploy engine behind [smbCloud](https://smbcloud.xyz) and the
4
+[`smb` CLI](https://github.com/smbcloudXYZ/smbcloud-cli).
5
+
6
+`smbcloud-deploy` takes one app from a repo and ships it: detect the runtime,
7
+build it, and transfer the result to the server. The engine is UI-agnostic and
8
+auth-agnostic, so the same code drives the interactive CLI, CI pipelines, and the
9
+server-side git receiver. They differ only in how they report progress and how
10
+they authenticate.
11
+
12
+## Design
13
+
14
+Two things are inverted so the engine stays reusable:
15
+
16
+- `Reporter` takes the place of direct terminal output. The engine reports steps;
17
+ the caller decides how to render them. The CLI uses spinners, CI prints plain
18
+ lines, and the server streams output back over the git push.
19
+- Authentication is passed in. The engine never reads local credentials or
20
+ prompts for login; a token or credentials come from the caller.
21
+
22
+Transport sits behind a `Transport` trait, so rsync today and git-smart-HTTP
23
+later are swappable without changing any caller.
24
+
25
+## Status
26
+
27
+Early and moving. This crate is extracted from the `smb` CLI so the deploy logic
28
+lives in one place. The API will change while the surface settles.
29
+
30
+## Part of smbCloud
31
+
32
+smbCloud is a local-first build and deployment platform for shipping web apps,
33
+APIs, and inference services from a repo to production. You build locally, push
34
+the artifact, and smbCloud places it and serves it.
35
+
36
+- Homepage: [smbcloud.xyz](https://smbcloud.xyz)
37
+- CLI and source: [github.com/smbcloudXYZ/smbcloud-cli](https://github.com/smbcloudXYZ/smbcloud-cli)
38
+
39
+## License
40
+
41
+[Apache-2.0](LICENSE)
42
+
43
+## Copyright
44
+
45
+© 2026 [Splitfire AB](https://5mb.app) ([smbCloud](https://smbcloud.xyz)).
crates/smbcloud-deploy/src/error.rs
new
+23
@@ -0,0 +1,23 @@
1
+use thiserror::Error;
2
+
3
+/// Errors surfaced by the deploy engine.
4
+///
5
+/// These are transport- and UI-neutral: a front-end decides how to render them
6
+/// (a red spinner line in the CLI, a failed CI step, a git sideband error).
7
+#[derive(Debug, Error)]
8
+pub enum DeployError {
9
+ /// No `.smb/config.toml` in the working directory. The front-end owns the
10
+ /// interactive setup flow, so the engine reports this rather than prompting.
11
+ #[error("no .smb/config.toml found; run setup first")]
12
+ NeedsSetup,
13
+
14
+ /// Couldn't determine the runtime from the working directory.
15
+ #[error(
16
+ "could not detect a runner: no package.json, Gemfile, Package.swift, or Cargo.toml found"
17
+ )]
18
+ RunnerNotDetected,
19
+
20
+ /// Anything not yet modelled as a specific variant.
21
+ #[error(transparent)]
22
+ Other(#[from] anyhow::Error),
23
+}
crates/smbcloud-deploy/src/known_hosts.rs
renamed
crates/smbcloud-deploy/src/lib.rs
new
+27
@@ -0,0 +1,27 @@
1
+//! smbCloud build and deploy engine.
2
+//!
3
+//! This crate holds the deploy logic: runner detection, config resolution,
4
+//! per-runtime build strategies, and transport. It has no front-end of its own.
5
+//! The CLI, the CI action, and the server-side git receiver all drive the same
6
+//! engine; they differ only in how they report progress and how they
7
+//! authenticate.
8
+//!
9
+//! Two things are inverted so the engine stays reusable. [`Reporter`] takes the
10
+//! place of direct `spinners`, `dialoguer`, and `println!` calls, so the engine
11
+//! never owns the terminal. Auth is passed in (a token or credentials); the
12
+//! engine never reads `~/.smb` or prompts for login.
13
+//!
14
+//! Interactive setup (creating or selecting a project, writing
15
+//! `.smb/config.toml`) stays in the front-end. The engine takes a resolved
16
+//! config, or returns [`DeployError::NeedsSetup`] for the caller to handle.
17
+
18
+pub mod error;
19
+pub mod known_hosts;
20
+pub mod report;
21
+pub mod runner;
22
+pub mod transport;
23
+
24
+pub use error::DeployError;
25
+pub use report::{NoopReporter, Reporter};
26
+pub use runner::detect_runner;
27
+pub use transport::{RsyncTransport, Transport};
crates/smbcloud-deploy/src/report.rs
new
+46
@@ -0,0 +1,46 @@
1
+//! Progress reporting, inverted.
2
+//!
3
+//! The engine calls a [`Reporter`] instead of touching the terminal. Each
4
+//! front-end supplies its own implementation:
5
+//! - the CLI renders steps as `spinners` lines with themed symbols,
6
+//! - CI prints plain, timestamped lines,
7
+//! - the server-side git receiver streams lines over the push sideband.
8
+
9
+/// How the engine tells the outside world what it is doing.
10
+///
11
+/// A "step" is one unit of work (detect runner, build, upload, restart). The
12
+/// engine brackets each with [`step_start`](Reporter::step_start) and one of
13
+/// [`step_done`](Reporter::step_done) / [`step_fail`](Reporter::step_fail).
14
+pub trait Reporter: Send + Sync {
15
+ /// A unit of work has started.
16
+ fn step_start(&self, msg: &str);
17
+
18
+ /// The current step finished successfully.
19
+ fn step_done(&self, msg: &str);
20
+
21
+ /// The current step failed.
22
+ fn step_fail(&self, msg: &str);
23
+
24
+ /// An informational note not tied to a step. Defaults to no-op.
25
+ fn info(&self, msg: &str) {
26
+ let _ = msg;
27
+ }
28
+
29
+ /// A single line of streamed output from a build or the remote (e.g. a line
30
+ /// of `next build` output, or a git receive-pack message). Defaults to no-op.
31
+ fn remote_line(&self, line: &str) {
32
+ let _ = line;
33
+ }
34
+}
35
+
36
+/// A [`Reporter`] that discards everything.
37
+///
38
+/// Useful for tests and for non-interactive callers that only care about the
39
+/// final `Result`.
40
+pub struct NoopReporter;
41
+
42
+impl Reporter for NoopReporter {
43
+ fn step_start(&self, _msg: &str) {}
44
+ fn step_done(&self, _msg: &str) {}
45
+ fn step_fail(&self, _msg: &str) {}
46
+}
crates/smbcloud-deploy/src/runner.rs
new
+52
@@ -0,0 +1,52 @@
1
+//! Runner (runtime) detection for a working directory.
2
+
3
+use crate::{error::DeployError, report::Reporter};
4
+use smbcloud_model::runner::Runner;
5
+use smbcloud_utils::config::Config;
6
+use std::{env::current_dir, path::Path};
7
+
8
+/// Detect which runtime this project uses: Next.js, Rails, Rust, Swift, or a
9
+/// build-less static site.
10
+///
11
+/// The detection itself lives in [`Runner::from`]; this wraps it with progress
12
+/// reporting and the monorepo short-circuit. It owns no spinner and prints
13
+/// nothing directly. All progress goes through `reporter`, so the same call
14
+/// works under the CLI, in CI, or on the server.
15
+pub fn detect_runner(config: &Config, reporter: &dyn Reporter) -> Result<Runner, DeployError> {
16
+ reporter.step_start("Checking runner");
17
+
18
+ // A monorepo declares its runner explicitly and defers per-app detection to
19
+ // the selected `[[projects]]` entry.
20
+ if config.project.runner == Runner::Monorepo && config.projects.is_some() {
21
+ reporter.step_done("Monorepo universal runner");
22
+ return Ok(Runner::Monorepo);
23
+ }
24
+
25
+ let path = current_dir().map_err(|_| {
26
+ reporter.step_fail("Could not read the current directory.");
27
+ DeployError::RunnerNotDetected
28
+ })?;
29
+
30
+ let runner = Runner::from(&path).map_err(|_| {
31
+ reporter.step_fail("Could not detect a runner.");
32
+ DeployError::RunnerNotDetected
33
+ })?;
34
+
35
+ reporter.step_done(label_for(runner));
36
+ Ok(runner)
37
+}
38
+
39
+/// Human-readable confirmation line for a detected runner.
40
+fn label_for(runner: Runner) -> &'static str {
41
+ match runner {
42
+ Runner::Monorepo => "Monorepo universal runner",
43
+ Runner::NodeJs => "Node.js runner detected",
44
+ Runner::Static => "Static site, no build step required",
45
+ Runner::Ruby if Path::new("Gemfile").exists() => "Ruby runner with Rails app detected",
46
+ Runner::Swift if Path::new("Package.swift").exists() => {
47
+ "Swift runner with Vapor app detected"
48
+ }
49
+ Runner::Rust if Path::new("Cargo.toml").exists() => "Rust runner with Cargo project detected",
50
+ _ => "Runner detected",
51
+ }
52
+}
crates/smbcloud-deploy/src/transport.rs
new
+122
@@ -0,0 +1,122 @@
1
+//! Shipping a source tree to its deploy target.
2
+//!
3
+//! A [`Transport`] takes files that are ready to ship and puts them on the
4
+//! server. Today the only one is [`RsyncTransport`] (rsync over SSH with the
5
+//! server host key pinned). A git-smart-HTTP transport can implement the same
6
+//! trait later without changing any callers.
7
+
8
+use crate::{error::DeployError, known_hosts, report::Reporter};
9
+use anyhow::anyhow;
10
+use std::{
11
+ io::Write,
12
+ path::{Path, PathBuf},
13
+ process::Command,
14
+};
15
+use tempfile::NamedTempFile;
16
+
17
+/// Ships a source tree to its deploy target.
18
+pub trait Transport {
19
+ /// Transfer `source` to the target, reporting progress via `reporter`.
20
+ fn ship(&self, source: &Path, reporter: &dyn Reporter) -> Result<(), DeployError>;
21
+}
22
+
23
+/// rsync over SSH with the server's host key pinned (see [`known_hosts`]).
24
+///
25
+/// Auth and topology are resolved by the caller and passed in: the front-end
26
+/// owns where the identity file lives and how the remote path is derived, so the
27
+/// engine makes no assumptions about `~/.ssh` layout or project naming.
28
+pub struct RsyncTransport {
29
+ host: String,
30
+ remote_path: String,
31
+ identity_file: PathBuf,
32
+}
33
+
34
+impl RsyncTransport {
35
+ pub fn new(host: String, remote_path: String, identity_file: PathBuf) -> Self {
36
+ Self {
37
+ host,
38
+ remote_path,
39
+ identity_file,
40
+ }
41
+ }
42
+}
43
+
44
+impl Transport for RsyncTransport {
45
+ fn ship(&self, source: &Path, reporter: &dyn Reporter) -> Result<(), DeployError> {
46
+ // Pin the server's host key to a temp known_hosts file for this transfer,
47
+ // protecting every user against DNS/BGP hijacking even on untrusted
48
+ // networks. The file is deleted when it drops at the end of this call.
49
+ let mut known_hosts_file = NamedTempFile::new()
50
+ .map_err(|e| anyhow!("Failed to create temp known_hosts file: {e}"))?;
51
+ writeln!(known_hosts_file, "{}", known_hosts::for_host(&self.host))
52
+ .map_err(|e| anyhow!("Failed to write known_hosts: {e}"))?;
53
+
54
+ // Fully hardened SSH: only the pinned host key is trusted, only the given
55
+ // identity is used, no password fallback, never prompt.
56
+ let ssh_command = format!(
57
+ "ssh -i {identity} \
58
+ -o StrictHostKeyChecking=yes \
59
+ -o UserKnownHostsFile={known_hosts} \
60
+ -o IdentitiesOnly=yes \
61
+ -o PasswordAuthentication=no \
62
+ -o BatchMode=yes",
63
+ identity = self.identity_file.to_string_lossy(),
64
+ known_hosts = known_hosts_file.path().display(),
65
+ );
66
+
67
+ // `rsync -a src/` syncs contents, not the directory itself, so both
68
+ // sides need a trailing slash.
69
+ let source_str = source.to_string_lossy();
70
+ let source_with_slash = if source_str.ends_with('/') {
71
+ source_str.into_owned()
72
+ } else {
73
+ format!("{source_str}/")
74
+ };
75
+ let remote_with_slash = if self.remote_path.ends_with('/') {
76
+ self.remote_path.clone()
77
+ } else {
78
+ format!("{}/", self.remote_path)
79
+ };
80
+ let destination = format!("git@{}:{}", self.host, remote_with_slash);
81
+
82
+ reporter.step_start(&format!("Syncing {source_with_slash} -> {destination}"));
83
+
84
+ // known_hosts_file must stay alive until rsync exits so SSH can read it.
85
+ let output = Command::new("rsync")
86
+ .args([
87
+ "-a",
88
+ "--exclude=.git",
89
+ "--exclude=.smb",
90
+ "-e",
91
+ &ssh_command,
92
+ &source_with_slash,
93
+ &destination,
94
+ ])
95
+ .output();
96
+ drop(known_hosts_file);
97
+
98
+ match output {
99
+ Ok(result) if result.status.success() => {
100
+ for line in String::from_utf8_lossy(&result.stderr).lines() {
101
+ reporter.remote_line(line);
102
+ }
103
+ reporter.step_done("Deployment complete via rsync.");
104
+ Ok(())
105
+ }
106
+ Ok(result) => {
107
+ let code = result.status.code().unwrap_or(-1);
108
+ reporter.step_fail(&format!("rsync exited with status {code}"));
109
+ for line in String::from_utf8_lossy(&result.stderr).lines() {
110
+ reporter.remote_line(line);
111
+ }
112
+ Err(DeployError::Other(anyhow!(
113
+ "rsync exited with status {code}"
114
+ )))
115
+ }
116
+ Err(e) => {
117
+ reporter.step_fail(&format!("Failed to launch rsync: {e}. Is rsync installed?"));
118
+ Err(DeployError::Other(anyhow!("Failed to launch rsync: {e}")))
119
+ }
120
+ }
121
+ }
122
+}
crates/smbcloud-gresiq-sdk/README.md
+1
-1
@@ -76,4 +76,4 @@ Apache-2.0
76
77
## Copyright
78
79
-© 2026 [smbCloud](https://smbcloud.xyz) (Splitfire AB).
79
+© 2026 [Splitfire AB](https://5mb.app) ([smbCloud](https://smbcloud.xyz)).
crates/smbcloud-gresiq/README.md
+1
-1
@@ -89,4 +89,4 @@ Apache-2.0
89
90
## Copyright
91
92
-© 2026 [smbCloud](https://smbcloud.xyz) (Splitfire AB).
92
+© 2026 [Splitfire AB](https://5mb.app) ([smbCloud](https://smbcloud.xyz)).
crates/smbcloud-mail/README.md
+1
-1
@@ -14,4 +14,4 @@ Apache-2.0
14
15
## Copyright
16
17
-© 2026 [smbCloud](https://smbcloud.xyz) (Splitfire AB).
17
+© 2026 [Splitfire AB](https://5mb.app) ([smbCloud](https://smbcloud.xyz)).
crates/smbcloud-model/README.md
+1
-1
@@ -10,4 +10,4 @@ Apache-2.0
10
11
## Copyright
12
13
-© 2026 [smbCloud](https://smbcloud.xyz) (Splitfire AB).
13
+© 2026 [Splitfire AB](https://5mb.app) ([smbCloud](https://smbcloud.xyz)).
crates/smbcloud-network/README.md
+1
-1
@@ -10,4 +10,4 @@ Apache-2.0
10
11
## Copyright
12
13
-© 2026 [smbCloud](https://smbcloud.xyz) (Splitfire AB).
13
+© 2026 [Splitfire AB](https://5mb.app) ([smbCloud](https://smbcloud.xyz)).
crates/smbcloud-networking-project/README.md
+1
-1
@@ -10,4 +10,4 @@ Apache-2.0
10
11
## Copyright
12
13
-© 2026 [smbCloud](https://smbcloud.xyz) (Splitfire AB).
13
+© 2026 [Splitfire AB](https://5mb.app) ([smbCloud](https://smbcloud.xyz)).
crates/smbcloud-networking/README.md
+1
-1
@@ -12,4 +12,4 @@ Apache-2.0
12
13
## Copyright
14
15
-© 2026 [smbCloud](https://smbcloud.xyz) (Splitfire AB).
15
+© 2026 [Splitfire AB](https://5mb.app) ([smbCloud](https://smbcloud.xyz)).
crates/smbcloud-s6n/README.md
+1
-1
@@ -10,4 +10,4 @@ Apache-2.0
10
11
## Copyright
12
13
-© 2026 [smbCloud](https://smbcloud.xyz) (Splitfire AB).
13
+© 2026 [Splitfire AB](https://5mb.app) ([smbCloud](https://smbcloud.xyz)).
crates/smbcloud-utils/README.md
+1
-1
@@ -10,4 +10,4 @@ Apache-2.0
10
11
## Copyright
12
13
-© 2026 [smbCloud](https://smbcloud.xyz) (Splitfire AB).
13
+© 2026 [Splitfire AB](https://5mb.app) ([smbCloud](https://smbcloud.xyz)).