@akash-chowdhury-24/deployhub 2.0.16 → 2.0.18

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
@@ -909,6 +909,8 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
909
909
  - Cluster connectivity test during `init`
910
910
  - 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
911
 
912
+ > **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.
913
+
912
914
  **After `init`:**
913
915
  1. Verify context: `kubectl config get-contexts`
914
916
  2. Copy `.env.example` → `.env`; set `DOCKER_IMAGE_NAME`, registry username/token, and (for local deploys) `KUBECONFIG` / `KUBE_CONTEXT` / `KUBE_NAMESPACE` as needed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akash-chowdhury-24/deployhub",
3
- "version": "2.0.16",
3
+ "version": "2.0.18",
4
4
  "description": "Zero-configuration deployment and artifact manager",
5
5
  "type": "module",
6
6
  "main": "./src/cli/index.js",
@@ -6,6 +6,7 @@ import {
6
6
  saveConfig,
7
7
  } from '../core/config.js';
8
8
  import {
9
+ getEnabledEnvironmentNames,
9
10
  getEnvMethod,
10
11
  getEnvTrigger,
11
12
  isEnvEnabled,
@@ -25,6 +26,21 @@ import {
25
26
  envLatestArtifactRemoteKey,
26
27
  } from '../utils/build-id.js';
27
28
 
29
+ /**
30
+ * Regenerate GitHub Actions workflows after env enable/disable/add/remove.
31
+ * @param {import('../core/config.js').DeployHubConfig} config
32
+ * @param {string} cwd
33
+ */
34
+ async function regenerateWorkflows(config, cwd) {
35
+ await writeWorkflowFile(
36
+ config.storage || [],
37
+ getEnabledEnvironmentNames(config),
38
+ config.environments || {},
39
+ cwd,
40
+ config.cli?.source,
41
+ config
42
+ );
43
+ }
28
44
  /**
29
45
  * Best-effort last buildId for an env from per-env deploy history.
30
46
  * @param {import('../core/config.js').DeployHubConfig} config
@@ -172,17 +188,7 @@ export function registerEnvCommand(program) {
172
188
 
173
189
  await saveConfig(config, cwd);
174
190
 
175
- const enabledNames = Object.keys(config.environments).filter((n) =>
176
- isEnvEnabled(config.environments[n])
177
- );
178
- await writeWorkflowFile(
179
- config.storage || [],
180
- enabledNames,
181
- config.environments,
182
- cwd,
183
- config.cli?.source,
184
- config
185
- );
191
+ await regenerateWorkflows(config, cwd);
186
192
 
187
193
  log.success(`Added environment "${name}" (${deployAnswers.deployType})`);
188
194
  console.log(chalk.gray(` defaultEnvironment: ${config.defaultEnvironment}`));
@@ -234,7 +240,9 @@ export function registerEnvCommand(program) {
234
240
  }
235
241
  config.environments[name].enabled = true;
236
242
  await saveConfig(config, cwd);
243
+ await regenerateWorkflows(config, cwd);
237
244
  console.log(chalk.green(`✓ Enabled environment "${name}"`));
245
+ console.log(chalk.gray(' Workflows regenerated — commit .github/workflows if using CI.'));
238
246
  });
239
247
 
240
248
  env
@@ -250,7 +258,9 @@ export function registerEnvCommand(program) {
250
258
  }
251
259
  config.environments[name].enabled = false;
252
260
  await saveConfig(config, cwd);
261
+ await regenerateWorkflows(config, cwd);
253
262
  console.log(chalk.yellow(`Disabled environment "${name}"`));
263
+ console.log(chalk.gray(' Workflows regenerated — commit .github/workflows if using CI.'));
254
264
  });
255
265
 
256
266
  env
@@ -301,17 +311,7 @@ export function registerEnvCommand(program) {
301
311
  }
302
312
  await saveConfig(config, cwd);
303
313
 
304
- const enabledNames = Object.keys(config.environments).filter((n) =>
305
- isEnvEnabled(config.environments[n])
306
- );
307
- await writeWorkflowFile(
308
- config.storage || [],
309
- enabledNames,
310
- config.environments,
311
- cwd,
312
- config.cli?.source,
313
- config
314
- );
314
+ await regenerateWorkflows(config, cwd);
315
315
 
316
316
  console.log(chalk.green(`✓ Removed environment "${name}"`));
317
317
  console.log(
@@ -28,6 +28,8 @@ import {
28
28
  promptServerDeployment,
29
29
  buildServerEnvEntry,
30
30
  getDockerEnvSecrets,
31
+ applyInitTriggerDefaults,
32
+ formatMultiEnvTriggerReminder,
31
33
  SSH_BASED,
32
34
  } from '../deployment/init-prompts.js';
33
35
  import {
@@ -452,6 +454,8 @@ export function registerInitCommand(program) {
452
454
  defaultEnvironment = picked.defaultEnvironment;
453
455
  }
454
456
 
457
+ applyInitTriggerDefaults(environments, deploy, defaultEnvironment);
458
+
455
459
  const version = await getProjectVersion(cwd);
456
460
  let hasDocker =
457
461
  (detectedFrontend?.hasDocker || detectedBackend?.hasDocker) ?? false;
@@ -571,6 +575,16 @@ export function registerInitCommand(program) {
571
575
  console.log(' • .github/workflows/deployhub-rollback.yml');
572
576
  console.log(' • .env.example');
573
577
  console.log('');
578
+
579
+ if (deploy.length >= 2 && defaultEnvironment) {
580
+ console.log(
581
+ chalk.yellow(
582
+ formatMultiEnvTriggerReminder(String(defaultEnvironment), deploy)
583
+ )
584
+ );
585
+ console.log('');
586
+ }
587
+
574
588
  printAuthorFooter();
575
589
  });
576
590
  }
@@ -1,5 +1,6 @@
1
1
  import chalk from 'chalk';
2
2
  import { loadConfig, loadEnv } from '../core/config.js';
3
+ import { getEnabledEnvironmentNames } from '../core/environments.js';
3
4
  import {
4
5
  writeWorkflowFile,
5
6
  DEPLOY_WORKFLOW_FILENAME,
@@ -22,8 +23,10 @@ export function registerSyncWorkflowsCommand(program) {
22
23
  const config = await loadConfig(cwd);
23
24
 
24
25
  const storage = config.storage || [];
25
- const deploy = config.deploy || [];
26
26
  const environments = config.environments || {};
27
+ // Prefer enabled environments over legacy deploy[] so every reachable env
28
+ // gets its secrets into the regenerated workflow env blocks.
29
+ const deploy = getEnabledEnvironmentNames(config);
27
30
  const cliSource = config.cli?.source;
28
31
 
29
32
  await writeWorkflowFile(storage, deploy, environments, cwd, cliSource, config);
@@ -290,7 +290,7 @@ export function migrateConfigToEnvironments(raw) {
290
290
  newEnvironments.default = {
291
291
  enabled: true,
292
292
  method,
293
- trigger: 'manual',
293
+ trigger: 'push',
294
294
  config: flatConfig,
295
295
  };
296
296
  for (const key of FLAT_DEPLOY_KEYS) {
@@ -323,7 +323,7 @@ export function migrateConfigToEnvironments(raw) {
323
323
  newEnvironments[defaultEnvironment] = {
324
324
  enabled: true,
325
325
  method: 'ssh',
326
- trigger: 'manual',
326
+ trigger: 'push',
327
327
  config: {},
328
328
  };
329
329
  }
@@ -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
  }
@@ -539,6 +539,57 @@ export function buildServerEnvEntry(
539
539
  };
540
540
  }
541
541
 
542
+ /**
543
+ * Apply init-time trigger defaults after environments are collected.
544
+ * - Exactly one env → always `push` (git push = auto-deploy).
545
+ * - Two or more → grandfathered/default gets `push`; all others stay `manual`.
546
+ *
547
+ * @param {Record<string, { trigger?: string }>} environments
548
+ * @param {string[]} deployNames
549
+ * @param {string|undefined|null} defaultEnvironment
550
+ */
551
+ export function applyInitTriggerDefaults(environments, deployNames, defaultEnvironment) {
552
+ if (deployNames.length === 1 && environments[deployNames[0]]) {
553
+ environments[deployNames[0]].trigger = 'push';
554
+ return;
555
+ }
556
+ if (deployNames.length >= 2 && defaultEnvironment) {
557
+ for (const name of deployNames) {
558
+ if (!environments[name]) continue;
559
+ environments[name].trigger =
560
+ name === defaultEnvironment ? 'push' : 'manual';
561
+ }
562
+ }
563
+ }
564
+
565
+ /**
566
+ * End-of-init reminder for multi-env setups (grandfathered = push, others = manual).
567
+ * @param {string} grandfathered
568
+ * @param {string[]} allEnvNames
569
+ * @returns {string}
570
+ */
571
+ export function formatMultiEnvTriggerReminder(grandfathered, allEnvNames) {
572
+ const others = allEnvNames.filter((n) => n !== grandfathered);
573
+ const otherList =
574
+ others.length > 0 ? others.map((n) => `"${n}"`).join(', ') : '(none)';
575
+ const exampleEnv = others[0] || '<env-name>';
576
+ return [
577
+ '─────────────────────────────────────────────',
578
+ `By default, only your first environment ("${grandfathered}")`,
579
+ `auto-deploys on push. Your other environment(s) — ${otherList} — are set to manual and will only deploy via:`,
580
+ ' deployhub deploy --env <name>',
581
+ 'or GitHub Actions → Run workflow.',
582
+ '',
583
+ 'To make an environment auto-deploy on push instead, open',
584
+ 'deployhub.config.json and change its "trigger" to "push":',
585
+ ' "environments": {',
586
+ ` "${exampleEnv}": { "trigger": "push", ... }`,
587
+ ' }',
588
+ 'Then run: deployhub sync-workflows',
589
+ '─────────────────────────────────────────────',
590
+ ].join('\n');
591
+ }
592
+
542
593
  /**
543
594
  * @param {Awaited<ReturnType<typeof promptServerDeployment>>} deployAnswers
544
595
  * @returns {Record<string, string>|null}
@@ -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,6 +1,7 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'path';
3
3
  import yaml from 'js-yaml';
4
+ import { fileURLToPath } from 'url';
4
5
  import { getWorkflowHeaderComment } from './author.js';
5
6
  import {
6
7
  generateDeploymentEnvSection,
@@ -13,7 +14,11 @@ import {
13
14
  envUsesPrefixedSecrets,
14
15
  DEPLOYMENT_ENV_KEYS,
15
16
  } from '../deployment/deployment-env.js';
16
- import { getEnvMethod, getEnvTrigger, isEnvEnabled } from '../core/environments.js';
17
+ import {
18
+ getEnvMethod,
19
+ getEnabledEnvironmentNames,
20
+ isEnvEnabled,
21
+ } from '../core/environments.js';
17
22
 
18
23
  /** @typedef {'aws'|'azure'|'gcp'|'gdrive'|'dropbox'|'local'|'ftp'|'ssh'} ProviderEnvKey */
19
24
 
@@ -135,6 +140,34 @@ export function getCliInstallSpec(cliSource) {
135
140
  return normalized;
136
141
  }
137
142
 
143
+ /**
144
+ * Version/range suitable as a package.json dependency VALUE for this CLI
145
+ * (key is already NPM_PACKAGE — do not embed the package name again).
146
+ *
147
+ * @param {string} [cliSource]
148
+ * @returns {string}
149
+ */
150
+ export function getCliPackageJsonDependencyVersion(cliSource) {
151
+ const normalized = normalizeCliSource(cliSource);
152
+ if (normalized === DEFAULT_NPM_CLI_SOURCE) {
153
+ try {
154
+ const pkgPath = path.join(
155
+ path.dirname(fileURLToPath(import.meta.url)),
156
+ '../../package.json'
157
+ );
158
+ const pkg = fs.readJsonSync(pkgPath);
159
+ if (typeof pkg.version === 'string' && pkg.version.trim()) {
160
+ return `^${pkg.version.trim()}`;
161
+ }
162
+ } catch {
163
+ // fall through
164
+ }
165
+ return 'latest';
166
+ }
167
+ // github: / file: specs are valid package.json dependency values as-is
168
+ return getCliInstallSpec(cliSource);
169
+ }
170
+
138
171
  /**
139
172
  * Shell command to run deployhub build from the installed scoped package.
140
173
  * Uses node directly so it works reliably when installed from a private GitHub repo.
@@ -230,9 +263,41 @@ function hasKubernetesDeploy(deployEnvironments, environments) {
230
263
  }
231
264
 
232
265
  /**
266
+ * Secret name for the Configure-kubeconfig setup step.
267
+ * Must match the env that actually uses kubernetes — when the only k8s env is
268
+ * non-grandfathered (e.g. production), that is PRODUCTION_KUBECONFIG, not the
269
+ * unprefixed KUBECONFIG (which would never be injected and leave the file empty).
270
+ *
271
+ * LIMITATION (follow-up): GitHub Actions writes exactly ONE kubeconfig file per
272
+ * job. Multiple Kubernetes environments that target DIFFERENT clusters in the
273
+ * same workflow run are not yet fully supported — only the first/grandfathered
274
+ * k8s env's secret is used for the setup step. Track as a product follow-up
275
+ * before advertising multi-cluster multi-env CI.
276
+ *
277
+ * @param {string[]} deployEnvironments
278
+ * @param {Record<string, { type?: string, method?: string }>} environments
279
+ * @param {import('../core/config.js').DeployHubConfig|null} config
233
280
  * @returns {string}
234
281
  */
235
- function getKubernetesSetupSteps() {
282
+ function resolveKubeconfigWorkflowSecretName(deployEnvironments, environments, config) {
283
+ const cfg = { ...(config || {}), environments };
284
+ const k8sNames = deployEnvironments.filter(
285
+ (n) => getEnvMethod(environments[n]) === 'kubernetes'
286
+ );
287
+ if (k8sNames.length === 0) return 'KUBECONFIG';
288
+ const unprefixed = k8sNames.find((n) => !envUsesPrefixedSecrets(n, cfg));
289
+ const chosen = unprefixed || k8sNames[0];
290
+ return envUsesPrefixedSecrets(chosen, cfg)
291
+ ? prefixSecretKey(chosen, 'KUBECONFIG')
292
+ : 'KUBECONFIG';
293
+ }
294
+
295
+ /**
296
+ * @param {string} [kubeconfigSecretName]
297
+ * @returns {string}
298
+ */
299
+ function getKubernetesSetupSteps(kubeconfigSecretName = 'KUBECONFIG') {
300
+ // One kubeconfig file per job — see resolveKubeconfigWorkflowSecretName LIMITATION.
236
301
  return ` - name: Setup kubectl
237
302
  uses: azure/setup-kubectl@v4
238
303
  with:
@@ -240,7 +305,7 @@ function getKubernetesSetupSteps() {
240
305
 
241
306
  - name: Configure kubeconfig
242
307
  env:
243
- KUBECONFIG_SECRET: \${{ secrets.KUBECONFIG }}
308
+ KUBECONFIG_SECRET: \${{ secrets.${kubeconfigSecretName} }}
244
309
  run: |
245
310
  mkdir -p "$GITHUB_WORKSPACE/.kube"
246
311
  if echo "$KUBECONFIG_SECRET" | base64 -d > "$GITHUB_WORKSPACE/.kube/config" 2>/dev/null; then
@@ -289,29 +354,28 @@ function upsertWorkflowEnvLine(envVars, lhs, valueExpr) {
289
354
 
290
355
  /**
291
356
  * Shared env entries for deploy and rollback workflows (same secret resolution).
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.
357
+ * Multi-env: each environment contributes its own CI secret names (grandfathered
358
+ * unprefixed SSH_HOST, or PRODUCTION_SSH_HOST, etc.). Prefixed envs are NOT
359
+ * last-wins-mapped onto unprefixed keys — that would overwrite the grandfathered
360
+ * binding and leave production looking fine in YAML while development silently
361
+ * inherits the wrong credentials. `applyEnvSecretOverlay` remaps PREFIX_*
362
+ * unprefixed names at deploy time for non-grandfathered targets.
297
363
  *
298
364
  * @param {string[]} storageProviders
299
- * @param {string[]} deployEnvironments
365
+ * @param {string[]} deployEnvironments — environments whose secrets to inject
300
366
  * @param {Record<string, { type?: string, method?: string }>} environments
301
367
  * @param {import('../core/config.js').DeployHubConfig} [config]
302
- * @param {{ mapForEnv?: string|null }} [options] — when set, only map that env's secrets to unprefixed keys
303
368
  * @returns {Set<string>}
304
369
  */
305
370
  export function buildWorkflowEnvEntries(
306
371
  storageProviders,
307
372
  deployEnvironments,
308
373
  environments,
309
- config = null,
310
- options = {}
374
+ config = null
311
375
  ) {
312
376
  /** @type {Set<string>} */
313
377
  const envVars = new Set([
314
- `DEPLOYHUB_ENV: ${options.mapForEnv || deployEnvironments[0] || 'production'}`,
378
+ `DEPLOYHUB_ENV: ${deployEnvironments[0] || 'production'}`,
315
379
  ]);
316
380
 
317
381
  for (const provider of storageProviders) {
@@ -321,13 +385,9 @@ export function buildWorkflowEnvEntries(
321
385
  }
322
386
  }
323
387
 
324
- const targets = options.mapForEnv
325
- ? [options.mapForEnv]
326
- : deployEnvironments;
327
-
328
388
  const cfg = { ...(config || {}), environments };
329
389
 
330
- for (const envName of targets) {
390
+ for (const envName of deployEnvironments) {
331
391
  const env = environments[envName];
332
392
  if (!env) continue;
333
393
 
@@ -338,26 +398,25 @@ export function buildWorkflowEnvEntries(
338
398
  const secretName = envUsesPrefixedSecrets(envName, cfg)
339
399
  ? prefixSecretKey(envName, key)
340
400
  : 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} }}`);
401
+ // Bind process.env[secretName] for this environment (prefixed or grandfathered).
402
+ upsertWorkflowEnvLine(envVars, secretName, `\${{ secrets.${secretName} }}`);
348
403
  }
349
404
  }
350
405
 
351
- applyKubernetesWorkflowEnv(targets, environments, envVars);
406
+ applyKubernetesWorkflowEnv(deployEnvironments, environments, envVars);
352
407
  return envVars;
353
408
  }
354
409
 
355
410
  /**
411
+ * Choice options for workflow_dispatch — enabled environments only, plus `all`.
412
+ * Disabled envs are omitted so selecting them cannot waste a CI run.
413
+ *
356
414
  * @param {Record<string, unknown>} environments
357
415
  * @returns {string}
358
416
  */
359
417
  function formatEnvironmentChoiceOptions(environments) {
360
- const names = Object.keys(environments || {});
418
+ // Single source of truth: getEnabledEnvironmentNames (do not re-filter here).
419
+ const names = getEnabledEnvironmentNames({ environments });
361
420
  const options = [...names, 'all'];
362
421
  return options.map((n) => ` - ${n}`).join('\n');
363
422
  }
@@ -412,21 +471,28 @@ export function generateWorkflowYaml(
412
471
  config = null
413
472
  ) {
414
473
  const envNames = Object.keys(environments || {});
474
+ // Secret injection uses ALL enabled environments (not push-only / pipelineDeployTargets).
475
+ // Prefer live enabled names over a possibly-stale deploy[] argument so no enabled
476
+ // env is missing from the job env block. Canonical helper: getEnabledEnvironmentNames.
477
+ const enabledEnvs = getEnabledEnvironmentNames({ ...(config || {}), environments });
415
478
  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);
479
+ enabledEnvs.length > 0
480
+ ? enabledEnvs
481
+ : deployEnvironments.length > 0
482
+ ? deployEnvironments
483
+ : envNames;
484
+
485
+ // CRITICAL: Build and Deploy (workflow_dispatch) MUST share the same secret union —
486
+ // every enabled environment. Filtering Build to push-only caused a live regression:
487
+ // Dispatch correctly got PRODUCTION_* while Build did not, so `deployhub build`'s
488
+ // push deploy stage failed on production with a missing SSH key. Trigger only
489
+ // controls which envs the CLI deploys; it must not control which secrets are injected.
490
+ const secretEnvs =
491
+ allDeployNames.length > 0 ? allDeployNames : envNames.slice(0, 1);
426
492
 
427
493
  const envVars = buildWorkflowEnvEntries(
428
494
  storageProviders,
429
- buildSecretEnvs,
495
+ secretEnvs,
430
496
  environments,
431
497
  config
432
498
  );
@@ -438,8 +504,13 @@ export function generateWorkflowYaml(
438
504
  const githubGitConfigStep = isGithubCliSource(cliSource)
439
505
  ? `${getGithubGitConfigStep()}\n`
440
506
  : '';
507
+ const kubeconfigSecret = resolveKubeconfigWorkflowSecretName(
508
+ allDeployNames,
509
+ environments,
510
+ config
511
+ );
441
512
  const kubernetesSteps = hasKubernetesDeploy(allDeployNames, environments)
442
- ? `${getKubernetesSetupSteps()}\n`
513
+ ? `${getKubernetesSetupSteps(kubeconfigSecret)}\n`
443
514
  : '';
444
515
 
445
516
  const projectType = config?.projectType || 'frontend';
@@ -529,10 +600,13 @@ export function generateRollbackWorkflowYaml(
529
600
  config = null
530
601
  ) {
531
602
  const envNames = Object.keys(environments || {});
603
+ const enabledEnvs = getEnabledEnvironmentNames({ ...(config || {}), environments });
532
604
  const allDeployNames =
533
- deployEnvironments.length > 0
534
- ? deployEnvironments
535
- : envNames.filter((n) => isEnvEnabled(environments[n]));
605
+ enabledEnvs.length > 0
606
+ ? enabledEnvs
607
+ : deployEnvironments.length > 0
608
+ ? deployEnvironments
609
+ : envNames.filter((n) => isEnvEnabled(environments[n]));
536
610
 
537
611
  const envVars = buildWorkflowEnvEntries(
538
612
  storageProviders,
@@ -546,8 +620,13 @@ export function generateRollbackWorkflowYaml(
546
620
  const githubGitConfigStep = isGithubCliSource(cliSource)
547
621
  ? `${getGithubGitConfigStep()}\n`
548
622
  : '';
623
+ const kubeconfigSecret = resolveKubeconfigWorkflowSecretName(
624
+ allDeployNames,
625
+ environments,
626
+ config
627
+ );
549
628
  const kubernetesSteps = hasKubernetesDeploy(allDeployNames, environments)
550
- ? `${getKubernetesSetupSteps()}\n`
629
+ ? `${getKubernetesSetupSteps(kubeconfigSecret)}\n`
551
630
  : '';
552
631
 
553
632
  const rollbackCmd = getCliRollbackCommand();
@@ -727,11 +806,15 @@ export function expectedWorkflowSecretKeysFromConfig(config, kind = 'rollback')
727
806
  /** @type {string[]} */
728
807
  let targets;
729
808
  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);
809
+ const enabled = getEnabledEnvironmentNames({ ...config, environments });
810
+ // Same union as generateWorkflowYaml Build + dispatch steps (all enabled).
811
+ targets = enabled.length > 0 ? enabled : allNames.slice(0, 1);
733
812
  } else {
734
- targets = allNames;
813
+ targets =
814
+ allNames.length > 0
815
+ ? getEnabledEnvironmentNames({ ...config, environments })
816
+ : allNames;
817
+ if (targets.length === 0) targets = allNames;
735
818
  }
736
819
 
737
820
  const entries = buildWorkflowEnvEntries(
@@ -785,7 +868,7 @@ export function detectWorkflowConfigDrift(yamlText, config, filename = DEPLOY_WO
785
868
  return { drifted: false, missingEnvs, missingSecrets, summary: '' };
786
869
  }
787
870
 
788
- const envNames = Object.keys(config.environments || {});
871
+ const envNames = getEnabledEnvironmentNames(config);
789
872
  const root = /** @type {Record<string, any>} */ (parsed);
790
873
  const options = root?.on?.workflow_dispatch?.inputs?.environment?.options;
791
874
 
@@ -883,7 +966,9 @@ export async function addDeployhubToPackageJson(cliSource, cwd = process.cwd())
883
966
 
884
967
  const pkg = await fs.readJson(pkgPath);
885
968
  pkg.devDependencies = pkg.devDependencies || {};
886
- pkg.devDependencies[NPM_PACKAGE] = getCliInstallSpec(cliSource);
969
+ // package.json value must be a semver range / "latest" / git URL — never "name@version"
970
+ // (that form is only for `npm install <spec>` via getCliInstallSpec).
971
+ pkg.devDependencies[NPM_PACKAGE] = getCliPackageJsonDependencyVersion(cliSource);
887
972
  delete pkg.devDependencies.deployhub;
888
973
  pkg.scripts = pkg.scripts || {};
889
974
  pkg.scripts['deployhub:build'] = 'deployhub build';