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,527 @@
1
+ import fsSync from 'fs';
2
+ import fs from 'fs/promises';
3
+ import path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import color from 'picocolors';
6
+ import { COST_ESTIMATE_MARKER, LEGACY_COST_ESTIMATE_MARKER } from './visualizer.js';
7
+ import { findResourceBlock, replaceBlock } from './hcl.js';
8
+
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = path.dirname(__filename);
11
+
12
+ // Fallback deployment-doc filename used only when README.md and
13
+ // DEPLOYMENT.md both exist and neither was generated by us. The legacy
14
+ // name stays a writable candidate so pre-rebrand docs keep updating.
15
+ export const MANAGED_DOC_FALLBACK = 'GRADA.md';
16
+ export const LEGACY_MANAGED_DOC_FALLBACK = 'DEPLOY-STACK.md';
17
+
18
+ // A doc is ours when it carries our cost-estimate marker (current or legacy).
19
+ export function isManagedDoc(content) {
20
+ const text = String(content ?? '');
21
+ return text.includes(COST_ESTIMATE_MARKER) || text.includes(LEGACY_COST_ESTIMATE_MARKER);
22
+ }
23
+
24
+ export function buildReadmeNotice(docFile) {
25
+ return `\n## 🚀 Deployment\nAWS Fargate infrastructure is managed via [grada](./${docFile}).\n`;
26
+ }
27
+
28
+ // Picks the file that should receive the rendered deployment doc without ever
29
+ // overwriting user-owned content. Returns null when every candidate exists
30
+ // and none is managed (caller then skips doc generation with a warning).
31
+ export function resolveDocDest(targetDir) {
32
+ const inspect = (name) => {
33
+ const filePath = path.join(targetDir, name);
34
+ if (!fsSync.existsSync(filePath)) return { exists: false, managed: false };
35
+ return { exists: true, managed: isManagedDoc(fsSync.readFileSync(filePath, 'utf-8')) };
36
+ };
37
+ const readme = inspect('README.md');
38
+ if (!readme.exists || readme.managed) return 'README.md';
39
+ const deploy = inspect('DEPLOYMENT.md');
40
+ if (!deploy.exists || deploy.managed) return 'DEPLOYMENT.md';
41
+ const fallback = inspect(MANAGED_DOC_FALLBACK);
42
+ if (!fallback.exists || fallback.managed) return MANAGED_DOC_FALLBACK;
43
+ const legacy = inspect(LEGACY_MANAGED_DOC_FALLBACK);
44
+ if (!legacy.exists || legacy.managed) return LEGACY_MANAGED_DOC_FALLBACK;
45
+ return null;
46
+ }
47
+
48
+ export const LAMBDA_ADAPTER_IMAGE = 'public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1';
49
+
50
+ // Injects the AWS Lambda Web Adapter layer into a rendered Dockerfile so
51
+ // standard HTTP servers handle API Gateway events with zero code changes.
52
+ // Inserted right after the LAST `FROM` (the final stage, still running as
53
+ // root) so the copy into /opt/extensions never hits a later `USER` switch.
54
+ // `ENV PORT` lines already present in the template are not duplicated.
55
+ export function injectLambdaAdapter(dockerContent, port) {
56
+ const content = String(dockerContent ?? '');
57
+ const lines = content.split('\n');
58
+ let lastFromIdx = -1;
59
+ for (let i = 0; i < lines.length; i++) {
60
+ if (/^\s*FROM\s+/i.test(lines[i])) lastFromIdx = i;
61
+ }
62
+ if (lastFromIdx === -1) return content;
63
+ const hasPort = /^\s*ENV\s+PORT[=\s]/im.test(content);
64
+ const hasLwaPort = /^\s*ENV\s+AWS_LWA_PORT[=\s]/im.test(content);
65
+ const injected = [
66
+ '',
67
+ '# AWS Lambda Web Adapter (grada --target lambda)',
68
+ `COPY --from=${LAMBDA_ADAPTER_IMAGE} /lambda-adapter /opt/extensions/lambda-adapter`,
69
+ ...(hasLwaPort ? [] : [`ENV AWS_LWA_PORT=${port}`]),
70
+ ...(hasPort ? [] : [`ENV PORT=${port}`]),
71
+ ];
72
+ lines.splice(lastFromIdx + 1, 0, ...injected);
73
+ return lines.join('\n');
74
+ }
75
+
76
+ // Converts a Lambda-target database.tf from AWS-managed master passwords to
77
+ // an explicit Terraform-managed password: VPC-attached functions cannot reach
78
+ // Secrets Manager without a paid VPC endpoint, so the password flows into the
79
+ // function's DB_PASSWORD environment variable instead. Removes the now-invalid
80
+ // master-secret IAM policy (indexing an empty `master_user_secret` tuple fails
81
+ // planning) and appends the `random_password` resource. No-op for ECS content
82
+ // or content that was already converted.
83
+ export function convertDatabaseTfForLambda(databaseTfContent) {
84
+ let content = String(databaseTfContent ?? '');
85
+ if (!content.includes('manage_master_user_password = true')) return content;
86
+ // aws_rds_cluster (Aurora) names the argument `master_password` while
87
+ // aws_db_instance (RDS Postgres/MySQL) names it `password`.
88
+ const passwordAttr = content.includes('resource "aws_rds_cluster"') ? 'master_password' : 'password';
89
+ content = content.replace(
90
+ ' # AWS automatically creates and manages the secret in Secrets Manager!\n manage_master_user_password = true',
91
+ ` # Lambda targets use an explicit Terraform-managed password (see\n # random_password.db_password below) so the value can flow into the\n # function's DB_PASSWORD environment variable without a Secrets Manager\n # VPC endpoint.\n ${passwordAttr} = random_password.db_password.result`
92
+ );
93
+ content = content.replace('# 4. IAM Permission for RDS Master Password Secret\n', '');
94
+ const resource = findResourceBlock(content, 'aws_iam_role_policy', 'rds_secret_access');
95
+ if (resource) {
96
+ const lineStart = content.lastIndexOf('\n', resource.headerIdx - 1) + 1;
97
+ content = replaceBlock(content, {
98
+ startIdx: lineStart,
99
+ openIdx: resource.openIdx,
100
+ closeIdx: resource.closeIdx,
101
+ }, '').trimEnd();
102
+ content += '\n';
103
+ }
104
+ // RDS passwords disallow / " @ and spaces; the override excludes them.
105
+ if (!content.endsWith('\n')) content += '\n';
106
+ content += `
107
+ resource "random_password" "db_password" {
108
+ length = 24
109
+ special = true
110
+ override_special = "!#$%&*()-_=+[]{}<>:?"
111
+ }
112
+ `;
113
+ return content.replace(/\n{3,}/g, '\n\n');
114
+ }
115
+
116
+ // Adds the `random` provider to a rendered backend.tf (Lambda + database
117
+ // projects only, for `random_password.db_password`). No-op when present.
118
+ export function addRandomProvider(backendTfContent) {
119
+ const content = String(backendTfContent ?? '');
120
+ if (/source\s*=\s*"hashicorp\/random"/.test(content)) return content;
121
+ const tlsBlock = ' tls = {\n source = "hashicorp/tls"\n version = "~> 4.0"\n }';
122
+ if (!content.includes(tlsBlock)) return content;
123
+ return content.replace(
124
+ tlsBlock,
125
+ `${tlsBlock}\n random = {\n source = "hashicorp/random"\n version = "~> 3.0"\n }`
126
+ );
127
+ }
128
+
129
+ export async function generateTemplates(targetDir, config) {
130
+ const isLambda = config.TARGET === 'lambda';
131
+
132
+ // 1. Create directories
133
+ await fs.mkdir(targetDir, { recursive: true });
134
+ await fs.mkdir(path.join(targetDir, 'terraform'), { recursive: true });
135
+ await fs.mkdir(path.join(targetDir, '.github', 'workflows'), { recursive: true });
136
+
137
+ // 2. Define paths (never overwrite user-owned docs — see resolveDocDest)
138
+ const templatesDir = path.join(__dirname, '../../templates');
139
+ const readmeDest = resolveDocDest(targetDir);
140
+ const filesToProcess = [
141
+ { src: isLambda ? 'terraform/main-lambda.tf' : 'terraform/main.tf', dest: 'terraform/main.tf' },
142
+ { src: 'terraform/network.tf', dest: 'terraform/network.tf' },
143
+ { src: 'terraform/secrets.tf', dest: 'terraform/secrets.tf' },
144
+ { src: 'terraform/oidc.tf', dest: 'terraform/oidc.tf' },
145
+ { src: 'terraform/backend.tf', dest: 'terraform/backend.tf' },
146
+ { src: isLambda ? 'terraform/cloudfront-lambda.tf' : 'terraform/cloudfront.tf', dest: 'terraform/cloudfront.tf' },
147
+ { src: `docker/${config.finalFramework}.Dockerfile`, dest: 'Dockerfile' },
148
+ { src: isLambda ? 'github/deploy-lambda.yml' : 'github/deploy.yml', dest: '.github/workflows/deploy.yml' },
149
+ ];
150
+ if (readmeDest) {
151
+ filesToProcess.push({ src: 'README.md', dest: readmeDest });
152
+ } else {
153
+ console.log(color.yellow('⚠️ README.md, DEPLOYMENT.md, GRADA.md, and DEPLOY-STACK.md all exist and none was generated by grada. Skipping deployment-doc generation to protect your files.'));
154
+ }
155
+
156
+ // 2.1 Point user-owned READMEs at the generated doc (append once only).
157
+ const readmePath = path.join(targetDir, 'README.md');
158
+ if (readmeDest && readmeDest !== 'README.md' && fsSync.existsSync(readmePath)) {
159
+ const notice = buildReadmeNotice(readmeDest);
160
+ const current = fsSync.readFileSync(readmePath, 'utf-8');
161
+ if (!current.includes(notice.trim())) {
162
+ fsSync.appendFileSync(readmePath, notice);
163
+ }
164
+ }
165
+
166
+ // 2.1 Add Ephemeral PR workflows only if opted in (teardown.yml is
167
+ // Terraform-only, so both targets share it)
168
+ if (config.ENABLE_PR_PREVIEWS) {
169
+ filesToProcess.push({ src: isLambda ? 'github/preview-lambda.yml' : 'github/preview.yml', dest: '.github/workflows/preview.yml' });
170
+ filesToProcess.push({ src: 'github/teardown.yml', dest: '.github/workflows/teardown.yml' });
171
+ }
172
+
173
+ // 3. Configure Compute Commands & Environment Variables
174
+ let secretsArray = [];
175
+
176
+ // 3.1. Format the ECS Task Command (Heroku Procfile Support)
177
+ // If a Procfile exists and has a 'web' process, override the Docker CMD
178
+ if (config.PROCFILE && config.PROCFILE.web) {
179
+ // e.g., ["gunicorn", "myproject.wsgi"] -> JSON string for Terraform
180
+ config.TASK_COMMAND = `command = ${JSON.stringify(config.PROCFILE.web)}`;
181
+ } else {
182
+ // Fallback to the default Dockerfile CMD
183
+ config.TASK_COMMAND = '';
184
+ }
185
+
186
+ // Lambda equivalent: a Procfile `web:` command overrides the image CMD
187
+ // via `image_config`. Omitted when empty (Dockerfile CMD wins).
188
+ if (isLambda && Array.isArray(config.PROCFILE?.web) && config.PROCFILE.web.length > 0) {
189
+ const webCommand = config.PROCFILE.web.map((part) => String(part)).join(' ');
190
+ config.LAMBDA_IMAGE_CONFIG = ` image_config {\n command = ${JSON.stringify(['sh', '-c', webCommand])}\n }\n`;
191
+ } else {
192
+ config.LAMBDA_IMAGE_CONFIG = '';
193
+ }
194
+
195
+ // A caller-provided WORKER_COMMAND (interactive init worker prompt,
196
+ // possibly customized from the Procfile default) wins verbatim;
197
+ // otherwise the Procfile worker process drives generation as before.
198
+ // Lambda targets never provision the ECS worker service.
199
+ if (!isLambda && typeof config.WORKER_COMMAND === 'string' && config.WORKER_COMMAND.trim() !== '') {
200
+ filesToProcess.push({ src: 'terraform/worker.tf', dest: 'terraform/worker.tf' });
201
+ } else if (!isLambda && config.PROCFILE && config.PROCFILE.worker) {
202
+ config.WORKER_COMMAND = `command = ${JSON.stringify(config.PROCFILE.worker)}`;
203
+ // Dynamically add worker.tf to the generation list
204
+ filesToProcess.push({ src: 'terraform/worker.tf', dest: 'terraform/worker.tf' });
205
+ } else {
206
+ config.WORKER_COMMAND = '';
207
+ }
208
+
209
+ // 3.2. Inject Managed Database Variables
210
+ if (config.NEEDS_DATABASE) {
211
+ const dbEngine = config.DB_ENGINE === 'mysql' || config.DB_ENGINE === 'aurora-postgresql'
212
+ ? config.DB_ENGINE
213
+ : 'postgres';
214
+ const dbTemplate = dbEngine === 'postgres'
215
+ ? 'terraform/database.tf'
216
+ : `terraform/database-${dbEngine}.tf`;
217
+ filesToProcess.push({ src: dbTemplate, dest: 'terraform/database.tf' });
218
+
219
+ const isCluster = dbEngine === 'aurora-postgresql';
220
+ const dbRef = isCluster ? 'aws_rds_cluster.postgres' : 'aws_db_instance.postgres';
221
+ const dbHostAttr = isCluster ? 'endpoint' : 'address';
222
+ const dbNameAttr = isCluster ? 'database_name' : 'db_name';
223
+ const dbPort = dbEngine === 'mysql' ? '3306' : '5432';
224
+
225
+ if (isLambda) {
226
+ // Lambda `environment.variables` map shape. The password flows as
227
+ // a plain variable (see convertDatabaseTfForLambda) because
228
+ // VPC-attached functions cannot reach Secrets Manager without a
229
+ // paid VPC endpoint.
230
+ config.DB_ENV_VARS = ` DB_HOST = ${dbRef}.${dbHostAttr}\n`
231
+ + ` DB_PORT = "${dbPort}"\n`
232
+ + ` DB_NAME = ${dbRef}.${dbNameAttr}\n`
233
+ + ` DB_USER = "dbadmin"\n`
234
+ + ` DB_PASSWORD = random_password.db_password.result\n`
235
+ + (dbEngine === 'postgres' ? '' : ` DB_ENGINE = "${dbEngine}"\n`);
236
+ } else {
237
+ config.DB_ENV_VARS = `
238
+ { "name": "DB_HOST", "value": ${dbRef}.${dbHostAttr} },
239
+ { "name": "DB_PORT", "value": "${dbPort}" },
240
+ { "name": "DB_NAME", "value": ${dbRef}.${dbNameAttr} }${dbEngine === 'postgres' ? '' : `,\n { "name": "DB_ENGINE", "value": "${dbEngine}" }`}`;
241
+
242
+ secretsArray.push(`{ "name": "DB_USER", "valueFrom": "\${${dbRef}.master_user_secret[0].secret_arn}:username::" }`);
243
+ secretsArray.push(`{ "name": "DB_PASSWORD", "valueFrom": "\${${dbRef}.master_user_secret[0].secret_arn}:password::" }`);
244
+ }
245
+
246
+ // VPC-attached functions reach RDS/ElastiCache over the VPC subnets;
247
+ // non-database functions stay outside the VPC with direct internet.
248
+ config.LAMBDA_VPC_CONFIG = isLambda
249
+ ? ` vpc_config {\n subnet_ids = aws_subnet.public[*].id\n security_group_ids = [aws_security_group.ecs_tasks.id]\n }\n`
250
+ : '';
251
+ } else {
252
+ config.DB_ENV_VARS = '';
253
+ config.LAMBDA_VPC_CONFIG = '';
254
+ }
255
+
256
+ // 3.3. Docker Compose Overrides & Sidecars
257
+ let composeWebEnvVars = [];
258
+ let extraContainersHCL = "";
259
+
260
+ if (config.DOCKER_COMPOSE && config.DOCKER_COMPOSE.length > 0) {
261
+ // Identify the main web service (the one with the exposed port)
262
+ const webService = config.DOCKER_COMPOSE.find(s => s.port) || config.DOCKER_COMPOSE[0];
263
+
264
+ // Extract Web Env Vars
265
+ if (webService.environment) {
266
+ for (const [key, val] of Object.entries(webService.environment)) {
267
+ if (isLambda) {
268
+ composeWebEnvVars.push(` ${key} = "${String(val).replace(/"/g, '\\"')}"`);
269
+ } else {
270
+ composeWebEnvVars.push(`{ "name": "${key}", "value": "${val}" }`);
271
+ }
272
+ }
273
+ }
274
+
275
+ // Extract Web Command Override (only if Procfile hasn't already set it)
276
+ if (webService.command && !config.TASK_COMMAND && !isLambda) {
277
+ config.TASK_COMMAND = `command = ${JSON.stringify(typeof webService.command === 'string' ? webService.command.split(' ') : webService.command)}`;
278
+ }
279
+ if (isLambda && webService.command && config.LAMBDA_IMAGE_CONFIG === '') {
280
+ const composeCommand = typeof webService.command === 'string' ? webService.command : webService.command.join(' ');
281
+ config.LAMBDA_IMAGE_CONFIG = ` image_config {\n command = ${JSON.stringify(['sh', '-c', composeCommand])}\n }\n`;
282
+ }
283
+
284
+ // Process Sidecar Containers (e.g., Redis, Memcached)
285
+ const sidecars = config.DOCKER_COMPOSE.filter(s => s.name !== webService.name);
286
+ if (isLambda && sidecars.length > 0) {
287
+ console.log(color.yellow(`⚠️ Skipping ${sidecars.length} Docker Compose sidecar(s) (${sidecars.map((s) => s.name).join(', ')}): Lambda targets run a single container per function.`));
288
+ }
289
+ if (!isLambda && sidecars.length > 0) {
290
+ extraContainersHCL = sidecars.map(service => {
291
+ const sidecarEnv = Object.entries(service.environment || {})
292
+ .map(([k, v]) => `{ "name": "${k}", "value": "${String(v).replace(/"/g, '\\"')}" }`)
293
+ .join(',\n ');
294
+
295
+ const sidecarCmd = service.command ? `\n command = ${JSON.stringify(typeof service.command === 'string' ? service.command.split(' ') : service.command)}` : '';
296
+
297
+ return `,
298
+ {
299
+ name = "${service.name}"
300
+ image = "${service.image || 'alpine:latest'}"
301
+ essential = true
302
+ environment = [
303
+ ${sidecarEnv}
304
+ ]${sidecarCmd}
305
+ logConfiguration = {
306
+ logDriver = "awslogs"
307
+ options = {
308
+ "awslogs-group" = aws_cloudwatch_log_group.app_logs.name
309
+ "awslogs-region" = "${config.REGION}"
310
+ "awslogs-stream-prefix" = "ecs-${service.name}"
311
+ }
312
+ }
313
+ }`;
314
+ }).join('');
315
+ }
316
+ }
317
+
318
+ config.COMPOSE_WEB_ENV_VARS = composeWebEnvVars.length === 0
319
+ ? ''
320
+ : isLambda
321
+ ? `${composeWebEnvVars.join('\n')}\n`
322
+ : `${composeWebEnvVars.join(',\n ')},`;
323
+ config.EXTRA_CONTAINERS = isLambda ? '' : extraContainersHCL;
324
+
325
+ // 4. Configure AWS Secrets Manager Integration
326
+ // Build the initial HCL map for AWS Secrets Manager
327
+ let initialSecretMap = `{\n EXAMPLE_API_KEY = "replace_me_in_aws_console"`;
328
+
329
+ // 4.1. Inject Rails Master Key if applicable
330
+ if (config.finalFramework === 'rails') {
331
+ secretsArray.push(`{ "name": "RAILS_MASTER_KEY", "valueFrom": "\${local.secret_arn}:RAILS_MASTER_KEY::" }`);
332
+
333
+ let initialMasterKey = "1234567890abcdef1234567890abcdef"; // Dummy fallback for CI/CD
334
+ const masterKeyPath = path.join(targetDir, 'config', 'master.key');
335
+
336
+ if (fsSync.existsSync(masterKeyPath)) {
337
+ initialMasterKey = fsSync.readFileSync(masterKeyPath, 'utf-8').trim();
338
+ }
339
+
340
+ // Inject the string literal directly, avoiding Terraform variables entirely
341
+ initialSecretMap += `,\n RAILS_MASTER_KEY = "${initialMasterKey}"`;
342
+ }
343
+
344
+ initialSecretMap += `\n }`;
345
+
346
+ // Lambda functions have no ECS `secrets` block: credentials flow via
347
+ // environment variables (DB_*) or runtime Secrets Manager reads
348
+ // (APP_SECRETS_ARN), so the task-level secret mappings stay empty.
349
+ if (isLambda && config.finalFramework === 'rails') {
350
+ console.log(color.yellow('⚠️ RAILS_MASTER_KEY is not auto-injected on Lambda targets: set it as a function environment variable after provisioning.'));
351
+ }
352
+ config.TASK_SECRETS = isLambda ? '' : secretsArray.join(',\n ');
353
+ config.INITIAL_SECRET_MAP = initialSecretMap;
354
+
355
+ config.SAFE_ALB_NAME = config.PROJECT_NAME.length > 27
356
+ ? config.PROJECT_NAME.substring(0, 27).replace(/-$/, '') // Remove trailing hyphens
357
+ : config.PROJECT_NAME;
358
+
359
+ // 4.5. Translate Vercel Rules to AWS ALB Listener Rules
360
+ let vercelTerraformRules = '';
361
+
362
+ if (config.VERCEL_RULES && config.VERCEL_RULES.redirects) {
363
+ config.VERCEL_RULES.redirects.forEach((rule, index) => {
364
+ // Vercel uses 'permanent: true' for 301, false for 302
365
+ const statusCode = rule.permanent === false ? "HTTP_302" : "HTTP_301";
366
+
367
+ // Map Vercel's source path to AWS ALB Path Pattern
368
+ let sourcePath = rule.source;
369
+ // Vercel sometimes uses regex syntax like '/blog/(.*)'. ALB uses '/blog/*'
370
+ sourcePath = sourcePath.replace(/\(\.\*\)/g, '*');
371
+
372
+ vercelTerraformRules += `
373
+ # Auto-generated from vercel.json redirect
374
+ resource "aws_lb_listener_rule" "vercel_redirect_${index}" {
375
+ listener_arn = aws_lb_listener.http.arn
376
+ priority = ${100 + index} # Start at 100 to avoid conflicts
377
+
378
+ action {
379
+ type = "redirect"
380
+ redirect {
381
+ status_code = "${statusCode}"
382
+ path = "${rule.destination}"
383
+ }
384
+ }
385
+
386
+ condition {
387
+ path_pattern {
388
+ values = ["${sourcePath}"]
389
+ }
390
+ }
391
+ }
392
+ `;
393
+ });
394
+ }
395
+
396
+ // Assign the compiled HCL to the config object so the template engine can inject it
397
+ // (Lambda targets have no ALB listener rules, so the routing stays empty).
398
+ config.VERCEL_EDGE_ROUTING = isLambda ? '' : vercelTerraformRules;
399
+
400
+ // 5. Process standard files
401
+ for (const file of filesToProcess) {
402
+ let content = await fs.readFile(path.join(templatesDir, file.src), 'utf-8');
403
+
404
+ // Inject variables
405
+ for (const [key, value] of Object.entries(config)) {
406
+ content = content.replace(new RegExp(`{{${key}}}`, 'g'), value);
407
+ }
408
+
409
+ if (isLambda && file.dest === 'Dockerfile') {
410
+ content = injectLambdaAdapter(content, config.PORT);
411
+ }
412
+
413
+ if (isLambda && file.dest === 'terraform/database.tf') {
414
+ content = convertDatabaseTfForLambda(content);
415
+ }
416
+
417
+ if (isLambda && config.NEEDS_DATABASE && file.dest === 'terraform/backend.tf') {
418
+ content = addRandomProvider(content);
419
+ }
420
+
421
+ if (file.dest === 'terraform/worker.tf' && !config.NEEDS_DATABASE) {
422
+ // {{DB_ENV_VARS}} expanded to empty, leaving a dangling comma +
423
+ // blank line after NODE_ENV (main.tf shares the value shape, so
424
+ // the cleanup stays worker-specific).
425
+ content = content.replace(
426
+ /(\{ "name": "NODE_ENV", "value": "production" \}),\s*\n\s*\n(\s*\])/,
427
+ '$1\n$2'
428
+ );
429
+ }
430
+
431
+ await fs.writeFile(path.join(targetDir, file.dest), content);
432
+ }
433
+
434
+ // 6. Create empty secrets file (preserve previously pushed keys on re-init)
435
+ const secretKeysPath = path.join(targetDir, 'terraform', 'secret_keys.json');
436
+ if (!fsSync.existsSync(secretKeysPath)) {
437
+ await fs.writeFile(secretKeysPath, "[]");
438
+ }
439
+
440
+ // 7. Handle .gitignore dynamically based on framework
441
+ const targetGitignore = path.join(targetDir, '.gitignore');
442
+
443
+ if (!fsSync.existsSync(targetGitignore)) {
444
+ await fs.writeFile(targetGitignore, getGitignoreContent(config.finalFramework));
445
+ } else {
446
+ const existingGitignore = await fs.readFile(targetGitignore, 'utf-8');
447
+ if (!existingGitignore.includes('terraform/.terraform/')) {
448
+ const appendIgnore = '\n# Added by grada (Terraform)\nterraform/.terraform/\nterraform/*.tfstate\nterraform/*.tfstate.backup\nterraform/.terraform.lock.hcl\n.env\n';
449
+ await fs.appendFile(targetGitignore, appendIgnore);
450
+ }
451
+ }
452
+
453
+ // 8. Create .dockerignore to keep images lean and secure
454
+ const dockerignorePath = path.join(targetDir, '.dockerignore');
455
+ const appendDockerIgnore = '\n# Infrastructure (grada)\nterraform/\n**/.terraform/\n**/.terraform.*\n**/*.tfstate*\n.env\n';
456
+
457
+ if (!fsSync.existsSync(dockerignorePath)) {
458
+ const dockerignoreContent = `
459
+ .git/
460
+ .github/
461
+ README.md
462
+ node_modules/
463
+ npm-debug.log
464
+ __pycache__/
465
+ *.pyc
466
+ .DS_Store
467
+ `.trim() + appendDockerIgnore;
468
+ await fs.writeFile(dockerignorePath, dockerignoreContent);
469
+ } else {
470
+ const existingDockerignore = await fs.readFile(dockerignorePath, 'utf-8');
471
+ if (!existingDockerignore.includes('terraform/')) {
472
+ await fs.appendFile(dockerignorePath, appendDockerIgnore);
473
+ }
474
+ }
475
+
476
+ function getGitignoreContent(framework) {
477
+ const baseIgnore = `
478
+ # Infrastructure (grada)
479
+ terraform/.terraform/
480
+ terraform/*.tfstate
481
+ terraform/*.tfstate.backup
482
+ terraform/.terraform.lock.hcl
483
+ # NOTE: terraform/secret_keys.json must stay committed (key names only, no values) — Terraform/CI reads it.
484
+ terraform/*.auto.tfvars
485
+ .env
486
+
487
+ # OS
488
+ .DS_Store
489
+ Thumbs.db
490
+ `;
491
+
492
+ const presets = {
493
+ node: '\n# Node.js\nnode_modules/\nnpm-debug.log\nyarn-error.log\n',
494
+ nestjs: '\n# NestJS\nnode_modules/\ndist/\nnpm-debug.log\n',
495
+ nextjs: '\n# Next.js\nnode_modules/\n.next/\nout/\nbuild/\nnext-env.d.ts\n',
496
+ nuxt: '\n# Nuxt 3\nnode_modules/\n.nuxt/\n.output/\ndist/\n',
497
+ python: '\n# Python\n__pycache__/\n*.py[cod]\n*$py.class\nvenv/\nenv/\n.venv/\n.pytest_cache/\n',
498
+ django: '\n# Django\n*.log\n*.pot\n*.pyc\n__pycache__/\ndb.sqlite3\nmedia/\n',
499
+ go: '\n# Go\n/bin/\n/pkg/\n/obj/\n*.exe\n*.exe~*\n*.dll\n*.so\n*.dylib\n',
500
+ rails: '\n# Rails\n/*.log\n/tmp/\n/log/\n/public/system/\n/public/assets/\n/vendor/bundle/\n',
501
+ static: '\n# Static Sites\nnode_modules/\ndist/\nbuild/\nout/\n.output/\n.cache/\npublic/\n'
502
+ };
503
+
504
+ const frameworkIgnore = presets[framework] || '';
505
+ return (baseIgnore + frameworkIgnore).trim();
506
+ }
507
+
508
+ // 9. Framework-specific cleanup
509
+ // Disable default workflows that crash in isolated CI environments
510
+ if (config.finalFramework === 'rails') {
511
+ const railsCiPath = path.join(targetDir, '.github', 'workflows', 'ci.yml');
512
+ const dependabotPath = path.join(targetDir, '.github', 'dependabot.yml');
513
+
514
+ if (config.DISABLE_DEFAULT_CI) {
515
+ if (fsSync.existsSync(railsCiPath)) {
516
+ fsSync.renameSync(railsCiPath, `${railsCiPath}.bak`);
517
+ console.log(' ✅ Safely disabled default Rails ci.yml (renamed to .bak).');
518
+ }
519
+ if (fsSync.existsSync(dependabotPath)) {
520
+ fsSync.renameSync(dependabotPath, `${dependabotPath}.bak`);
521
+ console.log(' ✅ Safely disabled default dependabot.yml (renamed to .bak).');
522
+ }
523
+ } else if (fsSync.existsSync(railsCiPath) || fsSync.existsSync(dependabotPath)) {
524
+ console.log(' ⚠️ You chose to keep your existing GitHub Actions. Please note that default tests may fail until you configure a database service in your CI runner.');
525
+ }
526
+ }
527
+ }