@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.
package/README.md CHANGED
@@ -86,7 +86,7 @@ This interactive wizard will:
86
86
  - Set up storage providers (AWS, Google Drive, Azure, GCP, Dropbox, Local)
87
87
  - Optionally configure deployment targets (SSH, Docker, EC2, Azure VM, GCP VM, Kubernetes)
88
88
  - Generate `deployhub.config.json`
89
- - Generate `.github/workflows/deployhub.yml`
89
+ - Generate `.github/workflows/deployhub.yml` and `.github/workflows/deployhub-rollback.yml`
90
90
  - Generate `.env.example`
91
91
 
92
92
  ### 2. Configure credentials
@@ -182,7 +182,8 @@ The wizard asks the same core questions for every setup:
182
182
  **Generated files:**
183
183
 
184
184
  - `deployhub.config.json` — project settings (no secrets)
185
- - `.github/workflows/deployhub.yml` — CI pipeline
185
+ - `.github/workflows/deployhub.yml` — CI deploy pipeline (push to main)
186
+ - `.github/workflows/deployhub-rollback.yml` — manual CI rollback (`workflow_dispatch`)
186
187
  - `.env.example` — list of env vars you may need
187
188
  - `nginx.conf` — auto-generated if frontend deploys to SSH
188
189
  - `Dockerfile` — auto-generated if missing and you chose Docker or Kubernetes deploy (your existing `Dockerfile` is never overwritten)
@@ -228,12 +229,41 @@ Or push to `main` / `master` — the generated workflow runs the same command.
228
229
  deployhub artifact list # local artifacts
229
230
  deployhub artifact list --remote # include storage history.json
230
231
  deployhub artifact restore <buildId> # download a past build
231
- deployhub deploy # deploy latest artifact without rebuilding
232
- deployhub rollback # previous build from history
233
- 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
+
248
+ deployhub sync-workflows # regenerate deploy + rollback GitHub Actions YAML
249
+ deployhub sync-k8s-ports # fix containerPort/targetPort in existing k8s manifests
234
250
  deployhub logs # last deployment logs
235
251
  ```
236
252
 
253
+ ### Rollback behavior
254
+
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:
256
+
257
+ | Argument | Behavior |
258
+ |----------|----------|
259
+ | *(none)* | Rolls back to the **previous** build (second entry in newest-first history) |
260
+ | Exact `buildId` | Rolls back to that specific build |
261
+ | Semver / version string matching **multiple** builds | Does **not** guess — prints the matching `buildId`s and exits; re-run with an exact one |
262
+
263
+ `buildId` looks like `{semver}-{stamp}` where the stamp is a short git SHA when available, otherwise a CI run id, otherwise a high-resolution timestamp. When `DOCKER_IMAGE_TAG` is left unset, Docker and Kubernetes use that same `buildId` as the image tag — so you can correlate an artifact in storage with the image that was pushed.
264
+
265
+ For CI-triggered rollback, see [CI rollback](#ci-rollback-deployhub-rollbackyml).
266
+
237
267
  ---
238
268
 
239
269
  ## Walkthrough: Storage only
@@ -276,12 +306,14 @@ Artifacts appear under `artifact/{projectName}/{date}/v{buildId}/` locally.
276
306
  **Remote storage** (S3 and other providers) uses a different layout:
277
307
 
278
308
  ```text
279
- {project}/builds/{buildId}/artifact.zip # immutable per CI/build
280
- {project}/history.json # newest-first index for rollback / list --remote
281
- {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)
282
314
  ```
283
315
 
284
- `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`.
285
317
 
286
318
  ### Same steps for other languages
287
319
 
@@ -555,17 +587,27 @@ All JS frontends share the same install/build flow: `npm ci` → `npm run build`
555
587
  After `init`, commit these files:
556
588
 
557
589
  ```bash
558
- git add deployhub.config.json .github/workflows/deployhub.yml .env.example
590
+ git add deployhub.config.json .github/workflows/deployhub.yml .github/workflows/deployhub-rollback.yml .env.example
559
591
  git commit -m "Add DeployHub CI"
560
592
  ```
561
593
 
562
594
  1. Open **Settings → Secrets and variables → Actions** in your GitHub repo.
563
595
  2. Add every secret listed at the end of `deployhub init` (storage + deployment).
564
- 3. Push to `main` or `master` — the workflow triggers on push.
596
+ 3. Push to `main` or `master` — the deploy workflow triggers on push.
597
+
598
+ The deploy workflow (`deployhub.yml`) installs the correct language runtime (Node, Python, Java, Go, .NET, Ruby) based on your `deployhub.config.json`, installs DeployHub, runs `deployhub build`, and uses your secrets.
599
+
600
+ To run a deploy manually: **Actions → DeployHub → Run workflow**.
565
601
 
566
- The workflow installs the correct language runtime (Node, Python, Java, Go, .NET, Ruby) based on your `deployhub.config.json`, installs DeployHub, runs `deployhub build`, and uses your secrets.
602
+ ### CI rollback (`deployhub-rollback.yml`)
567
603
 
568
- To run manually: **Actions DeployHub Run workflow**.
604
+ `init` also generates a separate **DeployHub Rollback** workflow (`deployhub-rollback.yml`). It is triggered only via GitHub Actions' **Run workflow** button (not on push):
605
+
606
+ 1. Open **Actions → DeployHub Rollback → Run workflow**.
607
+ 2. Optionally enter an exact `buildId` (leave blank to roll back to the previous build).
608
+ 3. Run the workflow.
609
+
610
+ Already-initialized projects that only have `deployhub.yml` will not get the rollback workflow from a re-run of unrelated commands — run **`deployhub sync-workflows`** once to regenerate both workflow files from your current `deployhub.config.json`, then commit and push.
569
611
 
570
612
  ---
571
613
 
@@ -648,6 +690,8 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
648
690
 
649
691
  ### SSH
650
692
 
693
+ **Verification:** Real-world verified DEPLOY and ROLLBACK.
694
+
651
695
  **Prerequisites (before `deployhub init`):**
652
696
  - [ ] Complete **[one-time server setup](#one-time-server-setup-before-your-first-deploy)** (deploy path ownership + Nginx/sudo for frontends)
653
697
  - [ ] A Linux server with SSH enabled
@@ -687,7 +731,7 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
687
731
 
688
732
  ### Docker
689
733
 
690
- **Verification:** Real-world verified (local and CI Docker deploys).
734
+ **Verification:** Real-world verified DEPLOY and ROLLBACK (local and CI).
691
735
 
692
736
  **Prerequisites:**
693
737
  - [ ] Docker installed (`docker --version` works)
@@ -722,6 +766,8 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
722
766
 
723
767
  ### AWS EC2
724
768
 
769
+ **Verification:** Real-world verified DEPLOY and ROLLBACK.
770
+
725
771
  **Prerequisites:**
726
772
  - [ ] Complete **[one-time server setup](#one-time-server-setup-before-your-first-deploy)** (`ec2-user` on Amazon Linux)
727
773
  - [ ] EC2 instance launched in AWS Console (DeployHub does not create it)
@@ -758,6 +804,8 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
758
804
 
759
805
  ### Azure VM
760
806
 
807
+ **Verification:** Real-world verified DEPLOY. Rollback logic confirmed via shared-SSH-path audit; not yet live-tested independently.
808
+
761
809
  **Prerequisites:**
762
810
  - [ ] Complete **[one-time server setup](#one-time-server-setup-before-your-first-deploy)** (`azureuser` or your VM login user)
763
811
  - [ ] Azure VM created in Portal (DeployHub does not provision it)
@@ -792,6 +840,8 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
792
840
 
793
841
  ### GCP VM
794
842
 
843
+ **Verification:** Real-world verified DEPLOY. Rollback logic confirmed via shared-SSH-path audit; not yet live-tested independently.
844
+
795
845
  **Prerequisites:**
796
846
  - [ ] Complete **[one-time server setup](#one-time-server-setup-before-your-first-deploy)** (your GCP SSH username)
797
847
  - [ ] Compute Engine VM created (DeployHub does not create it)
@@ -832,7 +882,7 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
832
882
 
833
883
  ### Kubernetes
834
884
 
835
- **Verification:** Real-world verified (including k3s and CI deploys).
885
+ **Verification:** Real-world verified DEPLOY and ROLLBACK (including k3s and CI).
836
886
 
837
887
  **Prerequisites:**
838
888
  - [ ] Existing Kubernetes cluster (DeployHub does not provision clusters)
@@ -868,7 +918,7 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
868
918
  6. Add GitHub Secrets for CI (see table — **`KUBECONFIG` must be the file contents, not a path**)
869
919
  7. Run `deployhub doctor`, then `git push origin main`
870
920
 
871
- > **Warning Service `targetPort`:** the default `targetPort` in generated `k8s/service.yaml` may not match your app's actual exposed port (e.g. a static nginx image serves on port **80**, not the config's default like 3000). Verify and adjust `k8s/service.yaml`'s `targetPort` (and the Deployment `containerPort` if needed) before your first deploy.
921
+ > **Ports in generated Kubernetes manifests:** `containerPort` and `targetPort` are derived from your project's Dockerfile `EXPOSE` line (frontend / nginx images correctly get **80**; backends get their real exposed port). If no Dockerfile or usable `EXPOSE` exists yet, DeployHub falls back to a per-project-type default that matches its Dockerfile templates. Fresh `init` does **not** require a manual port edit. The Service's external `port:` stays **80** for all project types by design (Ingress-friendly); only the internal `targetPort` / `containerPort` track the container. Already-initialized projects with stale `3000` values can run `deployhub sync-k8s-ports` to patch just those fields.
872
922
 
873
923
  | Variable | Description | Example | Where to get it |
874
924
  |----------|-------------|---------|-----------------|
@@ -952,21 +1002,35 @@ Run `deployhub doctor` after any config change.
952
1002
 
953
1003
  | Command | Description |
954
1004
  |---------|-------------|
955
- | `deployhub init` | Interactive project setup |
956
- | `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) |
957
1007
  | `deployhub artifact create` | Create artifact from current build |
958
1008
  | `deployhub artifact list [--remote]` | List local artifacts; `--remote` merges storage `history.json` |
959
1009
  | `deployhub artifact restore <buildId\|semver>` | Download and extract an artifact |
960
1010
  | `deployhub storage add <provider>` | Add storage provider credentials |
961
1011
  | `deployhub storage list` | List storage providers and connection status |
962
- | `deployhub deploy` | Deploy latest artifact |
963
- | `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) |
1024
+ | `deployhub sync-workflows` | Regenerate `.github/workflows/deployhub.yml` and `deployhub-rollback.yml` from config |
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 |
964
1026
  | `deployhub logs` | Show logs from last deployment |
965
- | `deployhub doctor` | Pre-flight checks |
1027
+ | `deployhub doctor [--all]` | Pre-flight checks per environment (`--all` includes disabled envs as informational) |
966
1028
  | `deployhub verify` | Health check on configured endpoint |
967
1029
  | `deployhub clean` | Remove old local artifacts |
968
1030
  | `deployhub update` | Check for CLI updates |
969
1031
 
1032
+ **Tests:** `npm test` — currently **270 passing** across the Jest suites.
1033
+
970
1034
  ## GitHub Secrets
971
1035
 
972
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.
@@ -1009,6 +1073,8 @@ Add these secrets in your repository (Settings → Secrets and variables → Act
1009
1073
  | `DOCKER_IMAGE_NAME`, `DOCKER_REGISTRY_USERNAME`, `DOCKER_REGISTRY_TOKEN`, `DOCKER_REGISTRY_URL`, `DOCKER_HOST` | Docker deployment (`DOCKER_IMAGE_TAG` optional) |
1010
1074
  | `KUBECONFIG`, `KUBE_CONTEXT`, `KUBE_NAMESPACE`, `DOCKER_IMAGE_NAME`, `DOCKER_REGISTRY_USERNAME`, `DOCKER_REGISTRY_TOKEN`, `DOCKER_REGISTRY_URL`, `DOCKER_IMAGE_TAG`, `KUBE_IMAGE_PULL_SECRET` | Kubernetes — **`KUBECONFIG` in GitHub Secrets must be the kubeconfig file contents (or base64), not a filesystem path**. `DOCKER_IMAGE_TAG`, `KUBE_NAMESPACE`, `DOCKER_REGISTRY_URL`, and `KUBE_IMAGE_PULL_SECRET` are optional |
1011
1075
 
1076
+ **Kubernetes rollback vs deploy — registry credentials:** Kubernetes **rollback** specifically **requires** `DOCKER_REGISTRY_USERNAME` and `DOCKER_REGISTRY_TOKEN`. Rollback rebuilds and must push a fresh image tagged with the restored `buildId`; without registry credentials it fails loudly and early (a rollback that cannot push can never succeed against a real cluster). This is stricter than a normal Kubernetes **deploy**, which may still allow local-only / no-push flows in some setups. If deploy worked without those secrets but rollback fails asking for them, that asymmetry is intentional.
1077
+
1012
1078
  See [Deployment method guides](#deployment-method-guides) for full per-method variable tables with examples.
1013
1079
 
1014
1080
  ## `deployhub doctor` Output
@@ -1025,11 +1091,16 @@ The doctor command runs independent checks and always completes without crashing
1025
1091
  Checking Health endpoint... ✓ URL reachable (HTTP 200)
1026
1092
  Checking Secrets... ✓ All required env vars present
1027
1093
  Checking GitHub Actions... ✓ Workflow file exists at .github/workflows/deployhub.yml
1094
+ Checking Rollback workflow... ✓ Missing .github/workflows/deployhub-rollback.yml — run deployhub sync-workflows to add CI rollback (workflow_dispatch)
1028
1095
  Checking Storage write... ✓ Test upload succeeded
1029
1096
 
1030
- ✓ Ready to deploy (10/10 checks passed)
1097
+ ✓ Ready to deploy (11/11 checks passed)
1031
1098
  ```
1032
1099
 
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.
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
+
1033
1104
  If checks fail:
1034
1105
 
1035
1106
  ```
@@ -1062,9 +1133,11 @@ artifact/
1062
1133
  **Remote keys** (all storage providers):
1063
1134
 
1064
1135
  ```
1065
- {project}/builds/{buildId}/artifact.zip
1066
- {project}/history.json
1067
- {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)
1068
1141
  ```
1069
1142
 
1070
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.14",
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
@@ -14,6 +14,8 @@ import { registerVerifyCommand } from '../commands/verify.js';
14
14
  import { registerCleanCommand } from '../commands/clean.js';
15
15
  import { registerUpdateCommand } from '../commands/update.js';
16
16
  import { registerSyncWorkflowsCommand } from '../commands/sync-workflows.js';
17
+ import { registerSyncK8sPortsCommand } from '../commands/sync-k8s-ports.js';
18
+ import { registerEnvCommand } from '../commands/env.js';
17
19
  import { formatVersionOutput, printBanner, shouldShowBanner } from '../utils/author.js';
18
20
 
19
21
  loadEnv();
@@ -43,5 +45,7 @@ registerVerifyCommand(program);
43
45
  registerCleanCommand(program);
44
46
  registerUpdateCommand(program);
45
47
  registerSyncWorkflowsCommand(program);
48
+ registerSyncK8sPortsCommand(program);
49
+ registerEnvCommand(program);
46
50
 
47
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
  {