@akash-chowdhury-24/deployhub 2.0.17 → 2.0.19

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
@@ -257,15 +257,78 @@ deployhub logs # last deployment logs
257
257
  | Argument | Behavior |
258
258
  |----------|----------|
259
259
  | *(none)* | Rolls back to the **previous** build (second entry in newest-first history) |
260
- | Exact `buildId` | Rolls back to that specific build |
260
+ | Exact `buildId` | Rolls back to that specific build **if it appears in that env's history** |
261
261
  | Semver / version string matching **multiple** builds | Does **not** guess — prints the matching `buildId`s and exits; re-run with an exact one |
262
262
 
263
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
264
 
265
+ ### Rollback is scoped per environment
266
+
267
+ Each environment maintains its own independent deploy history. When you roll back an environment, DeployHub only considers builds that were actually deployed **to that environment** — never builds deployed to a different environment, even if they're more recent or share the same project.
268
+
269
+ For example: if `development` has deployed builds A, C, and D (in that order), and `production` has separately deployed builds B and D, then rolling back `production` moves it from D to B — **not** to C, even though C is technically a more recent build overall, because C was never deployed to `production` in the first place.
270
+
271
+ You also cannot roll back an environment to a specific `buildId` that was never deployed to that environment — even with `deployhub rollback <buildId> --env <name>`, DeployHub will refuse if that buildId doesn't appear in that environment's own history. This guarantees every rollback returns an environment to a build that was genuinely running there before, never a build it never actually had.
272
+
273
+ Storage layout (per project):
274
+
275
+ ```text
276
+ {project}/builds/{buildId}/artifact.zip # immutable build blobs (shared)
277
+ {project}/envs/{env}/history.json # that env's deploy history only
278
+ {project}/envs/{env}/latest/artifact.zip # last successful deploy to that env
279
+ {project}/history.json # build catalog / legacy default-env history
280
+ ```
281
+
265
282
  For CI-triggered rollback, see [CI rollback](#ci-rollback-deployhub-rollbackyml).
266
283
 
267
284
  ---
268
285
 
286
+ ## Multi-environment deployments
287
+
288
+ DeployHub supports multiple named environments in one project (e.g. `development` + `production`), each with its own method, secrets, trigger, and deploy history.
289
+
290
+ ### Commands
291
+
292
+ ```bash
293
+ deployhub env list
294
+ deployhub env add staging --method ssh # or omit --method for interactive prompts
295
+ deployhub env enable staging
296
+ deployhub env disable staging
297
+ deployhub env remove staging
298
+
299
+ deployhub deploy --env staging
300
+ deployhub deploy --env all # every enabled environment
301
+
302
+ deployhub rollback --env production
303
+ deployhub rollback 1.2.3-abc1234 --env staging
304
+ deployhub rollback --env all # each env rolls back independently
305
+
306
+ deployhub sync-workflows # regenerate deployhub.yml + deployhub-rollback.yml
307
+ ```
308
+
309
+ ### Trigger defaults
310
+
311
+ | Situation | Default `trigger` |
312
+ |-----------|-------------------|
313
+ | Single-environment `init` | `"push"` — auto-deploy on push to main (original DeployHub behavior) |
314
+ | First / grandfathered env in multi-env `init` | `"push"` |
315
+ | Additional environments | `"manual"` — deploy only via Actions → Run workflow or `deployhub deploy --env` |
316
+
317
+ Multi-env `init` prints a reminder naming which environments are push vs manual and how to edit `deployhub.config.json` (`environments.<name>.trigger`) if you want a different mix. After changing triggers or envs, run `deployhub sync-workflows` and commit the regenerated YAML.
318
+
319
+ On a GitHub Actions **push**, `deployhub build` only auto-deploys environments with `trigger: "push"`. Environments with `trigger: "manual"` are never deployed on push — even though their secrets are present in the job for dispatch/rollback.
320
+
321
+ ### Secret naming
322
+
323
+ | Environment | GitHub Secret / env var style |
324
+ |-------------|-------------------------------|
325
+ | Grandfathered / original (`unprefixedSecretEnvironment`) | Unprefixed: `SSH_HOST`, `SSH_KEY`, `DOCKER_IMAGE_NAME`, … |
326
+ | Every additional environment | Prefixed: `PRODUCTION_SSH_HOST`, `STAGING_DOCKER_IMAGE_NAME`, … |
327
+
328
+ Build and Dispatch workflow steps share the **same** secret set (all enabled environments). Trigger only controls which environments are deployed on push — not which secrets are injected.
329
+
330
+ ---
331
+
269
332
  ## Walkthrough: Storage only
270
333
 
271
334
  Use this when you want **versioned build artifacts in the cloud** but deploy manually (or add deployment later).
@@ -909,6 +972,8 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
909
972
  - Cluster connectivity test during `init`
910
973
  - On deploy: registry login → reuse or build image → push (unique tag unless `DOCKER_IMAGE_TAG` is set) → ensure namespace exists (prompt locally / auto-create in CI) → `kubectl apply` → `kubectl set image` with the full resolved image ref → `kubectl rollout restart` when that ref is unchanged so pods pick up a new digest
911
974
 
975
+ > **Limitation — multiple Kubernetes clusters:** A generated workflow writes **one** kubeconfig file per job. Multiple Kubernetes environments that target **different clusters** in the same workflow run are not yet fully supported (follow-up). Same-cluster multi-namespace / multi-env is fine.
976
+
912
977
  **After `init`:**
913
978
  1. Verify context: `kubectl config get-contexts`
914
979
  2. Copy `.env.example` → `.env`; set `DOCKER_IMAGE_NAME`, registry username/token, and (for local deploys) `KUBECONFIG` / `KUBE_CONTEXT` / `KUBE_NAMESPACE` as needed
@@ -1029,7 +1094,7 @@ Run `deployhub doctor` after any config change.
1029
1094
  | `deployhub clean` | Remove old local artifacts |
1030
1095
  | `deployhub update` | Check for CLI updates |
1031
1096
 
1032
- **Tests:** `npm test` — currently **270 passing** across the Jest suites.
1097
+ **Tests:** `npm test` — currently **320 passing** across the Jest suites.
1033
1098
 
1034
1099
  ## GitHub Secrets
1035
1100
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akash-chowdhury-24/deployhub",
3
- "version": "2.0.17",
3
+ "version": "2.0.19",
4
4
  "description": "Zero-configuration deployment and artifact manager",
5
5
  "type": "module",
6
6
  "main": "./src/cli/index.js",
@@ -161,8 +161,13 @@ export function resolveDefaultEnvironmentName(config) {
161
161
  }
162
162
 
163
163
  /**
164
- * Enabled environment names (for `--env all` and pipeline defaults).
164
+ * Enabled environment names **the single source of truth** for “which envs
165
+ * apply” across CLI, pipeline gating, workflow secret injection, and dropdowns.
166
+ * Do not add a parallel helper that re-filters `environments` by `enabled`
167
+ * (that shape caused Build/Dispatch secret asymmetry when lists drifted).
168
+ *
165
169
  * Falls back to legacy `deploy[]` when present and environments lack `enabled`.
170
+ * Used for `--env all` and pipeline defaults.
166
171
  *
167
172
  * @param {Record<string, unknown>} config
168
173
  * @returns {string[]}
@@ -24,11 +24,14 @@ import {
24
24
  * - GitHub Actions push: every enabled env with trigger "push".
25
25
  * - workflow_dispatch: none here (explicit deploy step handles --env).
26
26
  *
27
+ * Separate from workflow secret injection: CI may inject secrets for all
28
+ * enabled envs, but only `trigger: "push"` envs are deployed on push.
29
+ *
27
30
  * @param {import('./config.js').DeployHubConfig} config
28
31
  * @param {Record<string, string|undefined>} [env]
29
32
  * @returns {string[]}
30
33
  */
31
- function pipelineDeployTargets(config, env = process.env) {
34
+ export function pipelineDeployTargets(config, env = process.env) {
32
35
  if (env.GITHUB_EVENT_NAME === 'workflow_dispatch') {
33
36
  return [];
34
37
  }
@@ -5,7 +5,11 @@ import path from 'path';
5
5
  * @param {Record<string, string>} [_env]
6
6
  */
7
7
  export function createLocalProvider(_env = process.env) {
8
- const baseDir = path.join(process.cwd(), '.deployhub-storage');
8
+ // Tests / isolated runs may pin storage under a temp dir without process.chdir
9
+ // (chdir races when Jest runs suites in parallel).
10
+ const baseDir = _env.DEPLOYHUB_LOCAL_STORAGE_DIR
11
+ ? path.resolve(_env.DEPLOYHUB_LOCAL_STORAGE_DIR)
12
+ : path.join(process.cwd(), '.deployhub-storage');
9
13
 
10
14
  /**
11
15
  * @param {string} localPath
@@ -1,8 +1,10 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'path';
3
3
  import yaml from 'js-yaml';
4
+ import chalk from 'chalk';
5
+ import semver from 'semver';
4
6
  import { fileURLToPath } from 'url';
5
- import { getWorkflowHeaderComment } from './author.js';
7
+ import { getWorkflowHeaderComment, getDeployHubVersion } from './author.js';
6
8
  import {
7
9
  generateDeploymentEnvSection,
8
10
  getDeploymentWorkflowSecretKeys,
@@ -14,7 +16,11 @@ import {
14
16
  envUsesPrefixedSecrets,
15
17
  DEPLOYMENT_ENV_KEYS,
16
18
  } from '../deployment/deployment-env.js';
17
- import { getEnvMethod, getEnvTrigger, isEnvEnabled } from '../core/environments.js';
19
+ import {
20
+ getEnvMethod,
21
+ getEnabledEnvironmentNames,
22
+ isEnvEnabled,
23
+ } from '../core/environments.js';
18
24
 
19
25
  /** @typedef {'aws'|'azure'|'gcp'|'gdrive'|'dropbox'|'local'|'ftp'|'ssh'} ProviderEnvKey */
20
26
 
@@ -140,28 +146,63 @@ export function getCliInstallSpec(cliSource) {
140
146
  * Version/range suitable as a package.json dependency VALUE for this CLI
141
147
  * (key is already NPM_PACKAGE — do not embed the package name again).
142
148
  *
149
+ * Reads the running CLI's package version dynamically. Never hardcode a
150
+ * specific semver fallback (e.g. "1.0.6") — if resolution fails, use "latest".
151
+ *
143
152
  * @param {string} [cliSource]
153
+ * @param {{ packageJsonPath?: string }} [opts] — test override: read this package.json
144
154
  * @returns {string}
145
155
  */
146
- export function getCliPackageJsonDependencyVersion(cliSource) {
156
+ export function getCliPackageJsonDependencyVersion(cliSource, opts = {}) {
147
157
  const normalized = normalizeCliSource(cliSource);
148
158
  if (normalized === DEFAULT_NPM_CLI_SOURCE) {
159
+ const resolved = readCliPackageVersion(opts.packageJsonPath);
160
+ if (resolved) return `^${resolved}`;
161
+ return 'latest';
162
+ }
163
+ // github: / file: specs are valid package.json dependency values as-is
164
+ return getCliInstallSpec(cliSource);
165
+ }
166
+
167
+ /**
168
+ * Resolve the running CLI package version for dependency ranges.
169
+ * Order: explicit package.json path (tests) → package.json next to this
170
+ * package root → getDeployHubVersion() (covers __DEPLOYHUB_VERSION__ in
171
+ * pkg binaries). Never returns a hardcoded stale semver.
172
+ *
173
+ * @param {string} [packageJsonPath]
174
+ * @returns {string|null}
175
+ */
176
+ function readCliPackageVersion(packageJsonPath) {
177
+ const candidates = [];
178
+ if (packageJsonPath) {
179
+ candidates.push(packageJsonPath);
180
+ } else {
181
+ candidates.push(
182
+ path.join(path.dirname(fileURLToPath(import.meta.url)), '../../package.json')
183
+ );
184
+ }
185
+
186
+ for (const pkgPath of candidates) {
149
187
  try {
150
- const pkgPath = path.join(
151
- path.dirname(fileURLToPath(import.meta.url)),
152
- '../../package.json'
153
- );
154
188
  const pkg = fs.readJsonSync(pkgPath);
155
- if (typeof pkg.version === 'string' && pkg.version.trim()) {
156
- return `^${pkg.version.trim()}`;
189
+ if (typeof pkg.version === 'string' && /^\d+\.\d+\.\d+/.test(pkg.version.trim())) {
190
+ return pkg.version.trim();
157
191
  }
158
192
  } catch {
159
- // fall through
193
+ // try next
160
194
  }
161
- return 'latest';
162
195
  }
163
- // github: / file: specs are valid package.json dependency values as-is
164
- return getCliInstallSpec(cliSource);
196
+
197
+ try {
198
+ const v = getDeployHubVersion();
199
+ if (typeof v === 'string' && /^\d+\.\d+\.\d+/.test(v.trim())) {
200
+ return v.trim();
201
+ }
202
+ } catch {
203
+ // ignore
204
+ }
205
+ return null;
165
206
  }
166
207
 
167
208
  /**
@@ -259,9 +300,41 @@ function hasKubernetesDeploy(deployEnvironments, environments) {
259
300
  }
260
301
 
261
302
  /**
303
+ * Secret name for the Configure-kubeconfig setup step.
304
+ * Must match the env that actually uses kubernetes — when the only k8s env is
305
+ * non-grandfathered (e.g. production), that is PRODUCTION_KUBECONFIG, not the
306
+ * unprefixed KUBECONFIG (which would never be injected and leave the file empty).
307
+ *
308
+ * LIMITATION (follow-up): GitHub Actions writes exactly ONE kubeconfig file per
309
+ * job. Multiple Kubernetes environments that target DIFFERENT clusters in the
310
+ * same workflow run are not yet fully supported — only the first/grandfathered
311
+ * k8s env's secret is used for the setup step. Track as a product follow-up
312
+ * before advertising multi-cluster multi-env CI.
313
+ *
314
+ * @param {string[]} deployEnvironments
315
+ * @param {Record<string, { type?: string, method?: string }>} environments
316
+ * @param {import('../core/config.js').DeployHubConfig|null} config
317
+ * @returns {string}
318
+ */
319
+ function resolveKubeconfigWorkflowSecretName(deployEnvironments, environments, config) {
320
+ const cfg = { ...(config || {}), environments };
321
+ const k8sNames = deployEnvironments.filter(
322
+ (n) => getEnvMethod(environments[n]) === 'kubernetes'
323
+ );
324
+ if (k8sNames.length === 0) return 'KUBECONFIG';
325
+ const unprefixed = k8sNames.find((n) => !envUsesPrefixedSecrets(n, cfg));
326
+ const chosen = unprefixed || k8sNames[0];
327
+ return envUsesPrefixedSecrets(chosen, cfg)
328
+ ? prefixSecretKey(chosen, 'KUBECONFIG')
329
+ : 'KUBECONFIG';
330
+ }
331
+
332
+ /**
333
+ * @param {string} [kubeconfigSecretName]
262
334
  * @returns {string}
263
335
  */
264
- function getKubernetesSetupSteps() {
336
+ function getKubernetesSetupSteps(kubeconfigSecretName = 'KUBECONFIG') {
337
+ // One kubeconfig file per job — see resolveKubeconfigWorkflowSecretName LIMITATION.
265
338
  return ` - name: Setup kubectl
266
339
  uses: azure/setup-kubectl@v4
267
340
  with:
@@ -269,7 +342,7 @@ function getKubernetesSetupSteps() {
269
342
 
270
343
  - name: Configure kubeconfig
271
344
  env:
272
- KUBECONFIG_SECRET: \${{ secrets.KUBECONFIG }}
345
+ KUBECONFIG_SECRET: \${{ secrets.${kubeconfigSecretName} }}
273
346
  run: |
274
347
  mkdir -p "$GITHUB_WORKSPACE/.kube"
275
348
  if echo "$KUBECONFIG_SECRET" | base64 -d > "$GITHUB_WORKSPACE/.kube/config" 2>/dev/null; then
@@ -316,27 +389,6 @@ function upsertWorkflowEnvLine(envVars, lhs, valueExpr) {
316
389
  envVars.add(`${linePrefix}${valueExpr}`);
317
390
  }
318
391
 
319
- /**
320
- * Enabled environment names from the live `environments` map (preferred over
321
- * a possibly-stale `deploy[]` argument passed into workflow generators).
322
- * @param {Record<string, { type?: string, method?: string }>} environments
323
- * @returns {string[]}
324
- */
325
- function listEnabledEnvironmentNames(environments) {
326
- return Object.keys(environments || {}).filter((n) => isEnvEnabled(environments[n]));
327
- }
328
-
329
- /**
330
- * Push-triggered enabled envs — same set `pipelineDeployTargets` deploys in GHA.
331
- * @param {Record<string, { type?: string, method?: string }>} environments
332
- * @returns {string[]}
333
- */
334
- function listPushEnvironmentNames(environments) {
335
- return listEnabledEnvironmentNames(environments).filter(
336
- (n) => getEnvTrigger(environments[n]) === 'push'
337
- );
338
- }
339
-
340
392
  /**
341
393
  * Shared env entries for deploy and rollback workflows (same secret resolution).
342
394
  * Multi-env: each environment contributes its own CI secret names (grandfathered
@@ -400,9 +452,8 @@ export function buildWorkflowEnvEntries(
400
452
  * @returns {string}
401
453
  */
402
454
  function formatEnvironmentChoiceOptions(environments) {
403
- const names = Object.keys(environments || {}).filter((n) =>
404
- isEnvEnabled(environments[n])
405
- );
455
+ // Single source of truth: getEnabledEnvironmentNames (do not re-filter here).
456
+ const names = getEnabledEnvironmentNames({ environments });
406
457
  const options = [...names, 'all'];
407
458
  return options.map((n) => ` - ${n}`).join('\n');
408
459
  }
@@ -457,11 +508,10 @@ export function generateWorkflowYaml(
457
508
  config = null
458
509
  ) {
459
510
  const envNames = Object.keys(environments || {});
460
- // Secret injection must follow live environments (same as pipelineDeployTargets),
461
- // not a possibly-stale deploy[] argument otherwise push-deployed envs missing
462
- // from deploy[] get no secrets in the job env block.
463
- const enabledEnvs = listEnabledEnvironmentNames(environments);
464
- const pushEnvs = listPushEnvironmentNames(environments);
511
+ // Secret injection uses ALL enabled environments (not push-only / pipelineDeployTargets).
512
+ // Prefer live enabled names over a possibly-stale deploy[] argument so no enabled
513
+ // env is missing from the job env block. Canonical helper: getEnabledEnvironmentNames.
514
+ const enabledEnvs = getEnabledEnvironmentNames({ ...(config || {}), environments });
465
515
  const allDeployNames =
466
516
  enabledEnvs.length > 0
467
517
  ? enabledEnvs
@@ -469,28 +519,21 @@ export function generateWorkflowYaml(
469
519
  ? deployEnvironments
470
520
  : envNames;
471
521
 
472
- // Build step: union of secrets for every push-triggered env (build deploys all of them).
473
- const buildSecretEnvs =
474
- pushEnvs.length > 0 ? pushEnvs : allDeployNames.slice(0, 1);
475
-
476
- // workflow_dispatch step: union for ALL enabled envs (dropdown can pick any / all).
477
- const dispatchSecretEnvs = allDeployNames;
522
+ // CRITICAL: Build and Deploy (workflow_dispatch) MUST share the same secret union
523
+ // every enabled environment. Filtering Build to push-only caused a live regression:
524
+ // Dispatch correctly got PRODUCTION_* while Build did not, so `deployhub build`'s
525
+ // push deploy stage failed on production with a missing SSH key. Trigger only
526
+ // controls which envs the CLI deploys; it must not control which secrets are injected.
527
+ const secretEnvs =
528
+ allDeployNames.length > 0 ? allDeployNames : envNames.slice(0, 1);
478
529
 
479
- const buildEnvVars = buildWorkflowEnvEntries(
480
- storageProviders,
481
- buildSecretEnvs,
482
- environments,
483
- config
484
- );
485
- const buildEnvBlock = formatWorkflowEnvBlock(buildEnvVars);
486
-
487
- const dispatchEnvVars = buildWorkflowEnvEntries(
530
+ const envVars = buildWorkflowEnvEntries(
488
531
  storageProviders,
489
- dispatchSecretEnvs,
532
+ secretEnvs,
490
533
  environments,
491
534
  config
492
535
  );
493
- const dispatchEnvBlock = formatWorkflowEnvBlock(dispatchEnvVars);
536
+ const envBlock = formatWorkflowEnvBlock(envVars);
494
537
 
495
538
  const installSpec = getCliInstallSpec(cliSource);
496
539
  const backendSteps = getBackendSetupSteps(config);
@@ -498,8 +541,13 @@ export function generateWorkflowYaml(
498
541
  const githubGitConfigStep = isGithubCliSource(cliSource)
499
542
  ? `${getGithubGitConfigStep()}\n`
500
543
  : '';
544
+ const kubeconfigSecret = resolveKubeconfigWorkflowSecretName(
545
+ allDeployNames,
546
+ environments,
547
+ config
548
+ );
501
549
  const kubernetesSteps = hasKubernetesDeploy(allDeployNames, environments)
502
- ? `${getKubernetesSetupSteps()}\n`
550
+ ? `${getKubernetesSetupSteps(kubeconfigSecret)}\n`
503
551
  : '';
504
552
 
505
553
  const projectType = config?.projectType || 'frontend';
@@ -527,7 +575,7 @@ ${envChoiceOptions}
527
575
  ? ` - name: Deploy (workflow_dispatch)
528
576
  if: github.event_name == 'workflow_dispatch'
529
577
  env:
530
- ${dispatchEnvBlock}
578
+ ${envBlock}
531
579
  run: |
532
580
  ENV_INPUT="\${{ inputs.environment }}"
533
581
  if [ -z "$ENV_INPUT" ] || [ "$ENV_INPUT" = "all" ]; then
@@ -557,7 +605,7 @@ ${kubernetesSteps}${githubGitConfigStep} - name: Install project dependenci
557
605
  - name: Build (and auto-deploy push-triggered envs)
558
606
  run: ${getCliBuildCommand()}
559
607
  env:
560
- ${buildEnvBlock}
608
+ ${envBlock}
561
609
  ${manualDeployStep}`;
562
610
 
563
611
  return workflow;
@@ -589,7 +637,7 @@ export function generateRollbackWorkflowYaml(
589
637
  config = null
590
638
  ) {
591
639
  const envNames = Object.keys(environments || {});
592
- const enabledEnvs = listEnabledEnvironmentNames(environments);
640
+ const enabledEnvs = getEnabledEnvironmentNames({ ...(config || {}), environments });
593
641
  const allDeployNames =
594
642
  enabledEnvs.length > 0
595
643
  ? enabledEnvs
@@ -609,8 +657,13 @@ export function generateRollbackWorkflowYaml(
609
657
  const githubGitConfigStep = isGithubCliSource(cliSource)
610
658
  ? `${getGithubGitConfigStep()}\n`
611
659
  : '';
660
+ const kubeconfigSecret = resolveKubeconfigWorkflowSecretName(
661
+ allDeployNames,
662
+ environments,
663
+ config
664
+ );
612
665
  const kubernetesSteps = hasKubernetesDeploy(allDeployNames, environments)
613
- ? `${getKubernetesSetupSteps()}\n`
666
+ ? `${getKubernetesSetupSteps(kubeconfigSecret)}\n`
614
667
  : '';
615
668
 
616
669
  const rollbackCmd = getCliRollbackCommand();
@@ -790,13 +843,14 @@ export function expectedWorkflowSecretKeysFromConfig(config, kind = 'rollback')
790
843
  /** @type {string[]} */
791
844
  let targets;
792
845
  if (kind === 'deploy') {
793
- const pushEnvs = listPushEnvironmentNames(environments);
794
- const enabled = listEnabledEnvironmentNames(environments);
795
- // Deploy workflow Build step uses push envs; doctor also flags dispatch-needed
796
- // secrets by using the broader enabled set via kind === 'rollback' / checklist.
797
- targets = pushEnvs.length > 0 ? pushEnvs : enabled.slice(0, 1);
846
+ const enabled = getEnabledEnvironmentNames({ ...config, environments });
847
+ // Same union as generateWorkflowYaml Build + dispatch steps (all enabled).
848
+ targets = enabled.length > 0 ? enabled : allNames.slice(0, 1);
798
849
  } else {
799
- targets = allNames.length > 0 ? listEnabledEnvironmentNames(environments) : allNames;
850
+ targets =
851
+ allNames.length > 0
852
+ ? getEnabledEnvironmentNames({ ...config, environments })
853
+ : allNames;
800
854
  if (targets.length === 0) targets = allNames;
801
855
  }
802
856
 
@@ -851,7 +905,7 @@ export function detectWorkflowConfigDrift(yamlText, config, filename = DEPLOY_WO
851
905
  return { drifted: false, missingEnvs, missingSecrets, summary: '' };
852
906
  }
853
907
 
854
- const envNames = listEnabledEnvironmentNames(config.environments || {});
908
+ const envNames = getEnabledEnvironmentNames(config);
855
909
  const root = /** @type {Record<string, any>} */ (parsed);
856
910
  const options = root?.on?.workflow_dispatch?.inputs?.environment?.options;
857
911
 
@@ -939,20 +993,133 @@ export async function getWorkflowDriftDoctorChecks(cwd, config) {
939
993
  return checks;
940
994
  }
941
995
 
996
+ /**
997
+ * Extract a comparable base semver from a package.json dependency value
998
+ * (`^2.0.19`, `~2.0.19`, `2.0.19`). Returns null for `latest`, git URLs,
999
+ * or anything that is not a valid semver range/version.
1000
+ *
1001
+ * @param {unknown} value
1002
+ * @returns {string|null}
1003
+ */
1004
+ export function parseDependencyBaseVersion(value) {
1005
+ if (typeof value !== 'string') return null;
1006
+ const trimmed = value.trim();
1007
+ if (!trimmed) return null;
1008
+ if (
1009
+ trimmed === 'latest' ||
1010
+ trimmed.startsWith('github:') ||
1011
+ trimmed.startsWith('file:') ||
1012
+ trimmed.startsWith('git+') ||
1013
+ trimmed.includes('://')
1014
+ ) {
1015
+ return null;
1016
+ }
1017
+ const coerced = semver.coerce(trimmed);
1018
+ return coerced ? coerced.version : null;
1019
+ }
1020
+
1021
+ /**
1022
+ * Decide whether to write a new DeployHub dependency version into a project
1023
+ * package.json. Never allows a lower semver to overwrite a higher existing pin.
1024
+ *
1025
+ * @param {string|null|undefined} existingValue
1026
+ * @param {string} proposedValue
1027
+ * @returns {{ write: boolean, value: string, warning: string|null }}
1028
+ */
1029
+ export function decideDeployhubDependencyVersionWrite(existingValue, proposedValue) {
1030
+ const proposed = typeof proposedValue === 'string' ? proposedValue.trim() : '';
1031
+ if (!proposed) {
1032
+ return {
1033
+ write: false,
1034
+ value: typeof existingValue === 'string' ? existingValue : '',
1035
+ warning:
1036
+ '⚠ Skipped updating package.json dependency version: resolved DeployHub version was empty.',
1037
+ };
1038
+ }
1039
+
1040
+ if (existingValue == null || existingValue === '') {
1041
+ return { write: true, value: proposed, warning: null };
1042
+ }
1043
+
1044
+ const existingBase = parseDependencyBaseVersion(existingValue);
1045
+ const proposedBase = parseDependencyBaseVersion(proposed);
1046
+
1047
+ if (!existingBase || !proposedBase) {
1048
+ return {
1049
+ write: false,
1050
+ value: String(existingValue),
1051
+ warning:
1052
+ `⚠ Skipped updating package.json dependency version: could not compare ` +
1053
+ `existing "${existingValue}" with resolved "${proposed}" as semver. ` +
1054
+ `Leaving the existing entry untouched.`,
1055
+ };
1056
+ }
1057
+
1058
+ if (semver.lt(proposedBase, existingBase)) {
1059
+ return {
1060
+ write: false,
1061
+ value: String(existingValue),
1062
+ warning:
1063
+ `⚠ Skipped updating package.json dependency version: the currently\n` +
1064
+ ` resolved DeployHub CLI version (${proposedBase}) is lower than what's already\n` +
1065
+ ` pinned (${existingBase}). Keeping the existing, newer version to avoid a\n` +
1066
+ ` downgrade. If this is unexpected, check that you're running the\n` +
1067
+ ` intended CLI version (which deployhub / npm ls -g @akash-chowdhury-24/deployhub).`,
1068
+ };
1069
+ }
1070
+
1071
+ // existing <= proposed → write (first-time already handled; upgrade or same)
1072
+ return { write: true, value: proposed, warning: null };
1073
+ }
1074
+
942
1075
  /**
943
1076
  * @param {string} cliSource
944
1077
  * @param {string} [cwd]
1078
+ * @param {{ packageJsonPath?: string, proposedVersion?: string }} [opts]
1079
+ * `proposedVersion` / `packageJsonPath` are for tests (mock resolved CLI version).
945
1080
  */
946
- export async function addDeployhubToPackageJson(cliSource, cwd = process.cwd()) {
1081
+ export async function addDeployhubToPackageJson(cliSource, cwd = process.cwd(), opts = {}) {
947
1082
  const pkgPath = path.join(cwd, 'package.json');
948
1083
  if (!(await fs.pathExists(pkgPath))) return;
949
1084
 
950
1085
  const pkg = await fs.readJson(pkgPath);
951
1086
  pkg.devDependencies = pkg.devDependencies || {};
1087
+
1088
+ const existingDev = pkg.devDependencies[NPM_PACKAGE];
1089
+ const existingProd =
1090
+ pkg.dependencies && typeof pkg.dependencies === 'object'
1091
+ ? pkg.dependencies[NPM_PACKAGE]
1092
+ : undefined;
1093
+ const existing =
1094
+ existingDev != null && existingDev !== ''
1095
+ ? existingDev
1096
+ : existingProd != null && existingProd !== ''
1097
+ ? existingProd
1098
+ : null;
1099
+
952
1100
  // package.json value must be a semver range / "latest" / git URL — never "name@version"
953
1101
  // (that form is only for `npm install <spec>` via getCliInstallSpec).
954
- pkg.devDependencies[NPM_PACKAGE] = getCliPackageJsonDependencyVersion(cliSource);
1102
+ const proposed =
1103
+ typeof opts.proposedVersion === 'string' && opts.proposedVersion.trim()
1104
+ ? opts.proposedVersion.trim()
1105
+ : getCliPackageJsonDependencyVersion(cliSource, opts);
1106
+
1107
+ const decision = decideDeployhubDependencyVersionWrite(existing, proposed);
1108
+ if (decision.warning) {
1109
+ console.log(chalk.yellow(decision.warning));
1110
+ }
1111
+ if (decision.write) {
1112
+ // Prefer updating whichever field already held the entry; default to devDependencies.
1113
+ if (existingProd != null && existingProd !== '' && (existingDev == null || existingDev === '')) {
1114
+ pkg.dependencies = pkg.dependencies || {};
1115
+ pkg.dependencies[NPM_PACKAGE] = decision.value;
1116
+ } else {
1117
+ pkg.devDependencies[NPM_PACKAGE] = decision.value;
1118
+ }
1119
+ }
1120
+
955
1121
  delete pkg.devDependencies.deployhub;
1122
+ if (pkg.dependencies) delete pkg.dependencies.deployhub;
956
1123
  pkg.scripts = pkg.scripts || {};
957
1124
  pkg.scripts['deployhub:build'] = 'deployhub build';
958
1125
  await fs.writeJson(pkgPath, pkg, { spaces: 2 });