@smbcloud / cli / commits / d7dce40

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 {