@akash-chowdhury-24/deployhub 2.0.14 → 2.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,14 +4,21 @@ import fs from 'fs-extra';
4
4
  import path from 'path';
5
5
  import os from 'os';
6
6
  import axios from 'axios';
7
- import { loadConfig, loadEnv } from '../core/config.js';
7
+ import { loadConfig, loadEnv, getEnvMethod, getEnvSettings } from '../core/config.js';
8
+ import {
9
+ isEnvEnabled,
10
+ resolveDefaultEnvironmentName,
11
+ } from '../core/environments.js';
12
+ import { loadEnvArtifactHistory } from '../storage/index.js';
8
13
  import { testProvider } from '../storage/index.js';
9
14
  import { getDeploymentProvider } from '../deployment/index.js';
10
- import { PROVIDER_ENV_MAP, getRollbackWorkflowDoctorCheck } from '../utils/github-actions.js';
15
+ import { PROVIDER_ENV_MAP, getRollbackWorkflowDoctorCheck, getWorkflowDriftDoctorChecks } from '../utils/github-actions.js';
11
16
  import { printDoctorFooter } from '../utils/author.js';
12
17
  import { createLocalProvider } from '../storage/providers/local.js';
13
18
  import {
14
19
  getDeploymentEnvKeys,
20
+ getDeploymentSecretKeysForEnv,
21
+ envUsesPrefixedSecrets,
15
22
  getDeploymentSecretKeys,
16
23
  } from '../deployment/deployment-env.js';
17
24
  import { testSshConnectivity, validateSshKeyForDoctor, testSshHostReachability } from '../deployment/init-helpers.js';
@@ -27,6 +34,62 @@ import { namespaceExists } from '../utils/kubernetes-namespace.js';
27
34
  * @typedef {{ name: string, pass: boolean, message: string }} CheckResult
28
35
  */
29
36
 
37
+ /**
38
+ * Whether doctor should `process.exit(1)` — only blocking (non-informational) failures count.
39
+ * @param {CheckResult[]} results
40
+ * @param {Set<string>} informationalCheckNames
41
+ * @returns {boolean}
42
+ */
43
+ export function doctorHasBlockingFailures(results, informationalCheckNames) {
44
+ const blocking = results.filter((r) => !informationalCheckNames.has(r.name));
45
+ return blocking.some((r) => !r.pass);
46
+ }
47
+
48
+ /**
49
+ * Run per-env deployment checks with try/catch isolation (one env crash does not abort others).
50
+ * Disabled env failures are marked informational when `includeDisabled` is true.
51
+ *
52
+ * @param {import('../core/config.js').DeployHubConfig} config
53
+ * @param {string[]} envNames
54
+ * @param {{ includeDisabled?: boolean, runChecks?: typeof runDeploymentChecks }} [options]
55
+ * @returns {Promise<{ results: CheckResult[], informationalCheckNames: Set<string> }>}
56
+ */
57
+ export async function collectEnvDeploymentChecks(config, envNames, options = {}) {
58
+ const includeDisabled = options.includeDisabled === true;
59
+ const runChecks = options.runChecks || runDeploymentChecks;
60
+ /** @type {CheckResult[]} */
61
+ const results = [];
62
+ /** @type {Set<string>} */
63
+ const informationalCheckNames = new Set();
64
+
65
+ for (const envName of envNames) {
66
+ const env = config.environments?.[envName];
67
+ if (!env) continue;
68
+ const enabled = isEnvEnabled(env);
69
+ if (!enabled && !includeDisabled) continue;
70
+
71
+ try {
72
+ const deployChecks = await runChecks(config, envName, env);
73
+ for (const c of deployChecks) {
74
+ c.name = `${envName}/${c.name}`;
75
+ results.push(c);
76
+ if (!enabled) informationalCheckNames.add(c.name);
77
+ }
78
+ } catch (err) {
79
+ const message = err instanceof Error ? err.message : String(err);
80
+ const name = `${envName}/checks`;
81
+ results.push({
82
+ name,
83
+ pass: false,
84
+ message: `Check aborted: ${message}`,
85
+ });
86
+ if (!enabled) informationalCheckNames.add(name);
87
+ }
88
+ }
89
+
90
+ return { results, informationalCheckNames };
91
+ }
92
+
30
93
  /** @type {Set<string>} */
31
94
  const NODE_FRAMEWORKS = new Set(['express', 'nestjs', 'fastify', 'koa', 'nextjs', 'node']);
32
95
  /** @type {Set<string>} */
@@ -102,9 +165,10 @@ function needsNginxActivationForDeploy(config) {
102
165
  * @param {Record<string, unknown>} envConfig
103
166
  * @returns {Promise<CheckResult[]>}
104
167
  */
105
- async function runDeploymentChecks(config, envName, envConfig) {
106
- const deployType = envConfig.type;
168
+ export async function runDeploymentChecks(config, envName, envConfig) {
169
+ const deployType = getEnvMethod(envConfig);
107
170
  if (!deployType || typeof deployType !== 'string') return [];
171
+ const settings = getEnvSettings(envConfig);
108
172
 
109
173
  /** @type {CheckResult[]} */
110
174
  const checks = [];
@@ -134,10 +198,10 @@ async function runDeploymentChecks(config, envName, envConfig) {
134
198
  );
135
199
 
136
200
  if (SSH_DEPLOY_TYPES.has(deployType)) {
137
- const host = envConfig.host || process.env.SSH_HOST;
138
- const user = envConfig.user || process.env.SSH_USER;
139
- const keyPath = envConfig.keyPath || process.env.SSH_KEY_PATH;
140
- const sshPort = Number(process.env.SSH_SSH_PORT || envConfig.sshPort) || 22;
201
+ const host = settings.host || process.env.SSH_HOST;
202
+ const user = settings.user || process.env.SSH_USER;
203
+ const keyPath = settings.keyPath || process.env.SSH_KEY_PATH;
204
+ const sshPort = Number(process.env.SSH_SSH_PORT || settings.sshPort) || 22;
141
205
 
142
206
  checks.push(
143
207
  await runCheck('SSH key', async () => {
@@ -171,7 +235,7 @@ async function runDeploymentChecks(config, envName, envConfig) {
171
235
  })
172
236
  );
173
237
 
174
- const deployPaths = resolveSshDeployPaths(config, envConfig);
238
+ const deployPaths = resolveSshDeployPaths(config, settings);
175
239
  const sshUser = String(user || process.env.SSH_USER || 'your-user');
176
240
 
177
241
  for (const deployPath of deployPaths) {
@@ -634,11 +698,17 @@ export function registerDoctorCommand(program) {
634
698
  program
635
699
  .command('doctor')
636
700
  .description('Run pre-flight checks before deploying')
637
- .action(async () => {
701
+ .option(
702
+ '--all',
703
+ 'Also run method checks for disabled environments (failures are informational only)'
704
+ )
705
+ .action(async (opts) => {
638
706
  loadEnv();
639
707
  const cwd = process.cwd();
640
708
  /** @type {CheckResult[]} */
641
709
  const results = [];
710
+ /** @type {Set<string>} names of checks that must not fail the summary */
711
+ const informationalCheckNames = new Set();
642
712
 
643
713
  results.push(
644
714
  await runCheck('Git', async () => {
@@ -801,14 +871,49 @@ export function registerDoctorCommand(program) {
801
871
  }
802
872
  }
803
873
 
804
- for (const envName of config.deploy || []) {
805
- const env = config.environments[envName];
806
- if (!env) continue;
807
-
808
- const deployChecks = await runDeploymentChecks(config, envName, env);
809
- results.push(...deployChecks);
874
+ // Per-environment summary + method checks (enabled by default; --all includes disabled)
875
+ const envNames = Object.keys(config.environments || {});
876
+ if (envNames.length > 0) {
877
+ console.log('');
878
+ console.log(chalk.bold('Environments:'));
879
+ for (const envName of envNames) {
880
+ const env = config.environments[envName];
881
+ const method = getEnvMethod(env) || '?';
882
+ const enabled = isEnvEnabled(env);
883
+ const status = enabled ? 'enabled' : 'disabled';
884
+ let last = 'never';
885
+ try {
886
+ const { entries } = await loadEnvArtifactHistory(
887
+ config.storage || [],
888
+ config.project,
889
+ envName,
890
+ { defaultEnvironment: resolveDefaultEnvironmentName(config) }
891
+ );
892
+ if (entries[0]?.buildId) {
893
+ last = entries[0].buildId;
894
+ }
895
+ } catch {
896
+ // doctor never crashes
897
+ }
898
+ const icon = enabled ? chalk.green('✓') : chalk.gray('–');
899
+ const hint = enabled
900
+ ? `last deploy: ${last}`
901
+ : `run \`deployhub env enable ${envName}\``;
902
+ console.log(
903
+ ` ${icon} ${envName.padEnd(14)} (${method})`.padEnd(36) +
904
+ `${status.padEnd(10)} — ${hint}`
905
+ );
906
+ }
907
+ console.log('');
810
908
  }
811
909
 
910
+ const { results: envResults, informationalCheckNames: envInfo } =
911
+ await collectEnvDeploymentChecks(config, envNames, {
912
+ includeDisabled: Boolean(opts.all),
913
+ });
914
+ results.push(...envResults);
915
+ for (const name of envInfo) informationalCheckNames.add(name);
916
+
812
917
  results.push(
813
918
  await runCheck('Health endpoint', async () => {
814
919
  const url = config.healthCheck?.url;
@@ -851,10 +956,13 @@ export function registerDoctorCommand(program) {
851
956
  const keys = PROVIDER_ENV_MAP[provider] || [];
852
957
  required.push(...keys);
853
958
  }
854
- for (const envName of config.deploy || []) {
959
+ for (const envName of Object.keys(config.environments || {})) {
855
960
  const env = config.environments[envName];
856
- if (!env?.type) continue;
857
- required.push(...getDeploymentSecretKeys(env.type, config));
961
+ if (!isEnvEnabled(env)) continue;
962
+ const method = getEnvMethod(env);
963
+ if (!method) continue;
964
+ // Local/.env uses unprefixed names; prefixed CI names are checked separately below.
965
+ required.push(...getDeploymentSecretKeys(method, config));
858
966
  }
859
967
 
860
968
  const unique = [...new Set(required)];
@@ -871,10 +979,65 @@ export function registerDoctorCommand(program) {
871
979
  message: `Missing: ${missing.join(', ')}`,
872
980
  };
873
981
  }
874
- return { name: 'Secrets', pass: true, message: 'All required env vars present' };
982
+ return { name: 'Secrets', pass: true, message: 'All required secrets present' };
875
983
  })
876
984
  );
877
985
 
986
+ // Explicit CI-prefixed secret reminders for non-grandfathered environments
987
+ if (config) {
988
+ const inCi = process.env.GITHUB_ACTIONS === 'true' || process.env.CI === 'true';
989
+ for (const envName of Object.keys(config.environments || {})) {
990
+ const env = config.environments[envName];
991
+ if (!isEnvEnabled(env)) continue;
992
+ if (!envUsesPrefixedSecrets(envName, config)) continue;
993
+ const method = getEnvMethod(env);
994
+ if (!method) continue;
995
+
996
+ const prefixedKeys = getDeploymentSecretKeysForEnv(
997
+ envName,
998
+ method,
999
+ config,
1000
+ config.environments
1001
+ );
1002
+ const baseKeys = getDeploymentSecretKeys(method, config);
1003
+
1004
+ for (let i = 0; i < baseKeys.length; i++) {
1005
+ const base = baseKeys[i];
1006
+ const prefixed = prefixedKeys[i] || base;
1007
+ if (prefixed === base) continue;
1008
+
1009
+ const checkName = `CI secret ${prefixed}`;
1010
+ results.push(
1011
+ await runCheck(checkName, async () => {
1012
+ if (process.env[prefixed]) {
1013
+ return {
1014
+ name: checkName,
1015
+ pass: true,
1016
+ message: `Present for environment "${envName}"`,
1017
+ };
1018
+ }
1019
+ const localFallback =
1020
+ base === 'SSH_KEY'
1021
+ ? process.env.SSH_KEY || process.env.SSH_KEY_PATH
1022
+ : process.env[base];
1023
+ if (!inCi && localFallback) {
1024
+ return {
1025
+ name: checkName,
1026
+ pass: false,
1027
+ message: `Add GitHub Actions secret "${prefixed}" for env "${envName}" (local has ${base}, but CI needs the prefixed name)`,
1028
+ };
1029
+ }
1030
+ return {
1031
+ name: checkName,
1032
+ pass: false,
1033
+ message: `Missing — add GitHub Actions secret "${prefixed}" for environment "${envName}"`,
1034
+ };
1035
+ })
1036
+ );
1037
+ }
1038
+ }
1039
+ }
1040
+
878
1041
  results.push(
879
1042
  await runCheck('GitHub Actions', async () => {
880
1043
  const workflowPath = path.join(cwd, '.github', 'workflows', 'deployhub.yml');
@@ -910,6 +1073,13 @@ export function registerDoctorCommand(program) {
910
1073
  );
911
1074
  }
912
1075
 
1076
+ if (config) {
1077
+ const driftChecks = await getWorkflowDriftDoctorChecks(cwd, config);
1078
+ for (const check of driftChecks) {
1079
+ results.push(await runCheck(check.name, async () => check));
1080
+ }
1081
+ }
1082
+
913
1083
  results.push(
914
1084
  await runCheck('Storage write', async () => {
915
1085
  const provider = createLocalProvider();
@@ -928,14 +1098,21 @@ export function registerDoctorCommand(program) {
928
1098
  );
929
1099
 
930
1100
  console.log('');
931
- const pad = (name) => name.padEnd(22);
1101
+ const pad = (name) => name.padEnd(28);
932
1102
  for (const r of results) {
933
- const icon = r.pass ? chalk.green('✓') : chalk.red('✗');
934
- console.log(` Checking ${pad(r.name)}... ${icon} ${r.message}`);
1103
+ const info = informationalCheckNames.has(r.name);
1104
+ const icon = r.pass
1105
+ ? chalk.green('✓')
1106
+ : info
1107
+ ? chalk.yellow('!')
1108
+ : chalk.red('✗');
1109
+ const suffix = info && !r.pass ? chalk.gray(' (informational — disabled env)') : '';
1110
+ console.log(` Checking ${pad(r.name)}... ${icon} ${r.message}${suffix}`);
935
1111
  }
936
1112
 
937
- const passed = results.filter((r) => r.pass).length;
938
- const total = results.length;
1113
+ const blocking = results.filter((r) => !informationalCheckNames.has(r.name));
1114
+ const passed = blocking.filter((r) => r.pass).length;
1115
+ const total = blocking.length;
939
1116
  console.log('');
940
1117
  if (passed === total) {
941
1118
  console.log(chalk.green.bold(` ✓ Ready to deploy (${passed}/${total} checks passed)`));
@@ -950,7 +1127,16 @@ export function registerDoctorCommand(program) {
950
1127
  console.log('');
951
1128
  printDoctorFooter();
952
1129
  console.log('');
1130
+
1131
+ if (doctorHasBlockingFailures(results, informationalCheckNames)) {
1132
+ process.exit(1);
1133
+ }
953
1134
  });
954
1135
  }
955
1136
 
956
- export default { registerDoctorCommand };
1137
+ export default {
1138
+ registerDoctorCommand,
1139
+ runDeploymentChecks,
1140
+ doctorHasBlockingFailures,
1141
+ collectEnvDeploymentChecks,
1142
+ };
@@ -0,0 +1,326 @@
1
+ import chalk from 'chalk';
2
+ import inquirer from 'inquirer';
3
+ import {
4
+ loadConfig,
5
+ loadEnv,
6
+ saveConfig,
7
+ } from '../core/config.js';
8
+ import {
9
+ getEnvMethod,
10
+ getEnvTrigger,
11
+ isEnvEnabled,
12
+ resolveDefaultEnvironmentName,
13
+ validateEnvironmentName,
14
+ } from '../core/environments.js';
15
+ import { promptServerDeployment, buildServerEnvEntry } from '../deployment/init-prompts.js';
16
+ import { writeWorkflowFile } from '../utils/github-actions.js';
17
+ import { loadEnvArtifactHistory } from '../storage/index.js';
18
+ import { createLogger } from '../logger/index.js';
19
+ import {
20
+ envUsesPrefixedSecrets,
21
+ getDeploymentWorkflowSecretKeysForEnv,
22
+ } from '../deployment/deployment-env.js';
23
+ import {
24
+ envHistoryRemoteKey,
25
+ envLatestArtifactRemoteKey,
26
+ } from '../utils/build-id.js';
27
+
28
+ /**
29
+ * Best-effort last buildId for an env from per-env deploy history.
30
+ * @param {import('../core/config.js').DeployHubConfig} config
31
+ * @param {string} envName
32
+ * @returns {Promise<string>}
33
+ */
34
+ async function lastDeployedBuildId(config, envName) {
35
+ try {
36
+ const providers = config.storage || [];
37
+ if (providers.length === 0) return '—';
38
+ const { entries } = await loadEnvArtifactHistory(providers, config.project, envName, {
39
+ defaultEnvironment: resolveDefaultEnvironmentName(config),
40
+ });
41
+ if (entries[0]?.buildId) return entries[0].buildId;
42
+ } catch {
43
+ // list never crashes
44
+ }
45
+ return '—';
46
+ }
47
+
48
+ /**
49
+ * @param {import('commander').Command} program
50
+ */
51
+ export function registerEnvCommand(program) {
52
+ const env = program.command('env').description('Manage deployment environments');
53
+
54
+ env
55
+ .command('list')
56
+ .description('List configured environments')
57
+ .action(async () => {
58
+ loadEnv();
59
+ const config = await loadConfig();
60
+ const names = Object.keys(config.environments || {});
61
+ if (names.length === 0) {
62
+ console.log(chalk.yellow('No environments configured. Run: deployhub env add <name>'));
63
+ return;
64
+ }
65
+
66
+ const def = resolveDefaultEnvironmentName(config);
67
+ console.log('');
68
+ console.log(
69
+ chalk.bold(
70
+ `${'Name'.padEnd(18)} ${'Method'.padEnd(12)} ${'Status'.padEnd(10)} ${'Trigger'.padEnd(8)} Last deploy`
71
+ )
72
+ );
73
+ console.log(chalk.gray('─'.repeat(72)));
74
+
75
+ for (const name of names) {
76
+ const entry = config.environments[name];
77
+ const method = getEnvMethod(entry) || '?';
78
+ const status = isEnvEnabled(entry) ? 'enabled' : 'disabled';
79
+ const trigger = getEnvTrigger(entry);
80
+ const last = await lastDeployedBuildId(config, name);
81
+ const label = name === def ? `${name}*` : name;
82
+ console.log(
83
+ `${label.padEnd(18)} ${method.padEnd(12)} ${status.padEnd(10)} ${trigger.padEnd(8)} ${last}`
84
+ );
85
+ }
86
+ console.log('');
87
+ console.log(chalk.gray('* defaultEnvironment'));
88
+ });
89
+
90
+ env
91
+ .command('add <name>')
92
+ .description('Add a named deployment environment')
93
+ .option(
94
+ '--method <type>',
95
+ 'Deployment method (ssh|docker|ec2|azure-vm|gcp-vm|kubernetes); skips the type prompt'
96
+ )
97
+ .option(
98
+ '--yes',
99
+ 'Non-interactive: use defaults for the chosen --method (required with --yes)'
100
+ )
101
+ .action(async (rawName, opts) => {
102
+ loadEnv();
103
+ const cwd = process.cwd();
104
+ const config = await loadConfig(cwd);
105
+ const log = createLogger('env');
106
+
107
+ const nameCheck = validateEnvironmentName(rawName, Object.keys(config.environments || {}));
108
+ if (!nameCheck.ok) {
109
+ console.error(chalk.red(nameCheck.error));
110
+ process.exit(1);
111
+ }
112
+ const name = nameCheck.name;
113
+
114
+ if (opts.yes && !opts.method) {
115
+ console.error(
116
+ chalk.red('Non-interactive --yes requires --method <ssh|docker|ec2|azure-vm|gcp-vm|kubernetes>.')
117
+ );
118
+ process.exit(1);
119
+ }
120
+
121
+ const projectType = config.projectType || 'frontend';
122
+ const backendConfig = config.backend || null;
123
+ const singleConfig =
124
+ projectType === 'both'
125
+ ? null
126
+ : {
127
+ framework: config.framework,
128
+ port: config.port,
129
+ };
130
+
131
+ const existingNames = Object.keys(config.environments || {});
132
+ // Permanently grandfather the original environment's unprefixed secrets.
133
+ if (!config.unprefixedSecretEnvironment && existingNames.length === 1) {
134
+ config.unprefixedSecretEnvironment = existingNames[0];
135
+ } else if (!config.unprefixedSecretEnvironment && existingNames.length === 0) {
136
+ // First env ever — this new one stays unprefixed.
137
+ config.unprefixedSecretEnvironment = name;
138
+ }
139
+
140
+ let deployAnswers;
141
+ try {
142
+ deployAnswers = await promptServerDeployment(
143
+ config.project,
144
+ projectType,
145
+ backendConfig,
146
+ {
147
+ envName: name,
148
+ existingEnvNames: Object.keys(config.environments || {}),
149
+ deployType: opts.method,
150
+ nonInteractive: Boolean(opts.yes),
151
+ }
152
+ );
153
+ } catch (err) {
154
+ console.error(chalk.red(err instanceof Error ? err.message : String(err)));
155
+ process.exit(1);
156
+ }
157
+
158
+ config.environments[name] = buildServerEnvEntry(
159
+ deployAnswers,
160
+ projectType,
161
+ config.project,
162
+ backendConfig,
163
+ singleConfig
164
+ );
165
+
166
+ if (!config.defaultEnvironment) {
167
+ config.defaultEnvironment = name;
168
+ }
169
+
170
+ config.pipeline = config.pipeline || {};
171
+ config.pipeline.deploy = true;
172
+
173
+ await saveConfig(config, cwd);
174
+
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
+ );
186
+
187
+ log.success(`Added environment "${name}" (${deployAnswers.deployType})`);
188
+ console.log(chalk.gray(` defaultEnvironment: ${config.defaultEnvironment}`));
189
+ console.log(chalk.gray(' Workflows regenerated — commit .github/workflows if using CI.'));
190
+
191
+ const envCount = Object.keys(config.environments).length;
192
+ if (envCount >= 2 && envUsesPrefixedSecrets(name, config)) {
193
+ const original = config.unprefixedSecretEnvironment;
194
+ const method = getEnvMethod(config.environments[name]);
195
+ const newSecrets = method
196
+ ? getDeploymentWorkflowSecretKeysForEnv(name, method, config, config.environments)
197
+ : [];
198
+ const requiredNew = newSecrets.filter((k) => !k.includes('OPTIONAL'));
199
+ console.log('');
200
+ console.log(chalk.yellow(`⚠ You now have ${envCount} environments configured.`));
201
+ if (original) {
202
+ console.log(
203
+ chalk.yellow(
204
+ ` "${original}" keeps using its existing unprefixed secrets (e.g. SSH_HOST) — no action needed.`
205
+ )
206
+ );
207
+ }
208
+ console.log(
209
+ chalk.yellow(
210
+ ` "${name}" requires new secrets in CI: ${requiredNew.slice(0, 8).join(', ')}${
211
+ requiredNew.length > 8 ? ', …' : ''
212
+ }`
213
+ )
214
+ );
215
+ console.log(
216
+ chalk.yellow(
217
+ ' Add these to your GitHub repo secrets before running a deploy/rollback for this environment in CI.'
218
+ )
219
+ );
220
+ console.log(chalk.yellow(' Run `deployhub sync-workflows` after adding them (already regenerated).'));
221
+ }
222
+ });
223
+
224
+ env
225
+ .command('enable <name>')
226
+ .description('Enable an environment for deploy / --env all')
227
+ .action(async (name) => {
228
+ loadEnv();
229
+ const cwd = process.cwd();
230
+ const config = await loadConfig(cwd);
231
+ if (!config.environments[name]) {
232
+ console.error(chalk.red(`Environment "${name}" not found.`));
233
+ process.exit(1);
234
+ }
235
+ config.environments[name].enabled = true;
236
+ await saveConfig(config, cwd);
237
+ console.log(chalk.green(`✓ Enabled environment "${name}"`));
238
+ });
239
+
240
+ env
241
+ .command('disable <name>')
242
+ .description('Disable an environment (deploy to it will fail until re-enabled)')
243
+ .action(async (name) => {
244
+ loadEnv();
245
+ const cwd = process.cwd();
246
+ const config = await loadConfig(cwd);
247
+ if (!config.environments[name]) {
248
+ console.error(chalk.red(`Environment "${name}" not found.`));
249
+ process.exit(1);
250
+ }
251
+ config.environments[name].enabled = false;
252
+ await saveConfig(config, cwd);
253
+ console.log(chalk.yellow(`Disabled environment "${name}"`));
254
+ });
255
+
256
+ env
257
+ .command('remove <name>')
258
+ .description('Remove an environment and its rollback history reference')
259
+ .action(async (name) => {
260
+ loadEnv();
261
+ const cwd = process.cwd();
262
+ const config = await loadConfig(cwd);
263
+ if (!config.environments[name]) {
264
+ console.error(chalk.red(`Environment "${name}" not found.`));
265
+ process.exit(1);
266
+ }
267
+
268
+ const { confirm } = await inquirer.prompt([
269
+ {
270
+ type: 'confirm',
271
+ name: 'confirm',
272
+ message: `Remove environment "${name}"? Its per-environment rollback history will no longer be tracked by DeployHub.`,
273
+ default: false,
274
+ },
275
+ ]);
276
+ if (!confirm) {
277
+ console.log(chalk.gray('Cancelled.'));
278
+ return;
279
+ }
280
+
281
+ if (config.defaultEnvironment === name) {
282
+ const remaining = Object.keys(config.environments).filter((n) => n !== name);
283
+ if (remaining.length > 0) {
284
+ console.error(
285
+ chalk.red(
286
+ `Cannot remove "${name}" while it is defaultEnvironment.\n` +
287
+ ` Set a new default first (edit defaultEnvironment in deployhub.config.json), ` +
288
+ `then run deployhub env remove ${name} again.`
289
+ )
290
+ );
291
+ process.exit(1);
292
+ }
293
+ }
294
+
295
+ const historyKey = envHistoryRemoteKey(config.project, name);
296
+ const latestKey = envLatestArtifactRemoteKey(config.project, name);
297
+
298
+ delete config.environments[name];
299
+ if (config.defaultEnvironment === name) {
300
+ config.defaultEnvironment = undefined;
301
+ }
302
+ await saveConfig(config, cwd);
303
+
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
+ );
315
+
316
+ console.log(chalk.green(`✓ Removed environment "${name}"`));
317
+ console.log(
318
+ chalk.yellow(
319
+ ` Storage not deleted (orphaned): ${historyKey}, ${latestKey}\n` +
320
+ ' Remove these manually from your storage provider if you no longer need rollback data.'
321
+ )
322
+ );
323
+ });
324
+ }
325
+
326
+ export default { registerEnvCommand };