@akash-chowdhury-24/deployhub 2.0.41 → 2.0.44

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.
@@ -3,6 +3,11 @@ import { createLogger } from '../../logger/index.js';
3
3
  import { getEnvSettings } from '../../core/config.js';
4
4
  import { createSshExecSession } from '../ssh-connection.js';
5
5
  import { runDeployHooks } from '../hooks.js';
6
+ import {
7
+ readManagedEnvFileContent,
8
+ resolveSshManagedEnvRemotePaths,
9
+ uploadManagedEnvFile,
10
+ } from '../env-file.js';
6
11
  import {
7
12
  getNginxSitesAvailablePath,
8
13
  getNginxSitesEnabledPath,
@@ -75,7 +80,8 @@ export function createSshProvider(config, envName, env = process.env) {
75
80
  const { connect, exec, defaultExecTimeoutMs } = session;
76
81
  const startStopTimeoutMs = Math.min(
77
82
  defaultExecTimeoutMs,
78
- Number(env.DEPLOYHUB_SSH_START_TIMEOUT_MS) || 60_000
83
+ Number(env.DEPLOYHUB_SSH_START_TIMEOUT_MS || process.env.DEPLOYHUB_SSH_START_TIMEOUT_MS) ||
84
+ 60_000
79
85
  );
80
86
 
81
87
  /**
@@ -253,9 +259,23 @@ export function createSshProvider(config, envName, env = process.env) {
253
259
  await assertPidAliveAfterStart(ssh, targetPath);
254
260
  }
255
261
 
262
+ /**
263
+ * After the framework's package-manager install (and any built-in migrate
264
+ * / artisan steps), before stop/start. Same stage name on rollback so
265
+ * codegen re-runs against the restored files. No-op when the env has no
266
+ * `postInstall` hooks.
267
+ *
268
+ * @param {import('node-ssh').NodeSSH} ssh
269
+ * @returns {Promise<import('node-ssh').NodeSSH>}
270
+ */
271
+ async function runPostInstallHooks(ssh) {
272
+ return runDeployHooks({ session, ssh, settings, stage: 'postInstall' });
273
+ }
274
+
256
275
  /**
257
276
  * @param {import('node-ssh').NodeSSH} ssh
258
277
  * @param {string} targetPath
278
+ * @returns {Promise<import('node-ssh').NodeSSH>} possibly replaced after postInstall reconnect
259
279
  */
260
280
  async function runBackendStartSequence(ssh, targetPath) {
261
281
  const framework = resolveFramework();
@@ -265,6 +285,7 @@ export function createSshProvider(config, envName, env = process.env) {
265
285
 
266
286
  if (NODE_FRAMEWORKS.has(framework)) {
267
287
  await exec(ssh, `cd ${dir} && npm install --production`);
288
+ ssh = await runPostInstallHooks(ssh);
268
289
  const start = startCommand || 'npm start';
269
290
  if (start === 'npm start') {
270
291
  await exec(
@@ -285,7 +306,7 @@ export function createSshProvider(config, envName, env = process.env) {
285
306
  );
286
307
  }
287
308
  await exec(ssh, 'pm2 save');
288
- return;
309
+ return ssh;
289
310
  }
290
311
 
291
312
  if (PYTHON_FRAMEWORKS.has(framework)) {
@@ -293,6 +314,7 @@ export function createSshProvider(config, envName, env = process.env) {
293
314
  if (framework === 'django') {
294
315
  await exec(ssh, `cd ${dir} && python manage.py migrate`);
295
316
  }
317
+ ssh = await runPostInstallHooks(ssh);
296
318
  await stopScopedBackendProcess(ssh, targetPath);
297
319
  if (framework === 'fastapi') {
298
320
  await startScopedNohup(
@@ -318,7 +340,7 @@ export function createSshProvider(config, envName, env = process.env) {
318
340
  );
319
341
  await assertPidAliveAfterStart(ssh, targetPath, logFile);
320
342
  }
321
- return;
343
+ return ssh;
322
344
  }
323
345
 
324
346
  if (PHP_FRAMEWORKS.has(framework)) {
@@ -338,14 +360,17 @@ export function createSshProvider(config, envName, env = process.env) {
338
360
  await exec(ssh, `cd ${dir} && php artisan config:cache`);
339
361
  }
340
362
 
363
+ ssh = await runPostInstallHooks(ssh);
364
+
341
365
  const fpmUnit = await resolveRemotePhpFpmUnit(ssh, phpVersion);
342
366
  log.info(`Restarting PHP-FPM service: ${fpmUnit}`);
343
367
  await exec(ssh, `sudo systemctl restart ${sh(fpmUnit)}`);
344
368
  await reloadNginx(ssh);
345
- return;
369
+ return ssh;
346
370
  }
347
371
 
348
372
  if (framework === 'spring' || framework === 'java') {
373
+ ssh = await runPostInstallHooks(ssh);
349
374
  await stopScopedBackendProcess(ssh, targetPath);
350
375
  // -Ddeployhub.app= embeds the env-scoped identity in the JVM command line
351
376
  // so cmdline marker scans and the PID file both target only this env.
@@ -354,18 +379,20 @@ export function createSshProvider(config, envName, env = process.env) {
354
379
  targetPath,
355
380
  `java -Ddeployhub.app=${appName} -jar target/*.jar`
356
381
  );
357
- return;
382
+ return ssh;
358
383
  }
359
384
 
360
385
  if (framework === 'go') {
386
+ ssh = await runPostInstallHooks(ssh);
361
387
  await stopScopedBackendProcess(ssh, targetPath);
362
388
  // Binary is always ./bin/app — must NOT pkill by appName alone (that never
363
389
  // matched the process) and must NOT pkill a bare "app" (cross-env collision).
364
390
  await startScopedNohup(ssh, targetPath, './bin/app');
365
- return;
391
+ return ssh;
366
392
  }
367
393
 
368
394
  if (framework === 'dotnet') {
395
+ ssh = await runPostInstallHooks(ssh);
369
396
  await stopScopedBackendProcess(ssh, targetPath);
370
397
  // Discover the published DLL at runtime — csproj name is not always App.dll
371
398
  // (same class of bug as the Docker CMD ["dotnet","App.dll"] hardcode).
@@ -374,22 +401,25 @@ export function createSshProvider(config, envName, env = process.env) {
374
401
  targetPath,
375
402
  `sh -c 'dll=$(ls -1 *.dll 2>/dev/null | head -n1); test -n "$dll" || { echo "No .dll found in $(pwd) for .NET start" >&2; exit 1; }; exec dotnet "$dll"'`
376
403
  );
377
- return;
404
+ return ssh;
378
405
  }
379
406
 
380
407
  if (framework === 'rails' || framework === 'ruby') {
381
408
  await exec(ssh, `cd ${dir} && bundle install --deployment`);
409
+ ssh = await runPostInstallHooks(ssh);
382
410
  await stopScopedBackendProcess(ssh, targetPath);
383
411
  await startScopedNohup(ssh, targetPath, `bundle exec puma -b tcp://0.0.0.0:${port}`);
384
- return;
412
+ return ssh;
385
413
  }
386
414
 
387
415
  await exec(ssh, `cd ${dir} && npm install --production`);
416
+ ssh = await runPostInstallHooks(ssh);
388
417
  await exec(
389
418
  ssh,
390
419
  `cd ${dir} && pm2 restart ${sh(appName)} || pm2 start npm --name ${sh(appName)} -- start`
391
420
  );
392
421
  await exec(ssh, 'pm2 save');
422
+ return ssh;
393
423
  }
394
424
 
395
425
  /**
@@ -539,6 +569,33 @@ export function createSshProvider(config, envName, env = process.env) {
539
569
  await exec(ssh, `unzip -o ${sh(remoteZip)} -d ${sh(targetPath)}`);
540
570
  }
541
571
 
572
+ /**
573
+ * DeployHub-managed .env: SFTP the current GitHub Secret onto the target.
574
+ * Overwrites every deploy, including rollback — .env is the current secret,
575
+ * not versioned per buildId. Runs after unzip/rsync and before
576
+ * runBackendStartSequence (install → postInstall → start), so a postInstall
577
+ * hook can `cd {deployPath} && set -a && . ./.env && set +a && prisma generate`.
578
+ * No-op when envFileSecretName is unset (hand-placed .env then survives
579
+ * unzip -o, same as before).
580
+ *
581
+ * @param {import('node-ssh').NodeSSH} ssh
582
+ */
583
+ async function uploadManagedEnvIfConfigured(ssh) {
584
+ const content = readManagedEnvFileContent(env, settings);
585
+ if (content == null) return;
586
+ const paths = resolveSshManagedEnvRemotePaths(config, settings);
587
+ for (const remotePath of paths) {
588
+ await uploadManagedEnvFile({
589
+ ssh,
590
+ session,
591
+ content,
592
+ remotePath,
593
+ user,
594
+ log,
595
+ });
596
+ }
597
+ }
598
+
542
599
  /**
543
600
  * @param {string} artifactDir
544
601
  * @param {{ isRollback?: boolean }} [options]
@@ -546,6 +603,9 @@ export function createSshProvider(config, envName, env = process.env) {
546
603
  async function deploy(artifactDir, options = {}) {
547
604
  const isRollback = options.isRollback === true;
548
605
  const preStage = isRollback ? 'rollback' : 'preDeploy';
606
+ // Fail fast (before SSH): GitHub Actions injects "" when the secret was
607
+ // never added or the name is misspelled. Must not unzip then write empty .env.
608
+ readManagedEnvFileContent(env, settings);
549
609
  let ssh = await connect();
550
610
  const projectType = config.projectType || 'frontend';
551
611
 
@@ -577,11 +637,13 @@ export function createSshProvider(config, envName, env = process.env) {
577
637
  `rsync -a ${sh(remoteStaging)}/backend/ ${sh(backendDeployPath)}/ || cp -r ${sh(remoteStaging)}/backend/* ${sh(backendDeployPath)}/`
578
638
  );
579
639
 
640
+ await uploadManagedEnvIfConfigured(ssh);
641
+
580
642
  if (await remoteFileExists(ssh, `${frontendDeployPath}/nginx.conf`)) {
581
643
  await setupNginx(ssh, frontendDeployPath);
582
644
  }
583
645
 
584
- await runBackendStartSequence(ssh, backendDeployPath);
646
+ ssh = await runBackendStartSequence(ssh, backendDeployPath);
585
647
  } finally {
586
648
  await exec(ssh, `rm -rf ${sh(remoteStaging)}`).catch(() => {});
587
649
  }
@@ -589,15 +651,17 @@ export function createSshProvider(config, envName, env = process.env) {
589
651
  log.info(`Backend deploy path: ${deployPath}`);
590
652
  ssh = await runDeployHooks({ session, ssh, settings, stage: preStage });
591
653
  await extractToPath(ssh, remoteZip, deployPath);
592
- await runBackendStartSequence(ssh, deployPath);
654
+ await uploadManagedEnvIfConfigured(ssh);
655
+ ssh = await runBackendStartSequence(ssh, deployPath);
593
656
  } else {
594
657
  log.info(`Frontend deploy path: ${deployPath}`);
595
658
  ssh = await runDeployHooks({ session, ssh, settings, stage: preStage });
596
659
  await extractToPath(ssh, remoteZip, deployPath);
660
+ await uploadManagedEnvIfConfigured(ssh);
597
661
 
598
662
  const framework = config.framework || 'react';
599
663
  if (framework === 'nextjs') {
600
- await runBackendStartSequence(ssh, deployPath);
664
+ ssh = await runBackendStartSequence(ssh, deployPath);
601
665
  } else if (await remoteFileExists(ssh, `${deployPath}/nginx.conf`)) {
602
666
  await setupNginx(ssh, deployPath);
603
667
  }
@@ -28,8 +28,12 @@ export function createSshExecSession(opts) {
28
28
  const log = opts.log || { info() {}, error() {} };
29
29
 
30
30
  // Defense-in-depth: never let a stuck SSH channel hang CI indefinitely.
31
- // Override with DEPLOYHUB_SSH_EXEC_TIMEOUT_MS (ms).
32
- const defaultExecTimeoutMs = Number(env.DEPLOYHUB_SSH_EXEC_TIMEOUT_MS) || 120_000;
31
+ // Override with DEPLOYHUB_SSH_EXEC_TIMEOUT_MS (ms). Production default is 120s.
32
+ // Fall back to process.env so sparse credential overlays in tests/CI do not
33
+ // drop a process-level timeout override.
34
+ const defaultExecTimeoutMs =
35
+ Number(env.DEPLOYHUB_SSH_EXEC_TIMEOUT_MS || process.env.DEPLOYHUB_SSH_EXEC_TIMEOUT_MS) ||
36
+ 120_000;
33
37
 
34
38
  async function connect() {
35
39
  if (!host || !user) {
@@ -109,15 +109,36 @@ export function createDockerImageDeployContext(config, env = process.env, log) {
109
109
  if (!hasRegistryCredentials()) return;
110
110
  const registry = registryUrl || 'https://index.docker.io/v1/';
111
111
  log.info('Logging in to container registry...');
112
- await execa(
113
- 'docker',
114
- ['login', registry, '-u', registryUser, '--password-stdin'],
115
- {
116
- input: registryToken,
117
- stdio: ['pipe', 'inherit', 'inherit'],
118
- env: getDockerEnv(),
112
+ const attempts = 3;
113
+ const loopback = /^(127\.0\.0\.1|localhost|\[::1\])/i.test(String(registry));
114
+ let lastErr;
115
+ for (let i = 1; i <= attempts; i++) {
116
+ try {
117
+ await execa(
118
+ 'docker',
119
+ ['login', registry, '-u', registryUser, '--password-stdin'],
120
+ {
121
+ input: registryToken,
122
+ stdio: ['pipe', 'pipe', 'pipe'],
123
+ env: getDockerEnv(),
124
+ timeout: 60_000,
125
+ }
126
+ );
127
+ return;
128
+ } catch (err) {
129
+ lastErr = err;
130
+ const execErr = /** @type {{ message?: string, stderr?: string, stdout?: string }} */ (err);
131
+ const combined = `${execErr?.message || ''} ${execErr?.stderr || ''} ${execErr?.stdout || ''}`;
132
+ const transient =
133
+ /timeout|deadline exceeded|connection refused|tls handshake|i\/o timeout|eof|reset by peer|502|503/i.test(
134
+ combined
135
+ );
136
+ if (i === attempts || (!transient && !loopback)) throw err;
137
+ log.warn(`docker login attempt ${i}/${attempts} failed, retrying...`);
138
+ await new Promise((r) => setTimeout(r, 2000 * i));
119
139
  }
120
- );
140
+ }
141
+ throw lastErr;
121
142
  }
122
143
 
123
144
  /**
@@ -178,7 +178,7 @@ export async function probeRemoteDockerPs(target) {
178
178
  * @param {string} imageRef
179
179
  * @param {string} containerName
180
180
  * @param {Record<string, string>} [runEnv]
181
- * @param {{ publishPort?: number|null }} [options]
181
+ * @param {{ publishPort?: number|null, envFile?: string }} [options]
182
182
  */
183
183
  export function buildRemoteDockerCommands(imageRef, containerName, runEnv = {}, options = {}) {
184
184
  const image = shellQuote(imageRef);
@@ -192,12 +192,17 @@ export function buildRemoteDockerCommands(imageRef, containerName, runEnv = {},
192
192
  const publishPort = options.publishPort;
193
193
  const pFlag =
194
194
  publishPort != null ? `-p ${shellQuote(`${publishPort}:${publishPort}`)} ` : '';
195
+ const envFile = options.envFile;
196
+ const envFileFlag =
197
+ envFile != null && String(envFile).trim()
198
+ ? `--env-file ${shellQuote(String(envFile).trim())} `
199
+ : '';
195
200
 
196
201
  return {
197
202
  stop: `docker stop ${name} 2>/dev/null || true`,
198
203
  rm: `docker rm -f ${name} 2>/dev/null || true`,
199
204
  pull: `docker pull ${image}`,
200
- run: `docker run -d --rm --name ${name} ${pFlag}${envArg}${image}`,
205
+ run: `docker run -d --rm --name ${name} ${pFlag}${envFileFlag}${envArg}${image}`,
201
206
  ps: `docker ps --filter ${shellQuote(`name=^/${containerName}$`)} --format ${shellQuote('{{.Status}}')}`,
202
207
  info: 'docker info',
203
208
  /**