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,164 @@
1
+ import { ListTasksCommand, DescribeTasksCommand } from '@aws-sdk/client-ecs';
2
+ import { GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
3
+ import { spawnSync } from 'child_process';
4
+ import net from 'node:net';
5
+ import { pickRuntimeContainer } from './ecs.js';
6
+ import { pollUntil } from './system.js';
7
+
8
+ // SSM port-forwarding session arguments for tunneling to a database
9
+ // through an ECS container. `remotePort` is the database port (5432 for
10
+ // PostgreSQL/Aurora, 3306 for MySQL); `localPort` is the loopback port.
11
+ export function buildSsmArgs({ cluster, taskId, runtimeId, dbHost, remotePort = '5432', localPort, region }) {
12
+ const args = [
13
+ 'ssm', 'start-session',
14
+ '--target', `ecs:${cluster}_${taskId}_${runtimeId}`,
15
+ '--document-name', 'AWS-StartPortForwardingSessionToRemoteHost',
16
+ '--parameters', `{"host":["${dbHost}"],"portNumber":["${remotePort}"],"localPortNumber":["${localPort}"]}`,
17
+ ];
18
+ if (region) args.push('--region', region);
19
+ return args;
20
+ }
21
+
22
+ // Fetches the managed master credentials for `secretArn`. Returns
23
+ // `{ username, password }`, or `null` when the secret is missing or
24
+ // malformed (callers report SECRET_MALFORMED).
25
+ export async function fetchManagedDbCredentials(secretsClient, secretArn) {
26
+ const secretResp = await secretsClient.send(
27
+ new GetSecretValueCommand({ SecretId: secretArn })
28
+ );
29
+ try {
30
+ const parsed = JSON.parse(secretResp.SecretString || '{}');
31
+ if (parsed.username && parsed.password) {
32
+ return { username: parsed.username, password: parsed.password };
33
+ }
34
+ return null;
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ // Picks a running ECS task to act as the SSM jump host. Returns
41
+ // `{ taskArn, taskId, containerName, runtimeId }`, or `{ error }` with
42
+ // `NO_RUNNING_TASKS` / `NO_RUNTIME_ID` for the caller to report.
43
+ export async function findJumpHostTarget(ecsClient, { cluster, service, expectedContainer }) {
44
+ const listResp = await ecsClient.send(
45
+ new ListTasksCommand({ cluster, serviceName: service, desiredStatus: 'RUNNING', maxResults: 10 })
46
+ );
47
+ const taskArns = listResp.taskArns || [];
48
+ if (taskArns.length === 0) return { error: 'NO_RUNNING_TASKS' };
49
+ const descResp = await ecsClient.send(
50
+ new DescribeTasksCommand({ cluster, tasks: taskArns.slice(0, 1) })
51
+ );
52
+ const task = (descResp.tasks || [])[0] || null;
53
+ if (!task?.taskArn) return { error: 'NO_RUNNING_TASKS' };
54
+ const container = pickRuntimeContainer(task, expectedContainer);
55
+ const runtimeId = container?.runtimeId;
56
+ if (!runtimeId) return { error: 'NO_RUNTIME_ID' };
57
+ return {
58
+ taskArn: task.taskArn,
59
+ taskId: task.taskArn.split('/').pop(),
60
+ containerName: container?.name,
61
+ runtimeId,
62
+ };
63
+ }
64
+
65
+ function probeTcpPort(host, port, timeoutMs = 2000) {
66
+ return new Promise((resolve) => {
67
+ const socket = net.connect({ host, port });
68
+ const done = (open) => {
69
+ socket.destroy();
70
+ resolve(open);
71
+ };
72
+ socket.once('connect', () => done(true));
73
+ socket.once('error', () => done(false));
74
+ socket.setTimeout(timeoutMs, () => done(false));
75
+ });
76
+ }
77
+
78
+ // Waits until `host:port` accepts TCP connections (an SSM tunnel coming
79
+ // up). Resolves `{ done: true }`, or `{ timedOut: true }` after `timeoutMs`.
80
+ export async function waitForTcpPort(host, port, { timeoutMs = 30000, pollIntervalMs = 500 } = {}) {
81
+ return pollUntil({
82
+ intervalMs: pollIntervalMs,
83
+ timeoutMs,
84
+ onTick: async () => {
85
+ const open = await probeTcpPort(host, port);
86
+ return open ? { done: true, value: true } : { done: false };
87
+ },
88
+ });
89
+ }
90
+
91
+ // Allocates an ephemeral free loopback port so background tunnels never
92
+ // collide with a local Postgres/MySQL on 5432/3306.
93
+ export function getFreeLocalPort(host = '127.0.0.1') {
94
+ return new Promise((resolve, reject) => {
95
+ const server = net.createServer();
96
+ server.once('error', reject);
97
+ server.listen(0, host, () => {
98
+ const { port } = server.address();
99
+ server.close(() => resolve(port));
100
+ });
101
+ });
102
+ }
103
+
104
+ // Masks the password in a `scheme://user:password@host/...` URI for safe
105
+ // terminal and error output. URIs without userinfo pass through unchanged.
106
+ export function redactUri(uri) {
107
+ const text = String(uri ?? '');
108
+ const schemeIdx = text.indexOf('://');
109
+ if (schemeIdx === -1) return text;
110
+ const authStart = schemeIdx + 3;
111
+ const slashIdx = text.indexOf('/', authStart);
112
+ const authEnd = slashIdx === -1 ? text.length : slashIdx;
113
+ const authority = text.slice(authStart, authEnd);
114
+ const atIdx = authority.lastIndexOf('@');
115
+ if (atIdx === -1) return text;
116
+ const userinfo = authority.slice(0, atIdx);
117
+ const colonIdx = userinfo.indexOf(':');
118
+ if (colonIdx === -1) return text;
119
+ return text.slice(0, authStart) + userinfo.slice(0, colonIdx) + ':****' + text.slice(authStart + atIdx);
120
+ }
121
+
122
+ // Parses a `--from` source URI into discrete connection parts (so passwords
123
+ // travel via process env, never argv). Returns `{ scheme, user, password,
124
+ // host, port, database }`, or `null` when the URI is unusable.
125
+ export function parseSourceUri(uri) {
126
+ const text = String(uri ?? '').trim();
127
+ const match = /^(postgres(?:ql)?|mysql):\/\/(.*)$/i.exec(text);
128
+ if (!match) return null;
129
+ const scheme = match[1].toLowerCase() === 'mysql' ? 'mysql' : 'postgresql';
130
+ let parsed;
131
+ try {
132
+ // WHATWG URL requires a valid scheme; normalize postgres:// first.
133
+ parsed = new URL(text.replace(/^[a-z]+:\/\//i, `${scheme}://`));
134
+ } catch {
135
+ return null;
136
+ }
137
+ if (!parsed.hostname) return null;
138
+ const database = decodeURIComponent(parsed.pathname.replace(/^\//, '').split('/')[0] || '');
139
+ if (!database) return null;
140
+ return {
141
+ scheme,
142
+ user: decodeURIComponent(parsed.username || ''),
143
+ password: decodeURIComponent(parsed.password || ''),
144
+ host: parsed.hostname,
145
+ port: parsed.port || (scheme === 'mysql' ? '3306' : '5432'),
146
+ database,
147
+ };
148
+ }
149
+
150
+ // Reports which of `names` are missing from PATH (via `<bin> --version`,
151
+ // mirroring the Session Manager plugin check). Pure ENOENT detection:
152
+ // a binary that spawns at all counts as present.
153
+ export function findMissingBinaries(names, { spawnSyncImpl = spawnSync } = {}) {
154
+ const missing = [];
155
+ for (const name of names || []) {
156
+ try {
157
+ const result = spawnSyncImpl(name, ['--version'], { stdio: 'ignore' });
158
+ if (result && result.error && result.error.code === 'ENOENT') missing.push(name);
159
+ } catch {
160
+ missing.push(name);
161
+ }
162
+ }
163
+ return missing;
164
+ }
@@ -0,0 +1,298 @@
1
+ import fsSync from 'fs';
2
+ import path from 'path';
3
+
4
+ export { detectProjectCapabilities } from './capabilities.js';
5
+
6
+ // Detects the framework based on the presence of framework-specific files.
7
+ export function detectFramework(targetDir) {
8
+ const packageJsonPath = path.join(targetDir, 'package.json');
9
+ const requirementsTxtPath = path.join(targetDir, 'requirements.txt');
10
+ const managePyPath = path.join(targetDir, 'manage.py');
11
+ const goModPath = path.join(targetDir, 'go.mod');
12
+ const gemfilePath = path.join(targetDir, 'Gemfile');
13
+
14
+ // 1. Detect Node.js Frameworks
15
+ if (fsSync.existsSync(packageJsonPath)) {
16
+ try {
17
+ const pkg = JSON.parse(fsSync.readFileSync(packageJsonPath, 'utf-8'));
18
+ // Merge dependencies and devDependencies to check both
19
+ const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
20
+
21
+ // Fullstack / API
22
+ if (deps['@nestjs/core']) return { id: 'nestjs', name: 'NestJS' };
23
+ if (deps['next']) return { id: 'nextjs', name: 'Next.js' };
24
+ if (deps['nuxt']) return { id: 'nuxt', name: 'Nuxt 3 (SSR)' };
25
+ if (deps['express']) return { id: 'node', name: 'Node.js / Express' };
26
+
27
+ // Explicit SvelteKit SSR Detection
28
+ if (deps['@sveltejs/kit']) return { id: 'svelte', name: 'SvelteKit SSR', buildDir: 'build' };
29
+
30
+ // Static Site Generators & SPAs (with precise build directories)
31
+ if (deps['@sveltejs/kit']) return { id: 'static', name: 'SvelteKit', buildDir: 'build' };
32
+ if (deps['react-scripts']) return { id: 'static', name: 'Create React App', buildDir: 'build' };
33
+ if (deps['gatsby']) return { id: 'static', name: 'Gatsby', buildDir: 'public' };
34
+ if (deps['astro']) return { id: 'static', name: 'Astro', buildDir: 'dist' };
35
+ if (deps['vite']) return { id: 'static', name: 'Vite', buildDir: 'dist' };
36
+ if (deps['@vue/cli-service']) return { id: 'static', name: 'Vue.js', buildDir: 'dist' };
37
+ if (deps['@angular/cli']) return { id: 'static', name: 'Angular', buildDir: 'dist' };
38
+ } catch (e) {
39
+ // Silently fail if package.json is malformed
40
+ }
41
+ }
42
+
43
+ // 2. Detect Python Frameworks
44
+ if (fsSync.existsSync(requirementsTxtPath)) {
45
+ try {
46
+ const reqs = fsSync.readFileSync(requirementsTxtPath, 'utf-8').toLowerCase();
47
+ if (reqs.includes('fastapi')) return { id: 'python', name: 'Python FastAPI' };
48
+ if (reqs.includes('django')) return { id: 'django', name: 'Django' };
49
+ } catch (e) {
50
+ // Silently fail
51
+ }
52
+ }
53
+
54
+ // Fallback Django detection (sometimes requirements.txt is named differently)
55
+ if (fsSync.existsSync(managePyPath)) return { id: 'django', name: 'Django' };
56
+
57
+ // 3. Detect Ruby on Rails
58
+ if (fsSync.existsSync(gemfilePath)) {
59
+ try {
60
+ const gemfile = fsSync.readFileSync(gemfilePath, 'utf-8').toLowerCase();
61
+ if (/gem\s+['"]rails['"]/i.test(gemfile)) { return { id: 'rails', name: 'Ruby on Rails' }; }
62
+ } catch (e) {
63
+ // Silently fail
64
+ }
65
+ }
66
+
67
+ // 4. Detect Go
68
+ if (fsSync.existsSync(goModPath)) return { id: 'go', name: 'Go' };
69
+
70
+ // 5. Fallback
71
+ return null;
72
+ }
73
+
74
+ // Splits a Procfile-style command into Terraform's JSON-array form,
75
+ // respecting single and double quotes. Shared by Procfile parsing and
76
+ // the interactive worker-command prompt so both produce identical arrays.
77
+ export function splitProcfileCommand(rawCommand) {
78
+ const match = String(rawCommand || '').match(/[^\s"']+|"([^"]*)"|'([^']*)'/g);
79
+ if (!match) return [];
80
+ return match.map(str => str.replace(/^["']|["']$/g, '')); // Strip the quotes
81
+ }
82
+
83
+ // Parses a Heroku/Render Procfile and formats the commands for Terraform ECS.
84
+ export function parseProcfile(targetDir) {
85
+ const procfilePath = path.join(targetDir, 'Procfile');
86
+
87
+ if (!fsSync.existsSync(procfilePath)) return null;
88
+
89
+ const content = fsSync.readFileSync(procfilePath, 'utf-8');
90
+ const processes = {};
91
+
92
+ // Match lines like "web: gunicorn myapp.wsgi"
93
+ const lines = content.split('\n');
94
+ const procRegex = /^([A-Za-z0-9_-]+):\s*(.+)$/;
95
+
96
+ for (const line of lines) {
97
+ const match = line.trim().match(procRegex);
98
+ if (match) {
99
+ const type = match[1].toLowerCase();
100
+ const rawCommand = match[2].trim();
101
+
102
+ // Terraform requires the command as a JSON array of strings
103
+ const commandArray = splitProcfileCommand(rawCommand);
104
+
105
+ processes[type] = commandArray;
106
+ }
107
+ }
108
+
109
+ return Object.keys(processes).length > 0 ? processes : null;
110
+ }
111
+
112
+ // Parses a vercel.json file to extract routing and edge rules
113
+ export function parseVercelConfig(targetDir) {
114
+ const vercelConfigPath = path.join(targetDir, 'vercel.json');
115
+ if (!fsSync.existsSync(vercelConfigPath)) return null;
116
+
117
+ try {
118
+ const content = fsSync.readFileSync(vercelConfigPath, 'utf-8');
119
+ const vercelJson = JSON.parse(content);
120
+
121
+ // We only care about network-level edge rules that AWS needs to handle
122
+ const rules = {
123
+ redirects: vercelJson.redirects || null,
124
+ headers: vercelJson.headers || null,
125
+ rewrites: vercelJson.rewrites || null
126
+ };
127
+
128
+ // If it's just an empty vercel.json, return null
129
+ if (!rules.redirects && !rules.headers && !rules.rewrites) {
130
+ return null;
131
+ }
132
+
133
+ return rules;
134
+ } catch (e) {
135
+ // Silently fail on malformed JSON
136
+ return null;
137
+ }
138
+ }
139
+
140
+ // Checks if Next.js is configured for 'standalone' output
141
+ export function analyzeNextConfig(targetDir) {
142
+ const extensions = ['js', 'mjs', 'cjs', 'ts'];
143
+ let configPath = null;
144
+ let configContent = '';
145
+
146
+ for (const ext of extensions) {
147
+ const tempPath = path.join(targetDir, `next.config.${ext}`);
148
+ if (fsSync.existsSync(tempPath)) {
149
+ configPath = tempPath;
150
+ configContent = fsSync.readFileSync(tempPath, 'utf-8');
151
+ break;
152
+ }
153
+ }
154
+
155
+ if (!configPath) return { hasConfig: false, isStandalone: false };
156
+
157
+ // Regex looks for output: 'standalone' or output: "standalone" (handling spacing)
158
+ const isStandalone = /output\s*:\s*['"`]standalone['"`]/.test(configContent);
159
+
160
+ return {
161
+ hasConfig: true,
162
+ isStandalone: isStandalone,
163
+ configPath: configPath
164
+ };
165
+ }
166
+
167
+ // Checks if SvelteKit is locked into Vercel
168
+ export function analyzeSvelteConfig(targetDir) {
169
+ const configPath = path.join(targetDir, 'svelte.config.js');
170
+ if (!fsSync.existsSync(configPath)) return { hasConfig: false, adapter: 'unknown' };
171
+
172
+ const content = fsSync.readFileSync(configPath, 'utf-8');
173
+
174
+ let adapter = 'unknown';
175
+ if (content.includes('@sveltejs/adapter-vercel')) adapter = 'vercel';
176
+ else if (content.includes('@sveltejs/adapter-node')) adapter = 'node';
177
+ else if (content.includes('@sveltejs/adapter-static')) adapter = 'static';
178
+ else if (content.includes('@sveltejs/adapter-auto')) adapter = 'auto'; // Vercel's default
179
+
180
+ return { hasConfig: true, adapter };
181
+ }
182
+
183
+ // Checks if Astro is locked into Vercel
184
+ export function analyzeAstroConfig(targetDir) {
185
+ const extensions = ['mjs', 'js', 'ts', 'cjs'];
186
+ let configPath = null;
187
+ let content = '';
188
+
189
+ for (const ext of extensions) {
190
+ const tempPath = path.join(targetDir, `astro.config.${ext}`);
191
+ if (fsSync.existsSync(tempPath)) {
192
+ configPath = tempPath;
193
+ content = fsSync.readFileSync(tempPath, 'utf-8');
194
+ break;
195
+ }
196
+ }
197
+
198
+ if (!configPath) return { hasConfig: false, adapter: 'unknown' };
199
+
200
+ let adapter = 'unknown';
201
+ if (content.includes('@astrojs/vercel')) adapter = 'vercel';
202
+ else if (content.includes('@astrojs/node')) adapter = 'node';
203
+
204
+ return { hasConfig: true, adapter };
205
+ }
206
+
207
+ // Detects the project's schema-migration command from well-known ORM and
208
+ // framework markers. Pure: reads files under `cwd`, never throws (malformed
209
+ // or unreadable files fall through to the next marker). Returns the shell
210
+ // command string or null when nothing matches.
211
+ export function detectMigrationCommand(cwd = process.cwd()) {
212
+ const readJson = (file) => {
213
+ try {
214
+ return JSON.parse(fsSync.readFileSync(path.join(cwd, file), 'utf-8'));
215
+ } catch {
216
+ return null;
217
+ }
218
+ };
219
+ const exists = (file) => {
220
+ try {
221
+ return fsSync.existsSync(path.join(cwd, file));
222
+ } catch {
223
+ return false;
224
+ }
225
+ };
226
+
227
+ // 1. Explicit package.json migration scripts win over framework markers.
228
+ const pkg = readJson('package.json');
229
+ const scripts = pkg && typeof pkg === 'object' ? pkg.scripts || {} : {};
230
+ if (typeof scripts['db:migrate'] === 'string' && scripts['db:migrate'].trim()) {
231
+ return 'npm run db:migrate';
232
+ }
233
+ if (typeof scripts.migrate === 'string' && scripts.migrate.trim()) {
234
+ return 'npm run migrate';
235
+ }
236
+
237
+ // 2. Prisma: schema file or dependency in either dep block.
238
+ const deps = pkg && typeof pkg === 'object'
239
+ ? { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }
240
+ : {};
241
+ if (exists(path.join('prisma', 'schema.prisma')) || deps.prisma) {
242
+ return 'npx prisma migrate deploy';
243
+ }
244
+
245
+ // 3. Drizzle: config file in any supported extension.
246
+ if (exists('drizzle.config.ts') || exists('drizzle.config.js') || exists('drizzle.config.mjs')) {
247
+ return 'npx drizzle-kit migrate';
248
+ }
249
+
250
+ // 4. Alembic.
251
+ if (exists('alembic.ini')) {
252
+ return 'alembic upgrade head';
253
+ }
254
+
255
+ // 5. Django.
256
+ if (exists('manage.py')) {
257
+ return 'python manage.py migrate --noinput';
258
+ }
259
+
260
+ // 6. Ruby on Rails: binstub or Gemfile rails dependency.
261
+ if (exists(path.join('bin', 'rails'))) {
262
+ return 'bundle exec rails db:migrate';
263
+ }
264
+ try {
265
+ const gemfilePath = path.join(cwd, 'Gemfile');
266
+ if (fsSync.existsSync(gemfilePath)) {
267
+ const gemfile = fsSync.readFileSync(gemfilePath, 'utf-8');
268
+ if (/gem\s+['"]rails['"]/i.test(gemfile)) {
269
+ return 'bundle exec rails db:migrate';
270
+ }
271
+ }
272
+ } catch {
273
+ // Fall through to null below.
274
+ }
275
+
276
+ return null;
277
+ }
278
+
279
+ // Checks if a NestJS app explicitly listens on 0.0.0.0 for Docker networking
280
+ export function analyzeNestApp(targetDir) {
281
+ const mainTsPath = path.join(targetDir, 'src', 'main.ts');
282
+ const mainJsPath = path.join(targetDir, 'src', 'main.js');
283
+
284
+ const filePath = fsSync.existsSync(mainTsPath) ? mainTsPath : (fsSync.existsSync(mainJsPath) ? mainJsPath : null);
285
+ if (!filePath) return { hasMain: false, listensOnAllInterfaces: true };
286
+
287
+ try {
288
+ const content = fsSync.readFileSync(filePath, 'utf-8');
289
+ const hasHostBinding = content.includes('0.0.0.0');
290
+ return {
291
+ hasMain: true,
292
+ listensOnAllInterfaces: hasHostBinding,
293
+ filePath: path.relative(targetDir, filePath)
294
+ };
295
+ } catch {
296
+ return { hasMain: false, listensOnAllInterfaces: true };
297
+ }
298
+ }
@@ -0,0 +1,65 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { load } from 'js-yaml';
4
+
5
+ // Parse docker-compose.yml (or its aliases) and normalize it into an
6
+ // array of services. Filenames are checked in canonical order.
7
+ const COMPOSE_FILENAMES = ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml'];
8
+
9
+ export function parseDockerCompose(targetDir) {
10
+ let composePath = null;
11
+ for (const filename of COMPOSE_FILENAMES) {
12
+ const candidate = path.join(targetDir, filename);
13
+ if (fs.existsSync(candidate)) {
14
+ composePath = candidate;
15
+ break;
16
+ }
17
+ }
18
+
19
+ if (!composePath) return null;
20
+
21
+ try {
22
+ const fileContents = fs.readFileSync(composePath, 'utf8');
23
+ const data = load(fileContents);
24
+
25
+ if (!data || !data.services) return null;
26
+
27
+ const services = [];
28
+
29
+ for (const [name, config] of Object.entries(data.services)) {
30
+ // Extract the container port (e.g., "8080:80" -> 80)
31
+ let exposedPort = null;
32
+ if (config.ports && config.ports.length > 0) {
33
+ // Handle different port formats like "3000" or "8000:8000" or "127.0.0.1:8001:8001"
34
+ const portMapping = config.ports[0].toString();
35
+ const parts = portMapping.split(':');
36
+ exposedPort = parseInt(parts[parts.length - 1].replace(/[^0-9]/g, ''), 10);
37
+ }
38
+
39
+ // Normalize environment variables from Array or Object
40
+ let envVars = {};
41
+ if (Array.isArray(config.environment)) {
42
+ config.environment.forEach(env => {
43
+ const [key, ...val] = env.split('=');
44
+ if (key) envVars[key] = val.join('=');
45
+ });
46
+ } else if (typeof config.environment === 'object') {
47
+ envVars = config.environment;
48
+ }
49
+
50
+ services.push({
51
+ name,
52
+ build: config.build || null,
53
+ image: config.image || null,
54
+ port: exposedPort,
55
+ command: config.command || null,
56
+ environment: envVars
57
+ });
58
+ }
59
+
60
+ return services;
61
+ } catch (e) {
62
+ console.error('⚠️ Failed to parse Docker Compose file:', e.message);
63
+ return null;
64
+ }
65
+ }
@@ -0,0 +1,73 @@
1
+ // Shared domain/zone/email validation and `domain.tf` parsing for the
2
+ // `domain` command and `add email:ses`. Both commands consume this module
3
+ // so their normalization rules can never drift apart. Dependency-free.
4
+ export const DOMAIN_REGEX = /^(?=.{1,253}$)(\*\.)?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/;
5
+ export const ZONE_ID_REGEX = /^(?:\/hostedzone\/)?(Z[A-Z0-9]{1,32})$/;
6
+ export const FROM_EMAIL_REGEX = /^[^\s@"'\\]+@[^\s@"'\\]+\.[^\s@"'\\]+$/;
7
+
8
+ export const DOMAIN_MODES = ['route53', 'external-pending', 'external-active'];
9
+
10
+ // Trims whitespace, strips one trailing FQDN dot, and lowercases.
11
+ // Non-string input normalizes to '' so callers fail validation instead
12
+ // of throwing.
13
+ export function normalizeDomain(input) {
14
+ if (typeof input !== 'string') return '';
15
+ return input.trim().toLowerCase().replace(/\.$/, '');
16
+ }
17
+
18
+ // Validates a normalized domain (with an optional `*.` wildcard prefix).
19
+ export function isValidDomain(domain) {
20
+ return DOMAIN_REGEX.test(normalizeDomain(domain));
21
+ }
22
+
23
+ // Strips an optional `/hostedzone/` prefix (as copied from the AWS
24
+ // console) and validates the `Z...` shape. Returns the canonical zone
25
+ // ID, or null when invalid.
26
+ export function normalizeZoneId(input) {
27
+ if (typeof input !== 'string') return null;
28
+ const match = ZONE_ID_REGEX.exec(input.trim());
29
+ return match ? match[1] : null;
30
+ }
31
+
32
+ // Validates sender-address syntax and that the address belongs to
33
+ // `expectedDomain` (or one of its subdomains), so SES sends never fail
34
+ // at runtime against an unverified identity. Wildcards are rejected on
35
+ // both sides: SES domain identities do not support them.
36
+ export function isValidFromEmail(email, expectedDomain) {
37
+ if (typeof email !== 'string' || typeof expectedDomain !== 'string') return false;
38
+ const address = email.trim();
39
+ if (!FROM_EMAIL_REGEX.test(address)) return false;
40
+ if (address.includes('*')) return false;
41
+ const domain = normalizeDomain(expectedDomain);
42
+ if (!domain || domain.includes('*')) return false;
43
+ const addressDomain = address.slice(address.lastIndexOf('@') + 1).toLowerCase();
44
+ return addressDomain === domain || addressDomain.endsWith(`.${domain}`);
45
+ }
46
+
47
+ // Parses a `terraform/domain.tf` document into `{ domain, mode, zoneId }`
48
+ // (fields are null when absent). Mode comes from the
49
+ // `# grada:domain-mode=<mode>` marker (legacy `# deploy-stack:` files
50
+ // still parse) with structural fallbacks for hand-edited files; the
51
+ // zone ID prefers the explicit `# grada:zone-id=<id>` marker over
52
+ // `zone_id` attributes.
53
+ export function parseDomainTf(content) {
54
+ const text = String(content ?? '');
55
+ if (!text.trim()) return null;
56
+ const modeMatch = text.match(/#\s*(?:grada|deploy-stack):domain-mode=([a-z-]+)/);
57
+ let mode = modeMatch && DOMAIN_MODES.includes(modeMatch[1]) ? modeMatch[1] : null;
58
+ if (!mode) {
59
+ if (text.includes('"cdn_alias_a"')) mode = 'route53';
60
+ else if (text.includes('"aws_acm_certificate_validation"')) mode = 'external-active';
61
+ else if (text.includes('"aws_acm_certificate"')) mode = 'external-pending';
62
+ }
63
+ const domainMatch = text.match(/domain_name\s*=\s*"([^"]+)"/);
64
+ const domain = domainMatch ? normalizeDomain(domainMatch[1]) : null;
65
+ let zoneId = null;
66
+ const zoneMarker = text.match(/#\s*(?:grada|deploy-stack):zone-id=([A-Za-z0-9/_-]+)/);
67
+ if (zoneMarker) zoneId = normalizeZoneId(zoneMarker[1]);
68
+ if (!zoneId) {
69
+ const zoneAttr = text.match(/zone_id\s*=\s*"([^"]+)"/);
70
+ if (zoneAttr) zoneId = normalizeZoneId(zoneAttr[1]);
71
+ }
72
+ return { domain: domain || null, mode, zoneId };
73
+ }