@smbcloud / cli / commits / 88b43e0

Improve Next.js SSR deploy: capture output, fix pm2 restart, add port config

- Capture and print stdout/stderr for dependency install and remote restart failures, ensuring users see error details. - Change pm2 restart logic to always delete and start the process, preventing stale entry points after switching to standalone output. - Add `port` field to Project config, allowing the standalone server port to be customized (default 3000).

keypair34 committed Apr 5, 2026 at 22:36 UTC 88b43e0362ef9926a8c7d24a08f1d8306068370f
2 files changed +64 -33
crates/cli/src/deploy/process_deploy_nextjs_ssr.rs
+61 -33
@@ -72,10 +72,12 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu
72 succeed_message(&format!("Installing dependencies in {}…", source)),
73 );
74
75 - let install_status = Command::new(package_manager)
75 + // Capture stdout/stderr so pnpm's output does not interleave with the
76 + // spinner animation. On failure the captured output is printed for the user.
77 + let install_output = Command::new(package_manager)
78 .args(["install", "--ignore-scripts"])
79 .current_dir(source)
78 - .status()
80 + .output()
81 .map_err(|e| {
82 anyhow!(fail_message(&format!(
83 "Failed to spawn '{} install': {}",
@@ -83,14 +85,21 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu
85 )))
86 })?;
87
86 - if !install_status.success() {
87 - install_spinner.stop_and_persist(
88 - &fail_symbol(),
89 - fail_message("Install failed. See output above."),
90 - );
88 + if !install_output.status.success() {
89 + install_spinner.stop_and_persist(&fail_symbol(), fail_message("Install failed."));
90 + let stderr = String::from_utf8_lossy(&install_output.stderr);
91 + let stdout = String::from_utf8_lossy(&install_output.stdout);
92 + let details = if !stderr.trim().is_empty() {
93 + stderr
94 + } else {
95 + stdout
96 + };
97 + if !details.trim().is_empty() {
98 + eprintln!("{}", details.trim());
99 + }
100 return Err(anyhow!(fail_message(&format!(
101 "'{} install --ignore-scripts' exited with status {}",
93 - package_manager, install_status
102 + package_manager, install_output.status
103 ))));
104 }
105
@@ -100,11 +109,10 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu
109 );
110
111 // ── Step 2: pnpm build ───────────────────────────────────────────────────
112 + // No spinner — Next.js writes its own rich progress output to the terminal.
113 + // Blank lines before and after keep it visually separated from our log lines.
114
104 - let mut build_spinner = Spinner::new(
105 - Spinners::SimpleDotsScrolling,
106 - succeed_message(&format!("Building {} with {}…", source, package_manager)),
107 - );
115 + println!();
116
117 let build_status = Command::new(package_manager)
118 .arg("build")
@@ -117,18 +125,20 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu
125 )))
126 })?;
127
128 + println!();
129 +
130 if !build_status.success() {
121 - build_spinner.stop_and_persist(
122 - &fail_symbol(),
123 - fail_message("Build failed. See output above."),
124 - );
131 return Err(anyhow!(fail_message(&format!(
132 "'{} build' exited with status {}",
133 package_manager, build_status
134 ))));
135 }
136
131 - build_spinner.stop_and_persist(&succeed_symbol(), succeed_message("Build complete."));
137 + println!(
138 + "{} {}",
139 + succeed_symbol(),
140 + succeed_message("Build complete.")
141 + );
142
143 // ── Step 3: verify standalone output exists ──────────────────────────────
144 //
@@ -266,11 +276,18 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu
276 //
277 // Runs `node server.js` via pm2 inside the deployed standalone directory.
278 // 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.
279 //
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)
280 + // We always delete the existing pm2 process (if any) and start fresh with
281 + // `node server.js`. A bare `pm2 restart` would re-execute the *old* command
282 + // (e.g. `next start --port XXXX` from a previous git-push deploy), which
283 + // fails when the working directory now contains standalone output instead of
284 + // the full Next.js build tree. Deleting first guarantees the entry point
285 + // and environment are always correct.
286 + //
287 + // The port defaults to 3000 and can be overridden with `port = XXXX` in
288 + // .smb/config.toml — it must match the nginx upstream configuration.
289 +
290 + let port = config.project.port.unwrap_or(3000);
291
292 let deploy_script = format!(
293 r#"set -e
@@ -284,17 +301,17 @@ fi
301
302 cd "$APP_PATH"
303
287 -echo "Starting/restarting $PM2_APP with pm2..."
304 +echo "Starting $PM2_APP with pm2..."
305 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
306 + pm2 delete "$PM2_APP"
307 fi
308 +PORT={port} HOSTNAME=127.0.0.1 pm2 start node --name "$PM2_APP" -- server.js
309 pm2 save
310 echo "Done."
311 "#,
312 remote_path = remote_path,
313 pm2_app = pm2_app,
314 + port = port,
315 );
316
317 let mut restart_spinner = Spinner::new(
@@ -333,6 +350,8 @@ echo "Done."
350 "bash -s",
351 ])
352 .stdin(Stdio::piped())
353 + .stdout(Stdio::piped())
354 + .stderr(Stdio::piped())
355 .spawn()
356 .map_err(|e| anyhow!(fail_message(&format!("Failed to spawn SSH: {}", e))))?;
357
@@ -342,17 +361,26 @@ echo "Done."
361 .map_err(|e| anyhow!("Failed to write deploy script to SSH stdin: {}", e))?;
362 }
363
345 - let ssh_status = child
346 - .wait()
364 + // stdin was dropped at the end of the if-let block above, sending EOF to
365 + // remote bash. wait_with_output() reads stdout/stderr then waits for exit.
366 + let ssh_output = child
367 + .wait_with_output()
368 .map_err(|e| anyhow!("Failed to wait for SSH process: {}", e))?;
369
370 drop(ssh_known_hosts_file);
371
351 - if !ssh_status.success() {
352 - restart_spinner.stop_and_persist(
353 - &fail_symbol(),
354 - fail_message("Remote restart failed. See output above."),
355 - );
372 + if !ssh_output.status.success() {
373 + restart_spinner.stop_and_persist(&fail_symbol(), fail_message("Remote restart failed."));
374 + let stderr = String::from_utf8_lossy(&ssh_output.stderr);
375 + let stdout = String::from_utf8_lossy(&ssh_output.stdout);
376 + let details = if !stderr.trim().is_empty() {
377 + stderr
378 + } else {
379 + stdout
380 + };
381 + if !details.trim().is_empty() {
382 + eprintln!("{}", details.trim());
383 + }
384 mark_failed(
385 &deploy_ref,
386 &created_deployment,
@@ -363,7 +391,7 @@ echo "Done."
391 .await;
392 return Err(anyhow!(fail_message(&format!(
393 "SSH deploy script exited with status {}",
366 - ssh_status
394 + ssh_output.status
395 ))));
396 }
397
crates/smbcloud-model/src/project.rs
+3
@@ -68,6 +68,9 @@ pub struct Project {
68 /// PM2 process name to restart after a nextjs-ssr deploy, e.g. "my-app".
69 /// Matches the name passed to `pm2 start` on the server.
70 pub pm2_app: Option<String>,
71 + /// Port the standalone server binds to (default: 3000). Must match nginx upstream configuration.
72 + #[serde(default)]
73 + pub port: Option<u16>,
74 /// Path to a shared lib directory to rsync to the server before deploying,
75 /// e.g. "lib". Used by Rails apps that depend on native gems built from
76 /// monorepo-level source. Relative to the repo root.