grada-run 0.0.2 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (231) hide show
  1. package/.github/workflows/deploy-docs.yml +37 -0
  2. package/.github/workflows/iac-validation.yml +303 -0
  3. package/.github/workflows/publish.yml +68 -0
  4. package/.github/workflows/sync-bedrock-models.yml +57 -0
  5. package/.github/workflows/test.yml +43 -0
  6. package/.muserules +31 -0
  7. package/LICENSE +21 -0
  8. package/README.md +193 -3
  9. package/apps/docs/.astro/collections/docs.schema.json +644 -0
  10. package/apps/docs/.astro/content-assets.mjs +4 -0
  11. package/apps/docs/.astro/content-modules.mjs +4 -0
  12. package/apps/docs/.astro/content.d.ts +179 -0
  13. package/apps/docs/.astro/data-store.json +1 -0
  14. package/apps/docs/.astro/dev.json +14 -0
  15. package/apps/docs/.astro/settings.json +5 -0
  16. package/apps/docs/.astro/types.d.ts +2 -0
  17. package/apps/docs/astro.config.mjs +97 -0
  18. package/apps/docs/package.json +17 -0
  19. package/apps/docs/src/content/docs/adrs/0001-s3-native-state-locking.md +37 -0
  20. package/apps/docs/src/content/docs/adrs/0002-eject-mechanism-pure-iac.md +39 -0
  21. package/apps/docs/src/content/docs/adrs/0003-sync-ai-context-strategy.md +48 -0
  22. package/apps/docs/src/content/docs/adrs/0004-iac-driven-diagnostic-context.md +37 -0
  23. package/apps/docs/src/content/docs/adrs/0005-ecs-fargate-alb-runtime-target.md +38 -0
  24. package/apps/docs/src/content/docs/adrs/0006-github-oidc-no-stored-keys.md +37 -0
  25. package/apps/docs/src/content/docs/adrs/0007-framework-detection-with-fallback.md +37 -0
  26. package/apps/docs/src/content/docs/adrs/0008-secrets-names-in-git-values-in-aws.md +37 -0
  27. package/apps/docs/src/content/docs/adrs/0009-regenerate-with-backup-on-rerun.md +37 -0
  28. package/apps/docs/src/content/docs/adrs/0010-advisory-only-security-scans.md +37 -0
  29. package/apps/docs/src/content/docs/cli/add.md +84 -0
  30. package/apps/docs/src/content/docs/cli/apply.md +32 -0
  31. package/apps/docs/src/content/docs/cli/db.md +200 -0
  32. package/apps/docs/src/content/docs/cli/destroy.md +31 -0
  33. package/apps/docs/src/content/docs/cli/diagnose.md +37 -0
  34. package/apps/docs/src/content/docs/cli/doctor.md +28 -0
  35. package/apps/docs/src/content/docs/cli/domain.md +57 -0
  36. package/apps/docs/src/content/docs/cli/drift.md +40 -0
  37. package/apps/docs/src/content/docs/cli/eject.md +29 -0
  38. package/apps/docs/src/content/docs/cli/exec.md +49 -0
  39. package/apps/docs/src/content/docs/cli/gc.md +37 -0
  40. package/apps/docs/src/content/docs/cli/init.md +72 -0
  41. package/apps/docs/src/content/docs/cli/logs.md +39 -0
  42. package/apps/docs/src/content/docs/cli/rollback.md +51 -0
  43. package/apps/docs/src/content/docs/cli/secrets.md +73 -0
  44. package/apps/docs/src/content/docs/cli/sleep.md +53 -0
  45. package/apps/docs/src/content/docs/cli/status.md +34 -0
  46. package/apps/docs/src/content/docs/cli/sync-ai.md +27 -0
  47. package/apps/docs/src/content/docs/guides/architecture.md +87 -0
  48. package/apps/docs/src/content/docs/guides/aws-credentials.md +72 -0
  49. package/apps/docs/src/content/docs/guides/background-workers.md +45 -0
  50. package/apps/docs/src/content/docs/guides/cicd-pipeline.md +64 -0
  51. package/apps/docs/src/content/docs/guides/database-connections.md +64 -0
  52. package/apps/docs/src/content/docs/guides/docker-compose.md +37 -0
  53. package/apps/docs/src/content/docs/guides/dockerfiles.md +46 -0
  54. package/apps/docs/src/content/docs/guides/ephemeral-pr-previews.md +39 -0
  55. package/apps/docs/src/content/docs/guides/examples.md +50 -0
  56. package/apps/docs/src/content/docs/guides/frameworks.md +88 -0
  57. package/apps/docs/src/content/docs/guides/headless.md +75 -0
  58. package/apps/docs/src/content/docs/guides/quickstart.md +52 -0
  59. package/apps/docs/src/content/docs/guides/rerun-init.md +43 -0
  60. package/apps/docs/src/content/docs/guides/secrets-management.md +83 -0
  61. package/apps/docs/src/content/docs/guides/understanding-your-bill.md +63 -0
  62. package/apps/docs/src/content/docs/index.mdx +103 -0
  63. package/apps/docs/src/content/docs/migrations/astro-vercel-to-aws.md +55 -0
  64. package/apps/docs/src/content/docs/migrations/heroku-procfile-to-aws.md +41 -0
  65. package/apps/docs/src/content/docs/migrations/nextjs-vercel-to-aws.md +51 -0
  66. package/apps/docs/src/content/docs/migrations/sveltekit-vercel-to-aws.md +63 -0
  67. package/apps/docs/src/content/docs/roadmap.md +97 -0
  68. package/apps/docs/src/content/docs/testing-strategy.md +32 -0
  69. package/apps/docs/src/content.config.ts +7 -0
  70. package/apps/docs/src/custom.css +14 -0
  71. package/apps/docs/tsconfig.json +6 -0
  72. package/bin/cli.js +140 -0
  73. package/package.json +105 -7
  74. package/scripts/sync-bedrock-models.js +22 -0
  75. package/scripts/test-iac.js +261 -0
  76. package/specs/add-redis-sqs-bedrock.md +128 -0
  77. package/specs/add-storage-dynamodb.md +106 -0
  78. package/specs/bedrock-model-catalog.md +131 -0
  79. package/specs/ci-pipeline.md +17 -0
  80. package/specs/cost-transparency.md +115 -0
  81. package/specs/custom-domains-and-ses.md +153 -0
  82. package/specs/database-suite-expansion.md +151 -0
  83. package/specs/db-connect.md +69 -0
  84. package/specs/db-lifecycle-migrations.md +159 -0
  85. package/specs/dependency-aware-init.md +176 -0
  86. package/specs/deploy-stack-to-grada-run-rebrand.md +45 -0
  87. package/specs/deployment-safety.md +170 -0
  88. package/specs/diagnose.md +16 -0
  89. package/specs/docs-hub.md +16 -0
  90. package/specs/dx-polish.md +46 -0
  91. package/specs/exec.md +25 -0
  92. package/specs/finops-cron-drift.md +161 -0
  93. package/specs/gc.md +26 -0
  94. package/specs/integration-suite.md +16 -0
  95. package/specs/logs.md +32 -0
  96. package/specs/rollback-live-polling.md +40 -0
  97. package/specs/secrets-pull-audit.md +51 -0
  98. package/specs/serverless-lambda-target.md +133 -0
  99. package/specs/status.md +31 -0
  100. package/specs/telemetry-and-spawn-hardening.md +69 -0
  101. package/specs/telemetry-hardening.md +35 -0
  102. package/src/commands/add.js +1111 -0
  103. package/src/commands/apply.js +214 -0
  104. package/src/commands/db/backup.js +229 -0
  105. package/src/commands/db/connect.js +304 -0
  106. package/src/commands/db/enable-vector.js +344 -0
  107. package/src/commands/db/import.js +604 -0
  108. package/src/commands/db/migrate.js +477 -0
  109. package/src/commands/db/restore.js +361 -0
  110. package/src/commands/db.js +87 -0
  111. package/src/commands/destroy.js +217 -0
  112. package/src/commands/diagnose.js +460 -0
  113. package/src/commands/doctor.js +109 -0
  114. package/src/commands/domain.js +685 -0
  115. package/src/commands/drift.js +243 -0
  116. package/src/commands/eject.js +127 -0
  117. package/src/commands/exec.js +222 -0
  118. package/src/commands/gc.js +250 -0
  119. package/src/commands/init.js +649 -0
  120. package/src/commands/logs.js +256 -0
  121. package/src/commands/rollback.js +323 -0
  122. package/src/commands/secrets.js +485 -0
  123. package/src/commands/sleep.js +347 -0
  124. package/src/commands/status.js +309 -0
  125. package/src/commands/sync-ai.js +115 -0
  126. package/src/commands/wake.js +337 -0
  127. package/src/core/parser.js +126 -0
  128. package/src/core/telemetry.js +244 -0
  129. package/src/data/bedrock-models.json +896 -0
  130. package/src/utils/addons.js +126 -0
  131. package/src/utils/ai-rules.js +59 -0
  132. package/src/utils/args.js +91 -0
  133. package/src/utils/aws.js +178 -0
  134. package/src/utils/backup.js +69 -0
  135. package/src/utils/bedrock-catalog.js +511 -0
  136. package/src/utils/capabilities.js +500 -0
  137. package/src/utils/command.js +65 -0
  138. package/src/utils/db-tunnel.js +164 -0
  139. package/src/utils/detector.js +298 -0
  140. package/src/utils/dockerCompose.js +65 -0
  141. package/src/utils/domains.js +73 -0
  142. package/src/utils/ecs-runner.js +289 -0
  143. package/src/utils/ecs.js +92 -0
  144. package/src/utils/frameworks.js +55 -0
  145. package/src/utils/generator.js +527 -0
  146. package/src/utils/hcl.js +426 -0
  147. package/src/utils/lambda-ecr.js +185 -0
  148. package/src/utils/prompts.js +278 -0
  149. package/src/utils/rds.js +131 -0
  150. package/src/utils/resolvers.js +174 -0
  151. package/src/utils/sleep-state.js +140 -0
  152. package/src/utils/sleep-targets.js +139 -0
  153. package/src/utils/system.js +42 -0
  154. package/src/utils/terraform.js +70 -0
  155. package/src/utils/visualizer.js +381 -0
  156. package/src/utils/warnings.js +49 -0
  157. package/templates/README.md +150 -0
  158. package/templates/docker/django.Dockerfile +40 -0
  159. package/templates/docker/go.Dockerfile +23 -0
  160. package/templates/docker/nestjs.Dockerfile +33 -0
  161. package/templates/docker/nextjs.Dockerfile +55 -0
  162. package/templates/docker/node.Dockerfile +24 -0
  163. package/templates/docker/nuxt.Dockerfile +47 -0
  164. package/templates/docker/python.Dockerfile +38 -0
  165. package/templates/docker/rails.Dockerfile +59 -0
  166. package/templates/docker/static.Dockerfile +32 -0
  167. package/templates/docker/svelte.Dockerfile +52 -0
  168. package/templates/github/deploy-lambda.yml +120 -0
  169. package/templates/github/deploy.yml +138 -0
  170. package/templates/github/drift.yml +112 -0
  171. package/templates/github/preview-lambda.yml +86 -0
  172. package/templates/github/preview.yml +69 -0
  173. package/templates/github/teardown.yml +43 -0
  174. package/templates/terraform/addons/bedrock.tf +34 -0
  175. package/templates/terraform/addons/cron-lambda.tf +78 -0
  176. package/templates/terraform/addons/cron.tf +101 -0
  177. package/templates/terraform/addons/dynamodb.tf +73 -0
  178. package/templates/terraform/addons/redis.tf +64 -0
  179. package/templates/terraform/addons/s3.tf +143 -0
  180. package/templates/terraform/addons/ses.tf +73 -0
  181. package/templates/terraform/addons/sqs.tf +67 -0
  182. package/templates/terraform/backend.tf +22 -0
  183. package/templates/terraform/cloudfront-lambda.tf +80 -0
  184. package/templates/terraform/cloudfront.tf +80 -0
  185. package/templates/terraform/database-aurora-postgresql.tf +92 -0
  186. package/templates/terraform/database-mysql.tf +72 -0
  187. package/templates/terraform/database.tf +71 -0
  188. package/templates/terraform/main-lambda.tf +229 -0
  189. package/templates/terraform/main.tf +296 -0
  190. package/templates/terraform/network.tf +95 -0
  191. package/templates/terraform/oidc.tf +64 -0
  192. package/templates/terraform/secrets.tf +31 -0
  193. package/templates/terraform/worker.tf +69 -0
  194. package/tests/__snapshots__/generator.test.js.snap +9633 -0
  195. package/tests/add.test.js +2037 -0
  196. package/tests/ai.test.js +94 -0
  197. package/tests/apply.test.js +488 -0
  198. package/tests/args.test.js +86 -0
  199. package/tests/aws.test.js +244 -0
  200. package/tests/capabilities.test.js +307 -0
  201. package/tests/cli.test.js +29 -0
  202. package/tests/command.test.js +100 -0
  203. package/tests/commands-import.test.js +74 -0
  204. package/tests/db.test.js +2704 -0
  205. package/tests/destroy.test.js +391 -0
  206. package/tests/detector.test.js +79 -0
  207. package/tests/diagnose.test.js +779 -0
  208. package/tests/doctor.test.js +202 -0
  209. package/tests/domain.test.js +899 -0
  210. package/tests/drift.test.js +243 -0
  211. package/tests/ecs.test.js +130 -0
  212. package/tests/eject.test.js +65 -0
  213. package/tests/exec.test.js +380 -0
  214. package/tests/gc.test.js +496 -0
  215. package/tests/generator.test.js +794 -0
  216. package/tests/headless.test.js +562 -0
  217. package/tests/lambda-ecr.test.js +185 -0
  218. package/tests/logs.test.js +447 -0
  219. package/tests/parser.test.js +160 -0
  220. package/tests/rds.test.js +244 -0
  221. package/tests/resolvers.test.js +282 -0
  222. package/tests/rollback.test.js +692 -0
  223. package/tests/secrets.test.js +752 -0
  224. package/tests/sleep-wake.test.js +1016 -0
  225. package/tests/status.test.js +370 -0
  226. package/tests/system.test.js +70 -0
  227. package/tests/telemetry.test.js +520 -0
  228. package/tests/terraform.test.js +84 -0
  229. package/tests/visualizer.test.js +496 -0
  230. package/vitest.config.js +9 -0
  231. package/index.js +0 -2
@@ -0,0 +1,278 @@
1
+ import path from 'path';
2
+ import { text, select, multiselect, confirm, group, cancel } from '@clack/prompts';
3
+ import color from 'picocolors';
4
+ import { execSync } from 'child_process';
5
+
6
+ // Sanitizes a raw project name for AWS resource compatibility (ALB names
7
+ // allow `[a-zA-Z0-9-]` only; ECR repositories require lowercase): trim,
8
+ // lowercase, collapse every run of characters outside `[a-z0-9-]` —
9
+ // including dots and underscores — into a single hyphen, strip
10
+ // leading/trailing hyphens, and fall back to `'app'` when empty.
11
+ export function sanitizeProjectName(rawName) {
12
+ const sanitized = String(rawName ?? '')
13
+ .trim()
14
+ .toLowerCase()
15
+ .replace(/[^a-z0-9-]+/g, '-')
16
+ .replace(/^-+|-+$/g, '');
17
+ return sanitized || 'app';
18
+ }
19
+
20
+ function resolveActualProjectName(rawName) {
21
+ const actualProjectName = sanitizeProjectName(rawName);
22
+ if (actualProjectName !== String(rawName ?? '').trim()) {
23
+ console.log(color.dim(`Project name sanitized to "${actualProjectName}" for AWS resource compatibility.`));
24
+ }
25
+ return actualProjectName;
26
+ }
27
+
28
+ export async function getTargetDirectory(isHeadless, headlessOptions) {
29
+ if (isHeadless) {
30
+ const projectName = headlessOptions.dir || '.';
31
+ const rawName = projectName === '.' ? path.basename(process.cwd()) : projectName;
32
+ return {
33
+ projectName,
34
+ actualProjectName: resolveActualProjectName(rawName),
35
+ targetDir: projectName === '.' ? process.cwd() : path.join(process.cwd(), projectName)
36
+ };
37
+ }
38
+
39
+ const projectName = await text({
40
+ message: 'Where should we generate the infrastructure? (Type "." for current directory)',
41
+ placeholder: '.',
42
+ initialValue: '.',
43
+ validate: (value) => {
44
+ if (!value) return 'Please enter a name or directory.';
45
+ if (value !== '.' && value.includes(' ')) return 'Name cannot contain spaces.';
46
+ },
47
+ });
48
+
49
+ if (typeof projectName === 'symbol') {
50
+ cancel('Operation cancelled.');
51
+ process.exit(0);
52
+ }
53
+
54
+ const rawName = projectName === '.' ? path.basename(process.cwd()) : projectName;
55
+ return {
56
+ projectName,
57
+ actualProjectName: resolveActualProjectName(rawName),
58
+ targetDir: projectName === '.' ? process.cwd() : path.join(process.cwd(), projectName)
59
+ };
60
+ }
61
+
62
+ export async function getProjectConfig(isHeadless, headlessOptions, targetDir, detectedFramework, hints = {}) {
63
+ const capabilities = hints && typeof hints === 'object' ? hints.capabilities : null;
64
+ const explicitDbEngine = hints && typeof hints === 'object' && typeof hints.dbEngine === 'string' && hints.dbEngine
65
+ ? hints.dbEngine
66
+ : null;
67
+ // Lambda functions are fixed at 512 MB with no ALB health checks or
68
+ // replica counts, so the ECS sizing prompts are skipped for that target.
69
+ const isLambda = hints && typeof hints === 'object' && hints.target === 'lambda';
70
+ if (isHeadless) {
71
+ return {
72
+ framework: headlessOptions.framework || (detectedFramework ? detectedFramework.id : 'static'),
73
+ region: headlessOptions.region || 'us-east-2',
74
+ port: headlessOptions.port || (headlessOptions.framework === 'static' ? '8080' : '3000'),
75
+ size: headlessOptions.size || 'micro',
76
+ healthCheckPath: headlessOptions.healthCheckPath || '/',
77
+ desiredCount: headlessOptions.desiredCount || '1',
78
+ branch: headlessOptions.branch || 'main',
79
+ needsDatabase: headlessOptions.needsDatabase === 'true' || headlessOptions.needsDatabase === true,
80
+ dbEngine: 'postgres',
81
+ enablePrPreviews: headlessOptions.enablePrPreviews === 'true' || headlessOptions.enablePrPreviews === true,
82
+ setupType: 'headless'
83
+ };
84
+ }
85
+
86
+ let finalFramework = detectedFramework ? detectedFramework.id : null;
87
+
88
+ if (!finalFramework) {
89
+ finalFramework = await select({
90
+ message: 'Which framework preset should we configure?',
91
+ options: [
92
+ { value: 'node', label: 'Node.js / Express' },
93
+ { value: 'nestjs', label: 'NestJS' },
94
+ { value: 'nextjs', label: 'Next.js (Standalone)' },
95
+ { value: 'nuxt', label: 'Nuxt 3 (SSR)' },
96
+ { value: 'svelte', label: 'SvelteKit (SSR)' },
97
+ { value: 'python', label: 'Python FastAPI' },
98
+ { value: 'django', label: 'Django (Python)' },
99
+ { value: 'rails', label: 'Ruby on Rails' },
100
+ { value: 'go', label: 'Go (Golang)' },
101
+ { value: 'static', label: 'Static Site (Gatsby, React, plain HTML via Nginx)' },
102
+ ],
103
+ });
104
+ if (typeof finalFramework === 'symbol') process.exit(0);
105
+ }
106
+
107
+ const setupType = await select({
108
+ message: 'Choose your setup mode:',
109
+ options: [
110
+ { value: 'quick', label: '⚡ Quickstart (Recommended)', hint: 'Production defaults, minimal prompts' },
111
+ { value: 'advanced', label: '🛠️ Advanced Configuration', hint: 'Customize health checks, task count, branch, etc.' },
112
+ ],
113
+ });
114
+ if (typeof setupType === 'symbol') process.exit(0);
115
+
116
+ let defaultPort = '3000';
117
+ if (finalFramework === 'static' || finalFramework === 'go') defaultPort = '8080';
118
+ if (finalFramework === 'python' || finalFramework === 'django') defaultPort = '8000';
119
+
120
+ let currentGitBranch = 'main';
121
+ try {
122
+ currentGitBranch = execSync('git symbolic-ref --short HEAD', { cwd: targetDir, stdio: 'pipe' }).toString().trim();
123
+ } catch (e) { }
124
+
125
+ let needsDatabase = false;
126
+ let dbEngine = 'postgres';
127
+ const isBackendFramework = ['node', 'nestjs', 'nextjs', 'nuxt', 'svelte', 'python', 'django', 'rails', 'go'].includes(finalFramework);
128
+
129
+ if (isBackendFramework) {
130
+ const dbEvidence = capabilities?.relationalDb?.detected === true
131
+ ? capabilities.relationalDb.evidence || []
132
+ : [];
133
+ const mysqlHint = capabilities?.upcomingHints?.mysql === true;
134
+ const dbChoice = await confirm({
135
+ message: dbEvidence.length > 0
136
+ ? `Do you need a managed AWS RDS PostgreSQL database? (Adds ~$14/month or uses AWS Free Tier) (detected: ${dbEvidence.join(', ')})`
137
+ : 'Do you need a managed AWS RDS PostgreSQL database? (Adds ~$14/month or uses AWS Free Tier)',
138
+ initialValue: dbEvidence.length > 0 || mysqlHint,
139
+ });
140
+ if (typeof dbChoice === 'symbol') process.exit(0);
141
+ needsDatabase = dbChoice;
142
+ if (needsDatabase) {
143
+ if (explicitDbEngine) {
144
+ dbEngine = explicitDbEngine;
145
+ } else {
146
+ const engineChoice = await select({
147
+ message: 'Which database engine should we provision?',
148
+ options: [
149
+ { value: 'postgres', label: 'PostgreSQL 16 (RDS db.t4g.micro)', hint: '~$13.98/mo fixed' },
150
+ { value: 'aurora-postgresql', label: 'Aurora PostgreSQL Serverless v2 (0–2 ACU)', hint: '$0/mo idle compute + storage' },
151
+ { value: 'mysql', label: 'MySQL 8.0 (RDS db.t4g.micro)', hint: '~$13.98/mo fixed' },
152
+ ],
153
+ initialValue: mysqlHint ? 'mysql' : 'postgres',
154
+ });
155
+ if (typeof engineChoice === 'symbol') process.exit(0);
156
+ dbEngine = engineChoice;
157
+ }
158
+ }
159
+ }
160
+
161
+ let enablePrPreviews = false;
162
+ let aiAssistants = [];
163
+ if (setupType === 'advanced') {
164
+ const prChoice = await confirm({
165
+ message: `Enable Ephemeral PR Previews? (Spins up isolated, temporary AWS environments for PRs)\n ${color.gray('📖 Learn more: https://github.com/grada-run/grada/blob/main/apps/docs/src/content/docs/guides/ephemeral-pr-previews.md')}`,
166
+ initialValue: false,
167
+ });
168
+ if (typeof prChoice === 'symbol') process.exit(0);
169
+ enablePrPreviews = prChoice;
170
+
171
+ aiAssistants = await getAiAssistants();
172
+ }
173
+
174
+ const project = await group({
175
+ region: () => select({
176
+ message: 'Which AWS region do you want to deploy to?',
177
+ options: [
178
+ { value: 'us-east-1', label: 'us-east-1 (N. Virginia)' },
179
+ { value: 'us-east-2', label: 'us-east-2 (Ohio)' },
180
+ { value: 'eu-west-1', label: 'eu-west-1 (Ireland)' },
181
+ { value: 'eu-central-1', label: 'EU (Frankfurt)' },
182
+ { value: 'ap-southeast-2', label: 'Asia Pacific (Sydney)' },
183
+ ],
184
+ }),
185
+ port: () => text({
186
+ message: 'What port does your container expose?',
187
+ placeholder: defaultPort,
188
+ defaultValue: defaultPort,
189
+ }),
190
+ size: () => isLambda ? undefined : select({
191
+ message: 'Select your Fargate compute size:',
192
+ options: [
193
+ { value: 'micro', label: 'Micro (0.25 vCPU, 512MB RAM) - Best for POCs' },
194
+ { value: 'small', label: 'Small (0.5 vCPU, 1GB RAM) - Best for small Projects' },
195
+ ],
196
+ }),
197
+ healthCheckPath: () => (setupType === 'quick' || isLambda) ? undefined : text({
198
+ message: 'ALB Health Check Path:',
199
+ placeholder: '/',
200
+ defaultValue: '/',
201
+ }),
202
+ desiredCount: () => (setupType === 'quick' || isLambda) ? undefined : select({
203
+ message: 'How many container replicas (tasks) should run?',
204
+ options: [
205
+ { value: '1', label: '1 Task (Single instance - lowest cost)' },
206
+ { value: '2', label: '2 Tasks (High Availability across AZs)' },
207
+ ],
208
+ defaultValue: '1',
209
+ }),
210
+ branch: () => setupType === 'quick' ? undefined : text({
211
+ message: 'Primary Git deployment branch for CI/CD:',
212
+ placeholder: currentGitBranch,
213
+ defaultValue: currentGitBranch,
214
+ }),
215
+ }, { onCancel: () => process.exit(0) });
216
+
217
+ return {
218
+ framework: finalFramework,
219
+ region: project.region,
220
+ port: project.port,
221
+ size: project.size || 'micro',
222
+ healthCheckPath: project.healthCheckPath || '/',
223
+ desiredCount: project.desiredCount || '1',
224
+ branch: project.branch || currentGitBranch,
225
+ needsDatabase,
226
+ dbEngine,
227
+ enablePrPreviews,
228
+ aiAssistants,
229
+ setupType
230
+ };
231
+ }
232
+
233
+ // Prompts for the background worker command. Pre-fills from the Procfile
234
+ // worker process when present, otherwise from capability detection
235
+ // (`npm run <script>`). Returns the (possibly edited) command string.
236
+ export async function promptWorkerCommand(procfile, capabilities = null) {
237
+ const procfileCommand = Array.isArray(procfile?.worker) ? procfile.worker.join(' ') : '';
238
+ const detectedCommand = typeof capabilities?.worker?.suggestedCommand === 'string'
239
+ ? capabilities.worker.suggestedCommand
240
+ : '';
241
+ const answer = await text({
242
+ message: procfileCommand
243
+ ? 'What command runs your background worker? (Empty keeps the Procfile default)'
244
+ : 'What command runs your background worker? (Empty skips the worker service)',
245
+ placeholder: 'celery -A config worker',
246
+ initialValue: procfileCommand || detectedCommand,
247
+ });
248
+ if (typeof answer === 'symbol') {
249
+ cancel('Operation cancelled.');
250
+ process.exit(0);
251
+ }
252
+ return typeof answer === 'string' ? answer : '';
253
+ }
254
+
255
+ export async function getAiAssistants() {
256
+ const selected = await multiselect({
257
+ message: 'Which AI coding assistants does your team use?',
258
+ options: [
259
+ { value: 'cursor', label: 'Cursor', hint: 'Generates .cursor/rules/grada.mdc' },
260
+ { value: 'roo', label: 'Roo Code', hint: 'Generates .roo/rules/grada.md' },
261
+ { value: 'trae', label: 'Trae IDE', hint: 'Generates .trae/rules/project_rules.md' },
262
+ { value: 'windsurf', label: 'Windsurf', hint: 'Generates .windsurfrules' },
263
+ { value: 'copilot', label: 'GitHub Copilot', hint: 'Generates .github/copilot-instructions.md' },
264
+ { value: 'claude', label: 'Claude Code', hint: 'Updates CLAUDE.md' },
265
+ { value: 'goose', label: 'Goose', hint: 'Updates .goosehints' },
266
+ { value: 'aider', label: 'Aider', hint: 'Updates .aider.conf.yml' },
267
+ { value: 'continue', label: 'Continue.dev', hint: 'Generates .prompts/grada.prompt' }
268
+ ],
269
+ required: false,
270
+ });
271
+
272
+ if (typeof selected === 'symbol') {
273
+ cancel('Operation cancelled.');
274
+ process.exit(0);
275
+ }
276
+
277
+ return selected;
278
+ }
@@ -0,0 +1,131 @@
1
+ import { DescribeDBInstancesCommand, DescribeDBClustersCommand } from '@aws-sdk/client-rds';
2
+ import { resolveProjectName, resolveWorkspaceSuffix } from './resolvers.js';
3
+ import { normalizeOptions } from './args.js';
4
+
5
+ // Error names the RDS API returns for a missing instance across SDK shapes.
6
+ const NOT_FOUND_NAMES = new Set(['DBInstanceNotFound', 'DBInstanceNotFoundFault']);
7
+
8
+ // Same convention for Aurora clusters.
9
+ const CLUSTER_NOT_FOUND_NAMES = new Set(['DBClusterNotFound', 'DBClusterNotFoundFault']);
10
+
11
+ // Shared RDS instance lookup for every `db` subcommand: exact identifier
12
+ // match, `null` when the project has no database, unexpected errors rethrown.
13
+ export async function findDbInstance(rdsClient, dbIdentifier) {
14
+ try {
15
+ const resp = await rdsClient.send(
16
+ new DescribeDBInstancesCommand({ DBInstanceIdentifier: dbIdentifier })
17
+ );
18
+ return (resp.DBInstances || [])[0] || null;
19
+ } catch (error) {
20
+ if (error && NOT_FOUND_NAMES.has(error.name)) return null;
21
+ throw error;
22
+ }
23
+ }
24
+
25
+ // Shared Aurora cluster lookup: exact identifier match, `null` when no
26
+ // cluster exists under it, unexpected errors rethrown.
27
+ export async function findDbCluster(rdsClient, dbClusterIdentifier) {
28
+ try {
29
+ const resp = await rdsClient.send(
30
+ new DescribeDBClustersCommand({ DBClusterIdentifier: dbClusterIdentifier })
31
+ );
32
+ return (resp.DBClusters || [])[0] || null;
33
+ } catch (error) {
34
+ if (error && CLUSTER_NOT_FOUND_NAMES.has(error.name)) return null;
35
+ throw error;
36
+ }
37
+ }
38
+
39
+ function isMysqlEngine(engine) {
40
+ return engine === 'mysql' || engine === 'aurora-mysql';
41
+ }
42
+
43
+ // Unified RDS discovery across single instances and Aurora clusters.
44
+ // Tries the instance identifier first (zero extra API calls for the common
45
+ // postgres/mysql case), then the cluster identifiers. Returns a normalized
46
+ // descriptor `{ kind, id, engine, status, endpoint, port, dbName,
47
+ // masterSecretArn, raw }` or `null` when nothing is provisioned.
48
+ export async function findDbTarget(rdsClient, { dbIdentifier, dbClusterIdentifier } = {}) {
49
+ if (dbIdentifier) {
50
+ const instance = await findDbInstance(rdsClient, dbIdentifier);
51
+ if (instance) {
52
+ const port = instance.Endpoint?.Port ?? (isMysqlEngine(instance.Engine) ? 3306 : 5432);
53
+ return {
54
+ kind: 'instance',
55
+ id: dbIdentifier,
56
+ engine: instance.Engine,
57
+ status: instance.Status,
58
+ endpoint: instance.Endpoint?.Address,
59
+ port: String(port),
60
+ dbName: instance.DBName,
61
+ masterSecretArn: instance.MasterUserSecret?.SecretArn,
62
+ raw: instance,
63
+ };
64
+ }
65
+ }
66
+ const clusterIds = [dbClusterIdentifier, dbIdentifier].filter(
67
+ (id, index, list) => typeof id === 'string' && id !== '' && list.indexOf(id) === index
68
+ );
69
+ for (const clusterId of clusterIds) {
70
+ const cluster = await findDbCluster(rdsClient, clusterId);
71
+ if (cluster) {
72
+ const port = cluster.Port ?? (isMysqlEngine(cluster.Engine) ? 3306 : 5432);
73
+ return {
74
+ kind: 'cluster',
75
+ id: clusterId,
76
+ engine: cluster.Engine,
77
+ status: cluster.Status,
78
+ endpoint: cluster.Endpoint,
79
+ port: String(port),
80
+ dbName: cluster.DatabaseName,
81
+ masterSecretArn: cluster.MasterUserSecret?.SecretArn,
82
+ raw: cluster,
83
+ };
84
+ }
85
+ }
86
+ return null;
87
+ }
88
+
89
+ function pad2(value) {
90
+ return String(value).padStart(2, '0');
91
+ }
92
+
93
+ // Default manual snapshot id: `<db>-manual-YYYYMMDD-HHmmss` in UTC.
94
+ // Lowercased so project names with uppercase stay RDS-safe.
95
+ export function generateSnapshotId(dbIdentifier, now = new Date()) {
96
+ const stamp = `${now.getUTCFullYear()}${pad2(now.getUTCMonth() + 1)}${pad2(now.getUTCDate())}`
97
+ + `-${pad2(now.getUTCHours())}${pad2(now.getUTCMinutes())}${pad2(now.getUTCSeconds())}`;
98
+ return `${dbIdentifier}-manual-${stamp}`.toLowerCase();
99
+ }
100
+
101
+ // RDS snapshot identifier rules: starts with a letter, letters/digits/hyphens
102
+ // after that, no consecutive hyphens, no trailing hyphen, max 255 chars.
103
+ export function isValidSnapshotId(id) {
104
+ if (typeof id !== 'string') return false;
105
+ if (!/^[a-zA-Z][a-zA-Z0-9-]{0,254}$/.test(id)) return false;
106
+ if (id.includes('--') || id.endsWith('-')) return false;
107
+ return true;
108
+ }
109
+
110
+ // Database identifier for the current project/workspace. An explicit
111
+ // `--db-identifier` override wins; otherwise `<appName>-db`.
112
+ export function resolveDbIdentifier(options = {}, cwd = process.cwd()) {
113
+ const opts = normalizeOptions(options);
114
+ if (typeof opts.dbIdentifier === 'string' && opts.dbIdentifier.trim()) {
115
+ return opts.dbIdentifier.trim();
116
+ }
117
+ const base = resolveProjectName(opts, cwd);
118
+ return `${base}${resolveWorkspaceSuffix(opts, cwd)}-db`;
119
+ }
120
+
121
+ // Aurora cluster identifier for the current project/workspace. An explicit
122
+ // `--db-identifier` override wins (tried as both kinds); otherwise
123
+ // `<appName>-db-cluster`, matching the generated cluster_identifier.
124
+ export function resolveDbClusterIdentifier(options = {}, cwd = process.cwd()) {
125
+ const opts = normalizeOptions(options);
126
+ if (typeof opts.dbIdentifier === 'string' && opts.dbIdentifier.trim()) {
127
+ return opts.dbIdentifier.trim();
128
+ }
129
+ const base = resolveProjectName(opts, cwd);
130
+ return `${base}${resolveWorkspaceSuffix(opts, cwd)}-db-cluster`;
131
+ }
@@ -0,0 +1,174 @@
1
+ import fsSync from 'fs';
2
+ import path from 'path';
3
+ import { normalizeOptions } from './args.js';
4
+
5
+ export const FALLBACK_REGION = 'us-east-2';
6
+
7
+ export function readFileSafe(filePath) {
8
+ if (typeof filePath !== 'string') return null;
9
+ try {
10
+ if (fsSync.existsSync(filePath)) return fsSync.readFileSync(filePath, 'utf8');
11
+ } catch {
12
+ // Fall through to defaults
13
+ }
14
+ return null;
15
+ }
16
+
17
+ // Compute targets supported by `--target`. `ecs` covers both the
18
+ // canonical `ecs` value and its `fargate` synonym.
19
+ export const COMPUTE_TARGETS = ['ecs', 'lambda'];
20
+
21
+ // Pure target check over rendered `main.tf` content: a Lambda project is
22
+ // any project whose main.tf provisions the serverless function.
23
+ export function detectComputeTargetFromMainTf(mainTfContent) {
24
+ if (typeof mainTfContent === 'string' && mainTfContent.includes('resource "aws_lambda_function"')) {
25
+ return 'lambda';
26
+ }
27
+ return 'ecs';
28
+ }
29
+
30
+ // Reads the compute target of the project rooted at `cwd`. Missing or
31
+ // unreadable Terraform falls back to `ecs` so legacy projects and
32
+ // pre-init guards keep their historical behavior.
33
+ export function readTerraformComputeTarget(cwd = process.cwd()) {
34
+ const base = resolveCwd({}, cwd);
35
+ return detectComputeTargetFromMainTf(readFileSafe(path.join(base, 'terraform', 'main.tf')));
36
+ }
37
+
38
+ // Working directory for file resolution: an explicit string `cwd` option,
39
+ // otherwise the positional `fallback` when it is a usable path, otherwise
40
+ // the process cwd. A throwing `process.cwd()` (deleted directory)
41
+ // propagates so callers route through PROJECT_NOT_INITIALIZED.
42
+ export function resolveCwd(options = {}, fallback) {
43
+ const opts = normalizeOptions(options);
44
+ if (typeof opts.cwd === 'string' && opts.cwd) return opts.cwd;
45
+ if (typeof fallback === 'string' && fallback) return fallback;
46
+ return process.cwd();
47
+ }
48
+
49
+ export function readTerraformRegion(cwd = process.cwd()) {
50
+ const base = resolveCwd({}, cwd);
51
+ const mainTf = readFileSafe(path.join(base, 'terraform', 'main.tf'));
52
+ if (!mainTf) return null;
53
+ const match = mainTf.match(/region\s*=\s*"([^"]+)"/);
54
+ if (!match || match[1].includes('{{')) return null;
55
+ return match[1];
56
+ }
57
+
58
+ export function resolveRegion(options = {}, cwd = process.cwd()) {
59
+ const opts = normalizeOptions(options);
60
+ if (typeof opts.region === 'string' && opts.region.trim()) {
61
+ return opts.region.trim();
62
+ }
63
+ if (typeof process.env.AWS_REGION === 'string' && process.env.AWS_REGION.trim()) {
64
+ return process.env.AWS_REGION.trim();
65
+ }
66
+ return readTerraformRegion(resolveCwd(opts, cwd)) || FALLBACK_REGION;
67
+ }
68
+
69
+ export function readTerraformProjectName(cwd = process.cwd()) {
70
+ const base = resolveCwd({}, cwd);
71
+ const mainTf = readFileSafe(path.join(base, 'terraform', 'main.tf'));
72
+ if (!mainTf) return null;
73
+ const appNameMatch = mainTf.match(/app_name\s*=\s*"([^"$]+)\$\{local\.env_suffix\}"/);
74
+ if (appNameMatch) return appNameMatch[1];
75
+ // Hand-written files may set a plain app_name (possibly with some other
76
+ // ${...} suffix); it still outranks the ECR heuristic below.
77
+ const genericMatch = mainTf.match(/app_name\s*=\s*"([^"]+)"/);
78
+ if (genericMatch && !genericMatch[1].includes('{{')) {
79
+ const stripped = genericMatch[1].replace(/\$\{.*$/, '').replace(/[-_]$/, '');
80
+ if (stripped) return stripped;
81
+ }
82
+ const ecrMatch = mainTf.match(/resource\s+"aws_ecr_repository"\s+"app"\s*\{[^}]*?name\s*=\s*"([^"]+)-repo"/);
83
+ if (ecrMatch) return ecrMatch[1];
84
+ return null;
85
+ }
86
+
87
+ export function resolveProjectName(options = {}, cwd = process.cwd()) {
88
+ const opts = normalizeOptions(options);
89
+ const base = resolveCwd(opts, cwd);
90
+ if (typeof opts.projectName === 'string' && opts.projectName.trim()) {
91
+ return opts.projectName.trim();
92
+ }
93
+ return readTerraformProjectName(base) || path.basename(path.resolve(base));
94
+ }
95
+
96
+ export function resolveCluster(options = {}, cwd = process.cwd()) {
97
+ const opts = normalizeOptions(options);
98
+ if (typeof opts.cluster === 'string' && opts.cluster.trim()) return opts.cluster.trim();
99
+ if (typeof process.env.ECS_CLUSTER === 'string' && process.env.ECS_CLUSTER.trim()) {
100
+ return process.env.ECS_CLUSTER.trim();
101
+ }
102
+ return `${resolveProjectName(opts, cwd)}-cluster`;
103
+ }
104
+
105
+ export function resolveService(options = {}, cwd = process.cwd()) {
106
+ const opts = normalizeOptions(options);
107
+ if (typeof opts.service === 'string' && opts.service.trim()) return opts.service.trim();
108
+ if (typeof opts.serviceName === 'string' && opts.serviceName.trim()) return opts.serviceName.trim();
109
+ if (typeof process.env.ECS_SERVICE === 'string' && process.env.ECS_SERVICE.trim()) {
110
+ return process.env.ECS_SERVICE.trim();
111
+ }
112
+ return `${resolveProjectName(opts, cwd)}-service`;
113
+ }
114
+
115
+ export function resolveLogGroup(options = {}, cwd = process.cwd()) {
116
+ const opts = normalizeOptions(options);
117
+ if (typeof opts.logGroup === 'string' && opts.logGroup.trim()) {
118
+ return opts.logGroup.trim();
119
+ }
120
+ if (typeof opts.logGroupName === 'string' && opts.logGroupName.trim()) {
121
+ return opts.logGroupName.trim();
122
+ }
123
+ if (typeof process.env.ECS_LOG_GROUP === 'string' && process.env.ECS_LOG_GROUP.trim()) {
124
+ return process.env.ECS_LOG_GROUP.trim();
125
+ }
126
+ const base = resolveCwd(opts, cwd);
127
+ const projectName = resolveProjectName(opts, base);
128
+ // Lambda functions log to /aws/lambda/<fn> (see main-lambda.tf), where
129
+ // the function name is `<app>-fn`.
130
+ if (readTerraformComputeTarget(base) === 'lambda') {
131
+ return `/aws/lambda/${projectName}-fn`;
132
+ }
133
+ return `/ecs/${projectName}`;
134
+ }
135
+
136
+ // True when prompts must not block: explicit headless flags, CI/test
137
+ // environments, or non-TTY stdio. An explicit `isHeadless: false` (or
138
+ // `headless: false`) forces interactive mode so unit tests can simulate
139
+ // TTY prompts. `env`/`stdin`/`stdout` are injectable for tests.
140
+ export function resolveHeadless(options = {}, env = process.env, stdin = process.stdin, stdout = process.stdout) {
141
+ const opts = normalizeOptions(options);
142
+ if (opts.isHeadless === false || opts.headless === false) return false;
143
+ return Boolean(
144
+ opts.isHeadless ||
145
+ opts.headless ||
146
+ (env && (env.CI || env.VITEST)) ||
147
+ (env && env.NODE_ENV === 'test') ||
148
+ !stdin?.isTTY ||
149
+ !stdout?.isTTY
150
+ );
151
+ }
152
+
153
+ // Workspace-namespaced application name (`myapp` or `myapp-pr-123`) shared
154
+ // by every `db` subcommand. `workspace` is the explicit `--workspace` value
155
+ // (or undefined to auto-detect `.terraform/environment` under `cwd`).
156
+ export function resolveAppName(projectName, workspace, cwd = process.cwd()) {
157
+ return `${projectName}${resolveWorkspaceSuffix({ workspace, cwd }, cwd)}`;
158
+ }
159
+
160
+ // Reads the local Terraform workspace (e.g. a PR-preview environment).
161
+ // Returns '' for the default workspace so names stay un-suffixed.
162
+ export function resolveWorkspaceSuffix(options = {}, cwd = process.cwd()) {
163
+ const opts = normalizeOptions(options);
164
+ const base = resolveCwd(opts, cwd);
165
+ let workspace = null;
166
+ if (typeof opts.workspace === 'string' && opts.workspace.trim()) {
167
+ workspace = opts.workspace.trim();
168
+ } else {
169
+ const detected = readFileSafe(path.join(base, '.terraform', 'environment'));
170
+ if (typeof detected === 'string' && detected.trim()) workspace = detected.trim();
171
+ }
172
+ if (!workspace || workspace === 'default') return '';
173
+ return `-${workspace}`;
174
+ }