@grada-run/grada 0.0.1 → 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,391 @@
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 { EventEmitter } from 'events';
6
+
7
+ // --- Deep mock for child_process.spawn (no real terraform binary) ---
8
+ const { mockSpawn, mockExecSync } = vi.hoisted(() => ({
9
+ mockSpawn: vi.fn(),
10
+ mockExecSync: vi.fn(),
11
+ }));
12
+
13
+ vi.mock('child_process', async (importOriginal) => {
14
+ const actual = await importOriginal();
15
+ return {
16
+ ...actual,
17
+ spawn: mockSpawn,
18
+ execSync: mockExecSync,
19
+ };
20
+ });
21
+
22
+ // --- Silence interactive UI; confirm is controllable per test ---
23
+ const clack = vi.hoisted(() => ({
24
+ mockIntro: vi.fn(),
25
+ mockOutro: vi.fn(),
26
+ mockConfirm: vi.fn(),
27
+ mockCancel: vi.fn(),
28
+ mockSpinnerStart: vi.fn(),
29
+ mockSpinnerStop: vi.fn(),
30
+ mockSpinnerMessage: vi.fn(),
31
+ }));
32
+
33
+ vi.mock('@clack/prompts', () => ({
34
+ intro: clack.mockIntro,
35
+ outro: clack.mockOutro,
36
+ confirm: clack.mockConfirm,
37
+ cancel: clack.mockCancel,
38
+ spinner: vi.fn(() => ({
39
+ start: clack.mockSpinnerStart,
40
+ stop: clack.mockSpinnerStop,
41
+ message: clack.mockSpinnerMessage,
42
+ })),
43
+ }));
44
+
45
+ // --- Never touch AWS or the network ---
46
+ vi.mock('../src/utils/aws.js', () => ({
47
+ checkAwsCredentials: vi.fn().mockResolvedValue({
48
+ accountId: '123456789012',
49
+ awsAccountId: '123456789012',
50
+ region: 'us-east-2',
51
+ }),
52
+ provisionStateBucket: vi.fn().mockResolvedValue({
53
+ awsAccountId: '123456789012',
54
+ stateBucketName: 'mock-tf-state-bucket',
55
+ }),
56
+ teardownStateBucket: vi.fn().mockResolvedValue(true),
57
+ // Tests that inject no client exercise the no-database path (the
58
+ // pre-destroy hook no-ops); hook tests inject a scripted client.
59
+ resolveClient: (injected) => injected ?? {
60
+ send: async (command) => {
61
+ const name = command.constructor.name;
62
+ if (name === 'DescribeDBInstancesCommand') {
63
+ throw Object.assign(new Error('DBInstanceNotFound'), { name: 'DBInstanceNotFound' });
64
+ }
65
+ if (name === 'DescribeDBClustersCommand') {
66
+ throw Object.assign(new Error('DBClusterNotFoundFault'), { name: 'DBClusterNotFoundFault' });
67
+ }
68
+ throw new Error(`unexpected command ${name}`);
69
+ },
70
+ },
71
+ }));
72
+
73
+ vi.mock('../src/utils/system.js', async (importOriginal) => {
74
+ const actual = await importOriginal();
75
+ return {
76
+ checkDependency: vi.fn().mockResolvedValue(true),
77
+ pollUntil: actual.pollUntil,
78
+ };
79
+ });
80
+
81
+ vi.mock('../src/core/telemetry.js', () => {
82
+ const trackEvent = vi.fn();
83
+ const flushTelemetry = vi.fn().mockResolvedValue();
84
+ // Mirrors the real trackSuccess delegation so success-path assertions
85
+ // keep observing trackEvent (the real helper is unit-tested separately).
86
+ const trackSuccess = vi.fn(async (event, properties) => {
87
+ trackEvent(event, { ...properties, success: true });
88
+ await flushTelemetry();
89
+ });
90
+ return { trackEvent, flushTelemetry, trackSuccess };
91
+ });
92
+
93
+ import { destroyStack } from '../src/commands/destroy.js';
94
+ import { teardownStateBucket } from '../src/utils/aws.js';
95
+ import { checkDependency } from '../src/utils/system.js';
96
+ import { trackEvent } from '../src/core/telemetry.js';
97
+
98
+ function makeChild({ code = 0, stdout = '', stderr = '', error = null } = {}) {
99
+ const child = new EventEmitter();
100
+ child.stdout = new EventEmitter();
101
+ child.stderr = new EventEmitter();
102
+ process.nextTick(() => {
103
+ if (error) {
104
+ child.emit('error', error);
105
+ return;
106
+ }
107
+ if (stdout) child.stdout.emit('data', Buffer.from(stdout));
108
+ if (stderr) child.stderr.emit('data', Buffer.from(stderr));
109
+ child.emit('close', code);
110
+ });
111
+ return child;
112
+ }
113
+
114
+ function exitError(code) {
115
+ return Object.assign(new Error(`process.exit:${code}`), { exitCode: code });
116
+ }
117
+
118
+ describe('Command: destroy (mocked terraform spawn)', () => {
119
+ const originalCwd = process.cwd();
120
+ let tmpDir;
121
+ let exitSpy;
122
+
123
+ beforeEach(() => {
124
+ vi.clearAllMocks();
125
+ checkDependency.mockResolvedValue(true);
126
+ teardownStateBucket.mockResolvedValue(true);
127
+ exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
128
+ throw exitError(code);
129
+ });
130
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'destroy-test-'));
131
+ });
132
+
133
+ afterEach(() => {
134
+ process.chdir(originalCwd);
135
+ exitSpy.mockRestore();
136
+ fs.rmSync(tmpDir, { recursive: true, force: true });
137
+ });
138
+
139
+ function writeBackend(bucket = 'myapp-tfstate-123', region = 'us-east-2') {
140
+ fs.mkdirSync(path.join(tmpDir, 'terraform'), { recursive: true });
141
+ fs.writeFileSync(
142
+ path.join(tmpDir, 'terraform', 'backend.tf'),
143
+ `terraform {\n backend "s3" {\n bucket = "${bucket}"\n region = "${region}"\n }\n}\n`
144
+ );
145
+ process.chdir(tmpDir);
146
+ }
147
+
148
+ it('exits 1 when terraform/backend.tf is missing', async () => {
149
+ process.chdir(tmpDir); // empty tmpdir
150
+ await expect(destroyStack()).rejects.toMatchObject({ exitCode: 1 });
151
+ expect(mockSpawn).not.toHaveBeenCalled();
152
+ expect(exitSpy).toHaveBeenCalledWith(1);
153
+ });
154
+
155
+ it('exits 1 when terraform is not installed', async () => {
156
+ writeBackend();
157
+ checkDependency.mockResolvedValue(false);
158
+ await expect(destroyStack()).rejects.toMatchObject({ exitCode: 1 });
159
+ expect(mockSpawn).not.toHaveBeenCalled();
160
+ });
161
+
162
+ it('cancels cleanly when the user declines the confirm prompt', async () => {
163
+ writeBackend();
164
+ clack.mockConfirm.mockResolvedValue(false);
165
+ await expect(destroyStack()).rejects.toMatchObject({ exitCode: 0 });
166
+ expect(mockSpawn).not.toHaveBeenCalled();
167
+ expect(teardownStateBucket).not.toHaveBeenCalled();
168
+ expect(clack.mockCancel).toHaveBeenCalled();
169
+ });
170
+
171
+ it('destroys compute and retains the state bucket when declined', async () => {
172
+ writeBackend('retain-bucket-123', 'eu-west-1');
173
+ clack.mockConfirm
174
+ .mockResolvedValueOnce(true) // proceed with destroy
175
+ .mockResolvedValueOnce(false); // retain bucket
176
+ mockSpawn.mockImplementation((cmd, args = []) => {
177
+ expect(cmd).toBe('terraform');
178
+ expect(args).toEqual(['destroy', '-auto-approve']);
179
+ return makeChild({ code: 0, stdout: 'destroy complete' });
180
+ });
181
+
182
+ await destroyStack(); // success path calls outro, not process.exit
183
+
184
+ expect(mockSpawn).toHaveBeenCalledTimes(1);
185
+ expect(teardownStateBucket).not.toHaveBeenCalled();
186
+ expect(trackEvent).toHaveBeenCalledWith(
187
+ 'infrastructure_destroyed',
188
+ expect.objectContaining({ success: true, retained_state_bucket: true })
189
+ );
190
+ expect(clack.mockOutro).toHaveBeenCalled();
191
+ expect(exitSpy).not.toHaveBeenCalled();
192
+ });
193
+
194
+ it('destroys compute and deletes the state bucket when confirmed', async () => {
195
+ writeBackend('delete-me-123', 'us-east-2');
196
+ clack.mockConfirm
197
+ .mockResolvedValueOnce(true) // proceed
198
+ .mockResolvedValueOnce(true); // delete bucket
199
+ mockSpawn.mockImplementation(() => makeChild({ code: 0, stdout: 'destroy complete' }));
200
+
201
+ await destroyStack();
202
+
203
+ expect(teardownStateBucket).toHaveBeenCalledWith('us-east-2', 'delete-me-123');
204
+ expect(trackEvent).toHaveBeenCalledWith(
205
+ 'infrastructure_destroyed',
206
+ expect.objectContaining({ success: true, retained_state_bucket: false })
207
+ );
208
+ });
209
+
210
+ it('exits 1 and tracks failure when terraform destroy exits non-zero', async () => {
211
+ writeBackend();
212
+ clack.mockConfirm.mockResolvedValueOnce(true);
213
+ mockSpawn.mockImplementation(() =>
214
+ makeChild({ code: 1, stderr: 'Error: destroy failed dramatically' })
215
+ );
216
+
217
+ await expect(destroyStack()).rejects.toMatchObject({ exitCode: 1 });
218
+ expect(teardownStateBucket).not.toHaveBeenCalled();
219
+ expect(trackEvent).toHaveBeenCalledWith(
220
+ 'infrastructure_destroyed',
221
+ expect.objectContaining({ success: false })
222
+ );
223
+ });
224
+
225
+ it('treats spawn errors (missing binary) as destroy failure', async () => {
226
+ writeBackend();
227
+ clack.mockConfirm.mockResolvedValueOnce(true);
228
+ mockSpawn.mockImplementation(() =>
229
+ makeChild({ error: new Error('spawn terraform ENOENT') })
230
+ );
231
+
232
+ await expect(destroyStack()).rejects.toMatchObject({ exitCode: 1 });
233
+ expect(trackEvent).toHaveBeenCalledWith(
234
+ 'infrastructure_destroyed',
235
+ expect.objectContaining({ success: false })
236
+ );
237
+ });
238
+
239
+ it('survives S3 bucket deletion errors and still reports success', async () => {
240
+ writeBackend('flaky-bucket', 'us-east-2');
241
+ clack.mockConfirm
242
+ .mockResolvedValueOnce(true)
243
+ .mockResolvedValueOnce(true);
244
+ mockSpawn.mockImplementation(() => makeChild({ code: 0, stdout: 'destroy ok' }));
245
+ teardownStateBucket.mockRejectedValueOnce(new Error('AccessDenied'));
246
+
247
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
248
+ try {
249
+ await destroyStack();
250
+ expect(teardownStateBucket).toHaveBeenCalled();
251
+ expect(trackEvent).toHaveBeenCalledWith(
252
+ 'infrastructure_destroyed',
253
+ expect.objectContaining({ success: true })
254
+ );
255
+ } finally {
256
+ consoleSpy.mockRestore();
257
+ }
258
+ });
259
+
260
+ it('is wired into bin/cli.js', () => {
261
+ const content = fs.readFileSync(path.join(originalCwd, 'bin/cli.js'), 'utf8');
262
+ expect(content).toContain('destroyStack');
263
+ expect(content).toContain("'destroy'");
264
+ });
265
+
266
+ describe('pre-destroy database wake hook', () => {
267
+ const noopSleep = async () => {};
268
+
269
+ // Scripted RDS client: `statuses` is the Status sequence returned by
270
+ // Describe calls (first call feeds findDbTarget, the rest feed the
271
+ // wait loops); Start calls are recorded in `seen`.
272
+ function scriptedRdsClient({ kind = 'instance', statuses = [] } = {}) {
273
+ const seen = [];
274
+ let calls = 0;
275
+ const nextStatus = () => statuses[Math.min(calls++, statuses.length - 1)];
276
+ return {
277
+ seen,
278
+ send: vi.fn(async (command) => {
279
+ const name = command.constructor.name;
280
+ seen.push(name);
281
+ if (name === 'DescribeDBInstancesCommand') {
282
+ if (kind !== 'instance') {
283
+ throw Object.assign(new Error('DBInstanceNotFound'), { name: 'DBInstanceNotFound' });
284
+ }
285
+ return { DBInstances: [{ Status: nextStatus(), Engine: 'postgres' }] };
286
+ }
287
+ if (name === 'DescribeDBClustersCommand') {
288
+ if (kind !== 'cluster') {
289
+ throw Object.assign(new Error('DBClusterNotFoundFault'), { name: 'DBClusterNotFoundFault' });
290
+ }
291
+ return { DBClusters: [{ Status: nextStatus(), Engine: 'aurora-postgresql' }] };
292
+ }
293
+ if (name === 'StartDBInstanceCommand' || name === 'StartDBClusterCommand') return {};
294
+ throw new Error(`unexpected command ${name}`);
295
+ }),
296
+ };
297
+ }
298
+
299
+ function confirmDestroy() {
300
+ clack.mockConfirm
301
+ .mockResolvedValueOnce(true) // proceed with destroy
302
+ .mockResolvedValueOnce(false); // retain bucket
303
+ mockSpawn.mockImplementation(() => makeChild({ code: 0, stdout: 'destroy complete' }));
304
+ }
305
+
306
+ it('starts a stopped instance and waits for available before destroying', async () => {
307
+ writeBackend();
308
+ confirmDestroy();
309
+ const rdsClient = scriptedRdsClient({ kind: 'instance', statuses: ['stopped', 'starting', 'available'] });
310
+
311
+ await destroyStack({ rdsClient, sleepFn: noopSleep });
312
+
313
+ expect(rdsClient.seen).toContain('StartDBInstanceCommand');
314
+ expect(rdsClient.seen).not.toContain('StartDBClusterCommand');
315
+ expect(clack.mockSpinnerStart).toHaveBeenCalledWith('Waking database before destruction...');
316
+ expect(mockSpawn).toHaveBeenCalledTimes(1);
317
+ expect(clack.mockOutro).toHaveBeenCalled();
318
+ });
319
+
320
+ it('settles a stopping cluster to stopped before starting it', async () => {
321
+ writeBackend();
322
+ confirmDestroy();
323
+ const rdsClient = scriptedRdsClient({ kind: 'cluster', statuses: ['stopping', 'stopping', 'stopped', 'starting', 'available'] });
324
+
325
+ await destroyStack({ rdsClient, sleepFn: noopSleep });
326
+
327
+ // Start fires only after the settle loop observed `stopped`
328
+ // (describe, describe, describe, start, ...).
329
+ expect(rdsClient.seen.indexOf('StartDBClusterCommand')).toBeGreaterThan(2);
330
+ expect(rdsClient.seen).not.toContain('StartDBInstanceCommand');
331
+ expect(mockSpawn).toHaveBeenCalledTimes(1);
332
+ });
333
+
334
+ it('waits for a starting database without sending Start', async () => {
335
+ writeBackend();
336
+ confirmDestroy();
337
+ const rdsClient = scriptedRdsClient({ kind: 'instance', statuses: ['starting', 'available'] });
338
+
339
+ await destroyStack({ rdsClient, sleepFn: noopSleep });
340
+
341
+ expect(rdsClient.seen).not.toContain('StartDBInstanceCommand');
342
+ expect(rdsClient.seen).not.toContain('StartDBClusterCommand');
343
+ expect(mockSpawn).toHaveBeenCalledTimes(1);
344
+ });
345
+
346
+ it('skips the hook when the database is already available', async () => {
347
+ writeBackend();
348
+ confirmDestroy();
349
+ const rdsClient = scriptedRdsClient({ kind: 'instance', statuses: ['available'] });
350
+
351
+ await destroyStack({ rdsClient, sleepFn: noopSleep });
352
+
353
+ expect(rdsClient.seen).not.toContain('StartDBInstanceCommand');
354
+ expect(clack.mockSpinnerStart).not.toHaveBeenCalledWith('Waking database before destruction...');
355
+ expect(mockSpawn).toHaveBeenCalledTimes(1);
356
+ });
357
+
358
+ it('aborts destroy when the database never becomes available', async () => {
359
+ writeBackend();
360
+ clack.mockConfirm.mockResolvedValueOnce(true);
361
+ mockSpawn.mockImplementation(() => makeChild({ code: 0, stdout: 'destroy complete' }));
362
+ const rdsClient = scriptedRdsClient({ kind: 'instance', statuses: ['stopped'] });
363
+
364
+ await expect(destroyStack({ rdsClient, sleepFn: noopSleep, timeoutMs: 0 })).rejects.toMatchObject({ exitCode: 1 });
365
+ expect(mockSpawn).not.toHaveBeenCalled();
366
+ expect(trackEvent).toHaveBeenCalledWith(
367
+ 'infrastructure_destroyed',
368
+ expect.objectContaining({ success: false, error_code: 'RDS_DESTROY_PREFLIGHT_TIMEOUT' })
369
+ );
370
+ });
371
+
372
+ it('aborts destroy when the database lookup fails', async () => {
373
+ writeBackend();
374
+ clack.mockConfirm.mockResolvedValueOnce(true);
375
+ mockSpawn.mockImplementation(() => makeChild({ code: 0, stdout: 'destroy complete' }));
376
+ const rdsClient = { send: vi.fn(async () => { throw new Error('boom'); }) };
377
+
378
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
379
+ try {
380
+ await expect(destroyStack({ rdsClient, sleepFn: noopSleep })).rejects.toMatchObject({ exitCode: 1 });
381
+ } finally {
382
+ consoleSpy.mockRestore();
383
+ }
384
+ expect(mockSpawn).not.toHaveBeenCalled();
385
+ expect(trackEvent).toHaveBeenCalledWith(
386
+ 'infrastructure_destroyed',
387
+ expect.objectContaining({ success: false, error_code: 'RDS_DESTROY_PREFLIGHT_FAILED' })
388
+ );
389
+ });
390
+ });
391
+ });
@@ -0,0 +1,79 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { detectMigrationCommand } from '../src/utils/detector.js';
6
+
7
+ function makeProject(files = {}) {
8
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'detect-migrate-'));
9
+ for (const [name, content] of Object.entries(files)) {
10
+ const full = path.join(dir, name);
11
+ fs.mkdirSync(path.dirname(full), { recursive: true });
12
+ fs.writeFileSync(full, content);
13
+ }
14
+ return dir;
15
+ }
16
+
17
+ describe('detectMigrationCommand', () => {
18
+ it('prefers the db:migrate npm script', () => {
19
+ const dir = makeProject({
20
+ 'package.json': JSON.stringify({ scripts: { 'db:migrate': 'prisma migrate deploy', migrate: 'other' } }),
21
+ });
22
+ expect(detectMigrationCommand(dir)).toBe('npm run db:migrate');
23
+ });
24
+
25
+ it('falls back to the migrate npm script', () => {
26
+ const dir = makeProject({
27
+ 'package.json': JSON.stringify({ scripts: { migrate: 'knex migrate:latest' } }),
28
+ });
29
+ expect(detectMigrationCommand(dir)).toBe('npm run migrate');
30
+ });
31
+
32
+ it('detects Prisma via schema file or dependency', () => {
33
+ expect(detectMigrationCommand(makeProject({ 'prisma/schema.prisma': 'datasource db {}' })))
34
+ .toBe('npx prisma migrate deploy');
35
+ expect(detectMigrationCommand(makeProject({
36
+ 'package.json': JSON.stringify({ dependencies: { prisma: '^5.0.0' } }),
37
+ }))).toBe('npx prisma migrate deploy');
38
+ expect(detectMigrationCommand(makeProject({
39
+ 'package.json': JSON.stringify({ devDependencies: { prisma: '^5.0.0' } }),
40
+ }))).toBe('npx prisma migrate deploy');
41
+ });
42
+
43
+ it('detects Drizzle via config file extensions', () => {
44
+ for (const file of ['drizzle.config.ts', 'drizzle.config.js', 'drizzle.config.mjs']) {
45
+ expect(detectMigrationCommand(makeProject({ [file]: 'export default {}' })))
46
+ .toBe('npx drizzle-kit migrate');
47
+ }
48
+ });
49
+
50
+ it('detects Alembic, Django, and Rails markers', () => {
51
+ expect(detectMigrationCommand(makeProject({ 'alembic.ini': '[alembic]' })))
52
+ .toBe('alembic upgrade head');
53
+ expect(detectMigrationCommand(makeProject({ 'manage.py': '# django' })))
54
+ .toBe('python manage.py migrate --noinput');
55
+ expect(detectMigrationCommand(makeProject({ 'bin/rails': '#!/usr/bin/env ruby' })))
56
+ .toBe('bundle exec rails db:migrate');
57
+ expect(detectMigrationCommand(makeProject({ Gemfile: 'source "https://rubygems.org"\ngem "rails"\n' })))
58
+ .toBe('bundle exec rails db:migrate');
59
+ });
60
+
61
+ it('ignores a Gemfile without rails', () => {
62
+ const dir = makeProject({ Gemfile: 'source "https://rubygems.org"\ngem "sinatra"\n' });
63
+ expect(detectMigrationCommand(dir)).toBeNull();
64
+ });
65
+
66
+ it('returns null for empty projects and survives malformed files', () => {
67
+ expect(detectMigrationCommand(makeProject({}))).toBeNull();
68
+ expect(detectMigrationCommand(makeProject({ 'package.json': 'not-json{{{' }))).toBeNull();
69
+ expect(detectMigrationCommand('/nonexistent-dir-xyz')).toBeNull();
70
+ });
71
+
72
+ it('keeps priority order across mixed markers', () => {
73
+ const dir = makeProject({
74
+ 'package.json': JSON.stringify({ scripts: { migrate: 'x' }, dependencies: { prisma: '1' } }),
75
+ 'manage.py': '# django',
76
+ });
77
+ expect(detectMigrationCommand(dir)).toBe('npm run migrate');
78
+ });
79
+ });