@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 +48 -1
- package/package.json +1 -1
- package/src/commands/doctor.js +5 -0
- package/src/core/config.js +18 -0
- package/src/deployment/hooks.js +269 -0
- package/src/deployment/index.js +6 -1
- package/src/deployment/init-prompts.js +147 -1
- package/src/deployment/providers/docker.js +22 -4
- package/src/deployment/providers/ssh.js +38 -23
- package/src/deployment/ssh-connection.js +33 -4
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
package/src/commands/doctor.js
CHANGED
|
@@ -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));
|
package/src/core/config.js
CHANGED
|
@@ -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
|
+
};
|
package/src/deployment/index.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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
|
-
|
|
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,
|