@akash-chowdhury-24/deployhub 2.0.15 → 2.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Multi-environment helpers — kept separate from config I/O to avoid ESM cycles
3
+ * (e.g. github-actions ↔ config via doctor/sync-workflows).
4
+ */
5
+
6
+ /** DNS label / Kubernetes namespace max length — tightest downstream constraint. */
7
+ export const ENV_NAME_MAX_LENGTH = 63;
8
+
9
+ /**
10
+ * Reserved CLI sentinel names. `"all"` collides with `--env all`.
11
+ * `"default"` is intentionally NOT reserved — it is a normal env key used by
12
+ * single-env init and legacy migration, with no special CLI meaning.
13
+ */
14
+ export const RESERVED_ENVIRONMENT_NAMES = new Set(['all']);
15
+
16
+ /**
17
+ * Lowercase kebab-case: safe for storage paths, CI secret prefixes (uppercased),
18
+ * and Nginx site filename segments.
19
+ */
20
+ export const ENV_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
21
+
22
+ /**
23
+ * Validate a candidate environment name before writing config.
24
+ * Shared by `deployhub env add` and interactive init / env-name prompts.
25
+ *
26
+ * @param {string} name
27
+ * @param {Iterable<string>} [existingNames]
28
+ * @returns {{ ok: true, name: string } | { ok: false, error: string }}
29
+ */
30
+ export function validateEnvironmentName(name, existingNames = []) {
31
+ const trimmed = typeof name === 'string' ? name.trim() : '';
32
+ if (!trimmed) {
33
+ return { ok: false, error: 'Environment name cannot be empty.' };
34
+ }
35
+ if (trimmed.length > ENV_NAME_MAX_LENGTH) {
36
+ return {
37
+ ok: false,
38
+ error: `Environment name must be at most ${ENV_NAME_MAX_LENGTH} characters.`,
39
+ };
40
+ }
41
+ if (RESERVED_ENVIRONMENT_NAMES.has(trimmed.toLowerCase())) {
42
+ return {
43
+ ok: false,
44
+ error: `"${trimmed}" is a reserved name and cannot be used as an environment name.`,
45
+ };
46
+ }
47
+ if (!ENV_NAME_PATTERN.test(trimmed)) {
48
+ return {
49
+ ok: false,
50
+ error: `Invalid environment name "${trimmed}". Use lowercase letters, numbers, and hyphens only (must start with a letter), e.g. production, staging, my-app.`,
51
+ };
52
+ }
53
+ const existing = [...existingNames];
54
+ const collision = existing.find((n) => String(n).toLowerCase() === trimmed.toLowerCase());
55
+ if (collision) {
56
+ return {
57
+ ok: false,
58
+ error: `Environment "${collision}" already exists (names are case-insensitive). Use a different name or remove it first.`,
59
+ };
60
+ }
61
+ return { ok: true, name: trimmed };
62
+ }
63
+
64
+ /**
65
+ * Inquirer `validate` callback for environment name prompts.
66
+ *
67
+ * @param {Iterable<string>} [existingNames]
68
+ * @returns {(input: string) => true|string}
69
+ */
70
+ export function createEnvNamePromptValidate(existingNames = []) {
71
+ return (input) => {
72
+ const result = validateEnvironmentName(input, existingNames);
73
+ return result.ok ? true : result.error;
74
+ };
75
+ }
76
+
77
+ /**
78
+ * @param {unknown} entry
79
+ * @returns {boolean}
80
+ */
81
+ export function isNewEnvironmentShape(entry) {
82
+ return Boolean(
83
+ entry &&
84
+ typeof entry === 'object' &&
85
+ typeof /** @type {Record<string, unknown>} */ (entry).method === 'string' &&
86
+ /** @type {Record<string, unknown>} */ (entry).config &&
87
+ typeof /** @type {Record<string, unknown>} */ (entry).config === 'object' &&
88
+ !Array.isArray(/** @type {Record<string, unknown>} */ (entry).config)
89
+ );
90
+ }
91
+
92
+ /**
93
+ * @param {unknown} envEntry
94
+ * @returns {string|undefined}
95
+ */
96
+ export function getEnvMethod(envEntry) {
97
+ if (!envEntry || typeof envEntry !== 'object') return undefined;
98
+ const e = /** @type {Record<string, unknown>} */ (envEntry);
99
+ if (typeof e.method === 'string') return e.method;
100
+ if (typeof e.type === 'string') return e.type;
101
+ return undefined;
102
+ }
103
+
104
+ /**
105
+ * Method-specific settings object (mutable reference for new-shape `config`).
106
+ * Supports legacy flat env entries used in tests.
107
+ *
108
+ * @param {unknown} envEntry
109
+ * @returns {Record<string, unknown>}
110
+ */
111
+ export function getEnvSettings(envEntry) {
112
+ if (!envEntry || typeof envEntry !== 'object') return {};
113
+ if (isNewEnvironmentShape(envEntry)) {
114
+ return /** @type {Record<string, unknown>} */ (
115
+ /** @type {Record<string, unknown>} */ (envEntry).config
116
+ );
117
+ }
118
+ return /** @type {Record<string, unknown>} */ (envEntry);
119
+ }
120
+
121
+ /**
122
+ * @param {unknown} envEntry
123
+ * @returns {boolean}
124
+ */
125
+ export function isEnvEnabled(envEntry) {
126
+ if (!envEntry || typeof envEntry !== 'object') return false;
127
+ const e = /** @type {Record<string, unknown>} */ (envEntry);
128
+ if (typeof e.enabled === 'boolean') return e.enabled;
129
+ return true;
130
+ }
131
+
132
+ /**
133
+ * @param {unknown} envEntry
134
+ * @returns {'push'|'manual'}
135
+ */
136
+ export function getEnvTrigger(envEntry) {
137
+ if (!envEntry || typeof envEntry !== 'object') return 'manual';
138
+ const e = /** @type {Record<string, unknown>} */ (envEntry);
139
+ return e.trigger === 'push' ? 'push' : 'manual';
140
+ }
141
+
142
+ /**
143
+ * @param {Record<string, unknown>} config
144
+ * @returns {string|null}
145
+ */
146
+ export function resolveDefaultEnvironmentName(config) {
147
+ const envs = /** @type {Record<string, unknown>} */ (config.environments || {});
148
+ const named = config.defaultEnvironment;
149
+ if (typeof named === 'string' && envs[named]) return named;
150
+
151
+ const deploy = /** @type {string[]|undefined} */ (config.deploy);
152
+ if (Array.isArray(deploy) && deploy[0] && envs[deploy[0]]) return deploy[0];
153
+
154
+ const enabled = Object.entries(envs)
155
+ .filter(([, e]) => isEnvEnabled(e))
156
+ .map(([n]) => n);
157
+ if (enabled.length === 1) return enabled[0];
158
+ if (envs.default) return 'default';
159
+ if (envs.production) return 'production';
160
+ return enabled[0] || Object.keys(envs)[0] || null;
161
+ }
162
+
163
+ /**
164
+ * Enabled environment names (for `--env all` and pipeline defaults).
165
+ * Falls back to legacy `deploy[]` when present and environments lack `enabled`.
166
+ *
167
+ * @param {Record<string, unknown>} config
168
+ * @returns {string[]}
169
+ */
170
+ export function getEnabledEnvironmentNames(config) {
171
+ const envs = /** @type {Record<string, unknown>} */ (config.environments || {});
172
+ const names = Object.keys(envs);
173
+ if (names.length === 0) return [];
174
+
175
+ const hasEnabledFlag = names.some(
176
+ (n) => typeof /** @type {Record<string, unknown>} */ (envs[n])?.enabled === 'boolean'
177
+ );
178
+ if (hasEnabledFlag || names.some((n) => isNewEnvironmentShape(envs[n]))) {
179
+ return names.filter((n) => isEnvEnabled(envs[n]));
180
+ }
181
+
182
+ const deploy = /** @type {string[]|undefined} */ (config.deploy);
183
+ if (Array.isArray(deploy) && deploy.length > 0) {
184
+ return deploy.filter((n) => Boolean(envs[n]));
185
+ }
186
+ return names;
187
+ }
188
+
189
+ /**
190
+ * Build a new-shape environment entry from method + settings.
191
+ *
192
+ * @param {string} method
193
+ * @param {Record<string, unknown>} [settings]
194
+ * @param {{ enabled?: boolean, trigger?: 'push'|'manual' }} [meta]
195
+ */
196
+ export function buildEnvironmentEntry(method, settings = {}, meta = {}) {
197
+ const cleaned = { ...settings };
198
+ delete cleaned.type;
199
+ delete cleaned.method;
200
+ delete cleaned.enabled;
201
+ delete cleaned.trigger;
202
+ delete cleaned.config;
203
+ return {
204
+ enabled: meta.enabled !== false,
205
+ method,
206
+ trigger: meta.trigger === 'push' ? 'push' : 'manual',
207
+ config: cleaned,
208
+ };
209
+ }
210
+
211
+ /**
212
+ * Resolve which environments a deploy/rollback should target.
213
+ *
214
+ * @param {Record<string, unknown>} config
215
+ * @param {string|undefined} envFlag — omitted = default only; `"all"` = every enabled; else one name
216
+ * @returns {{ targets: string[], skippedDisabled: string[] }}
217
+ */
218
+ export function resolveEnvTargets(config, envFlag) {
219
+ const envs = /** @type {Record<string, unknown>} */ (config.environments || {});
220
+
221
+ if (!envFlag) {
222
+ const def = resolveDefaultEnvironmentName(config);
223
+ if (!def) {
224
+ throw new Error(
225
+ 'No environments configured. Run "deployhub init" or "deployhub env add <name>".'
226
+ );
227
+ }
228
+ if (!isEnvEnabled(envs[def])) {
229
+ throw new Error(
230
+ `Environment "${def}" is disabled. Enable it with: deployhub env enable ${def}`
231
+ );
232
+ }
233
+ return { targets: [def], skippedDisabled: [] };
234
+ }
235
+
236
+ if (envFlag === 'all') {
237
+ /** @type {string[]} */
238
+ const skippedDisabled = [];
239
+ /** @type {string[]} */
240
+ const targets = [];
241
+ for (const name of Object.keys(envs)) {
242
+ if (isEnvEnabled(envs[name])) {
243
+ targets.push(name);
244
+ } else {
245
+ skippedDisabled.push(name);
246
+ }
247
+ }
248
+ return { targets, skippedDisabled };
249
+ }
250
+
251
+ if (!envs[envFlag]) {
252
+ throw new Error(
253
+ `Environment "${envFlag}" not found in config. Use: deployhub env list`
254
+ );
255
+ }
256
+ if (!isEnvEnabled(envs[envFlag])) {
257
+ throw new Error(
258
+ `Environment "${envFlag}" is disabled. Enable it with: deployhub env enable ${envFlag}`
259
+ );
260
+ }
261
+ return { targets: [envFlag], skippedDisabled: [] };
262
+ }
263
+
264
+ /**
265
+ * Overlay method-specific config onto process env for Docker/K8s providers.
266
+ * Secrets still come from real env vars; non-secret names/paths come from config.
267
+ *
268
+ * @param {Record<string, string|undefined>} env
269
+ * @param {Record<string, unknown>} settings
270
+ * @returns {Record<string, string|undefined>}
271
+ */
272
+ export function mergeMethodSettingsIntoEnv(env, settings) {
273
+ /** @type {Record<string, string|undefined>} */
274
+ const out = { ...env };
275
+ if (settings.dockerImageName) out.DOCKER_IMAGE_NAME = String(settings.dockerImageName);
276
+ if (settings.dockerRegistryUrl) out.DOCKER_REGISTRY_URL = String(settings.dockerRegistryUrl);
277
+ if (settings.dockerHost) out.DOCKER_HOST = String(settings.dockerHost);
278
+ if (settings.kubeNamespace) out.KUBE_NAMESPACE = String(settings.kubeNamespace);
279
+ if (settings.kubeconfig) out.KUBECONFIG = String(settings.kubeconfig);
280
+ if (settings.kubeContext) out.KUBE_CONTEXT = String(settings.kubeContext);
281
+ return out;
282
+ }
283
+
284
+ export default {
285
+ ENV_NAME_MAX_LENGTH,
286
+ RESERVED_ENVIRONMENT_NAMES,
287
+ ENV_NAME_PATTERN,
288
+ validateEnvironmentName,
289
+ createEnvNamePromptValidate,
290
+ isNewEnvironmentShape,
291
+ getEnvMethod,
292
+ getEnvSettings,
293
+ isEnvEnabled,
294
+ getEnvTrigger,
295
+ resolveDefaultEnvironmentName,
296
+ getEnabledEnvironmentNames,
297
+ buildEnvironmentEntry,
298
+ resolveEnvTargets,
299
+ mergeMethodSettingsIntoEnv,
300
+ };
@@ -4,10 +4,45 @@ import { createArtifact, repackArtifactZip } from '../artifact/engine.js';
4
4
  import { uploadToAll } from '../storage/index.js';
5
5
  import { deployToAll } from '../deployment/index.js';
6
6
  import { sendNotifications } from '../notifications/index.js';
7
- import axios from 'axios';
7
+ import {
8
+ anyEnvHasResolvableHealthCheckUrl,
9
+ runHealthChecksForEnvs,
10
+ } from '../utils/health-check.js';
8
11
  import { getProjectVersion } from '../utils/version.js';
9
12
  import { resolveBuildId } from '../utils/build-id.js';
10
13
  import { ensureDeployScaffold } from '../utils/scaffold.js';
14
+ import {
15
+ getEnabledEnvironmentNames,
16
+ resolveDefaultEnvironmentName,
17
+ isEnvEnabled,
18
+ getEnvTrigger,
19
+ } from './environments.js';
20
+
21
+ /**
22
+ * Environments to deploy during `deployhub build` (pipeline.deploy).
23
+ * - Local: default environment only (promote elsewhere via `deploy --env`).
24
+ * - GitHub Actions push: every enabled env with trigger "push".
25
+ * - workflow_dispatch: none here (explicit deploy step handles --env).
26
+ *
27
+ * @param {import('./config.js').DeployHubConfig} config
28
+ * @param {Record<string, string|undefined>} [env]
29
+ * @returns {string[]}
30
+ */
31
+ function pipelineDeployTargets(config, env = process.env) {
32
+ if (env.GITHUB_EVENT_NAME === 'workflow_dispatch') {
33
+ return [];
34
+ }
35
+ if (env.GITHUB_ACTIONS === 'true' || env.GITHUB_ACTIONS === '1') {
36
+ return Object.entries(config.environments || {})
37
+ .filter(([, entry]) => isEnvEnabled(entry) && getEnvTrigger(entry) === 'push')
38
+ .map(([name]) => name);
39
+ }
40
+ const def = resolveDefaultEnvironmentName(config);
41
+ if (def && getEnabledEnvironmentNames(config).includes(def)) {
42
+ return [def];
43
+ }
44
+ return getEnabledEnvironmentNames(config).slice(0, 1);
45
+ }
11
46
 
12
47
  /**
13
48
  * @param {import('../core/config.js').DeployHubConfig} config
@@ -143,7 +178,8 @@ export function buildPipelineStages(config, cwd, state) {
143
178
  name: 'storage',
144
179
  enabled: (ctx) => {
145
180
  const willDeploy =
146
- ctx.config.pipeline.deploy === true && (ctx.config.deploy?.length || 0) > 0;
181
+ ctx.config.pipeline.deploy === true &&
182
+ getEnabledEnvironmentNames(ctx.config).length > 0;
147
183
  if (willDeploy && (!ctx.config.storage || ctx.config.storage.length === 0)) {
148
184
  ctx.config.storage = ['local'];
149
185
  }
@@ -159,7 +195,8 @@ export function buildPipelineStages(config, cwd, state) {
159
195
  {
160
196
  name: 'deploy',
161
197
  enabled: (ctx) =>
162
- ctx.config.pipeline.deploy === true && (ctx.config.deploy?.length || 0) > 0,
198
+ ctx.config.pipeline.deploy === true &&
199
+ getEnabledEnvironmentNames(ctx.config).length > 0,
163
200
  async run(ctx) {
164
201
  if (!ctx.state.storageCompleted) {
165
202
  throw new Error(
@@ -168,7 +205,8 @@ export function buildPipelineStages(config, cwd, state) {
168
205
  }
169
206
  const artifactDir = /** @type {string} */ (ctx.state.artifactDir);
170
207
  if (!artifactDir) throw new Error('No artifact to deploy');
171
- const deployed = await deployToAll(ctx.config, artifactDir);
208
+ const targets = pipelineDeployTargets(ctx.config);
209
+ const deployed = await deployToAll(ctx.config, artifactDir, targets);
172
210
  ctx.state.deployedTargets = deployed;
173
211
 
174
212
  await repackArtifactZip(artifactDir);
@@ -180,23 +218,21 @@ export function buildPipelineStages(config, cwd, state) {
180
218
  },
181
219
  {
182
220
  name: 'verify',
183
- enabled: (ctx) =>
184
- ctx.config.pipeline.verify === true && !!ctx.config.healthCheck?.url,
221
+ enabled: (ctx) => {
222
+ if (ctx.config.pipeline.verify !== true) return false;
223
+ const deployed = /** @type {string[]} */ (ctx.state.deployedTargets || []);
224
+ return anyEnvHasResolvableHealthCheckUrl(ctx.config, deployed);
225
+ },
185
226
  async run(ctx) {
186
- const url = ctx.config.healthCheck.url;
187
- const timeout = (ctx.config.healthCheck.timeout || 30) * 1000;
188
- const start = Date.now();
189
- const response = await axios.get(url, {
190
- timeout,
191
- validateStatus: () => true,
192
- });
193
- const elapsed = Date.now() - start;
194
- if (response.status < 200 || response.status >= 400) {
195
- throw new Error(
196
- `Health check failed: HTTP ${response.status} (${elapsed}ms)`
197
- );
227
+ const deployed = /** @type {string[]} */ (ctx.state.deployedTargets || []);
228
+ const { results, failures } = await runHealthChecksForEnvs(ctx.config, deployed);
229
+ if (failures.length > 0) {
230
+ throw new Error(failures[0].error);
231
+ }
232
+ const last = results[results.length - 1];
233
+ if (last) {
234
+ ctx.state.healthCheck = { status: last.status, elapsed: last.elapsed };
198
235
  }
199
- ctx.state.healthCheck = { status: response.status, elapsed };
200
236
  },
201
237
  },
202
238
  {
@@ -504,6 +504,216 @@ export function getDeploymentSecretChecklistItems(deployType, config = null) {
504
504
  return Array.from(byKey.values());
505
505
  }
506
506
 
507
+ /**
508
+ * Uppercase env name for secret prefixes (e.g. production → PRODUCTION).
509
+ * @param {string} envName
510
+ * @returns {string}
511
+ */
512
+ export function envSecretPrefix(envName) {
513
+ return (
514
+ String(envName || 'default')
515
+ .toUpperCase()
516
+ .replace(/[^A-Z0-9]+/g, '_')
517
+ .replace(/^_+|_+$/g, '') || 'DEFAULT'
518
+ );
519
+ }
520
+
521
+ /**
522
+ * Trade-off (superseded for per-env grandfathering): true when 2+ environments exist.
523
+ * Prefer envUsesPrefixedSecrets(envName, config) so the original env stays unprefixed.
524
+ *
525
+ * @param {Record<string, unknown>} [environments]
526
+ * @returns {boolean}
527
+ */
528
+ export function shouldPrefixEnvSecrets(environments) {
529
+ return Object.keys(environments || {}).length > 1;
530
+ }
531
+
532
+ /**
533
+ * Which environment keeps reading unprefixed secrets (SSH_HOST, not ENV_SSH_HOST).
534
+ * Once established, this must not silently change when more environments are added.
535
+ *
536
+ * @param {Record<string, unknown>} config
537
+ * @returns {string|null}
538
+ */
539
+ export function resolveUnprefixedSecretEnvironment(config) {
540
+ const envs = /** @type {Record<string, unknown>} */ (config.environments || {});
541
+ const named = config.unprefixedSecretEnvironment;
542
+ if (typeof named === 'string' && envs[named]) return named;
543
+
544
+ const names = Object.keys(envs);
545
+ if (names.length === 1) return names[0];
546
+ if (typeof config.defaultEnvironment === 'string' && envs[config.defaultEnvironment]) {
547
+ return config.defaultEnvironment;
548
+ }
549
+ return names[0] || null;
550
+ }
551
+
552
+ /**
553
+ * Whether this environment's CI secrets use ENVNAME_KEY prefixes.
554
+ * - 0–1 environments: never prefixed (BC).
555
+ * - 2+ environments: every env EXCEPT the grandfathered unprefixedSecretEnvironment is prefixed.
556
+ *
557
+ * @param {string} envName
558
+ * @param {Record<string, unknown>} config
559
+ * @returns {boolean}
560
+ */
561
+ export function envUsesPrefixedSecrets(envName, config) {
562
+ const envs = config.environments || {};
563
+ if (Object.keys(envs).length <= 1) return false;
564
+ const unprefixed = resolveUnprefixedSecretEnvironment(config);
565
+ return envName !== unprefixed;
566
+ }
567
+
568
+ /**
569
+ * @param {string} envName
570
+ * @param {string} key
571
+ * @returns {string}
572
+ */
573
+ export function prefixSecretKey(envName, key) {
574
+ return `${envSecretPrefix(envName)}_${key}`;
575
+ }
576
+
577
+ /**
578
+ * Overlay prefixed CI secrets (e.g. STAGING_SSH_HOST) onto the unprefixed names
579
+ * providers already read (SSH_HOST). Used when a workflow job carries secrets for
580
+ * multiple environments — static YAML can only map one value per unprefixed key.
581
+ *
582
+ * For prefixed environments, ambient unprefixed leftovers (e.g. a developer's
583
+ * shell SSH_HOST from another project) are cleared unless a matching prefixed
584
+ * secret is present — config settings (host, dockerImageName, …) still win via
585
+ * provider resolution / mergeMethodSettingsIntoEnv.
586
+ *
587
+ * @param {string} envName
588
+ * @param {Record<string, unknown>} config
589
+ * @param {Record<string, string|undefined>} [env]
590
+ * @returns {Record<string, string|undefined>}
591
+ */
592
+ export function applyEnvSecretOverlay(envName, config, env = process.env) {
593
+ /** @type {Record<string, string|undefined>} */
594
+ const out = { ...env };
595
+ if (!envUsesPrefixedSecrets(envName, config)) {
596
+ return out;
597
+ }
598
+
599
+ const entry = /** @type {Record<string, unknown>|undefined} */ (
600
+ (config.environments || {})[envName]
601
+ );
602
+ const method =
603
+ (entry && typeof entry.method === 'string' && entry.method) ||
604
+ (entry && typeof entry.type === 'string' && entry.type) ||
605
+ null;
606
+ if (!method) return out;
607
+
608
+ const keys = getDeploymentWorkflowSecretKeys(method, /** @type {any} */ (config));
609
+ for (const key of keys) {
610
+ const prefixed = prefixSecretKey(envName, key);
611
+ if (out[prefixed]) {
612
+ out[key] = out[prefixed];
613
+ } else {
614
+ // Prefixed envs must NOT silently inherit ambient unprefixed leftovers
615
+ // (e.g. shell SSH_HOST from another project). Config settings still win
616
+ // via settings.host || env.SSH_HOST in providers.
617
+ delete out[key];
618
+ }
619
+ }
620
+ return out;
621
+ }
622
+
623
+ /**
624
+ * Workflow secret names for one environment (prefixed only when that env requires it).
625
+ * @param {string} envName
626
+ * @param {string} deployType
627
+ * @param {import('../core/config.js').DeployHubConfig} [config]
628
+ * @param {Record<string, unknown>} [environments]
629
+ * @returns {string[]}
630
+ */
631
+ export function getDeploymentWorkflowSecretKeysForEnv(
632
+ envName,
633
+ deployType,
634
+ config = null,
635
+ environments = null
636
+ ) {
637
+ const keys = getDeploymentWorkflowSecretKeys(deployType, config);
638
+ const cfg = {
639
+ ...(config || {}),
640
+ environments: environments || config?.environments || {},
641
+ };
642
+ if (!envUsesPrefixedSecrets(envName, cfg)) {
643
+ return keys;
644
+ }
645
+ return keys.map((k) => prefixSecretKey(envName, k));
646
+ }
647
+
648
+ /**
649
+ * Checklist items for one environment (prefixed keys when that env requires it).
650
+ * @param {string} envName
651
+ * @param {string} deployType
652
+ * @param {import('../core/config.js').DeployHubConfig} [config]
653
+ * @param {Record<string, unknown>} [environments]
654
+ * @returns {SecretChecklistItem[]}
655
+ */
656
+ export function getDeploymentSecretChecklistItemsForEnv(
657
+ envName,
658
+ deployType,
659
+ config = null,
660
+ environments = null
661
+ ) {
662
+ const items = getDeploymentSecretChecklistItems(deployType, config);
663
+ const cfg = {
664
+ ...(config || {}),
665
+ environments: environments || config?.environments || {},
666
+ };
667
+ if (!envUsesPrefixedSecrets(envName, cfg)) {
668
+ return items;
669
+ }
670
+ return items.map((item) => ({
671
+ ...item,
672
+ key: prefixSecretKey(envName, item.key),
673
+ note: item.note
674
+ ? `${item.note} (env: ${envName})`
675
+ : `Secret for environment "${envName}"`,
676
+ }));
677
+ }
678
+
679
+ /**
680
+ * Required doctor/CI secret keys for one environment.
681
+ * @param {string} envName
682
+ * @param {string} deployType
683
+ * @param {import('../core/config.js').DeployHubConfig} [config]
684
+ * @param {Record<string, unknown>} [environments]
685
+ * @returns {string[]}
686
+ */
687
+ export function getDeploymentSecretKeysForEnv(
688
+ envName,
689
+ deployType,
690
+ config = null,
691
+ environments = null
692
+ ) {
693
+ const keys = getDeploymentSecretKeys(deployType, config);
694
+ const cfg = {
695
+ ...(config || {}),
696
+ environments: environments || config?.environments || {},
697
+ };
698
+ if (!envUsesPrefixedSecrets(envName, cfg)) {
699
+ return keys;
700
+ }
701
+ return keys.map((k) => prefixSecretKey(envName, k));
702
+ }
703
+
704
+ /**
705
+ * Unprefixed process-env key ↔ GitHub secret name for one env.
706
+ * @param {string} envName
707
+ * @param {string} unprefixedKey
708
+ * @param {Record<string, unknown>} config
709
+ * @returns {string} secret name to look up in GitHub / doctor
710
+ */
711
+ export function resolveSecretNameForEnv(envName, unprefixedKey, config) {
712
+ return envUsesPrefixedSecrets(envName, config)
713
+ ? prefixSecretKey(envName, unprefixedKey)
714
+ : unprefixedKey;
715
+ }
716
+
507
717
  /**
508
718
  * Format a checklist item for console output.
509
719
  * @param {SecretChecklistItem} item