@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.
- package/.github/workflows/deploy-docs.yml +37 -0
- package/.github/workflows/e2e.yml +73 -0
- package/.github/workflows/iac-validation.yml +303 -0
- package/.github/workflows/publish.yml +68 -0
- package/.github/workflows/sync-bedrock-models.yml +57 -0
- package/.github/workflows/test.yml +43 -0
- package/.muserules +31 -0
- package/LICENSE +21 -0
- package/README.md +193 -3
- package/apps/docs/.astro/collections/docs.schema.json +644 -0
- package/apps/docs/.astro/content-assets.mjs +4 -0
- package/apps/docs/.astro/content-modules.mjs +4 -0
- package/apps/docs/.astro/content.d.ts +179 -0
- package/apps/docs/.astro/data-store.json +1 -0
- package/apps/docs/.astro/dev.json +14 -0
- package/apps/docs/.astro/settings.json +5 -0
- package/apps/docs/.astro/types.d.ts +2 -0
- package/apps/docs/astro.config.mjs +97 -0
- package/apps/docs/package.json +17 -0
- package/apps/docs/src/content/docs/adrs/0001-s3-native-state-locking.md +37 -0
- package/apps/docs/src/content/docs/adrs/0002-eject-mechanism-pure-iac.md +39 -0
- package/apps/docs/src/content/docs/adrs/0003-sync-ai-context-strategy.md +48 -0
- package/apps/docs/src/content/docs/adrs/0004-iac-driven-diagnostic-context.md +37 -0
- package/apps/docs/src/content/docs/adrs/0005-ecs-fargate-alb-runtime-target.md +38 -0
- package/apps/docs/src/content/docs/adrs/0006-github-oidc-no-stored-keys.md +37 -0
- package/apps/docs/src/content/docs/adrs/0007-framework-detection-with-fallback.md +37 -0
- package/apps/docs/src/content/docs/adrs/0008-secrets-names-in-git-values-in-aws.md +37 -0
- package/apps/docs/src/content/docs/adrs/0009-regenerate-with-backup-on-rerun.md +37 -0
- package/apps/docs/src/content/docs/adrs/0010-advisory-only-security-scans.md +37 -0
- package/apps/docs/src/content/docs/cli/add.md +85 -0
- package/apps/docs/src/content/docs/cli/apply.md +35 -0
- package/apps/docs/src/content/docs/cli/db.md +200 -0
- package/apps/docs/src/content/docs/cli/destroy.md +35 -0
- package/apps/docs/src/content/docs/cli/diagnose.md +37 -0
- package/apps/docs/src/content/docs/cli/doctor.md +28 -0
- package/apps/docs/src/content/docs/cli/domain.md +57 -0
- package/apps/docs/src/content/docs/cli/drift.md +40 -0
- package/apps/docs/src/content/docs/cli/eject.md +33 -0
- package/apps/docs/src/content/docs/cli/exec.md +49 -0
- package/apps/docs/src/content/docs/cli/gc.md +37 -0
- package/apps/docs/src/content/docs/cli/init.md +72 -0
- package/apps/docs/src/content/docs/cli/logs.md +39 -0
- package/apps/docs/src/content/docs/cli/rollback.md +51 -0
- package/apps/docs/src/content/docs/cli/secrets.md +73 -0
- package/apps/docs/src/content/docs/cli/sleep.md +53 -0
- package/apps/docs/src/content/docs/cli/status.md +34 -0
- package/apps/docs/src/content/docs/cli/sync-ai.md +27 -0
- package/apps/docs/src/content/docs/guides/architecture.md +87 -0
- package/apps/docs/src/content/docs/guides/aws-credentials.md +72 -0
- package/apps/docs/src/content/docs/guides/background-workers.md +45 -0
- package/apps/docs/src/content/docs/guides/cicd-pipeline.md +64 -0
- package/apps/docs/src/content/docs/guides/database-connections.md +64 -0
- package/apps/docs/src/content/docs/guides/docker-compose.md +37 -0
- package/apps/docs/src/content/docs/guides/dockerfiles.md +46 -0
- package/apps/docs/src/content/docs/guides/ephemeral-pr-previews.md +39 -0
- package/apps/docs/src/content/docs/guides/examples.md +50 -0
- package/apps/docs/src/content/docs/guides/frameworks.md +88 -0
- package/apps/docs/src/content/docs/guides/headless.md +87 -0
- package/apps/docs/src/content/docs/guides/quickstart.md +52 -0
- package/apps/docs/src/content/docs/guides/rerun-init.md +43 -0
- package/apps/docs/src/content/docs/guides/secrets-management.md +83 -0
- package/apps/docs/src/content/docs/guides/understanding-your-bill.md +63 -0
- package/apps/docs/src/content/docs/index.mdx +103 -0
- package/apps/docs/src/content/docs/migrations/astro-vercel-to-aws.md +55 -0
- package/apps/docs/src/content/docs/migrations/heroku-procfile-to-aws.md +41 -0
- package/apps/docs/src/content/docs/migrations/nextjs-vercel-to-aws.md +51 -0
- package/apps/docs/src/content/docs/migrations/sveltekit-vercel-to-aws.md +63 -0
- package/apps/docs/src/content/docs/roadmap.md +99 -0
- package/apps/docs/src/content/docs/testing-strategy.md +37 -0
- package/apps/docs/src/content.config.ts +7 -0
- package/apps/docs/src/custom.css +14 -0
- package/apps/docs/tsconfig.json +6 -0
- package/bin/cli.js +140 -0
- package/package.json +107 -7
- package/scripts/sync-bedrock-models.js +22 -0
- package/scripts/test-iac.js +261 -0
- package/specs/add-redis-sqs-bedrock.md +128 -0
- package/specs/add-storage-dynamodb.md +106 -0
- package/specs/bedrock-model-catalog.md +131 -0
- package/specs/ci-pipeline.md +17 -0
- package/specs/cost-transparency.md +115 -0
- package/specs/custom-domains-and-ses.md +153 -0
- package/specs/database-suite-expansion.md +151 -0
- package/specs/db-connect.md +69 -0
- package/specs/db-lifecycle-migrations.md +159 -0
- package/specs/dependency-aware-init.md +176 -0
- package/specs/deployment-safety.md +170 -0
- package/specs/diagnose.md +16 -0
- package/specs/docs-hub.md +16 -0
- package/specs/dx-polish.md +46 -0
- package/specs/e2e-testing.md +50 -0
- package/specs/exec.md +25 -0
- package/specs/finops-cron-drift.md +161 -0
- package/specs/gc.md +26 -0
- package/specs/integration-suite.md +16 -0
- package/specs/logs.md +32 -0
- package/specs/rollback-live-polling.md +40 -0
- package/specs/secrets-pull-audit.md +51 -0
- package/specs/serverless-lambda-target.md +133 -0
- package/specs/status.md +31 -0
- package/specs/telemetry-and-spawn-hardening.md +69 -0
- package/specs/telemetry-hardening.md +35 -0
- package/specs/test-suite-deduplication.md +42 -0
- package/src/commands/add.js +1111 -0
- package/src/commands/apply.js +214 -0
- package/src/commands/db/backup.js +229 -0
- package/src/commands/db/connect.js +304 -0
- package/src/commands/db/enable-vector.js +344 -0
- package/src/commands/db/import.js +604 -0
- package/src/commands/db/migrate.js +477 -0
- package/src/commands/db/restore.js +361 -0
- package/src/commands/db.js +87 -0
- package/src/commands/destroy.js +217 -0
- package/src/commands/diagnose.js +460 -0
- package/src/commands/doctor.js +109 -0
- package/src/commands/domain.js +685 -0
- package/src/commands/drift.js +243 -0
- package/src/commands/eject.js +130 -0
- package/src/commands/exec.js +222 -0
- package/src/commands/gc.js +250 -0
- package/src/commands/init.js +649 -0
- package/src/commands/logs.js +256 -0
- package/src/commands/rollback.js +323 -0
- package/src/commands/secrets.js +485 -0
- package/src/commands/sleep.js +347 -0
- package/src/commands/status.js +309 -0
- package/src/commands/sync-ai.js +115 -0
- package/src/commands/wake.js +337 -0
- package/src/core/parser.js +128 -0
- package/src/core/telemetry.js +244 -0
- package/src/data/bedrock-models.json +896 -0
- package/src/utils/addons.js +126 -0
- package/src/utils/ai-rules.js +59 -0
- package/src/utils/args.js +91 -0
- package/src/utils/aws.js +178 -0
- package/src/utils/backup.js +69 -0
- package/src/utils/bedrock-catalog.js +511 -0
- package/src/utils/capabilities.js +500 -0
- package/src/utils/command.js +78 -0
- package/src/utils/db-tunnel.js +164 -0
- package/src/utils/detector.js +298 -0
- package/src/utils/dockerCompose.js +65 -0
- package/src/utils/domains.js +73 -0
- package/src/utils/ecs-runner.js +289 -0
- package/src/utils/ecs.js +92 -0
- package/src/utils/frameworks.js +55 -0
- package/src/utils/generator.js +527 -0
- package/src/utils/hcl.js +426 -0
- package/src/utils/lambda-ecr.js +185 -0
- package/src/utils/prompts.js +278 -0
- package/src/utils/rds.js +131 -0
- package/src/utils/resolvers.js +174 -0
- package/src/utils/sleep-state.js +140 -0
- package/src/utils/sleep-targets.js +139 -0
- package/src/utils/system.js +42 -0
- package/src/utils/terraform.js +70 -0
- package/src/utils/visualizer.js +381 -0
- package/src/utils/warnings.js +49 -0
- package/templates/README.md +150 -0
- package/templates/docker/django.Dockerfile +40 -0
- package/templates/docker/go.Dockerfile +23 -0
- package/templates/docker/nestjs.Dockerfile +33 -0
- package/templates/docker/nextjs.Dockerfile +55 -0
- package/templates/docker/node.Dockerfile +24 -0
- package/templates/docker/nuxt.Dockerfile +47 -0
- package/templates/docker/python.Dockerfile +38 -0
- package/templates/docker/rails.Dockerfile +59 -0
- package/templates/docker/static.Dockerfile +32 -0
- package/templates/docker/svelte.Dockerfile +52 -0
- package/templates/github/deploy-lambda.yml +120 -0
- package/templates/github/deploy.yml +138 -0
- package/templates/github/drift.yml +112 -0
- package/templates/github/preview-lambda.yml +86 -0
- package/templates/github/preview.yml +69 -0
- package/templates/github/teardown.yml +43 -0
- package/templates/terraform/addons/bedrock.tf +34 -0
- package/templates/terraform/addons/cron-lambda.tf +78 -0
- package/templates/terraform/addons/cron.tf +101 -0
- package/templates/terraform/addons/dynamodb.tf +73 -0
- package/templates/terraform/addons/redis.tf +64 -0
- package/templates/terraform/addons/s3.tf +143 -0
- package/templates/terraform/addons/ses.tf +73 -0
- package/templates/terraform/addons/sqs.tf +67 -0
- package/templates/terraform/backend.tf +22 -0
- package/templates/terraform/cloudfront-lambda.tf +80 -0
- package/templates/terraform/cloudfront.tf +80 -0
- package/templates/terraform/database-aurora-postgresql.tf +92 -0
- package/templates/terraform/database-mysql.tf +72 -0
- package/templates/terraform/database.tf +71 -0
- package/templates/terraform/main-lambda.tf +229 -0
- package/templates/terraform/main.tf +296 -0
- package/templates/terraform/network.tf +95 -0
- package/templates/terraform/oidc.tf +64 -0
- package/templates/terraform/secrets.tf +31 -0
- package/templates/terraform/worker.tf +69 -0
- package/tests/__snapshots__/generator.test.js.snap +9633 -0
- package/tests/add.test.js +2018 -0
- package/tests/ai.test.js +91 -0
- package/tests/apply.test.js +446 -0
- package/tests/args.test.js +86 -0
- package/tests/aws.test.js +244 -0
- package/tests/capabilities.test.js +304 -0
- package/tests/cli.test.js +29 -0
- package/tests/command.test.js +99 -0
- package/tests/commands-import.test.js +74 -0
- package/tests/db.test.js +2673 -0
- package/tests/destroy.test.js +365 -0
- package/tests/detector.test.js +79 -0
- package/tests/diagnose.test.js +765 -0
- package/tests/doctor.test.js +195 -0
- package/tests/domain.test.js +882 -0
- package/tests/drift.test.js +228 -0
- package/tests/e2e/helpers.js +122 -0
- package/tests/e2e/tier0.e2e.test.js +142 -0
- package/tests/e2e/tier1.live.e2e.test.js +118 -0
- package/tests/ecs.test.js +130 -0
- package/tests/eject.test.js +65 -0
- package/tests/exec.test.js +366 -0
- package/tests/gc.test.js +466 -0
- package/tests/generator.test.js +794 -0
- package/tests/headless.test.js +491 -0
- package/tests/helpers/clack.js +103 -0
- package/tests/helpers/console.js +41 -0
- package/tests/helpers/telemetry.js +37 -0
- package/tests/helpers/tmpdir.js +36 -0
- package/tests/lambda-ecr.test.js +185 -0
- package/tests/logs.test.js +436 -0
- package/tests/parser.test.js +160 -0
- package/tests/rds.test.js +244 -0
- package/tests/resolvers.test.js +279 -0
- package/tests/rollback.test.js +670 -0
- package/tests/secrets.test.js +729 -0
- package/tests/sleep-wake.test.js +998 -0
- package/tests/status.test.js +356 -0
- package/tests/system.test.js +70 -0
- package/tests/telemetry.test.js +520 -0
- package/tests/terraform.test.js +84 -0
- package/tests/visualizer.test.js +480 -0
- package/vitest.config.js +10 -0
- package/vitest.e2e.tier0.config.js +8 -0
- package/vitest.e2e.tier1.config.js +8 -0
- 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;
|