@smbcloud / cli / commits / 12a933e

Fix Next.js SSR deploy to preserve ecosystem.config.js and logs

- Protect operator-managed runtime files from rsync deletion - Prefer pm2 start with ecosystem.config.js if present on server - Always delete and start pm2 process fresh after deploy

keypair34 committed May 9, 2026 at 22:43 UTC 12a933e4ee7c4e8b7f80184b94488e62fa4c9e6b
2 files changed +93 -43
crates/cli/src/account/login/process.rs
+4
@@ -100,6 +100,10 @@ async fn process_authorization(env: Environment, auth: SmbAuthorization) -> Resu
100 return Err(error);
101 }
102 GithubNotLinked => return connect_github_account(env, auth).await,
103 + _ => {
104 + let error = anyhow!(error_code.to_string());
105 + return Err(error);
106 + }
107 }
108 }
109
crates/cli/src/deploy/process_deploy_nextjs_ssr.rs
+89 -43
@@ -36,7 +36,7 @@ use {
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`
39 +/// 7. SSH: pm2 delete + start fresh (prefer server `ecosystem.config.js` if present)
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(".");
@@ -214,16 +214,37 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu
214 }
215 );
216
217 + struct Transfer {
218 + local_rel: &'static str,
219 + remote_rel: &'static str,
220 + protect_runtime_files: bool,
221 + }
222 +
223 // (local_source, remote_destination)
224 // .next/standalone contents go to the root of remote_path.
225 // .next/static and public go into their correct subdirectories within it.
220 - let transfers: &[(&str, &str)] = &[
226 + let transfers: &[Transfer] = &[
227 // standalone contents → remote root (server.js lives here)
222 - (".next/standalone/", ""),
228 + Transfer {
229 + local_rel: ".next/standalone/",
230 + remote_rel: "",
231 + // The server copy of ecosystem.config.js is operator-managed runtime
232 + // config and must survive deploys. Without this protection, rsync
233 + // `--delete` removes it because it does not exist in .next/standalone/.
234 + protect_runtime_files: true,
235 + },
236 // static chunks → .next/static/ inside the standalone tree
224 - (".next/static/", ".next/static/"),
237 + Transfer {
238 + local_rel: ".next/static/",
239 + remote_rel: ".next/static/",
240 + protect_runtime_files: false,
241 + },
242 // public assets → public/ inside the standalone tree
226 - ("public/", "public/"),
243 + Transfer {
244 + local_rel: "public/",
245 + remote_rel: "public/",
246 + protect_runtime_files: false,
247 + },
248 ];
249
250 let mut upload_spinner = Spinner::new(
@@ -231,24 +252,35 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu
252 succeed_message(&format!("Uploading to {}…", remote_path)),
253 );
254
234 - for (local_rel, remote_rel) in transfers {
235 - let local_path = format!("{}/{}", source, local_rel);
236 - let destination = format!("{}{}", remote_base, remote_rel);
255 + for transfer in transfers {
256 + let local_path = format!("{}/{}", source, transfer.local_rel);
257 + let destination = format!("{}{}", remote_base, transfer.remote_rel);
258
259 // Skip optional directories that the project does not have.
260 if !std::path::Path::new(&local_path).exists() {
261 continue;
262 }
263
264 + let mut rsync_args = vec!["-az".to_string(), "--delete".to_string()];
265 +
266 + if transfer.protect_runtime_files {
267 + rsync_args.extend([
268 + "--exclude".to_string(),
269 + "ecosystem.config.js".to_string(),
270 + "--exclude".to_string(),
271 + "logs/".to_string(),
272 + ]);
273 + }
274 +
275 + rsync_args.extend([
276 + "-e".to_string(),
277 + ssh_command.clone(),
278 + local_path.clone(),
279 + destination,
280 + ]);
281 +
282 let output = Command::new("rsync")
244 - .args([
245 - "-az",
246 - "--delete",
247 - "-e",
248 - &ssh_command,
249 - &local_path,
250 - &destination,
251 - ])
283 + .args(&rsync_args)
284 .output()
285 .map_err(|e| anyhow!(fail_message(&format!("Failed to launch rsync: {}", e))))?;
286
@@ -265,7 +297,7 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu
297 .await;
298 return Err(anyhow!(fail_message(&format!(
299 "rsync of '{}' failed (status {}): {}",
268 - local_rel,
300 + transfer.local_rel,
301 output.status.code().unwrap_or(-1),
302 stderr.trim()
303 ))));
@@ -279,15 +311,17 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu
311
312 // ── Step 8: SSH remote restart ───────────────────────────────────────────
313 //
282 - // Runs `node server.js` via pm2 inside the deployed standalone directory.
283 - // PORT and HOSTNAME are set so Next.js binds correctly behind nginx.
314 + // Runs the deployed app via pm2 inside the standalone directory.
315 + //
316 + // We always delete the existing pm2 process (if any) and start fresh. A
317 + // bare `pm2 restart` would re-execute the old command (e.g. `next start
318 + // --port XXXX` from a previous git-push deploy), which fails when the
319 + // working directory now contains standalone output instead of the full
320 + // Next.js build tree.
321 //
285 - // We always delete the existing pm2 process (if any) and start fresh with
286 - // `node server.js`. A bare `pm2 restart` would re-execute the *old* command
287 - // (e.g. `next start --port XXXX` from a previous git-push deploy), which
288 - // fails when the working directory now contains standalone output instead of
289 - // the full Next.js build tree. Deleting first guarantees the entry point
290 - // and environment are always correct.
322 + // If the server has an operator-managed `ecosystem.config.js`, prefer it as
323 + // the source of truth for runtime env and pm2 settings. Otherwise fall back
324 + // to `node server.js` with the minimal inline env needed to bind the app.
325 //
326 // The port defaults to 3000 and can be overridden with `port = XXXX` in
327 // .smb/config.toml — it must match the nginx upstream configuration.
@@ -296,24 +330,36 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu
330
331 let deploy_script = format!(
332 r#"set -e
299 -APP_PATH="{remote_path}"
300 -PM2_APP="{pm2_app}"
301 -
302 -if [ ! -d "$APP_PATH" ]; then
303 - echo "Error: $APP_PATH is not a directory."
304 - exit 1
305 -fi
306 -
307 -cd "$APP_PATH"
308 -
309 -echo "Starting $PM2_APP with pm2..."
310 -if pm2 describe "$PM2_APP" > /dev/null 2>&1; then
311 - pm2 delete "$PM2_APP"
312 -fi
313 -NODE_ENV=production PORT={port} HOSTNAME=127.0.0.1 pm2 start node --name "$PM2_APP" -- server.js
314 -pm2 save
315 -echo "Done."
316 -"#,
333 + APP_PATH="{remote_path}"
334 + PM2_APP="{pm2_app}"
335 +
336 + case "$APP_PATH" in
337 + /*) ;;
338 + *) APP_PATH="$HOME/$APP_PATH" ;;
339 + esac
340 +
341 + if [ ! -d "$APP_PATH" ]; then
342 + echo "Error: $APP_PATH is not a directory."
343 + exit 1
344 + fi
345 +
346 + cd "$APP_PATH"
347 + mkdir -p logs
348 +
349 + echo "Starting $PM2_APP with pm2..."
350 + if pm2 describe "$PM2_APP" > /dev/null 2>&1; then
351 + pm2 delete "$PM2_APP"
352 + fi
353 +
354 + if [ -f ecosystem.config.js ]; then
355 + pm2 start ecosystem.config.js --only "$PM2_APP" --env production
356 + else
357 + NODE_ENV=production PORT={port} HOSTNAME=127.0.0.1 pm2 start node --name "$PM2_APP" -- server.js
358 + fi
359 +
360 + pm2 save
361 + echo "Done."
362 + "#,
363 remote_path = remote_path,
364 pm2_app = pm2_app,
365 port = port,