@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,1111 @@
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 { intro, outro, select, text, spinner, log, cancel, isCancel } from '@clack/prompts';
7
+ import { trackEvent, flushTelemetry, trackSuccess, isActiveEnvValue } from '../core/telemetry.js';
8
+ import { resolveRegion, resolveProjectName, resolveCwd, readFileSafe, detectComputeTargetFromMainTf } from '../utils/resolvers.js';
9
+ import { ADDON_REGISTRY, ADDON_UPSERT_KEYS, resolveAddonEnvVars } from '../utils/addons.js';
10
+ import { normalizeDomain, isValidDomain, normalizeZoneId, isValidFromEmail, parseDomainTf } from '../utils/domains.js';
11
+ import { parseFlags, normalizeOptions, normalizeArgv } from '../utils/args.js';
12
+ import { failCommand, failProjectNotInitialized } from '../utils/command.js';
13
+ import { findArrayBounds, findMapBounds, findNestedBlock, findResourceBlock, enclosingBraceBounds } from '../utils/hcl.js';
14
+ import { syncDocCostEstimate } from '../utils/visualizer.js';
15
+ import {
16
+ FALLBACK_BEDROCK_MODEL,
17
+ GENERIC_MODEL_HINT,
18
+ findCatalogEntry,
19
+ loadBedrockCatalog,
20
+ normalizeProviderName,
21
+ refreshBedrockCatalog,
22
+ resolveModelIdForRegion,
23
+ } from '../utils/bedrock-catalog.js';
24
+
25
+ export { ADDON_REGISTRY };
26
+
27
+ const __filename = fileURLToPath(import.meta.url);
28
+ const __dirname = path.dirname(__filename);
29
+
30
+ export const DEFAULT_PARTITION_KEY = 'id';
31
+ export const PARTITION_KEY_RE = /^[a-zA-Z0-9_.-]+$/;
32
+ export const DEFAULT_BEDROCK_MODEL = loadBedrockCatalog().defaultModelId || FALLBACK_BEDROCK_MODEL;
33
+ export const MODEL_ID_RE = /^[a-zA-Z0-9_.:-]+$/;
34
+ export const CUSTOM_MODEL_VALUE = '__custom__';
35
+
36
+ // EventBridge Scheduler expressions (`cron(...)`, `rate(...)`, `at(...)`)
37
+ // plus safe IANA timezone characters. Single schedule per project: `--name`
38
+ // customizes it, `--force` replaces it in place.
39
+ export const CRON_SCHEDULE_RE = /^(cron|rate|at)\(.+\)$/;
40
+ export const CRON_TIMEZONE_RE = /^[A-Za-z0-9/_+-]{1,64}$/;
41
+ export const CRON_CUSTOM_VALUE = '__custom__';
42
+ export const DEFAULT_CRON_SCHEDULE = 'cron(0 0 * * ? *)';
43
+ export const DEFAULT_CRON_COMMAND = 'npm run cron';
44
+ export const DEFAULT_CRON_NAME = 'daily-job';
45
+ export const MAX_SCHEDULE_NAME_LENGTH = 64;
46
+
47
+ // Normalizes a `--name` job slug to lowercase `[a-z0-9-]` (runs of other
48
+ // characters collapse to one hyphen, edges trimmed, capped at 32 chars so
49
+ // `${app}-cron-${name}` stays within the 64-char Scheduler limit).
50
+ export function sanitizeCronName(raw) {
51
+ return String(raw ?? '')
52
+ .trim()
53
+ .toLowerCase()
54
+ .replace(/[^a-z0-9-]+/g, '-')
55
+ .replace(/^-+|-+$/g, '')
56
+ .slice(0, 32);
57
+ }
58
+
59
+ const TEMPLATES_DIR = path.join(__dirname, '../../templates/terraform/addons');
60
+
61
+ export function parseAddArgs(argv = []) {
62
+ const args = normalizeArgv(argv);
63
+ if (args[0] === 'add') args.shift();
64
+ const { options: parsed, rest } = parseFlags(args, {
65
+ string: ['region', 'project-name', 'partition-key', 'model', 'domain', 'from-email', 'zone-id', 'schedule', { name: 'cmd', key: 'cronCommand' }, { name: 'cron-command', key: 'cronCommand' }, 'name', 'timezone'],
66
+ boolean: ['force', { name: 'headless', key: 'isHeadless' }],
67
+ bareBoolean: ['list-models', 'refresh'],
68
+ });
69
+ const options = {
70
+ partitionKey: DEFAULT_PARTITION_KEY,
71
+ model: DEFAULT_BEDROCK_MODEL,
72
+ modelProvided: false,
73
+ listModels: false,
74
+ refresh: false,
75
+ isHeadless: false,
76
+ force: false,
77
+ ...parsed,
78
+ };
79
+ if (parsed.model !== undefined) options.modelProvided = true;
80
+ for (const arg of rest) {
81
+ if (typeof arg === 'string' && !arg.startsWith('-') && options.capability === undefined) {
82
+ options.capability = arg;
83
+ }
84
+ }
85
+ return options;
86
+ }
87
+
88
+ // Locates the first `environment = [...]` array of the primary container in
89
+ // `aws_ecs_task_definition."<taskDefinitionName>"`. Returns the bracket
90
+ // bounds or null when the resource or array cannot be found.
91
+ function findEnvBlockBounds(content, taskDefinitionName) {
92
+ const resource = findResourceBlock(content, 'aws_ecs_task_definition', taskDefinitionName);
93
+ if (!resource) return null;
94
+ const block = content.slice(resource.openIdx, resource.closeIdx + 1);
95
+ const array = findArrayBounds(block, 'environment');
96
+ if (!array) return null;
97
+ return { openIdx: resource.openIdx + array.openIdx, closeIdx: resource.openIdx + array.closeIdx };
98
+ }
99
+
100
+ function envNameExists(block, name) {
101
+ if (!/^[A-Za-z0-9_]+$/.test(name)) return false;
102
+ const exists = new RegExp(`"name"\\s*[:=]\\s*"${name}"|\\bname\\s*=\\s*"${name}"`);
103
+ return exists.test(block);
104
+ }
105
+
106
+ function escapeRegExp(raw) {
107
+ return String(raw).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
108
+ }
109
+
110
+ // Renders an env `value` for HCL: a lone "${...}" interpolation becomes a
111
+ // bare reference (tflint terraform_deprecated_interpolation), while literal
112
+ // strings and multi-part interpolations ("https://${...}") stay quoted.
113
+ function renderEnvValue(value) {
114
+ const single = /^\$\{([^{}]+)\}$/.exec(String(value));
115
+ return single ? single[1] : `"${value}"`;
116
+ }
117
+
118
+ // Replaces the `value` of the `{ name = "<name>", value = ... }` object
119
+ // inside an environment block, in either HCL or JSON spelling and with a
120
+ // quoted or bare current value. Returns the block unchanged when the entry
121
+ // is missing or already holds newValue.
122
+ function upsertEnvValueInBlock(block, name, newValue) {
123
+ const namePattern = new RegExp(`(?:"name"|name)\\s*[:=]\\s*"${escapeRegExp(name)}"`);
124
+ const nameMatch = namePattern.exec(block);
125
+ if (!nameMatch) return block;
126
+ const bounds = enclosingBraceBounds(block, nameMatch.index);
127
+ if (!bounds) return block;
128
+ const objText = block.slice(bounds.openIdx, bounds.closeIdx + 1);
129
+ const rendered = renderEnvValue(newValue);
130
+ const valuePattern = /((?:"value"|value)\s*[:=]\s*)("[^"]*"|[^\s,}]+)/;
131
+ const valueMatch = objText.match(valuePattern);
132
+ if (!valueMatch || valueMatch[2] === rendered) return block;
133
+ const replacement = objText.replace(valuePattern, () => `${valueMatch[1]}${rendered}`);
134
+ return block.slice(0, bounds.openIdx) + replacement + block.slice(bounds.closeIdx + 1);
135
+ }
136
+
137
+ // Inserts { name, value } entries into the first `environment = [...]` array
138
+ // of the primary container in `aws_ecs_task_definition."<taskDefinitionName>"`
139
+ // (`"app"` for main.tf, `"worker"` for worker.tf), skipping keys that already
140
+ // exist so reruns (e.g. with --force) stay idempotent — except keys listed in
141
+ // `options.upsertKeys`, whose values are replaced in place (Day-2 switching
142
+ // for `BEDROCK_MODEL_ID`, migration refreshes like `REDIS_URL`). Returns the
143
+ // content unchanged when the resource or array cannot be found.
144
+ export function injectContainerEnvVars(tfContent, envEntries = [], taskDefinitionName = 'app', options = {}) {
145
+ if (typeof taskDefinitionName === 'object' && taskDefinitionName !== null) {
146
+ options = taskDefinitionName;
147
+ taskDefinitionName = options.taskDefinitionName || 'app';
148
+ }
149
+ if (!Array.isArray(envEntries) || envEntries.length === 0) return tfContent;
150
+ const content = String(tfContent ?? '');
151
+ const upsertKeys = new Set(options.upsertKeys || []);
152
+
153
+ const bounds = findEnvBlockBounds(content, taskDefinitionName);
154
+ if (!bounds) return content;
155
+ const { openIdx, closeIdx } = bounds;
156
+
157
+ const block = content.slice(openIdx, closeIdx + 1);
158
+ const missing = envEntries.filter(({ name }) => !envNameExists(block, name));
159
+ let updated = content;
160
+ if (missing.length > 0) {
161
+ const inner = content.slice(openIdx + 1, closeIdx);
162
+ const glue = inner.trim() === '' ? '\n ' : ',\n ';
163
+ const insertion = missing
164
+ .map(({ name, value }) => `{ name = "${name}", value = ${renderEnvValue(value)} }`)
165
+ .join(',\n ');
166
+ // Strip a dangling comma so files rendered with one (e.g. worker.tf
167
+ // without a database, or hand-edited arrays) never produce `, ,`.
168
+ const prefix = content.slice(0, closeIdx).replace(/,\s*$/, '');
169
+ updated = `${prefix}${glue}${insertion}\n ${content.slice(closeIdx)}`;
170
+ }
171
+
172
+ const upserts = envEntries.filter(({ name }) => upsertKeys.has(name));
173
+ if (upserts.length === 0) return updated;
174
+ const upsertBounds = findEnvBlockBounds(updated, taskDefinitionName);
175
+ if (!upsertBounds) return updated;
176
+ let upsertBlock = updated.slice(upsertBounds.openIdx, upsertBounds.closeIdx + 1);
177
+ for (const { name, value } of upserts) {
178
+ upsertBlock = upsertEnvValueInBlock(upsertBlock, name, value);
179
+ }
180
+ return updated.slice(0, upsertBounds.openIdx) + upsertBlock + updated.slice(upsertBounds.closeIdx + 1);
181
+ }
182
+
183
+ // Locates the `variables = {...}` map inside the `environment` block of
184
+ // `aws_lambda_function."app"`. Returns the brace bounds or null when the
185
+ // resource, block, or map cannot be found.
186
+ function findLambdaVariablesBounds(content) {
187
+ const resource = findResourceBlock(content, 'aws_lambda_function', 'app');
188
+ if (!resource) return null;
189
+ const resourceBlock = content.slice(resource.openIdx, resource.closeIdx + 1);
190
+ const environment = findNestedBlock(resourceBlock, 'environment');
191
+ if (!environment) return null;
192
+ const environmentBlock = resourceBlock.slice(environment.openIdx, environment.closeIdx + 1);
193
+ const variables = findMapBounds(environmentBlock, 'variables');
194
+ if (!variables) return null;
195
+ const base = resource.openIdx + environment.openIdx;
196
+ return { openIdx: base + variables.openIdx, closeIdx: base + variables.closeIdx };
197
+ }
198
+
199
+ function lambdaVarExists(mapBlock, name) {
200
+ if (!/^[A-Za-z0-9_]+$/.test(name)) return false;
201
+ return new RegExp(`(?:^|[^A-Za-z0-9_])${name}\\s*=`).test(mapBlock);
202
+ }
203
+
204
+ // Replaces the value of the `NAME = ...` entry inside a Lambda variables
205
+ // map, with a quoted or bare current value. Returns the block unchanged
206
+ // when the entry is missing or already holds newValue.
207
+ function upsertLambdaVarInBlock(mapBlock, name, newValue) {
208
+ const pattern = new RegExp(`((?:^|[^A-Za-z0-9_])${escapeRegExp(name)}\\s*=\\s*)("[^"]*"|[^\\s}]+)`, 'm');
209
+ const match = mapBlock.match(pattern);
210
+ if (!match) return mapBlock;
211
+ const rendered = renderEnvValue(newValue);
212
+ if (match[2] === rendered) return mapBlock;
213
+ return mapBlock.replace(pattern, () => `${match[1]}${rendered}`);
214
+ }
215
+
216
+ // Inserts { name, value } entries into the `environment { variables = {...} }`
217
+ // map of `aws_lambda_function."app"`, skipping keys that already exist so
218
+ // reruns stay idempotent — except keys listed in `options.upsertKeys`, whose
219
+ // values are replaced in place (mirroring injectContainerEnvVars). Returns
220
+ // the content unchanged when the resource or map cannot be found.
221
+ export function injectLambdaEnvVars(tfContent, envEntries = [], options = {}) {
222
+ if (!Array.isArray(envEntries) || envEntries.length === 0) return tfContent;
223
+ const content = String(tfContent ?? '');
224
+ const upsertKeys = new Set(options.upsertKeys || []);
225
+
226
+ const bounds = findLambdaVariablesBounds(content);
227
+ if (!bounds) return content;
228
+ const { openIdx, closeIdx } = bounds;
229
+
230
+ const block = content.slice(openIdx, closeIdx + 1);
231
+ const missing = envEntries.filter(({ name }) => !lambdaVarExists(block, name));
232
+ let updated = content;
233
+ if (missing.length > 0) {
234
+ const prefix = content.slice(0, closeIdx).replace(/[ \t]+$/, '');
235
+ const insertion = missing
236
+ .map(({ name, value }) => ` ${name} = ${renderEnvValue(value)}`)
237
+ .join('\n');
238
+ updated = `${prefix.endsWith('\n') ? prefix : `${prefix}\n`}${insertion}\n ${content.slice(closeIdx)}`;
239
+ }
240
+
241
+ const upserts = envEntries.filter(({ name }) => upsertKeys.has(name));
242
+ if (upserts.length === 0) return updated;
243
+ const upsertBounds = findLambdaVariablesBounds(updated);
244
+ if (!upsertBounds) return updated;
245
+ let upsertBlock = updated.slice(upsertBounds.openIdx, upsertBounds.closeIdx + 1);
246
+ for (const { name, value } of upserts) {
247
+ upsertBlock = upsertLambdaVarInBlock(upsertBlock, name, value);
248
+ }
249
+ return updated.slice(0, upsertBounds.openIdx) + upsertBlock + updated.slice(upsertBounds.closeIdx + 1);
250
+ }
251
+
252
+ // `vpc_config` attaching the Lambda function to the VPC subnets so it can
253
+ // reach RDS and ElastiCache without a NAT gateway.
254
+ export const LAMBDA_VPC_CONFIG_BLOCK = ` vpc_config {
255
+ subnet_ids = aws_subnet.public[*].id
256
+ security_group_ids = [aws_security_group.ecs_tasks.id]
257
+ }`;
258
+
259
+ // Idempotently attaches `vpc_config` to `aws_lambda_function."app"` (used
260
+ // when `add db:redis` runs on a Lambda project generated without a
261
+ // database). Returns the content unchanged for ECS projects or when the
262
+ // function is already VPC-attached.
263
+ export function ensureLambdaVpcConfig(mainTfContent) {
264
+ const content = String(mainTfContent ?? '');
265
+ const resource = findResourceBlock(content, 'aws_lambda_function', 'app');
266
+ if (!resource) return content;
267
+ const block = content.slice(resource.openIdx, resource.closeIdx + 1);
268
+ const uncommented = block.split('\n').map((line) => {
269
+ const hash = line.indexOf('#');
270
+ return hash === -1 ? line : line.slice(0, hash);
271
+ }).join('\n');
272
+ if (/vpc_config\s*\{/.test(uncommented)) return content;
273
+ const prefix = content.slice(0, resource.closeIdx);
274
+ const glue = prefix.endsWith('\n') ? '' : '\n';
275
+ return `${prefix}${glue}${LAMBDA_VPC_CONFIG_BLOCK}\n${content.slice(resource.closeIdx)}`;
276
+ }
277
+
278
+ // Queue-depth auto-scaling for the dedicated ECS worker service. Rendered
279
+ // active when terraform/worker.tf exists, otherwise commented out so users
280
+ // can uncomment it after adding a worker. Application Auto Scaling creates
281
+ // the AWSServiceRoleForApplicationAutoScaling_ECSService service-linked
282
+ // role automatically upon target registration.
283
+ export const WORKER_AUTOSCALING_HCL = `# --- SQS Queue-Depth Auto-Scaling (worker service only) ---
284
+ # Scales aws_ecs_service.worker between 0 and 5 tasks. Never attach
285
+ # min_capacity = 0 scaling to aws_ecs_service.app (it serves HTTP traffic).
286
+ resource "aws_appautoscaling_target" "worker_scale" {
287
+ service_namespace = "ecs"
288
+ scalable_dimension = "ecs:service:DesiredCount"
289
+ resource_id = "service/\${aws_ecs_cluster.main.name}/\${aws_ecs_service.worker.name}"
290
+ min_capacity = 0
291
+ max_capacity = 5
292
+ }
293
+
294
+ resource "aws_appautoscaling_policy" "worker_scale_out" {
295
+ name = "\${local.app_name}-worker-scale-out"
296
+ service_namespace = aws_appautoscaling_target.worker_scale.service_namespace
297
+ scalable_dimension = aws_appautoscaling_target.worker_scale.scalable_dimension
298
+ resource_id = aws_appautoscaling_target.worker_scale.resource_id
299
+ policy_type = "StepScaling"
300
+
301
+ step_scaling_policy_configuration {
302
+ adjustment_type = "ChangeInCapacity"
303
+ cooldown = 60
304
+
305
+ step_adjustment {
306
+ metric_interval_lower_bound = 0
307
+ scaling_adjustment = 1
308
+ }
309
+ }
310
+ }
311
+
312
+ resource "aws_cloudwatch_metric_alarm" "sqs_queue_high" {
313
+ alarm_name = "\${local.app_name}-sqs-queue-high"
314
+ comparison_operator = "GreaterThanOrEqualToThreshold"
315
+ evaluation_periods = 1
316
+ metric_name = "ApproximateNumberOfMessagesVisible"
317
+ namespace = "AWS/SQS"
318
+ period = 60
319
+ statistic = "Average"
320
+ threshold = 1
321
+
322
+ dimensions = {
323
+ QueueName = aws_sqs_queue.main.name
324
+ }
325
+
326
+ alarm_actions = [aws_appautoscaling_policy.worker_scale_out.arn]
327
+ }
328
+
329
+ resource "aws_appautoscaling_policy" "worker_scale_in" {
330
+ name = "\${local.app_name}-worker-scale-in"
331
+ service_namespace = aws_appautoscaling_target.worker_scale.service_namespace
332
+ scalable_dimension = aws_appautoscaling_target.worker_scale.scalable_dimension
333
+ resource_id = aws_appautoscaling_target.worker_scale.resource_id
334
+ policy_type = "StepScaling"
335
+
336
+ step_scaling_policy_configuration {
337
+ adjustment_type = "ChangeInCapacity"
338
+ cooldown = 300
339
+
340
+ step_adjustment {
341
+ metric_interval_upper_bound = 0
342
+ scaling_adjustment = -5
343
+ }
344
+ }
345
+ }
346
+
347
+ resource "aws_cloudwatch_metric_alarm" "sqs_queue_empty" {
348
+ alarm_name = "\${local.app_name}-sqs-queue-empty"
349
+ comparison_operator = "LessThanOrEqualToThreshold"
350
+ evaluation_periods = 5
351
+ metric_name = "ApproximateNumberOfMessagesVisible"
352
+ namespace = "AWS/SQS"
353
+ period = 60
354
+ statistic = "Maximum"
355
+ threshold = 0
356
+
357
+ dimensions = {
358
+ QueueName = aws_sqs_queue.main.name
359
+ }
360
+
361
+ alarm_actions = [aws_appautoscaling_policy.worker_scale_in.arn]
362
+ }`;
363
+
364
+ export function renderWorkerAutoscalingBlock(hasWorker) {
365
+ if (hasWorker) return WORKER_AUTOSCALING_HCL;
366
+ return WORKER_AUTOSCALING_HCL.split('\n')
367
+ .map((line) => (line === '' ? '#' : `# ${line}`))
368
+ .join('\n');
369
+ }
370
+
371
+ // Renders an addon template by substituting `{{PLACEHOLDER}}` variables
372
+ // and named conditional blocks. Placeholders absent from a template are
373
+ // no-ops, so callers pass the full maps unconditionally.
374
+ function renderAddonTemplate(templateContent, templateVars = {}, conditionalBlocks = {}) {
375
+ let rendered = String(templateContent ?? '');
376
+ for (const [key, value] of Object.entries(templateVars)) {
377
+ rendered = rendered.replaceAll(`{{${key}}}`, value ?? '');
378
+ }
379
+ for (const [key, value] of Object.entries(conditionalBlocks)) {
380
+ rendered = rendered.replaceAll(`{{${key}}}`, value ?? '');
381
+ }
382
+ return rendered;
383
+ }
384
+
385
+ // Renders the optional Route 53 verification/DKIM/SPF/DMARC records for
386
+ // `add email:ses --zone-id`. Returns '' in external-DNS mode (records
387
+ // are then created by hand from the `ses_*` outputs).
388
+ export function renderSesRoute53RecordsBlock({ domain, zoneId, region }) {
389
+ if (!domain || !zoneId || !region) return '';
390
+ return `# --- Route 53 DNS Records for SES (auto-managed) ---
391
+ # Scoped to the default workspace: these FQDNs are singletons and must
392
+ # not be duplicated (or destroyed) by PR preview workspaces.
393
+ resource "aws_route53_record" "ses_verification" {
394
+ count = terraform.workspace == "default" ? 1 : 0
395
+ zone_id = "${zoneId}"
396
+ name = "_amazonses.${domain}"
397
+ type = "TXT"
398
+ ttl = 600
399
+ records = [aws_ses_domain_identity.ses[0].verification_token]
400
+ }
401
+
402
+ resource "aws_route53_record" "ses_dkim" {
403
+ count = terraform.workspace == "default" ? 3 : 0
404
+ zone_id = "${zoneId}"
405
+ name = "\${aws_ses_domain_dkim.ses[0].dkim_tokens[count.index]}._domainkey.${domain}"
406
+ type = "CNAME"
407
+ ttl = 600
408
+ records = ["\${aws_ses_domain_dkim.ses[0].dkim_tokens[count.index]}.dkim.amazonses.com"]
409
+ }
410
+
411
+ resource "aws_route53_record" "ses_mail_from_mx" {
412
+ count = terraform.workspace == "default" ? 1 : 0
413
+ zone_id = "${zoneId}"
414
+ name = "mail.${domain}"
415
+ type = "MX"
416
+ ttl = 600
417
+ records = ["10 feedback-smtp.${region}.amazonses.com"]
418
+ }
419
+
420
+ resource "aws_route53_record" "ses_mail_from_spf" {
421
+ count = terraform.workspace == "default" ? 1 : 0
422
+ zone_id = "${zoneId}"
423
+ name = "mail.${domain}"
424
+ type = "TXT"
425
+ ttl = 600
426
+ records = ["v=spf1 include:amazonses.com ~all"]
427
+ }
428
+
429
+ resource "aws_route53_record" "ses_dmarc" {
430
+ count = terraform.workspace == "default" ? 1 : 0
431
+ zone_id = "${zoneId}"
432
+ name = "_dmarc.${domain}"
433
+ type = "TXT"
434
+ ttl = 600
435
+ records = ["v=DMARC1; p=none;"]
436
+ }
437
+ `;
438
+ }
439
+
440
+ // Idempotently adds `lifecycle { ignore_changes = [desired_count] }` to
441
+ // `aws_ecs_service.worker` so queue-depth auto-scaling does not cause
442
+ // Terraform drift on existing projects. Returns the content unchanged when
443
+ // the resource is missing or already ignores changes.
444
+ export function ensureWorkerDesiredCountLifecycle(workerTfContent) {
445
+ const content = String(workerTfContent ?? '');
446
+ const resource = findResourceBlock(content, 'aws_ecs_service', 'worker');
447
+ if (!resource) return content;
448
+ const { openIdx, closeIdx } = resource;
449
+
450
+ const block = content.slice(openIdx, closeIdx + 1);
451
+ if (block.includes('ignore_changes')) return content;
452
+
453
+ const insertion = '\n lifecycle {\n ignore_changes = [desired_count]\n }\n';
454
+ return `${content.slice(0, closeIdx)}${insertion}${content.slice(closeIdx)}`;
455
+ }
456
+
457
+ async function promptBedrockProvider(catalog) {
458
+ const providerOptions = catalog.providers.map((group) => ({
459
+ value: group.provider,
460
+ label: group.provider,
461
+ hint: group.models.map((entry) => entry.name).slice(0, 3).join(', '),
462
+ }));
463
+ providerOptions.push({ value: CUSTOM_MODEL_VALUE, label: 'Custom model ID...' });
464
+ return select({ message: 'Choose a Bedrock provider:', options: providerOptions });
465
+ }
466
+
467
+ async function promptBedrockModel(group) {
468
+ const modelOptions = group.models.map((entry) => ({
469
+ value: entry.id,
470
+ label: `${entry.name} (${entry.id})`,
471
+ hint: entry.hint,
472
+ }));
473
+ modelOptions.push({ value: CUSTOM_MODEL_VALUE, label: 'Custom model ID...' });
474
+ return select({ message: `Choose a ${group.provider} model:`, options: modelOptions });
475
+ }
476
+
477
+ async function promptCustomModelId() {
478
+ return text({ message: 'Enter a Bedrock model ID:', placeholder: DEFAULT_BEDROCK_MODEL });
479
+ }
480
+
481
+ async function promptCronSchedule() {
482
+ return select({
483
+ message: 'Choose a schedule:',
484
+ options: [
485
+ { value: 'rate(1 hour)', label: 'Hourly', hint: 'rate(1 hour)' },
486
+ { value: 'cron(0 0 * * ? *)', label: 'Daily at 00:00 UTC', hint: 'cron(0 0 * * ? *)' },
487
+ { value: 'rate(15 minutes)', label: 'Every 15 minutes', hint: 'rate(15 minutes)' },
488
+ { value: CRON_CUSTOM_VALUE, label: 'Custom expression...' },
489
+ ],
490
+ });
491
+ }
492
+
493
+ async function promptCustomSchedule() {
494
+ return text({ message: 'Enter a schedule expression:', placeholder: 'cron(0 2 * * ? *)' });
495
+ }
496
+
497
+ async function promptCronCommand() {
498
+ return text({ message: 'Command to run inside the container:', placeholder: DEFAULT_CRON_COMMAND });
499
+ }
500
+
501
+ // An explicitly passed option: anything but undefined/null/blank-string.
502
+ // Non-string values count as explicit so they fail validation instead of
503
+ // silently falling through to auto-detection.
504
+ function explicitStringOption(options, key) {
505
+ const raw = options[key];
506
+ if (raw === undefined || raw === null) return undefined;
507
+ if (typeof raw === 'string' && raw.trim() === '') return undefined;
508
+ return raw;
509
+ }
510
+
511
+ // Pre-guard flag validation shared by every capability: explicit flags
512
+ // fail fast with structured errors before the terraform/overwrite guards
513
+ // (matching the historical `--model` / `--partition-key` order).
514
+ // Returns null when valid, or `{ errorCode, reason, message, hint }`.
515
+ export function validateAddonFlags(capability, options = {}) {
516
+ const opts = normalizeOptions(options);
517
+ if (capability === 'db:dynamodb') {
518
+ const partitionKey = opts.partitionKey ?? DEFAULT_PARTITION_KEY;
519
+ if (!PARTITION_KEY_RE.test(String(partitionKey))) {
520
+ return {
521
+ errorCode: 'INVALID_PARTITION_KEY',
522
+ reason: 'invalid-partition-key',
523
+ message: `\n✖ Invalid partition key "${partitionKey}".`,
524
+ hint: ' Use only letters, numbers, underscore, hyphen, and dot (e.g. --partition-key userId).\n',
525
+ };
526
+ }
527
+ }
528
+ if (capability === 'ai:bedrock') {
529
+ const model = opts.model ?? DEFAULT_BEDROCK_MODEL;
530
+ if (!MODEL_ID_RE.test(String(model))) {
531
+ return {
532
+ errorCode: 'INVALID_MODEL_ID',
533
+ reason: 'invalid-model-id',
534
+ message: `\n✖ Invalid model ID "${model}".`,
535
+ hint: ` Use only letters, numbers, underscore, dot, colon, and hyphen (e.g. --model ${DEFAULT_BEDROCK_MODEL}).\n`,
536
+ };
537
+ }
538
+ }
539
+ if (capability === 'cron') {
540
+ const schedule = explicitStringOption(opts, 'schedule');
541
+ if (schedule !== undefined && !CRON_SCHEDULE_RE.test(String(schedule).trim())) {
542
+ return {
543
+ errorCode: 'INVALID_CRON_SCHEDULE',
544
+ reason: 'invalid-cron-schedule',
545
+ message: `\n✖ Invalid schedule expression "${schedule}".`,
546
+ hint: ' Use an EventBridge Scheduler expression like "cron(0 2 * * ? *)" or "rate(1 hour)".\n',
547
+ };
548
+ }
549
+ const timezone = explicitStringOption(opts, 'timezone');
550
+ if (timezone !== undefined && !CRON_TIMEZONE_RE.test(String(timezone).trim())) {
551
+ return {
552
+ errorCode: 'INVALID_TIMEZONE',
553
+ reason: 'invalid-timezone',
554
+ message: `\n✖ Invalid timezone "${timezone}".`,
555
+ hint: ' Use an IANA timezone like UTC or America/New_York.\n',
556
+ };
557
+ }
558
+ const name = explicitStringOption(opts, 'name');
559
+ if (name !== undefined && sanitizeCronName(name) === '') {
560
+ return {
561
+ errorCode: 'INVALID_CRON_NAME',
562
+ reason: 'invalid-cron-name',
563
+ message: `\n✖ Invalid job name "${name}".`,
564
+ hint: ' Use a lowercase slug with letters, numbers, and hyphens (e.g. --name nightly-cleanup).\n',
565
+ };
566
+ }
567
+ }
568
+ if (capability === 'email:ses') {
569
+ const domain = explicitStringOption(opts, 'domain');
570
+ if (domain !== undefined && !isValidDomain(domain)) {
571
+ return {
572
+ errorCode: 'INVALID_DOMAIN',
573
+ reason: 'invalid-domain',
574
+ message: `\n✖ Invalid domain "${domain}".`,
575
+ hint: ' Use a fully qualified domain name (e.g. --domain example.com).\n',
576
+ };
577
+ }
578
+ const zoneId = explicitStringOption(opts, 'zoneId');
579
+ if (zoneId !== undefined && normalizeZoneId(zoneId) === null) {
580
+ return {
581
+ errorCode: 'INVALID_ZONE_ID',
582
+ reason: 'invalid-zone-id',
583
+ message: `\n✖ Invalid Route 53 zone ID "${zoneId}".`,
584
+ hint: ' Zone IDs look like Z1234567890ABC (find yours with: aws route53 list-hosted-zones).\n',
585
+ };
586
+ }
587
+ const fromEmail = explicitStringOption(opts, 'fromEmail');
588
+ if (fromEmail !== undefined && domain !== undefined && !isValidFromEmail(fromEmail, domain)) {
589
+ return {
590
+ errorCode: 'INVALID_FROM_EMAIL',
591
+ reason: 'invalid-from-email',
592
+ message: `\n✖ Invalid --from-email "${fromEmail}".`,
593
+ hint: ` Use an address on your SES domain (e.g. noreply@${normalizeDomain(domain)}).\n`,
594
+ };
595
+ }
596
+ }
597
+ return null;
598
+ }
599
+
600
+ function invalidModelFailure(model) {
601
+ return {
602
+ errorCode: 'INVALID_MODEL_ID',
603
+ reason: 'invalid-model-id',
604
+ message: `\n✖ Invalid model ID "${model}".`,
605
+ hint: ` Use only letters, numbers, underscore, dot, colon, and hyphen (e.g. --model ${DEFAULT_BEDROCK_MODEL}).\n`,
606
+ };
607
+ }
608
+
609
+ // Post-guard option resolution per capability: interactive prompts,
610
+ // auto-detection, and template/env assembly. `ctx` carries
611
+ // `{ cwd, region, isInteractive, heldCatalog }`. Returns
612
+ // `{ ok: true, templateVars, conditionalBlocks, envVars, upsertKeys, meta }`,
613
+ // `{ ok: false, cancelled: true }`, or a structured validation failure.
614
+ export async function resolveAddonOptions(capability, options = {}, ctx = {}) {
615
+ const opts = normalizeOptions(options);
616
+ const context = normalizeOptions(ctx);
617
+ const cwd = context.cwd || process.cwd();
618
+ const region = context.region || resolveRegion(opts, cwd);
619
+ const isInteractive = context.isInteractive === true;
620
+ const heldCatalog = context.heldCatalog || null;
621
+ const upsertKeys = [...(ADDON_UPSERT_KEYS[capability] || [])];
622
+
623
+ const flagError = validateAddonFlags(capability, opts);
624
+ if (flagError) return { ok: false, ...flagError };
625
+
626
+ if (capability === 'db:dynamodb') {
627
+ return {
628
+ ok: true,
629
+ templateVars: { REGION: region, PARTITION_KEY: opts.partitionKey ?? DEFAULT_PARTITION_KEY },
630
+ conditionalBlocks: {},
631
+ envVars: resolveAddonEnvVars(capability, { region }),
632
+ upsertKeys,
633
+ meta: {},
634
+ };
635
+ }
636
+
637
+ if (capability === 'ai:bedrock') {
638
+ const explicitModel = opts.modelProvided === true || (opts.model !== undefined && opts.modelProvided !== false);
639
+ let model = opts.model ?? DEFAULT_BEDROCK_MODEL;
640
+ let selectedInteractively = false;
641
+ let customTypedModel = false;
642
+ if (isInteractive && !explicitModel) {
643
+ const catalog = heldCatalog || loadBedrockCatalog({ cachePath: opts.cachePath });
644
+ const providerChoice = await promptBedrockProvider(catalog);
645
+ if (isCancel(providerChoice)) return { ok: false, cancelled: true };
646
+ if (providerChoice === CUSTOM_MODEL_VALUE) {
647
+ const custom = await promptCustomModelId();
648
+ if (isCancel(custom)) return { ok: false, cancelled: true };
649
+ model = String(custom).trim();
650
+ if (!MODEL_ID_RE.test(model)) return { ok: false, ...invalidModelFailure(model) };
651
+ customTypedModel = true;
652
+ } else {
653
+ const group = catalog.providers.find((candidate) => candidate.provider === providerChoice);
654
+ const modelChoice = await promptBedrockModel(group);
655
+ if (isCancel(modelChoice)) return { ok: false, cancelled: true };
656
+ if (modelChoice === CUSTOM_MODEL_VALUE) {
657
+ const custom = await promptCustomModelId();
658
+ if (isCancel(custom)) return { ok: false, cancelled: true };
659
+ model = String(custom).trim();
660
+ if (!MODEL_ID_RE.test(model)) return { ok: false, ...invalidModelFailure(model) };
661
+ customTypedModel = true;
662
+ } else {
663
+ model = modelChoice;
664
+ }
665
+ }
666
+ selectedInteractively = true;
667
+ }
668
+ if (!explicitModel && !customTypedModel) {
669
+ // Catalog-derived IDs (default or picker choice) align to the
670
+ // project's region; user-typed IDs stay verbatim.
671
+ const catalog = heldCatalog || loadBedrockCatalog({ cachePath: opts.cachePath });
672
+ model = resolveModelIdForRegion(findCatalogEntry(catalog, model) || model, region);
673
+ }
674
+ return {
675
+ ok: true,
676
+ templateVars: { REGION: region, BEDROCK_MODEL_ID: model },
677
+ conditionalBlocks: {},
678
+ envVars: resolveAddonEnvVars(capability, { region, model }),
679
+ upsertKeys,
680
+ meta: { model, explicitModel, selectedInteractively },
681
+ };
682
+ }
683
+
684
+ if (capability === 'email:ses') {
685
+ const explicitDomain = explicitStringOption(opts, 'domain');
686
+ const explicitZoneId = explicitStringOption(opts, 'zoneId');
687
+ const explicitFromEmail = explicitStringOption(opts, 'fromEmail');
688
+ let domain = explicitDomain !== undefined ? normalizeDomain(explicitDomain) : null;
689
+ let zoneId = null;
690
+ if (explicitZoneId !== undefined) {
691
+ zoneId = normalizeZoneId(explicitZoneId);
692
+ if (zoneId === null) {
693
+ return {
694
+ ok: false,
695
+ errorCode: 'INVALID_ZONE_ID',
696
+ reason: 'invalid-zone-id',
697
+ message: `\n✖ Invalid Route 53 zone ID "${explicitZoneId}".`,
698
+ hint: ' Zone IDs look like Z1234567890ABC (find yours with: aws route53 list-hosted-zones).\n',
699
+ };
700
+ }
701
+ }
702
+ if (domain === null) {
703
+ const domainTf = readFileSafe(path.join(cwd, 'terraform', 'domain.tf'));
704
+ if (domainTf) {
705
+ const parsed = parseDomainTf(domainTf);
706
+ if (parsed && parsed.domain) {
707
+ domain = parsed.domain;
708
+ if (zoneId === null && parsed.zoneId) zoneId = parsed.zoneId;
709
+ }
710
+ }
711
+ }
712
+ if (domain === null) {
713
+ if (!isInteractive) {
714
+ return {
715
+ ok: false,
716
+ errorCode: 'MISSING_SES_DOMAIN',
717
+ reason: 'missing-ses-domain',
718
+ message: '\n✖ No domain for Amazon SES. Pass --domain <domain> or run from a project with terraform/domain.tf.\n',
719
+ hint: null,
720
+ };
721
+ }
722
+ const answer = await text({ message: 'Enter the domain for Amazon SES (e.g. example.com):' });
723
+ if (isCancel(answer)) return { ok: false, cancelled: true };
724
+ if (typeof answer !== 'string' || !answer.trim()) {
725
+ return {
726
+ ok: false,
727
+ errorCode: 'MISSING_SES_DOMAIN',
728
+ reason: 'missing-ses-domain',
729
+ message: '\n✖ No domain for Amazon SES. Pass --domain <domain> or run from a project with terraform/domain.tf.\n',
730
+ hint: null,
731
+ };
732
+ }
733
+ domain = normalizeDomain(answer);
734
+ }
735
+ if (!isValidDomain(domain)) {
736
+ return {
737
+ ok: false,
738
+ errorCode: 'INVALID_DOMAIN',
739
+ reason: 'invalid-domain',
740
+ message: `\n✖ Invalid domain "${domain}".`,
741
+ hint: ' Use a fully qualified domain name (e.g. --domain example.com).\n',
742
+ };
743
+ }
744
+ if (domain.startsWith('*.')) {
745
+ return {
746
+ ok: false,
747
+ errorCode: 'INVALID_DOMAIN',
748
+ reason: 'invalid-domain',
749
+ message: `\n✖ Invalid domain "${domain}" for Amazon SES: SES domain identities do not support wildcards.`,
750
+ hint: ' Use the apex domain (e.g. --domain example.com).\n',
751
+ };
752
+ }
753
+ const fromEmail = explicitFromEmail !== undefined ? String(explicitFromEmail).trim() : `noreply@${domain}`;
754
+ if (!isValidFromEmail(fromEmail, domain)) {
755
+ return {
756
+ ok: false,
757
+ errorCode: 'INVALID_FROM_EMAIL',
758
+ reason: 'invalid-from-email',
759
+ message: `\n✖ Invalid --from-email "${explicitFromEmail !== undefined ? explicitFromEmail : fromEmail}".`,
760
+ hint: ` Use an address on your SES domain (e.g. noreply@${domain}).\n`,
761
+ };
762
+ }
763
+ return {
764
+ ok: true,
765
+ templateVars: { REGION: region, SES_DOMAIN: domain, SES_FROM_EMAIL: fromEmail },
766
+ conditionalBlocks: {
767
+ SES_ROUTE53_RECORDS_BLOCK: zoneId ? renderSesRoute53RecordsBlock({ domain, zoneId, region }) : '',
768
+ },
769
+ envVars: resolveAddonEnvVars(capability, { region, sesFromEmail: fromEmail }),
770
+ upsertKeys,
771
+ meta: { domain, zoneId, fromEmail },
772
+ };
773
+ }
774
+
775
+ if (capability === 'cron') {
776
+ let schedule = explicitStringOption(opts, 'schedule');
777
+ if (typeof schedule === 'string') schedule = schedule.trim();
778
+ let cronCommand = explicitStringOption(opts, 'cronCommand');
779
+ if (typeof cronCommand === 'string') cronCommand = cronCommand.trim();
780
+ const explicitName = explicitStringOption(opts, 'name');
781
+ const name = explicitName === undefined ? DEFAULT_CRON_NAME : sanitizeCronName(explicitName);
782
+ let timezone = explicitStringOption(opts, 'timezone');
783
+ timezone = typeof timezone === 'string' ? timezone.trim() : 'UTC';
784
+
785
+ if (!schedule && isInteractive) {
786
+ const choice = await promptCronSchedule();
787
+ if (isCancel(choice)) return { ok: false, cancelled: true };
788
+ if (choice === CRON_CUSTOM_VALUE) {
789
+ const custom = await promptCustomSchedule();
790
+ if (isCancel(custom)) return { ok: false, cancelled: true };
791
+ schedule = String(custom).trim();
792
+ } else {
793
+ schedule = choice;
794
+ }
795
+ }
796
+ schedule = schedule || DEFAULT_CRON_SCHEDULE;
797
+ if (!CRON_SCHEDULE_RE.test(schedule)) {
798
+ return {
799
+ ok: false,
800
+ errorCode: 'INVALID_CRON_SCHEDULE',
801
+ reason: 'invalid-cron-schedule',
802
+ message: `\n✖ Invalid schedule expression "${schedule}".`,
803
+ hint: ' Use an EventBridge Scheduler expression like "cron(0 2 * * ? *)" or "rate(1 hour)".\n',
804
+ };
805
+ }
806
+
807
+ if (!cronCommand && isInteractive) {
808
+ const answer = await promptCronCommand();
809
+ if (isCancel(answer)) return { ok: false, cancelled: true };
810
+ cronCommand = String(answer).trim();
811
+ }
812
+ cronCommand = cronCommand || DEFAULT_CRON_COMMAND;
813
+
814
+ if (!CRON_TIMEZONE_RE.test(timezone)) {
815
+ return {
816
+ ok: false,
817
+ errorCode: 'INVALID_TIMEZONE',
818
+ reason: 'invalid-timezone',
819
+ message: `\n✖ Invalid timezone "${timezone}".`,
820
+ hint: ' Use an IANA timezone like UTC or America/New_York.\n',
821
+ };
822
+ }
823
+ if (!name) {
824
+ return {
825
+ ok: false,
826
+ errorCode: 'INVALID_CRON_NAME',
827
+ reason: 'invalid-cron-name',
828
+ message: '\n✖ Invalid job name: it sanitizes to an empty slug.',
829
+ hint: ' Use a lowercase slug with letters, numbers, and hyphens (e.g. --name nightly-cleanup).\n',
830
+ };
831
+ }
832
+ const scheduleName = `${resolveProjectName(opts, cwd)}-cron-${name}`;
833
+ if (scheduleName.length > MAX_SCHEDULE_NAME_LENGTH) {
834
+ return {
835
+ ok: false,
836
+ errorCode: 'INVALID_CRON_NAME',
837
+ reason: 'invalid-cron-name',
838
+ message: `\n✖ Schedule name "${scheduleName}" exceeds the ${MAX_SCHEDULE_NAME_LENGTH}-character EventBridge Scheduler limit.`,
839
+ hint: ' Use a shorter --name (PR-preview workspaces add a suffix on top).\n',
840
+ };
841
+ }
842
+
843
+ return {
844
+ ok: true,
845
+ templateVars: {
846
+ REGION: region,
847
+ CRON_NAME: name,
848
+ SCHEDULE_EXPRESSION: schedule,
849
+ SCHEDULE_TIMEZONE: timezone,
850
+ CRON_COMMAND_JSON: JSON.stringify(cronCommand),
851
+ },
852
+ conditionalBlocks: {},
853
+ envVars: resolveAddonEnvVars(capability, { region }),
854
+ upsertKeys,
855
+ meta: { schedule, cronCommand, name, timezone },
856
+ };
857
+ }
858
+
859
+ return {
860
+ ok: true,
861
+ templateVars: { REGION: region },
862
+ conditionalBlocks: {},
863
+ envVars: resolveAddonEnvVars(capability, { region }),
864
+ upsertKeys,
865
+ meta: {},
866
+ };
867
+ }
868
+
869
+ // Renders the model catalog as scannable provider sections: one header line
870
+ // per provider plus a `• id — hint` bullet per model. Returns one block per
871
+ // provider (callers log each block once, keeping bullets contiguous instead
872
+ // of separated by logger chrome). Groups that normalize to the same provider
873
+ // (live data spells some two ways: writer/Writer, moonshotai/Moonshot AI)
874
+ // merge into a single section.
875
+ export function formatCatalogListing(catalog) {
876
+ const grouped = new Map();
877
+ for (const group of catalog?.providers || []) {
878
+ const name = normalizeProviderName(group?.provider);
879
+ if (!grouped.has(name)) grouped.set(name, []);
880
+ grouped.get(name).push(...(group?.models || []));
881
+ }
882
+ const blocks = [];
883
+ for (const [name, models] of grouped) {
884
+ const lines = [`${color.bold(name)} (${models.length}):`];
885
+ for (const model of models) {
886
+ const scopeTag = Array.isArray(model.scopes) && model.scopes.length > 1
887
+ ? ` [${model.scopes.join(', ')}]`
888
+ : '';
889
+ lines.push(` ${color.cyan(`• ${model.id}`)}${scopeTag} — ${color.dim(model.hint || GENERIC_MODEL_HINT)}`);
890
+ }
891
+ blocks.push(lines.join('\n'));
892
+ }
893
+ return blocks;
894
+ }
895
+
896
+ export async function runAdd(input = {}) {
897
+ const options = normalizeOptions(input);
898
+ let cwd;
899
+ let projectName;
900
+ try {
901
+ cwd = resolveCwd(options);
902
+ projectName = resolveProjectName(options, cwd);
903
+ } catch {
904
+ return failProjectNotInitialized({ event: 'add_run' });
905
+ }
906
+ const capability = typeof options.capability === 'string' ? options.capability.trim() : '';
907
+ const force = options.force === true || options.force === 'true';
908
+ const addon = ADDON_REGISTRY[capability];
909
+
910
+ intro(color.bgCyan(color.black(' grada add 🧩 ')));
911
+
912
+ if (!addon) {
913
+ return failCommand({
914
+ print: () => {
915
+ console.log(color.red(`\n✖ Unknown capability "${capability || 'none'}".`));
916
+ console.log(` Supported capabilities: ${color.cyan(Object.keys(ADDON_REGISTRY).join(', '))}\n`);
917
+ },
918
+ event: 'add_run',
919
+ telemetry: { capability: capability || 'none', error_code: 'UNSUPPORTED_CAPABILITY' },
920
+ reason: 'unsupported-capability',
921
+ resultExtra: { capability: capability || 'none' },
922
+ });
923
+ }
924
+
925
+ const cancelSelection = () => failCommand({
926
+ print: () => cancel(capability === 'email:ses' ? 'SES setup cancelled.' : 'Model selection cancelled.'),
927
+ event: 'add_run',
928
+ telemetry: { projectName, capability, reason: 'cancelled' },
929
+ reason: 'cancelled',
930
+ exitCode: null,
931
+ });
932
+
933
+ const failResolved = (failure) => failCommand({
934
+ message: failure.message,
935
+ hint: failure.hint ?? null,
936
+ event: 'add_run',
937
+ telemetry: { projectName, capability, error_code: failure.errorCode },
938
+ reason: failure.reason,
939
+ resultExtra: { capability, projectName },
940
+ });
941
+
942
+ // Explicit flags fail fast here, before the terraform/overwrite guards.
943
+ const flagError = validateAddonFlags(capability, options);
944
+ if (flagError) return failResolved(flagError);
945
+
946
+ // --refresh fetches live models before listing or provisioning.
947
+ let heldCatalog = null;
948
+ if (capability === 'ai:bedrock' && options.refresh) {
949
+ const s = spinner();
950
+ s.start('Refreshing Bedrock model catalog from AWS...');
951
+ heldCatalog = await refreshBedrockCatalog({
952
+ bedrockClient: options.bedrockClient,
953
+ cachePath: options.cachePath,
954
+ region: options.region,
955
+ });
956
+ s.stop('Bedrock model catalog refreshed.');
957
+ }
958
+
959
+ // --list-models works anywhere, even outside a Terraform project.
960
+ if (capability === 'ai:bedrock' && options.listModels) {
961
+ const catalog = heldCatalog || loadBedrockCatalog({ cachePath: options.cachePath });
962
+ for (const block of formatCatalogListing(catalog)) {
963
+ log.info(block);
964
+ }
965
+ outro(`Bedrock catalog ready — ${catalog.providers.length} providers (updated ${catalog.updatedAt}).`);
966
+ await trackSuccess('add_run', { projectName, capability, action: 'list_models' });
967
+ return { ok: true, action: 'list-models', models: catalog.providers };
968
+ }
969
+
970
+ const mainTfPath = path.join(cwd, 'terraform', 'main.tf');
971
+ if (!fsSync.existsSync(mainTfPath)) {
972
+ return failCommand({
973
+ message: '\n✖ No terraform/main.tf found. Run "grada" first before adding services.\n',
974
+ event: 'add_run',
975
+ telemetry: { capability, error_code: 'TERRAFORM_NOT_INITIALIZED' },
976
+ reason: 'terraform-not-initialized',
977
+ resultExtra: { capability },
978
+ });
979
+ }
980
+
981
+ // Interactive selection runs only on real TTYs without explicit
982
+ // options, so headless runs, CI, and unit tests never block on prompts.
983
+ const isInteractive = options.interactive ?? (
984
+ !options.isHeadless &&
985
+ !isActiveEnvValue(process.env.CI) &&
986
+ !isActiveEnvValue(process.env.VITEST) &&
987
+ process.env.NODE_ENV !== 'test' &&
988
+ Boolean(process.stdout?.isTTY)
989
+ );
990
+
991
+ const region = resolveRegion(options, cwd);
992
+ const resolved = await resolveAddonOptions(capability, options, { cwd, region, isInteractive, heldCatalog });
993
+ if (!resolved.ok && resolved.cancelled) return cancelSelection();
994
+ if (!resolved.ok) return failResolved(resolved);
995
+
996
+ const targetPath = path.join(cwd, 'terraform', addon.file);
997
+ if (fsSync.existsSync(targetPath) && !force) {
998
+ const canSwitchBedrock = capability === 'ai:bedrock' && (resolved.meta.explicitModel || resolved.meta.selectedInteractively);
999
+ if (!canSwitchBedrock) {
1000
+ return failCommand({
1001
+ message: `\n⚠ terraform/${addon.file} already exists. Pass --force to overwrite.\n`,
1002
+ tone: 'yellow',
1003
+ event: 'add_run',
1004
+ telemetry: { projectName, capability, error_code: 'ADDON_ALREADY_EXISTS' },
1005
+ reason: 'addon-already-exists',
1006
+ resultExtra: { capability, projectName, region },
1007
+ exitCode: null,
1008
+ });
1009
+ }
1010
+ }
1011
+
1012
+ const scaffolded = await scaffoldAddon(capability, resolved, { cwd, region });
1013
+ const envVars = scaffolded.envVars;
1014
+ const envInjected = scaffolded.envInjected;
1015
+
1016
+ console.log(color.green(`\n✅ Created terraform/${addon.file}${envInjected ? ' and injected container environment variables' : ''}.`));
1017
+ if (scaffolded.workerEnvInjected) {
1018
+ console.log(` ${color.dim('worker:')} injected container environment variables into terraform/worker.tf`);
1019
+ }
1020
+ if (scaffolded.vpcInjected) {
1021
+ console.log(` ${color.dim('vpc:')} attached vpc_config to the Lambda function so it can reach ElastiCache`);
1022
+ }
1023
+ for (const { name } of envVars) {
1024
+ console.log(` ${color.dim('env:')} ${color.cyan(name)}`);
1025
+ }
1026
+ console.log(color.yellow(`\n💰 Cost Impact: ${scaffolded.costImpact}`));
1027
+
1028
+ // Env-less addons (cron carries its command in the schedule input, not
1029
+ // in container environment) skip the variable list instead of printing
1030
+ // an empty "Available in your container as .".
1031
+ const envSuffix = envVars.length > 0
1032
+ ? ` Available in your container as ${envVars.map((e) => e.name).join(', ')}.`
1033
+ : '';
1034
+ outro(
1035
+ `Run ${color.green('grada apply')} (or commit and push to trigger CI) to provision ${capability}.${envSuffix}`
1036
+ );
1037
+ await trackSuccess('add_run', { projectName, capability });
1038
+ return { ok: true, capability, projectName, region, file: `terraform/${addon.file}`, envInjected };
1039
+ }
1040
+
1041
+ // Quiet addon scaffolding pipeline shared by `runAdd` and `mainStack`
1042
+ // (dependency-aware `init`): renders the addon `.tf` file, injects
1043
+ // container environment variables into `main.tf` and `worker.tf` (when
1044
+ // present), applies the `queue:sqs` worker lifecycle rule, and refreshes
1045
+ // the README cost estimate. Takes a `resolveAddonOptions` result as
1046
+ // `resolvedOpts`. Prints nothing, emits no telemetry, and never exits —
1047
+ // callers own banners, telemetry, and failure handling.
1048
+ export async function scaffoldAddon(capability, resolvedOpts, { cwd, region } = {}) {
1049
+ void region;
1050
+ const addon = ADDON_REGISTRY[capability];
1051
+ if (!addon) throw new Error(`Unknown addon capability: ${capability}`);
1052
+ const opts = normalizeOptions(resolvedOpts);
1053
+ const projectDir = cwd || process.cwd();
1054
+ const targetPath = path.join(projectDir, 'terraform', addon.file);
1055
+ const mainTfPath = path.join(projectDir, 'terraform', 'main.tf');
1056
+ const workerTfPath = path.join(projectDir, 'terraform', 'worker.tf');
1057
+ const hasWorker = fsSync.existsSync(workerTfPath);
1058
+
1059
+ const mainTfContent = await fs.readFile(mainTfPath, 'utf-8');
1060
+ const isLambda = detectComputeTargetFromMainTf(mainTfContent) === 'lambda';
1061
+ const templateName = capability === 'cron' && isLambda ? 'cron-lambda.tf' : addon.template;
1062
+ const templateRaw = fsSync.readFileSync(path.join(TEMPLATES_DIR, templateName), 'utf-8');
1063
+ const rendered = renderAddonTemplate(templateRaw, opts.templateVars || {}, {
1064
+ WORKER_AUTOSCALING_BLOCK: renderWorkerAutoscalingBlock(hasWorker === true),
1065
+ ...(opts.conditionalBlocks || {}),
1066
+ });
1067
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
1068
+ await fs.writeFile(targetPath, rendered);
1069
+
1070
+ const envVars = Array.isArray(opts.envVars) ? opts.envVars : [];
1071
+ const upsertKeys = Array.isArray(opts.upsertKeys) ? opts.upsertKeys : [];
1072
+ const injectOptions = upsertKeys.length > 0 ? { upsertKeys } : {};
1073
+ const updated = isLambda
1074
+ ? injectLambdaEnvVars(mainTfContent, envVars, injectOptions)
1075
+ : injectContainerEnvVars(mainTfContent, envVars, 'app', injectOptions);
1076
+ // ElastiCache lives inside the VPC: attach the function on first Redis
1077
+ // provisioning when the project was generated without a database.
1078
+ const withVpc = capability === 'db:redis' ? ensureLambdaVpcConfig(updated) : updated;
1079
+ const envInjected = withVpc !== mainTfContent;
1080
+ const vpcInjected = withVpc !== updated;
1081
+ if (envInjected) {
1082
+ await fs.writeFile(mainTfPath, withVpc);
1083
+ }
1084
+
1085
+ let workerEnvInjected = false;
1086
+ if (hasWorker) {
1087
+ const workerTfContent = await fs.readFile(workerTfPath, 'utf-8');
1088
+ const updatedWorker = injectContainerEnvVars(workerTfContent, envVars, 'worker', injectOptions);
1089
+ workerEnvInjected = updatedWorker !== workerTfContent;
1090
+ let finalWorker = updatedWorker;
1091
+ if (capability === 'queue:sqs') {
1092
+ finalWorker = ensureWorkerDesiredCountLifecycle(updatedWorker);
1093
+ }
1094
+ if (finalWorker !== workerTfContent) {
1095
+ await fs.writeFile(workerTfPath, finalWorker);
1096
+ }
1097
+ }
1098
+
1099
+ await syncDocCostEstimate(projectDir);
1100
+
1101
+ return {
1102
+ file: addon.file,
1103
+ envVars,
1104
+ costImpact: addon.cost.summary,
1105
+ envInjected,
1106
+ workerEnvInjected,
1107
+ vpcInjected,
1108
+ };
1109
+ }
1110
+
1111
+ export default runAdd;