@smbcloud / cli / commits / a69edf1

Add smbcloud-deploy

keypair34 committed Jul 1, 2026 at 14:07 UTC a69edf19f9d912a619570016cc31065cfbfe17f0
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)).