grada-run 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 -6
  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,562 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import os from 'os';
3
+ import fs from 'fs/promises';
4
+ import path from 'path';
5
+ import { parseCliArgs } from '../src/core/parser.js';
6
+ import { getProjectConfig, getTargetDirectory, sanitizeProjectName } from '../src/utils/prompts.js';
7
+ import { trackEvent } from '../src/core/telemetry.js';
8
+ import { provisionStateBucket } from '../src/utils/aws.js';
9
+ import { mainStack } from '../src/commands/init.js';
10
+
11
+ // Deep-mock the interactive prompt library. The CLI only uses
12
+ // `@clack/prompts` for interactive mode (verified: no `inquirer` or
13
+ // `commander` prompt calls exist under src/ or bin/), so mocking every
14
+ // `@clack/prompts` export is the correct automation guard. `inquirer` is
15
+ // not a dependency, so there is nothing to mock there.
16
+ const clack = vi.hoisted(() => ({
17
+ mockText: vi.fn(),
18
+ mockSelect: vi.fn(),
19
+ mockMultiselect: vi.fn(),
20
+ mockConfirm: vi.fn(),
21
+ mockGroup: vi.fn(),
22
+ mockCancel: vi.fn(),
23
+ mockIsCancel: vi.fn(() => false),
24
+ mockIntro: vi.fn(),
25
+ mockOutro: vi.fn(),
26
+ mockNote: vi.fn(),
27
+ mockLogSuccess: vi.fn(),
28
+ mockLogWarn: vi.fn(),
29
+ mockLogError: vi.fn(),
30
+ mockLogInfo: vi.fn(),
31
+ mockSpinnerStart: vi.fn(),
32
+ mockSpinnerStop: vi.fn(),
33
+ mockSpinnerMessage: vi.fn(),
34
+ }));
35
+
36
+ vi.mock('@clack/prompts', () => ({
37
+ text: clack.mockText,
38
+ select: clack.mockSelect,
39
+ multiselect: clack.mockMultiselect,
40
+ confirm: clack.mockConfirm,
41
+ group: clack.mockGroup,
42
+ cancel: clack.mockCancel,
43
+ isCancel: clack.mockIsCancel,
44
+ intro: clack.mockIntro,
45
+ outro: clack.mockOutro,
46
+ note: clack.mockNote,
47
+ log: {
48
+ success: clack.mockLogSuccess,
49
+ warn: clack.mockLogWarn,
50
+ error: clack.mockLogError,
51
+ info: clack.mockLogInfo,
52
+ message: clack.mockLogInfo,
53
+ },
54
+ spinner: vi.fn(() => ({
55
+ start: clack.mockSpinnerStart,
56
+ stop: clack.mockSpinnerStop,
57
+ message: clack.mockSpinnerMessage,
58
+ })),
59
+ }));
60
+
61
+ // Never touch the real environment: fake terraform presence and AWS.
62
+ vi.mock('../src/utils/system.js', () => ({
63
+ checkDependency: vi.fn(async () => true),
64
+ }));
65
+
66
+ vi.mock('../src/utils/aws.js', () => ({
67
+ checkAwsCredentials: vi.fn(async (region) => ({
68
+ accountId: '123456789012',
69
+ awsAccountId: '123456789012',
70
+ region: region || 'us-east-1',
71
+ })),
72
+ provisionStateBucket: vi.fn(async () => ({
73
+ awsAccountId: '123456789012',
74
+ stateBucketName: 'mock-tf-state-bucket',
75
+ })),
76
+ teardownStateBucket: vi.fn(async () => true),
77
+ }));
78
+
79
+ // Silence telemetry so tests never hit the network.
80
+ vi.mock('../src/core/telemetry.js', () => ({
81
+ trackEvent: vi.fn(),
82
+ flushTelemetry: vi.fn(async () => {}),
83
+ }));
84
+
85
+ function expectNoInteractivePrompts() {
86
+ expect(clack.mockText).not.toHaveBeenCalled();
87
+ expect(clack.mockSelect).not.toHaveBeenCalled();
88
+ expect(clack.mockMultiselect).not.toHaveBeenCalled();
89
+ expect(clack.mockConfirm).not.toHaveBeenCalled();
90
+ expect(clack.mockGroup).not.toHaveBeenCalled();
91
+ }
92
+
93
+ describe('Headless contract (automation-safe)', () => {
94
+ const originalCwd = process.cwd();
95
+ const originalArgv = [...process.argv];
96
+ const originalDoNotTrack = process.env.DO_NOT_TRACK;
97
+ let tmpDir;
98
+
99
+ beforeEach(async () => {
100
+ vi.clearAllMocks();
101
+ process.env.DO_NOT_TRACK = '1';
102
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grada-headless-'));
103
+ });
104
+
105
+ afterEach(async () => {
106
+ process.chdir(originalCwd);
107
+ process.argv = [...originalArgv];
108
+ if (originalDoNotTrack === undefined) delete process.env.DO_NOT_TRACK;
109
+ else process.env.DO_NOT_TRACK = originalDoNotTrack;
110
+ if (tmpDir) await fs.rm(tmpDir, { recursive: true, force: true });
111
+ vi.restoreAllMocks();
112
+ });
113
+
114
+ it('parses --headless with flag values for automation wrappers', () => {
115
+ const result = parseCliArgs([
116
+ '--headless',
117
+ '--framework=nestjs',
118
+ '--port=3000',
119
+ '--region=eu-west-1',
120
+ ]);
121
+
122
+ expect(result.isHeadless).toBe(true);
123
+ expect(result.headlessOptions.framework).toBe('nestjs');
124
+ expect(result.headlessOptions.port).toBe('3000');
125
+ expect(result.headlessOptions.region).toBe('eu-west-1');
126
+ });
127
+
128
+ it('resolves the target directory without prompting in headless mode', async () => {
129
+ const config = await getTargetDirectory(true, { dir: '.' });
130
+
131
+ expect(config.targetDir).toBe(process.cwd());
132
+ expectNoInteractivePrompts();
133
+ });
134
+
135
+ it('applies flag values instead of interactive defaults in headless mode', async () => {
136
+ const config = await getProjectConfig(
137
+ true,
138
+ { framework: 'nestjs', port: '3000', region: 'eu-west-1' },
139
+ tmpDir,
140
+ null,
141
+ );
142
+
143
+ expect(config.framework).toBe('nestjs');
144
+ expect(config.port).toBe('3000');
145
+ expect(config.region).toBe('eu-west-1');
146
+ expect(config.setupType).toBe('headless');
147
+ expectNoInteractivePrompts();
148
+ });
149
+
150
+ it('runs mainStack --headless --preconfigured end to end without hanging on prompts', async () => {
151
+ // Simulate: npx grada-run --headless --preconfigured
152
+ // --framework=nestjs --port=3000 --region=eu-west-1
153
+ // Run inside the temp dir so no Terraform files pollute the repo.
154
+ process.chdir(tmpDir);
155
+ process.argv = [
156
+ process.argv[0],
157
+ 'grada',
158
+ '--headless',
159
+ '--preconfigured',
160
+ '--framework=nestjs',
161
+ '--port=3000',
162
+ '--region=eu-west-1',
163
+ ];
164
+
165
+ const parsed = parseCliArgs(process.argv.slice(2));
166
+ expect(parsed.isHeadless).toBe(true);
167
+
168
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
169
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
170
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
171
+
172
+ try {
173
+ await mainStack({
174
+ isHeadless: parsed.isHeadless,
175
+ headlessOptions: { ...parsed.headlessOptions, dir: '.' },
176
+ });
177
+
178
+ // Automation guarantee: no interactive prompt ever fired.
179
+ expectNoInteractivePrompts();
180
+ expect(exitSpy).not.toHaveBeenCalled();
181
+
182
+ // Flag values win over interactive defaults in generated output.
183
+ const mainTf = await fs.readFile(path.join(tmpDir, 'terraform', 'main.tf'), 'utf-8');
184
+ expect(mainTf).toContain('containerPort = 3000');
185
+ expect(mainTf).toContain('region = "eu-west-1"');
186
+
187
+ const dockerfile = await fs.readFile(path.join(tmpDir, 'Dockerfile'), 'utf-8');
188
+ expect(dockerfile).toContain('EXPOSE 3000');
189
+ } finally {
190
+ exitSpy.mockRestore();
191
+ logSpy.mockRestore();
192
+ errorSpy.mockRestore();
193
+ }
194
+ });
195
+
196
+ it('tolerates null headlessOptions in headless mode', async () => {
197
+ process.chdir(tmpDir);
198
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
199
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
200
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
201
+
202
+ try {
203
+ await mainStack({ isHeadless: true, headlessOptions: null });
204
+
205
+ expectNoInteractivePrompts();
206
+ expect(exitSpy).not.toHaveBeenCalled();
207
+ const mainTf = await fs.readFile(path.join(tmpDir, 'terraform', 'main.tf'), 'utf-8');
208
+ expect(mainTf).toContain('region = "us-east-2"');
209
+ } finally {
210
+ exitSpy.mockRestore();
211
+ logSpy.mockRestore();
212
+ errorSpy.mockRestore();
213
+ }
214
+ });
215
+
216
+ it('sanitizes dots, underscores, and casing without changing the target dir', async () => {
217
+ expect(sanitizeProjectName('my.app_v2')).toBe('my-app-v2');
218
+ expect(sanitizeProjectName(' My App__Name.. ')).toBe('my-app-name');
219
+ expect(sanitizeProjectName('already-clean-123')).toBe('already-clean-123');
220
+ expect(sanitizeProjectName('...')).toBe('app');
221
+ expect(sanitizeProjectName('')).toBe('app');
222
+ expect(sanitizeProjectName(null)).toBe('app');
223
+
224
+ const dotted = path.join(tmpDir, 'my.app_v2');
225
+ await fs.mkdir(dotted, { recursive: true });
226
+ process.chdir(dotted);
227
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
228
+ try {
229
+ const config = await getTargetDirectory(true, { dir: '.' });
230
+ expect(config.actualProjectName).toBe('my-app-v2');
231
+ expect(config.targetDir).toBe(process.cwd());
232
+ expect(path.basename(config.targetDir)).toBe('my.app_v2');
233
+ } finally {
234
+ logSpy.mockRestore();
235
+ }
236
+ });
237
+
238
+ it('writes no addon files by default even when dependencies exist', async () => {
239
+ process.chdir(tmpDir);
240
+ await fs.writeFile(
241
+ path.join(tmpDir, 'package.json'),
242
+ JSON.stringify({ dependencies: { ioredis: '^5.0.0', pg: '^8.0.0' } })
243
+ );
244
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
245
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
246
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
247
+
248
+ try {
249
+ await mainStack({ isHeadless: true, headlessOptions: { dir: '.', framework: 'node' } });
250
+
251
+ expectNoInteractivePrompts();
252
+ expect(exitSpy).not.toHaveBeenCalled();
253
+ for (const file of ['s3.tf', 'dynamodb.tf', 'redis.tf', 'sqs.tf', 'bedrock.tf', 'ses.tf']) {
254
+ await expect(fs.stat(path.join(tmpDir, 'terraform', file))).rejects.toThrow();
255
+ }
256
+ expect(trackEvent).toHaveBeenCalledWith(
257
+ 'project_provisioned',
258
+ expect.objectContaining({ selected_addons: [], detected_addons: ['db:redis'] })
259
+ );
260
+ } finally {
261
+ exitSpy.mockRestore();
262
+ logSpy.mockRestore();
263
+ errorSpy.mockRestore();
264
+ }
265
+ });
266
+
267
+ it('scaffolds --with addons headless with env injection and worker lifecycle', async () => {
268
+ process.chdir(tmpDir);
269
+ await fs.writeFile(path.join(tmpDir, 'Procfile'), 'web: node index.js\nworker: celery -A app worker\n');
270
+ const parsed = parseCliArgs([
271
+ '--headless', '--framework=node', '--with=db:redis,queue:sqs,ai:bedrock',
272
+ ]);
273
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
274
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
275
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
276
+
277
+ try {
278
+ await mainStack({
279
+ isHeadless: parsed.isHeadless,
280
+ isPreconfigured: parsed.isPreconfigured,
281
+ headlessOptions: { ...parsed.headlessOptions, dir: '.' },
282
+ initOptions: parsed.initOptions,
283
+ });
284
+
285
+ expectNoInteractivePrompts();
286
+ expect(exitSpy).not.toHaveBeenCalled();
287
+ const mainTf = await fs.readFile(path.join(tmpDir, 'terraform', 'main.tf'), 'utf-8');
288
+ expect(mainTf).toContain('REDIS_URL');
289
+ expect(mainTf).toContain('SQS_QUEUE_URL');
290
+ expect(mainTf).toContain('BEDROCK_MODEL_ID');
291
+ const workerTf = await fs.readFile(path.join(tmpDir, 'terraform', 'worker.tf'), 'utf-8');
292
+ expect(workerTf).toContain('REDIS_URL');
293
+ expect(workerTf).toContain('ignore_changes = [desired_count]');
294
+ expect(trackEvent).toHaveBeenCalledWith(
295
+ 'project_provisioned',
296
+ expect.objectContaining({ selected_addons: ['db:redis', 'queue:sqs', 'ai:bedrock'] })
297
+ );
298
+ } finally {
299
+ exitSpy.mockRestore();
300
+ logSpy.mockRestore();
301
+ errorSpy.mockRestore();
302
+ }
303
+ });
304
+
305
+ it('scaffolds email:ses headless with --domain and wires the migration gate', async () => {
306
+ process.chdir(tmpDir);
307
+ await fs.writeFile(
308
+ path.join(tmpDir, 'package.json'),
309
+ JSON.stringify({ scripts: { 'db:migrate': 'prisma migrate deploy' } })
310
+ );
311
+ const parsed = parseCliArgs([
312
+ '--headless', '--framework=node', '--needsDatabase',
313
+ '--with=email:ses', '--domain=example.com', '--setup-ci-migrate',
314
+ ]);
315
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
316
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
317
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
318
+
319
+ try {
320
+ await mainStack({
321
+ isHeadless: parsed.isHeadless,
322
+ isPreconfigured: parsed.isPreconfigured,
323
+ headlessOptions: { ...parsed.headlessOptions, dir: '.' },
324
+ initOptions: parsed.initOptions,
325
+ });
326
+
327
+ expectNoInteractivePrompts();
328
+ expect(exitSpy).not.toHaveBeenCalled();
329
+ const sesTf = await fs.readFile(path.join(tmpDir, 'terraform', 'ses.tf'), 'utf-8');
330
+ expect(sesTf).toContain('domain = "example.com"');
331
+ const deployYml = await fs.readFile(path.join(tmpDir, '.github', 'workflows', 'deploy.yml'), 'utf-8');
332
+ expect(deployYml).toContain('# grada:db-migrate-start');
333
+ expect(deployYml).toContain('db:migrate');
334
+ expect(trackEvent).toHaveBeenCalledWith(
335
+ 'project_provisioned',
336
+ expect.objectContaining({ selected_addons: ['email:ses'], migration_gate_enabled: true })
337
+ );
338
+ } finally {
339
+ exitSpy.mockRestore();
340
+ logSpy.mockRestore();
341
+ errorSpy.mockRestore();
342
+ }
343
+ });
344
+
345
+ it('scaffolds drift.yml headless with --setup-ci-drift and skips it by default', async () => {
346
+ process.chdir(tmpDir);
347
+ const parsed = parseCliArgs(['--headless', '--framework=node', '--setup-ci-drift']);
348
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
349
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
350
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
351
+
352
+ try {
353
+ await mainStack({
354
+ isHeadless: parsed.isHeadless,
355
+ isPreconfigured: parsed.isPreconfigured,
356
+ headlessOptions: { ...parsed.headlessOptions, dir: '.' },
357
+ initOptions: parsed.initOptions,
358
+ });
359
+
360
+ expectNoInteractivePrompts();
361
+ expect(exitSpy).not.toHaveBeenCalled();
362
+ const driftYml = await fs.readFile(path.join(tmpDir, '.github', 'workflows', 'drift.yml'), 'utf-8');
363
+ expect(driftYml).toContain('role-to-assume: arn:aws:iam::123456789012:role/');
364
+ expect(driftYml).toContain("cron: '0 6 * * *'");
365
+ expect(trackEvent).toHaveBeenCalledWith(
366
+ 'project_provisioned',
367
+ expect.objectContaining({ drift_detection_enabled: true })
368
+ );
369
+ } finally {
370
+ exitSpy.mockRestore();
371
+ logSpy.mockRestore();
372
+ errorSpy.mockRestore();
373
+ }
374
+ });
375
+
376
+ it('does not scaffold drift.yml without the flag', async () => {
377
+ process.chdir(tmpDir);
378
+ const parsed = parseCliArgs(['--headless', '--framework=node']);
379
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
380
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
381
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
382
+
383
+ try {
384
+ await mainStack({
385
+ isHeadless: parsed.isHeadless,
386
+ isPreconfigured: parsed.isPreconfigured,
387
+ headlessOptions: { ...parsed.headlessOptions, dir: '.' },
388
+ initOptions: parsed.initOptions,
389
+ });
390
+
391
+ await expect(fs.stat(path.join(tmpDir, '.github', 'workflows', 'drift.yml'))).rejects.toThrow();
392
+ expect(trackEvent).toHaveBeenCalledWith(
393
+ 'project_provisioned',
394
+ expect.objectContaining({ drift_detection_enabled: false })
395
+ );
396
+ } finally {
397
+ exitSpy.mockRestore();
398
+ logSpy.mockRestore();
399
+ errorSpy.mockRestore();
400
+ }
401
+ });
402
+
403
+ it.each([
404
+ [['--headless', '--with=db:nope'], 'unsupported-capability', 'UNSUPPORTED_CAPABILITY'],
405
+ [['--headless', '--with=ai:bedrock', '--model=bad model!'], 'invalid-model-id', 'INVALID_MODEL_ID'],
406
+ [['--headless', '--with=email:ses'], 'missing-ses-domain', 'MISSING_SES_DOMAIN'],
407
+ [['--headless', '--db-engine=sqlite'], 'invalid-db-engine', 'INVALID_DB_ENGINE'],
408
+ [['--headless', '--target=eks'], 'invalid-compute-target', 'INVALID_COMPUTE_TARGET'],
409
+ ])('fails fast (%s) before AWS provisioning or file backup', async (argv, reason, code) => {
410
+ process.chdir(tmpDir);
411
+ await fs.mkdir(path.join(tmpDir, 'terraform'), { recursive: true });
412
+ await fs.writeFile(path.join(tmpDir, 'terraform', 'keep.tf'), '# user file');
413
+ const parsed = parseCliArgs(argv);
414
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
415
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
416
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
417
+
418
+ try {
419
+ const result = await mainStack({
420
+ isHeadless: parsed.isHeadless,
421
+ isPreconfigured: parsed.isPreconfigured,
422
+ headlessOptions: { ...parsed.headlessOptions, dir: '.' },
423
+ initOptions: parsed.initOptions,
424
+ });
425
+
426
+ expect(result).toMatchObject({ ok: false, reason });
427
+ expect(exitSpy).toHaveBeenCalledWith(1);
428
+ expect(provisionStateBucket).not.toHaveBeenCalled();
429
+ expect(trackEvent).toHaveBeenCalledWith(
430
+ 'cli-error',
431
+ expect.objectContaining({ step: 'init_validation', error_code: code })
432
+ );
433
+ // No backup was created and the existing dir is untouched.
434
+ expect(await fs.readdir(tmpDir)).toEqual(['terraform']);
435
+ expect(await fs.readFile(path.join(tmpDir, 'terraform', 'keep.tf'), 'utf-8')).toBe('# user file');
436
+ } finally {
437
+ exitSpy.mockRestore();
438
+ logSpy.mockRestore();
439
+ errorSpy.mockRestore();
440
+ }
441
+ });
442
+
443
+ it.each([
444
+ ['mysql', 'engine = "mysql"', '{ "name": "DB_PORT", "value": "3306" }'],
445
+ ['aurora-postgresql', 'resource "aws_rds_cluster" "postgres"', '{ "name": "DB_ENGINE", "value": "aurora-postgresql" }'],
446
+ ])('scaffolds --db-engine=%s headless with engine-specific wiring', async (engine, dbMarker, envMarker) => {
447
+ process.chdir(tmpDir);
448
+ const parsed = parseCliArgs([
449
+ '--headless', '--framework=node', '--needsDatabase', `--db-engine=${engine}`,
450
+ ]);
451
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
452
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
453
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
454
+
455
+ try {
456
+ await mainStack({
457
+ isHeadless: parsed.isHeadless,
458
+ isPreconfigured: parsed.isPreconfigured,
459
+ headlessOptions: { ...parsed.headlessOptions, dir: '.' },
460
+ initOptions: parsed.initOptions,
461
+ });
462
+
463
+ expectNoInteractivePrompts();
464
+ expect(exitSpy).not.toHaveBeenCalled();
465
+ const databaseTf = await fs.readFile(path.join(tmpDir, 'terraform', 'database.tf'), 'utf-8');
466
+ expect(databaseTf).toContain(dbMarker);
467
+ const mainTf = await fs.readFile(path.join(tmpDir, 'terraform', 'main.tf'), 'utf-8');
468
+ expect(mainTf).toContain(envMarker);
469
+ expect(trackEvent).toHaveBeenCalledWith(
470
+ 'project_provisioned',
471
+ expect.objectContaining({ has_database: true, db_engine: engine })
472
+ );
473
+ } finally {
474
+ exitSpy.mockRestore();
475
+ logSpy.mockRestore();
476
+ errorSpy.mockRestore();
477
+ }
478
+ });
479
+ });
480
+
481
+ describe('Headless: --target lambda', () => {
482
+ let tmpDir;
483
+
484
+ beforeEach(async () => {
485
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grada-headless-lambda-'));
486
+ process.chdir(tmpDir);
487
+ });
488
+
489
+ afterEach(async () => {
490
+ process.chdir(path.dirname(tmpDir));
491
+ if (tmpDir) await fs.rm(tmpDir, { recursive: true, force: true });
492
+ });
493
+
494
+ async function runHeadlessLambda(argv) {
495
+ const parsed = parseCliArgs(argv);
496
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
497
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
498
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
499
+ try {
500
+ await mainStack({
501
+ isHeadless: parsed.isHeadless,
502
+ isPreconfigured: parsed.isPreconfigured,
503
+ headlessOptions: { ...parsed.headlessOptions, dir: '.' },
504
+ initOptions: parsed.initOptions,
505
+ });
506
+ return { exitSpy };
507
+ } finally {
508
+ exitSpy.mockRestore();
509
+ logSpy.mockRestore();
510
+ errorSpy.mockRestore();
511
+ }
512
+ }
513
+
514
+ it('scaffolds the serverless stack headless with lambda telemetry', async () => {
515
+ const { exitSpy } = await runHeadlessLambda(['--headless', '--framework=node', '--target=lambda']);
516
+ expect(exitSpy).not.toHaveBeenCalled();
517
+
518
+ const mainTf = await fs.readFile(path.join(tmpDir, 'terraform', 'main.tf'), 'utf-8');
519
+ expect(mainTf).toContain('resource "aws_lambda_function" "app"');
520
+ expect(mainTf).not.toContain('aws_ecs_service');
521
+
522
+ const dockerfile = await fs.readFile(path.join(tmpDir, 'Dockerfile'), 'utf-8');
523
+ expect(dockerfile).toContain('aws-lambda-adapter');
524
+
525
+ expect(trackEvent).toHaveBeenCalledWith(
526
+ 'project_provisioned',
527
+ expect.objectContaining({ target: 'lambda' })
528
+ );
529
+ });
530
+
531
+ it('accepts --target fargate as an ECS synonym', async () => {
532
+ const { exitSpy } = await runHeadlessLambda(['--headless', '--framework=node', '--target=fargate']);
533
+ expect(exitSpy).not.toHaveBeenCalled();
534
+ const mainTf = await fs.readFile(path.join(tmpDir, 'terraform', 'main.tf'), 'utf-8');
535
+ expect(mainTf).toContain('resource "aws_ecs_service" "app"');
536
+ expect(trackEvent).toHaveBeenCalledWith(
537
+ 'project_provisioned',
538
+ expect.objectContaining({ target: 'ecs' })
539
+ );
540
+ });
541
+
542
+ it('defaults to ECS when --target is absent', async () => {
543
+ await runHeadlessLambda(['--headless', '--framework=node']);
544
+ const mainTf = await fs.readFile(path.join(tmpDir, 'terraform', 'main.tf'), 'utf-8');
545
+ expect(mainTf).toContain('resource "aws_ecs_service" "app"');
546
+ });
547
+
548
+ it('warns about connection bursts for lambda with a database', async () => {
549
+ vi.clearAllMocks();
550
+ await runHeadlessLambda(['--headless', '--framework=node', '--target=lambda', '--needsDatabase']);
551
+ expect(clack.mockLogWarn).toHaveBeenCalledWith(expect.stringContaining('connection per concurrent execution'));
552
+ });
553
+
554
+ it('stays quiet without the lambda-plus-database combination', async () => {
555
+ vi.clearAllMocks();
556
+ await runHeadlessLambda(['--headless', '--framework=node', '--target=lambda']);
557
+ await runHeadlessLambda(['--headless', '--framework=node', '--target=ecs', '--needsDatabase']);
558
+ for (const [message] of clack.mockLogWarn.mock.calls) {
559
+ expect(String(message)).not.toContain('connection per concurrent execution');
560
+ }
561
+ });
562
+ });