@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
package/package.json CHANGED
@@ -1,15 +1,115 @@
1
1
  {
2
2
  "name": "@grada-run/grada",
3
- "version": "0.0.1",
4
- "description": "Grada CLI — Open-hood cloud infrastructure from Day 0 to Day N.",
5
- "homepage": "https://grada.run",
3
+ "version": "0.33.0",
4
+ "description": "Provision production-ready AWS infrastructure and CI/CD pipelines in seconds.",
5
+ "engines": {
6
+ "node": ">=18.0.0"
7
+ },
8
+ "type": "module",
9
+ "workspaces": [
10
+ "apps/*"
11
+ ],
12
+ "bin": {
13
+ "grada": "bin/cli.js",
14
+ "grada-run": "bin/cli.js",
15
+ "deploy-stack": "bin/cli.js"
16
+ },
17
+ "scripts": {
18
+ "test": "vitest run",
19
+ "test:watch": "vitest",
20
+ "test:update": "vitest run -u",
21
+ "test:iac": "node scripts/test-iac.js",
22
+ "test:e2e:tier0": "vitest run -c vitest.e2e.tier0.config.js",
23
+ "test:e2e:tier1": "vitest run -c vitest.e2e.tier1.config.js",
24
+ "sync:bedrock-models": "node scripts/sync-bedrock-models.js",
25
+ "docs:dev": "npm run dev --workspace=apps/docs",
26
+ "docs:build": "npm run build --workspace=apps/docs"
27
+ },
28
+ "author": "grada-run",
6
29
  "repository": {
7
30
  "type": "git",
8
31
  "url": "git+https://github.com/grada-run/grada.git"
9
32
  },
10
- "bin": {
11
- "grada": "index.js",
12
- "grada-run": "index.js"
33
+ "homepage": "https://github.com/grada-run/grada#readme",
34
+ "bugs": {
35
+ "url": "https://github.com/grada-run/grada/issues"
36
+ },
37
+ "keywords": [
38
+ "aws",
39
+ "terraform",
40
+ "infrastructure-as-code",
41
+ "devops",
42
+ "cli",
43
+ "github-actions",
44
+ "ci-cd",
45
+ "oidc",
46
+ "serverless",
47
+ "ecs",
48
+ "fargate",
49
+ "cloudfront",
50
+ "cdn",
51
+ "rds",
52
+ "postgres",
53
+ "redis",
54
+ "dynamodb",
55
+ "sqs",
56
+ "ses",
57
+ "transactional-email",
58
+ "nodejs",
59
+ "expressjs",
60
+ "nestjs",
61
+ "nextjs",
62
+ "nextjs-deployment",
63
+ "python",
64
+ "fastapi",
65
+ "django",
66
+ "rails",
67
+ "golang",
68
+ "sveltekit",
69
+ "nuxt",
70
+ "nuxtjs",
71
+ "astro",
72
+ "static-site",
73
+ "nginx",
74
+ "docker",
75
+ "docker-compose",
76
+ "heroku",
77
+ "vercel",
78
+ "custom-domains",
79
+ "ssl",
80
+ "route53",
81
+ "bedrock",
82
+ "generative-ai",
83
+ "preview-environments",
84
+ "zero-downtime",
85
+ "rollback",
86
+ "secrets-management",
87
+ "background-jobs",
88
+ "database-migrations",
89
+ "cost-optimization",
90
+ "deploy-stack",
91
+ "grada",
92
+ "grada-run"
93
+ ],
94
+ "license": "MIT",
95
+ "dependencies": {
96
+ "@aws-sdk/client-bedrock": "3.1119.0",
97
+ "@aws-sdk/client-cloudwatch": "3.1119.0",
98
+ "@aws-sdk/client-cloudwatch-logs": "3.1119.0",
99
+ "@aws-sdk/client-dynamodb": "3.1119.0",
100
+ "@aws-sdk/client-ec2": "3.1119.0",
101
+ "@aws-sdk/client-ecr": "3.1119.0",
102
+ "@aws-sdk/client-ecs": "3.1119.0",
103
+ "@aws-sdk/client-rds": "3.1119.0",
104
+ "@aws-sdk/client-s3": "3.1115.0",
105
+ "@aws-sdk/client-secrets-manager": "3.1112.0",
106
+ "@aws-sdk/client-sts": "3.1115.0",
107
+ "@clack/prompts": "1.7.0",
108
+ "dotenv": "17.4.2",
109
+ "js-yaml": "5.4.1",
110
+ "picocolors": "1.1.1"
13
111
  },
14
- "license": "MIT"
112
+ "devDependencies": {
113
+ "vitest": "5.0.0"
114
+ }
15
115
  }
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ // Maintainer script: refresh the bundled Bedrock model catalog from live AWS
3
+ // data, pruning models that are no longer available. Run with local AWS
4
+ // credentials (`npm run sync:bedrock-models`) or via the weekly workflow.
5
+ // Exits non-zero when live models arrive without family hint rules so the
6
+ // catalog never silently accumulates generic entries.
7
+ import path from 'path';
8
+ import { refreshBedrockCatalog, validateCatalogHints } from '../src/utils/bedrock-catalog.js';
9
+
10
+ const target = path.resolve('src/data/bedrock-models.json');
11
+ const catalog = await refreshBedrockCatalog({ cachePath: target, pruneMissing: true });
12
+ const modelCount = catalog.providers.reduce((total, group) => total + group.models.length, 0);
13
+ console.log(
14
+ `Synced ${modelCount} models across ${catalog.providers.length} providers ` +
15
+ `to src/data/bedrock-models.json (updatedAt=${catalog.updatedAt}).`
16
+ );
17
+
18
+ const result = validateCatalogHints(catalog);
19
+ if (!result.ok) {
20
+ console.error(`::error file=src/utils/bedrock-catalog.js::Unrecognized Bedrock model families missing hint rules: ${result.unmatchedModels.map(m => m.id).join(', ')}. Update generateHintForModel() in src/utils/bedrock-catalog.js.`);
21
+ process.exit(1);
22
+ }
@@ -0,0 +1,261 @@
1
+ #!/usr/bin/env node
2
+ // Local parity check for the Terraform + TFLint steps in
3
+ // .github/workflows/iac-validation.yml (no Docker builds or Trivy scans).
4
+ // Scaffolds a base project, a full addons + domain project, one project per
5
+ // non-default database engine, and the Lambda equivalents (base, full
6
+ // Aurora addons + domain, MySQL conversion) in temp dirs, runs
7
+ // `terraform init -backend=false`, `terraform validate`, and (when
8
+ // installed) `tflint --init` + `tflint` in each, then cleans up.
9
+ // Exits non-zero on any failure. Run with: npm run test:iac
10
+ import { execFileSync } from 'node:child_process';
11
+ import fs from 'node:fs';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
+ const CLI = path.join(ROOT, 'bin', 'cli.js');
18
+
19
+ // Shared provider cache across runs so the second project (and repeat runs)
20
+ // skip re-downloading providers. Lives outside the per-run temp root so
21
+ // cleanup never deletes it.
22
+ const pluginCacheDir = path.join(os.tmpdir(), 'grada-iac-plugin-cache');
23
+ fs.mkdirSync(pluginCacheDir, { recursive: true });
24
+
25
+ const baseEnv = {
26
+ ...process.env,
27
+ CI_MOCK_AWS: 'true',
28
+ DO_NOT_TRACK: '1',
29
+ TF_PLUGIN_CACHE_DIR: pluginCacheDir,
30
+ };
31
+
32
+ function section(title) {
33
+ console.log(`\n=== ${title} ===`);
34
+ }
35
+
36
+ function run(cmd, args, options) {
37
+ console.log(`$ ${cmd} ${args.join(' ')}`);
38
+ execFileSync(cmd, args, { stdio: 'inherit', ...options });
39
+ }
40
+
41
+ function commandExists(cmd) {
42
+ try {
43
+ execFileSync(cmd, ['--version'], { stdio: 'pipe' });
44
+ return true;
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ if (!commandExists('terraform')) {
51
+ console.error('ERROR: terraform is not installed. Install it (https://developer.hashicorp.com/terraform/install) and re-run.');
52
+ process.exit(1);
53
+ }
54
+
55
+ const tflintAvailable = commandExists('tflint');
56
+ if (!tflintAvailable) {
57
+ console.log('WARNING: tflint not found on PATH — lint steps will be skipped. Install it with `brew install tflint` (macOS) or see https://github.com/terraform-linters/tflint, then re-run.');
58
+ }
59
+
60
+ function checkTerraform(terraformDir) {
61
+ run('terraform', ['init', '-backend=false'], { cwd: terraformDir, env: baseEnv });
62
+ run('terraform', ['validate'], { cwd: terraformDir, env: baseEnv });
63
+ if (tflintAvailable) {
64
+ run('tflint', ['--init'], { cwd: terraformDir, env: baseEnv });
65
+ run('tflint', [], { cwd: terraformDir, env: baseEnv });
66
+ }
67
+ }
68
+
69
+ const ADDONS = 'storage:s3,db:dynamodb,db:redis,queue:sqs,ai:bedrock,email:ses,cron';
70
+
71
+ // Mirrors the Scaffold steps of the validate-addons job: without these seed
72
+ // files init cannot generate worker.tf or the migration gate.
73
+ function seedAddonsProject(dir) {
74
+ fs.writeFileSync(path.join(dir, 'package.json'), '{"scripts":{"db:migrate":"prisma migrate deploy"}}');
75
+ fs.writeFileSync(path.join(dir, 'Procfile'), 'web: node index.js\nworker: node worker.js\n');
76
+ }
77
+
78
+ // Lambda variant: web-only Procfile (Lambda targets run a single function,
79
+ // so worker processes are skipped) plus a migration script (the ECS-based
80
+ // migration gate is skipped with a warning on Lambda).
81
+ function seedLambdaAddonsProject(dir) {
82
+ fs.writeFileSync(path.join(dir, 'package.json'), '{"scripts":{"db:migrate":"prisma migrate deploy"}}');
83
+ fs.writeFileSync(path.join(dir, 'Procfile'), 'web: node index.js\n');
84
+ }
85
+
86
+ function assertAddonsFiles(dir) {
87
+ for (const file of ['terraform/database.tf', 'terraform/ses.tf', 'terraform/bedrock.tf', 'terraform/cron.tf', 'terraform/worker.tf', 'terraform/domain.tf']) {
88
+ if (!fs.existsSync(path.join(dir, file))) {
89
+ throw new Error(`expected ${file} to be generated`);
90
+ }
91
+ }
92
+ const deployYml = fs.readFileSync(path.join(dir, '.github', 'workflows', 'deploy.yml'), 'utf-8');
93
+ if (!deployYml.includes('grada:db-migrate-start')) {
94
+ throw new Error('expected the migration gate (grada:db-migrate-start) in .github/workflows/deploy.yml');
95
+ }
96
+ }
97
+
98
+ function assertEngineFiles(dir, engine, marker) {
99
+ const databaseTf = fs.readFileSync(path.join(dir, 'terraform', 'database.tf'), 'utf-8');
100
+ if (!databaseTf.includes(marker)) {
101
+ throw new Error(`expected ${marker} in terraform/database.tf for --db-engine=${engine}`);
102
+ }
103
+ }
104
+
105
+ function engineProject(engine, marker) {
106
+ return {
107
+ name: `engine: ${engine}`,
108
+ dirName: `test-app-engine-${engine}`,
109
+ steps() {
110
+ return [
111
+ ['scaffold', () => run('node', [CLI, 'init', '--headless', '--framework=node', '--needsDatabase', '--db-engine', engine], { cwd: this.dir, env: baseEnv })],
112
+ ['assert files', () => assertEngineFiles(this.dir, engine, marker)],
113
+ ['terraform', () => checkTerraform(path.join(this.dir, 'terraform'))],
114
+ ];
115
+ },
116
+ };
117
+ }
118
+
119
+ function assertLambdaBaseFiles(dir) {
120
+ const mainTf = fs.readFileSync(path.join(dir, 'terraform', 'main.tf'), 'utf-8');
121
+ for (const marker of [
122
+ 'resource "aws_lambda_function" "app"',
123
+ 'resource "aws_apigatewayv2_api" "main"',
124
+ 'ignore_changes = [image_uri]',
125
+ 'output "api_gateway_url"',
126
+ ]) {
127
+ if (!mainTf.includes(marker)) throw new Error(`expected ${marker} in terraform/main.tf`);
128
+ }
129
+ if (mainTf.includes('aws_ecs_service')) throw new Error('expected no ECS resources in terraform/main.tf');
130
+ const cloudfrontTf = fs.readFileSync(path.join(dir, 'terraform', 'cloudfront.tf'), 'utf-8');
131
+ if (!cloudfrontTf.includes('origin_protocol_policy = "https-only"')) {
132
+ throw new Error('expected the https-only API Gateway origin in terraform/cloudfront.tf');
133
+ }
134
+ const dockerfile = fs.readFileSync(path.join(dir, 'Dockerfile'), 'utf-8');
135
+ if (!dockerfile.includes('aws-lambda-adapter')) throw new Error('expected the Lambda Web Adapter in Dockerfile');
136
+ const deployYml = fs.readFileSync(path.join(dir, '.github', 'workflows', 'deploy.yml'), 'utf-8');
137
+ if (!deployYml.includes('aws lambda update-function-code')) {
138
+ throw new Error('expected update-function-code in .github/workflows/deploy.yml');
139
+ }
140
+ if (fs.existsSync(path.join(dir, 'terraform', 'worker.tf'))) {
141
+ throw new Error('expected no terraform/worker.tf on a lambda project');
142
+ }
143
+ }
144
+
145
+ function assertLambdaAddonsFiles(dir) {
146
+ for (const file of ['terraform/database.tf', 'terraform/ses.tf', 'terraform/bedrock.tf', 'terraform/cron.tf', 'terraform/redis.tf', 'terraform/domain.tf']) {
147
+ if (!fs.existsSync(path.join(dir, file))) {
148
+ throw new Error(`expected ${file} to be generated`);
149
+ }
150
+ }
151
+ const mainTf = fs.readFileSync(path.join(dir, 'terraform', 'main.tf'), 'utf-8');
152
+ if (!mainTf.includes('vpc_config {')) throw new Error('expected vpc_config in terraform/main.tf');
153
+ if (!mainTf.includes('REDIS_URL = ')) throw new Error('expected REDIS_URL in the function variables map');
154
+ if (!mainTf.includes('image_config {')) throw new Error('expected image_config from the Procfile web command');
155
+ const cronTf = fs.readFileSync(path.join(dir, 'terraform', 'cron.tf'), 'utf-8');
156
+ if (!cronTf.includes('aws_lambda_function.app.arn')) throw new Error('expected the Lambda scheduler target in terraform/cron.tf');
157
+ const databaseTf = fs.readFileSync(path.join(dir, 'terraform', 'database.tf'), 'utf-8');
158
+ if (!databaseTf.includes('resource "random_password" "db_password"')) {
159
+ throw new Error('expected random_password.db_password in terraform/database.tf');
160
+ }
161
+ const deployYml = fs.readFileSync(path.join(dir, '.github', 'workflows', 'deploy.yml'), 'utf-8');
162
+ if (deployYml.includes('grada:db-migrate-start')) {
163
+ throw new Error('expected no ECS migration gate in the lambda deploy workflow');
164
+ }
165
+ }
166
+
167
+ const projects = [
168
+ {
169
+ name: 'base (no domain)',
170
+ dirName: 'test-app-base',
171
+ steps() {
172
+ return [
173
+ ['scaffold', () => run('node', [CLI, 'init', '--headless', '--framework=node', '--needsDatabase'], { cwd: this.dir, env: baseEnv })],
174
+ ['terraform', () => checkTerraform(path.join(this.dir, 'terraform'))],
175
+ ];
176
+ },
177
+ },
178
+ engineProject('mysql', 'engine = "mysql"'),
179
+ engineProject('aurora-postgresql', 'resource "aws_rds_cluster" "postgres"'),
180
+ {
181
+ name: 'addons + domain',
182
+ dirName: 'test-app-addons',
183
+ steps() {
184
+ return [
185
+ ['seed', () => seedAddonsProject(this.dir)],
186
+ ['scaffold', () => run('node', [CLI, 'init', '--headless', '--framework=node', '--needsDatabase', '--with', ADDONS, '--domain', 'example.com', '--zone-id', 'Z1234567890ABC', '--setup-ci-migrate'], { cwd: this.dir, env: baseEnv })],
187
+ ['domain add', () => run('node', [CLI, 'domain', 'add', 'example.com', '--zone-id', 'Z1234567890ABC'], { cwd: this.dir, env: baseEnv })],
188
+ ['assert files', () => assertAddonsFiles(this.dir)],
189
+ ['terraform', () => checkTerraform(path.join(this.dir, 'terraform'))],
190
+ ];
191
+ },
192
+ },
193
+ {
194
+ name: 'lambda base (no domain)',
195
+ dirName: 'test-app-lambda-base',
196
+ steps() {
197
+ return [
198
+ ['scaffold', () => run('node', [CLI, 'init', '--headless', '--framework=node', '--target', 'lambda'], { cwd: this.dir, env: baseEnv })],
199
+ ['assert files', () => assertLambdaBaseFiles(this.dir)],
200
+ ['terraform', () => checkTerraform(path.join(this.dir, 'terraform'))],
201
+ ];
202
+ },
203
+ },
204
+ {
205
+ name: 'lambda aurora + addons + domain',
206
+ dirName: 'test-app-lambda-addons',
207
+ steps() {
208
+ return [
209
+ ['seed', () => seedLambdaAddonsProject(this.dir)],
210
+ ['scaffold', () => run('node', [CLI, 'init', '--headless', '--framework=node', '--target', 'lambda', '--needsDatabase', '--db-engine', 'aurora-postgresql', '--with', ADDONS, '--domain', 'example.com', '--zone-id', 'Z1234567890ABC', '--setup-ci-migrate'], { cwd: this.dir, env: baseEnv })],
211
+ ['domain add', () => run('node', [CLI, 'domain', 'add', 'example.com', '--zone-id', 'Z1234567890ABC'], { cwd: this.dir, env: baseEnv })],
212
+ ['assert files', () => assertLambdaAddonsFiles(this.dir)],
213
+ ['terraform', () => checkTerraform(path.join(this.dir, 'terraform'))],
214
+ ];
215
+ },
216
+ },
217
+ {
218
+ name: 'lambda mysql (instance conversion)',
219
+ dirName: 'test-app-lambda-mysql',
220
+ steps() {
221
+ return [
222
+ ['scaffold', () => run('node', [CLI, 'init', '--headless', '--framework=node', '--target', 'lambda', '--needsDatabase', '--db-engine', 'mysql'], { cwd: this.dir, env: baseEnv })],
223
+ ['assert files', () => assertEngineFiles(this.dir, 'mysql', 'engine = "mysql"')],
224
+ ['terraform', () => checkTerraform(path.join(this.dir, 'terraform'))],
225
+ ];
226
+ },
227
+ },
228
+ ];
229
+
230
+ const failures = [];
231
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'grada-iac-'));
232
+ console.log(`Working directory: ${tmpRoot}`);
233
+ try {
234
+ for (const project of projects) {
235
+ project.dir = path.join(tmpRoot, project.dirName);
236
+ fs.mkdirSync(project.dir, { recursive: true });
237
+ section(`Project: ${project.name}`);
238
+ for (const [stepName, stepFn] of project.steps()) {
239
+ try {
240
+ stepFn();
241
+ } catch (error) {
242
+ failures.push({ project: project.name, step: stepName, error });
243
+ console.error(`FAILED: ${project.name} / ${stepName}: ${error.message}`);
244
+ break;
245
+ }
246
+ }
247
+ }
248
+ } finally {
249
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
250
+ console.log(`Cleaned up: ${tmpRoot}`);
251
+ }
252
+
253
+ if (failures.length > 0) {
254
+ console.error(`\nIaC validation FAILED (${failures.length} failing step${failures.length === 1 ? '' : 's'}):`);
255
+ for (const failure of failures) {
256
+ console.error(` - ${failure.project} / ${failure.step}`);
257
+ }
258
+ process.exit(1);
259
+ }
260
+
261
+ console.log('\nIaC validation passed.');
@@ -0,0 +1,128 @@
1
+ # Spec: Modular Service Engine (`deploy-stack add`) — `db:redis`, `queue:sqs` & `ai:bedrock`
2
+
3
+ ## Overview
4
+ Extend the modular service engine with three new capabilities:
5
+ 1. **`db:redis`**: AWS ElastiCache for Valkey 8.0 (`cache.t4g.micro`) isolated inside the VPC with security group ingress restricted to ECS tasks.
6
+ 2. **`queue:sqs`**: Managed Amazon SQS standard queue with long polling, paired with a Dead-Letter Queue (DLQ, `maxReceiveCount = 3`), least-privilege Task Role IAM permissions, and conditional scale-to-zero worker auto-scaling.
7
+ 3. **`ai:bedrock`**: Least-privilege Amazon Bedrock runtime IAM permissions (`InvokeModel` and `InvokeModelWithResponseStream` across foundation models and cross-region inference profiles) attached to the ECS Task Role with a configurable `--model` flag.
8
+
9
+ ---
10
+
11
+ ## Part 1: Registry Contracts, CLI Flags & Container Env Wiring
12
+
13
+ ### 1. Preserve the Existing Split Between `src/utils/addons.js` and `src/commands/add.js`
14
+ * **`ADDON_REGISTRY` in `src/utils/addons.js`:** Keep the exact existing schema (`file`, `template`, `label`, `cost: { model, monthlyFixed, summary }`). Do not add `envVars` to `src/utils/addons.js`.
15
+ * **`db:redis`**:
16
+ * `file` and `template`: `redis.tf`
17
+ * `label`: `ElastiCache Valkey 8.0`
18
+ * `cost`: `model` set to `fixed-baseline`, `monthlyFixed` set to `9.49` (using `$0.013/hr` / `$9.49/mo` as the canonical `us-east-2` reference rate across code, tests, and docs), and `summary` describing the `~$9.49/mo` fixed baseline (`$0.013/hr` Valkey 8.0 `cache.t4g.micro` in `us-east-2`) with `$0` intra-AZ VPC transfer.
19
+ * **`queue:sqs`**:
20
+ * `file` and `template`: `sqs.tf`
21
+ * `label`: `SQS + DLQ`
22
+ * `cost`: `model` set to `usage-based`, `monthlyFixed` set to `0`, and `summary` noting `$0/mo` fixed baseline with the first 1M requests/mo free and `$0.40` per million requests thereafter.
23
+ * **`ai:bedrock`**:
24
+ * `file` and `template`: `bedrock.tf`
25
+ * `label`: `Bedrock Runtime IAM`
26
+ * `cost`: `model` set to `usage-based`, `monthlyFixed` set to `0`, and `summary` noting `$0/mo` fixed baseline billed per 1K input/output tokens on `InvokeModel` calls.
27
+ * **`ADDON_ENV_VARS` in `src/commands/add.js`:** Keep environment variable definitions in `ADDON_ENV_VARS` in `src/commands/add.js`:
28
+ * `db:redis`: injects `REDIS_URL` pointing to `redis://<cluster_node_0_address>:6379`.
29
+ * `queue:sqs`: injects `SQS_QUEUE_URL` (`aws_sqs_queue.main.id`) and `SQS_DLQ_URL` (`aws_sqs_queue.dlq.id`).
30
+ * `ai:bedrock`: injects `BEDROCK_MODEL_ID` populated with the resolved model ID.
31
+
32
+ ### 2. `runAdd(options = {})` Signature & `--model` Flag Handling (`src/commands/add.js`, `bin/cli.js`)
33
+ * Preserve the existing single-object signature `runAdd(options = {})` where `capability` is read from `options`.
34
+ * Export `DEFAULT_BEDROCK_MODEL` set to `us.anthropic.claude-sonnet-4-20250514-v1:0`.
35
+ * Default `model` to `DEFAULT_BEDROCK_MODEL` in both `parseAddArgs` and `runAdd` (matching the exact `partitionKey` pattern).
36
+ * Silently ignore `--model` when `capability` is not `ai:bedrock` (matching `--partition-key`).
37
+ * Validate `model` only when `capability === 'ai:bedrock'`, positioned immediately after the `UNSUPPORTED_CAPABILITY` check and before `TERRAFORM_NOT_INITIALIZED`:
38
+ * Allow only alphanumeric characters, underscores, dots, colons, and hyphens.
39
+ * On validation failure, match the exact failure convention of `INVALID_PARTITION_KEY`: log the error, track `add_run` with `projectName`, `capability`, `success: false`, and `error_code: 'INVALID_MODEL_ID'`, flush telemetry, call `process.exit(1)`, and return `{ ok: false, reason: 'invalid_model_id' }`.
40
+ * Update `HELP_TEXT` in `bin/cli.js` to include the new capabilities and `--model <id>`.
41
+
42
+ ### 3. Parameterized Container Env Injection for `main.tf` and `worker.tf` (`src/commands/add.js`)
43
+ * Update `injectContainerEnvVars` so it is not hardcoded strictly to the `"app"` task definition resource name—either accept a target task definition resource name parameter (`"app"` vs `"worker"`) or match whichever `aws_ecs_task_definition` block is present in the file.
44
+ * Apply `injectContainerEnvVars` to `terraform/main.tf` as today, and if `terraform/worker.tf` exists in the project's `terraform` directory, also apply `injectContainerEnvVars` to `terraform/worker.tf` (for all capabilities in `runAdd`, including reruns of `storage:s3` and `db:dynamodb`) so worker containers receive the same addon environment variables as the web container.
45
+
46
+ ---
47
+
48
+ ## Part 2: Capability 1 — `db:redis` (`templates/terraform/addons/redis.tf` -> `terraform/redis.tf`)
49
+
50
+ 1. **Networking & Security Group (Match `templates/terraform/network.tf`):**
51
+ * Define `aws_elasticache_subnet_group.redis` named `${local.app_name}-redis-subnets` referencing `aws_subnet.public[*].id`.
52
+ * Define `aws_security_group.redis` named `${local.app_name}-redis-sg` in `aws_vpc.main.id`:
53
+ * Ingress: TCP port `6379` restricted strictly to `aws_security_group.ecs_tasks.id`.
54
+ * Egress: standard all-traffic egress with the `trivy:ignore:AVD-AWS-0104` annotation to match `network.tf`.
55
+ 2. **Collision-Free 20-Character Cluster ID (`aws_elasticache_cluster.redis`):**
56
+ * AWS ElastiCache `cluster_id` allows at most 20 characters, must start with a letter, and cannot end with a hyphen. Pure prefix truncation cuts off the `-pr-N` suffix on project names >= 11 characters and collides across workspaces.
57
+ * Construct `cluster_id` deterministically within 20 characters by combining:
58
+ * A leading `ds-` prefix (3 chars, guaranteeing a leading ASCII letter),
59
+ * Up to 5 characters of the sanitized lowercase `local.app_name` (trimmed of trailing hyphens),
60
+ * A hyphen plus a 5-character substring of `md5(local.app_name)` (6 chars total, guaranteeing uniqueness between `default` and PR workspaces regardless of project name length),
61
+ * The `-redis` suffix (6 chars, totaling at most 20 characters).
62
+ * Configure `engine = "valkey"`, `engine_version = "8.0"`, `node_type = "cache.t4g.micro"`, `num_cache_nodes = 1`, `port = 6379`, `subnet_group_name`, and `security_group_ids`.
63
+ 3. **Outputs:**
64
+ * Output `redis_endpoint` (`cache_nodes[0].address`) and `redis_port` (`port`).
65
+
66
+ ---
67
+
68
+ ## Part 3: Capability 2 — `queue:sqs` (`templates/terraform/addons/sqs.tf` -> `terraform/sqs.tf`)
69
+
70
+ 1. **Queues & Redrive Policy:**
71
+ * `aws_sqs_queue.dlq`: named `${local.app_name}-dlq`, 14-day retention (`1209600` seconds), `sqs_managed_sse_enabled = true`.
72
+ * `aws_sqs_queue.main`: named `${local.app_name}-queue`, 30s visibility timeout, 4-day retention (`345600` seconds), 20s long polling (`receive_wait_time_seconds = 20`), `sqs_managed_sse_enabled = true`, and `redrive_policy` targeting `aws_sqs_queue.dlq.arn` with `maxReceiveCount = 3`.
73
+ 2. **Task Role IAM Policy (`aws_iam_role_policy.queue_sqs_access`):**
74
+ * Named `${local.app_name}-queue-sqs-access` attached to `aws_iam_role.task_role.id`.
75
+ * Grant `sqs:SendMessage`, `sqs:ReceiveMessage`, `sqs:DeleteMessage`, `sqs:ChangeMessageVisibility`, `sqs:GetQueueAttributes`, and `sqs:GetQueueUrl` on both `aws_sqs_queue.main.arn` and `aws_sqs_queue.dlq.arn`.
76
+ 3. **Worker Queue-Depth Auto-Scaling (`{{WORKER_AUTOSCALING_BLOCK}}`):**
77
+ * Detect whether `worker.tf` exists in the project's `terraform` directory during `runAdd` and pass a boolean flag into `renderAddonTemplate`. When `worker.tf` exists, render the auto-scaling resources as active HCL; when absent, render the same block commented out with `# ` per line.
78
+ * **Auto-Scaling Resources (Scale-Out + Scale-In):**
79
+ * `aws_appautoscaling_target.worker_scale`: `service_namespace = "ecs"`, `scalable_dimension = "ecs:service:DesiredCount"`, `resource_id = "service/${aws_ecs_cluster.main.name}/${aws_ecs_service.worker.name}"`, `min_capacity = 0`, `max_capacity = 5`. (Note: AWS Application Auto Scaling automatically creates the `AWSServiceRoleForApplicationAutoScaling_ECSService` service-linked role upon target registration.)
80
+ * **Scale-Out Policy & Alarm:** `aws_appautoscaling_policy.worker_scale_out` (`policy_type = "StepScaling"`, `adjustment_type = "ChangeInCapacity"`, `cooldown = 60`, step adjustment `+1` with `metric_interval_lower_bound = 0`) triggered by `aws_cloudwatch_metric_alarm.sqs_queue_high` (`AWS/SQS` `ApproximateNumberOfMessagesVisible`, `QueueName = aws_sqs_queue.main.name`, `statistic = "Average"`, `period = 60`, `evaluation_periods = 1`, `comparison_operator = "GreaterThanOrEqualToThreshold"`, `threshold = 1`).
81
+ * **Scale-In Policy & Alarm (Scale back to zero):** `aws_appautoscaling_policy.worker_scale_in` (`policy_type = "StepScaling"`, `adjustment_type = "ChangeInCapacity"`, `cooldown = 300`, step adjustment `-5` with `metric_interval_upper_bound = 0`) triggered by `aws_cloudwatch_metric_alarm.sqs_queue_empty` (`AWS/SQS` `ApproximateNumberOfMessagesVisible`, `QueueName = aws_sqs_queue.main.name`, `statistic = "Maximum"`, `period = 60`, `evaluation_periods = 5`, `comparison_operator = "LessThanOrEqualToThreshold"`, `threshold = 0`).
82
+ * **Worker `desired_count` Drift Prevention:** Add `lifecycle { ignore_changes = [desired_count] }` to `aws_ecs_service.worker` in `templates/terraform/worker.tf` (and update the `worker.tf` snapshot in `tests/generator.test.js`). Additionally, when `runAdd` runs `queue:sqs` on an existing project where `terraform/worker.tf` is present but lacks `ignore_changes`, idempotently inject `lifecycle { ignore_changes = [desired_count] }` into `aws_ecs_service.worker` in `terraform/worker.tf`.
83
+ 4. **Outputs:**
84
+ * Output `sqs_queue_url`, `sqs_queue_arn`, and `sqs_dlq_url`.
85
+
86
+ ---
87
+
88
+ ## Part 4: Capability 3 — `ai:bedrock` (`templates/terraform/addons/bedrock.tf` -> `terraform/bedrock.tf`)
89
+
90
+ 1. **Task Role IAM Policy (`aws_iam_role_policy.ai_bedrock_access`):**
91
+ * Named `${local.app_name}-ai-bedrock-access` attached to `aws_iam_role.task_role.id`.
92
+ * Grant `bedrock:InvokeModel` and `bedrock:InvokeModelWithResponseStream` on both `arn:aws:bedrock:*::foundation-model/*` and `arn:aws:bedrock:*:*:inference-profile/*`.
93
+ 2. **Template Placeholder & Output:**
94
+ * Replace `{{BEDROCK_MODEL_ID}}` in `renderAddonTemplate` with the resolved model ID and expose `output "bedrock_model_id"`.
95
+
96
+ ---
97
+
98
+ ## Part 5: Visualizer, Viewport Budget & Doc Sync (`src/utils/visualizer.js`)
99
+
100
+ 1. **Preserve `estimateMonthlyCost(config)` Contract:**
101
+ * Keep the existing return object shape unchanged, summing `monthlyFixed` from active addons into the monthly total and deriving hourly cost as `total / 730`.
102
+ 2. **Deterministic Viewport Compaction in `renderDryRunPreview(config)` (<= 14 Lines, <= 90 Columns):**
103
+ * **Addon Tree Lines:**
104
+ * When `1` or `2` addons are active, keep the existing per-addon tree line format (including the puzzle emoji and ANSI styling) so existing 2-addon tests continue to pass unchanged.
105
+ * When `3` or more addons are active, collapse them into a single tree line displaying the puzzle emoji and `[Addons (<count>): <comma-separated capability keys>]` (e.g., `[Addons (5): storage:s3, db:dynamodb, db:redis, queue:sqs, ai:bedrock]`, which is 80 visible characters and never requires truncation).
106
+ * **Fixed Baseline Line (`<= 90` visible characters):**
107
+ * When fixed-cost addons (`monthlyFixed > 0`) are active, include `Addons: $<sum>` in `costParts`.
108
+ * If the resulting `Est. Fixed Baseline:` line exceeds 90 visible characters (which happens when Fargate, ALB, RDS, Secrets, and Addons are all present simultaneously), switch the parenthetical breakdown to a compact form (e.g., omitting colons or folding minor items like `Secrets: $0.40` into the total) so the line stays strictly `<= 90` visible characters.
109
+ * **Usage-Based Summary Line Suppression:**
110
+ * Filter active addons to those with `(addon.cost?.monthlyFixed || 0) === 0`.
111
+ * Only render the `+ Usage-based (<N> addon/addons): $0/mo fixed ...` line when that filtered count is strictly `> 0`. When only `db:redis` is active (zero usage-based addons), omit the usage-based line completely.
112
+ 3. **Keep `### Active Addons (Usage-Based)` Heading in `syncDocCostEstimate`:**
113
+ * Retain the existing heading string in `syncDocCostEstimate` for test compatibility.
114
+
115
+ ---
116
+
117
+ ## Part 6: Docs, Snapshots & Unit Tests
118
+
119
+ 1. **Snapshots (`tests/generator.test.js`):**
120
+ * Run `npx vitest run -u` to update the `Heroku_Procfile_Migration - worker.tf` snapshot after adding `lifecycle { ignore_changes = [desired_count] }` to `templates/terraform/worker.tf`.
121
+ 2. **Documentation & Roadmap (`apps/docs/`, `README.md`):**
122
+ * Update `apps/docs/src/content/docs/cli/add.md`, `apps/docs/src/content/docs/guides/ephemeral-pr-previews.md` (noting the `$9.49/mo` / `$0.013/hr` per-PR Valkey cluster cost when `db:redis` is used), `README.md`, and `apps/docs/src/content/docs/roadmap.md`.
123
+ * Include a note in `cli/add.md` reminding users to enable model access in the AWS Bedrock console across backing `us.*` inference profile regions.
124
+ 3. **Unit Tests (`tests/add.test.js` & `tests/visualizer.test.js`):**
125
+ * Cover `db:redis` (including MD5-suffixed 20-char `cluster_id` uniqueness across workspaces and fixed cost impact), `queue:sqs` (both with and without `worker.tf`, verifying active vs. commented auto-scaling and `worker.tf` env var + `ignore_changes` injection), and `ai:bedrock` (default model, custom `--model`, `INVALID_MODEL_ID` exit/return behavior, and ignoring `--model` on other capabilities).
126
+ * In `tests/visualizer.test.js`, test:
127
+ * Only `db:redis` active (verifying `+ Usage-based` line is suppressed when 0 usage-based addons are present).
128
+ * Maximal configuration (`hasDb: true`, `hasWorker: true`, `hasSecrets: true`, and all 5 addons active) asserting total box height `<= 14` lines and every line `<= 90` visible characters.