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>,