Add Next.js SSR and Vite SPA deploy handlers
- Add process_deploy_nextjs_ssr and process_deploy_vite_spa modules - Implement deployment flows for Next.js SSR (standalone) and Vite SPA projects - Extend Deploy command to accept optional project name argument - Update Project model with fields for kind, source, output, package_manager, and pm2_app - Wire new deploy handlers into process_deploy - Add remote_messages helpers for Vite SPA deploys
Seto committed
Mar 24, 2026 at 00:30 UTC
d7dce406762efad8945a623e6b62ecb5ee245219
8 files changed
+724
-15
crates/cli/src/cli/mod.rs
+6
-1
@@ -40,7 +40,12 @@ pub enum Commands {
40
about = "Deploy project. This is smb main command. Requires an smbCloud account.",
41
display_order = 0
42
)]
43
- Deploy {},
43
+ Deploy {
44
+ /// Name of the sub-project to deploy (for monorepo configs with [[projects]]).
45
+ /// Matches the `name` field in .smb/config.toml. Omit to deploy the root project.
46
+ #[arg(short, long)]
47
+ project: Option<String>,
48
+ },
49
#[clap(
50
about = "Initialize project. Requires an smbCloud account.",
51
display_order = 1
crates/cli/src/deploy/mod.rs
+2
@@ -3,6 +3,8 @@ mod detect_runner;
3
mod git;
4
mod known_hosts;
5
pub mod process_deploy;
6
+pub mod process_deploy_nextjs_ssr;
7
+pub mod process_deploy_vite_spa;
8
mod remote_messages;
9
pub(crate) mod rsync_deploy;
10
pub(crate) mod setup_create_new_project;
crates/cli/src/deploy/process_deploy.rs
+94
-11
@@ -7,6 +7,8 @@ use {
7
config::{check_project, credentials, get_config},
8
detect_runner::detect_runner,
9
git::remote_deployment_setup,
10
+ process_deploy_nextjs_ssr::process_deploy_nextjs_ssr,
11
+ process_deploy_vite_spa::process_deploy_vite_spa,
12
remote_messages::{build_next_app, start_server},
13
rsync_deploy::rsync_deploy,
14
},
@@ -14,13 +16,18 @@ use {
16
ui::{fail_message, succeed_message, succeed_symbol},
17
},
18
anyhow::{anyhow, Result},
19
+ dialoguer::{console::Term, theme::ColorfulTheme, Select},
20
git2::{PushOptions, RemoteCallbacks, Repository},
18
- smbcloud_model::project::{DeploymentMethod, DeploymentPayload, DeploymentStatus},
21
+ smbcloud_model::{
22
+ project::{DeploymentMethod, DeploymentPayload, DeploymentStatus},
23
+ runner::Runner,
24
+ },
25
smbcloud_network::environment::Environment,
26
smbcloud_networking_account::me::me,
27
smbcloud_networking_project::{
28
crud_project_deployment_create::create_deployment, crud_project_deployment_update::update,
29
},
30
+ smbcloud_utils::config::Config,
31
spinners::Spinner,
32
std::sync::{
33
atomic::{AtomicBool, Ordering},
@@ -28,7 +35,59 @@ use {
35
},
36
};
37
31
-pub async fn process_deploy(env: Environment) -> Result<CommandResult> {
38
+/// Interactively prompt the user to pick one of the [[projects]] entries.
39
+fn prompt_select_project(config: &Config) -> Result<String> {
40
+ let projects = config.projects.as_ref().ok_or_else(|| {
41
+ anyhow!(fail_message(
42
+ "No [[projects]] entries found in .smb/config.toml."
43
+ ))
44
+ })?;
45
+
46
+ let labels: Vec<&str> = projects.iter().map(|p| p.name.as_str()).collect();
47
+
48
+ let index = Select::with_theme(&ColorfulTheme::default())
49
+ .with_prompt("Select project to deploy")
50
+ .items(&labels)
51
+ .default(0)
52
+ .interact_on(&Term::stderr())
53
+ .map_err(|_| anyhow!(fail_message("No project selected.")))?;
54
+
55
+ Ok(labels[index].to_owned())
56
+}
57
+
58
+/// Swap `config.project` for the named entry in `config.projects`, preserving
59
+/// the rest of the config unchanged so auth, SSH keys, etc. still resolve.
60
+fn resolve_sub_project(mut config: Config, name: &str) -> Result<Config> {
61
+ let projects = config.projects.as_ref().ok_or_else(|| {
62
+ anyhow!(fail_message(
63
+ "No [[projects]] entries found in .smb/config.toml."
64
+ ))
65
+ })?;
66
+
67
+ let sub_project = projects
68
+ .iter()
69
+ .find(|project| project.name == name)
70
+ .ok_or_else(|| {
71
+ anyhow!(fail_message(&format!(
72
+ "Sub-project '{}' not found in [[projects]]. Available: {}",
73
+ name,
74
+ projects
75
+ .iter()
76
+ .map(|project| project.name.as_str())
77
+ .collect::<Vec<_>>()
78
+ .join(", ")
79
+ )))
80
+ })?
81
+ .clone();
82
+
83
+ config.project = sub_project;
84
+ Ok(config)
85
+}
86
+
87
+pub async fn process_deploy(
88
+ env: Environment,
89
+ project_name: Option<String>,
90
+) -> Result<CommandResult> {
91
// Check credentials.
92
let is_logged_in = is_logged_in(env).await?;
93
@@ -36,15 +95,40 @@ pub async fn process_deploy(env: Environment) -> Result<CommandResult> {
95
let _ = process_login(env, Some(is_logged_in)).await?;
96
}
97
39
- // Get current token
98
+ // Get current token.
99
let access_token = get_smb_token(env)?;
100
101
// Check config.
43
- let config = get_config(env, Some(&access_token)).await?;
102
+ let mut config = get_config(env, Some(&access_token)).await?;
103
+
104
+ // When a sub-project name is given (monorepo), swap config.project to that
105
+ // entry from [[projects]] so all downstream logic operates on the right one.
106
+ // When omitted and the root project is a monorepo, prompt the user to pick.
107
+ let resolved_name = match project_name {
108
+ Some(name) => Some(name),
109
+ None if config.project.runner == Runner::Monorepo => Some(prompt_select_project(&config)?),
110
+ None => None,
111
+ };
112
+
113
+ if let Some(ref name) = resolved_name {
114
+ config = resolve_sub_project(config, name)?;
115
+ }
116
45
- // Validate config with project.
117
+ // Validate that the logged-in user has access to this project before doing
118
+ // any work — applies to every deployment path including vite-spa.
119
check_project(env, &access_token, config.project.id).await?;
120
121
+ // Route Vite SPA projects to a dedicated local-build + rsync deploy path.
122
+ // The kind field in config.toml drives this: kind = "vite-spa".
123
+ if config.project.kind.as_deref() == Some("vite-spa") {
124
+ return process_deploy_vite_spa(env, config).await;
125
+ }
126
+
127
+ // Route Next.js SSR projects: pnpm install + build, rsync 8 items, SSH pm2 restart.
128
+ if config.project.kind.as_deref() == Some("nextjs-ssr") {
129
+ return process_deploy_nextjs_ssr(env, config).await;
130
+ }
131
+
132
match config.project.deployment_method {
133
DeploymentMethod::Rsync => {
134
// For rsync deployments the runner is known from config — no framework
@@ -75,7 +159,7 @@ async fn git_deploy(
159
}
160
};
161
78
- // Get the current branch
162
+ // Get the current branch.
163
let head = match repo.head() {
164
Ok(head) => head,
165
Err(_) => {
@@ -85,7 +169,7 @@ async fn git_deploy(
169
}
170
};
171
88
- // Check if we're on the main branch
172
+ // Check if we're on the main branch.
173
let branch_name = match head.shorthand() {
174
Some(name) => name,
175
None => {
@@ -131,17 +215,16 @@ async fn git_deploy(
215
let mut push_opts = PushOptions::new();
216
let mut callbacks = RemoteCallbacks::new();
217
134
- // For updating status to failed
218
+ // For updating status to failed.
219
let deployment_failed_flag = Arc::new(AtomicBool::new(false));
220
let update_env = env; // Env is Copy
221
let update_access_token = access_token.to_owned();
222
let update_project_id = config.project.id;
223
let update_deployment_id = created_deployment.id;
224
141
- // Set the credentials
225
+ // Set the credentials.
226
callbacks.credentials(credentials(&config, user));
227
callbacks.sideband_progress(|data| {
144
- // Convert bytes to string, print line by line
228
if let Ok(text) = std::str::from_utf8(data) {
229
for line in text.lines() {
230
if line.contains(&build_next_app()) {
@@ -208,7 +291,7 @@ async fn git_deploy(
291
292
match origin.push(&["refs/heads/main:refs/heads/main"], Some(&mut push_opts)) {
293
Ok(_) => {
211
- // Update deployment status to Done
294
+ // Update deployment status to Done.
295
let update_payload = DeploymentPayload {
296
commit_hash: commit_hash.to_string(),
297
status: DeploymentStatus::Done,
crates/cli/src/deploy/process_deploy_nextjs_ssr.rs
new
+426
@@ -0,0 +1,426 @@
1
+use {
2
+ crate::{
3
+ cli::CommandResult,
4
+ client,
5
+ deploy::known_hosts,
6
+ ui::{fail_message, fail_symbol, succeed_message, succeed_symbol},
7
+ },
8
+ anyhow::{anyhow, Result},
9
+ chrono::Utc,
10
+ smbcloud_model::project::{DeploymentPayload, DeploymentStatus},
11
+ smbcloud_network::environment::Environment,
12
+ smbcloud_networking_account::me::me,
13
+ smbcloud_networking_project::{
14
+ crud_project_deployment_create::create_deployment, crud_project_deployment_update::update,
15
+ },
16
+ smbcloud_utils::config::Config,
17
+ spinners::{Spinner, Spinners},
18
+ std::{
19
+ io::Write,
20
+ process::{Command, Stdio},
21
+ },
22
+ tempfile::NamedTempFile,
23
+};
24
+
25
+/// Deploys a Next.js SSR app using standalone output mode.
26
+///
27
+/// Requires `output: 'standalone'` in next.config.js. This produces a
28
+/// self-contained `.next/standalone/` directory that includes only the
29
+/// production Node.js dependencies needed to run the server — no
30
+/// `node_modules` transfer required.
31
+///
32
+/// Steps:
33
+/// 1. `pnpm install --ignore-scripts`
34
+/// 2. `pnpm build`
35
+/// 3. POST deployment record as Started
36
+/// 4. rsync .next/standalone/ → server:path/ (server + bundled deps)
37
+/// 5. rsync .next/static/ → server:path/.next/static/ (static chunks)
38
+/// 6. rsync public/ → server:path/public/ (public assets)
39
+/// 7. SSH: pm2 restart/start `node server.js`
40
+/// 8. PATCH deployment record as Done
41
+pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Result<CommandResult> {
42
+ let source = config.project.source.as_deref().unwrap_or(".");
43
+ let package_manager = config.project.package_manager.as_deref().unwrap_or("pnpm");
44
+
45
+ let remote_path = config.project.path.as_deref().ok_or_else(|| {
46
+ anyhow!(fail_message(
47
+ "path not set in .smb/config.toml (e.g. path = \"apps/web/myapp\")"
48
+ ))
49
+ })?;
50
+
51
+ let pm2_app = config.project.pm2_app.as_deref().ok_or_else(|| {
52
+ anyhow!(fail_message(
53
+ "pm2_app not set in .smb/config.toml (e.g. pm2_app = \"my-app\")"
54
+ ))
55
+ })?;
56
+
57
+ let access_token = crate::token::get_smb_token::get_smb_token(env)?;
58
+ let user = me(env, client(), &access_token).await?;
59
+
60
+ // ── Step 1: pnpm install --ignore-scripts ────────────────────────────────
61
+
62
+ let source_dir = std::path::Path::new(source);
63
+ if !source_dir.exists() {
64
+ return Err(anyhow!(fail_message(&format!(
65
+ "Source path '{}' does not exist. Check the 'source' field in .smb/config.toml.",
66
+ source
67
+ ))));
68
+ }
69
+
70
+ let mut install_spinner = Spinner::new(
71
+ Spinners::SimpleDotsScrolling,
72
+ succeed_message(&format!("Installing dependencies in {}…", source)),
73
+ );
74
+
75
+ let install_status = Command::new(package_manager)
76
+ .args(["install", "--ignore-scripts"])
77
+ .current_dir(source)
78
+ .status()
79
+ .map_err(|e| {
80
+ anyhow!(fail_message(&format!(
81
+ "Failed to spawn '{} install': {}",
82
+ package_manager, e
83
+ )))
84
+ })?;
85
+
86
+ if !install_status.success() {
87
+ install_spinner.stop_and_persist(
88
+ &fail_symbol(),
89
+ fail_message("Install failed. See output above."),
90
+ );
91
+ return Err(anyhow!(fail_message(&format!(
92
+ "'{} install --ignore-scripts' exited with status {}",
93
+ package_manager, install_status
94
+ ))));
95
+ }
96
+
97
+ install_spinner.stop_and_persist(
98
+ &succeed_symbol(),
99
+ succeed_message("Dependencies installed."),
100
+ );
101
+
102
+ // ── Step 2: pnpm build ───────────────────────────────────────────────────
103
+
104
+ let mut build_spinner = Spinner::new(
105
+ Spinners::SimpleDotsScrolling,
106
+ succeed_message(&format!("Building {} with {}…", source, package_manager)),
107
+ );
108
+
109
+ let build_status = Command::new(package_manager)
110
+ .arg("build")
111
+ .current_dir(source)
112
+ .status()
113
+ .map_err(|e| {
114
+ anyhow!(fail_message(&format!(
115
+ "Failed to spawn '{} build': {}",
116
+ package_manager, e
117
+ )))
118
+ })?;
119
+
120
+ if !build_status.success() {
121
+ build_spinner.stop_and_persist(
122
+ &fail_symbol(),
123
+ fail_message("Build failed. See output above."),
124
+ );
125
+ return Err(anyhow!(fail_message(&format!(
126
+ "'{} build' exited with status {}",
127
+ package_manager, build_status
128
+ ))));
129
+ }
130
+
131
+ build_spinner.stop_and_persist(&succeed_symbol(), succeed_message("Build complete."));
132
+
133
+ // ── Step 3: verify standalone output exists ──────────────────────────────
134
+ //
135
+ // If output: 'standalone' is missing from next.config.js the build
136
+ // succeeds but .next/standalone/ is never created.
137
+
138
+ let standalone_dir = format!("{}/.next/standalone", source);
139
+ if !std::path::Path::new(&standalone_dir).exists() {
140
+ return Err(anyhow!(fail_message(
141
+ ".next/standalone not found. Add `output: 'standalone'` to next.config.js and rebuild."
142
+ )));
143
+ }
144
+
145
+ // ── Step 4: record deployment as Started ─────────────────────────────────
146
+
147
+ let deploy_ref = Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
148
+
149
+ let created_deployment = create_deployment(
150
+ env,
151
+ client(),
152
+ &access_token,
153
+ config.project.id,
154
+ DeploymentPayload {
155
+ commit_hash: deploy_ref.clone(),
156
+ status: DeploymentStatus::Started,
157
+ },
158
+ )
159
+ .await
160
+ .ok();
161
+
162
+ // ── Steps 5–7: rsync three items to the server ───────────────────────────
163
+ //
164
+ // Standalone mode produces everything needed to run the server:
165
+ //
166
+ // .next/standalone/ — server.js + bundled production deps (no node_modules needed)
167
+ // .next/static/ — client-side chunks (must be copied into standalone manually)
168
+ // public/ — static assets served directly by Next.js
169
+ //
170
+ // The static and public dirs must sit inside the standalone tree so
171
+ // `node server.js` can find them at runtime.
172
+
173
+ let runner = config.project.runner;
174
+ let rsync_host = runner.rsync_host();
175
+
176
+ let home = dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory"))?;
177
+ let identity_file = home.join(".ssh").join(format!("id_{}@smbcloud", user.id));
178
+ let identity_file_str = identity_file.to_string_lossy().into_owned();
179
+
180
+ // Write pinned known_hosts once for all rsync calls.
181
+ let mut known_hosts_file = NamedTempFile::new()
182
+ .map_err(|e| anyhow!("Failed to create temp known_hosts file: {}", e))?;
183
+ writeln!(known_hosts_file, "{}", known_hosts::for_host(&rsync_host))
184
+ .map_err(|e| anyhow!("Failed to write known_hosts: {}", e))?;
185
+
186
+ let ssh_command = format!(
187
+ "ssh -i {identity} \
188
+ -o StrictHostKeyChecking=yes \
189
+ -o UserKnownHostsFile={known_hosts} \
190
+ -o IdentitiesOnly=yes \
191
+ -o PasswordAuthentication=no \
192
+ -o BatchMode=yes",
193
+ identity = identity_file_str,
194
+ known_hosts = known_hosts_file.path().display(),
195
+ );
196
+
197
+ let remote_base = format!(
198
+ "git@{}:{}",
199
+ rsync_host,
200
+ if remote_path.ends_with('/') {
201
+ remote_path.to_owned()
202
+ } else {
203
+ format!("{}/", remote_path)
204
+ }
205
+ );
206
+
207
+ // (local_source, remote_destination)
208
+ // .next/standalone contents go to the root of remote_path.
209
+ // .next/static and public go into their correct subdirectories within it.
210
+ let transfers: &[(&str, &str)] = &[
211
+ // standalone contents → remote root (server.js lives here)
212
+ (".next/standalone/", ""),
213
+ // static chunks → .next/static/ inside the standalone tree
214
+ (".next/static/", ".next/static/"),
215
+ // public assets → public/ inside the standalone tree
216
+ ("public/", "public/"),
217
+ ];
218
+
219
+ let mut upload_spinner = Spinner::new(
220
+ Spinners::Hamburger,
221
+ succeed_message(&format!("Uploading to {}…", remote_path)),
222
+ );
223
+
224
+ for (local_rel, remote_rel) in transfers {
225
+ let local_path = format!("{}/{}", source, local_rel);
226
+ let destination = format!("{}{}", remote_base, remote_rel);
227
+
228
+ let output = Command::new("rsync")
229
+ .args([
230
+ "-az",
231
+ "--delete",
232
+ "-e",
233
+ &ssh_command,
234
+ &local_path,
235
+ &destination,
236
+ ])
237
+ .output()
238
+ .map_err(|e| anyhow!(fail_message(&format!("Failed to launch rsync: {}", e))))?;
239
+
240
+ if !output.status.success() {
241
+ let stderr = String::from_utf8_lossy(&output.stderr);
242
+ upload_spinner.stop_and_persist(&fail_symbol(), fail_message("Upload failed."));
243
+ mark_failed(
244
+ &deploy_ref,
245
+ &created_deployment,
246
+ &config,
247
+ env,
248
+ &access_token,
249
+ )
250
+ .await;
251
+ return Err(anyhow!(fail_message(&format!(
252
+ "rsync of '{}' failed (status {}): {}",
253
+ local_rel,
254
+ output.status.code().unwrap_or(-1),
255
+ stderr.trim()
256
+ ))));
257
+ }
258
+ }
259
+
260
+ // known_hosts_file must stay alive until all rsync calls complete.
261
+ drop(known_hosts_file);
262
+
263
+ upload_spinner.stop_and_persist(&succeed_symbol(), succeed_message("Upload complete."));
264
+
265
+ // ── Step 8: SSH remote restart ───────────────────────────────────────────
266
+ //
267
+ // Runs `node server.js` via pm2 inside the deployed standalone directory.
268
+ // PORT and HOSTNAME are set so Next.js binds correctly behind nginx.
269
+ // nvm is sourced from ~/.profile so the right Node version is used.
270
+ //
271
+ // pm2 describe checks whether the process already exists:
272
+ // - exists → pm2 restart (picks up new server.js in place)
273
+ // - missing → pm2 start (first deploy)
274
+
275
+ let deploy_script = format!(
276
+ r#"set -e
277
+APP_PATH="{remote_path}"
278
+PM2_APP="{pm2_app}"
279
+
280
+if [ ! -d "$APP_PATH" ]; then
281
+ echo "Error: $APP_PATH is not a directory."
282
+ exit 1
283
+fi
284
+
285
+cd "$APP_PATH"
286
+
287
+echo "Starting/restarting $PM2_APP with pm2..."
288
+if pm2 describe "$PM2_APP" > /dev/null 2>&1; then
289
+ pm2 restart "$PM2_APP"
290
+else
291
+ PORT=3010 HOSTNAME=127.0.0.1 pm2 start node --name "$PM2_APP" -- server.js
292
+fi
293
+pm2 save
294
+echo "Done."
295
+"#,
296
+ remote_path = remote_path,
297
+ pm2_app = pm2_app,
298
+ );
299
+
300
+ let mut restart_spinner = Spinner::new(
301
+ Spinners::SimpleDotsScrolling,
302
+ succeed_message(&format!("Restarting {} on server…", pm2_app)),
303
+ );
304
+
305
+ // Fresh known_hosts file for the SSH exec (previous one was dropped).
306
+ let mut ssh_known_hosts_file = NamedTempFile::new()
307
+ .map_err(|e| anyhow!("Failed to create temp known_hosts file: {}", e))?;
308
+ writeln!(
309
+ ssh_known_hosts_file,
310
+ "{}",
311
+ known_hosts::for_host(&rsync_host)
312
+ )
313
+ .map_err(|e| anyhow!("Failed to write known_hosts: {}", e))?;
314
+
315
+ let mut child = Command::new("ssh")
316
+ .args([
317
+ "-i",
318
+ &identity_file_str,
319
+ "-o",
320
+ "StrictHostKeyChecking=yes",
321
+ "-o",
322
+ &format!(
323
+ "UserKnownHostsFile={}",
324
+ ssh_known_hosts_file.path().display()
325
+ ),
326
+ "-o",
327
+ "IdentitiesOnly=yes",
328
+ "-o",
329
+ "PasswordAuthentication=no",
330
+ "-o",
331
+ "BatchMode=yes",
332
+ &format!("git@{}", rsync_host),
333
+ "bash -s",
334
+ ])
335
+ .stdin(Stdio::piped())
336
+ .spawn()
337
+ .map_err(|e| anyhow!(fail_message(&format!("Failed to spawn SSH: {}", e))))?;
338
+
339
+ if let Some(mut stdin) = child.stdin.take() {
340
+ stdin
341
+ .write_all(deploy_script.as_bytes())
342
+ .map_err(|e| anyhow!("Failed to write deploy script to SSH stdin: {}", e))?;
343
+ }
344
+
345
+ let ssh_status = child
346
+ .wait()
347
+ .map_err(|e| anyhow!("Failed to wait for SSH process: {}", e))?;
348
+
349
+ drop(ssh_known_hosts_file);
350
+
351
+ if !ssh_status.success() {
352
+ restart_spinner.stop_and_persist(
353
+ &fail_symbol(),
354
+ fail_message("Remote restart failed. See output above."),
355
+ );
356
+ mark_failed(
357
+ &deploy_ref,
358
+ &created_deployment,
359
+ &config,
360
+ env,
361
+ &access_token,
362
+ )
363
+ .await;
364
+ return Err(anyhow!(fail_message(&format!(
365
+ "SSH deploy script exited with status {}",
366
+ ssh_status
367
+ ))));
368
+ }
369
+
370
+ restart_spinner.stop_and_persist(
371
+ &succeed_symbol(),
372
+ succeed_message(&format!("{} restarted.", pm2_app)),
373
+ );
374
+
375
+ // ── Step 9: mark deployment as Done ──────────────────────────────────────
376
+
377
+ if let Some(ref deployment) = created_deployment {
378
+ match update(
379
+ env,
380
+ client(),
381
+ access_token,
382
+ config.project.id,
383
+ deployment.id,
384
+ DeploymentPayload {
385
+ commit_hash: deploy_ref,
386
+ status: DeploymentStatus::Done,
387
+ },
388
+ )
389
+ .await
390
+ {
391
+ Ok(_) => println!("App is running {}", succeed_symbol()),
392
+ Err(e) => eprintln!("Error updating deployment status to Done: {}", e),
393
+ }
394
+ }
395
+
396
+ Ok(CommandResult {
397
+ spinner: Spinner::new(Spinners::Hamburger, String::new()),
398
+ symbol: succeed_symbol(),
399
+ msg: succeed_message("Deployment complete."),
400
+ })
401
+}
402
+
403
+/// Mark the in-flight deployment record as Failed.
404
+/// Called on any early-return error path so the dashboard reflects reality.
405
+async fn mark_failed(
406
+ deploy_ref: &str,
407
+ created_deployment: &Option<smbcloud_model::project::Deployment>,
408
+ config: &Config,
409
+ env: Environment,
410
+ access_token: &str,
411
+) {
412
+ if let Some(ref deployment) = created_deployment {
413
+ let _ = update(
414
+ env,
415
+ client(),
416
+ access_token.to_owned(),
417
+ config.project.id,
418
+ deployment.id,
419
+ DeploymentPayload {
420
+ commit_hash: deploy_ref.to_owned(),
421
+ status: DeploymentStatus::Failed,
422
+ },
423
+ )
424
+ .await;
425
+ }
426
+}
crates/cli/src/deploy/process_deploy_vite_spa.rs
new
+170
@@ -0,0 +1,170 @@
1
+use {
2
+ crate::{
3
+ cli::CommandResult,
4
+ client,
5
+ deploy::rsync_deploy::rsync_deploy,
6
+ ui::{fail_message, fail_symbol, succeed_message, succeed_symbol},
7
+ },
8
+ anyhow::{anyhow, Result},
9
+ chrono::Utc,
10
+ smbcloud_model::project::{DeploymentPayload, DeploymentStatus},
11
+ smbcloud_network::environment::Environment,
12
+ smbcloud_networking_account::me::me,
13
+ smbcloud_networking_project::{
14
+ crud_project_deployment_create::create_deployment, crud_project_deployment_update::update,
15
+ },
16
+ smbcloud_utils::config::Config,
17
+ spinners::{Spinner, Spinners},
18
+ std::process::Command,
19
+};
20
+
21
+pub async fn process_deploy_vite_spa(env: Environment, config: Config) -> Result<CommandResult> {
22
+ // Resolve required SPA fields from the project config.
23
+ // `source` is the local directory containing the vite project (e.g. "frontend/connected-devices/").
24
+ // `path` is the remote destination on the server, consumed by rsync_deploy.
25
+ let project_path = config.project.source.as_deref().unwrap_or(".");
26
+ let output_dir = config.project.output.as_deref().unwrap_or("dist");
27
+ let package_manager = config.project.package_manager.as_deref().unwrap_or("pnpm");
28
+
29
+ // Fetch user for SSH key and deployment record.
30
+ let access_token = crate::token::get_smb_token::get_smb_token(env)?;
31
+ let user = me(env, client(), &access_token).await?;
32
+
33
+ // ── Step 1: build locally ────────────────────────────────────────────────
34
+
35
+ let mut build_spinner = Spinner::new(
36
+ Spinners::SimpleDotsScrolling,
37
+ succeed_message(&format!(
38
+ "Building {} with {}…",
39
+ project_path, package_manager
40
+ )),
41
+ );
42
+
43
+ // Validate the directory exists before spawning — current_dir silently
44
+ // produces os error 2 at status() time if the path doesn't exist, which
45
+ // gives a confusing "Failed to spawn" message instead of the real cause.
46
+ let project_dir = std::path::Path::new(project_path);
47
+ if !project_dir.exists() {
48
+ build_spinner.stop_and_persist(
49
+ &fail_symbol(),
50
+ fail_message(&format!(
51
+ "Project path '{}' does not exist. Check the 'path' field in .smb/config.toml.",
52
+ project_path
53
+ )),
54
+ );
55
+ return Err(anyhow!(fail_message(&format!(
56
+ "Project path '{}' does not exist.",
57
+ project_path
58
+ ))));
59
+ }
60
+
61
+ let build_status = Command::new(package_manager)
62
+ .arg("build")
63
+ .current_dir(project_path)
64
+ .status()
65
+ .map_err(|error| {
66
+ anyhow!(fail_message(&format!(
67
+ "Failed to spawn '{}': {}",
68
+ package_manager, error
69
+ )))
70
+ })?;
71
+
72
+ if !build_status.success() {
73
+ build_spinner.stop_and_persist(
74
+ &fail_symbol(),
75
+ fail_message("Build failed. See output above."),
76
+ );
77
+ return Err(anyhow!(fail_message(&format!(
78
+ "'{} build' exited with status {}",
79
+ package_manager, build_status,
80
+ ))));
81
+ }
82
+
83
+ build_spinner.stop_and_persist(&succeed_symbol(), succeed_message("Build complete."));
84
+
85
+ // ── Step 2: record deployment as Started ─────────────────────────────────
86
+ //
87
+ // A pure rsync SPA deploy has no git commit hash; use a UTC timestamp as a
88
+ // lightweight, non-empty identifier for the API record.
89
+
90
+ let deploy_ref = Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
91
+
92
+ let start_payload = DeploymentPayload {
93
+ commit_hash: deploy_ref.clone(),
94
+ status: DeploymentStatus::Started,
95
+ };
96
+
97
+ let created_deployment = create_deployment(
98
+ env,
99
+ client(),
100
+ &access_token,
101
+ config.project.id,
102
+ start_payload,
103
+ )
104
+ .await
105
+ .ok();
106
+
107
+ // ── Step 3: rsync <project_path>/<output_dir>/ to api.smbcloud.xyz ───────
108
+ //
109
+ // config.project.path holds the remote destination on the server
110
+ // (e.g. "apps/web/myapp"). rsync_deploy appends a
111
+ // trailing slash and targets git@api.smbcloud.xyz:<path>/ using the
112
+ // pinned known-hosts and the user's smbCloud SSH key — exactly the same
113
+ // transport used for static site deployments.
114
+
115
+ let local_dist = format!("{}/{}", project_path, output_dir);
116
+ let runner = config.project.runner;
117
+
118
+ match rsync_deploy(&config, &runner, user.id, &local_dist) {
119
+ Ok(_) => {}
120
+ Err(error) => {
121
+ if let Some(ref deployment) = created_deployment {
122
+ let failed_payload = DeploymentPayload {
123
+ commit_hash: deploy_ref.clone(),
124
+ status: DeploymentStatus::Failed,
125
+ };
126
+ let _ = update(
127
+ env,
128
+ client(),
129
+ access_token.clone(),
130
+ config.project.id,
131
+ deployment.id,
132
+ failed_payload,
133
+ )
134
+ .await;
135
+ }
136
+ return Err(error);
137
+ }
138
+ }
139
+
140
+ // ── Step 4: mark deployment as Done ──────────────────────────────────────
141
+
142
+ if let Some(ref deployment) = created_deployment {
143
+ let done_payload = DeploymentPayload {
144
+ commit_hash: deploy_ref,
145
+ status: DeploymentStatus::Done,
146
+ };
147
+ match update(
148
+ env,
149
+ client(),
150
+ access_token,
151
+ config.project.id,
152
+ deployment.id,
153
+ done_payload,
154
+ )
155
+ .await
156
+ {
157
+ Ok(_) => println!("App is running {}", succeed_symbol()),
158
+ Err(update_err) => {
159
+ eprintln!("Error updating deployment status to Done: {}", update_err)
160
+ }
161
+ }
162
+ }
163
+
164
+ let spinner = Spinner::new(Spinners::Hamburger, succeed_message("Deployment complete."));
165
+ Ok(CommandResult {
166
+ spinner,
167
+ symbol: succeed_symbol(),
168
+ msg: succeed_message("Deployment complete."),
169
+ })
170
+}
crates/cli/src/deploy/remote_messages.rs
+10
@@ -2,6 +2,16 @@ pub fn build_next_app() -> String {
2
"> next build".to_owned()
3
}
4
5
+#[allow(dead_code)]
6
+pub fn build_vite_spa() -> String {
7
+ "vite build".to_owned()
8
+}
9
+
10
pub fn start_server(name: &str) -> String {
11
format!("Start {}", name)
12
}
13
+
14
+#[allow(dead_code)]
15
+pub fn deploy_vite_spa_complete(name: &str) -> String {
16
+ format!("Deployed {}", name)
17
+}
crates/cli/src/main.rs
+3
-3
@@ -111,7 +111,7 @@ async fn run() -> Result<CommandResult> {
111
// Check if the command requires internet connection
112
let needs_internet = match &cli.command {
113
Some(Commands::Me {})
114
- | Some(Commands::Deploy {})
114
+ | Some(Commands::Deploy { .. })
115
| Some(Commands::Login {})
116
| Some(Commands::Logout {})
117
| Some(Commands::Account { .. })
@@ -130,11 +130,11 @@ async fn run() -> Result<CommandResult> {
130
match cli.command {
131
Some(Commands::Me {}) => process_me(cli.environment).await,
132
Some(Commands::Init {}) => process_project_init(cli.environment, true).await,
133
- Some(Commands::Deploy {}) => process_deploy(cli.environment).await,
133
+ Some(Commands::Deploy { project }) => process_deploy(cli.environment, project).await,
134
Some(Commands::Account { command }) => process_account(cli.environment, command).await,
135
Some(Commands::Login {}) => process_login(cli.environment, None).await,
136
Some(Commands::Logout {}) => process_logout(cli.environment).await,
137
Some(Commands::Project { command }) => process_project(cli.environment, command).await,
138
- None => process_deploy(cli.environment).await,
138
+ None => process_deploy(cli.environment, None).await,
139
}
140
}
crates/smbcloud-model/src/project.rs
+13
@@ -51,6 +51,19 @@ pub struct Project {
51
pub description: Option<String>,
52
pub created_at: DateTime<Utc>,
53
pub updated_at: DateTime<Utc>,
54
+ /// Deployment kind, e.g. "vite-spa". Absent for server-side runners.
55
+ pub kind: Option<String>,
56
+ /// Local source directory to build from, e.g. "frontend/connected-devices".
57
+ /// Used by vite-spa deploys as the working directory for the build step.
58
+ /// Distinct from `path`, which is the remote destination on the server.
59
+ pub source: Option<String>,
60
+ /// Build output directory relative to `source`, e.g. "dist".
61
+ pub output: Option<String>,
62
+ /// Package manager to use for the build step, e.g. "pnpm".
63
+ pub package_manager: Option<String>,
64
+ /// PM2 process name to restart after a nextjs-ssr deploy, e.g. "my-app".
65
+ /// Matches the name passed to `pm2 start` on the server.
66
+ pub pm2_app: Option<String>,
67
}
68
69
impl Display for Project {