@akash-chowdhury-24/deployhub 2.0.14 → 2.0.16

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.
@@ -1,12 +1,19 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'path';
3
+ import yaml from 'js-yaml';
3
4
  import { getWorkflowHeaderComment } from './author.js';
4
5
  import {
5
6
  generateDeploymentEnvSection,
6
7
  getDeploymentWorkflowSecretKeys,
7
8
  getDeploymentSecretChecklistItems,
9
+ getDeploymentWorkflowSecretKeysForEnv,
10
+ getDeploymentSecretChecklistItemsForEnv,
11
+ shouldPrefixEnvSecrets,
12
+ prefixSecretKey,
13
+ envUsesPrefixedSecrets,
8
14
  DEPLOYMENT_ENV_KEYS,
9
15
  } from '../deployment/deployment-env.js';
16
+ import { getEnvMethod, getEnvTrigger, isEnvEnabled } from '../core/environments.js';
10
17
 
11
18
  /** @typedef {'aws'|'azure'|'gcp'|'gdrive'|'dropbox'|'local'|'ftp'|'ssh'} ProviderEnvKey */
12
19
 
@@ -217,7 +224,9 @@ const KUBECTL_VERSION = 'v1.30.4';
217
224
  * @returns {boolean}
218
225
  */
219
226
  function hasKubernetesDeploy(deployEnvironments, environments) {
220
- return deployEnvironments.some((envName) => environments[envName]?.type === 'kubernetes');
227
+ return deployEnvironments.some(
228
+ (envName) => getEnvMethod(environments[envName]) === 'kubernetes'
229
+ );
221
230
  }
222
231
 
223
232
  /**
@@ -262,46 +271,97 @@ function applyKubernetesWorkflowEnv(deployEnvironments, environments, envVars) {
262
271
  export const DEPLOY_WORKFLOW_FILENAME = 'deployhub.yml';
263
272
  export const ROLLBACK_WORKFLOW_FILENAME = 'deployhub-rollback.yml';
264
273
 
274
+ /**
275
+ * Upsert a `KEY: value` line into the workflow env set (LHS must be unique for valid YAML).
276
+ * @param {Set<string>} envVars
277
+ * @param {string} lhs
278
+ * @param {string} valueExpr
279
+ */
280
+ function upsertWorkflowEnvLine(envVars, lhs, valueExpr) {
281
+ const linePrefix = `${lhs}: `;
282
+ for (const entry of [...envVars]) {
283
+ if (entry.startsWith(linePrefix)) {
284
+ envVars.delete(entry);
285
+ }
286
+ }
287
+ envVars.add(`${linePrefix}${valueExpr}`);
288
+ }
289
+
265
290
  /**
266
291
  * Shared env entries for deploy and rollback workflows (same secret resolution).
267
- * Uses getDeploymentWorkflowSecretKeys(env.type) — no separate rollback list.
292
+ * Multi-env: secrets are prefixed (PRODUCTION_SSH_HOST) but mapped to unprefixed
293
+ * process env names the CLI already understands (SSH_HOST).
294
+ *
295
+ * Prefixed secret names are also exported as their own env vars so a single job can
296
+ * carry credentials for every environment; deploy/rollback overlays them per target.
268
297
  *
269
298
  * @param {string[]} storageProviders
270
299
  * @param {string[]} deployEnvironments
271
- * @param {Record<string, { type: string }>} environments
300
+ * @param {Record<string, { type?: string, method?: string }>} environments
272
301
  * @param {import('../core/config.js').DeployHubConfig} [config]
302
+ * @param {{ mapForEnv?: string|null }} [options] — when set, only map that env's secrets to unprefixed keys
273
303
  * @returns {Set<string>}
274
304
  */
275
305
  export function buildWorkflowEnvEntries(
276
306
  storageProviders,
277
307
  deployEnvironments,
278
308
  environments,
279
- config = null
309
+ config = null,
310
+ options = {}
280
311
  ) {
281
312
  /** @type {Set<string>} */
282
- const envVars = new Set(['DEPLOYHUB_ENV: production']);
313
+ const envVars = new Set([
314
+ `DEPLOYHUB_ENV: ${options.mapForEnv || deployEnvironments[0] || 'production'}`,
315
+ ]);
283
316
 
284
317
  for (const provider of storageProviders) {
285
318
  const keys = PROVIDER_ENV_MAP[provider] || [];
286
319
  for (const key of keys) {
287
- envVars.add(`${key}: \${{ secrets.${key} }}`);
320
+ upsertWorkflowEnvLine(envVars, key, `\${{ secrets.${key} }}`);
288
321
  }
289
322
  }
290
323
 
291
- for (const envName of deployEnvironments) {
324
+ const targets = options.mapForEnv
325
+ ? [options.mapForEnv]
326
+ : deployEnvironments;
327
+
328
+ const cfg = { ...(config || {}), environments };
329
+
330
+ for (const envName of targets) {
292
331
  const env = environments[envName];
293
332
  if (!env) continue;
294
333
 
295
- const keys = getDeploymentWorkflowSecretKeys(env.type, config);
296
- for (const key of keys) {
297
- envVars.add(`${key}: \${{ secrets.${key} }}`);
334
+ const method = getEnvMethod(env);
335
+ if (!method) continue;
336
+ const unprefixedKeys = getDeploymentWorkflowSecretKeys(method, config);
337
+ for (const key of unprefixedKeys) {
338
+ const secretName = envUsesPrefixedSecrets(envName, cfg)
339
+ ? prefixSecretKey(envName, key)
340
+ : key;
341
+ // Keep every env's prefixed secret available under its full CI name.
342
+ if (secretName !== key) {
343
+ upsertWorkflowEnvLine(envVars, secretName, `\${{ secrets.${secretName} }}`);
344
+ }
345
+ // Unprefixed mapping for CLI defaults (last target wins in static YAML;
346
+ // applyEnvSecretOverlay restores the correct env's values at deploy time).
347
+ upsertWorkflowEnvLine(envVars, key, `\${{ secrets.${secretName} }}`);
298
348
  }
299
349
  }
300
350
 
301
- applyKubernetesWorkflowEnv(deployEnvironments, environments, envVars);
351
+ applyKubernetesWorkflowEnv(targets, environments, envVars);
302
352
  return envVars;
303
353
  }
304
354
 
355
+ /**
356
+ * @param {Record<string, unknown>} environments
357
+ * @returns {string}
358
+ */
359
+ function formatEnvironmentChoiceOptions(environments) {
360
+ const names = Object.keys(environments || {});
361
+ const options = [...names, 'all'];
362
+ return options.map((n) => ` - ${n}`).join('\n');
363
+ }
364
+
305
365
  /**
306
366
  * @param {Set<string>} envVars
307
367
  * @param {string} [indent]
@@ -339,7 +399,7 @@ export function extractWorkflowSecretKeys(yaml) {
339
399
  /**
340
400
  * @param {string[]} storageProviders
341
401
  * @param {string[]} deployEnvironments
342
- * @param {Record<string, { type: string }>} environments
402
+ * @param {Record<string, { type?: string, method?: string }>} environments
343
403
  * @param {string} [cliSource]
344
404
  * @param {import('../core/config.js').DeployHubConfig} [config]
345
405
  * @returns {string}
@@ -351,9 +411,22 @@ export function generateWorkflowYaml(
351
411
  cliSource = DEFAULT_NPM_CLI_SOURCE,
352
412
  config = null
353
413
  ) {
414
+ const envNames = Object.keys(environments || {});
415
+ const allDeployNames =
416
+ deployEnvironments.length > 0 ? deployEnvironments : envNames.filter((n) => isEnvEnabled(environments[n]));
417
+
418
+ // Push-triggered auto-deploy targets (build pipeline filters these in CI).
419
+ const pushEnvs = allDeployNames.filter(
420
+ (n) => isEnvEnabled(environments[n]) && getEnvTrigger(environments[n]) === 'push'
421
+ );
422
+
423
+ // Secrets for build job: storage + all push envs (or default if none are push).
424
+ const buildSecretEnvs =
425
+ pushEnvs.length > 0 ? pushEnvs : allDeployNames.slice(0, 1);
426
+
354
427
  const envVars = buildWorkflowEnvEntries(
355
428
  storageProviders,
356
- deployEnvironments,
429
+ buildSecretEnvs,
357
430
  environments,
358
431
  config
359
432
  );
@@ -365,7 +438,7 @@ export function generateWorkflowYaml(
365
438
  const githubGitConfigStep = isGithubCliSource(cliSource)
366
439
  ? `${getGithubGitConfigStep()}\n`
367
440
  : '';
368
- const kubernetesSteps = hasKubernetesDeploy(deployEnvironments, environments)
441
+ const kubernetesSteps = hasKubernetesDeploy(allDeployNames, environments)
369
442
  ? `${getKubernetesSetupSteps()}\n`
370
443
  : '';
371
444
 
@@ -375,11 +448,41 @@ export function generateWorkflowYaml(
375
448
  ? getInstallDepsCommand(config)
376
449
  : 'npm install';
377
450
 
451
+ const envChoiceOptions = formatEnvironmentChoiceOptions(environments);
452
+ const hasEnvs = envNames.length > 0;
453
+ const dispatchInputs = hasEnvs
454
+ ? ` workflow_dispatch:
455
+ inputs:
456
+ environment:
457
+ description: 'Environment to deploy (leave blank on push; use "all" for every enabled env)'
458
+ required: false
459
+ type: choice
460
+ options:
461
+ ${envChoiceOptions}
462
+ `
463
+ : ` workflow_dispatch:
464
+ `;
465
+
466
+ const manualDeployStep = hasEnvs
467
+ ? ` - name: Deploy (workflow_dispatch)
468
+ if: github.event_name == 'workflow_dispatch'
469
+ env:
470
+ ${envBlock}
471
+ run: |
472
+ ENV_INPUT="\${{ inputs.environment }}"
473
+ if [ -z "$ENV_INPUT" ] || [ "$ENV_INPUT" = "all" ]; then
474
+ ${getCliDeployCommand()} --env all
475
+ else
476
+ ${getCliDeployCommand()} --env "$ENV_INPUT"
477
+ fi
478
+ `
479
+ : '';
480
+
378
481
  const workflow = `${getWorkflowHeaderComment()}name: DeployHub
379
482
  on:
380
483
  push:
381
484
  branches: [main]
382
- jobs:
485
+ ${dispatchInputs}jobs:
383
486
  deploy:
384
487
  runs-on: ubuntu-latest
385
488
  steps:
@@ -391,20 +494,29 @@ ${kubernetesSteps}${githubGitConfigStep} - name: Install project dependenci
391
494
  run: ${installDepsCommand}
392
495
  - name: Install DeployHub CLI
393
496
  run: npm install ${installSpec} --no-save
394
- - run: ${getCliBuildCommand()}
497
+ - name: Build (and auto-deploy push-triggered envs)
498
+ run: ${getCliBuildCommand()}
395
499
  env:
396
500
  ${envBlock}
397
- `;
501
+ ${manualDeployStep}`;
398
502
 
399
503
  return workflow;
400
504
  }
401
505
 
506
+ /**
507
+ * Shell command to run deployhub deploy from the installed scoped package.
508
+ * @returns {string}
509
+ */
510
+ export function getCliDeployCommand() {
511
+ return `node ./node_modules/${NPM_PACKAGE}/src/cli/index.js deploy`;
512
+ }
513
+
402
514
  /**
403
515
  * Manual rollback via Actions tab / gh workflow run (workflow_dispatch only).
404
516
  *
405
517
  * @param {string[]} storageProviders
406
518
  * @param {string[]} deployEnvironments
407
- * @param {Record<string, { type: string }>} environments
519
+ * @param {Record<string, { type?: string, method?: string }>} environments
408
520
  * @param {string} [cliSource]
409
521
  * @param {import('../core/config.js').DeployHubConfig} [config]
410
522
  * @returns {string}
@@ -416,9 +528,15 @@ export function generateRollbackWorkflowYaml(
416
528
  cliSource = DEFAULT_NPM_CLI_SOURCE,
417
529
  config = null
418
530
  ) {
531
+ const envNames = Object.keys(environments || {});
532
+ const allDeployNames =
533
+ deployEnvironments.length > 0
534
+ ? deployEnvironments
535
+ : envNames.filter((n) => isEnvEnabled(environments[n]));
536
+
419
537
  const envVars = buildWorkflowEnvEntries(
420
538
  storageProviders,
421
- deployEnvironments,
539
+ allDeployNames,
422
540
  environments,
423
541
  config
424
542
  );
@@ -428,11 +546,43 @@ export function generateRollbackWorkflowYaml(
428
546
  const githubGitConfigStep = isGithubCliSource(cliSource)
429
547
  ? `${getGithubGitConfigStep()}\n`
430
548
  : '';
431
- const kubernetesSteps = hasKubernetesDeploy(deployEnvironments, environments)
549
+ const kubernetesSteps = hasKubernetesDeploy(allDeployNames, environments)
432
550
  ? `${getKubernetesSetupSteps()}\n`
433
551
  : '';
434
552
 
435
553
  const rollbackCmd = getCliRollbackCommand();
554
+ const envChoiceOptions = formatEnvironmentChoiceOptions(environments);
555
+ const hasEnvs = envNames.length > 0;
556
+
557
+ const environmentInput = hasEnvs
558
+ ? ` environment:
559
+ description: 'Environment to roll back (or "all")'
560
+ required: false
561
+ type: choice
562
+ options:
563
+ ${envChoiceOptions}
564
+ `
565
+ : '';
566
+
567
+ const rollbackRun = hasEnvs
568
+ ? ` ENV_INPUT="\${{ inputs.environment }}"
569
+ BUILD_INPUT="\${{ inputs.buildId }}"
570
+ ENV_FLAG=""
571
+ if [ -z "$ENV_INPUT" ] || [ "$ENV_INPUT" = "all" ]; then
572
+ ENV_FLAG="--env all"
573
+ else
574
+ ENV_FLAG="--env $ENV_INPUT"
575
+ fi
576
+ if [ -n "$BUILD_INPUT" ]; then
577
+ ${rollbackCmd} "$BUILD_INPUT" $ENV_FLAG
578
+ else
579
+ ${rollbackCmd} $ENV_FLAG
580
+ fi`
581
+ : ` if [ -n "\${{ inputs.buildId }}" ]; then
582
+ ${rollbackCmd} "\${{ inputs.buildId }}"
583
+ else
584
+ ${rollbackCmd}
585
+ fi`;
436
586
 
437
587
  return `${getWorkflowHeaderComment()}name: DeployHub Rollback
438
588
  on:
@@ -442,7 +592,7 @@ on:
442
592
  description: 'Exact buildId to restore (leave blank = previous build)'
443
593
  required: false
444
594
  type: string
445
- jobs:
595
+ ${environmentInput}jobs:
446
596
  rollback:
447
597
  runs-on: ubuntu-latest
448
598
  steps:
@@ -456,11 +606,7 @@ ${kubernetesSteps}${githubGitConfigStep} - name: Install DeployHub CLI
456
606
  env:
457
607
  ${envBlock}
458
608
  run: |
459
- if [ -n "\${{ inputs.buildId }}" ]; then
460
- ${rollbackCmd} "\${{ inputs.buildId }}"
461
- else
462
- ${rollbackCmd}
463
- fi
609
+ ${rollbackRun}
464
610
  `;
465
611
  }
466
612
 
@@ -567,6 +713,166 @@ export async function getRollbackWorkflowDoctorCheck(cwd, config) {
567
713
  };
568
714
  }
569
715
 
716
+ /**
717
+ * Expected CI secret names for the current config — same set a fresh
718
+ * `sync-workflows` would reference (via buildWorkflowEnvEntries).
719
+ *
720
+ * @param {import('../core/config.js').DeployHubConfig} config
721
+ * @param {'deploy'|'rollback'} [kind]
722
+ * @returns {Set<string>}
723
+ */
724
+ export function expectedWorkflowSecretKeysFromConfig(config, kind = 'rollback') {
725
+ const environments = config.environments || {};
726
+ const allNames = Object.keys(environments);
727
+ /** @type {string[]} */
728
+ let targets;
729
+ if (kind === 'deploy') {
730
+ const enabled = allNames.filter((n) => isEnvEnabled(environments[n]));
731
+ const pushEnvs = enabled.filter((n) => getEnvTrigger(environments[n]) === 'push');
732
+ targets = pushEnvs.length > 0 ? pushEnvs : enabled.slice(0, 1);
733
+ } else {
734
+ targets = allNames;
735
+ }
736
+
737
+ const entries = buildWorkflowEnvEntries(
738
+ config.storage || [],
739
+ targets,
740
+ environments,
741
+ config
742
+ );
743
+
744
+ /** @type {Set<string>} */
745
+ const keys = new Set();
746
+ const re = /secrets\.([A-Z0-9_]+)/g;
747
+ for (const line of entries) {
748
+ let match;
749
+ re.lastIndex = 0;
750
+ while ((match = re.exec(line)) !== null) {
751
+ keys.add(match[1]);
752
+ }
753
+ }
754
+ return keys;
755
+ }
756
+
757
+ /**
758
+ * Lightweight drift check: checked-in workflow vs current config.
759
+ * Catches missing dispatch env options and missing required secret references.
760
+ *
761
+ * @param {string} yamlText
762
+ * @param {import('../core/config.js').DeployHubConfig} config
763
+ * @param {string} [filename]
764
+ * @returns {{ drifted: boolean, missingEnvs: string[], missingSecrets: string[], summary: string }}
765
+ */
766
+ export function detectWorkflowConfigDrift(yamlText, config, filename = DEPLOY_WORKFLOW_FILENAME) {
767
+ /** @type {string[]} */
768
+ const missingEnvs = [];
769
+ /** @type {string[]} */
770
+ const missingSecrets = [];
771
+
772
+ let parsed;
773
+ try {
774
+ parsed = yaml.load(yamlText);
775
+ } catch {
776
+ return {
777
+ drifted: false,
778
+ missingEnvs,
779
+ missingSecrets,
780
+ summary: '',
781
+ };
782
+ }
783
+
784
+ if (!parsed || typeof parsed !== 'object') {
785
+ return { drifted: false, missingEnvs, missingSecrets, summary: '' };
786
+ }
787
+
788
+ const envNames = Object.keys(config.environments || {});
789
+ const root = /** @type {Record<string, any>} */ (parsed);
790
+ const options = root?.on?.workflow_dispatch?.inputs?.environment?.options;
791
+
792
+ if (Array.isArray(options) && envNames.length > 0) {
793
+ const optionSet = new Set(options.map(String));
794
+ for (const name of envNames) {
795
+ if (!optionSet.has(name)) missingEnvs.push(name);
796
+ }
797
+ } else if (envNames.length > 1) {
798
+ // Multi-env config but no dispatch dropdown — stale single-env workflow.
799
+ missingEnvs.push(...envNames);
800
+ }
801
+
802
+ const kind = filename.includes('rollback') ? 'rollback' : 'deploy';
803
+ const fileSecrets = new Set(extractWorkflowSecretKeys(yamlText));
804
+ const expected = expectedWorkflowSecretKeysFromConfig(config, kind);
805
+ for (const key of expected) {
806
+ if (!fileSecrets.has(key)) missingSecrets.push(key);
807
+ }
808
+
809
+ const drifted = missingEnvs.length > 0 || missingSecrets.length > 0;
810
+ /** @type {string[]} */
811
+ const parts = [];
812
+ if (missingEnvs.length > 0) {
813
+ parts.push(
814
+ `missing: ${missingEnvs.map((n) => `"${n}"`).join(', ')} in the dispatch dropdown`
815
+ );
816
+ }
817
+ if (missingSecrets.length > 0) {
818
+ const shown = missingSecrets.slice(0, 4);
819
+ parts.push(
820
+ `missing secret(s): ${shown.join(', ')}${missingSecrets.length > 4 ? ', …' : ''}`
821
+ );
822
+ }
823
+
824
+ return {
825
+ drifted,
826
+ missingEnvs,
827
+ missingSecrets,
828
+ summary: parts.join('; '),
829
+ };
830
+ }
831
+
832
+ /**
833
+ * Doctor helper: informational checks when checked-in workflows drift from config.
834
+ * Skips missing/unparseable files (other checks cover "file missing").
835
+ * Returns an empty array when everything is in sync or not applicable.
836
+ *
837
+ * @param {string} cwd
838
+ * @param {import('../core/config.js').DeployHubConfig} config
839
+ * @returns {Promise<{ name: string, pass: boolean, message: string }[]>}
840
+ */
841
+ export async function getWorkflowDriftDoctorChecks(cwd, config) {
842
+ const envCount = Object.keys(config.environments || {}).length;
843
+ if (envCount === 0) return [];
844
+
845
+ /** @type {{ name: string, pass: boolean, message: string }[]} */
846
+ const checks = [];
847
+ const files = [DEPLOY_WORKFLOW_FILENAME, ROLLBACK_WORKFLOW_FILENAME];
848
+
849
+ for (const filename of files) {
850
+ const filePath = path.join(cwd, '.github', 'workflows', filename);
851
+ if (!(await fs.pathExists(filePath))) continue;
852
+
853
+ let text;
854
+ try {
855
+ text = await fs.readFile(filePath, 'utf8');
856
+ } catch {
857
+ continue;
858
+ }
859
+
860
+ const drift = detectWorkflowConfigDrift(text, config, filename);
861
+ if (!drift.drifted) continue;
862
+
863
+ checks.push({
864
+ name: `Workflow sync (${filename})`,
865
+ // Informational nudge — same pattern as missing rollback workflow (pass: true).
866
+ pass: true,
867
+ message:
868
+ `.github/workflows/${filename} looks out of date with your current environments config ` +
869
+ `(${drift.summary}). Run: deployhub sync-workflows`,
870
+ });
871
+ }
872
+
873
+ return checks;
874
+ }
875
+
570
876
  /**
571
877
  * @param {string} cliSource
572
878
  * @param {string} [cwd]
@@ -669,9 +975,15 @@ export function getGithubSecretsChecklist(
669
975
 
670
976
  for (const envName of deployEnvironments) {
671
977
  const env = environments[envName];
672
- if (!env?.type) continue;
673
-
674
- for (const item of getDeploymentSecretChecklistItems(env.type, config)) {
978
+ const method = getEnvMethod(env);
979
+ if (!method) continue;
980
+
981
+ for (const item of getDeploymentSecretChecklistItemsForEnv(
982
+ envName,
983
+ method,
984
+ config,
985
+ environments
986
+ )) {
675
987
  const existing = byKey.get(item.key);
676
988
  if (existing) {
677
989
  if (item.required && !existing.required) {
@@ -741,9 +1053,10 @@ export function generateEnvExampleContent(
741
1053
 
742
1054
  for (const envName of deployEnvironments) {
743
1055
  const env = environments[envName];
744
- if (!env?.type) continue;
1056
+ const method = getEnvMethod(env);
1057
+ if (!method) continue;
745
1058
 
746
- const deploySection = generateDeploymentEnvSection(env.type, config, environments);
1059
+ const deploySection = generateDeploymentEnvSection(method, config, environments);
747
1060
  if (deploySection) {
748
1061
  sections.push(`${deploySection}\n`);
749
1062
  }
@@ -0,0 +1,158 @@
1
+ import axios from 'axios';
2
+ import { getEnvSettings } from '../core/environments.js';
3
+
4
+ /**
5
+ * Per-environment health URL: `environments.{env}.config.healthCheckUrl`, else top-level fallback.
6
+ *
7
+ * @param {import('../core/config.js').DeployHubConfig} config
8
+ * @param {string} envName
9
+ * @returns {string}
10
+ */
11
+ export function resolveEnvHealthCheckUrl(config, envName) {
12
+ const env = config.environments?.[envName];
13
+ const settings = getEnvSettings(env);
14
+ const override = settings.healthCheckUrl;
15
+ if (typeof override === 'string' && override.trim()) {
16
+ return override.trim();
17
+ }
18
+ const fallback = config.healthCheck?.url;
19
+ return typeof fallback === 'string' && fallback.trim() ? fallback.trim() : '';
20
+ }
21
+
22
+ /**
23
+ * @param {import('../core/config.js').DeployHubConfig} config
24
+ * @param {string[]} envNames
25
+ * @returns {boolean}
26
+ */
27
+ export function anyEnvHasResolvableHealthCheckUrl(config, envNames) {
28
+ return (envNames || []).some((name) => Boolean(resolveEnvHealthCheckUrl(config, name)));
29
+ }
30
+
31
+ /**
32
+ * @param {number} status
33
+ * @returns {boolean}
34
+ */
35
+ export function isHealthyHttpStatus(status) {
36
+ return status >= 200 && status < 400;
37
+ }
38
+
39
+ /**
40
+ * @param {import('../core/config.js').DeployHubConfig} config
41
+ * @param {string} envName
42
+ * @param {{ httpGet?: (url: string, timeoutMs: number) => Promise<{ status: number }> }} [options]
43
+ * @returns {Promise<{ ok: true, envName: string, url: string, status: number, elapsed: number } | { ok: false, envName: string, url: string, error: string }>}
44
+ */
45
+ export async function checkEnvHealth(config, envName, options = {}) {
46
+ const url = resolveEnvHealthCheckUrl(config, envName);
47
+ if (!url) {
48
+ return { ok: false, envName, url: '', error: `No health check URL configured for environment "${envName}"` };
49
+ }
50
+
51
+ const timeoutMs = (config.healthCheck?.timeout || 30) * 1000;
52
+ const httpGet =
53
+ options.httpGet ||
54
+ (async (target, ms) => {
55
+ const response = await axios.get(target, {
56
+ timeout: ms,
57
+ validateStatus: () => true,
58
+ });
59
+ return { status: response.status };
60
+ });
61
+
62
+ const start = Date.now();
63
+ try {
64
+ const { status } = await httpGet(url, timeoutMs);
65
+ const elapsed = Date.now() - start;
66
+ if (!isHealthyHttpStatus(status)) {
67
+ return {
68
+ ok: false,
69
+ envName,
70
+ url,
71
+ error: `Health check failed for ${envName}: HTTP ${status} (${elapsed}ms) — ${url}`,
72
+ };
73
+ }
74
+ return { ok: true, envName, url, status, elapsed };
75
+ } catch (err) {
76
+ const elapsed = Date.now() - start;
77
+ const detail = err instanceof Error ? err.message : String(err);
78
+ return {
79
+ ok: false,
80
+ envName,
81
+ url,
82
+ error: `Health check failed for ${envName}: ${detail} (${elapsed}ms) — ${url}`,
83
+ };
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Run health checks for each deployed environment independently (checks all, then summarizes).
89
+ *
90
+ * @param {import('../core/config.js').DeployHubConfig} config
91
+ * @param {string[]} envNames
92
+ * @param {{ httpGet?: (url: string, timeoutMs: number) => Promise<{ status: number }> }} [options]
93
+ */
94
+ export async function runHealthChecksForEnvs(config, envNames, options = {}) {
95
+ /** @type {{ envName: string, url: string, status: number, elapsed: number }[]} */
96
+ const results = [];
97
+ /** @type {{ envName: string, url: string, error: string }[]} */
98
+ const failures = [];
99
+
100
+ for (const envName of envNames || []) {
101
+ const url = resolveEnvHealthCheckUrl(config, envName);
102
+ if (!url) continue;
103
+
104
+ const outcome = await checkEnvHealth(config, envName, options);
105
+ if (outcome.ok) {
106
+ results.push({
107
+ envName: outcome.envName,
108
+ url: outcome.url,
109
+ status: outcome.status,
110
+ elapsed: outcome.elapsed,
111
+ });
112
+ } else {
113
+ failures.push({
114
+ envName: outcome.envName,
115
+ url: outcome.url,
116
+ error: outcome.error,
117
+ });
118
+ }
119
+ }
120
+
121
+ return { results, failures };
122
+ }
123
+
124
+ /**
125
+ * @param {{ envName: string, url: string, status: number, elapsed: number }[]} results
126
+ * @param {{ envName: string, url: string, error: string }[]} failures
127
+ * @returns {string}
128
+ */
129
+ export function formatHealthCheckAllSummary(results, failures) {
130
+ const lines = ['Health check summary:'];
131
+ for (const r of results) {
132
+ lines.push(
133
+ ` ✓ ${r.envName.padEnd(14)} → HTTP ${r.status} (${r.elapsed}ms) ${r.url}`
134
+ );
135
+ }
136
+ for (const f of failures) {
137
+ lines.push(` ✗ ${f.envName.padEnd(14)} → FAILED: ${f.error}`);
138
+ }
139
+ lines.push('');
140
+ if (failures.length === 0) {
141
+ lines.push(`All ${results.length} environment(s) passed health checks.`);
142
+ } else {
143
+ const total = results.length + failures.length;
144
+ lines.push(
145
+ `${failures.length} of ${total} environment(s) failed health checks. See above for details.`
146
+ );
147
+ }
148
+ return lines.join('\n');
149
+ }
150
+
151
+ export default {
152
+ resolveEnvHealthCheckUrl,
153
+ anyEnvHasResolvableHealthCheckUrl,
154
+ isHealthyHttpStatus,
155
+ checkEnvHealth,
156
+ runHealthChecksForEnvs,
157
+ formatHealthCheckAllSummary,
158
+ };