@akash-chowdhury-24/deployhub 2.0.15 → 2.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,20 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'path';
3
+ import yaml from 'js-yaml';
4
+ import { fileURLToPath } from 'url';
3
5
  import { getWorkflowHeaderComment } from './author.js';
4
6
  import {
5
7
  generateDeploymentEnvSection,
6
8
  getDeploymentWorkflowSecretKeys,
7
9
  getDeploymentSecretChecklistItems,
10
+ getDeploymentWorkflowSecretKeysForEnv,
11
+ getDeploymentSecretChecklistItemsForEnv,
12
+ shouldPrefixEnvSecrets,
13
+ prefixSecretKey,
14
+ envUsesPrefixedSecrets,
8
15
  DEPLOYMENT_ENV_KEYS,
9
16
  } from '../deployment/deployment-env.js';
17
+ import { getEnvMethod, getEnvTrigger, isEnvEnabled } from '../core/environments.js';
10
18
 
11
19
  /** @typedef {'aws'|'azure'|'gcp'|'gdrive'|'dropbox'|'local'|'ftp'|'ssh'} ProviderEnvKey */
12
20
 
@@ -128,6 +136,34 @@ export function getCliInstallSpec(cliSource) {
128
136
  return normalized;
129
137
  }
130
138
 
139
+ /**
140
+ * Version/range suitable as a package.json dependency VALUE for this CLI
141
+ * (key is already NPM_PACKAGE — do not embed the package name again).
142
+ *
143
+ * @param {string} [cliSource]
144
+ * @returns {string}
145
+ */
146
+ export function getCliPackageJsonDependencyVersion(cliSource) {
147
+ const normalized = normalizeCliSource(cliSource);
148
+ if (normalized === DEFAULT_NPM_CLI_SOURCE) {
149
+ try {
150
+ const pkgPath = path.join(
151
+ path.dirname(fileURLToPath(import.meta.url)),
152
+ '../../package.json'
153
+ );
154
+ const pkg = fs.readJsonSync(pkgPath);
155
+ if (typeof pkg.version === 'string' && pkg.version.trim()) {
156
+ return `^${pkg.version.trim()}`;
157
+ }
158
+ } catch {
159
+ // fall through
160
+ }
161
+ return 'latest';
162
+ }
163
+ // github: / file: specs are valid package.json dependency values as-is
164
+ return getCliInstallSpec(cliSource);
165
+ }
166
+
131
167
  /**
132
168
  * Shell command to run deployhub build from the installed scoped package.
133
169
  * Uses node directly so it works reliably when installed from a private GitHub repo.
@@ -217,7 +253,9 @@ const KUBECTL_VERSION = 'v1.30.4';
217
253
  * @returns {boolean}
218
254
  */
219
255
  function hasKubernetesDeploy(deployEnvironments, environments) {
220
- return deployEnvironments.some((envName) => environments[envName]?.type === 'kubernetes');
256
+ return deployEnvironments.some(
257
+ (envName) => getEnvMethod(environments[envName]) === 'kubernetes'
258
+ );
221
259
  }
222
260
 
223
261
  /**
@@ -262,13 +300,55 @@ function applyKubernetesWorkflowEnv(deployEnvironments, environments, envVars) {
262
300
  export const DEPLOY_WORKFLOW_FILENAME = 'deployhub.yml';
263
301
  export const ROLLBACK_WORKFLOW_FILENAME = 'deployhub-rollback.yml';
264
302
 
303
+ /**
304
+ * Upsert a `KEY: value` line into the workflow env set (LHS must be unique for valid YAML).
305
+ * @param {Set<string>} envVars
306
+ * @param {string} lhs
307
+ * @param {string} valueExpr
308
+ */
309
+ function upsertWorkflowEnvLine(envVars, lhs, valueExpr) {
310
+ const linePrefix = `${lhs}: `;
311
+ for (const entry of [...envVars]) {
312
+ if (entry.startsWith(linePrefix)) {
313
+ envVars.delete(entry);
314
+ }
315
+ }
316
+ envVars.add(`${linePrefix}${valueExpr}`);
317
+ }
318
+
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
+
265
340
  /**
266
341
  * Shared env entries for deploy and rollback workflows (same secret resolution).
267
- * Uses getDeploymentWorkflowSecretKeys(env.type) — no separate rollback list.
342
+ * Multi-env: each environment contributes its own CI secret names (grandfathered
343
+ * unprefixed SSH_HOST, or PRODUCTION_SSH_HOST, etc.). Prefixed envs are NOT
344
+ * last-wins-mapped onto unprefixed keys — that would overwrite the grandfathered
345
+ * binding and leave production looking fine in YAML while development silently
346
+ * inherits the wrong credentials. `applyEnvSecretOverlay` remaps PREFIX_* →
347
+ * unprefixed names at deploy time for non-grandfathered targets.
268
348
  *
269
349
  * @param {string[]} storageProviders
270
- * @param {string[]} deployEnvironments
271
- * @param {Record<string, { type: string }>} environments
350
+ * @param {string[]} deployEnvironments — environments whose secrets to inject
351
+ * @param {Record<string, { type?: string, method?: string }>} environments
272
352
  * @param {import('../core/config.js').DeployHubConfig} [config]
273
353
  * @returns {Set<string>}
274
354
  */
@@ -279,22 +359,32 @@ export function buildWorkflowEnvEntries(
279
359
  config = null
280
360
  ) {
281
361
  /** @type {Set<string>} */
282
- const envVars = new Set(['DEPLOYHUB_ENV: production']);
362
+ const envVars = new Set([
363
+ `DEPLOYHUB_ENV: ${deployEnvironments[0] || 'production'}`,
364
+ ]);
283
365
 
284
366
  for (const provider of storageProviders) {
285
367
  const keys = PROVIDER_ENV_MAP[provider] || [];
286
368
  for (const key of keys) {
287
- envVars.add(`${key}: \${{ secrets.${key} }}`);
369
+ upsertWorkflowEnvLine(envVars, key, `\${{ secrets.${key} }}`);
288
370
  }
289
371
  }
290
372
 
373
+ const cfg = { ...(config || {}), environments };
374
+
291
375
  for (const envName of deployEnvironments) {
292
376
  const env = environments[envName];
293
377
  if (!env) continue;
294
378
 
295
- const keys = getDeploymentWorkflowSecretKeys(env.type, config);
296
- for (const key of keys) {
297
- envVars.add(`${key}: \${{ secrets.${key} }}`);
379
+ const method = getEnvMethod(env);
380
+ if (!method) continue;
381
+ const unprefixedKeys = getDeploymentWorkflowSecretKeys(method, config);
382
+ for (const key of unprefixedKeys) {
383
+ const secretName = envUsesPrefixedSecrets(envName, cfg)
384
+ ? prefixSecretKey(envName, key)
385
+ : key;
386
+ // Bind process.env[secretName] for this environment (prefixed or grandfathered).
387
+ upsertWorkflowEnvLine(envVars, secretName, `\${{ secrets.${secretName} }}`);
298
388
  }
299
389
  }
300
390
 
@@ -302,6 +392,21 @@ export function buildWorkflowEnvEntries(
302
392
  return envVars;
303
393
  }
304
394
 
395
+ /**
396
+ * Choice options for workflow_dispatch — enabled environments only, plus `all`.
397
+ * Disabled envs are omitted so selecting them cannot waste a CI run.
398
+ *
399
+ * @param {Record<string, unknown>} environments
400
+ * @returns {string}
401
+ */
402
+ function formatEnvironmentChoiceOptions(environments) {
403
+ const names = Object.keys(environments || {}).filter((n) =>
404
+ isEnvEnabled(environments[n])
405
+ );
406
+ const options = [...names, 'all'];
407
+ return options.map((n) => ` - ${n}`).join('\n');
408
+ }
409
+
305
410
  /**
306
411
  * @param {Set<string>} envVars
307
412
  * @param {string} [indent]
@@ -339,7 +444,7 @@ export function extractWorkflowSecretKeys(yaml) {
339
444
  /**
340
445
  * @param {string[]} storageProviders
341
446
  * @param {string[]} deployEnvironments
342
- * @param {Record<string, { type: string }>} environments
447
+ * @param {Record<string, { type?: string, method?: string }>} environments
343
448
  * @param {string} [cliSource]
344
449
  * @param {import('../core/config.js').DeployHubConfig} [config]
345
450
  * @returns {string}
@@ -351,13 +456,41 @@ export function generateWorkflowYaml(
351
456
  cliSource = DEFAULT_NPM_CLI_SOURCE,
352
457
  config = null
353
458
  ) {
354
- const envVars = buildWorkflowEnvEntries(
459
+ 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);
465
+ const allDeployNames =
466
+ enabledEnvs.length > 0
467
+ ? enabledEnvs
468
+ : deployEnvironments.length > 0
469
+ ? deployEnvironments
470
+ : envNames;
471
+
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;
478
+
479
+ const buildEnvVars = buildWorkflowEnvEntries(
355
480
  storageProviders,
356
- deployEnvironments,
481
+ buildSecretEnvs,
357
482
  environments,
358
483
  config
359
484
  );
360
- const envBlock = formatWorkflowEnvBlock(envVars);
485
+ const buildEnvBlock = formatWorkflowEnvBlock(buildEnvVars);
486
+
487
+ const dispatchEnvVars = buildWorkflowEnvEntries(
488
+ storageProviders,
489
+ dispatchSecretEnvs,
490
+ environments,
491
+ config
492
+ );
493
+ const dispatchEnvBlock = formatWorkflowEnvBlock(dispatchEnvVars);
361
494
 
362
495
  const installSpec = getCliInstallSpec(cliSource);
363
496
  const backendSteps = getBackendSetupSteps(config);
@@ -365,7 +498,7 @@ export function generateWorkflowYaml(
365
498
  const githubGitConfigStep = isGithubCliSource(cliSource)
366
499
  ? `${getGithubGitConfigStep()}\n`
367
500
  : '';
368
- const kubernetesSteps = hasKubernetesDeploy(deployEnvironments, environments)
501
+ const kubernetesSteps = hasKubernetesDeploy(allDeployNames, environments)
369
502
  ? `${getKubernetesSetupSteps()}\n`
370
503
  : '';
371
504
 
@@ -375,11 +508,41 @@ export function generateWorkflowYaml(
375
508
  ? getInstallDepsCommand(config)
376
509
  : 'npm install';
377
510
 
511
+ const envChoiceOptions = formatEnvironmentChoiceOptions(environments);
512
+ const hasEnvs = envNames.length > 0;
513
+ const dispatchInputs = hasEnvs
514
+ ? ` workflow_dispatch:
515
+ inputs:
516
+ environment:
517
+ description: 'Environment to deploy (leave blank on push; use "all" for every enabled env)'
518
+ required: false
519
+ type: choice
520
+ options:
521
+ ${envChoiceOptions}
522
+ `
523
+ : ` workflow_dispatch:
524
+ `;
525
+
526
+ const manualDeployStep = hasEnvs
527
+ ? ` - name: Deploy (workflow_dispatch)
528
+ if: github.event_name == 'workflow_dispatch'
529
+ env:
530
+ ${dispatchEnvBlock}
531
+ run: |
532
+ ENV_INPUT="\${{ inputs.environment }}"
533
+ if [ -z "$ENV_INPUT" ] || [ "$ENV_INPUT" = "all" ]; then
534
+ ${getCliDeployCommand()} --env all
535
+ else
536
+ ${getCliDeployCommand()} --env "$ENV_INPUT"
537
+ fi
538
+ `
539
+ : '';
540
+
378
541
  const workflow = `${getWorkflowHeaderComment()}name: DeployHub
379
542
  on:
380
543
  push:
381
544
  branches: [main]
382
- jobs:
545
+ ${dispatchInputs}jobs:
383
546
  deploy:
384
547
  runs-on: ubuntu-latest
385
548
  steps:
@@ -391,20 +554,29 @@ ${kubernetesSteps}${githubGitConfigStep} - name: Install project dependenci
391
554
  run: ${installDepsCommand}
392
555
  - name: Install DeployHub CLI
393
556
  run: npm install ${installSpec} --no-save
394
- - run: ${getCliBuildCommand()}
557
+ - name: Build (and auto-deploy push-triggered envs)
558
+ run: ${getCliBuildCommand()}
395
559
  env:
396
- ${envBlock}
397
- `;
560
+ ${buildEnvBlock}
561
+ ${manualDeployStep}`;
398
562
 
399
563
  return workflow;
400
564
  }
401
565
 
566
+ /**
567
+ * Shell command to run deployhub deploy from the installed scoped package.
568
+ * @returns {string}
569
+ */
570
+ export function getCliDeployCommand() {
571
+ return `node ./node_modules/${NPM_PACKAGE}/src/cli/index.js deploy`;
572
+ }
573
+
402
574
  /**
403
575
  * Manual rollback via Actions tab / gh workflow run (workflow_dispatch only).
404
576
  *
405
577
  * @param {string[]} storageProviders
406
578
  * @param {string[]} deployEnvironments
407
- * @param {Record<string, { type: string }>} environments
579
+ * @param {Record<string, { type?: string, method?: string }>} environments
408
580
  * @param {string} [cliSource]
409
581
  * @param {import('../core/config.js').DeployHubConfig} [config]
410
582
  * @returns {string}
@@ -416,9 +588,18 @@ export function generateRollbackWorkflowYaml(
416
588
  cliSource = DEFAULT_NPM_CLI_SOURCE,
417
589
  config = null
418
590
  ) {
591
+ const envNames = Object.keys(environments || {});
592
+ const enabledEnvs = listEnabledEnvironmentNames(environments);
593
+ const allDeployNames =
594
+ enabledEnvs.length > 0
595
+ ? enabledEnvs
596
+ : deployEnvironments.length > 0
597
+ ? deployEnvironments
598
+ : envNames.filter((n) => isEnvEnabled(environments[n]));
599
+
419
600
  const envVars = buildWorkflowEnvEntries(
420
601
  storageProviders,
421
- deployEnvironments,
602
+ allDeployNames,
422
603
  environments,
423
604
  config
424
605
  );
@@ -428,11 +609,43 @@ export function generateRollbackWorkflowYaml(
428
609
  const githubGitConfigStep = isGithubCliSource(cliSource)
429
610
  ? `${getGithubGitConfigStep()}\n`
430
611
  : '';
431
- const kubernetesSteps = hasKubernetesDeploy(deployEnvironments, environments)
612
+ const kubernetesSteps = hasKubernetesDeploy(allDeployNames, environments)
432
613
  ? `${getKubernetesSetupSteps()}\n`
433
614
  : '';
434
615
 
435
616
  const rollbackCmd = getCliRollbackCommand();
617
+ const envChoiceOptions = formatEnvironmentChoiceOptions(environments);
618
+ const hasEnvs = envNames.length > 0;
619
+
620
+ const environmentInput = hasEnvs
621
+ ? ` environment:
622
+ description: 'Environment to roll back (or "all")'
623
+ required: false
624
+ type: choice
625
+ options:
626
+ ${envChoiceOptions}
627
+ `
628
+ : '';
629
+
630
+ const rollbackRun = hasEnvs
631
+ ? ` ENV_INPUT="\${{ inputs.environment }}"
632
+ BUILD_INPUT="\${{ inputs.buildId }}"
633
+ ENV_FLAG=""
634
+ if [ -z "$ENV_INPUT" ] || [ "$ENV_INPUT" = "all" ]; then
635
+ ENV_FLAG="--env all"
636
+ else
637
+ ENV_FLAG="--env $ENV_INPUT"
638
+ fi
639
+ if [ -n "$BUILD_INPUT" ]; then
640
+ ${rollbackCmd} "$BUILD_INPUT" $ENV_FLAG
641
+ else
642
+ ${rollbackCmd} $ENV_FLAG
643
+ fi`
644
+ : ` if [ -n "\${{ inputs.buildId }}" ]; then
645
+ ${rollbackCmd} "\${{ inputs.buildId }}"
646
+ else
647
+ ${rollbackCmd}
648
+ fi`;
436
649
 
437
650
  return `${getWorkflowHeaderComment()}name: DeployHub Rollback
438
651
  on:
@@ -442,7 +655,7 @@ on:
442
655
  description: 'Exact buildId to restore (leave blank = previous build)'
443
656
  required: false
444
657
  type: string
445
- jobs:
658
+ ${environmentInput}jobs:
446
659
  rollback:
447
660
  runs-on: ubuntu-latest
448
661
  steps:
@@ -456,11 +669,7 @@ ${kubernetesSteps}${githubGitConfigStep} - name: Install DeployHub CLI
456
669
  env:
457
670
  ${envBlock}
458
671
  run: |
459
- if [ -n "\${{ inputs.buildId }}" ]; then
460
- ${rollbackCmd} "\${{ inputs.buildId }}"
461
- else
462
- ${rollbackCmd}
463
- fi
672
+ ${rollbackRun}
464
673
  `;
465
674
  }
466
675
 
@@ -567,6 +776,169 @@ export async function getRollbackWorkflowDoctorCheck(cwd, config) {
567
776
  };
568
777
  }
569
778
 
779
+ /**
780
+ * Expected CI secret names for the current config — same set a fresh
781
+ * `sync-workflows` would reference (via buildWorkflowEnvEntries).
782
+ *
783
+ * @param {import('../core/config.js').DeployHubConfig} config
784
+ * @param {'deploy'|'rollback'} [kind]
785
+ * @returns {Set<string>}
786
+ */
787
+ export function expectedWorkflowSecretKeysFromConfig(config, kind = 'rollback') {
788
+ const environments = config.environments || {};
789
+ const allNames = Object.keys(environments);
790
+ /** @type {string[]} */
791
+ let targets;
792
+ 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);
798
+ } else {
799
+ targets = allNames.length > 0 ? listEnabledEnvironmentNames(environments) : allNames;
800
+ if (targets.length === 0) targets = allNames;
801
+ }
802
+
803
+ const entries = buildWorkflowEnvEntries(
804
+ config.storage || [],
805
+ targets,
806
+ environments,
807
+ config
808
+ );
809
+
810
+ /** @type {Set<string>} */
811
+ const keys = new Set();
812
+ const re = /secrets\.([A-Z0-9_]+)/g;
813
+ for (const line of entries) {
814
+ let match;
815
+ re.lastIndex = 0;
816
+ while ((match = re.exec(line)) !== null) {
817
+ keys.add(match[1]);
818
+ }
819
+ }
820
+ return keys;
821
+ }
822
+
823
+ /**
824
+ * Lightweight drift check: checked-in workflow vs current config.
825
+ * Catches missing dispatch env options and missing required secret references.
826
+ *
827
+ * @param {string} yamlText
828
+ * @param {import('../core/config.js').DeployHubConfig} config
829
+ * @param {string} [filename]
830
+ * @returns {{ drifted: boolean, missingEnvs: string[], missingSecrets: string[], summary: string }}
831
+ */
832
+ export function detectWorkflowConfigDrift(yamlText, config, filename = DEPLOY_WORKFLOW_FILENAME) {
833
+ /** @type {string[]} */
834
+ const missingEnvs = [];
835
+ /** @type {string[]} */
836
+ const missingSecrets = [];
837
+
838
+ let parsed;
839
+ try {
840
+ parsed = yaml.load(yamlText);
841
+ } catch {
842
+ return {
843
+ drifted: false,
844
+ missingEnvs,
845
+ missingSecrets,
846
+ summary: '',
847
+ };
848
+ }
849
+
850
+ if (!parsed || typeof parsed !== 'object') {
851
+ return { drifted: false, missingEnvs, missingSecrets, summary: '' };
852
+ }
853
+
854
+ const envNames = listEnabledEnvironmentNames(config.environments || {});
855
+ const root = /** @type {Record<string, any>} */ (parsed);
856
+ const options = root?.on?.workflow_dispatch?.inputs?.environment?.options;
857
+
858
+ if (Array.isArray(options) && envNames.length > 0) {
859
+ const optionSet = new Set(options.map(String));
860
+ for (const name of envNames) {
861
+ if (!optionSet.has(name)) missingEnvs.push(name);
862
+ }
863
+ } else if (envNames.length > 1) {
864
+ // Multi-env config but no dispatch dropdown — stale single-env workflow.
865
+ missingEnvs.push(...envNames);
866
+ }
867
+
868
+ const kind = filename.includes('rollback') ? 'rollback' : 'deploy';
869
+ const fileSecrets = new Set(extractWorkflowSecretKeys(yamlText));
870
+ const expected = expectedWorkflowSecretKeysFromConfig(config, kind);
871
+ for (const key of expected) {
872
+ if (!fileSecrets.has(key)) missingSecrets.push(key);
873
+ }
874
+
875
+ const drifted = missingEnvs.length > 0 || missingSecrets.length > 0;
876
+ /** @type {string[]} */
877
+ const parts = [];
878
+ if (missingEnvs.length > 0) {
879
+ parts.push(
880
+ `missing: ${missingEnvs.map((n) => `"${n}"`).join(', ')} in the dispatch dropdown`
881
+ );
882
+ }
883
+ if (missingSecrets.length > 0) {
884
+ const shown = missingSecrets.slice(0, 4);
885
+ parts.push(
886
+ `missing secret(s): ${shown.join(', ')}${missingSecrets.length > 4 ? ', …' : ''}`
887
+ );
888
+ }
889
+
890
+ return {
891
+ drifted,
892
+ missingEnvs,
893
+ missingSecrets,
894
+ summary: parts.join('; '),
895
+ };
896
+ }
897
+
898
+ /**
899
+ * Doctor helper: informational checks when checked-in workflows drift from config.
900
+ * Skips missing/unparseable files (other checks cover "file missing").
901
+ * Returns an empty array when everything is in sync or not applicable.
902
+ *
903
+ * @param {string} cwd
904
+ * @param {import('../core/config.js').DeployHubConfig} config
905
+ * @returns {Promise<{ name: string, pass: boolean, message: string }[]>}
906
+ */
907
+ export async function getWorkflowDriftDoctorChecks(cwd, config) {
908
+ const envCount = Object.keys(config.environments || {}).length;
909
+ if (envCount === 0) return [];
910
+
911
+ /** @type {{ name: string, pass: boolean, message: string }[]} */
912
+ const checks = [];
913
+ const files = [DEPLOY_WORKFLOW_FILENAME, ROLLBACK_WORKFLOW_FILENAME];
914
+
915
+ for (const filename of files) {
916
+ const filePath = path.join(cwd, '.github', 'workflows', filename);
917
+ if (!(await fs.pathExists(filePath))) continue;
918
+
919
+ let text;
920
+ try {
921
+ text = await fs.readFile(filePath, 'utf8');
922
+ } catch {
923
+ continue;
924
+ }
925
+
926
+ const drift = detectWorkflowConfigDrift(text, config, filename);
927
+ if (!drift.drifted) continue;
928
+
929
+ checks.push({
930
+ name: `Workflow sync (${filename})`,
931
+ // Informational nudge — same pattern as missing rollback workflow (pass: true).
932
+ pass: true,
933
+ message:
934
+ `.github/workflows/${filename} looks out of date with your current environments config ` +
935
+ `(${drift.summary}). Run: deployhub sync-workflows`,
936
+ });
937
+ }
938
+
939
+ return checks;
940
+ }
941
+
570
942
  /**
571
943
  * @param {string} cliSource
572
944
  * @param {string} [cwd]
@@ -577,7 +949,9 @@ export async function addDeployhubToPackageJson(cliSource, cwd = process.cwd())
577
949
 
578
950
  const pkg = await fs.readJson(pkgPath);
579
951
  pkg.devDependencies = pkg.devDependencies || {};
580
- pkg.devDependencies[NPM_PACKAGE] = getCliInstallSpec(cliSource);
952
+ // package.json value must be a semver range / "latest" / git URL — never "name@version"
953
+ // (that form is only for `npm install <spec>` via getCliInstallSpec).
954
+ pkg.devDependencies[NPM_PACKAGE] = getCliPackageJsonDependencyVersion(cliSource);
581
955
  delete pkg.devDependencies.deployhub;
582
956
  pkg.scripts = pkg.scripts || {};
583
957
  pkg.scripts['deployhub:build'] = 'deployhub build';
@@ -669,9 +1043,15 @@ export function getGithubSecretsChecklist(
669
1043
 
670
1044
  for (const envName of deployEnvironments) {
671
1045
  const env = environments[envName];
672
- if (!env?.type) continue;
673
-
674
- for (const item of getDeploymentSecretChecklistItems(env.type, config)) {
1046
+ const method = getEnvMethod(env);
1047
+ if (!method) continue;
1048
+
1049
+ for (const item of getDeploymentSecretChecklistItemsForEnv(
1050
+ envName,
1051
+ method,
1052
+ config,
1053
+ environments
1054
+ )) {
675
1055
  const existing = byKey.get(item.key);
676
1056
  if (existing) {
677
1057
  if (item.required && !existing.required) {
@@ -741,9 +1121,10 @@ export function generateEnvExampleContent(
741
1121
 
742
1122
  for (const envName of deployEnvironments) {
743
1123
  const env = environments[envName];
744
- if (!env?.type) continue;
1124
+ const method = getEnvMethod(env);
1125
+ if (!method) continue;
745
1126
 
746
- const deploySection = generateDeploymentEnvSection(env.type, config, environments);
1127
+ const deploySection = generateDeploymentEnvSection(method, config, environments);
747
1128
  if (deploySection) {
748
1129
  sections.push(`${deploySection}\n`);
749
1130
  }