grada-run 0.0.2 → 0.32.0

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.
Files changed (231) hide show
  1. package/.github/workflows/deploy-docs.yml +37 -0
  2. package/.github/workflows/iac-validation.yml +303 -0
  3. package/.github/workflows/publish.yml +68 -0
  4. package/.github/workflows/sync-bedrock-models.yml +57 -0
  5. package/.github/workflows/test.yml +43 -0
  6. package/.muserules +31 -0
  7. package/LICENSE +21 -0
  8. package/README.md +193 -3
  9. package/apps/docs/.astro/collections/docs.schema.json +644 -0
  10. package/apps/docs/.astro/content-assets.mjs +4 -0
  11. package/apps/docs/.astro/content-modules.mjs +4 -0
  12. package/apps/docs/.astro/content.d.ts +179 -0
  13. package/apps/docs/.astro/data-store.json +1 -0
  14. package/apps/docs/.astro/dev.json +14 -0
  15. package/apps/docs/.astro/settings.json +5 -0
  16. package/apps/docs/.astro/types.d.ts +2 -0
  17. package/apps/docs/astro.config.mjs +97 -0
  18. package/apps/docs/package.json +17 -0
  19. package/apps/docs/src/content/docs/adrs/0001-s3-native-state-locking.md +37 -0
  20. package/apps/docs/src/content/docs/adrs/0002-eject-mechanism-pure-iac.md +39 -0
  21. package/apps/docs/src/content/docs/adrs/0003-sync-ai-context-strategy.md +48 -0
  22. package/apps/docs/src/content/docs/adrs/0004-iac-driven-diagnostic-context.md +37 -0
  23. package/apps/docs/src/content/docs/adrs/0005-ecs-fargate-alb-runtime-target.md +38 -0
  24. package/apps/docs/src/content/docs/adrs/0006-github-oidc-no-stored-keys.md +37 -0
  25. package/apps/docs/src/content/docs/adrs/0007-framework-detection-with-fallback.md +37 -0
  26. package/apps/docs/src/content/docs/adrs/0008-secrets-names-in-git-values-in-aws.md +37 -0
  27. package/apps/docs/src/content/docs/adrs/0009-regenerate-with-backup-on-rerun.md +37 -0
  28. package/apps/docs/src/content/docs/adrs/0010-advisory-only-security-scans.md +37 -0
  29. package/apps/docs/src/content/docs/cli/add.md +84 -0
  30. package/apps/docs/src/content/docs/cli/apply.md +32 -0
  31. package/apps/docs/src/content/docs/cli/db.md +200 -0
  32. package/apps/docs/src/content/docs/cli/destroy.md +31 -0
  33. package/apps/docs/src/content/docs/cli/diagnose.md +37 -0
  34. package/apps/docs/src/content/docs/cli/doctor.md +28 -0
  35. package/apps/docs/src/content/docs/cli/domain.md +57 -0
  36. package/apps/docs/src/content/docs/cli/drift.md +40 -0
  37. package/apps/docs/src/content/docs/cli/eject.md +29 -0
  38. package/apps/docs/src/content/docs/cli/exec.md +49 -0
  39. package/apps/docs/src/content/docs/cli/gc.md +37 -0
  40. package/apps/docs/src/content/docs/cli/init.md +72 -0
  41. package/apps/docs/src/content/docs/cli/logs.md +39 -0
  42. package/apps/docs/src/content/docs/cli/rollback.md +51 -0
  43. package/apps/docs/src/content/docs/cli/secrets.md +73 -0
  44. package/apps/docs/src/content/docs/cli/sleep.md +53 -0
  45. package/apps/docs/src/content/docs/cli/status.md +34 -0
  46. package/apps/docs/src/content/docs/cli/sync-ai.md +27 -0
  47. package/apps/docs/src/content/docs/guides/architecture.md +87 -0
  48. package/apps/docs/src/content/docs/guides/aws-credentials.md +72 -0
  49. package/apps/docs/src/content/docs/guides/background-workers.md +45 -0
  50. package/apps/docs/src/content/docs/guides/cicd-pipeline.md +64 -0
  51. package/apps/docs/src/content/docs/guides/database-connections.md +64 -0
  52. package/apps/docs/src/content/docs/guides/docker-compose.md +37 -0
  53. package/apps/docs/src/content/docs/guides/dockerfiles.md +46 -0
  54. package/apps/docs/src/content/docs/guides/ephemeral-pr-previews.md +39 -0
  55. package/apps/docs/src/content/docs/guides/examples.md +50 -0
  56. package/apps/docs/src/content/docs/guides/frameworks.md +88 -0
  57. package/apps/docs/src/content/docs/guides/headless.md +75 -0
  58. package/apps/docs/src/content/docs/guides/quickstart.md +52 -0
  59. package/apps/docs/src/content/docs/guides/rerun-init.md +43 -0
  60. package/apps/docs/src/content/docs/guides/secrets-management.md +83 -0
  61. package/apps/docs/src/content/docs/guides/understanding-your-bill.md +63 -0
  62. package/apps/docs/src/content/docs/index.mdx +103 -0
  63. package/apps/docs/src/content/docs/migrations/astro-vercel-to-aws.md +55 -0
  64. package/apps/docs/src/content/docs/migrations/heroku-procfile-to-aws.md +41 -0
  65. package/apps/docs/src/content/docs/migrations/nextjs-vercel-to-aws.md +51 -0
  66. package/apps/docs/src/content/docs/migrations/sveltekit-vercel-to-aws.md +63 -0
  67. package/apps/docs/src/content/docs/roadmap.md +97 -0
  68. package/apps/docs/src/content/docs/testing-strategy.md +32 -0
  69. package/apps/docs/src/content.config.ts +7 -0
  70. package/apps/docs/src/custom.css +14 -0
  71. package/apps/docs/tsconfig.json +6 -0
  72. package/bin/cli.js +140 -0
  73. package/package.json +105 -7
  74. package/scripts/sync-bedrock-models.js +22 -0
  75. package/scripts/test-iac.js +261 -0
  76. package/specs/add-redis-sqs-bedrock.md +128 -0
  77. package/specs/add-storage-dynamodb.md +106 -0
  78. package/specs/bedrock-model-catalog.md +131 -0
  79. package/specs/ci-pipeline.md +17 -0
  80. package/specs/cost-transparency.md +115 -0
  81. package/specs/custom-domains-and-ses.md +153 -0
  82. package/specs/database-suite-expansion.md +151 -0
  83. package/specs/db-connect.md +69 -0
  84. package/specs/db-lifecycle-migrations.md +159 -0
  85. package/specs/dependency-aware-init.md +176 -0
  86. package/specs/deploy-stack-to-grada-run-rebrand.md +45 -0
  87. package/specs/deployment-safety.md +170 -0
  88. package/specs/diagnose.md +16 -0
  89. package/specs/docs-hub.md +16 -0
  90. package/specs/dx-polish.md +46 -0
  91. package/specs/exec.md +25 -0
  92. package/specs/finops-cron-drift.md +161 -0
  93. package/specs/gc.md +26 -0
  94. package/specs/integration-suite.md +16 -0
  95. package/specs/logs.md +32 -0
  96. package/specs/rollback-live-polling.md +40 -0
  97. package/specs/secrets-pull-audit.md +51 -0
  98. package/specs/serverless-lambda-target.md +133 -0
  99. package/specs/status.md +31 -0
  100. package/specs/telemetry-and-spawn-hardening.md +69 -0
  101. package/specs/telemetry-hardening.md +35 -0
  102. package/src/commands/add.js +1111 -0
  103. package/src/commands/apply.js +214 -0
  104. package/src/commands/db/backup.js +229 -0
  105. package/src/commands/db/connect.js +304 -0
  106. package/src/commands/db/enable-vector.js +344 -0
  107. package/src/commands/db/import.js +604 -0
  108. package/src/commands/db/migrate.js +477 -0
  109. package/src/commands/db/restore.js +361 -0
  110. package/src/commands/db.js +87 -0
  111. package/src/commands/destroy.js +217 -0
  112. package/src/commands/diagnose.js +460 -0
  113. package/src/commands/doctor.js +109 -0
  114. package/src/commands/domain.js +685 -0
  115. package/src/commands/drift.js +243 -0
  116. package/src/commands/eject.js +127 -0
  117. package/src/commands/exec.js +222 -0
  118. package/src/commands/gc.js +250 -0
  119. package/src/commands/init.js +649 -0
  120. package/src/commands/logs.js +256 -0
  121. package/src/commands/rollback.js +323 -0
  122. package/src/commands/secrets.js +485 -0
  123. package/src/commands/sleep.js +347 -0
  124. package/src/commands/status.js +309 -0
  125. package/src/commands/sync-ai.js +115 -0
  126. package/src/commands/wake.js +337 -0
  127. package/src/core/parser.js +126 -0
  128. package/src/core/telemetry.js +244 -0
  129. package/src/data/bedrock-models.json +896 -0
  130. package/src/utils/addons.js +126 -0
  131. package/src/utils/ai-rules.js +59 -0
  132. package/src/utils/args.js +91 -0
  133. package/src/utils/aws.js +178 -0
  134. package/src/utils/backup.js +69 -0
  135. package/src/utils/bedrock-catalog.js +511 -0
  136. package/src/utils/capabilities.js +500 -0
  137. package/src/utils/command.js +65 -0
  138. package/src/utils/db-tunnel.js +164 -0
  139. package/src/utils/detector.js +298 -0
  140. package/src/utils/dockerCompose.js +65 -0
  141. package/src/utils/domains.js +73 -0
  142. package/src/utils/ecs-runner.js +289 -0
  143. package/src/utils/ecs.js +92 -0
  144. package/src/utils/frameworks.js +55 -0
  145. package/src/utils/generator.js +527 -0
  146. package/src/utils/hcl.js +426 -0
  147. package/src/utils/lambda-ecr.js +185 -0
  148. package/src/utils/prompts.js +278 -0
  149. package/src/utils/rds.js +131 -0
  150. package/src/utils/resolvers.js +174 -0
  151. package/src/utils/sleep-state.js +140 -0
  152. package/src/utils/sleep-targets.js +139 -0
  153. package/src/utils/system.js +42 -0
  154. package/src/utils/terraform.js +70 -0
  155. package/src/utils/visualizer.js +381 -0
  156. package/src/utils/warnings.js +49 -0
  157. package/templates/README.md +150 -0
  158. package/templates/docker/django.Dockerfile +40 -0
  159. package/templates/docker/go.Dockerfile +23 -0
  160. package/templates/docker/nestjs.Dockerfile +33 -0
  161. package/templates/docker/nextjs.Dockerfile +55 -0
  162. package/templates/docker/node.Dockerfile +24 -0
  163. package/templates/docker/nuxt.Dockerfile +47 -0
  164. package/templates/docker/python.Dockerfile +38 -0
  165. package/templates/docker/rails.Dockerfile +59 -0
  166. package/templates/docker/static.Dockerfile +32 -0
  167. package/templates/docker/svelte.Dockerfile +52 -0
  168. package/templates/github/deploy-lambda.yml +120 -0
  169. package/templates/github/deploy.yml +138 -0
  170. package/templates/github/drift.yml +112 -0
  171. package/templates/github/preview-lambda.yml +86 -0
  172. package/templates/github/preview.yml +69 -0
  173. package/templates/github/teardown.yml +43 -0
  174. package/templates/terraform/addons/bedrock.tf +34 -0
  175. package/templates/terraform/addons/cron-lambda.tf +78 -0
  176. package/templates/terraform/addons/cron.tf +101 -0
  177. package/templates/terraform/addons/dynamodb.tf +73 -0
  178. package/templates/terraform/addons/redis.tf +64 -0
  179. package/templates/terraform/addons/s3.tf +143 -0
  180. package/templates/terraform/addons/ses.tf +73 -0
  181. package/templates/terraform/addons/sqs.tf +67 -0
  182. package/templates/terraform/backend.tf +22 -0
  183. package/templates/terraform/cloudfront-lambda.tf +80 -0
  184. package/templates/terraform/cloudfront.tf +80 -0
  185. package/templates/terraform/database-aurora-postgresql.tf +92 -0
  186. package/templates/terraform/database-mysql.tf +72 -0
  187. package/templates/terraform/database.tf +71 -0
  188. package/templates/terraform/main-lambda.tf +229 -0
  189. package/templates/terraform/main.tf +296 -0
  190. package/templates/terraform/network.tf +95 -0
  191. package/templates/terraform/oidc.tf +64 -0
  192. package/templates/terraform/secrets.tf +31 -0
  193. package/templates/terraform/worker.tf +69 -0
  194. package/tests/__snapshots__/generator.test.js.snap +9633 -0
  195. package/tests/add.test.js +2037 -0
  196. package/tests/ai.test.js +94 -0
  197. package/tests/apply.test.js +488 -0
  198. package/tests/args.test.js +86 -0
  199. package/tests/aws.test.js +244 -0
  200. package/tests/capabilities.test.js +307 -0
  201. package/tests/cli.test.js +29 -0
  202. package/tests/command.test.js +100 -0
  203. package/tests/commands-import.test.js +74 -0
  204. package/tests/db.test.js +2704 -0
  205. package/tests/destroy.test.js +391 -0
  206. package/tests/detector.test.js +79 -0
  207. package/tests/diagnose.test.js +779 -0
  208. package/tests/doctor.test.js +202 -0
  209. package/tests/domain.test.js +899 -0
  210. package/tests/drift.test.js +243 -0
  211. package/tests/ecs.test.js +130 -0
  212. package/tests/eject.test.js +65 -0
  213. package/tests/exec.test.js +380 -0
  214. package/tests/gc.test.js +496 -0
  215. package/tests/generator.test.js +794 -0
  216. package/tests/headless.test.js +562 -0
  217. package/tests/lambda-ecr.test.js +185 -0
  218. package/tests/logs.test.js +447 -0
  219. package/tests/parser.test.js +160 -0
  220. package/tests/rds.test.js +244 -0
  221. package/tests/resolvers.test.js +282 -0
  222. package/tests/rollback.test.js +692 -0
  223. package/tests/secrets.test.js +752 -0
  224. package/tests/sleep-wake.test.js +1016 -0
  225. package/tests/status.test.js +370 -0
  226. package/tests/system.test.js +70 -0
  227. package/tests/telemetry.test.js +520 -0
  228. package/tests/terraform.test.js +84 -0
  229. package/tests/visualizer.test.js +496 -0
  230. package/vitest.config.js +9 -0
  231. package/index.js +0 -2
@@ -0,0 +1,692 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import {
6
+ DescribeServicesCommand,
7
+ ListTaskDefinitionsCommand,
8
+ DescribeTaskDefinitionCommand,
9
+ UpdateServiceCommand,
10
+ } from '@aws-sdk/client-ecs';
11
+ import { runRollback, parseRollbackArgs } from '../src/commands/rollback.js';
12
+ import { resolveWorkspaceSuffix } from '../src/utils/resolvers.js';
13
+ import { trackEvent, flushTelemetry } from '../src/core/telemetry.js';
14
+ import { select } from '@clack/prompts';
15
+ import { handleAwsAuthError } from '../src/utils/aws.js';
16
+
17
+ const createdSpinners = vi.hoisted(() => []);
18
+
19
+ vi.mock('@clack/prompts', () => ({
20
+ intro: vi.fn(),
21
+ outro: vi.fn(),
22
+ spinner: () => {
23
+ const instance = { start: vi.fn(), stop: vi.fn(), message: vi.fn() };
24
+ createdSpinners.push(instance);
25
+ return instance;
26
+ },
27
+ select: vi.fn(),
28
+ isCancel: (value) => typeof value === 'symbol',
29
+ }));
30
+
31
+ vi.mock('../src/core/telemetry.js', () => {
32
+ const trackEvent = vi.fn();
33
+ const flushTelemetry = vi.fn().mockResolvedValue();
34
+ // Mirrors the real trackSuccess delegation so success-path assertions
35
+ // keep observing trackEvent (the real helper is unit-tested separately).
36
+ const trackSuccess = vi.fn(async (event, properties) => {
37
+ trackEvent(event, { ...properties, success: true });
38
+ await flushTelemetry();
39
+ });
40
+ const trackFailure = vi.fn(async (event, properties) => {
41
+ trackEvent(event, { ...properties, success: false });
42
+ await flushTelemetry();
43
+ });
44
+ return { trackEvent, flushTelemetry, trackSuccess, trackFailure };
45
+ });
46
+
47
+ vi.mock('../src/utils/aws.js', () => ({
48
+ hasAwsCli: vi.fn().mockReturnValue(true),
49
+ handleAwsAuthError: vi.fn(),
50
+ isAuthError: (error) => !!error && (error.name === 'UnrecognizedClientException' || error.name === 'ExpiredTokenException'),
51
+ resolveClient: (injected, ClientClass, clientOptions = {}) =>
52
+ (injected && typeof injected.send === 'function' ? injected : new ClientClass(clientOptions)),
53
+ AWS_CLI_INSTALL_URL: 'https://example.invalid/aws-cli',
54
+ }));
55
+
56
+ const arn = (rev) => `arn:aws:ecs:us-east-2:123456789012:task-definition/myapp-task:${rev}`;
57
+
58
+ function activeService(rev, deployments = []) {
59
+ return {
60
+ serviceName: 'myapp-service',
61
+ status: 'ACTIVE',
62
+ taskDefinition: arn(rev),
63
+ desiredCount: 1,
64
+ runningCount: 1,
65
+ deployments,
66
+ };
67
+ }
68
+
69
+ function completedPrimary(rev) {
70
+ return [{
71
+ status: 'PRIMARY',
72
+ taskDefinition: arn(rev),
73
+ rolloutState: 'COMPLETED',
74
+ runningCount: 1,
75
+ desiredCount: 1,
76
+ }];
77
+ }
78
+
79
+ function mockClient(handler) {
80
+ return { send: vi.fn((command) => handler(command)) };
81
+ }
82
+
83
+ function baseOptions(overrides = {}) {
84
+ return {
85
+ projectName: 'myapp',
86
+ cluster: 'myapp-cluster',
87
+ service: 'myapp-service',
88
+ region: 'us-east-2',
89
+ pollIntervalMs: 1,
90
+ timeoutMs: 50,
91
+ isHeadless: true,
92
+ ...overrides,
93
+ };
94
+ }
95
+
96
+ describe('rollback: CLI args', () => {
97
+ it('parses positional revision and all flags', () => {
98
+ expect(parseRollbackArgs(['rollback', '12', '--cluster', 'c', '--service', 's', '--region', 'eu-west-1', '--workspace', 'pr-7', '--skip-wait'])).toEqual({
99
+ revision: '12',
100
+ cluster: 'c',
101
+ service: 's',
102
+ region: 'eu-west-1',
103
+ workspace: 'pr-7',
104
+ skipWait: true,
105
+ });
106
+ });
107
+
108
+ it('supports = syntax, defaults skipWait to false, and accepts family:rev and ARN revisions', () => {
109
+ expect(parseRollbackArgs(['rollback', '--cluster=c', '--service=s', '--region=us-west-2'])).toEqual({
110
+ cluster: 'c',
111
+ service: 's',
112
+ region: 'us-west-2',
113
+ skipWait: false,
114
+ });
115
+ expect(parseRollbackArgs(['rollback', 'myapp-task:12']).revision).toBe('myapp-task:12');
116
+ expect(parseRollbackArgs(['rollback', arn(12)]).revision).toBe(arn(12));
117
+ expect(parseRollbackArgs(['rollback'])).toEqual({ skipWait: false });
118
+ expect(parseRollbackArgs([])).toEqual({ skipWait: false });
119
+ expect(parseRollbackArgs(['rollback', '--skip-wait']).skipWait).toBe(true);
120
+ expect(parseRollbackArgs(['rollback', '--skip-wait=true']).skipWait).toBe(true);
121
+ });
122
+
123
+ it('is wired into bin/cli.js', () => {
124
+ const cliSource = fs.readFileSync(path.join(process.cwd(), 'bin', 'cli.js'), 'utf8');
125
+ expect(cliSource).toContain('rollback');
126
+ expect(cliSource).toContain('runRollback');
127
+ expect(cliSource).toContain('parseRollbackArgs');
128
+ });
129
+ });
130
+
131
+ describe('rollback: workspace resolution', () => {
132
+ it('returns no suffix without a workspace', () => {
133
+ expect(resolveWorkspaceSuffix({}, '/tmp')).toBe('');
134
+ expect(resolveWorkspaceSuffix({ workspace: 'default' }, '/tmp')).toBe('');
135
+ });
136
+
137
+ it('appends an explicit --workspace flag', () => {
138
+ expect(resolveWorkspaceSuffix({ workspace: 'pr-123' }, '/tmp')).toBe('-pr-123');
139
+ });
140
+ });
141
+
142
+ describe('Command: rollback (mocked ECS)', () => {
143
+ let exitSpy;
144
+ let consoleSpy;
145
+ const savedCi = process.env.CI;
146
+
147
+ const spinnerMessages = () => createdSpinners.flatMap(
148
+ (instance) => instance.message.mock.calls.map(([msg]) => msg)
149
+ );
150
+
151
+ beforeEach(() => {
152
+ exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
153
+ consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
154
+ createdSpinners.length = 0;
155
+ delete process.env.CI;
156
+ });
157
+
158
+ afterEach(() => {
159
+ exitSpy.mockRestore();
160
+ consoleSpy.mockRestore();
161
+ if (savedCi === undefined) delete process.env.CI;
162
+ else process.env.CI = savedCi;
163
+ vi.clearAllMocks();
164
+ });
165
+
166
+ it('explicit numeric revision describes family:12, updates service, waits for COMPLETED', async () => {
167
+ let describeCount = 0;
168
+ const ecsClient = mockClient((command) => {
169
+ if (command instanceof DescribeServicesCommand) {
170
+ describeCount += 1;
171
+ return describeCount === 1
172
+ ? { services: [activeService(14)] }
173
+ : { services: [activeService(12, completedPrimary(12))] };
174
+ }
175
+ if (command instanceof DescribeTaskDefinitionCommand) {
176
+ return { taskDefinition: { taskDefinitionArn: arn(12), revision: 12, status: 'ACTIVE' } };
177
+ }
178
+ if (command instanceof UpdateServiceCommand) return {};
179
+ throw new Error(`unexpected command ${command.constructor.name}`);
180
+ });
181
+
182
+ const result = await runRollback({ ...baseOptions(), revision: '12', ecsClient });
183
+
184
+ const describeCall = ecsClient.send.mock.calls.find(
185
+ ([cmd]) => cmd instanceof DescribeTaskDefinitionCommand
186
+ );
187
+ expect(describeCall[0].input).toEqual({ taskDefinition: 'myapp-task:12' });
188
+
189
+ const updateCall = ecsClient.send.mock.calls.find(
190
+ ([cmd]) => cmd instanceof UpdateServiceCommand
191
+ );
192
+ expect(updateCall[0].input).toEqual({
193
+ cluster: 'myapp-cluster',
194
+ service: 'myapp-service',
195
+ taskDefinition: arn(12),
196
+ forceNewDeployment: true,
197
+ });
198
+
199
+ expect(trackEvent).toHaveBeenCalledWith('rollback_run', {
200
+ projectName: 'myapp',
201
+ success: true,
202
+ targetRevision: '12',
203
+ });
204
+ expect(flushTelemetry).toHaveBeenCalled();
205
+ expect(result).toMatchObject({ ok: true, targetTaskDefArn: arn(12), targetRevision: '12' });
206
+ expect(exitSpy).not.toHaveBeenCalled();
207
+ });
208
+
209
+ it('--skip-wait triggers UpdateService, skips polling, and tracks skipWait', async () => {
210
+ let describeServicesCalls = 0;
211
+ const ecsClient = mockClient((command) => {
212
+ if (command instanceof DescribeServicesCommand) {
213
+ describeServicesCalls += 1;
214
+ return { services: [activeService(14)] };
215
+ }
216
+ if (command instanceof DescribeTaskDefinitionCommand) {
217
+ return { taskDefinition: { taskDefinitionArn: arn(12), revision: 12, status: 'ACTIVE' } };
218
+ }
219
+ if (command instanceof UpdateServiceCommand) return {};
220
+ throw new Error(`unexpected command ${command.constructor.name}`);
221
+ });
222
+
223
+ const result = await runRollback({ ...baseOptions(), revision: '12', skipWait: true, ecsClient });
224
+
225
+ expect(describeServicesCalls).toBe(1);
226
+ expect(trackEvent).toHaveBeenCalledWith('rollback_run', {
227
+ projectName: 'myapp',
228
+ success: true,
229
+ targetRevision: '12',
230
+ skipWait: true,
231
+ });
232
+ expect(result).toMatchObject({ ok: true, targetTaskDefArn: arn(12), targetRevision: '12' });
233
+ expect(exitSpy).not.toHaveBeenCalled();
234
+ });
235
+
236
+ it('headless mode selects newest revision older than current (ignores newer undeployed rev)', async () => {
237
+ const ecsClient = mockClient((command) => {
238
+ if (command instanceof DescribeServicesCommand) {
239
+ const calls = ecsClient.send.mock.calls.filter(([c]) => c instanceof DescribeServicesCommand).length;
240
+ return calls <= 1
241
+ ? { services: [activeService(14)] }
242
+ : { services: [activeService(13, completedPrimary(13))] };
243
+ }
244
+ if (command instanceof ListTaskDefinitionsCommand) {
245
+ return { taskDefinitionArns: [arn(15), arn(14), arn(13), arn(12)] };
246
+ }
247
+ if (command instanceof UpdateServiceCommand) return {};
248
+ throw new Error(`unexpected command ${command.constructor.name}`);
249
+ });
250
+
251
+ const result = await runRollback({ ...baseOptions(), ecsClient });
252
+
253
+ const updateCall = ecsClient.send.mock.calls.find(
254
+ ([cmd]) => cmd instanceof UpdateServiceCommand
255
+ );
256
+ expect(updateCall[0].input.taskDefinition).toBe(arn(13));
257
+ expect(result).toMatchObject({ ok: true, targetRevision: '13' });
258
+ });
259
+
260
+ it('falls back to CI auto-detection when isHeadless is omitted', async () => {
261
+ process.env.CI = 'true';
262
+ const ecsClient = mockClient((command) => {
263
+ if (command instanceof DescribeServicesCommand) {
264
+ const calls = ecsClient.send.mock.calls.filter(([c]) => c instanceof DescribeServicesCommand).length;
265
+ return calls <= 1
266
+ ? { services: [activeService(14)] }
267
+ : { services: [activeService(13, completedPrimary(13))] };
268
+ }
269
+ if (command instanceof ListTaskDefinitionsCommand) {
270
+ return { taskDefinitionArns: [arn(14), arn(13)] };
271
+ }
272
+ if (command instanceof UpdateServiceCommand) return {};
273
+ throw new Error(`unexpected command ${command.constructor.name}`);
274
+ });
275
+
276
+ const { isHeadless, ...withoutHeadless } = baseOptions();
277
+ const result = await runRollback({ ...withoutHeadless, ecsClient });
278
+
279
+ expect(select).not.toHaveBeenCalled();
280
+ expect(result).toMatchObject({ ok: true, targetRevision: '13' });
281
+ });
282
+
283
+ it('interactive mode prompts with shortened image tags and updates with the chosen ARN', async () => {
284
+ vi.mocked(select).mockResolvedValue(arn(12));
285
+ const ecsClient = mockClient((command) => {
286
+ if (command instanceof DescribeServicesCommand) {
287
+ const calls = ecsClient.send.mock.calls.filter(([c]) => c instanceof DescribeServicesCommand).length;
288
+ return calls <= 1
289
+ ? { services: [activeService(14)] }
290
+ : { services: [activeService(12, completedPrimary(12))] };
291
+ }
292
+ if (command instanceof ListTaskDefinitionsCommand) {
293
+ return { taskDefinitionArns: [arn(13), arn(12), arn(11)] };
294
+ }
295
+ if (command instanceof DescribeTaskDefinitionCommand) {
296
+ const rev = Number(command.input.taskDefinition.split(':').pop());
297
+ return {
298
+ taskDefinition: {
299
+ taskDefinitionArn: arn(rev),
300
+ revision: rev,
301
+ status: 'ACTIVE',
302
+ containerDefinitions: [{ image: '123456789012.dkr.ecr.us-east-2.amazonaws.com/myapp:sha-abcdef' }],
303
+ registeredAt: new Date('2026-09-01T00:00:00.000Z'),
304
+ },
305
+ };
306
+ }
307
+ if (command instanceof UpdateServiceCommand) return {};
308
+ throw new Error(`unexpected command ${command.constructor.name}`);
309
+ });
310
+
311
+ const result = await runRollback({ ...baseOptions(), isHeadless: false, ecsClient });
312
+
313
+ expect(select).toHaveBeenCalledTimes(1);
314
+ const promptArg = vi.mocked(select).mock.calls[0][0];
315
+ expect(promptArg.message).toMatch(/roll back/i);
316
+ expect(promptArg.options).toHaveLength(3);
317
+ expect(promptArg.options[0].label).toContain('myapp:sha-abcdef');
318
+ expect(promptArg.options[0].label).not.toContain('dkr.ecr');
319
+
320
+ const updateCall = ecsClient.send.mock.calls.find(
321
+ ([cmd]) => cmd instanceof UpdateServiceCommand
322
+ );
323
+ expect(updateCall[0].input.taskDefinition).toBe(arn(12));
324
+ expect(result).toMatchObject({ ok: true, targetRevision: '12' });
325
+ });
326
+
327
+ it('interactive cancellation exits cleanly without error', async () => {
328
+ vi.mocked(select).mockResolvedValue(Symbol.for('clack:cancel'));
329
+ const ecsClient = mockClient((command) => {
330
+ if (command instanceof DescribeServicesCommand) return { services: [activeService(14)] };
331
+ if (command instanceof ListTaskDefinitionsCommand) {
332
+ return { taskDefinitionArns: [arn(13), arn(12)] };
333
+ }
334
+ if (command instanceof DescribeTaskDefinitionCommand) {
335
+ return {
336
+ taskDefinition: {
337
+ taskDefinitionArn: arn(13),
338
+ revision: 13,
339
+ status: 'ACTIVE',
340
+ containerDefinitions: [{ image: 'myapp:latest' }],
341
+ registeredAt: new Date(),
342
+ },
343
+ };
344
+ }
345
+ throw new Error(`unexpected command ${command.constructor.name}`);
346
+ });
347
+
348
+ const result = await runRollback({ ...baseOptions(), isHeadless: false, ecsClient });
349
+
350
+ expect(result).toEqual(expect.objectContaining({ ok: false, reason: 'cancelled' }));
351
+ expect(exitSpy).not.toHaveBeenCalled();
352
+ expect(ecsClient.send.mock.calls.some(([c]) => c instanceof UpdateServiceCommand)).toBe(false);
353
+ });
354
+
355
+ it('exits 1 with SERVICE_NOT_FOUND when the service is missing', async () => {
356
+ const ecsClient = mockClient(() => ({ services: [] }));
357
+
358
+ await runRollback({ ...baseOptions(), ecsClient });
359
+
360
+ expect(exitSpy).toHaveBeenCalledWith(1);
361
+ expect(trackEvent).toHaveBeenCalledWith('rollback_run', {
362
+ projectName: 'myapp',
363
+ success: false,
364
+ error_code: 'SERVICE_NOT_FOUND',
365
+ });
366
+ });
367
+
368
+ it('exits 1 with NO_PRIOR_REVISIONS when only the current revision exists', async () => {
369
+ const ecsClient = mockClient((command) => {
370
+ if (command instanceof DescribeServicesCommand) return { services: [activeService(1)] };
371
+ if (command instanceof ListTaskDefinitionsCommand) return { taskDefinitionArns: [arn(1)] };
372
+ throw new Error(`unexpected command ${command.constructor.name}`);
373
+ });
374
+
375
+ await runRollback({ ...baseOptions(), ecsClient });
376
+
377
+ expect(exitSpy).toHaveBeenCalledWith(1);
378
+ expect(trackEvent).toHaveBeenCalledWith('rollback_run', {
379
+ projectName: 'myapp',
380
+ success: false,
381
+ error_code: 'NO_PRIOR_REVISIONS',
382
+ });
383
+ });
384
+
385
+ it('exits 1 with REVISION_NOT_FOUND for a non-existent explicit revision', async () => {
386
+ const ecsClient = mockClient((command) => {
387
+ if (command instanceof DescribeServicesCommand) return { services: [activeService(14)] };
388
+ if (command instanceof DescribeTaskDefinitionCommand) {
389
+ const error = new Error('Task definition not found');
390
+ error.name = 'ClientException';
391
+ throw error;
392
+ }
393
+ throw new Error(`unexpected command ${command.constructor.name}`);
394
+ });
395
+
396
+ await runRollback({ ...baseOptions(), revision: '99', ecsClient });
397
+
398
+ expect(exitSpy).toHaveBeenCalledWith(1);
399
+ expect(trackEvent).toHaveBeenCalledWith('rollback_run', {
400
+ projectName: 'myapp',
401
+ success: false,
402
+ error_code: 'REVISION_NOT_FOUND',
403
+ });
404
+ });
405
+
406
+ it('exits 1 with ROLLOUT_FAILED when the deployment fails', async () => {
407
+ const ecsClient = mockClient((command) => {
408
+ if (command instanceof DescribeServicesCommand) {
409
+ const calls = ecsClient.send.mock.calls.filter(([c]) => c instanceof DescribeServicesCommand).length;
410
+ if (calls <= 1) return { services: [activeService(14)] };
411
+ return {
412
+ services: [activeService(13, [{
413
+ status: 'PRIMARY',
414
+ taskDefinition: arn(13),
415
+ rolloutState: 'FAILED',
416
+ runningCount: 0,
417
+ desiredCount: 1,
418
+ }])],
419
+ };
420
+ }
421
+ if (command instanceof ListTaskDefinitionsCommand) {
422
+ return { taskDefinitionArns: [arn(14), arn(13)] };
423
+ }
424
+ if (command instanceof UpdateServiceCommand) return {};
425
+ throw new Error(`unexpected command ${command.constructor.name}`);
426
+ });
427
+
428
+ await runRollback({ ...baseOptions(), ecsClient });
429
+
430
+ expect(exitSpy).toHaveBeenCalledWith(1);
431
+ expect(trackEvent).toHaveBeenCalledWith('rollback_run', expect.objectContaining({
432
+ success: false,
433
+ error_code: 'ROLLOUT_FAILED',
434
+ targetRevision: '13',
435
+ }));
436
+ });
437
+
438
+ it('exits 1 with ROLLOUT_TIMEOUT when stabilization never completes', async () => {
439
+ const ecsClient = mockClient((command) => {
440
+ if (command instanceof DescribeServicesCommand) {
441
+ const calls = ecsClient.send.mock.calls.filter(([c]) => c instanceof DescribeServicesCommand).length;
442
+ if (calls <= 1) return { services: [activeService(14)] };
443
+ return {
444
+ services: [activeService(13, [{
445
+ status: 'PRIMARY',
446
+ taskDefinition: arn(13),
447
+ rolloutState: 'IN_PROGRESS',
448
+ runningCount: 0,
449
+ desiredCount: 1,
450
+ }])],
451
+ };
452
+ }
453
+ if (command instanceof ListTaskDefinitionsCommand) {
454
+ return { taskDefinitionArns: [arn(14), arn(13)] };
455
+ }
456
+ if (command instanceof UpdateServiceCommand) return {};
457
+ throw new Error(`unexpected command ${command.constructor.name}`);
458
+ });
459
+
460
+ await runRollback({ ...baseOptions(), timeoutMs: 10, ecsClient });
461
+
462
+ expect(exitSpy).toHaveBeenCalledWith(1);
463
+ expect(trackEvent).toHaveBeenCalledWith('rollback_run', expect.objectContaining({
464
+ success: false,
465
+ error_code: 'ROLLOUT_TIMEOUT',
466
+ targetRevision: '13',
467
+ }));
468
+ });
469
+
470
+ it('tracks AUTH_EXPIRED before delegating to handleAwsAuthError', async () => {
471
+ const authError = new Error('security token expired');
472
+ authError.name = 'ExpiredTokenException';
473
+ const ecsClient = mockClient(() => { throw authError; });
474
+
475
+ await runRollback({ ...baseOptions(), ecsClient });
476
+
477
+ expect(trackEvent).toHaveBeenCalledWith('rollback_run', {
478
+ projectName: 'myapp',
479
+ success: false,
480
+ error_code: 'AUTH_EXPIRED',
481
+ });
482
+ expect(handleAwsAuthError).toHaveBeenCalledTimes(1);
483
+ expect(trackEvent.mock.invocationCallOrder[0])
484
+ .toBeLessThan(handleAwsAuthError.mock.invocationCallOrder[0]);
485
+ });
486
+
487
+ it('shows registering state when PRIMARY has not appeared yet, then resolves', async () => {
488
+ const ecsClient = mockClient((command) => {
489
+ if (command instanceof DescribeServicesCommand) {
490
+ const calls = ecsClient.send.mock.calls.filter(([c]) => c instanceof DescribeServicesCommand).length;
491
+ if (calls <= 1) return { services: [activeService(14)] };
492
+ if (calls === 2) return { services: [activeService(14, [])] };
493
+ return { services: [activeService(13, completedPrimary(13))] };
494
+ }
495
+ if (command instanceof ListTaskDefinitionsCommand) {
496
+ return { taskDefinitionArns: [arn(14), arn(13)] };
497
+ }
498
+ if (command instanceof UpdateServiceCommand) return {};
499
+ throw new Error(`unexpected command ${command.constructor.name}`);
500
+ });
501
+
502
+ const result = await runRollback({ ...baseOptions(), ecsClient });
503
+
504
+ expect(result).toMatchObject({ ok: true, targetRevision: '13' });
505
+ expect(spinnerMessages()[0]).toBe(
506
+ 'Rolling back myapp-service to revision 13... [0s] (registering deployment...)'
507
+ );
508
+ });
509
+
510
+ it('shows standard provisioning progress while tasks boot', async () => {
511
+ const ecsClient = mockClient((command) => {
512
+ if (command instanceof DescribeServicesCommand) {
513
+ const calls = ecsClient.send.mock.calls.filter(([c]) => c instanceof DescribeServicesCommand).length;
514
+ if (calls <= 1) return { services: [activeService(14)] };
515
+ if (calls === 2) {
516
+ return {
517
+ services: [activeService(14, [{
518
+ status: 'PRIMARY',
519
+ taskDefinition: arn(13),
520
+ rolloutState: 'IN_PROGRESS',
521
+ runningCount: 0,
522
+ desiredCount: 1,
523
+ pendingCount: 1,
524
+ }])],
525
+ };
526
+ }
527
+ return { services: [activeService(13, completedPrimary(13))] };
528
+ }
529
+ if (command instanceof ListTaskDefinitionsCommand) {
530
+ return { taskDefinitionArns: [arn(14), arn(13)] };
531
+ }
532
+ if (command instanceof UpdateServiceCommand) return {};
533
+ throw new Error(`unexpected command ${command.constructor.name}`);
534
+ });
535
+
536
+ const result = await runRollback({ ...baseOptions(), ecsClient });
537
+
538
+ expect(result).toMatchObject({ ok: true, targetRevision: '13' });
539
+ expect(spinnerMessages()).toContain(
540
+ 'Rolling back myapp-service to revision 13... [0s] (0/1 running, 1 pending)'
541
+ );
542
+ });
543
+
544
+ it('shows draining progress when new tasks run while old ones drain', async () => {
545
+ const ecsClient = mockClient((command) => {
546
+ if (command instanceof DescribeServicesCommand) {
547
+ const calls = ecsClient.send.mock.calls.filter(([c]) => c instanceof DescribeServicesCommand).length;
548
+ if (calls <= 1) return { services: [activeService(14)] };
549
+ if (calls === 2) {
550
+ return {
551
+ services: [activeService(14, [
552
+ {
553
+ status: 'PRIMARY',
554
+ taskDefinition: arn(13),
555
+ rolloutState: 'IN_PROGRESS',
556
+ runningCount: 1,
557
+ desiredCount: 1,
558
+ pendingCount: 0,
559
+ },
560
+ {
561
+ status: 'ACTIVE',
562
+ taskDefinition: arn(14),
563
+ rolloutState: 'COMPLETED',
564
+ runningCount: 1,
565
+ desiredCount: 1,
566
+ pendingCount: 0,
567
+ },
568
+ ])],
569
+ };
570
+ }
571
+ return { services: [activeService(13, completedPrimary(13))] };
572
+ }
573
+ if (command instanceof ListTaskDefinitionsCommand) {
574
+ return { taskDefinitionArns: [arn(14), arn(13)] };
575
+ }
576
+ if (command instanceof UpdateServiceCommand) return {};
577
+ throw new Error(`unexpected command ${command.constructor.name}`);
578
+ });
579
+
580
+ const result = await runRollback({ ...baseOptions(), ecsClient });
581
+
582
+ expect(result).toMatchObject({ ok: true, targetRevision: '13' });
583
+ expect(spinnerMessages()).toContain(
584
+ 'Rolling back myapp-service to revision 13... [0s] (1/1 running — draining previous tasks)'
585
+ );
586
+ });
587
+
588
+ it('warns about crashing containers when tasks fail', async () => {
589
+ const ecsClient = mockClient((command) => {
590
+ if (command instanceof DescribeServicesCommand) {
591
+ const calls = ecsClient.send.mock.calls.filter(([c]) => c instanceof DescribeServicesCommand).length;
592
+ if (calls <= 1) return { services: [activeService(14)] };
593
+ if (calls === 2) {
594
+ return {
595
+ services: [activeService(14, [{
596
+ status: 'PRIMARY',
597
+ taskDefinition: arn(13),
598
+ rolloutState: 'IN_PROGRESS',
599
+ runningCount: 0,
600
+ desiredCount: 1,
601
+ pendingCount: 0,
602
+ failedTasks: 2,
603
+ }])],
604
+ };
605
+ }
606
+ return { services: [activeService(13, completedPrimary(13))] };
607
+ }
608
+ if (command instanceof ListTaskDefinitionsCommand) {
609
+ return { taskDefinitionArns: [arn(14), arn(13)] };
610
+ }
611
+ if (command instanceof UpdateServiceCommand) return {};
612
+ throw new Error(`unexpected command ${command.constructor.name}`);
613
+ });
614
+
615
+ const result = await runRollback({ ...baseOptions(), ecsClient });
616
+
617
+ expect(result).toMatchObject({ ok: true, targetRevision: '13' });
618
+ expect(spinnerMessages()).toContain(
619
+ 'Rolling back myapp-service to revision 13... [0s] (0/1 running, 0 pending, 2 failed ⚠️ — container crashing)'
620
+ );
621
+ });
622
+ });
623
+
624
+ describe('rollback: fuzzer hardening', () => {
625
+ let exitSpy;
626
+ let consoleSpy;
627
+
628
+ beforeEach(() => {
629
+ vi.clearAllMocks();
630
+ exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
631
+ consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
632
+ });
633
+
634
+ afterEach(() => {
635
+ exitSpy.mockRestore();
636
+ consoleSpy.mockRestore();
637
+ });
638
+
639
+ it('routes unresolvable projects through PROJECT_NOT_INITIALIZED', async () => {
640
+ const cwdSpy = vi.spyOn(process, 'cwd').mockImplementation(() => { throw new Error('deleted'); });
641
+ try {
642
+ const result = await runRollback(null);
643
+ expect(result).toEqual({ ok: false, reason: 'project-not-initialized' });
644
+ expect(exitSpy).toHaveBeenCalledWith(1);
645
+ expect(trackEvent).toHaveBeenCalledWith('rollback_run', expect.objectContaining({
646
+ success: false,
647
+ error_code: 'PROJECT_NOT_INITIALIZED',
648
+ }));
649
+ } finally {
650
+ cwdSpy.mockRestore();
651
+ }
652
+ });
653
+
654
+ it.each([null, 42, true, { port: 'string' }])('parseRollbackArgs(%s) returns defaults', (bad) => {
655
+ expect(parseRollbackArgs(bad)).toEqual({ skipWait: false });
656
+ });
657
+ });
658
+
659
+ describe('rollback on --target lambda projects', () => {
660
+ let exitSpy;
661
+ let logSpy;
662
+
663
+ beforeEach(() => {
664
+ vi.clearAllMocks();
665
+ exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
666
+ logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
667
+ });
668
+
669
+ afterEach(() => {
670
+ exitSpy.mockRestore();
671
+ logSpy.mockRestore();
672
+ });
673
+
674
+ it('fails gracefully with the update-function-code redeploy path', async () => {
675
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rollback-lambda-test-'));
676
+ try {
677
+ fs.mkdirSync(path.join(dir, 'terraform'), { recursive: true });
678
+ fs.writeFileSync(
679
+ path.join(dir, 'terraform', 'main.tf'),
680
+ 'locals {\n app_name = "myapp${local.env_suffix}"\n}\nresource "aws_lambda_function" "app" {}\n'
681
+ );
682
+ const result = await runRollback({ cwd: dir });
683
+ expect(result).toMatchObject({ ok: false, reason: 'lambda-target-unsupported' });
684
+ expect(exitSpy).toHaveBeenCalledWith(1);
685
+ const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n');
686
+ expect(output).toContain('aws lambda update-function-code');
687
+ expect(output).toContain('myapp-fn');
688
+ } finally {
689
+ fs.rmSync(dir, { recursive: true, force: true });
690
+ }
691
+ });
692
+ });