@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,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 };
@@ -2,7 +2,7 @@ import inquirer from 'inquirer';
2
2
  import chalk from 'chalk';
3
3
  import fs from 'fs-extra';
4
4
  import path from 'path';
5
- import { saveConfig, appendEnv } from '../core/config.js';
5
+ import { saveConfig, appendEnv, getEnvMethod, getEnvSettings } from '../core/config.js';
6
6
  import { detectFrontend, detectBackend } from '../detectors/index.js';
7
7
  import {
8
8
  getFrontendInfo,
@@ -174,10 +174,11 @@ async function promptBuildSettings(defaults, side) {
174
174
  async function generateProjectScaffold(config, environments, cwd) {
175
175
  const envList = Object.values(environments);
176
176
 
177
- const usesFrontendSsh = envList.some(
178
- (env) =>
179
- SSH_BASED.includes(env.type) || env.deploymentType === 'server'
180
- );
177
+ const usesFrontendSsh = envList.some((env) => {
178
+ const method = getEnvMethod(env);
179
+ const settings = getEnvSettings(env);
180
+ return SSH_BASED.includes(method) || settings.deploymentType === 'server';
181
+ });
181
182
 
182
183
  const isFrontendProject =
183
184
  config.projectType === 'frontend' || config.projectType === 'both';
@@ -343,84 +344,114 @@ export function registerInitCommand(program) {
343
344
  let dockerEnvToAppend = null;
344
345
 
345
346
  if (answers.configureDeploy) {
346
- if (projectType === 'backend') {
347
+ const { envMode } = await inquirer.prompt([
348
+ {
349
+ type: 'list',
350
+ name: 'envMode',
351
+ message:
352
+ 'How many environments do you want to set up? (1 is fine — you can add more later)',
353
+ choices: [
354
+ { name: 'Just one environment', value: 'one' },
355
+ {
356
+ name: 'Multiple environments (e.g. development, testing, production)',
357
+ value: 'multi',
358
+ },
359
+ ],
360
+ default: 'one',
361
+ },
362
+ ]);
363
+
364
+ /**
365
+ * Shared single-round prompt used by init (loop) and env add.
366
+ * @param {{ envName?: string }} [opts]
367
+ */
368
+ async function promptAndStoreEnvironment(opts = {}) {
369
+ const deployTypeForPrompt =
370
+ projectType === 'both' ? 'both' : projectType;
347
371
  const deployAnswers = await promptServerDeployment(
348
372
  projectName,
349
- projectType,
350
- backendConfig
351
- );
352
- primaryDeployType = deployAnswers.deployType;
353
- deploy.push(deployAnswers.envName);
354
- environments[deployAnswers.envName] = buildServerEnvEntry(
355
- deployAnswers,
356
- projectType,
357
- projectName,
373
+ deployTypeForPrompt,
358
374
  backendConfig,
359
- singleConfig
360
- );
361
- if (deployAnswers.healthUrl) {
362
- healthUrl = deployAnswers.healthUrl;
363
- } else if (singleConfig?.port) {
364
- healthUrl = `http://localhost:${singleConfig.port}/health`;
365
- }
366
- dockerEnvToAppend = getDockerEnvSecrets(deployAnswers);
367
- } else if (projectType === 'frontend') {
368
- const deployAnswers = await promptServerDeployment(
369
- projectName,
370
- projectType,
371
- backendConfig
375
+ {
376
+ ...(opts.envName ? { envName: opts.envName } : {}),
377
+ existingEnvNames: Object.keys(environments),
378
+ }
372
379
  );
373
380
  primaryDeployType = deployAnswers.deployType;
374
- deploy.push(deployAnswers.envName);
375
- environments[deployAnswers.envName] = buildServerEnvEntry(
381
+ const name = deployAnswers.envName;
382
+ deploy.push(name);
383
+
384
+ let entry = buildServerEnvEntry(
376
385
  deployAnswers,
377
386
  projectType,
378
387
  projectName,
379
388
  backendConfig,
380
389
  singleConfig
381
390
  );
391
+
392
+ if (projectType === 'both' && entry.config) {
393
+ entry = {
394
+ ...entry,
395
+ config: {
396
+ ...entry.config,
397
+ backendDeploymentType: 'server',
398
+ frontendDeploymentType: 'server',
399
+ deploymentType: 'server',
400
+ },
401
+ };
402
+ }
403
+
404
+ environments[name] = entry;
405
+
382
406
  if (deployAnswers.healthUrl) {
383
407
  healthUrl = deployAnswers.healthUrl;
408
+ } else if (!healthUrl && (singleConfig?.port || backendConfig?.port)) {
409
+ healthUrl = `http://localhost:${singleConfig?.port || backendConfig?.port}/health`;
384
410
  }
385
- dockerEnvToAppend = getDockerEnvSecrets(deployAnswers);
386
- } else {
387
- const backendDeployAnswers = await promptServerDeployment(
388
- projectName,
389
- 'both',
390
- backendConfig
391
- );
392
- primaryDeployType = backendDeployAnswers.deployType;
393
- deploy.push(backendDeployAnswers.envName);
394
-
395
- /** @type {Record<string, unknown>} */
396
- const envEntry = {
397
- backendDeploymentType: 'server',
398
- frontendDeploymentType: 'server',
399
- deploymentType: 'server',
400
- };
401
-
402
- Object.assign(
403
- envEntry,
404
- buildServerEnvEntry(
405
- backendDeployAnswers,
406
- 'both',
407
- projectName,
408
- backendConfig,
409
- singleConfig
410
- )
411
- );
412
411
 
413
- if (backendDeployAnswers.healthUrl) {
414
- healthUrl = backendDeployAnswers.healthUrl;
415
- } else if (backendConfig?.port) {
416
- healthUrl = `http://localhost:${backendConfig.port}/health`;
412
+ const secrets = getDockerEnvSecrets(deployAnswers);
413
+ if (secrets) {
414
+ dockerEnvToAppend = { ...(dockerEnvToAppend || {}), ...secrets };
417
415
  }
416
+ return name;
417
+ }
418
418
 
419
- environments[backendDeployAnswers.envName] = envEntry;
420
- dockerEnvToAppend = getDockerEnvSecrets(backendDeployAnswers);
419
+ if (envMode === 'one') {
420
+ // Transparent single env named "default" (visible later via env list).
421
+ await promptAndStoreEnvironment({ envName: 'default' });
422
+ } else {
423
+ let addMore = true;
424
+ while (addMore) {
425
+ await promptAndStoreEnvironment();
426
+ if (deploy.length >= 1) {
427
+ const again = await inquirer.prompt([
428
+ {
429
+ type: 'confirm',
430
+ name: 'more',
431
+ message: 'Add another environment?',
432
+ default: false,
433
+ },
434
+ ]);
435
+ addMore = again.more;
436
+ }
437
+ }
421
438
  }
422
439
  }
423
440
 
441
+ let defaultEnvironment = deploy[0];
442
+ if (deploy.length >= 2) {
443
+ const picked = await inquirer.prompt([
444
+ {
445
+ type: 'list',
446
+ name: 'defaultEnvironment',
447
+ message: 'Which environment is the default for `deployhub deploy`?',
448
+ choices: deploy,
449
+ default: deploy[0],
450
+ },
451
+ ]);
452
+ defaultEnvironment = picked.defaultEnvironment;
453
+ }
454
+
424
455
  const version = await getProjectVersion(cwd);
425
456
  let hasDocker =
426
457
  (detectedFrontend?.hasDocker || detectedBackend?.hasDocker) ?? false;
@@ -432,7 +463,9 @@ export function registerInitCommand(program) {
432
463
  projectType,
433
464
  artifact: true,
434
465
  storage: answers.storage.length > 0 ? answers.storage : ['local'],
435
- deploy,
466
+ defaultEnvironment: defaultEnvironment || undefined,
467
+ unprefixedSecretEnvironment: defaultEnvironment || undefined,
468
+ legacyHistoryMigrated: true,
436
469
  environments,
437
470
  healthCheck: {
438
471
  url: healthUrl,
@@ -1,6 +1,11 @@
1
1
  import chalk from 'chalk';
2
2
  import { loadConfig, loadEnv } from '../core/config.js';
3
- import { rollbackToVersion } from '../utils/rollback/engine.js';
3
+ import { resolveEnvTargets } from '../core/environments.js';
4
+ import {
5
+ rollbackToVersion,
6
+ formatRollbackAllSummary,
7
+ } from '../utils/rollback/engine.js';
8
+ import { createLogger } from '../logger/index.js';
4
9
  import axios from 'axios';
5
10
 
6
11
  /**
@@ -12,37 +17,92 @@ export function registerRollbackCommand(program) {
12
17
  .description(
13
18
  'Rollback to a previous artifact build (omit arg = previous build; use exact buildId if semver is ambiguous)'
14
19
  )
15
- .action(async (versionOrBuildId) => {
20
+ .option(
21
+ '--env <name>',
22
+ 'Environment to roll back (name, or "all" for every enabled environment independently)'
23
+ )
24
+ .action(async (versionOrBuildId, opts) => {
16
25
  loadEnv();
17
26
  const config = await loadConfig();
27
+ const log = createLogger('rollback');
18
28
 
29
+ let targets;
30
+ let skippedDisabled;
19
31
  try {
20
- const { entry } = await rollbackToVersion(config, versionOrBuildId);
21
- if (!versionOrBuildId) {
22
- console.log(chalk.gray(`Rolled back to previous build: ${entry.buildId}`));
23
- }
32
+ ({ targets, skippedDisabled } = resolveEnvTargets(config, opts.env));
33
+ } catch (err) {
34
+ console.error(chalk.red(err instanceof Error ? err.message : String(err)));
35
+ process.exit(1);
36
+ }
37
+
38
+ for (const name of skippedDisabled) {
39
+ log.info(`Skipping disabled environment "${name}"`);
40
+ }
41
+
42
+ if (targets.length === 0) {
43
+ console.error(chalk.red('No enabled environments to roll back.'));
44
+ process.exit(1);
45
+ }
24
46
 
25
- if (config.healthCheck?.url) {
26
- try {
27
- const response = await axios.get(config.healthCheck.url, {
28
- timeout: (config.healthCheck.timeout || 30) * 1000,
29
- validateStatus: () => true,
30
- });
31
- if (response.status >= 200 && response.status < 400) {
32
- console.log(chalk.green(`Health check passed: HTTP ${response.status}`));
33
- } else {
34
- console.log(chalk.yellow(`Health check returned HTTP ${response.status}`));
47
+ const isAll = opts.env === 'all';
48
+
49
+ try {
50
+ const { results, failures } = await rollbackToVersion(
51
+ config,
52
+ versionOrBuildId,
53
+ process.cwd(),
54
+ {
55
+ envNames: targets,
56
+ // --env all: attempt every enabled env independently; summarize at end.
57
+ continueOnError: isAll,
58
+ }
59
+ );
60
+
61
+ if (isAll) {
62
+ const summary = formatRollbackAllSummary(
63
+ results || [],
64
+ failures || [],
65
+ skippedDisabled
66
+ );
67
+ console.log('');
68
+ console.log(summary);
69
+ if ((failures || []).length > 0) {
70
+ process.exit(1);
71
+ }
72
+ } else {
73
+ for (const r of results || []) {
74
+ if (!versionOrBuildId) {
75
+ console.log(
76
+ chalk.gray(`Rolled back ${r.envName} to previous build: ${r.entry.buildId}`)
77
+ );
35
78
  }
36
- } catch (err) {
37
- console.log(
38
- chalk.yellow(
39
- `Health check failed: ${err instanceof Error ? err.message : String(err)}`
40
- )
41
- );
42
79
  }
43
- }
44
80
 
45
- console.log(chalk.green(`✓ Rolled back to ${entry.buildId}`));
81
+ if (config.healthCheck?.url) {
82
+ try {
83
+ const response = await axios.get(config.healthCheck.url, {
84
+ timeout: (config.healthCheck.timeout || 30) * 1000,
85
+ validateStatus: () => true,
86
+ });
87
+ if (response.status >= 200 && response.status < 400) {
88
+ console.log(chalk.green(`Health check passed: HTTP ${response.status}`));
89
+ } else {
90
+ console.log(chalk.yellow(`Health check returned HTTP ${response.status}`));
91
+ }
92
+ } catch (err) {
93
+ console.log(
94
+ chalk.yellow(
95
+ `Health check failed: ${err instanceof Error ? err.message : String(err)}`
96
+ )
97
+ );
98
+ }
99
+ }
100
+
101
+ const summary = (results || [])
102
+ .map((r) => `${r.envName}→${r.entry.buildId}`)
103
+ .join(', ');
104
+ console.log(chalk.green(`✓ Rolled back: ${summary}`));
105
+ }
46
106
  } catch (err) {
47
107
  console.error(chalk.red(err instanceof Error ? err.message : String(err)));
48
108
  process.exit(1);