@akash-chowdhury-24/deployhub 2.0.15 → 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.
package/README.md CHANGED
@@ -229,9 +229,22 @@ Or push to `main` / `master` — the generated workflow runs the same command.
229
229
  deployhub artifact list # local artifacts
230
230
  deployhub artifact list --remote # include storage history.json
231
231
  deployhub artifact restore <buildId> # download a past build
232
- deployhub deploy # deploy latest artifact without rebuilding
233
- deployhub rollback # previous build from history
234
- deployhub rollback <buildId> # exact build (required if semver is ambiguous)
232
+
233
+ deployhub deploy # deploy latest artifact to defaultEnvironment
234
+ deployhub deploy --env staging # deploy one named environment
235
+ deployhub deploy --env all # every enabled environment (skips disabled)
236
+
237
+ deployhub rollback # previous build for defaultEnvironment
238
+ deployhub rollback --env production # roll back one env using its own history
239
+ deployhub rollback 1.0.6-a1b2c3d --env staging
240
+ deployhub rollback --env all # continue-on-error across enabled envs
241
+
242
+ deployhub env list # list environments (method, status, last deploy)
243
+ deployhub env add staging # add an environment (interactive method prompts)
244
+ deployhub env enable staging # include in deploy / --env all
245
+ deployhub env disable staging # exclude without deleting config
246
+ deployhub env remove staging # remove env (re-point defaultEnvironment first if needed)
247
+
235
248
  deployhub sync-workflows # regenerate deploy + rollback GitHub Actions YAML
236
249
  deployhub sync-k8s-ports # fix containerPort/targetPort in existing k8s manifests
237
250
  deployhub logs # last deployment logs
@@ -239,7 +252,7 @@ deployhub logs # last deployment logs
239
252
 
240
253
  ### Rollback behavior
241
254
 
242
- `deployhub rollback` restores a previous artifact from storage `history.json` and redeploys it:
255
+ `deployhub rollback` restores a previous artifact from **that environment's** `envs/{env}/history.json` (legacy project `history.json` is treated as the default env's history) and redeploys it:
243
256
 
244
257
  | Argument | Behavior |
245
258
  |----------|----------|
@@ -293,12 +306,14 @@ Artifacts appear under `artifact/{projectName}/{date}/v{buildId}/` locally.
293
306
  **Remote storage** (S3 and other providers) uses a different layout:
294
307
 
295
308
  ```text
296
- {project}/builds/{buildId}/artifact.zip # immutable per CI/build
297
- {project}/history.json # newest-first index for rollback / list --remote
298
- {project}/latest/artifact.zip # mutable pointer overwritten every upload (NOT a backup)
309
+ {project}/builds/{buildId}/artifact.zip # immutable per CI/build (shared across envs)
310
+ {project}/history.json # build catalog for artifact list --remote
311
+ {project}/latest/artifact.zip # last uploaded build (NOT a backup)
312
+ {project}/envs/{env}/history.json # per-environment deploy history (rollback)
313
+ {project}/envs/{env}/latest/artifact.zip # last deploy to that env (NOT a backup)
299
314
  ```
300
315
 
301
- `buildId` is unique per pipeline run (e.g. `1.0.6-a1b2c3d`) even if `package.json` semver is unchanged. Legacy keys `{project}/v{semver}/artifact.zip` are no longer written; they remain readable for older uploads only.
316
+ `buildId` is unique per pipeline run (e.g. `1.0.6-a1b2c3d`) even if `package.json` semver is unchanged. Legacy keys `{project}/v{semver}/artifact.zip` are no longer written; they remain readable for older uploads only. Build once, then promote the same `buildId` to any environment with `deployhub deploy --env`.
302
317
 
303
318
  ### Same steps for other languages
304
319
 
@@ -987,23 +1002,35 @@ Run `deployhub doctor` after any config change.
987
1002
 
988
1003
  | Command | Description |
989
1004
  |---------|-------------|
990
- | `deployhub init` | Interactive project setup |
991
- | `deployhub build` | Full pipeline: detect → install → test → build → artifact → storage → deploy |
1005
+ | `deployhub init` | Interactive project setup (one or multiple environments) |
1006
+ | `deployhub build` | Full pipeline: detect → install → test → build → artifact → storage → deploy (default / push-triggered envs) |
992
1007
  | `deployhub artifact create` | Create artifact from current build |
993
1008
  | `deployhub artifact list [--remote]` | List local artifacts; `--remote` merges storage `history.json` |
994
1009
  | `deployhub artifact restore <buildId\|semver>` | Download and extract an artifact |
995
1010
  | `deployhub storage add <provider>` | Add storage provider credentials |
996
1011
  | `deployhub storage list` | List storage providers and connection status |
997
- | `deployhub deploy` | Deploy latest artifact |
998
- | `deployhub rollback [buildId\|semver]` | Previous build, or exact buildId (ambiguous semver lists matches and exits) |
1012
+ | `deployhub deploy` | Deploy latest artifact to `defaultEnvironment` |
1013
+ | `deployhub deploy --env staging` | Deploy to one named environment |
1014
+ | `deployhub deploy --env all` | Deploy to every enabled environment (skips disabled) |
1015
+ | `deployhub rollback` | Roll back `defaultEnvironment` to its previous build |
1016
+ | `deployhub rollback --env production` | Roll back one env from `envs/{env}/history.json` |
1017
+ | `deployhub rollback <buildId> --env staging` | Restore an exact `buildId` to that environment |
1018
+ | `deployhub rollback --env all` | Roll back each enabled env (continue-on-error + summary) |
1019
+ | `deployhub env list` | List environments (method, enabled, trigger, last deploy) |
1020
+ | `deployhub env add <name>` | Add an environment (interactive method prompts; regenerates workflows) |
1021
+ | `deployhub env enable <name>` | Enable an environment for deploy / `--env all` |
1022
+ | `deployhub env disable <name>` | Disable without deleting config |
1023
+ | `deployhub env remove <name>` | Remove an environment (re-point `defaultEnvironment` first if needed) |
999
1024
  | `deployhub sync-workflows` | Regenerate `.github/workflows/deployhub.yml` and `deployhub-rollback.yml` from config |
1000
1025
  | `deployhub sync-k8s-ports` | Update only `containerPort` / `targetPort` in `k8s/deployment.yaml` and `k8s/service.yaml` from Dockerfile `EXPOSE` (or config/fallback). Does **not** change replicas, resources, env, or probes. Heavily customized / multi-container manifests may need a manual review |
1001
1026
  | `deployhub logs` | Show logs from last deployment |
1002
- | `deployhub doctor` | Pre-flight checks (including an informational warning if the rollback workflow file is missing) |
1027
+ | `deployhub doctor [--all]` | Pre-flight checks per environment (`--all` includes disabled envs as informational) |
1003
1028
  | `deployhub verify` | Health check on configured endpoint |
1004
1029
  | `deployhub clean` | Remove old local artifacts |
1005
1030
  | `deployhub update` | Check for CLI updates |
1006
1031
 
1032
+ **Tests:** `npm test` — currently **270 passing** across the Jest suites.
1033
+
1007
1034
  ## GitHub Secrets
1008
1035
 
1009
1036
  Add these secrets in your repository (Settings → Secrets and variables → Actions). Only add secrets for providers you selected during `init`. At the end of `deployhub init`, DeployHub prints the exact list for your project.
@@ -1072,6 +1099,8 @@ The doctor command runs independent checks and always completes without crashing
1072
1099
 
1073
1100
  The **Rollback workflow** check is informational and non-blocking (`✓` even when the file is missing). It suggests `deployhub sync-workflows` so already-initialized projects can pick up `deployhub-rollback.yml` without a full re-init. When the file exists, the message confirms the path instead.
1074
1101
 
1102
+ If checked-in workflow files exist but are **out of date** with `deployhub.config.json` (e.g. you ran `env add` or a silent migration and forgot `sync-workflows`), doctor also reports an informational **Workflow sync** nudge naming the missing environment or secret and suggesting `deployhub sync-workflows`. That check never fails the doctor exit code.
1103
+
1075
1104
  If checks fail:
1076
1105
 
1077
1106
  ```
@@ -1104,9 +1133,11 @@ artifact/
1104
1133
  **Remote keys** (all storage providers):
1105
1134
 
1106
1135
  ```
1107
- {project}/builds/{buildId}/artifact.zip
1108
- {project}/history.json
1109
- {project}/latest/artifact.zip # overwritten every build — convenience pointer only, not version history
1136
+ {project}/builds/{buildId}/artifact.zip # immutable per CI/build (shared across envs)
1137
+ {project}/history.json # build catalog for artifact list --remote
1138
+ {project}/latest/artifact.zip # overwritten every build — convenience pointer only
1139
+ {project}/envs/{env}/history.json # per-environment deploy history (rollback)
1140
+ {project}/envs/{env}/latest/artifact.zip # last deploy to that env (NOT a backup)
1110
1141
  ```
1111
1142
 
1112
1143
  Legacy (read-only fallback, no longer written): `{project}/v{semver}/artifact.zip`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akash-chowdhury-24/deployhub",
3
- "version": "2.0.15",
3
+ "version": "2.0.16",
4
4
  "description": "Zero-configuration deployment and artifact manager",
5
5
  "type": "module",
6
6
  "main": "./src/cli/index.js",
@@ -13,6 +13,12 @@ import {
13
13
  copyDeployAssetsToArtifactDir,
14
14
  } from '../utils/scaffold.js';
15
15
  import { getGeneratedByMetadata, getArtifactReadmeFooter } from '../utils/author.js';
16
+ import {
17
+ getEnabledEnvironmentNames,
18
+ getEnvMethod,
19
+ getEnvSettings,
20
+ resolveDefaultEnvironmentName,
21
+ } from '../core/config.js';
16
22
 
17
23
  /**
18
24
  * @param {string} cwd
@@ -151,13 +157,14 @@ async function stageFrontendArtifact(cwd, stagingDir, config) {
151
157
 
152
158
  await copyKubernetesManifestsIfPresent(cwd, stagingDir);
153
159
 
154
- const hasSshDeploy = (config.deploy || []).some(
155
- (envName) => config.environments[envName]?.type === 'ssh'
160
+ const deployTargets = getEnabledEnvironmentNames(config);
161
+ const hasSshDeploy = deployTargets.some(
162
+ (envName) => getEnvMethod(config.environments[envName]) === 'ssh'
156
163
  );
157
164
 
158
165
  if (hasSshDeploy && !fs.existsSync(path.join(stagingDir, 'nginx.conf'))) {
159
- const envName = config.deploy?.[0];
160
- const env = envName ? config.environments[envName] : null;
166
+ const envName = resolveDefaultEnvironmentName(config) || deployTargets[0];
167
+ const env = envName ? getEnvSettings(config.environments[envName]) : null;
161
168
  const deployPath =
162
169
  env?.frontendDeployPath || env?.deployPath || env?.path || `/var/www/${config.project}`;
163
170
  const nginxConf = generateNginxConfig(config.project, deployPath, outputName);
package/src/cli/index.js CHANGED
@@ -15,6 +15,7 @@ import { registerCleanCommand } from '../commands/clean.js';
15
15
  import { registerUpdateCommand } from '../commands/update.js';
16
16
  import { registerSyncWorkflowsCommand } from '../commands/sync-workflows.js';
17
17
  import { registerSyncK8sPortsCommand } from '../commands/sync-k8s-ports.js';
18
+ import { registerEnvCommand } from '../commands/env.js';
18
19
  import { formatVersionOutput, printBanner, shouldShowBanner } from '../utils/author.js';
19
20
 
20
21
  loadEnv();
@@ -45,5 +46,6 @@ registerCleanCommand(program);
45
46
  registerUpdateCommand(program);
46
47
  registerSyncWorkflowsCommand(program);
47
48
  registerSyncK8sPortsCommand(program);
49
+ registerEnvCommand(program);
48
50
 
49
51
  program.parse();
@@ -1,10 +1,16 @@
1
1
  import chalk from 'chalk';
2
2
  import { loadConfig, loadEnv } from '../core/config.js';
3
+ import { resolveEnvTargets } from '../core/environments.js';
3
4
  import { runPipeline } from '../core/pipeline.js';
4
5
  import { listLocalArtifacts } from '../artifact/engine.js';
5
6
  import { deployToAll } from '../deployment/index.js';
6
7
  import { sendNotifications } from '../notifications/index.js';
7
- import axios from 'axios';
8
+ import {
9
+ anyEnvHasResolvableHealthCheckUrl,
10
+ runHealthChecksForEnvs,
11
+ formatHealthCheckAllSummary,
12
+ } from '../utils/health-check.js';
13
+ import { createLogger } from '../logger/index.js';
8
14
 
9
15
  /**
10
16
  * @param {import('commander').Command} program
@@ -12,11 +18,34 @@ import axios from 'axios';
12
18
  export function registerDeployCommand(program) {
13
19
  program
14
20
  .command('deploy')
15
- .description('Deploy the latest artifact')
16
- .action(async () => {
21
+ .description('Deploy the latest artifact (default environment, or --env)')
22
+ .option(
23
+ '--env <name>',
24
+ 'Environment to deploy (name, or "all" for every enabled environment)'
25
+ )
26
+ .action(async (opts) => {
17
27
  loadEnv();
18
28
  const cwd = process.cwd();
19
29
  const config = await loadConfig(cwd);
30
+ const log = createLogger('deploy');
31
+
32
+ let targets;
33
+ let skippedDisabled;
34
+ try {
35
+ ({ targets, skippedDisabled } = resolveEnvTargets(config, opts.env));
36
+ } catch (err) {
37
+ console.error(chalk.red(err instanceof Error ? err.message : String(err)));
38
+ process.exit(1);
39
+ }
40
+
41
+ for (const name of skippedDisabled) {
42
+ log.info(`Skipping disabled environment "${name}"`);
43
+ }
44
+
45
+ if (targets.length === 0) {
46
+ console.error(chalk.red('No enabled environments to deploy.'));
47
+ process.exit(1);
48
+ }
20
49
 
21
50
  const artifacts = await listLocalArtifacts(cwd);
22
51
  if (artifacts.length === 0) {
@@ -34,27 +63,43 @@ export function registerDeployCommand(program) {
34
63
  async run(ctx) {
35
64
  const deployed = await deployToAll(
36
65
  ctx.config,
37
- /** @type {string} */ (ctx.state.artifactDir)
66
+ /** @type {string} */ (ctx.state.artifactDir),
67
+ targets
38
68
  );
39
69
  ctx.state.deployedTargets = deployed;
40
70
  },
41
71
  },
42
72
  {
43
73
  name: 'verify',
44
- enabled: (ctx) => !!ctx.config.healthCheck?.url,
74
+ enabled: (ctx) => {
75
+ const deployed = /** @type {string[]} */ (
76
+ ctx.state.deployedTargets || targets
77
+ );
78
+ return anyEnvHasResolvableHealthCheckUrl(ctx.config, deployed);
79
+ },
45
80
  async run(ctx) {
46
- const url = ctx.config.healthCheck.url;
47
- const timeout = (ctx.config.healthCheck.timeout || 30) * 1000;
48
- const start = Date.now();
49
- const response = await axios.get(url, {
50
- timeout,
51
- validateStatus: () => true,
52
- });
53
- const elapsed = Date.now() - start;
54
- if (response.status < 200 || response.status >= 400) {
55
- throw new Error(`Health check failed: HTTP ${response.status}`);
81
+ const deployed = /** @type {string[]} */ (
82
+ ctx.state.deployedTargets || targets
83
+ );
84
+ const { results, failures } = await runHealthChecksForEnvs(
85
+ ctx.config,
86
+ deployed
87
+ );
88
+
89
+ if (deployed.length > 1 || failures.length > 0) {
90
+ console.log('');
91
+ console.log(formatHealthCheckAllSummary(results, failures));
92
+ } else if (results[0]) {
93
+ console.log(
94
+ chalk.green(
95
+ `Health check passed (${results[0].envName}): HTTP ${results[0].status} (${results[0].elapsed}ms)`
96
+ )
97
+ );
98
+ }
99
+
100
+ if (failures.length > 0) {
101
+ throw new Error(failures[0].error);
56
102
  }
57
- console.log(chalk.green(`Health check passed: ${response.status} (${elapsed}ms)`));
58
103
  },
59
104
  },
60
105
  {
@@ -4,14 +4,21 @@ import fs from 'fs-extra';
4
4
  import path from 'path';
5
5
  import os from 'os';
6
6
  import axios from 'axios';
7
- import { loadConfig, loadEnv } from '../core/config.js';
7
+ import { loadConfig, loadEnv, getEnvMethod, getEnvSettings } from '../core/config.js';
8
+ import {
9
+ isEnvEnabled,
10
+ resolveDefaultEnvironmentName,
11
+ } from '../core/environments.js';
12
+ import { loadEnvArtifactHistory } from '../storage/index.js';
8
13
  import { testProvider } from '../storage/index.js';
9
14
  import { getDeploymentProvider } from '../deployment/index.js';
10
- import { PROVIDER_ENV_MAP, getRollbackWorkflowDoctorCheck } from '../utils/github-actions.js';
15
+ import { PROVIDER_ENV_MAP, getRollbackWorkflowDoctorCheck, getWorkflowDriftDoctorChecks } from '../utils/github-actions.js';
11
16
  import { printDoctorFooter } from '../utils/author.js';
12
17
  import { createLocalProvider } from '../storage/providers/local.js';
13
18
  import {
14
19
  getDeploymentEnvKeys,
20
+ getDeploymentSecretKeysForEnv,
21
+ envUsesPrefixedSecrets,
15
22
  getDeploymentSecretKeys,
16
23
  } from '../deployment/deployment-env.js';
17
24
  import { testSshConnectivity, validateSshKeyForDoctor, testSshHostReachability } from '../deployment/init-helpers.js';
@@ -27,6 +34,62 @@ import { namespaceExists } from '../utils/kubernetes-namespace.js';
27
34
  * @typedef {{ name: string, pass: boolean, message: string }} CheckResult
28
35
  */
29
36
 
37
+ /**
38
+ * Whether doctor should `process.exit(1)` — only blocking (non-informational) failures count.
39
+ * @param {CheckResult[]} results
40
+ * @param {Set<string>} informationalCheckNames
41
+ * @returns {boolean}
42
+ */
43
+ export function doctorHasBlockingFailures(results, informationalCheckNames) {
44
+ const blocking = results.filter((r) => !informationalCheckNames.has(r.name));
45
+ return blocking.some((r) => !r.pass);
46
+ }
47
+
48
+ /**
49
+ * Run per-env deployment checks with try/catch isolation (one env crash does not abort others).
50
+ * Disabled env failures are marked informational when `includeDisabled` is true.
51
+ *
52
+ * @param {import('../core/config.js').DeployHubConfig} config
53
+ * @param {string[]} envNames
54
+ * @param {{ includeDisabled?: boolean, runChecks?: typeof runDeploymentChecks }} [options]
55
+ * @returns {Promise<{ results: CheckResult[], informationalCheckNames: Set<string> }>}
56
+ */
57
+ export async function collectEnvDeploymentChecks(config, envNames, options = {}) {
58
+ const includeDisabled = options.includeDisabled === true;
59
+ const runChecks = options.runChecks || runDeploymentChecks;
60
+ /** @type {CheckResult[]} */
61
+ const results = [];
62
+ /** @type {Set<string>} */
63
+ const informationalCheckNames = new Set();
64
+
65
+ for (const envName of envNames) {
66
+ const env = config.environments?.[envName];
67
+ if (!env) continue;
68
+ const enabled = isEnvEnabled(env);
69
+ if (!enabled && !includeDisabled) continue;
70
+
71
+ try {
72
+ const deployChecks = await runChecks(config, envName, env);
73
+ for (const c of deployChecks) {
74
+ c.name = `${envName}/${c.name}`;
75
+ results.push(c);
76
+ if (!enabled) informationalCheckNames.add(c.name);
77
+ }
78
+ } catch (err) {
79
+ const message = err instanceof Error ? err.message : String(err);
80
+ const name = `${envName}/checks`;
81
+ results.push({
82
+ name,
83
+ pass: false,
84
+ message: `Check aborted: ${message}`,
85
+ });
86
+ if (!enabled) informationalCheckNames.add(name);
87
+ }
88
+ }
89
+
90
+ return { results, informationalCheckNames };
91
+ }
92
+
30
93
  /** @type {Set<string>} */
31
94
  const NODE_FRAMEWORKS = new Set(['express', 'nestjs', 'fastify', 'koa', 'nextjs', 'node']);
32
95
  /** @type {Set<string>} */
@@ -102,9 +165,10 @@ function needsNginxActivationForDeploy(config) {
102
165
  * @param {Record<string, unknown>} envConfig
103
166
  * @returns {Promise<CheckResult[]>}
104
167
  */
105
- async function runDeploymentChecks(config, envName, envConfig) {
106
- const deployType = envConfig.type;
168
+ export async function runDeploymentChecks(config, envName, envConfig) {
169
+ const deployType = getEnvMethod(envConfig);
107
170
  if (!deployType || typeof deployType !== 'string') return [];
171
+ const settings = getEnvSettings(envConfig);
108
172
 
109
173
  /** @type {CheckResult[]} */
110
174
  const checks = [];
@@ -134,10 +198,10 @@ async function runDeploymentChecks(config, envName, envConfig) {
134
198
  );
135
199
 
136
200
  if (SSH_DEPLOY_TYPES.has(deployType)) {
137
- const host = envConfig.host || process.env.SSH_HOST;
138
- const user = envConfig.user || process.env.SSH_USER;
139
- const keyPath = envConfig.keyPath || process.env.SSH_KEY_PATH;
140
- const sshPort = Number(process.env.SSH_SSH_PORT || envConfig.sshPort) || 22;
201
+ const host = settings.host || process.env.SSH_HOST;
202
+ const user = settings.user || process.env.SSH_USER;
203
+ const keyPath = settings.keyPath || process.env.SSH_KEY_PATH;
204
+ const sshPort = Number(process.env.SSH_SSH_PORT || settings.sshPort) || 22;
141
205
 
142
206
  checks.push(
143
207
  await runCheck('SSH key', async () => {
@@ -171,7 +235,7 @@ async function runDeploymentChecks(config, envName, envConfig) {
171
235
  })
172
236
  );
173
237
 
174
- const deployPaths = resolveSshDeployPaths(config, envConfig);
238
+ const deployPaths = resolveSshDeployPaths(config, settings);
175
239
  const sshUser = String(user || process.env.SSH_USER || 'your-user');
176
240
 
177
241
  for (const deployPath of deployPaths) {
@@ -634,11 +698,17 @@ export function registerDoctorCommand(program) {
634
698
  program
635
699
  .command('doctor')
636
700
  .description('Run pre-flight checks before deploying')
637
- .action(async () => {
701
+ .option(
702
+ '--all',
703
+ 'Also run method checks for disabled environments (failures are informational only)'
704
+ )
705
+ .action(async (opts) => {
638
706
  loadEnv();
639
707
  const cwd = process.cwd();
640
708
  /** @type {CheckResult[]} */
641
709
  const results = [];
710
+ /** @type {Set<string>} names of checks that must not fail the summary */
711
+ const informationalCheckNames = new Set();
642
712
 
643
713
  results.push(
644
714
  await runCheck('Git', async () => {
@@ -801,14 +871,49 @@ export function registerDoctorCommand(program) {
801
871
  }
802
872
  }
803
873
 
804
- for (const envName of config.deploy || []) {
805
- const env = config.environments[envName];
806
- if (!env) continue;
807
-
808
- const deployChecks = await runDeploymentChecks(config, envName, env);
809
- results.push(...deployChecks);
874
+ // Per-environment summary + method checks (enabled by default; --all includes disabled)
875
+ const envNames = Object.keys(config.environments || {});
876
+ if (envNames.length > 0) {
877
+ console.log('');
878
+ console.log(chalk.bold('Environments:'));
879
+ for (const envName of envNames) {
880
+ const env = config.environments[envName];
881
+ const method = getEnvMethod(env) || '?';
882
+ const enabled = isEnvEnabled(env);
883
+ const status = enabled ? 'enabled' : 'disabled';
884
+ let last = 'never';
885
+ try {
886
+ const { entries } = await loadEnvArtifactHistory(
887
+ config.storage || [],
888
+ config.project,
889
+ envName,
890
+ { defaultEnvironment: resolveDefaultEnvironmentName(config) }
891
+ );
892
+ if (entries[0]?.buildId) {
893
+ last = entries[0].buildId;
894
+ }
895
+ } catch {
896
+ // doctor never crashes
897
+ }
898
+ const icon = enabled ? chalk.green('✓') : chalk.gray('–');
899
+ const hint = enabled
900
+ ? `last deploy: ${last}`
901
+ : `run \`deployhub env enable ${envName}\``;
902
+ console.log(
903
+ ` ${icon} ${envName.padEnd(14)} (${method})`.padEnd(36) +
904
+ `${status.padEnd(10)} — ${hint}`
905
+ );
906
+ }
907
+ console.log('');
810
908
  }
811
909
 
910
+ const { results: envResults, informationalCheckNames: envInfo } =
911
+ await collectEnvDeploymentChecks(config, envNames, {
912
+ includeDisabled: Boolean(opts.all),
913
+ });
914
+ results.push(...envResults);
915
+ for (const name of envInfo) informationalCheckNames.add(name);
916
+
812
917
  results.push(
813
918
  await runCheck('Health endpoint', async () => {
814
919
  const url = config.healthCheck?.url;
@@ -851,10 +956,13 @@ export function registerDoctorCommand(program) {
851
956
  const keys = PROVIDER_ENV_MAP[provider] || [];
852
957
  required.push(...keys);
853
958
  }
854
- for (const envName of config.deploy || []) {
959
+ for (const envName of Object.keys(config.environments || {})) {
855
960
  const env = config.environments[envName];
856
- if (!env?.type) continue;
857
- required.push(...getDeploymentSecretKeys(env.type, config));
961
+ if (!isEnvEnabled(env)) continue;
962
+ const method = getEnvMethod(env);
963
+ if (!method) continue;
964
+ // Local/.env uses unprefixed names; prefixed CI names are checked separately below.
965
+ required.push(...getDeploymentSecretKeys(method, config));
858
966
  }
859
967
 
860
968
  const unique = [...new Set(required)];
@@ -871,10 +979,65 @@ export function registerDoctorCommand(program) {
871
979
  message: `Missing: ${missing.join(', ')}`,
872
980
  };
873
981
  }
874
- return { name: 'Secrets', pass: true, message: 'All required env vars present' };
982
+ return { name: 'Secrets', pass: true, message: 'All required secrets present' };
875
983
  })
876
984
  );
877
985
 
986
+ // Explicit CI-prefixed secret reminders for non-grandfathered environments
987
+ if (config) {
988
+ const inCi = process.env.GITHUB_ACTIONS === 'true' || process.env.CI === 'true';
989
+ for (const envName of Object.keys(config.environments || {})) {
990
+ const env = config.environments[envName];
991
+ if (!isEnvEnabled(env)) continue;
992
+ if (!envUsesPrefixedSecrets(envName, config)) continue;
993
+ const method = getEnvMethod(env);
994
+ if (!method) continue;
995
+
996
+ const prefixedKeys = getDeploymentSecretKeysForEnv(
997
+ envName,
998
+ method,
999
+ config,
1000
+ config.environments
1001
+ );
1002
+ const baseKeys = getDeploymentSecretKeys(method, config);
1003
+
1004
+ for (let i = 0; i < baseKeys.length; i++) {
1005
+ const base = baseKeys[i];
1006
+ const prefixed = prefixedKeys[i] || base;
1007
+ if (prefixed === base) continue;
1008
+
1009
+ const checkName = `CI secret ${prefixed}`;
1010
+ results.push(
1011
+ await runCheck(checkName, async () => {
1012
+ if (process.env[prefixed]) {
1013
+ return {
1014
+ name: checkName,
1015
+ pass: true,
1016
+ message: `Present for environment "${envName}"`,
1017
+ };
1018
+ }
1019
+ const localFallback =
1020
+ base === 'SSH_KEY'
1021
+ ? process.env.SSH_KEY || process.env.SSH_KEY_PATH
1022
+ : process.env[base];
1023
+ if (!inCi && localFallback) {
1024
+ return {
1025
+ name: checkName,
1026
+ pass: false,
1027
+ message: `Add GitHub Actions secret "${prefixed}" for env "${envName}" (local has ${base}, but CI needs the prefixed name)`,
1028
+ };
1029
+ }
1030
+ return {
1031
+ name: checkName,
1032
+ pass: false,
1033
+ message: `Missing — add GitHub Actions secret "${prefixed}" for environment "${envName}"`,
1034
+ };
1035
+ })
1036
+ );
1037
+ }
1038
+ }
1039
+ }
1040
+
878
1041
  results.push(
879
1042
  await runCheck('GitHub Actions', async () => {
880
1043
  const workflowPath = path.join(cwd, '.github', 'workflows', 'deployhub.yml');
@@ -910,6 +1073,13 @@ export function registerDoctorCommand(program) {
910
1073
  );
911
1074
  }
912
1075
 
1076
+ if (config) {
1077
+ const driftChecks = await getWorkflowDriftDoctorChecks(cwd, config);
1078
+ for (const check of driftChecks) {
1079
+ results.push(await runCheck(check.name, async () => check));
1080
+ }
1081
+ }
1082
+
913
1083
  results.push(
914
1084
  await runCheck('Storage write', async () => {
915
1085
  const provider = createLocalProvider();
@@ -928,14 +1098,21 @@ export function registerDoctorCommand(program) {
928
1098
  );
929
1099
 
930
1100
  console.log('');
931
- const pad = (name) => name.padEnd(22);
1101
+ const pad = (name) => name.padEnd(28);
932
1102
  for (const r of results) {
933
- const icon = r.pass ? chalk.green('✓') : chalk.red('✗');
934
- console.log(` Checking ${pad(r.name)}... ${icon} ${r.message}`);
1103
+ const info = informationalCheckNames.has(r.name);
1104
+ const icon = r.pass
1105
+ ? chalk.green('✓')
1106
+ : info
1107
+ ? chalk.yellow('!')
1108
+ : chalk.red('✗');
1109
+ const suffix = info && !r.pass ? chalk.gray(' (informational — disabled env)') : '';
1110
+ console.log(` Checking ${pad(r.name)}... ${icon} ${r.message}${suffix}`);
935
1111
  }
936
1112
 
937
- const passed = results.filter((r) => r.pass).length;
938
- const total = results.length;
1113
+ const blocking = results.filter((r) => !informationalCheckNames.has(r.name));
1114
+ const passed = blocking.filter((r) => r.pass).length;
1115
+ const total = blocking.length;
939
1116
  console.log('');
940
1117
  if (passed === total) {
941
1118
  console.log(chalk.green.bold(` ✓ Ready to deploy (${passed}/${total} checks passed)`));
@@ -950,7 +1127,16 @@ export function registerDoctorCommand(program) {
950
1127
  console.log('');
951
1128
  printDoctorFooter();
952
1129
  console.log('');
1130
+
1131
+ if (doctorHasBlockingFailures(results, informationalCheckNames)) {
1132
+ process.exit(1);
1133
+ }
953
1134
  });
954
1135
  }
955
1136
 
956
- export default { registerDoctorCommand };
1137
+ export default {
1138
+ registerDoctorCommand,
1139
+ runDeploymentChecks,
1140
+ doctorHasBlockingFailures,
1141
+ collectEnvDeploymentChecks,
1142
+ };