@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,685 @@
1
+ import fsSync from 'fs';
2
+ import fs from 'fs/promises';
3
+ import path from 'path';
4
+ import color from 'picocolors';
5
+ import { intro, outro, confirm, cancel, isCancel } from '@clack/prompts';
6
+ import { trackSuccess } from '../core/telemetry.js';
7
+ import { resolveProjectName, resolveHeadless, resolveCwd } from '../utils/resolvers.js';
8
+ import { parseFlags, normalizeOptions, normalizeArgv } from '../utils/args.js';
9
+ import { failCommand, failProjectNotInitialized } from '../utils/command.js';
10
+ import { getTerraformOutputs } from '../utils/terraform.js';
11
+ import {
12
+ findResourceBlock,
13
+ findNestedBlock,
14
+ replaceBlock,
15
+ upsertAttribute,
16
+ removeAttribute,
17
+ getAttributeValue,
18
+ } from '../utils/hcl.js';
19
+ import { normalizeDomain, isValidDomain, normalizeZoneId, parseDomainTf } from '../utils/domains.js';
20
+
21
+ export const DOMAIN_SUBCOMMANDS = ['add', 'verify', 'activate', 'status', 'remove'];
22
+
23
+ // --- Pure HCL builders for terraform/domain.tf ---
24
+
25
+ function acmCertificateBlock(domain) {
26
+ return `resource "aws_acm_certificate" "domain" {
27
+ count = terraform.workspace == "default" ? 1 : 0
28
+ provider = aws.us_east_1
29
+ domain_name = "${domain}"
30
+ validation_method = "DNS"
31
+
32
+ lifecycle {
33
+ create_before_destroy = true
34
+ }
35
+ }`;
36
+ }
37
+
38
+ function usEast1ProviderBlock() {
39
+ return `# tflint-ignore: terraform_unused_declarations
40
+ provider "aws" {
41
+ alias = "us_east_1"
42
+ region = "us-east-1"
43
+
44
+ default_tags {
45
+ tags = {
46
+ ManagedBy = "grada"
47
+ }
48
+ }
49
+ }`;
50
+ }
51
+
52
+ // Appends the us_east_1 provider alias to main.tf when missing, so projects
53
+ // that configured a domain before the provider moved out of domain.tf keep a
54
+ // provider configuration Terraform can use to destroy the orphaned
55
+ // certificate. No-op when main.tf is absent or already declares the alias.
56
+ export function ensureUsEast1Provider(mainTfPath) {
57
+ if (!fsSync.existsSync(mainTfPath)) return false;
58
+ const content = fsSync.readFileSync(mainTfPath, 'utf-8');
59
+ if (/alias\s*=\s*"us_east_1"/.test(content)) return false;
60
+ const suffix = content.endsWith('\n') ? '' : '\n';
61
+ fsSync.writeFileSync(mainTfPath, `${content}${suffix}\n${usEast1ProviderBlock()}\n`);
62
+ return true;
63
+ }
64
+
65
+ // Route 53 1-step mode: validation records, certificate validation, and
66
+ // CloudFront alias records are all managed in one apply.
67
+ export function renderDomainTfRoute53({ domain, zoneId }) {
68
+ return `# grada:domain-mode=route53
69
+ # grada:zone-id=${zoneId}
70
+ # Managed by \`grada domain\`. Do not edit by hand — re-running
71
+ # \`domain add --force\` overwrites this file.
72
+
73
+ ${acmCertificateBlock(domain)}
74
+
75
+ resource "aws_route53_record" "domain_validation" {
76
+ for_each = terraform.workspace == "default" ? { for dvo in aws_acm_certificate.domain[0].domain_validation_options : dvo.domain_name => { name = dvo.resource_record_name, record = dvo.resource_record_value, type = dvo.resource_record_type } } : {}
77
+
78
+ zone_id = "${zoneId}"
79
+ allow_overwrite = true
80
+ ttl = 60
81
+ name = each.value.name
82
+ type = each.value.type
83
+ records = [each.value.record]
84
+ }
85
+
86
+ resource "aws_acm_certificate_validation" "domain" {
87
+ count = terraform.workspace == "default" ? 1 : 0
88
+ provider = aws.us_east_1
89
+ certificate_arn = aws_acm_certificate.domain[0].arn
90
+ validation_record_fqdns = [for record in aws_route53_record.domain_validation : record.fqdn]
91
+ }
92
+
93
+ resource "aws_route53_record" "cdn_alias_a" {
94
+ count = terraform.workspace == "default" ? 1 : 0
95
+ zone_id = "${zoneId}"
96
+ name = "${domain}"
97
+ type = "A"
98
+
99
+ alias {
100
+ name = aws_cloudfront_distribution.cdn.domain_name
101
+ zone_id = aws_cloudfront_distribution.cdn.hosted_zone_id
102
+ evaluate_target_health = false
103
+ }
104
+ }
105
+
106
+ resource "aws_route53_record" "cdn_alias_aaaa" {
107
+ count = terraform.workspace == "default" ? 1 : 0
108
+ zone_id = "${zoneId}"
109
+ name = "${domain}"
110
+ type = "AAAA"
111
+
112
+ alias {
113
+ name = aws_cloudfront_distribution.cdn.domain_name
114
+ zone_id = aws_cloudfront_distribution.cdn.hosted_zone_id
115
+ evaluate_target_health = false
116
+ }
117
+ }
118
+
119
+ output "custom_domain_url" {
120
+ value = "https://${domain}"
121
+ }
122
+
123
+ output "acm_certificate_arn" {
124
+ value = terraform.workspace == "default" ? aws_acm_certificate.domain[0].arn : null
125
+ }
126
+ `;
127
+ }
128
+
129
+ // External-DNS Stage 1: only the certificate is created (in seconds, with
130
+ // no DNS dependency). CloudFront stays on the default certificate until
131
+ // \`domain verify\` activates the domain.
132
+ export function renderDomainTfExternalPending({ domain }) {
133
+ return `# grada:domain-mode=external-pending
134
+ # Managed by \`grada domain\`. Do not edit by hand — re-running
135
+ # \`domain add --force\` overwrites this file.
136
+
137
+ ${acmCertificateBlock(domain)}
138
+
139
+ output "acm_validation_records" {
140
+ value = terraform.workspace == "default" ? [for dvo in aws_acm_certificate.domain[0].domain_validation_options : { name = dvo.resource_record_name, type = dvo.resource_record_type, value = dvo.resource_record_value }] : []
141
+ }
142
+
143
+ output "custom_domain_cname_target" {
144
+ value = aws_cloudfront_distribution.cdn.domain_name
145
+ }
146
+ `;
147
+ }
148
+
149
+ // Idempotently transitions external-pending Stage 1 content to
150
+ // external-active by appending the validation resource (which waits for
151
+ // the user-created DNS records) and the domain URL output. No-op for
152
+ // content that is already active.
153
+ export function appendValidationResource(domainTfContent) {
154
+ const content = String(domainTfContent ?? '');
155
+ if (!content.trim()) return content;
156
+ let updated = content;
157
+ if (updated.includes('"aws_acm_certificate_validation"')) {
158
+ if (!updated.includes('output "custom_domain_url"')) {
159
+ if (!updated.endsWith('\n')) updated += '\n';
160
+ updated += `
161
+ output "custom_domain_url" {
162
+ value = "https://${parseDomainTf(updated)?.domain || ''}"
163
+ }
164
+ `;
165
+ }
166
+ } else {
167
+ if (!updated.endsWith('\n')) updated += '\n';
168
+ updated += `
169
+ resource "aws_acm_certificate_validation" "domain" {
170
+ count = terraform.workspace == "default" ? 1 : 0
171
+ provider = aws.us_east_1
172
+ certificate_arn = aws_acm_certificate.domain[0].arn
173
+ }
174
+
175
+ output "custom_domain_url" {
176
+ value = "https://${parseDomainTf(updated)?.domain || ''}"
177
+ }
178
+ `;
179
+ }
180
+ return updated.replace(
181
+ /#\s*(?:grada|deploy-stack):domain-mode=external-pending/,
182
+ '# grada:domain-mode=external-active'
183
+ );
184
+ }
185
+
186
+ // --- Pure CloudFront patchers for terraform/cloudfront.tf ---
187
+
188
+ function parseHclStringList(value) {
189
+ const items = [];
190
+ for (const match of String(value ?? '').matchAll(/"([^"]*)"/g)) {
191
+ if (match[1] !== '') items.push(match[1]);
192
+ }
193
+ return items;
194
+ }
195
+
196
+ // Renders `aliases` in workspace-conditional form so PR preview
197
+ // workspaces never claim the production alias (which would fail with
198
+ // CNAMEAlreadyExists). An empty list degrades to a plain `[]`.
199
+ function renderConditionalAliasesList(domains) {
200
+ const list = `[${domains.map((domain) => `"${domain}"`).join(', ')}]`;
201
+ if (domains.length === 0) return list;
202
+ return `terraform.workspace == "default" ? ${list} : []`;
203
+ }
204
+
205
+ // Extracts alias domains from an `aliases` value, reading only the
206
+ // true-branch list in conditional form so `"default"` is never misread
207
+ // as an alias domain. Plain lists parse whole (legacy/hand-edited files).
208
+ function parseAliasesValue(value) {
209
+ const text = String(value ?? '');
210
+ const match = text.match(/\?\s*\[([\s\S]*)\]\s*:\s*\[\s*\]\s*$/);
211
+ return parseHclStringList(match ? `[${match[1]}]` : text);
212
+ }
213
+
214
+ // Replaces the whole `viewer_certificate { ... }` statement (attributes
215
+ // differ per mode and are mutually exclusive, so merging is never
216
+ // correct). Inserted before the resource close when absent.
217
+ function replaceViewerCertificate(block, innerLines) {
218
+ const replacement = [
219
+ ' viewer_certificate {',
220
+ ...innerLines.map((line) => ` ${line}`),
221
+ ' }',
222
+ ].join('\n');
223
+ const viewer = findNestedBlock(block, 'viewer_certificate');
224
+ if (!viewer) {
225
+ const closeIdx = block.lastIndexOf('}');
226
+ if (closeIdx === -1) return block;
227
+ const glue = closeIdx > 0 && block[closeIdx - 1] === '\n' ? '' : '\n';
228
+ return `${block.slice(0, closeIdx)}${glue}${replacement}\n${block.slice(closeIdx)}`;
229
+ }
230
+ const lineStart = block.lastIndexOf('\n', viewer.startIdx) + 1;
231
+ return block.slice(0, lineStart) + replacement + block.slice(viewer.closeIdx + 1);
232
+ }
233
+
234
+ const ACM_VIEWER_CERTIFICATE = [
235
+ 'cloudfront_default_certificate = terraform.workspace != "default"',
236
+ 'acm_certificate_arn = terraform.workspace == "default" ? aws_acm_certificate_validation.domain[0].certificate_arn : null',
237
+ 'ssl_support_method = terraform.workspace == "default" ? "sni-only" : null',
238
+ 'minimum_protocol_version = terraform.workspace == "default" ? "TLSv1.2_2021" : "TLSv1"',
239
+ ];
240
+
241
+ const DEFAULT_VIEWER_CERTIFICATE = ['cloudfront_default_certificate = true'];
242
+
243
+ // Adds `domain` to the workspace-conditional `aliases` of
244
+ // `aws_cloudfront_distribution "cdn"` (merging with user-added entries
245
+ // in the true-branch list) and switches `viewer_certificate` to the
246
+ // validated ACM certificate in production, default certificate in
247
+ // previews. Idempotent; returns the content unchanged when the
248
+ // distribution is missing.
249
+ export function patchCloudFrontDomain(cloudfrontTfContent, domain) {
250
+ const content = String(cloudfrontTfContent ?? '');
251
+ const resource = findResourceBlock(content, 'aws_cloudfront_distribution', 'cdn');
252
+ if (!resource) return content;
253
+ const head = content.slice(0, resource.openIdx);
254
+ const tail = content.slice(resource.closeIdx + 1);
255
+ let block = content.slice(resource.openIdx, resource.closeIdx + 1);
256
+ const current = getAttributeValue(block, 'aliases');
257
+ const aliases = current === null ? [] : parseAliasesValue(current);
258
+ if (!aliases.includes(domain)) {
259
+ block = upsertAttribute(block, 'aliases', renderConditionalAliasesList([...aliases, domain]));
260
+ }
261
+ block = replaceViewerCertificate(block, ACM_VIEWER_CERTIFICATE);
262
+ return head + block + tail;
263
+ }
264
+
265
+ // Removes `domain` from the `aliases` (dropping the attribute when no
266
+ // aliases remain) and restores the default CloudFront certificate.
267
+ // Idempotent; returns the content unchanged when the distribution or
268
+ // the domain wiring is missing.
269
+ export function unpatchCloudFrontDomain(cloudfrontTfContent, domain) {
270
+ const content = String(cloudfrontTfContent ?? '');
271
+ const resource = findResourceBlock(content, 'aws_cloudfront_distribution', 'cdn');
272
+ if (!resource) return content;
273
+ const head = content.slice(0, resource.openIdx);
274
+ const tail = content.slice(resource.closeIdx + 1);
275
+ let block = content.slice(resource.openIdx, resource.closeIdx + 1);
276
+ const current = getAttributeValue(block, 'aliases');
277
+ if (current !== null) {
278
+ const remaining = parseAliasesValue(current).filter((entry) => entry !== domain);
279
+ block = remaining.length === 0
280
+ ? removeAttribute(block, 'aliases')
281
+ : upsertAttribute(block, 'aliases', renderConditionalAliasesList(remaining));
282
+ }
283
+ block = replaceViewerCertificate(block, DEFAULT_VIEWER_CERTIFICATE);
284
+ return head + block + tail;
285
+ }
286
+
287
+ // True when `domain` is present in the `aliases` of the CloudFront
288
+ // distribution (used by `domain status`).
289
+ export function isCloudFrontWired(cloudfrontTfContent, domain) {
290
+ const content = String(cloudfrontTfContent ?? '');
291
+ const resource = findResourceBlock(content, 'aws_cloudfront_distribution', 'cdn');
292
+ if (!resource) return false;
293
+ const block = content.slice(resource.openIdx, resource.closeIdx + 1);
294
+ const current = getAttributeValue(block, 'aliases');
295
+ if (current === null) return false;
296
+ return parseAliasesValue(current).includes(domain);
297
+ }
298
+
299
+ // --- CLI surface ---
300
+
301
+ export function parseDomainArgs(argv = []) {
302
+ const args = normalizeArgv(argv);
303
+ if (args[0] === 'domain') args.shift();
304
+ const { options: parsed, rest } = parseFlags(args, {
305
+ string: ['zone-id'],
306
+ boolean: ['force', 'activate', 'yes', { name: 'headless', key: 'isHeadless' }],
307
+ });
308
+ const positionals = rest.filter((arg) => typeof arg === 'string' && !arg.startsWith('-'));
309
+ return {
310
+ subcommand: positionals[0],
311
+ domain: positionals[1],
312
+ zoneId: parsed.zoneId,
313
+ activate: parsed.activate === true,
314
+ force: parsed.force === true,
315
+ yes: parsed.yes === true,
316
+ isHeadless: parsed.isHeadless === true,
317
+ };
318
+ }
319
+
320
+ function printDomainUsage() {
321
+ console.log('Usage:');
322
+ console.log(' grada domain add <domain> [--zone-id <id>] [--activate] [--force]');
323
+ console.log(' grada domain verify [--headless] (alias: activate)');
324
+ console.log(' grada domain status');
325
+ console.log(' grada domain remove [--yes]');
326
+ }
327
+
328
+ // Unwraps `terraform output -json` values (`{ name: { value } }`),
329
+ // tolerating already-unwrapped values from test doubles.
330
+ function outputValue(outputs, name) {
331
+ const entry = outputs?.[name];
332
+ if (entry !== null && typeof entry === 'object' && !Array.isArray(entry) && 'value' in entry) {
333
+ return entry.value;
334
+ }
335
+ return entry;
336
+ }
337
+
338
+ // Renders copy-paste DNS rows for external providers. Columns size to
339
+ // content and names/values print untouched so records paste verbatim.
340
+ function printDnsTable(rows) {
341
+ const typeWidth = Math.max(4, ...rows.map((row) => row.type.length));
342
+ const nameWidth = Math.max(4, ...rows.map((row) => row.name.length));
343
+ console.log(` ${'Type'.padEnd(typeWidth)} ${'Name'.padEnd(nameWidth)} Value`);
344
+ for (const row of rows) {
345
+ console.log(` ${color.cyan(row.type.padEnd(typeWidth))} ${row.name.padEnd(nameWidth)} ${color.dim(row.value)}`);
346
+ }
347
+ }
348
+
349
+ export async function runDomain(input = {}) {
350
+ const options = normalizeOptions(input);
351
+ const subcommand = typeof options.subcommand === 'string' ? options.subcommand.trim().toLowerCase() : undefined;
352
+
353
+ intro(color.bgCyan(color.black(' grada domain 🌐 ')));
354
+
355
+ if (!DOMAIN_SUBCOMMANDS.includes(subcommand)) {
356
+ // Closed enum only — never echo the raw user token into telemetry.
357
+ const telemetrySubcommand = typeof options.subcommand === 'string' && options.subcommand.trim() !== ''
358
+ ? 'unknown'
359
+ : 'none';
360
+ return failCommand({
361
+ print: () => {
362
+ if (subcommand === undefined) {
363
+ console.log(color.yellow('\n⚠ Missing domain subcommand.'));
364
+ } else {
365
+ console.log(color.red(`\n✖ Unknown domain subcommand "${options.subcommand}".`));
366
+ }
367
+ console.log('');
368
+ printDomainUsage();
369
+ console.log('');
370
+ },
371
+ event: 'domain_run',
372
+ telemetry: { subcommand: telemetrySubcommand },
373
+ errorCode: 'UNKNOWN_DOMAIN_SUBCOMMAND',
374
+ reason: 'unknown-domain-subcommand',
375
+ });
376
+ }
377
+
378
+ // Flag validation runs before filesystem guards (matching add.js).
379
+ let domain = null;
380
+ let zoneId = null;
381
+ if (subcommand === 'add') {
382
+ domain = normalizeDomain(options.domain);
383
+ if (!domain || !isValidDomain(domain)) {
384
+ return failCommand({
385
+ message: `\n✖ Invalid domain "${options.domain ?? ''}".`,
386
+ hint: ' Use a fully qualified domain name (e.g. grada domain add example.com).\n',
387
+ event: 'domain_run',
388
+ telemetry: { subcommand, error_code: 'INVALID_DOMAIN' },
389
+ reason: 'invalid-domain',
390
+ resultExtra: { subcommand },
391
+ });
392
+ }
393
+ if (options.zoneId !== undefined && options.zoneId !== null) {
394
+ zoneId = normalizeZoneId(options.zoneId);
395
+ if (zoneId === null) {
396
+ return failCommand({
397
+ message: `\n✖ Invalid Route 53 zone ID "${options.zoneId}".`,
398
+ hint: ' Zone IDs look like Z1234567890ABC (find yours with: aws route53 list-hosted-zones).\n',
399
+ event: 'domain_run',
400
+ telemetry: { subcommand, error_code: 'INVALID_ZONE_ID' },
401
+ reason: 'invalid-zone-id',
402
+ resultExtra: { subcommand },
403
+ });
404
+ }
405
+ }
406
+ }
407
+
408
+ let cwd;
409
+ let projectName;
410
+ try {
411
+ cwd = resolveCwd(options);
412
+ projectName = resolveProjectName(options, cwd);
413
+ } catch {
414
+ return failProjectNotInitialized({ event: 'domain_run' });
415
+ }
416
+
417
+ const terraformDir = path.join(cwd, 'terraform');
418
+ const domainTfPath = path.join(terraformDir, 'domain.tf');
419
+ const cloudfrontTfPath = path.join(terraformDir, 'cloudfront.tf');
420
+
421
+ // `remove` tolerates a missing cloudfront.tf (there may be nothing to
422
+ // unpatch); every other subcommand needs the scaffolded file.
423
+ if (subcommand !== 'remove' && !fsSync.existsSync(cloudfrontTfPath)) {
424
+ return failCommand({
425
+ message: '\n✖ No terraform/cloudfront.tf found. Run "grada" first before managing custom domains.\n',
426
+ event: 'domain_run',
427
+ telemetry: { subcommand, error_code: 'TERRAFORM_NOT_INITIALIZED' },
428
+ reason: 'terraform-not-initialized',
429
+ resultExtra: { subcommand },
430
+ });
431
+ }
432
+
433
+ if (subcommand === 'add') return runDomainAdd({ options, cwd, projectName, domain, zoneId, domainTfPath, cloudfrontTfPath });
434
+ if (subcommand === 'verify' || subcommand === 'activate') {
435
+ return runDomainVerify({ options, cwd, projectName, domainTfPath, cloudfrontTfPath });
436
+ }
437
+ if (subcommand === 'status') return runDomainStatus({ options, cwd, projectName, domainTfPath, cloudfrontTfPath });
438
+ return runDomainRemove({ options, cwd, projectName, domainTfPath, cloudfrontTfPath });
439
+ }
440
+
441
+ async function runDomainAdd({ options, projectName, domain, zoneId, domainTfPath, cloudfrontTfPath }) {
442
+ const force = options.force === true;
443
+ const activate = options.activate === true;
444
+ let previousDomain = null;
445
+ if (fsSync.existsSync(domainTfPath)) {
446
+ if (!force) {
447
+ return failCommand({
448
+ message: '\n⚠ A custom domain is already configured (terraform/domain.tf exists).',
449
+ hint: ' Pass --force to replace it, or run "grada domain verify" to activate.\n',
450
+ tone: 'yellow',
451
+ event: 'domain_run',
452
+ telemetry: { projectName, subcommand: 'add', error_code: 'DOMAIN_ALREADY_CONFIGURED' },
453
+ reason: 'domain-already-configured',
454
+ resultExtra: { subcommand: 'add', projectName },
455
+ exitCode: null,
456
+ });
457
+ }
458
+ try {
459
+ previousDomain = parseDomainTf(fsSync.readFileSync(domainTfPath, 'utf-8'))?.domain || null;
460
+ } catch {
461
+ previousDomain = null;
462
+ }
463
+ }
464
+
465
+ const mode = zoneId ? 'route53' : (activate ? 'external-active' : 'external-pending');
466
+ let content;
467
+ if (mode === 'route53') {
468
+ content = renderDomainTfRoute53({ domain, zoneId });
469
+ } else if (mode === 'external-active') {
470
+ content = appendValidationResource(renderDomainTfExternalPending({ domain }));
471
+ } else {
472
+ content = renderDomainTfExternalPending({ domain });
473
+ }
474
+ await fs.mkdir(path.dirname(domainTfPath), { recursive: true });
475
+ await fs.writeFile(domainTfPath, content);
476
+ ensureUsEast1Provider(path.join(path.dirname(domainTfPath), 'main.tf'));
477
+
478
+ let cloudfrontPatched = false;
479
+ if (mode !== 'external-pending') {
480
+ const patched = await patchCloudFrontFile(cloudfrontTfPath, domain, previousDomain, 'add');
481
+ if (!patched.ok) return patched.failure;
482
+ cloudfrontPatched = patched.changed;
483
+ }
484
+
485
+ if (mode === 'route53') {
486
+ console.log(color.green(`\n✅ Custom domain ${domain} configured with automated Route 53 validation.`));
487
+ console.log(` Run ${color.green('npx grada-run apply')} to issue the certificate and bind it to CloudFront.`);
488
+ } else if (mode === 'external-active') {
489
+ console.log(color.green(`\n✅ Custom domain ${domain} activated.`));
490
+ console.log(' Ensure your external DNS validation CNAMEs are in place, then run');
491
+ console.log(` ${color.green('npx grada-run apply')} to bind the certificate to CloudFront.`);
492
+ } else {
493
+ console.log(color.green(`\n✅ Custom domain ${domain} staged for external DNS verification.`));
494
+ console.log(` 1. Run ${color.green('npx grada-run apply')} to generate the ACM validation CNAMEs.`);
495
+ console.log(` 2. Add the CNAMEs at your DNS provider (see ${color.green('npx grada-run domain status')}).`);
496
+ console.log(` 3. Run ${color.green('npx grada-run domain verify')} to activate.`);
497
+ }
498
+ outro(color.green(`Domain ${domain} ready.`));
499
+ await trackSuccess('domain_run', { projectName, subcommand: 'add', mode });
500
+ return { ok: true, subcommand: 'add', mode, domain, file: 'terraform/domain.tf', cloudfrontPatched };
501
+ }
502
+
503
+ // Patches `cloudfront.tf`, replacing a previous managed domain on --force.
504
+ // Returns `{ ok: true, changed }` or `{ ok: false, failure }`.
505
+ async function patchCloudFrontFile(cloudfrontTfPath, domain, previousDomain, subcommand) {
506
+ let content;
507
+ try {
508
+ content = await fs.readFile(cloudfrontTfPath, 'utf-8');
509
+ } catch {
510
+ content = null;
511
+ }
512
+ if (content === null || !findResourceBlock(content, 'aws_cloudfront_distribution', 'cdn')) {
513
+ return {
514
+ ok: false,
515
+ failure: failCommand({
516
+ message: '\n✖ terraform/cloudfront.tf does not contain aws_cloudfront_distribution "cdn". The domain file was written but CloudFront could not be wired.\n',
517
+ event: 'domain_run',
518
+ telemetry: { subcommand, error_code: 'CLOUDFRONT_RESOURCE_NOT_FOUND' },
519
+ reason: 'cloudfront-resource-not-found',
520
+ resultExtra: { subcommand },
521
+ }),
522
+ };
523
+ }
524
+ let updated = content;
525
+ if (previousDomain && previousDomain !== domain) {
526
+ updated = unpatchCloudFrontDomain(updated, previousDomain);
527
+ }
528
+ updated = patchCloudFrontDomain(updated, domain);
529
+ const changed = updated !== content;
530
+ if (changed) await fs.writeFile(cloudfrontTfPath, updated);
531
+ return { ok: true, changed };
532
+ }
533
+
534
+ async function runDomainVerify({ projectName, domainTfPath, cloudfrontTfPath }) {
535
+ if (!fsSync.existsSync(domainTfPath)) {
536
+ return failCommand({
537
+ message: '\n✖ No custom domain configured. Run "grada domain add <domain>" first.\n',
538
+ event: 'domain_run',
539
+ telemetry: { projectName, subcommand: 'verify', error_code: 'DOMAIN_NOT_CONFIGURED' },
540
+ reason: 'domain-not-configured',
541
+ resultExtra: { subcommand: 'verify', projectName },
542
+ });
543
+ }
544
+ const content = fsSync.readFileSync(domainTfPath, 'utf-8');
545
+ const parsed = parseDomainTf(content);
546
+ const domain = parsed?.domain;
547
+ if (!domain) {
548
+ return failCommand({
549
+ message: '\n✖ terraform/domain.tf exists but no domain_name could be parsed from it.\n',
550
+ event: 'domain_run',
551
+ telemetry: { projectName, subcommand: 'verify', error_code: 'DOMAIN_NOT_CONFIGURED' },
552
+ reason: 'domain-not-configured',
553
+ resultExtra: { subcommand: 'verify', projectName },
554
+ });
555
+ }
556
+ const mode = parsed?.mode;
557
+ if (mode === 'route53' || mode === 'external-active') {
558
+ const patched = await patchCloudFrontFile(cloudfrontTfPath, domain, null, 'verify');
559
+ if (!patched.ok) return patched.failure;
560
+ console.log(color.green(`\n✅ Custom domain ${domain} is already active (${mode}).`));
561
+ outro(color.green('Nothing to do.'));
562
+ await trackSuccess('domain_run', { projectName, subcommand: 'verify', mode, alreadyActive: true });
563
+ return { ok: true, subcommand: 'verify', mode, domain, alreadyActive: true };
564
+ }
565
+ await fs.writeFile(domainTfPath, appendValidationResource(content));
566
+ const patched = await patchCloudFrontFile(cloudfrontTfPath, domain, null, 'verify');
567
+ if (!patched.ok) return patched.failure;
568
+ console.log(color.green(`\n✅ Custom domain ${domain} activated.`));
569
+ console.log(' Ensure your external DNS validation CNAMEs are in place before running');
570
+ console.log(` ${color.green('npx grada-run apply')} (apply waits on DNS propagation).`);
571
+ outro(color.green(`Domain ${domain} ready.`));
572
+ await trackSuccess('domain_run', { projectName, subcommand: 'verify', mode: 'external-active' });
573
+ return { ok: true, subcommand: 'verify', mode: 'external-active', domain };
574
+ }
575
+
576
+ async function runDomainStatus({ options, cwd, projectName, domainTfPath, cloudfrontTfPath }) {
577
+ if (!fsSync.existsSync(domainTfPath)) {
578
+ console.log('\nNo custom domain configured. Run npx grada-run domain add <domain> to get started.');
579
+ outro(color.green('No custom domain configured.'));
580
+ await trackSuccess('domain_run', { projectName, subcommand: 'status', configured: false });
581
+ return { ok: true, subcommand: 'status', configured: false };
582
+ }
583
+ const parsed = parseDomainTf(fsSync.readFileSync(domainTfPath, 'utf-8'));
584
+ const domain = parsed?.domain || '(unparseable)';
585
+ const mode = parsed?.mode || 'unknown';
586
+ let wired = false;
587
+ try {
588
+ if (fsSync.existsSync(cloudfrontTfPath)) {
589
+ wired = isCloudFrontWired(fsSync.readFileSync(cloudfrontTfPath, 'utf-8'), parsed?.domain);
590
+ }
591
+ } catch {
592
+ wired = false;
593
+ }
594
+ console.log(`\n ${color.bold('Domain:')} ${color.cyan(domain)}`);
595
+ console.log(` ${color.bold('Mode:')} ${mode}`);
596
+ console.log(` ${color.bold('CloudFront:')} ${wired ? color.green('wired') : color.yellow('not wired yet')}`);
597
+
598
+ const getOutputs = typeof options.getOutputs === 'function'
599
+ ? options.getOutputs
600
+ : (dir) => getTerraformOutputs(dir);
601
+ let outputs = {};
602
+ try {
603
+ outputs = await getOutputs(path.join(cwd, 'terraform')) || {};
604
+ } catch {
605
+ outputs = {};
606
+ }
607
+ const rows = [];
608
+ const records = outputValue(outputs, 'acm_validation_records');
609
+ if (Array.isArray(records)) {
610
+ for (const record of records) {
611
+ if (record && record.name && record.value) {
612
+ rows.push({ type: record.type || 'CNAME', name: String(record.name), value: String(record.value) });
613
+ }
614
+ }
615
+ }
616
+ const target = outputValue(outputs, 'custom_domain_cname_target') || outputValue(outputs, 'cloudfront_url');
617
+ if (typeof target === 'string' && target) {
618
+ rows.push({ type: 'CNAME', name: domain, value: target.replace(/^https:\/\//, '') });
619
+ }
620
+ if (rows.length > 0) {
621
+ console.log('');
622
+ console.log(` ${color.dim('Add these records at your DNS provider:')}`);
623
+ printDnsTable(rows);
624
+ } else {
625
+ console.log(` ${color.dim('No DNS outputs yet — run npx grada-run apply first.')}`);
626
+ }
627
+ outro(color.green(wired ? 'Domain is active on CloudFront.' : 'Run npx grada-run domain verify once DNS records are in place.'));
628
+ await trackSuccess('domain_run', { projectName, subcommand: 'status', configured: true, mode });
629
+ return { ok: true, subcommand: 'status', configured: true, domain: parsed?.domain || null, mode, wired };
630
+ }
631
+
632
+ async function runDomainRemove({ options, projectName, domainTfPath, cloudfrontTfPath }) {
633
+ if (!fsSync.existsSync(domainTfPath)) {
634
+ return failCommand({
635
+ message: '\n✖ No custom domain configured. Nothing to remove.\n',
636
+ event: 'domain_run',
637
+ telemetry: { projectName, subcommand: 'remove', error_code: 'DOMAIN_NOT_CONFIGURED' },
638
+ reason: 'domain-not-configured',
639
+ resultExtra: { subcommand: 'remove', projectName },
640
+ });
641
+ }
642
+ const parsed = parseDomainTf(fsSync.readFileSync(domainTfPath, 'utf-8'));
643
+ const domain = parsed?.domain;
644
+ const mode = parsed?.mode || 'unknown';
645
+ const yes = options.yes === true;
646
+ if (!yes && resolveHeadless(options)) {
647
+ return failCommand({
648
+ message: '\n✖ Refusing to remove the custom domain without confirmation. Re-run with --yes.\n',
649
+ event: 'domain_run',
650
+ telemetry: { projectName, subcommand: 'remove', error_code: 'CONFIRMATION_REQUIRED' },
651
+ reason: 'confirmation-required',
652
+ resultExtra: { subcommand: 'remove', projectName },
653
+ });
654
+ }
655
+ if (!yes) {
656
+ const answer = await confirm({ message: `Remove custom domain ${domain || '(unparseable)'} and restore the default CloudFront certificate?` });
657
+ if (isCancel(answer) || !answer) {
658
+ return failCommand({
659
+ print: () => cancel('Domain removal cancelled.'),
660
+ event: 'domain_run',
661
+ telemetry: { projectName, subcommand: 'remove', reason: 'cancelled' },
662
+ reason: 'cancelled',
663
+ exitCode: null,
664
+ });
665
+ }
666
+ }
667
+ try {
668
+ if (domain && fsSync.existsSync(cloudfrontTfPath)) {
669
+ const content = fsSync.readFileSync(cloudfrontTfPath, 'utf-8');
670
+ const updated = unpatchCloudFrontDomain(content, domain);
671
+ if (updated !== content) fsSync.writeFileSync(cloudfrontTfPath, updated);
672
+ }
673
+ } catch {
674
+ // A best-effort unpatch never blocks domain.tf removal.
675
+ }
676
+ ensureUsEast1Provider(path.join(path.dirname(domainTfPath), 'main.tf'));
677
+ fsSync.rmSync(domainTfPath, { force: true });
678
+ console.log(color.green(`\n✅ Custom domain ${domain || ''} removed; CloudFront restored to the default certificate.`.replace(' ', ' ')));
679
+ console.log(` Run ${color.green('npx grada-run apply')} to push the change.`);
680
+ outro(color.green('Done.'));
681
+ await trackSuccess('domain_run', { projectName, subcommand: 'remove', mode });
682
+ return { ok: true, subcommand: 'remove', removed: true, domain: domain || null };
683
+ }
684
+
685
+ export default runDomain;