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