@akash-chowdhury-24/deployhub 2.0.36 → 2.0.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -753,6 +753,51 @@ You can enable **multiple providers** — DeployHub uploads to all of them in pa
753
753
 
754
754
  ---
755
755
 
756
+ ## Custom deploy hooks
757
+
758
+ SSH-based deploys (`ssh`, `ec2`, `azure-vm`, `gcp-vm`, and `docker` with `remote.mode: "ssh"`) can run your own commands on the remote host as part of deploy and rollback. Use them for migrations, cache clearing, or a notification — anything you currently SSH in to do by hand.
759
+
760
+ | Hook | When it runs | Typical use | Failure default |
761
+ |------|----------------|-------------|-----------------|
762
+ | `preDeploy` | After the artifact/image is ready and the host is reachable, **before** the running app/container is replaced or started | DB migrations that must finish before new code runs | Abort the deploy (`continueOnError: false`) |
763
+ | `postDeploy` | After the app is up (SSH start / docker-ssh port publish) | Cache warm, Slack ping, non-critical cleanup | Continue (`continueOnError: true` when added via `init` / `env add`) |
764
+ | `rollback` | During `deployhub rollback`, in the same slot as `preDeploy` — before the restored version takes over | Your own down-migration. DeployHub does not reverse migrations for you | Abort the rollback (`continueOnError: false`) |
765
+
766
+ Commands run over the **existing** SSH session (not a second connection), unless a successful hook sets `"reconnect": true`. Kubernetes and Docker `local` / `raw` modes do not support hooks (no persistent remote shell session); configuring them there fails loudly at deploy/rollback.
767
+
768
+ On docker-ssh, `preDeploy` / `rollback` run **before** remote registry login and `docker stop`/`run`, so a hook can install Docker on a bare host. `postDeploy` runs **after** the port-publish inspect check (`docker inspect` confirms `0.0.0.0:<port>->`). A hook that curls the app's published port therefore sees a container DeployHub already treated as published. If that inspect fails, `postDeploy` does not run.
769
+
770
+ Hook `command` strings are raw remote shell — there is **no** `{{buildId}}` / `{{containerName}}` / `{{port}}` / `{{environment}}` substitution. Hardcode values per environment (or read them from the remote environment). Extra Docker environments use an env-scoped container name (`{project}-{env}`; the first/grandfathered env stays `{project}`), so a hook that `docker exec myapp …` on staging will miss `myapp-staging`.
771
+
772
+ Hook commands that look like they embed a secret (`--password`, `-p secret`, `TOKEN=…`) are not printed in full in the `$ …` log line. This is a best-effort check, not encryption: hook stdout/stderr is still logged, so prefer env vars on the remote host over inline secrets.
773
+
774
+ Set `"reconnect": true` on a hook when the command only takes effect on a **new SSH login** — the usual case is `sudo usermod -aG docker $USER`. After that command succeeds, DeployHub closes the current session and opens a new one before the next hook or deploy step. Failed commands never reconnect. Omitted / `false` (the default) never reconnects.
775
+
776
+ `init` and `env add` ask optionally — default is skip. After each command they ask **Add another … command?** so one stage can collect several entries (no cap), and whether that command needs an SSH reconnect (`[y/N]`, default N). `--yes` / non-interactive env add still writes no hooks. Example:
777
+
778
+ ```json
779
+ "environments": {
780
+ "production": {
781
+ "config": {
782
+ "hooks": {
783
+ "preDeploy": [
784
+ { "command": "sudo usermod -aG docker ec2-user", "reconnect": true },
785
+ { "command": "docker exec myapp python manage.py migrate", "continueOnError": false, "timeoutMs": 60000 }
786
+ ],
787
+ "postDeploy": [
788
+ { "command": "curl -s https://hooks.slack.com/services/T000/B000/xxx -d deployed", "continueOnError": true }
789
+ ],
790
+ "rollback": [
791
+ { "command": "docker exec myapp python manage.py migrate 0042_previous", "continueOnError": false }
792
+ ]
793
+ }
794
+ }
795
+ }
796
+ }
797
+ ```
798
+
799
+ ---
800
+
756
801
  ## Choosing a deployment method
757
802
 
758
803
  DeployHub supports six deployment targets. Pick based on what infrastructure you already have — DeployHub does not provision servers, VMs, or clusters for you.
@@ -808,6 +853,8 @@ sudo usermod -aG docker your-ssh-user
808
853
 
809
854
  Then **reconnect** (group membership applies on the next login). `deployhub doctor` reports this if missing (it prints the exact `usermod` line). See [Docker](#docker) below.
810
855
 
856
+ You can also put that bootstrap in **preDeploy hooks** on a bare host (install Docker, `usermod`, `"reconnect": true`). Registry login runs **after** preDeploy, so those hooks get a chance to install `docker` before `docker login`. Failed login used to abort first (`docker: command not found`) and skip the hooks.
857
+
811
858
  ### SSH
812
859
 
813
860
  **Verification:** Real-world verified DEPLOY and ROLLBACK.
@@ -857,7 +904,7 @@ Then **reconnect** (group membership applies on the next login). `deployhub doct
857
904
  - [ ] Docker installed (`docker --version` works) — on this machine / CI for **local** and **raw**; on the remote Linux host for **ssh**
858
905
  - [ ] Registry account if pushing private images
859
906
  - [ ] `docker-compose.yml` in project if you use multi-service Compose (not auto-generated)
860
- - [ ] **SSH mode only:** [one-time server setup](#one-time-server-setup-before-your-first-deploy) — Docker on the host and the SSH user in the `docker` group (`sudo usermod -aG docker <user>`, then reconnect)
907
+ - [ ] **SSH mode only:** [one-time server setup](#one-time-server-setup-before-your-first-deploy) — Docker on the host and the SSH user in the `docker` group (`sudo usermod -aG docker <user>`, then reconnect). A `preDeploy` hook can install Docker and run that `usermod` with `"reconnect": true`; registry login happens after preDeploy.
861
908
 
862
909
  `init` and `env add` ask **Where should the container run?**
863
910
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akash-chowdhury-24/deployhub",
3
- "version": "2.0.36",
3
+ "version": "2.0.40",
4
4
  "description": "Zero-configuration deployment and artifact manager",
5
5
  "type": "module",
6
6
  "main": "./src/cli/index.js",
@@ -53,6 +53,7 @@ import {
53
53
  pickPhpFpmUnitName,
54
54
  preferredPhpFpmUnitName,
55
55
  } from '../utils/php-fpm.js';
56
+ import { getHooksDoctorChecks } from '../deployment/hooks.js';
56
57
 
57
58
  /**
58
59
  * @typedef {{ name: string, pass: boolean, message: string }} CheckResult
@@ -1409,6 +1410,10 @@ export function registerDoctorCommand(program) {
1409
1410
  informationalCheckNames.add(branchCheck.name);
1410
1411
  results.push(await runCheck(branchCheck.name, async () => branchCheck));
1411
1412
  }
1413
+ for (const hookCheck of getHooksDoctorChecks(config)) {
1414
+ informationalCheckNames.add(hookCheck.name);
1415
+ results.push(await runCheck(hookCheck.name, async () => hookCheck));
1416
+ }
1412
1417
  const driftChecks = await getWorkflowDriftDoctorChecks(cwd, config);
1413
1418
  for (const check of driftChecks) {
1414
1419
  results.push(await runCheck(check.name, async () => check));
@@ -29,6 +29,13 @@ const SideConfigSchema = z.object({
29
29
  port: z.number().optional(),
30
30
  });
31
31
 
32
+ const HookCommandSchema = z.object({
33
+ command: z.string().min(1),
34
+ continueOnError: z.boolean().optional(),
35
+ timeoutMs: z.number().positive().optional(),
36
+ reconnect: z.boolean().optional(),
37
+ });
38
+
32
39
  /** Method-specific non-secret settings (host, paths, namespace, image name, etc.). */
33
40
  const MethodConfigSchema = z
34
41
  .object({
@@ -66,6 +73,17 @@ const MethodConfigSchema = z
66
73
  })
67
74
  .optional(),
68
75
  healthCheckUrl: z.string().optional(),
76
+ /**
77
+ * Remote shell hooks for SSH-based methods (ssh / ec2 / azure-vm / gcp-vm /
78
+ * docker remote.mode ssh). Rejected on kubernetes and docker local/raw.
79
+ */
80
+ hooks: z
81
+ .object({
82
+ preDeploy: z.array(HookCommandSchema).optional(),
83
+ postDeploy: z.array(HookCommandSchema).optional(),
84
+ rollback: z.array(HookCommandSchema).optional(),
85
+ })
86
+ .optional(),
69
87
  appName: z.string().optional(),
70
88
  framework: z.string().optional(),
71
89
  port: z.number().optional(),
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Per-environment remote shell hooks (preDeploy / postDeploy / rollback).
3
+ * One implementation for ssh, ec2, azure-vm, gcp-vm, and docker remote.mode ssh.
4
+ * Callers must pass the deploy's existing SSH session — do not connect again.
5
+ */
6
+
7
+ import { createLogger } from '../logger/index.js';
8
+ import { formatRemoteCommandFailure } from '../utils/shell-quote.js';
9
+ import { getEnvMethod, getEnvSettings } from '../core/environments.js';
10
+ import { resolveDockerRemoteMode } from '../utils/docker-remote-mode.js';
11
+
12
+ /** @typedef {'preDeploy'|'postDeploy'|'rollback'} HookStage */
13
+
14
+ /** @type {HookStage[]} */
15
+ export const HOOK_STAGES = ['preDeploy', 'postDeploy', 'rollback'];
16
+
17
+ const SSH_BASED_METHODS = new Set(['ssh', 'ec2', 'azure-vm', 'gcp-vm']);
18
+
19
+ /**
20
+ * @param {unknown} command
21
+ * @returns {boolean}
22
+ */
23
+ export function commandLooksSensitive(command) {
24
+ const c = String(command || '');
25
+ if (/(?:^|\s)(--password|--passwd|--secret|--token|--api-key)(?:=|\s+)\S+/i.test(c)) {
26
+ return true;
27
+ }
28
+ // `-p secret` but not `-p 22` (SSH port) or `-p22`.
29
+ if (/(?:^|\s)-p\s+(?!-)(?!\d+\b)\S+/.test(c)) {
30
+ return true;
31
+ }
32
+ if (/(?:PASSWORD|SECRET_ACCESS_KEY|SECRET|TOKEN|API_KEY)\s*=\s*\S+/i.test(c)) {
33
+ return true;
34
+ }
35
+ return false;
36
+ }
37
+
38
+ /**
39
+ * @param {string} command
40
+ * @returns {string}
41
+ */
42
+ export function formatHookCommandForLog(command) {
43
+ if (commandLooksSensitive(command)) {
44
+ return '<command withheld — possible credential in hook string>';
45
+ }
46
+ return command;
47
+ }
48
+
49
+ /**
50
+ * @param {unknown} raw
51
+ * @returns {{ command: string, continueOnError: boolean, reconnect: boolean, timeoutMs?: number }[]}
52
+ */
53
+ function normalizeHookList(raw) {
54
+ if (!Array.isArray(raw)) return [];
55
+ /** @type {{ command: string, continueOnError: boolean, reconnect: boolean, timeoutMs?: number }[]} */
56
+ const out = [];
57
+ for (const item of raw) {
58
+ if (!item || typeof item !== 'object') continue;
59
+ const command = /** @type {Record<string, unknown>} */ (item).command;
60
+ if (typeof command !== 'string' || !command.trim()) continue;
61
+ const timeoutRaw = /** @type {Record<string, unknown>} */ (item).timeoutMs;
62
+ const timeoutMs =
63
+ typeof timeoutRaw === 'number' && Number.isFinite(timeoutRaw) && timeoutRaw > 0
64
+ ? timeoutRaw
65
+ : undefined;
66
+ out.push({
67
+ command: command.trim(),
68
+ continueOnError: /** @type {Record<string, unknown>} */ (item).continueOnError === true,
69
+ reconnect: /** @type {Record<string, unknown>} */ (item).reconnect === true,
70
+ ...(timeoutMs != null ? { timeoutMs } : {}),
71
+ });
72
+ }
73
+ return out;
74
+ }
75
+
76
+ /**
77
+ * @param {Record<string, unknown>} [settings]
78
+ * @returns {{ preDeploy: ReturnType<typeof normalizeHookList>, postDeploy: ReturnType<typeof normalizeHookList>, rollback: ReturnType<typeof normalizeHookList> }}
79
+ */
80
+ export function getEnvHooks(settings = {}) {
81
+ const raw = settings.hooks;
82
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
83
+ return { preDeploy: [], postDeploy: [], rollback: [] };
84
+ }
85
+ const h = /** @type {Record<string, unknown>} */ (raw);
86
+ return {
87
+ preDeploy: normalizeHookList(h.preDeploy),
88
+ postDeploy: normalizeHookList(h.postDeploy),
89
+ rollback: normalizeHookList(h.rollback),
90
+ };
91
+ }
92
+
93
+ /**
94
+ * @param {Record<string, unknown>} [settings]
95
+ * @returns {boolean}
96
+ */
97
+ export function envHasAnyHooks(settings = {}) {
98
+ const h = getEnvHooks(settings);
99
+ return h.preDeploy.length + h.postDeploy.length + h.rollback.length > 0;
100
+ }
101
+
102
+ /**
103
+ * @param {string|undefined} method
104
+ * @param {Record<string, unknown>} [settings]
105
+ * @returns {boolean}
106
+ */
107
+ export function hooksSupportedForMethod(method, settings = {}) {
108
+ if (SSH_BASED_METHODS.has(String(method || ''))) return true;
109
+ if (method === 'docker') {
110
+ return resolveDockerRemoteMode(settings) === 'ssh';
111
+ }
112
+ return false;
113
+ }
114
+
115
+ /**
116
+ * Throw if hooks are configured on a method that cannot run them.
117
+ * No-op when no hooks are set (additive — existing deploys unchanged).
118
+ *
119
+ * @param {string|undefined} method
120
+ * @param {Record<string, unknown>} [settings]
121
+ * @param {string} [envName]
122
+ */
123
+ export function assertHooksAllowed(method, settings = {}, envName = 'this environment') {
124
+ if (!envHasAnyHooks(settings)) return;
125
+ if (hooksSupportedForMethod(method, settings)) return;
126
+
127
+ let why;
128
+ if (method === 'kubernetes') {
129
+ why =
130
+ 'Kubernetes deploys via kubectl, not a persistent remote SSH session.';
131
+ } else if (method === 'docker') {
132
+ why =
133
+ `Docker remote.mode "${resolveDockerRemoteMode(settings)}" has no DeployHub-managed SSH session (hooks require remote.mode "ssh").`;
134
+ } else {
135
+ why = `Method "${method}" does not support remote shell hooks.`;
136
+ }
137
+ throw new Error(
138
+ `Hooks are configured for environment "${envName}" but are not supported: ${why} ` +
139
+ `Remove environments.${envName}.config.hooks or use ssh / ec2 / azure-vm / gcp-vm / docker (remote.mode ssh).`
140
+ );
141
+ }
142
+
143
+ /**
144
+ * Run one hook stage on an already-open SSH session.
145
+ *
146
+ * @param {{
147
+ * session: { execUnchecked: Function, defaultExecTimeoutMs: number, reconnect?: Function },
148
+ * ssh: unknown,
149
+ * settings: Record<string, unknown>,
150
+ * stage: HookStage,
151
+ * }} opts
152
+ * @returns {Promise<unknown>} the SSH connection to keep using (replaced after reconnect)
153
+ */
154
+ export async function runDeployHooks(opts) {
155
+ let { session, ssh, settings, stage } = opts;
156
+ const list = getEnvHooks(settings)[stage] || [];
157
+ if (list.length === 0) return ssh;
158
+
159
+ const log = createLogger(`hook:${stage}`);
160
+ for (const hook of list) {
161
+ const timeoutMs = hook.timeoutMs ?? session.defaultExecTimeoutMs;
162
+ log.info(`$ ${formatHookCommandForLog(hook.command)}`);
163
+
164
+ /** @type {{ code?: number|null, stdout?: string, stderr?: string }} */
165
+ let result;
166
+ try {
167
+ result = await session.execUnchecked(ssh, hook.command, {
168
+ timeoutMs,
169
+ logCommand: false,
170
+ });
171
+ } catch (err) {
172
+ const msg = err instanceof Error ? err.message : String(err);
173
+ const timedOut = /timed out after/i.test(msg);
174
+ const wrapped = timedOut
175
+ ? `${stage} hook timed out after ${timeoutMs}ms`
176
+ : `${stage} hook failed: ${msg}`;
177
+ if (hook.continueOnError) {
178
+ log.warn(wrapped);
179
+ continue;
180
+ }
181
+ log.error(wrapped);
182
+ throw new Error(wrapped);
183
+ }
184
+
185
+ const out = String(result.stdout || '').trim();
186
+ if (out) {
187
+ for (const line of out.split(/\r?\n/)) {
188
+ log.info(line);
189
+ }
190
+ }
191
+
192
+ if (result.code !== 0 && result.code !== null && result.code !== undefined) {
193
+ const failure = formatRemoteCommandFailure(
194
+ hook.command,
195
+ result.code,
196
+ result.stderr,
197
+ result.stdout
198
+ );
199
+ const wrapped = `${stage} hook failed: ${failure}`;
200
+ if (hook.continueOnError) {
201
+ log.warn(wrapped);
202
+ continue;
203
+ }
204
+ log.error(wrapped);
205
+ throw new Error(wrapped);
206
+ }
207
+
208
+ if (hook.reconnect) {
209
+ log.info('Reconnecting SSH session after hook...');
210
+ if (typeof session.reconnect !== 'function') {
211
+ throw new Error(
212
+ `${stage} hook reconnect failed: SSH session does not support reconnect`
213
+ );
214
+ }
215
+ try {
216
+ ssh = await session.reconnect(ssh);
217
+ } catch (err) {
218
+ const msg = err instanceof Error ? err.message : String(err);
219
+ const wrapped = `${stage} hook reconnect failed: ${msg}`;
220
+ log.error(wrapped);
221
+ throw new Error(wrapped);
222
+ }
223
+ }
224
+ }
225
+ return ssh;
226
+ }
227
+
228
+ /**
229
+ * Informational doctor lines — pass: true, never blocks.
230
+ *
231
+ * @param {Record<string, unknown>} config
232
+ * @returns {{ name: string, pass: boolean, message: string }[]}
233
+ */
234
+ export function getHooksDoctorChecks(config) {
235
+ const envs = /** @type {Record<string, unknown>} */ (config.environments || {});
236
+ /** @type {{ name: string, pass: boolean, message: string }[]} */
237
+ const checks = [];
238
+ for (const [name, entry] of Object.entries(envs)) {
239
+ const settings = getEnvSettings(entry);
240
+ if (!envHasAnyHooks(settings)) continue;
241
+ const h = getEnvHooks(settings);
242
+ const parts = [];
243
+ if (h.preDeploy.length) parts.push(`${h.preDeploy.length} preDeploy`);
244
+ if (h.postDeploy.length) parts.push(`${h.postDeploy.length} postDeploy`);
245
+ if (h.rollback.length) parts.push(`${h.rollback.length} rollback`);
246
+ const method = getEnvMethod(entry);
247
+ const supported = hooksSupportedForMethod(method, settings);
248
+ checks.push({
249
+ name: `Hooks (${name})`,
250
+ pass: true,
251
+ message: supported
252
+ ? `Hooks configured for '${name}': ${parts.join(', ')}`
253
+ : `Hooks configured for '${name}' (${parts.join(', ')}) but ${method} does not run them — remove config.hooks or use an SSH-based method`,
254
+ });
255
+ }
256
+ return checks;
257
+ }
258
+
259
+ export default {
260
+ HOOK_STAGES,
261
+ commandLooksSensitive,
262
+ formatHookCommandForLog,
263
+ getEnvHooks,
264
+ envHasAnyHooks,
265
+ hooksSupportedForMethod,
266
+ assertHooksAllowed,
267
+ runDeployHooks,
268
+ getHooksDoctorChecks,
269
+ };
@@ -8,7 +8,9 @@ import { createLogger } from '../logger/index.js';
8
8
  import {
9
9
  getEnabledEnvironmentNames,
10
10
  getEnvMethod,
11
+ getEnvSettings,
11
12
  } from '../core/environments.js';
13
+ import { assertHooksAllowed } from './hooks.js';
12
14
  import { applyEnvSecretOverlay } from './deployment-env.js';
13
15
  import { recordEnvDeployment } from '../storage/index.js';
14
16
  import { buildArtifactRemoteKey } from '../utils/build-id.js';
@@ -81,6 +83,7 @@ export async function deployToAll(config, artifactDir, envNames) {
81
83
  }
82
84
 
83
85
  const method = getEnvMethod(envConfig);
86
+ assertHooksAllowed(method, getEnvSettings(envConfig), envName);
84
87
  const provider = getDeploymentProvider(method, config, envName);
85
88
  log.info(`Deploying to ${envName} (${method})...`);
86
89
  await provider.deploy(artifactDir);
@@ -128,7 +131,9 @@ export async function rollbackAll(config, artifactDir, envNames, meta) {
128
131
  const targets = envNames || getEnabledEnvironmentNames(config);
129
132
  for (const envName of targets) {
130
133
  const envConfig = config.environments[envName];
131
- const provider = getDeploymentProvider(getEnvMethod(envConfig), config, envName);
134
+ const method = getEnvMethod(envConfig);
135
+ assertHooksAllowed(method, getEnvSettings(envConfig), envName);
136
+ const provider = getDeploymentProvider(method, config, envName);
132
137
  await provider.rollback(artifactDir, meta);
133
138
  }
134
139
  }
@@ -153,7 +153,137 @@ export async function promptServerDeployment(
153
153
  }
154
154
 
155
155
  const triggerMeta = await promptTriggerAndBranch(options);
156
- return { ...methodAnswers, ...triggerMeta };
156
+ const hookMeta = await promptDeployHooksIfSupported(deployType, methodAnswers);
157
+ return { ...methodAnswers, ...triggerMeta, ...hookMeta };
158
+ }
159
+
160
+ /**
161
+ * Optional pre/post/rollback commands. Skipped unless the method has a
162
+ * DeployHub-managed SSH session (ssh / ec2 / azure-vm / gcp-vm / docker-ssh).
163
+ *
164
+ * @param {string} deployType
165
+ * @param {Record<string, unknown>} methodAnswers
166
+ */
167
+ async function promptDeployHooksIfSupported(deployType, methodAnswers) {
168
+ const sshBased = SSH_BASED.includes(deployType);
169
+ const dockerSsh = deployType === 'docker' && methodAnswers.remoteMode === 'ssh';
170
+ if (!sshBased && !dockerSsh) {
171
+ return {};
172
+ }
173
+ return promptDeployHooks();
174
+ }
175
+
176
+ /**
177
+ * Collect zero or more commands for one hook stage. First confirm defaults
178
+ * to no (skippable). "Add another?" also defaults to no. No cap — the
179
+ * executor already runs the array in order.
180
+ *
181
+ * @param {{
182
+ * firstConfirmName: string,
183
+ * firstMessage: string,
184
+ * anotherMessage: string,
185
+ * commandMessage: string,
186
+ * abortMessage: string,
187
+ * abortDefault: boolean,
188
+ * }} opts
189
+ * @returns {Promise<{ command: string, continueOnError: boolean }[]>}
190
+ */
191
+ async function promptHookStageCommands(opts) {
192
+ const firstName = opts.firstConfirmName;
193
+ const firstAnswers = await inquirer.prompt([
194
+ {
195
+ type: 'confirm',
196
+ name: firstName,
197
+ message: opts.firstMessage,
198
+ default: false,
199
+ },
200
+ ]);
201
+ if (!firstAnswers[firstName]) return [];
202
+
203
+ /** @type {{ command: string, continueOnError: boolean }[]} */
204
+ const list = [];
205
+ for (;;) {
206
+ const { command, abortOnFailure, reconnect } = await inquirer.prompt([
207
+ {
208
+ type: 'input',
209
+ name: 'command',
210
+ message: opts.commandMessage,
211
+ validate: (input) =>
212
+ String(input || '').trim() ? true : 'Enter a command to run on the remote host.',
213
+ },
214
+ {
215
+ type: 'confirm',
216
+ name: 'abortOnFailure',
217
+ message: opts.abortMessage,
218
+ default: opts.abortDefault,
219
+ },
220
+ {
221
+ type: 'confirm',
222
+ name: 'reconnect',
223
+ message:
224
+ 'Does this command require reconnecting the SSH session afterward? (e.g. it changes group membership or shell environment)',
225
+ default: false,
226
+ },
227
+ ]);
228
+ /** @type {{ command: string, continueOnError: boolean, reconnect?: boolean }} */
229
+ const entry = {
230
+ command: String(command).trim(),
231
+ continueOnError: abortOnFailure !== true,
232
+ };
233
+ if (reconnect === true) entry.reconnect = true;
234
+ list.push(entry);
235
+
236
+ const { addAnother } = await inquirer.prompt([
237
+ {
238
+ type: 'confirm',
239
+ name: 'addAnother',
240
+ message: opts.anotherMessage,
241
+ default: false,
242
+ },
243
+ ]);
244
+ if (!addAnother) break;
245
+ }
246
+ return list;
247
+ }
248
+
249
+ /**
250
+ * @returns {Promise<{ hooks?: Record<string, { command: string, continueOnError: boolean }[]> }>}
251
+ */
252
+ export async function promptDeployHooks() {
253
+ /** @type {Record<string, { command: string, continueOnError: boolean }[]>} */
254
+ const hooks = {};
255
+
256
+ const preDeploy = await promptHookStageCommands({
257
+ firstConfirmName: 'addPreDeploy',
258
+ firstMessage: 'Add a pre-deploy command? (e.g. run migrations)',
259
+ anotherMessage: 'Add another pre-deploy command?',
260
+ commandMessage: 'Pre-deploy command:',
261
+ abortMessage: 'Abort deploy if this command fails?',
262
+ abortDefault: true,
263
+ });
264
+ if (preDeploy.length) hooks.preDeploy = preDeploy;
265
+
266
+ const postDeploy = await promptHookStageCommands({
267
+ firstConfirmName: 'addPostDeploy',
268
+ firstMessage: 'Add a post-deploy command? (e.g. clear cache, notify)',
269
+ anotherMessage: 'Add another post-deploy command?',
270
+ commandMessage: 'Post-deploy command:',
271
+ abortMessage: 'Abort deploy if this command fails?',
272
+ abortDefault: false,
273
+ });
274
+ if (postDeploy.length) hooks.postDeploy = postDeploy;
275
+
276
+ const rollback = await promptHookStageCommands({
277
+ firstConfirmName: 'addRollback',
278
+ firstMessage: 'Add a rollback command? (e.g. reverse migration)',
279
+ anotherMessage: 'Add another rollback command?',
280
+ commandMessage: 'Rollback command:',
281
+ abortMessage: 'Abort rollback if this command fails?',
282
+ abortDefault: true,
283
+ });
284
+ if (rollback.length) hooks.rollback = rollback;
285
+
286
+ return Object.keys(hooks).length > 0 ? { hooks } : {};
157
287
  }
158
288
 
159
289
  /**
@@ -754,6 +884,7 @@ export function buildServerEnvEntry(
754
884
  if (Number.isInteger(n) && n >= 1 && n <= 65535) {
755
885
  settings.port = n;
756
886
  }
887
+ attachHooksFromAnswers(settings, deployAnswers);
757
888
  return withTriggerAndBranch(
758
889
  {
759
890
  enabled: true,
@@ -799,6 +930,7 @@ export function buildServerEnvEntry(
799
930
  settings.path = settings.deployPath;
800
931
  }
801
932
 
933
+ attachHooksFromAnswers(settings, deployAnswers);
802
934
  return withTriggerAndBranch(
803
935
  {
804
936
  enabled: true,
@@ -810,6 +942,20 @@ export function buildServerEnvEntry(
810
942
  );
811
943
  }
812
944
 
945
+ /**
946
+ * Copy optional hook answers onto the env method config.
947
+ * @param {Record<string, unknown>} settings
948
+ * @param {Record<string, unknown>} deployAnswers
949
+ */
950
+ function attachHooksFromAnswers(settings, deployAnswers) {
951
+ const raw = deployAnswers && deployAnswers.hooks;
952
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return;
953
+ const hooks = /** @type {Record<string, unknown>} */ (raw);
954
+ if (hooks.preDeploy || hooks.postDeploy || hooks.rollback) {
955
+ settings.hooks = hooks;
956
+ }
957
+ }
958
+
813
959
  /**
814
960
  * Overlay prompt answers onto the env entry. `--yes` / missing answers keep
815
961
  * trigger `manual` and omit `branch` (backward compatible).
@@ -5,6 +5,7 @@ import { resolveDockerImageRefForTag } from '../../utils/docker-image.js';
5
5
  import { resolveDockerContainerName } from '../../utils/docker-container-name.js';
6
6
  import { getEnvSettings, mergeMethodSettingsIntoEnv } from '../../core/environments.js';
7
7
  import { createSshExecSession } from '../ssh-connection.js';
8
+ import { assertHooksAllowed, runDeployHooks } from '../hooks.js';
8
9
  import { resolveDockerRemoteMode } from '../../utils/docker-remote-mode.js';
9
10
  import {
10
11
  resolveDockerSshTarget,
@@ -63,9 +64,10 @@ export function createDockerProvider(config, envName, env = process.env) {
63
64
 
64
65
  /**
65
66
  * @param {string} artifactDir
66
- * @param {{ fullImage?: string, skipImageReuse?: boolean }} [options]
67
+ * @param {{ fullImage?: string, skipImageReuse?: boolean, isRollback?: boolean }} [options]
67
68
  */
68
69
  async function deploy(artifactDir, options = {}) {
70
+ assertHooksAllowed('docker', settings, envName);
69
71
  const imageRef = options.fullImage || fullImage;
70
72
  log.info(`Deploying via Docker (image: ${imageRef})...`);
71
73
 
@@ -88,7 +90,9 @@ export function createDockerProvider(config, envName, env = process.env) {
88
90
  if (publishPort == null) {
89
91
  throw new Error(formatDockerSshPortRequired(envName));
90
92
  }
91
- await deployOverSsh(imageRef, publishPort);
93
+ await deployOverSsh(imageRef, publishPort, {
94
+ isRollback: options.isRollback === true,
95
+ });
92
96
  log.success('Docker deployment complete');
93
97
  return;
94
98
  }
@@ -151,16 +155,25 @@ export function createDockerProvider(config, envName, env = process.env) {
151
155
  /**
152
156
  * @param {string} imageRef
153
157
  * @param {number} port
158
+ * @param {{ isRollback?: boolean }} [options]
154
159
  */
155
- async function deployOverSsh(imageRef, port) {
160
+ async function deployOverSsh(imageRef, port, options = {}) {
161
+ const isRollback = options.isRollback === true;
156
162
  const cmds = buildRemoteDockerCommands(imageRef, containerName, {}, { publishPort: port });
157
163
  const session = sshSession();
158
- const ssh = await session.connect();
164
+ let ssh = await session.connect();
159
165
  try {
160
166
  const registryUser = imageEnv.DOCKER_REGISTRY_USERNAME || '';
161
167
  const registryToken = imageEnv.DOCKER_REGISTRY_TOKEN || '';
162
168
  const registryUrl = imageEnv.DOCKER_REGISTRY_URL || '';
163
169
 
170
+ ssh = await runDeployHooks({
171
+ session,
172
+ ssh,
173
+ settings,
174
+ stage: isRollback ? 'rollback' : 'preDeploy',
175
+ });
176
+
164
177
  if (registryUser && registryToken) {
165
178
  const registry = registryUrl || 'https://index.docker.io/v1/';
166
179
  log.info('Logging in to container registry on remote host...');
@@ -192,6 +205,10 @@ export function createDockerProvider(config, envName, env = process.env) {
192
205
  if (verdict.reason === 'published') {
193
206
  log.info(verdict.message);
194
207
  }
208
+
209
+ if (!isRollback) {
210
+ ssh = await runDeployHooks({ session, ssh, settings, stage: 'postDeploy' });
211
+ }
195
212
  } finally {
196
213
  ssh.dispose();
197
214
  }
@@ -220,6 +237,7 @@ export function createDockerProvider(config, envName, env = process.env) {
220
237
  await deploy(artifactDir, {
221
238
  fullImage: rollbackImage,
222
239
  skipImageReuse: true,
240
+ isRollback: true,
223
241
  });
224
242
  }
225
243
 
@@ -2,6 +2,7 @@ import path from 'path';
2
2
  import { createLogger } from '../../logger/index.js';
3
3
  import { getEnvSettings } from '../../core/config.js';
4
4
  import { createSshExecSession } from '../ssh-connection.js';
5
+ import { runDeployHooks } from '../hooks.js';
5
6
  import {
6
7
  getNginxSitesAvailablePath,
7
8
  getNginxSitesEnabledPath,
@@ -540,9 +541,12 @@ export function createSshProvider(config, envName, env = process.env) {
540
541
 
541
542
  /**
542
543
  * @param {string} artifactDir
544
+ * @param {{ isRollback?: boolean }} [options]
543
545
  */
544
- async function deploy(artifactDir) {
545
- const ssh = await connect();
546
+ async function deploy(artifactDir, options = {}) {
547
+ const isRollback = options.isRollback === true;
548
+ const preStage = isRollback ? 'rollback' : 'preDeploy';
549
+ let ssh = await connect();
546
550
  const projectType = config.projectType || 'frontend';
547
551
 
548
552
  try {
@@ -555,33 +559,40 @@ export function createSshProvider(config, envName, env = process.env) {
555
559
 
556
560
  if (projectType === 'both') {
557
561
  const remoteStaging = `/tmp/deployhub-staging-${Date.now()}`;
558
- await exec(ssh, `mkdir -p ${sh(remoteStaging)}`);
559
- await exec(ssh, `unzip -o ${sh(remoteZip)} -d ${sh(remoteStaging)}`);
560
-
561
- await ensureWritableDeployDir(ssh, frontendDeployPath);
562
- await exec(
563
- ssh,
564
- `rsync -a ${sh(remoteStaging)}/ ${sh(frontendDeployPath)}/ --exclude backend || cp -r ${sh(remoteStaging)}/* ${sh(frontendDeployPath)}/`
565
- );
566
-
567
- await ensureWritableDeployDir(ssh, backendDeployPath);
568
- await exec(
569
- ssh,
570
- `rsync -a ${sh(remoteStaging)}/backend/ ${sh(backendDeployPath)}/ || cp -r ${sh(remoteStaging)}/backend/* ${sh(backendDeployPath)}/`
571
- );
572
-
573
- if (await remoteFileExists(ssh, `${frontendDeployPath}/nginx.conf`)) {
574
- await setupNginx(ssh, frontendDeployPath);
562
+ try {
563
+ await exec(ssh, `mkdir -p ${sh(remoteStaging)}`);
564
+ await exec(ssh, `unzip -o ${sh(remoteZip)} -d ${sh(remoteStaging)}`);
565
+
566
+ ssh = await runDeployHooks({ session, ssh, settings, stage: preStage });
567
+
568
+ await ensureWritableDeployDir(ssh, frontendDeployPath);
569
+ await exec(
570
+ ssh,
571
+ `rsync -a ${sh(remoteStaging)}/ ${sh(frontendDeployPath)}/ --exclude backend || cp -r ${sh(remoteStaging)}/* ${sh(frontendDeployPath)}/`
572
+ );
573
+
574
+ await ensureWritableDeployDir(ssh, backendDeployPath);
575
+ await exec(
576
+ ssh,
577
+ `rsync -a ${sh(remoteStaging)}/backend/ ${sh(backendDeployPath)}/ || cp -r ${sh(remoteStaging)}/backend/* ${sh(backendDeployPath)}/`
578
+ );
579
+
580
+ if (await remoteFileExists(ssh, `${frontendDeployPath}/nginx.conf`)) {
581
+ await setupNginx(ssh, frontendDeployPath);
582
+ }
583
+
584
+ await runBackendStartSequence(ssh, backendDeployPath);
585
+ } finally {
586
+ await exec(ssh, `rm -rf ${sh(remoteStaging)}`).catch(() => {});
575
587
  }
576
-
577
- await runBackendStartSequence(ssh, backendDeployPath);
578
- await exec(ssh, `rm -rf ${sh(remoteStaging)}`);
579
588
  } else if (projectType === 'backend') {
580
589
  log.info(`Backend deploy path: ${deployPath}`);
590
+ ssh = await runDeployHooks({ session, ssh, settings, stage: preStage });
581
591
  await extractToPath(ssh, remoteZip, deployPath);
582
592
  await runBackendStartSequence(ssh, deployPath);
583
593
  } else {
584
594
  log.info(`Frontend deploy path: ${deployPath}`);
595
+ ssh = await runDeployHooks({ session, ssh, settings, stage: preStage });
585
596
  await extractToPath(ssh, remoteZip, deployPath);
586
597
 
587
598
  const framework = config.framework || 'react';
@@ -592,6 +603,10 @@ export function createSshProvider(config, envName, env = process.env) {
592
603
  }
593
604
  }
594
605
 
606
+ if (!isRollback) {
607
+ ssh = await runDeployHooks({ session, ssh, settings, stage: 'postDeploy' });
608
+ }
609
+
595
610
  await exec(ssh, `rm -f ${sh(remoteZip)}`);
596
611
  log.success('Deployment complete');
597
612
  } finally {
@@ -609,7 +624,7 @@ export function createSshProvider(config, envName, env = process.env) {
609
624
  }
610
625
 
611
626
  async function rollback(artifactDir, _meta) {
612
- await deploy(artifactDir);
627
+ await deploy(artifactDir, { isRollback: true });
613
628
  }
614
629
 
615
630
  async function healthCheck() {
@@ -71,14 +71,42 @@ export function createSshExecSession(opts) {
71
71
  return ssh;
72
72
  }
73
73
 
74
+ /**
75
+ * Close the current connection and open a new one with the same host/user/key.
76
+ * Used after a successful hook with `reconnect: true` (e.g. usermod -aG).
77
+ * Never returns the old session — on failure the previous connection is already closed.
78
+ *
79
+ * @param {import('node-ssh').NodeSSH} [oldSsh]
80
+ */
81
+ async function reconnect(oldSsh) {
82
+ try {
83
+ oldSsh?.dispose();
84
+ } catch {
85
+ // already closed
86
+ }
87
+ try {
88
+ const next = await connect();
89
+ log.info(`Reconnected SSH session to ${user}@${host}:${sshPort}`);
90
+ return next;
91
+ } catch (err) {
92
+ const msg = err instanceof Error ? err.message : String(err);
93
+ throw new Error(
94
+ `SSH reconnect failed to ${user}@${host}:${sshPort} — ${msg}. ` +
95
+ 'The previous session was closed; a new connection could not be opened.'
96
+ );
97
+ }
98
+ }
99
+
74
100
  /**
75
101
  * @param {import('node-ssh').NodeSSH} ssh
76
102
  * @param {string} command
77
- * @param {{ timeoutMs?: number }} [execOpts]
103
+ * @param {{ timeoutMs?: number, logCommand?: boolean }} [execOpts]
78
104
  */
79
105
  async function runCommand(ssh, command, execOpts = {}) {
80
106
  const timeoutMs = execOpts.timeoutMs ?? defaultExecTimeoutMs;
81
- log.info(`$ ${command}`);
107
+ if (execOpts.logCommand !== false) {
108
+ log.info(`$ ${command}`);
109
+ }
82
110
 
83
111
  /** @type {ReturnType<typeof setTimeout> | undefined} */
84
112
  let timer;
@@ -106,7 +134,7 @@ export function createSshExecSession(opts) {
106
134
  /**
107
135
  * @param {import('node-ssh').NodeSSH} ssh
108
136
  * @param {string} command
109
- * @param {{ timeoutMs?: number }} [execOpts]
137
+ * @param {{ timeoutMs?: number, logCommand?: boolean }} [execOpts]
110
138
  */
111
139
  async function exec(ssh, command, execOpts = {}) {
112
140
  const result = await runCommand(ssh, command, execOpts);
@@ -129,7 +157,7 @@ export function createSshExecSession(opts) {
129
157
  *
130
158
  * @param {import('node-ssh').NodeSSH} ssh
131
159
  * @param {string} command
132
- * @param {{ timeoutMs?: number }} [execOpts]
160
+ * @param {{ timeoutMs?: number, logCommand?: boolean }} [execOpts]
133
161
  */
134
162
  async function execUnchecked(ssh, command, execOpts = {}) {
135
163
  return runCommand(ssh, command, execOpts);
@@ -137,6 +165,7 @@ export function createSshExecSession(opts) {
137
165
 
138
166
  return {
139
167
  connect,
168
+ reconnect,
140
169
  exec,
141
170
  execUnchecked,
142
171
  host,