@smbcloud / cli / commits / cacb649

Revamp deploy output for clarity and consistency

- Standardize spinner and status messages with colored symbols - Show binary size and deploy duration in output - Display project name and deployment source at start - Use commit SHA as deploy ref when available - Improve error and info messages for config and deploy steps - Pipe build output directly to terminal for better feedback - Add serde defaults for new Project fields in model crate

Seto Elkahfi committed May 17, 2026 at 11:10 UTC cacb649e2bd27072e29d34dd3c05bfaf0407ea94
3 files changed +158 -62
crates/cli/src/deploy/config.rs
+32 -12
@@ -25,7 +25,11 @@ pub(crate) async fn get_config(
25 ) -> Result<Config, ErrorResponse> {
26 let mut spinner: Spinner = Spinner::new(
27 spinners::Spinners::SimpleDotsScrolling,
28 - succeed_message("Checking config"),
28 + format!(
29 + " {} {}",
30 + console::style("◼").cyan(),
31 + console::style("Loading config…").dim()
32 + ),
33 );
34
35 // Check .smb directory
@@ -38,7 +42,11 @@ pub(crate) async fn get_config(
42 setup_project(env, access_token).await?;
43 spinner = Spinner::new(
44 spinners::Spinners::SimpleDotsScrolling,
41 - succeed_message("Checking config"),
45 + format!(
46 + " {} {}",
47 + console::style("◼").cyan(),
48 + console::style("Loading config…").dim()
49 + ),
50 );
51 }
52
@@ -58,8 +66,13 @@ pub(crate) async fn get_config(
66 }
67 };
68 spinner.stop_and_persist(
61 - &succeed_symbol(),
62 - succeed_message(&format!("Valid config for {}", config.name)),
69 + " ",
70 + format!(
71 + " {} {} {}",
72 + console::style("◼").cyan(),
73 + console::style("Config").white().bold(),
74 + console::style(&config.name).dim(),
75 + ),
76 );
77
78 // Attempt to fetch server-side deploy config when we have both a token
@@ -73,8 +86,9 @@ pub(crate) async fn get_config(
86
87 if has_server_fields {
88 println!(
76 - " {} Using server-side deploy config",
77 - console::style("ℹ").cyan()
89 + " {} {}",
90 + console::style(" ").dim(),
91 + console::style("Loaded from smbcloud.xyz").dim(),
92 );
93
94 if let Some(remote_kind) = deploy_config.kind {
@@ -121,15 +135,17 @@ pub(crate) async fn get_config(
135 config.project.deploy_repo_id = deploy_config.deploy_repo_id;
136 } else {
137 println!(
124 - " {} Using local deploy config (.smb/config.toml)",
125 - console::style("ℹ").cyan()
138 + " {} {}",
139 + console::style(" ").dim(),
140 + console::style("Loaded from .smb/config.toml").dim(),
141 );
142 }
143 }
144 Err(_) => {
145 println!(
131 - " {} Could not fetch server config, using local .smb/config.toml",
132 - console::style("ℹ").yellow()
146 + " {} {}",
147 + console::style(" ").dim(),
148 + console::style("Loaded from .smb/config.toml").dim(),
149 );
150 }
151 }
@@ -150,7 +166,11 @@ pub(crate) async fn check_project(
166 ) -> Result<(), ErrorResponse> {
167 let mut spinner: Spinner = Spinner::new(
168 spinners::Spinners::Hamburger,
153 - succeed_message("Validate project"),
169 + format!(
170 + " {} {}",
171 + console::style("◼").cyan(),
172 + console::style("Validating project…").dim()
173 + ),
174 );
175 match get_project(
176 env,
@@ -161,7 +181,7 @@ pub(crate) async fn check_project(
181 .await
182 {
183 Ok(_) => {
164 - spinner.stop_and_persist(&succeed_symbol(), succeed_message("Valid project"));
184 + spinner.stop_and_persist(" ", String::new());
185 Ok(())
186 }
187 Err(_) => {
crates/cli/src/deploy/process_deploy_rust.rs
+118 -50
@@ -3,7 +3,7 @@ use {
3 cli::CommandResult,
4 client,
5 deploy::known_hosts,
6 - ui::{fail_message, fail_symbol, succeed_message, succeed_symbol},
6 + ui::{fail_message, fail_symbol, succeed_symbol},
7 },
8 anyhow::{anyhow, Result},
9 chrono::Utc,
@@ -39,6 +39,8 @@ const DEFAULT_RUST_TARGET: &str = "x86_64-unknown-linux-gnu";
39 /// - `binary_name` — binary filename to upload; falls back to Cargo package name
40 /// - `rust_target` — local cross-compilation target triple; defaults to `x86_64-unknown-linux-gnu`
41 pub async fn process_deploy_rust(env: Environment, config: Config) -> Result<CommandResult> {
42 + let deploy_start = std::time::Instant::now();
43 +
44 let source = config.project.source.as_deref().unwrap_or(".");
45 let remote_path = config.project.path.as_deref().ok_or_else(|| {
46 anyhow!(fail_message(
@@ -61,6 +63,11 @@ pub async fn process_deploy_rust(env: Environment, config: Config) -> Result<Com
63 .as_deref()
64 .unwrap_or(DEFAULT_RUST_TARGET);
65
66 + // Header
67 + println!();
68 + println!(" {}", console::style(&config.name).white().bold());
69 + println!();
70 +
71 let binary_path = build_local_binary(source, rust_target, &binary_name)?;
72
73 let access_token = crate::token::get_smb_token::get_smb_token(env)?;
@@ -68,7 +75,14 @@ pub async fn process_deploy_rust(env: Environment, config: Config) -> Result<Com
75 let runner = config.project.runner;
76 let rsync_host = runner.rsync_host();
77
71 - let deploy_ref = Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
78 + let deploy_ref = git2::Repository::discover(source)
79 + .ok()
80 + .and_then(|repo| {
81 + let head = repo.head().ok()?;
82 + let commit = head.peel_to_commit().ok()?;
83 + Some(commit.id().to_string())
84 + })
85 + .unwrap_or_else(|| Utc::now().format("%Y%m%dT%H%M%SZ").to_string());
86 let created_deployment = create_deployment(
87 env,
88 client(),
@@ -94,7 +108,11 @@ pub async fn process_deploy_rust(env: Environment, config: Config) -> Result<Com
108
109 let mut prepare_spinner = Spinner::new(
110 Spinners::SimpleDotsScrolling,
97 - succeed_message(&format!("Preparing {} on server…", remote_path)),
111 + format!(
112 + " {} {}",
113 + console::style("◼").cyan(),
114 + console::style("Preparing server…").dim()
115 + ),
116 );
117
118 let prepare_script = build_remote_prepare_script(remote_path);
@@ -112,7 +130,14 @@ pub async fn process_deploy_rust(env: Environment, config: Config) -> Result<Com
130 })?;
131
132 if !prepare_output.status.success() {
115 - prepare_spinner.stop_and_persist(&fail_symbol(), fail_message("Remote prepare failed."));
133 + prepare_spinner.stop_and_persist(
134 + &fail_symbol(),
135 + format!(
136 + " {} {}",
137 + console::style("✘").red(),
138 + fail_message("Server prepare failed")
139 + ),
140 + );
141 print_output_details(&prepare_output);
142 drop(known_hosts_file);
143 mark_failed(
@@ -129,11 +154,6 @@ pub async fn process_deploy_rust(env: Environment, config: Config) -> Result<Com
154 ))));
155 }
156
132 - prepare_spinner.stop_and_persist(
133 - &succeed_symbol(),
134 - succeed_message("Remote directory ready."),
135 - );
136 -
157 let ssh_command = build_ssh_command(&identity_file_str, &known_hosts_file);
158 let remote_with_slash = if remote_path.ends_with('/') {
159 remote_path.to_owned()
@@ -143,25 +163,47 @@ pub async fn process_deploy_rust(env: Environment, config: Config) -> Result<Com
163 let destination = format!("git@{}:{}", rsync_host, remote_with_slash);
164 let binary_path_str = binary_path.to_string_lossy().into_owned();
165
166 + let binary_size = fs::metadata(&binary_path).map(|m| m.len()).unwrap_or(0);
167 + let upload_size = if binary_size > 1_000_000 {
168 + format!("{} MB", binary_size / 1_000_000)
169 + } else {
170 + format!("{} KB", binary_size / 1_000)
171 + };
172 +
173 + prepare_spinner.stop_and_persist(
174 + &format!(" {}", console::style("\u{25fc}").cyan()),
175 + format!(
176 + "{} {} \u{2192} {} ({})",
177 + console::style("Upload").white().bold(),
178 + console::style(&binary_name).dim(),
179 + console::style(format!("{}:{}", &rsync_host, remote_with_slash)).dim(),
180 + console::style(&upload_size).dim(),
181 + ),
182 + );
183 +
184 let mut upload_spinner = Spinner::new(
185 Spinners::Hamburger,
148 - succeed_message(&format!("Uploading {}…", binary_name)),
186 + format!(
187 + " {} {}",
188 + console::style("\u{25fc}").cyan(),
189 + console::style("Uploading\u{2026}").dim()
190 + ),
191 );
192
193 let upload_output = Command::new("rsync")
152 - .args([
153 - "-az",
154 - "--chmod=755",
155 - "-e",
156 - &ssh_command,
157 - &binary_path_str,
158 - &destination,
159 - ])
194 + .args(["-az", "-e", &ssh_command, &binary_path_str, &destination])
195 .output()
196 .map_err(|error| anyhow!(fail_message(&format!("Failed to launch rsync: {}", error))))?;
197
198 if !upload_output.status.success() {
164 - upload_spinner.stop_and_persist(&fail_symbol(), fail_message("Upload failed."));
199 + upload_spinner.stop_and_persist(
200 + &fail_symbol(),
201 + format!(
202 + " {} {}",
203 + console::style("✘").red(),
204 + fail_message("Upload failed")
205 + ),
206 + );
207 print_output_details(&upload_output);
208 drop(known_hosts_file);
209 mark_failed(
@@ -178,11 +220,13 @@ pub async fn process_deploy_rust(env: Environment, config: Config) -> Result<Com
220 ))));
221 }
222
181 - upload_spinner.stop_and_persist(&succeed_symbol(), succeed_message("Upload complete."));
182 -
183 - let mut remote_spinner = Spinner::new(
184 - Spinners::SimpleDotsScrolling,
185 - succeed_message(&format!("Starting {} on server…", binary_name)),
223 + upload_spinner.stop_and_persist(
224 + &format!(" {}", console::style("\u{25fc}").cyan()),
225 + format!(
226 + "{} Starting {}\u{2026}",
227 + console::style("Launch").white().bold(),
228 + console::style(&binary_name).dim(),
229 + ),
230 );
231
232 let deploy_script = build_remote_start_script(remote_path, &binary_name);
@@ -197,7 +241,11 @@ pub async fn process_deploy_rust(env: Environment, config: Config) -> Result<Com
241 drop(known_hosts_file);
242
243 if !ssh_output.status.success() {
200 - remote_spinner.stop_and_persist(&fail_symbol(), fail_message("Remote deploy failed."));
244 + println!(
245 + " {} {}",
246 + console::style("\u{2718}").red(),
247 + fail_message("Launch failed")
248 + );
249 print_output_details(&ssh_output);
250 mark_failed(
251 &deploy_ref,
@@ -213,13 +261,21 @@ pub async fn process_deploy_rust(env: Environment, config: Config) -> Result<Com
261 ))));
262 }
263
216 - remote_spinner.stop_and_persist(
217 - &succeed_symbol(),
218 - succeed_message("Remote Rust deploy script completed."),
264 + let stdout_text = String::from_utf8_lossy(&ssh_output.stdout);
265 + let pid_line = stdout_text
266 + .lines()
267 + .find(|line| line.starts_with("Started"))
268 + .unwrap_or("Started");
269 +
270 + println!(
271 + " {} {} {}",
272 + console::style("\u{25fc}").cyan(),
273 + console::style("Launch").white().bold(),
274 + console::style(pid_line).dim(),
275 );
276
277 if let Some(ref deployment) = created_deployment {
222 - match update(
278 + let _ = update(
279 env,
280 client(),
281 access_token,
@@ -231,17 +287,26 @@ pub async fn process_deploy_rust(env: Environment, config: Config) -> Result<Com
287 frontend_app_id: config.project.frontend_app_id.clone(),
288 },
289 )
234 - .await
235 - {
236 - Ok(_) => println!("App is running {}", succeed_symbol()),
237 - Err(error) => eprintln!("Error updating deployment status to Done: {}", error),
238 - }
290 + .await;
291 }
292
293 + let elapsed = deploy_start.elapsed().as_secs();
294 + let duration = if elapsed >= 60 {
295 + format!("{}m {}s", elapsed / 60, elapsed % 60)
296 + } else {
297 + format!("{}s", elapsed)
298 + };
299 +
300 + println!();
301 +
302 Ok(CommandResult {
303 spinner: Spinner::new(Spinners::Hamburger, String::new()),
304 symbol: succeed_symbol(),
244 - msg: succeed_message("Deployment complete."),
305 + msg: format!(
306 + "Deployed {} in {}",
307 + console::style(&config.name).white().bold(),
308 + console::style(&duration).cyan(),
309 + ),
310 })
311 }
312
@@ -297,8 +362,6 @@ fn resolve_binary_name(config: &Config, source_dir: &Path) -> Result<String> {
362 }
363
364 fn build_local_binary(source: &str, rust_target: &str, binary_name: &str) -> Result<PathBuf> {
300 - println!();
301 -
365 let mut build_command;
366 let build_tool;
367 let is_native = native_linux_target() == Some(rust_target);
@@ -345,6 +408,8 @@ fn build_local_binary(source: &str, rust_target: &str, binary_name: &str) -> Res
408
409 let status = build_command
410 .current_dir(source)
411 + .stdout(Stdio::inherit())
412 + .stderr(Stdio::inherit())
413 .status()
414 .map_err(|error| {
415 anyhow!(fail_message(&format!(
@@ -353,8 +418,6 @@ fn build_local_binary(source: &str, rust_target: &str, binary_name: &str) -> Res
418 )))
419 })?;
420
356 - println!();
357 -
421 if !status.success() {
422 return Err(anyhow!(fail_message(&format!(
423 "'{} build --release --target {} --bin {}' exited with status {}",
@@ -375,10 +438,20 @@ fn build_local_binary(source: &str, rust_target: &str, binary_name: &str) -> Res
438 ))));
439 }
440
441 + let binary_size = fs::metadata(&binary_path).map(|m| m.len()).unwrap_or(0);
442 + let size_display = if binary_size > 1_000_000 {
443 + format!("{} MB", binary_size / 1_000_000)
444 + } else {
445 + format!("{} KB", binary_size / 1_000)
446 + };
447 +
448 println!(
379 - "{} {}",
380 - succeed_symbol(),
381 - succeed_message(&format!("Built {} for {}.", binary_name, rust_target))
449 + " {} {} {} \u{2192} {} ({})",
450 + console::style("\u{25fc}").cyan(),
451 + console::style("Build").white().bold(),
452 + console::style(binary_name).dim(),
453 + console::style(rust_target).dim(),
454 + console::style(&size_display).dim(),
455 );
456
457 Ok(binary_path)
@@ -458,14 +531,9 @@ echo "Done."
531
532 fn build_ssh_command(identity_file: &str, known_hosts_file: &NamedTempFile) -> String {
533 format!(
461 - "ssh -i {identity} \\
462 - -o StrictHostKeyChecking=yes \\
463 - -o UserKnownHostsFile={known_hosts} \\
464 - -o IdentitiesOnly=yes \\
465 - -o PasswordAuthentication=no \\
466 - -o BatchMode=yes",
467 - identity = identity_file,
468 - known_hosts = known_hosts_file.path().display(),
534 + "ssh -i {} -o StrictHostKeyChecking=yes -o UserKnownHostsFile={} -o IdentitiesOnly=yes -o PasswordAuthentication=no -o BatchMode=yes",
535 + identity_file,
536 + known_hosts_file.path().display(),
537 )
538 }
539
crates/smbcloud-model/src/project.rs
+8
@@ -7,6 +7,10 @@ use {
7 tsync::tsync,
8 };
9
10 +fn default_datetime() -> DateTime<Utc> {
11 + DateTime::UNIX_EPOCH
12 +}
13 +
14 /// How the project's files are delivered to the server.
15 ///
16 /// `Git` — the classic smbCloud flow: push to a remote git repo, the server
@@ -46,7 +50,9 @@ pub struct Config {
50 pub struct Project {
51 /// Umbrella smbCloud workspace ID.
52 pub id: i32,
53 + #[serde(default)]
54 pub name: String,
55 + #[serde(default)]
56 pub runner: Runner,
57 /// Defaults to `Git` when absent (older API responses won't include the field).
58 #[serde(default)]
@@ -62,7 +68,9 @@ pub struct Project {
68 pub deploy_repo_id: Option<i64>,
69 /// Repo-relative app path for monorepo targets, e.g. "apps/web/console".
70 pub source_path: Option<String>,
71 + #[serde(default = "default_datetime")]
72 pub created_at: DateTime<Utc>,
73 + #[serde(default = "default_datetime")]
74 pub updated_at: DateTime<Utc>,
75 /// Deployment kind, e.g. "vite-spa", "nextjs-ssr", or "rust".
76 pub kind: Option<String>,