85
Ok(removed)
86
}
87
88
+/// Build the shell snippet that (re)starts the app after the standalone bundle
89
+/// is on the server.
90
+///
91
+/// Two supervisors are supported, selected by `process_manager`:
92
+/// - `None` / anything but `"systemd"` → **pm2** (default): delete + start
93
+/// fresh, preferring an operator-managed `ecosystem.config.cjs`/`.js`, else
94
+/// `pm2 start node -- server.js` with `PORT`/`HOSTNAME` inline.
95
+/// - `"systemd"` → a **git-owned `systemctl --user` service** named
96
+/// `<pm2_app>.service` (created out-of-band by the pm2-to-systemd migration).
97
+/// Restarted with no sudo — the deploy SSHes as the git app-owner, and
98
+/// `systemctl --user` (with `XDG_RUNTIME_DIR` set) drives that user's own
99
+/// manager. Fails loudly if the unit does not exist.
100
+///
101
+/// The returned snippet references the shell var `$PM2_APP` (set earlier in the
102
+/// deploy script). `port` is only used by the pm2 fallback path.
103
+fn restart_stanza(process_manager: Option<&str>, port: u16) -> String {
104
+ let use_systemd = process_manager
105
+ .map(|m| m.eq_ignore_ascii_case("systemd"))
106
+ .unwrap_or(false);
107
+
108
+ if use_systemd {
109
+ r#" echo "Restarting $PM2_APP via systemd --user…"
110
+ export XDG_RUNTIME_DIR="/run/user/$(id -u)"
111
+ UNIT="$PM2_APP.service"
112
+ if ! systemctl --user cat "$UNIT" >/dev/null 2>&1; then
113
+ echo "Error: systemd --user unit $UNIT not found on the server."
114
+ echo "Create it first with the pm2-to-systemd migration, then re-deploy."
115
+ exit 1
116
+ fi
117
+ systemctl --user restart "$UNIT"
118
+ sleep 3
119
+ if ! systemctl --user is-active "$UNIT" >/dev/null 2>&1; then
120
+ echo "Error: $UNIT failed to become active after restart."
121
+ systemctl --user status "$UNIT" --no-pager 2>&1 | head -20 || true
122
+ exit 1
123
+ fi
124
+ echo "Done."
125
+"#
126
+ .to_string()
127
+ } else {
128
+ format!(
129
+ r#" echo "Starting $PM2_APP with pm2..."
130
+ if pm2 describe "$PM2_APP" > /dev/null 2>&1; then
131
+ pm2 delete "$PM2_APP"
132
+ fi
133
+
134
+ if [ -f ecosystem.config.cjs ]; then
135
+ pm2 start ecosystem.config.cjs --only "$PM2_APP" --env production
136
+ elif [ -f ecosystem.config.js ]; then
137
+ pm2 start ecosystem.config.js --only "$PM2_APP" --env production
138
+ else
139
+ NODE_ENV=production PORT={port} HOSTNAME=127.0.0.1 pm2 start node --name "$PM2_APP" -- server.js
140
+ fi
141
+
142
+ pm2 save
143
+ echo "Done."
144
+"#
145
+ )
146
+ }
147
+}
148
+
149
/// Deploys a Next.js SSR app using standalone output mode.
150
///
151
/// Requires `output: 'standalone'` in next.config.js. This produces a
160
/// 4. rsync .next/standalone/ → server:path/ (server + bundled deps)
161
/// 5. rsync .next/static/ → server:path/.next/static/ (static chunks)
162
/// 6. rsync public/ → server:path/public/ (public assets)
102
-/// 7. SSH: pm2 delete + start fresh (prefer server `ecosystem.config.cjs` or `.js` if present)
163
+/// 7. SSH: restart the app — pm2 (default) or a git-owned systemd --user
164
+/// service when `process_manager = "systemd"` (see `restart_stanza`)
165
/// 8. PATCH deployment record as Done
166
pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Result<CommandResult> {
167
let source = config.project.source.as_deref().unwrap_or(".");
592
)
593
};
594
595
+ // Runtime supervisor: pm2 (default) or a git-owned systemd --user service.
596
+ let restart_stanza = restart_stanza(config.project.process_manager.as_deref(), port);
597
+
598
let deploy_script = format!(
599
r#"set -e
600
APP_PATH="{remote_path}"
727
ln -sfn ../../server.js .next/standalone/server.js
728
fi
729
665
- echo "Starting $PM2_APP with pm2..."
666
- if pm2 describe "$PM2_APP" > /dev/null 2>&1; then
667
- pm2 delete "$PM2_APP"
668
- fi
669
-
670
- if [ -f ecosystem.config.cjs ]; then
671
- pm2 start ecosystem.config.cjs --only "$PM2_APP" --env production
672
- elif [ -f ecosystem.config.js ]; then
673
- pm2 start ecosystem.config.js --only "$PM2_APP" --env production
674
- else
675
- NODE_ENV=production PORT={port} HOSTNAME=127.0.0.1 pm2 start node --name "$PM2_APP" -- server.js
676
- fi
677
-
678
- pm2 save
679
- echo "Done."
680
- "#,
730
+{restart} "#,
731
remote_path = remote_path,
732
pm2_app = pm2_app,
733
runtime_subdir = runtime_subdir_for_shell,
684
- port = port,
734
+ restart = restart_stanza,
735
ecosystem_config = ecosystem_config_content,
736
);
737
878
879
#[cfg(all(test, unix))]
880
mod tests {
831
- use {super::prune_dangling_symlinks, std::os::unix::fs::symlink, tempfile::tempdir};
881
+ use {
882
+ super::{prune_dangling_symlinks, restart_stanza},
883
+ std::os::unix::fs::symlink,
884
+ tempfile::tempdir,
885
+ };
886
887
/// Mirrors the real failure: a pnpm compat link points at a version dir that
888
/// was never traced into the bundle, so it dangles and must be pruned, while
969
"the dangling root symlink is gone"
970
);
971
}
972
+
973
+ #[test]
974
+ fn restart_stanza_defaults_to_pm2() {
975
+ for pm in [None, Some("pm2"), Some("PM2"), Some("other")] {
976
+ let s = restart_stanza(pm, 3025);
977
+ assert!(s.contains("pm2 start"), "pm={pm:?} should use pm2");
978
+ assert!(s.contains("PORT=3025"), "pm={pm:?} injects the port");
979
+ assert!(!s.contains("systemctl"), "pm={pm:?} must not touch systemd");
980
+ }
981
+ }
982
+
983
+ #[test]
984
+ fn restart_stanza_systemd_uses_user_unit_no_sudo() {
985
+ let s = restart_stanza(Some("systemd"), 3025);
986
+ assert!(s.contains("systemctl --user restart \"$UNIT\""));
987
+ assert!(s.contains("UNIT=\"$PM2_APP.service\""));
988
+ assert!(s.contains("XDG_RUNTIME_DIR=\"/run/user/$(id -u)\""));
989
+ // No sudo (git drives its own user manager) and no pm2 fallback.
990
+ assert!(!s.contains("sudo"), "must not require sudo");
991
+ assert!(!s.contains("pm2 "), "systemd path must not call pm2");
992
+ // Case-insensitive selector.
993
+ assert_eq!(s, restart_stanza(Some("SystemD"), 3025));
994
+ }
995
}