@grada-run/grada 0.0.1 → 0.33.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 (242) hide show
  1. package/.github/workflows/deploy-docs.yml +37 -0
  2. package/.github/workflows/e2e.yml +73 -0
  3. package/.github/workflows/iac-validation.yml +303 -0
  4. package/.github/workflows/publish.yml +68 -0
  5. package/.github/workflows/sync-bedrock-models.yml +57 -0
  6. package/.github/workflows/test.yml +43 -0
  7. package/.muserules +31 -0
  8. package/LICENSE +21 -0
  9. package/README.md +193 -3
  10. package/apps/docs/.astro/collections/docs.schema.json +644 -0
  11. package/apps/docs/.astro/content-assets.mjs +4 -0
  12. package/apps/docs/.astro/content-modules.mjs +4 -0
  13. package/apps/docs/.astro/content.d.ts +179 -0
  14. package/apps/docs/.astro/data-store.json +1 -0
  15. package/apps/docs/.astro/dev.json +14 -0
  16. package/apps/docs/.astro/settings.json +5 -0
  17. package/apps/docs/.astro/types.d.ts +2 -0
  18. package/apps/docs/astro.config.mjs +97 -0
  19. package/apps/docs/package.json +17 -0
  20. package/apps/docs/src/content/docs/adrs/0001-s3-native-state-locking.md +37 -0
  21. package/apps/docs/src/content/docs/adrs/0002-eject-mechanism-pure-iac.md +39 -0
  22. package/apps/docs/src/content/docs/adrs/0003-sync-ai-context-strategy.md +48 -0
  23. package/apps/docs/src/content/docs/adrs/0004-iac-driven-diagnostic-context.md +37 -0
  24. package/apps/docs/src/content/docs/adrs/0005-ecs-fargate-alb-runtime-target.md +38 -0
  25. package/apps/docs/src/content/docs/adrs/0006-github-oidc-no-stored-keys.md +37 -0
  26. package/apps/docs/src/content/docs/adrs/0007-framework-detection-with-fallback.md +37 -0
  27. package/apps/docs/src/content/docs/adrs/0008-secrets-names-in-git-values-in-aws.md +37 -0
  28. package/apps/docs/src/content/docs/adrs/0009-regenerate-with-backup-on-rerun.md +37 -0
  29. package/apps/docs/src/content/docs/adrs/0010-advisory-only-security-scans.md +37 -0
  30. package/apps/docs/src/content/docs/cli/add.md +85 -0
  31. package/apps/docs/src/content/docs/cli/apply.md +35 -0
  32. package/apps/docs/src/content/docs/cli/db.md +200 -0
  33. package/apps/docs/src/content/docs/cli/destroy.md +35 -0
  34. package/apps/docs/src/content/docs/cli/diagnose.md +37 -0
  35. package/apps/docs/src/content/docs/cli/doctor.md +28 -0
  36. package/apps/docs/src/content/docs/cli/domain.md +57 -0
  37. package/apps/docs/src/content/docs/cli/drift.md +40 -0
  38. package/apps/docs/src/content/docs/cli/eject.md +33 -0
  39. package/apps/docs/src/content/docs/cli/exec.md +49 -0
  40. package/apps/docs/src/content/docs/cli/gc.md +37 -0
  41. package/apps/docs/src/content/docs/cli/init.md +72 -0
  42. package/apps/docs/src/content/docs/cli/logs.md +39 -0
  43. package/apps/docs/src/content/docs/cli/rollback.md +51 -0
  44. package/apps/docs/src/content/docs/cli/secrets.md +73 -0
  45. package/apps/docs/src/content/docs/cli/sleep.md +53 -0
  46. package/apps/docs/src/content/docs/cli/status.md +34 -0
  47. package/apps/docs/src/content/docs/cli/sync-ai.md +27 -0
  48. package/apps/docs/src/content/docs/guides/architecture.md +87 -0
  49. package/apps/docs/src/content/docs/guides/aws-credentials.md +72 -0
  50. package/apps/docs/src/content/docs/guides/background-workers.md +45 -0
  51. package/apps/docs/src/content/docs/guides/cicd-pipeline.md +64 -0
  52. package/apps/docs/src/content/docs/guides/database-connections.md +64 -0
  53. package/apps/docs/src/content/docs/guides/docker-compose.md +37 -0
  54. package/apps/docs/src/content/docs/guides/dockerfiles.md +46 -0
  55. package/apps/docs/src/content/docs/guides/ephemeral-pr-previews.md +39 -0
  56. package/apps/docs/src/content/docs/guides/examples.md +50 -0
  57. package/apps/docs/src/content/docs/guides/frameworks.md +88 -0
  58. package/apps/docs/src/content/docs/guides/headless.md +87 -0
  59. package/apps/docs/src/content/docs/guides/quickstart.md +52 -0
  60. package/apps/docs/src/content/docs/guides/rerun-init.md +43 -0
  61. package/apps/docs/src/content/docs/guides/secrets-management.md +83 -0
  62. package/apps/docs/src/content/docs/guides/understanding-your-bill.md +63 -0
  63. package/apps/docs/src/content/docs/index.mdx +103 -0
  64. package/apps/docs/src/content/docs/migrations/astro-vercel-to-aws.md +55 -0
  65. package/apps/docs/src/content/docs/migrations/heroku-procfile-to-aws.md +41 -0
  66. package/apps/docs/src/content/docs/migrations/nextjs-vercel-to-aws.md +51 -0
  67. package/apps/docs/src/content/docs/migrations/sveltekit-vercel-to-aws.md +63 -0
  68. package/apps/docs/src/content/docs/roadmap.md +99 -0
  69. package/apps/docs/src/content/docs/testing-strategy.md +37 -0
  70. package/apps/docs/src/content.config.ts +7 -0
  71. package/apps/docs/src/custom.css +14 -0
  72. package/apps/docs/tsconfig.json +6 -0
  73. package/bin/cli.js +140 -0
  74. package/package.json +107 -7
  75. package/scripts/sync-bedrock-models.js +22 -0
  76. package/scripts/test-iac.js +261 -0
  77. package/specs/add-redis-sqs-bedrock.md +128 -0
  78. package/specs/add-storage-dynamodb.md +106 -0
  79. package/specs/bedrock-model-catalog.md +131 -0
  80. package/specs/ci-pipeline.md +17 -0
  81. package/specs/cost-transparency.md +115 -0
  82. package/specs/custom-domains-and-ses.md +153 -0
  83. package/specs/database-suite-expansion.md +151 -0
  84. package/specs/db-connect.md +69 -0
  85. package/specs/db-lifecycle-migrations.md +159 -0
  86. package/specs/dependency-aware-init.md +176 -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/e2e-testing.md +50 -0
  92. package/specs/exec.md +25 -0
  93. package/specs/finops-cron-drift.md +161 -0
  94. package/specs/gc.md +26 -0
  95. package/specs/integration-suite.md +16 -0
  96. package/specs/logs.md +32 -0
  97. package/specs/rollback-live-polling.md +40 -0
  98. package/specs/secrets-pull-audit.md +51 -0
  99. package/specs/serverless-lambda-target.md +133 -0
  100. package/specs/status.md +31 -0
  101. package/specs/telemetry-and-spawn-hardening.md +69 -0
  102. package/specs/telemetry-hardening.md +35 -0
  103. package/specs/test-suite-deduplication.md +42 -0
  104. package/src/commands/add.js +1111 -0
  105. package/src/commands/apply.js +214 -0
  106. package/src/commands/db/backup.js +229 -0
  107. package/src/commands/db/connect.js +304 -0
  108. package/src/commands/db/enable-vector.js +344 -0
  109. package/src/commands/db/import.js +604 -0
  110. package/src/commands/db/migrate.js +477 -0
  111. package/src/commands/db/restore.js +361 -0
  112. package/src/commands/db.js +87 -0
  113. package/src/commands/destroy.js +217 -0
  114. package/src/commands/diagnose.js +460 -0
  115. package/src/commands/doctor.js +109 -0
  116. package/src/commands/domain.js +685 -0
  117. package/src/commands/drift.js +243 -0
  118. package/src/commands/eject.js +130 -0
  119. package/src/commands/exec.js +222 -0
  120. package/src/commands/gc.js +250 -0
  121. package/src/commands/init.js +649 -0
  122. package/src/commands/logs.js +256 -0
  123. package/src/commands/rollback.js +323 -0
  124. package/src/commands/secrets.js +485 -0
  125. package/src/commands/sleep.js +347 -0
  126. package/src/commands/status.js +309 -0
  127. package/src/commands/sync-ai.js +115 -0
  128. package/src/commands/wake.js +337 -0
  129. package/src/core/parser.js +128 -0
  130. package/src/core/telemetry.js +244 -0
  131. package/src/data/bedrock-models.json +896 -0
  132. package/src/utils/addons.js +126 -0
  133. package/src/utils/ai-rules.js +59 -0
  134. package/src/utils/args.js +91 -0
  135. package/src/utils/aws.js +178 -0
  136. package/src/utils/backup.js +69 -0
  137. package/src/utils/bedrock-catalog.js +511 -0
  138. package/src/utils/capabilities.js +500 -0
  139. package/src/utils/command.js +78 -0
  140. package/src/utils/db-tunnel.js +164 -0
  141. package/src/utils/detector.js +298 -0
  142. package/src/utils/dockerCompose.js +65 -0
  143. package/src/utils/domains.js +73 -0
  144. package/src/utils/ecs-runner.js +289 -0
  145. package/src/utils/ecs.js +92 -0
  146. package/src/utils/frameworks.js +55 -0
  147. package/src/utils/generator.js +527 -0
  148. package/src/utils/hcl.js +426 -0
  149. package/src/utils/lambda-ecr.js +185 -0
  150. package/src/utils/prompts.js +278 -0
  151. package/src/utils/rds.js +131 -0
  152. package/src/utils/resolvers.js +174 -0
  153. package/src/utils/sleep-state.js +140 -0
  154. package/src/utils/sleep-targets.js +139 -0
  155. package/src/utils/system.js +42 -0
  156. package/src/utils/terraform.js +70 -0
  157. package/src/utils/visualizer.js +381 -0
  158. package/src/utils/warnings.js +49 -0
  159. package/templates/README.md +150 -0
  160. package/templates/docker/django.Dockerfile +40 -0
  161. package/templates/docker/go.Dockerfile +23 -0
  162. package/templates/docker/nestjs.Dockerfile +33 -0
  163. package/templates/docker/nextjs.Dockerfile +55 -0
  164. package/templates/docker/node.Dockerfile +24 -0
  165. package/templates/docker/nuxt.Dockerfile +47 -0
  166. package/templates/docker/python.Dockerfile +38 -0
  167. package/templates/docker/rails.Dockerfile +59 -0
  168. package/templates/docker/static.Dockerfile +32 -0
  169. package/templates/docker/svelte.Dockerfile +52 -0
  170. package/templates/github/deploy-lambda.yml +120 -0
  171. package/templates/github/deploy.yml +138 -0
  172. package/templates/github/drift.yml +112 -0
  173. package/templates/github/preview-lambda.yml +86 -0
  174. package/templates/github/preview.yml +69 -0
  175. package/templates/github/teardown.yml +43 -0
  176. package/templates/terraform/addons/bedrock.tf +34 -0
  177. package/templates/terraform/addons/cron-lambda.tf +78 -0
  178. package/templates/terraform/addons/cron.tf +101 -0
  179. package/templates/terraform/addons/dynamodb.tf +73 -0
  180. package/templates/terraform/addons/redis.tf +64 -0
  181. package/templates/terraform/addons/s3.tf +143 -0
  182. package/templates/terraform/addons/ses.tf +73 -0
  183. package/templates/terraform/addons/sqs.tf +67 -0
  184. package/templates/terraform/backend.tf +22 -0
  185. package/templates/terraform/cloudfront-lambda.tf +80 -0
  186. package/templates/terraform/cloudfront.tf +80 -0
  187. package/templates/terraform/database-aurora-postgresql.tf +92 -0
  188. package/templates/terraform/database-mysql.tf +72 -0
  189. package/templates/terraform/database.tf +71 -0
  190. package/templates/terraform/main-lambda.tf +229 -0
  191. package/templates/terraform/main.tf +296 -0
  192. package/templates/terraform/network.tf +95 -0
  193. package/templates/terraform/oidc.tf +64 -0
  194. package/templates/terraform/secrets.tf +31 -0
  195. package/templates/terraform/worker.tf +69 -0
  196. package/tests/__snapshots__/generator.test.js.snap +9633 -0
  197. package/tests/add.test.js +2018 -0
  198. package/tests/ai.test.js +91 -0
  199. package/tests/apply.test.js +446 -0
  200. package/tests/args.test.js +86 -0
  201. package/tests/aws.test.js +244 -0
  202. package/tests/capabilities.test.js +304 -0
  203. package/tests/cli.test.js +29 -0
  204. package/tests/command.test.js +99 -0
  205. package/tests/commands-import.test.js +74 -0
  206. package/tests/db.test.js +2673 -0
  207. package/tests/destroy.test.js +365 -0
  208. package/tests/detector.test.js +79 -0
  209. package/tests/diagnose.test.js +765 -0
  210. package/tests/doctor.test.js +195 -0
  211. package/tests/domain.test.js +882 -0
  212. package/tests/drift.test.js +228 -0
  213. package/tests/e2e/helpers.js +122 -0
  214. package/tests/e2e/tier0.e2e.test.js +142 -0
  215. package/tests/e2e/tier1.live.e2e.test.js +118 -0
  216. package/tests/ecs.test.js +130 -0
  217. package/tests/eject.test.js +65 -0
  218. package/tests/exec.test.js +366 -0
  219. package/tests/gc.test.js +466 -0
  220. package/tests/generator.test.js +794 -0
  221. package/tests/headless.test.js +491 -0
  222. package/tests/helpers/clack.js +103 -0
  223. package/tests/helpers/console.js +41 -0
  224. package/tests/helpers/telemetry.js +37 -0
  225. package/tests/helpers/tmpdir.js +36 -0
  226. package/tests/lambda-ecr.test.js +185 -0
  227. package/tests/logs.test.js +436 -0
  228. package/tests/parser.test.js +160 -0
  229. package/tests/rds.test.js +244 -0
  230. package/tests/resolvers.test.js +279 -0
  231. package/tests/rollback.test.js +670 -0
  232. package/tests/secrets.test.js +729 -0
  233. package/tests/sleep-wake.test.js +998 -0
  234. package/tests/status.test.js +356 -0
  235. package/tests/system.test.js +70 -0
  236. package/tests/telemetry.test.js +520 -0
  237. package/tests/terraform.test.js +84 -0
  238. package/tests/visualizer.test.js +480 -0
  239. package/vitest.config.js +10 -0
  240. package/vitest.e2e.tier0.config.js +8 -0
  241. package/vitest.e2e.tier1.config.js +8 -0
  242. package/index.js +0 -2
@@ -0,0 +1,365 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ // NOTE: helper imports must stay above src imports: vi.mock factories run
3
+ // during module evaluation and need the factories initialized.
4
+ import { clackPromptsMockFactory, clackMocks as clack } from './helpers/clack.js';
5
+ import { telemetryMockFactory } from './helpers/telemetry.js';
6
+ import fs from 'fs';
7
+ import os from 'os';
8
+ import path from 'path';
9
+ import { EventEmitter } from 'events';
10
+
11
+ // --- Deep mock for child_process.spawn (no real terraform binary) ---
12
+ const { mockSpawn, mockExecSync } = vi.hoisted(() => ({
13
+ mockSpawn: vi.fn(),
14
+ mockExecSync: vi.fn(),
15
+ }));
16
+
17
+ vi.mock('child_process', async (importOriginal) => {
18
+ const actual = await importOriginal();
19
+ return {
20
+ ...actual,
21
+ spawn: mockSpawn,
22
+ execSync: mockExecSync,
23
+ };
24
+ });
25
+
26
+ // --- Silence interactive UI; confirm is controllable per test ---
27
+ vi.mock('@clack/prompts', () => clackPromptsMockFactory());
28
+
29
+ // --- Never touch AWS or the network ---
30
+ vi.mock('../src/utils/aws.js', () => ({
31
+ checkAwsCredentials: vi.fn().mockResolvedValue({
32
+ accountId: '123456789012',
33
+ awsAccountId: '123456789012',
34
+ region: 'us-east-2',
35
+ }),
36
+ provisionStateBucket: vi.fn().mockResolvedValue({
37
+ awsAccountId: '123456789012',
38
+ stateBucketName: 'mock-tf-state-bucket',
39
+ }),
40
+ teardownStateBucket: vi.fn().mockResolvedValue(true),
41
+ // Tests that inject no client exercise the no-database path (the
42
+ // pre-destroy hook no-ops); hook tests inject a scripted client.
43
+ resolveClient: (injected) => injected ?? {
44
+ send: async (command) => {
45
+ const name = command.constructor.name;
46
+ if (name === 'DescribeDBInstancesCommand') {
47
+ throw Object.assign(new Error('DBInstanceNotFound'), { name: 'DBInstanceNotFound' });
48
+ }
49
+ if (name === 'DescribeDBClustersCommand') {
50
+ throw Object.assign(new Error('DBClusterNotFoundFault'), { name: 'DBClusterNotFoundFault' });
51
+ }
52
+ throw new Error(`unexpected command ${name}`);
53
+ },
54
+ },
55
+ }));
56
+
57
+ vi.mock('../src/utils/system.js', async (importOriginal) => {
58
+ const actual = await importOriginal();
59
+ return {
60
+ checkDependency: vi.fn().mockResolvedValue(true),
61
+ pollUntil: actual.pollUntil,
62
+ };
63
+ });
64
+
65
+ vi.mock('../src/core/telemetry.js', (importOriginal) => telemetryMockFactory(importOriginal));
66
+
67
+ import { destroyStack } from '../src/commands/destroy.js';
68
+ import { teardownStateBucket } from '../src/utils/aws.js';
69
+ import { checkDependency } from '../src/utils/system.js';
70
+ import { trackEvent } from '../src/core/telemetry.js';
71
+
72
+ function makeChild({ code = 0, stdout = '', stderr = '', error = null } = {}) {
73
+ const child = new EventEmitter();
74
+ child.stdout = new EventEmitter();
75
+ child.stderr = new EventEmitter();
76
+ process.nextTick(() => {
77
+ if (error) {
78
+ child.emit('error', error);
79
+ return;
80
+ }
81
+ if (stdout) child.stdout.emit('data', Buffer.from(stdout));
82
+ if (stderr) child.stderr.emit('data', Buffer.from(stderr));
83
+ child.emit('close', code);
84
+ });
85
+ return child;
86
+ }
87
+
88
+ function exitError(code) {
89
+ return Object.assign(new Error(`process.exit:${code}`), { exitCode: code });
90
+ }
91
+
92
+ describe('Command: destroy (mocked terraform spawn)', () => {
93
+ const originalCwd = process.cwd();
94
+ let tmpDir;
95
+ let exitSpy;
96
+
97
+ beforeEach(() => {
98
+ vi.clearAllMocks();
99
+ checkDependency.mockResolvedValue(true);
100
+ teardownStateBucket.mockResolvedValue(true);
101
+ exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
102
+ throw exitError(code);
103
+ });
104
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'destroy-test-'));
105
+ });
106
+
107
+ afterEach(() => {
108
+ process.chdir(originalCwd);
109
+ exitSpy.mockRestore();
110
+ fs.rmSync(tmpDir, { recursive: true, force: true });
111
+ });
112
+
113
+ function writeBackend(bucket = 'myapp-tfstate-123', region = 'us-east-2') {
114
+ fs.mkdirSync(path.join(tmpDir, 'terraform'), { recursive: true });
115
+ fs.writeFileSync(
116
+ path.join(tmpDir, 'terraform', 'backend.tf'),
117
+ `terraform {\n backend "s3" {\n bucket = "${bucket}"\n region = "${region}"\n }\n}\n`
118
+ );
119
+ process.chdir(tmpDir);
120
+ }
121
+
122
+ it('exits 1 when terraform/backend.tf is missing', async () => {
123
+ process.chdir(tmpDir); // empty tmpdir
124
+ await expect(destroyStack()).rejects.toMatchObject({ exitCode: 1 });
125
+ expect(mockSpawn).not.toHaveBeenCalled();
126
+ expect(exitSpy).toHaveBeenCalledWith(1);
127
+ });
128
+
129
+ it('exits 1 when terraform is not installed', async () => {
130
+ writeBackend();
131
+ checkDependency.mockResolvedValue(false);
132
+ await expect(destroyStack()).rejects.toMatchObject({ exitCode: 1 });
133
+ expect(mockSpawn).not.toHaveBeenCalled();
134
+ });
135
+
136
+ it('cancels cleanly when the user declines the confirm prompt', async () => {
137
+ writeBackend();
138
+ clack.mockConfirm.mockResolvedValue(false);
139
+ await expect(destroyStack()).rejects.toMatchObject({ exitCode: 0 });
140
+ expect(mockSpawn).not.toHaveBeenCalled();
141
+ expect(teardownStateBucket).not.toHaveBeenCalled();
142
+ expect(clack.mockCancel).toHaveBeenCalled();
143
+ });
144
+
145
+ it('destroys compute and retains the state bucket when declined', async () => {
146
+ writeBackend('retain-bucket-123', 'eu-west-1');
147
+ clack.mockConfirm
148
+ .mockResolvedValueOnce(true) // proceed with destroy
149
+ .mockResolvedValueOnce(false); // retain bucket
150
+ mockSpawn.mockImplementation((cmd, args = []) => {
151
+ expect(cmd).toBe('terraform');
152
+ expect(args).toEqual(['destroy', '-auto-approve']);
153
+ return makeChild({ code: 0, stdout: 'destroy complete' });
154
+ });
155
+
156
+ await destroyStack(); // success path calls outro, not process.exit
157
+
158
+ expect(mockSpawn).toHaveBeenCalledTimes(1);
159
+ expect(teardownStateBucket).not.toHaveBeenCalled();
160
+ expect(trackEvent).toHaveBeenCalledWith(
161
+ 'infrastructure_destroyed',
162
+ expect.objectContaining({ success: true, retained_state_bucket: true })
163
+ );
164
+ expect(clack.mockOutro).toHaveBeenCalled();
165
+ expect(exitSpy).not.toHaveBeenCalled();
166
+ });
167
+
168
+ it('destroys compute and deletes the state bucket when confirmed', async () => {
169
+ writeBackend('delete-me-123', 'us-east-2');
170
+ clack.mockConfirm
171
+ .mockResolvedValueOnce(true) // proceed
172
+ .mockResolvedValueOnce(true); // delete bucket
173
+ mockSpawn.mockImplementation(() => makeChild({ code: 0, stdout: 'destroy complete' }));
174
+
175
+ await destroyStack();
176
+
177
+ expect(teardownStateBucket).toHaveBeenCalledWith('us-east-2', 'delete-me-123');
178
+ expect(trackEvent).toHaveBeenCalledWith(
179
+ 'infrastructure_destroyed',
180
+ expect.objectContaining({ success: true, retained_state_bucket: false })
181
+ );
182
+ });
183
+
184
+ it('exits 1 and tracks failure when terraform destroy exits non-zero', async () => {
185
+ writeBackend();
186
+ clack.mockConfirm.mockResolvedValueOnce(true);
187
+ mockSpawn.mockImplementation(() =>
188
+ makeChild({ code: 1, stderr: 'Error: destroy failed dramatically' })
189
+ );
190
+
191
+ await expect(destroyStack()).rejects.toMatchObject({ exitCode: 1 });
192
+ expect(teardownStateBucket).not.toHaveBeenCalled();
193
+ expect(trackEvent).toHaveBeenCalledWith(
194
+ 'infrastructure_destroyed',
195
+ expect.objectContaining({ success: false })
196
+ );
197
+ });
198
+
199
+ it('treats spawn errors (missing binary) as destroy failure', async () => {
200
+ writeBackend();
201
+ clack.mockConfirm.mockResolvedValueOnce(true);
202
+ mockSpawn.mockImplementation(() =>
203
+ makeChild({ error: new Error('spawn terraform ENOENT') })
204
+ );
205
+
206
+ await expect(destroyStack()).rejects.toMatchObject({ exitCode: 1 });
207
+ expect(trackEvent).toHaveBeenCalledWith(
208
+ 'infrastructure_destroyed',
209
+ expect.objectContaining({ success: false })
210
+ );
211
+ });
212
+
213
+ it('survives S3 bucket deletion errors and still reports success', async () => {
214
+ writeBackend('flaky-bucket', 'us-east-2');
215
+ clack.mockConfirm
216
+ .mockResolvedValueOnce(true)
217
+ .mockResolvedValueOnce(true);
218
+ mockSpawn.mockImplementation(() => makeChild({ code: 0, stdout: 'destroy ok' }));
219
+ teardownStateBucket.mockRejectedValueOnce(new Error('AccessDenied'));
220
+
221
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
222
+ try {
223
+ await destroyStack();
224
+ expect(teardownStateBucket).toHaveBeenCalled();
225
+ expect(trackEvent).toHaveBeenCalledWith(
226
+ 'infrastructure_destroyed',
227
+ expect.objectContaining({ success: true })
228
+ );
229
+ } finally {
230
+ consoleSpy.mockRestore();
231
+ }
232
+ });
233
+
234
+ it('is wired into bin/cli.js', () => {
235
+ const content = fs.readFileSync(path.join(originalCwd, 'bin/cli.js'), 'utf8');
236
+ expect(content).toContain('destroyStack');
237
+ expect(content).toContain("'destroy'");
238
+ });
239
+
240
+ describe('pre-destroy database wake hook', () => {
241
+ const noopSleep = async () => {};
242
+
243
+ // Scripted RDS client: `statuses` is the Status sequence returned by
244
+ // Describe calls (first call feeds findDbTarget, the rest feed the
245
+ // wait loops); Start calls are recorded in `seen`.
246
+ function scriptedRdsClient({ kind = 'instance', statuses = [] } = {}) {
247
+ const seen = [];
248
+ let calls = 0;
249
+ const nextStatus = () => statuses[Math.min(calls++, statuses.length - 1)];
250
+ return {
251
+ seen,
252
+ send: vi.fn(async (command) => {
253
+ const name = command.constructor.name;
254
+ seen.push(name);
255
+ if (name === 'DescribeDBInstancesCommand') {
256
+ if (kind !== 'instance') {
257
+ throw Object.assign(new Error('DBInstanceNotFound'), { name: 'DBInstanceNotFound' });
258
+ }
259
+ return { DBInstances: [{ Status: nextStatus(), Engine: 'postgres' }] };
260
+ }
261
+ if (name === 'DescribeDBClustersCommand') {
262
+ if (kind !== 'cluster') {
263
+ throw Object.assign(new Error('DBClusterNotFoundFault'), { name: 'DBClusterNotFoundFault' });
264
+ }
265
+ return { DBClusters: [{ Status: nextStatus(), Engine: 'aurora-postgresql' }] };
266
+ }
267
+ if (name === 'StartDBInstanceCommand' || name === 'StartDBClusterCommand') return {};
268
+ throw new Error(`unexpected command ${name}`);
269
+ }),
270
+ };
271
+ }
272
+
273
+ function confirmDestroy() {
274
+ clack.mockConfirm
275
+ .mockResolvedValueOnce(true) // proceed with destroy
276
+ .mockResolvedValueOnce(false); // retain bucket
277
+ mockSpawn.mockImplementation(() => makeChild({ code: 0, stdout: 'destroy complete' }));
278
+ }
279
+
280
+ it('starts a stopped instance and waits for available before destroying', async () => {
281
+ writeBackend();
282
+ confirmDestroy();
283
+ const rdsClient = scriptedRdsClient({ kind: 'instance', statuses: ['stopped', 'starting', 'available'] });
284
+
285
+ await destroyStack({ rdsClient, sleepFn: noopSleep });
286
+
287
+ expect(rdsClient.seen).toContain('StartDBInstanceCommand');
288
+ expect(rdsClient.seen).not.toContain('StartDBClusterCommand');
289
+ expect(clack.mockSpinnerStart).toHaveBeenCalledWith('Waking database before destruction...');
290
+ expect(mockSpawn).toHaveBeenCalledTimes(1);
291
+ expect(clack.mockOutro).toHaveBeenCalled();
292
+ });
293
+
294
+ it('settles a stopping cluster to stopped before starting it', async () => {
295
+ writeBackend();
296
+ confirmDestroy();
297
+ const rdsClient = scriptedRdsClient({ kind: 'cluster', statuses: ['stopping', 'stopping', 'stopped', 'starting', 'available'] });
298
+
299
+ await destroyStack({ rdsClient, sleepFn: noopSleep });
300
+
301
+ // Start fires only after the settle loop observed `stopped`
302
+ // (describe, describe, describe, start, ...).
303
+ expect(rdsClient.seen.indexOf('StartDBClusterCommand')).toBeGreaterThan(2);
304
+ expect(rdsClient.seen).not.toContain('StartDBInstanceCommand');
305
+ expect(mockSpawn).toHaveBeenCalledTimes(1);
306
+ });
307
+
308
+ it('waits for a starting database without sending Start', async () => {
309
+ writeBackend();
310
+ confirmDestroy();
311
+ const rdsClient = scriptedRdsClient({ kind: 'instance', statuses: ['starting', 'available'] });
312
+
313
+ await destroyStack({ rdsClient, sleepFn: noopSleep });
314
+
315
+ expect(rdsClient.seen).not.toContain('StartDBInstanceCommand');
316
+ expect(rdsClient.seen).not.toContain('StartDBClusterCommand');
317
+ expect(mockSpawn).toHaveBeenCalledTimes(1);
318
+ });
319
+
320
+ it('skips the hook when the database is already available', async () => {
321
+ writeBackend();
322
+ confirmDestroy();
323
+ const rdsClient = scriptedRdsClient({ kind: 'instance', statuses: ['available'] });
324
+
325
+ await destroyStack({ rdsClient, sleepFn: noopSleep });
326
+
327
+ expect(rdsClient.seen).not.toContain('StartDBInstanceCommand');
328
+ expect(clack.mockSpinnerStart).not.toHaveBeenCalledWith('Waking database before destruction...');
329
+ expect(mockSpawn).toHaveBeenCalledTimes(1);
330
+ });
331
+
332
+ it('aborts destroy when the database never becomes available', async () => {
333
+ writeBackend();
334
+ clack.mockConfirm.mockResolvedValueOnce(true);
335
+ mockSpawn.mockImplementation(() => makeChild({ code: 0, stdout: 'destroy complete' }));
336
+ const rdsClient = scriptedRdsClient({ kind: 'instance', statuses: ['stopped'] });
337
+
338
+ await expect(destroyStack({ rdsClient, sleepFn: noopSleep, timeoutMs: 0 })).rejects.toMatchObject({ exitCode: 1 });
339
+ expect(mockSpawn).not.toHaveBeenCalled();
340
+ expect(trackEvent).toHaveBeenCalledWith(
341
+ 'infrastructure_destroyed',
342
+ expect.objectContaining({ success: false, error_code: 'RDS_DESTROY_PREFLIGHT_TIMEOUT' })
343
+ );
344
+ });
345
+
346
+ it('aborts destroy when the database lookup fails', async () => {
347
+ writeBackend();
348
+ clack.mockConfirm.mockResolvedValueOnce(true);
349
+ mockSpawn.mockImplementation(() => makeChild({ code: 0, stdout: 'destroy complete' }));
350
+ const rdsClient = { send: vi.fn(async () => { throw new Error('boom'); }) };
351
+
352
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
353
+ try {
354
+ await expect(destroyStack({ rdsClient, sleepFn: noopSleep })).rejects.toMatchObject({ exitCode: 1 });
355
+ } finally {
356
+ consoleSpy.mockRestore();
357
+ }
358
+ expect(mockSpawn).not.toHaveBeenCalled();
359
+ expect(trackEvent).toHaveBeenCalledWith(
360
+ 'infrastructure_destroyed',
361
+ expect.objectContaining({ success: false, error_code: 'RDS_DESTROY_PREFLIGHT_FAILED' })
362
+ );
363
+ });
364
+ });
365
+ });
@@ -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
+ });