@stacksjs/ts-cloud 0.1.8 → 0.1.9

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 (75) hide show
  1. package/dist/bin/cli.js +1 -1
  2. package/package.json +18 -16
  3. package/src/aws/acm.ts +768 -0
  4. package/src/aws/application-autoscaling.ts +845 -0
  5. package/src/aws/bedrock.ts +4074 -0
  6. package/src/aws/client.ts +891 -0
  7. package/src/aws/cloudformation.ts +896 -0
  8. package/src/aws/cloudfront.ts +1531 -0
  9. package/src/aws/cloudwatch-logs.ts +154 -0
  10. package/src/aws/comprehend.ts +839 -0
  11. package/src/aws/connect.ts +1056 -0
  12. package/src/aws/deploy-imap.ts +384 -0
  13. package/src/aws/dynamodb.ts +340 -0
  14. package/src/aws/ec2.ts +1385 -0
  15. package/src/aws/ecr.ts +621 -0
  16. package/src/aws/ecs.ts +615 -0
  17. package/src/aws/elasticache.ts +301 -0
  18. package/src/aws/elbv2.ts +942 -0
  19. package/src/aws/email.ts +928 -0
  20. package/src/aws/eventbridge.ts +248 -0
  21. package/src/aws/iam.ts +1689 -0
  22. package/src/aws/imap-server.ts +2100 -0
  23. package/src/aws/index.ts +213 -0
  24. package/src/aws/kendra.ts +1097 -0
  25. package/src/aws/lambda.ts +786 -0
  26. package/src/aws/opensearch.ts +158 -0
  27. package/src/aws/personalize.ts +977 -0
  28. package/src/aws/polly.ts +559 -0
  29. package/src/aws/rds.ts +888 -0
  30. package/src/aws/rekognition.ts +846 -0
  31. package/src/aws/route53-domains.ts +359 -0
  32. package/src/aws/route53.ts +1046 -0
  33. package/src/aws/s3.ts +2334 -0
  34. package/src/aws/scheduler.ts +571 -0
  35. package/src/aws/secrets-manager.ts +769 -0
  36. package/src/aws/ses.ts +1081 -0
  37. package/src/aws/setup-phone.ts +104 -0
  38. package/src/aws/setup-sms.ts +580 -0
  39. package/src/aws/sms.ts +1735 -0
  40. package/src/aws/smtp-server.ts +531 -0
  41. package/src/aws/sns.ts +758 -0
  42. package/src/aws/sqs.ts +382 -0
  43. package/src/aws/ssm.ts +807 -0
  44. package/src/aws/sts.ts +92 -0
  45. package/src/aws/support.ts +391 -0
  46. package/src/aws/test-imap.ts +86 -0
  47. package/src/aws/textract.ts +780 -0
  48. package/src/aws/transcribe.ts +108 -0
  49. package/src/aws/translate.ts +641 -0
  50. package/src/aws/voice.ts +1379 -0
  51. package/src/config.ts +35 -0
  52. package/src/deploy/index.ts +7 -0
  53. package/src/deploy/static-site-external-dns.ts +945 -0
  54. package/src/deploy/static-site.ts +1175 -0
  55. package/src/dns/cloudflare.ts +548 -0
  56. package/src/dns/godaddy.ts +412 -0
  57. package/src/dns/index.ts +205 -0
  58. package/src/dns/porkbun.ts +362 -0
  59. package/src/dns/route53-adapter.ts +414 -0
  60. package/src/dns/types.ts +119 -0
  61. package/src/dns/validator.ts +369 -0
  62. package/src/generators/index.ts +5 -0
  63. package/src/generators/infrastructure.ts +1660 -0
  64. package/src/index.ts +163 -0
  65. package/src/push/apns.ts +452 -0
  66. package/src/push/fcm.ts +506 -0
  67. package/src/push/index.ts +58 -0
  68. package/src/security/pre-deploy-scanner.ts +655 -0
  69. package/src/ssl/acme-client.ts +478 -0
  70. package/src/ssl/index.ts +7 -0
  71. package/src/ssl/letsencrypt.ts +747 -0
  72. package/src/types.ts +2 -0
  73. package/src/utils/cli.ts +398 -0
  74. package/src/validation/index.ts +5 -0
  75. package/src/validation/template.ts +405 -0
@@ -0,0 +1,945 @@
1
+ /**
2
+ * Static Site Deployment with External DNS Provider Support
3
+ * Deploys static sites to AWS (S3 + CloudFront + ACM) with DNS managed by external providers (Porkbun, GoDaddy, etc.)
4
+ */
5
+
6
+ import { CloudFormationClient } from '../aws/cloudformation'
7
+ import { S3Client } from '../aws/s3'
8
+ import { CloudFrontClient } from '../aws/cloudfront'
9
+ import { ACMClient } from '../aws/acm'
10
+ import type { DnsProvider, DnsProviderConfig } from '../dns/types'
11
+ import { createDnsProvider } from '../dns'
12
+ import { UnifiedDnsValidator } from '../dns/validator'
13
+
14
+ export interface ExternalDnsStaticSiteConfig {
15
+ /** Site name used for resource naming */
16
+ siteName: string
17
+ /** AWS region for S3 bucket */
18
+ region?: string
19
+ /** Custom domain (e.g., bunpress.org) */
20
+ domain: string
21
+ /** S3 bucket name (auto-generated if not provided) */
22
+ bucket?: string
23
+ /** ACM certificate ARN (auto-created if not provided) */
24
+ certificateArn?: string
25
+ /** CloudFormation stack name */
26
+ stackName?: string
27
+ /** Default root object */
28
+ defaultRootObject?: string
29
+ /** Error document */
30
+ errorDocument?: string
31
+ /** Cache control for assets */
32
+ cacheControl?: string
33
+ /** Tags to apply to resources */
34
+ tags?: Record<string, string>
35
+ /** DNS provider configuration */
36
+ dnsProvider: DnsProviderConfig
37
+ /** Skip DNS verification and record creation (use when DNS is already configured) */
38
+ skipDnsVerification?: boolean
39
+ }
40
+
41
+ export interface ExternalDnsDeployResult {
42
+ success: boolean
43
+ stackId?: string
44
+ stackName: string
45
+ bucket: string
46
+ distributionId?: string
47
+ distributionDomain?: string
48
+ domain?: string
49
+ certificateArn?: string
50
+ message: string
51
+ filesUploaded?: number
52
+ filesSkipped?: number
53
+ }
54
+
55
+ /**
56
+ * Generate CloudFormation template for static site infrastructure (without Route53)
57
+ */
58
+ export function generateExternalDnsStaticSiteTemplate(config: {
59
+ bucketName: string
60
+ domain?: string
61
+ aliases?: string[]
62
+ certificateArn?: string
63
+ defaultRootObject?: string
64
+ errorDocument?: string
65
+ }): object {
66
+ const {
67
+ bucketName,
68
+ domain,
69
+ aliases,
70
+ certificateArn,
71
+ defaultRootObject = 'index.html',
72
+ errorDocument = '404.html',
73
+ } = config
74
+
75
+ const resources: Record<string, any> = {}
76
+ const outputs: Record<string, any> = {}
77
+
78
+ // S3 Bucket
79
+ resources.S3Bucket = {
80
+ Type: 'AWS::S3::Bucket',
81
+ Properties: {
82
+ BucketName: bucketName,
83
+ PublicAccessBlockConfiguration: {
84
+ BlockPublicAcls: true,
85
+ BlockPublicPolicy: false,
86
+ IgnorePublicAcls: true,
87
+ RestrictPublicBuckets: false,
88
+ },
89
+ WebsiteConfiguration: {
90
+ IndexDocument: defaultRootObject,
91
+ ErrorDocument: errorDocument,
92
+ },
93
+ },
94
+ }
95
+
96
+ outputs.BucketName = {
97
+ Description: 'S3 Bucket Name',
98
+ Value: { Ref: 'S3Bucket' },
99
+ }
100
+
101
+ outputs.BucketArn = {
102
+ Description: 'S3 Bucket ARN',
103
+ Value: { 'Fn::GetAtt': ['S3Bucket', 'Arn'] },
104
+ }
105
+
106
+ // Origin Access Control
107
+ resources.CloudFrontOAC = {
108
+ Type: 'AWS::CloudFront::OriginAccessControl',
109
+ Properties: {
110
+ OriginAccessControlConfig: {
111
+ Name: `OAC-${bucketName}`,
112
+ Description: `OAC for ${bucketName}`,
113
+ OriginAccessControlOriginType: 's3',
114
+ SigningBehavior: 'always',
115
+ SigningProtocol: 'sigv4',
116
+ },
117
+ },
118
+ }
119
+
120
+ // CloudFront Function for URL rewriting
121
+ resources.UrlRewriteFunction = {
122
+ Type: 'AWS::CloudFront::Function',
123
+ Properties: {
124
+ Name: { 'Fn::Sub': '${AWS::StackName}-url-rewrite' },
125
+ AutoPublish: true,
126
+ FunctionConfig: {
127
+ Comment: 'Append .html extension to URLs without extensions',
128
+ Runtime: 'cloudfront-js-2.0',
129
+ },
130
+ FunctionCode: `function handler(event) {
131
+ var request = event.request;
132
+ var uri = request.uri;
133
+
134
+ // If URI ends with /, serve index.html
135
+ if (uri.endsWith('/')) {
136
+ request.uri = uri + 'index.html';
137
+ }
138
+ // If URI doesn't have an extension, append .html
139
+ else if (!uri.includes('.')) {
140
+ request.uri = uri + '.html';
141
+ }
142
+
143
+ return request;
144
+ }`,
145
+ },
146
+ }
147
+
148
+ // CloudFront Distribution
149
+ const distributionConfig: any = {
150
+ Enabled: true,
151
+ DefaultRootObject: defaultRootObject,
152
+ HttpVersion: 'http2and3',
153
+ IPV6Enabled: true,
154
+ PriceClass: 'PriceClass_100',
155
+ Origins: [
156
+ {
157
+ Id: `S3-${bucketName}`,
158
+ DomainName: { 'Fn::GetAtt': ['S3Bucket', 'RegionalDomainName'] },
159
+ S3OriginConfig: {
160
+ OriginAccessIdentity: '',
161
+ },
162
+ OriginAccessControlId: { 'Fn::GetAtt': ['CloudFrontOAC', 'Id'] },
163
+ },
164
+ ],
165
+ DefaultCacheBehavior: {
166
+ TargetOriginId: `S3-${bucketName}`,
167
+ ViewerProtocolPolicy: 'redirect-to-https',
168
+ AllowedMethods: ['GET', 'HEAD'],
169
+ CachedMethods: ['GET', 'HEAD'],
170
+ Compress: true,
171
+ CachePolicyId: '658327ea-f89d-4fab-a63d-7e88639e58f6', // Managed-CachingOptimized
172
+ FunctionAssociations: [
173
+ {
174
+ EventType: 'viewer-request',
175
+ FunctionARN: { 'Fn::GetAtt': ['UrlRewriteFunction', 'FunctionARN'] },
176
+ },
177
+ ],
178
+ },
179
+ CustomErrorResponses: [
180
+ {
181
+ ErrorCode: 403,
182
+ ResponseCode: 200,
183
+ ResponsePagePath: `/${defaultRootObject}`,
184
+ ErrorCachingMinTTL: 300,
185
+ },
186
+ {
187
+ ErrorCode: 404,
188
+ ResponseCode: 404,
189
+ ResponsePagePath: `/${errorDocument}`,
190
+ ErrorCachingMinTTL: 300,
191
+ },
192
+ ],
193
+ }
194
+
195
+ // Add custom domain configuration if provided
196
+ if (domain && certificateArn) {
197
+ // Use provided aliases or default to just the domain
198
+ distributionConfig.Aliases = aliases && aliases.length > 0 ? aliases : [domain]
199
+ distributionConfig.ViewerCertificate = {
200
+ AcmCertificateArn: certificateArn,
201
+ SslSupportMethod: 'sni-only',
202
+ MinimumProtocolVersion: 'TLSv1.2_2021',
203
+ }
204
+ }
205
+ else {
206
+ distributionConfig.ViewerCertificate = {
207
+ CloudFrontDefaultCertificate: true,
208
+ }
209
+ }
210
+
211
+ resources.CloudFrontDistribution = {
212
+ Type: 'AWS::CloudFront::Distribution',
213
+ DependsOn: ['S3Bucket', 'CloudFrontOAC', 'UrlRewriteFunction'],
214
+ Properties: {
215
+ DistributionConfig: distributionConfig,
216
+ },
217
+ }
218
+
219
+ outputs.DistributionId = {
220
+ Description: 'CloudFront Distribution ID',
221
+ Value: { Ref: 'CloudFrontDistribution' },
222
+ }
223
+
224
+ outputs.DistributionDomain = {
225
+ Description: 'CloudFront Distribution Domain',
226
+ Value: { 'Fn::GetAtt': ['CloudFrontDistribution', 'DomainName'] },
227
+ }
228
+
229
+ // S3 Bucket Policy for CloudFront OAC
230
+ resources.S3BucketPolicy = {
231
+ Type: 'AWS::S3::BucketPolicy',
232
+ DependsOn: ['S3Bucket', 'CloudFrontDistribution'],
233
+ Properties: {
234
+ Bucket: { Ref: 'S3Bucket' },
235
+ PolicyDocument: {
236
+ Version: '2012-10-17',
237
+ Statement: [
238
+ {
239
+ Sid: 'AllowCloudFrontServicePrincipal',
240
+ Effect: 'Allow',
241
+ Principal: {
242
+ Service: 'cloudfront.amazonaws.com',
243
+ },
244
+ Action: 's3:GetObject',
245
+ Resource: { 'Fn::Sub': 'arn:aws:s3:::${S3Bucket}/*' },
246
+ Condition: {
247
+ StringEquals: {
248
+ 'AWS:SourceArn': {
249
+ 'Fn::Sub': 'arn:aws:cloudfront::${AWS::AccountId}:distribution/${CloudFrontDistribution}',
250
+ },
251
+ },
252
+ },
253
+ },
254
+ ],
255
+ },
256
+ },
257
+ }
258
+
259
+ // NOTE: No Route53 records - DNS will be managed by external provider
260
+
261
+ outputs.SiteUrl = {
262
+ Description: 'Site URL',
263
+ Value: domain ? `https://${domain}` : { 'Fn::Sub': 'https://${CloudFrontDistribution.DomainName}' },
264
+ }
265
+
266
+ return {
267
+ AWSTemplateFormatVersion: '2010-09-09',
268
+ Description: `Static site infrastructure for ${domain || bucketName} (External DNS)`,
269
+ Resources: resources,
270
+ Outputs: outputs,
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Deploy a static site to AWS with external DNS provider
276
+ */
277
+ export async function deployStaticSiteWithExternalDns(
278
+ config: ExternalDnsStaticSiteConfig,
279
+ ): Promise<ExternalDnsDeployResult> {
280
+ const region = config.region || 'us-east-1'
281
+ const cfRegion = 'us-east-1' // CloudFormation for global resources must be in us-east-1
282
+ const domain = config.domain
283
+
284
+ // Generate bucket name if not provided
285
+ const bucket = config.bucket || domain.replace(/\./g, '-')
286
+
287
+ // Generate stack name
288
+ const stackName = config.stackName || `${config.siteName}-static-site`
289
+
290
+ // Initialize clients
291
+ const cf = new CloudFormationClient(cfRegion)
292
+ const acm = new ACMClient('us-east-1') // ACM certs for CloudFront must be in us-east-1
293
+
294
+ // Create DNS provider (only if not skipping DNS verification)
295
+ let dnsProvider: DnsProvider | null = null
296
+
297
+ if (!config.skipDnsVerification) {
298
+ dnsProvider = createDnsProvider(config.dnsProvider)
299
+
300
+ // Verify DNS provider can manage this domain
301
+ console.log(`Verifying DNS provider can manage ${domain}...`)
302
+ const canManage = await dnsProvider.canManageDomain(domain)
303
+ if (!canManage) {
304
+ return {
305
+ success: false,
306
+ stackName,
307
+ bucket,
308
+ message: `DNS provider '${dnsProvider.name}' cannot manage domain ${domain}. Please check your API credentials and domain ownership.`,
309
+ }
310
+ }
311
+ console.log(`DNS provider '${dnsProvider.name}' verified for ${domain}`)
312
+ }
313
+ else {
314
+ console.log(`Skipping DNS verification for ${domain}`)
315
+ }
316
+
317
+ let certificateArn = config.certificateArn
318
+
319
+ // Determine if we're dealing with an apex domain (need www SAN)
320
+ const domainParts = domain.split('.')
321
+ const isApexDomain = domainParts.length === 2
322
+ const wwwDomain = isApexDomain ? `www.${domain}` : undefined
323
+
324
+ // Auto-create SSL certificate if not provided
325
+ if (!certificateArn) {
326
+ console.log(`Checking for existing SSL certificate for ${domain}...`)
327
+
328
+ // Check for existing certificate that covers both apex and www
329
+ const existingCert = await acm.findCertificateByDomain(domain)
330
+ let existingCertCoversWww = false
331
+
332
+ if (existingCert && existingCert.Status === 'ISSUED') {
333
+ // Check if the existing cert also covers www
334
+ if (wwwDomain && existingCert.SubjectAlternativeNames) {
335
+ existingCertCoversWww = existingCert.SubjectAlternativeNames.includes(wwwDomain)
336
+ || existingCert.SubjectAlternativeNames.some(san => san === `*.${domain}`)
337
+ }
338
+ else {
339
+ existingCertCoversWww = true // Not apex domain, no www needed
340
+ }
341
+
342
+ if (existingCertCoversWww) {
343
+ certificateArn = existingCert.CertificateArn
344
+ console.log(`Found existing certificate with www coverage: ${certificateArn}`)
345
+ }
346
+ else {
347
+ console.log(`Existing certificate doesn't cover ${wwwDomain}, requesting new one...`)
348
+ }
349
+ }
350
+
351
+ if (!certificateArn) {
352
+ if (!dnsProvider) {
353
+ return {
354
+ success: false,
355
+ stackName,
356
+ bucket,
357
+ message: `No DNS provider available to validate SSL certificate for ${domain}. Provide a certificateArn or enable DNS verification.`,
358
+ }
359
+ }
360
+
361
+ // Request and validate new certificate using external DNS provider
362
+ // Always include www for apex domains
363
+ console.log(`Requesting new SSL certificate for ${domain}${wwwDomain ? ` (including ${wwwDomain})` : ''}...`)
364
+ const validator = new UnifiedDnsValidator(dnsProvider, 'us-east-1')
365
+
366
+ const certResult = await validator.findOrCreateCertificate({
367
+ domainName: domain,
368
+ subjectAlternativeNames: wwwDomain ? [wwwDomain] : undefined,
369
+ waitForValidation: true,
370
+ maxWaitMinutes: 10,
371
+ })
372
+
373
+ if (certResult.status !== 'issued') {
374
+ return {
375
+ success: false,
376
+ stackName,
377
+ bucket,
378
+ message: `SSL certificate validation failed. Status: ${certResult.status}`,
379
+ }
380
+ }
381
+
382
+ certificateArn = certResult.certificateArn
383
+ console.log(`Certificate issued: ${certificateArn}`)
384
+ }
385
+ }
386
+
387
+ // Check if stack already exists
388
+ let stackExists = false
389
+ let existingBucketName: string | undefined
390
+ try {
391
+ const existingStacks = await cf.describeStacks({ stackName })
392
+ if (existingStacks.Stacks.length > 0) {
393
+ const stack = existingStacks.Stacks[0]
394
+ const stackStatus = stack.StackStatus
395
+
396
+ if (stackStatus === 'DELETE_IN_PROGRESS') {
397
+ console.log('Previous stack is still being deleted, waiting...')
398
+ await cf.waitForStack(stackName, 'stack-delete-complete')
399
+ stackExists = false
400
+ }
401
+ else if (stackStatus === 'DELETE_COMPLETE') {
402
+ stackExists = false
403
+ }
404
+ else {
405
+ stackExists = true
406
+ const outputs = stack.Outputs || []
407
+ existingBucketName = outputs.find(o => o.OutputKey === 'BucketName')?.OutputValue
408
+ }
409
+ }
410
+ }
411
+ catch (err: any) {
412
+ if (err.message?.includes('does not exist') || err.code === 'ValidationError') {
413
+ stackExists = false
414
+ }
415
+ else {
416
+ throw err
417
+ }
418
+ }
419
+
420
+ // Determine final bucket name
421
+ let finalBucket = existingBucketName || bucket
422
+ if (!stackExists) {
423
+ const s3 = new S3Client(region)
424
+ const cloudfront = new CloudFrontClient()
425
+
426
+ // Check for existing CloudFront distributions with our domain FIRST
427
+ // before deciding to clean up any "orphaned" buckets
428
+ let hasExistingDistribution = false
429
+ if (domain) {
430
+ try {
431
+ console.log(`Checking for existing CloudFront distributions with alias ${domain}...`)
432
+ const distributions = await cloudfront.listDistributions()
433
+ for (const dist of distributions) {
434
+ let aliases: string[] = []
435
+ if (dist.Aliases?.Items) {
436
+ if (Array.isArray(dist.Aliases.Items)) {
437
+ aliases = dist.Aliases.Items
438
+ }
439
+ else if (typeof dist.Aliases.Items === 'object') {
440
+ const cname = (dist.Aliases.Items as any).CNAME
441
+ if (typeof cname === 'string') {
442
+ aliases = [cname]
443
+ }
444
+ else if (Array.isArray(cname)) {
445
+ aliases = cname
446
+ }
447
+ }
448
+ }
449
+ if (aliases.includes(domain)) {
450
+ hasExistingDistribution = true
451
+ console.log(`Found existing CloudFront distribution ${dist.Id} with alias ${domain}`)
452
+ console.log(`Reusing existing infrastructure for updates...`)
453
+
454
+ // Get the origin bucket from the distribution
455
+ const distConfig = await cloudfront.getDistributionConfig(dist.Id!)
456
+ const originsData = distConfig.DistributionConfig?.Origins?.Items
457
+ let originBucket: string | undefined
458
+
459
+ if (originsData) {
460
+ // Handle AWS XML-to-JSON format: single item is { Origin: {...} }, multiple is { Origin: [...] } or [...]
461
+ let originList: any[] = []
462
+ if (Array.isArray(originsData)) {
463
+ originList = originsData
464
+ }
465
+ else if (originsData.Origin) {
466
+ originList = Array.isArray(originsData.Origin) ? originsData.Origin : [originsData.Origin]
467
+ }
468
+ else {
469
+ originList = [originsData]
470
+ }
471
+
472
+ for (const origin of originList) {
473
+ const domainName = origin.DomainName || ''
474
+ // Extract bucket name from S3 domain (e.g., "bucket-name.s3.us-east-1.amazonaws.com")
475
+ const s3Match = domainName.match(/^([^.]+)\.s3[\.-]/)
476
+ if (s3Match) {
477
+ originBucket = s3Match[1]
478
+ break
479
+ }
480
+ }
481
+ }
482
+
483
+ if (originBucket) {
484
+ console.log(`Using existing S3 bucket: ${originBucket}`)
485
+ // Return success with existing infrastructure info - skip CloudFormation
486
+ return {
487
+ success: true,
488
+ stackName: `existing-${dist.Id}`,
489
+ bucket: originBucket,
490
+ distributionId: dist.Id,
491
+ distributionDomain: dist.DomainName,
492
+ domain,
493
+ certificateArn,
494
+ message: 'Using existing CloudFront distribution',
495
+ _existingInfrastructure: true, // Flag to indicate we're reusing infrastructure
496
+ } as ExternalDnsDeployResult & { _existingInfrastructure: boolean }
497
+ }
498
+ }
499
+ }
500
+ }
501
+ catch {
502
+ // No distributions or error listing them
503
+ }
504
+ }
505
+
506
+ // Only clean up orphaned S3 buckets when no CloudFront distribution references them
507
+ if (!hasExistingDistribution) {
508
+ try {
509
+ const headResult = await s3.headBucket(bucket)
510
+ if (headResult.exists) {
511
+ console.log(`Found orphaned S3 bucket ${bucket}, cleaning up...`)
512
+ try {
513
+ const cleanupPromise = s3.emptyBucket(bucket).then(() => s3.deleteBucket(bucket))
514
+ const timeoutPromise = new Promise<never>((_, reject) =>
515
+ setTimeout(() => reject(new Error('Bucket cleanup timeout')), 30000),
516
+ )
517
+ await Promise.race([cleanupPromise, timeoutPromise])
518
+ console.log(`Deleted orphaned S3 bucket ${bucket}`)
519
+ }
520
+ catch (cleanupErr: any) {
521
+ console.log(`Note: Could not clean up S3 bucket: ${cleanupErr.message}`)
522
+ const suffix = Date.now().toString(36)
523
+ finalBucket = `${bucket}-${suffix}`
524
+ console.log(`Using alternative bucket name: ${finalBucket}`)
525
+ }
526
+ }
527
+ }
528
+ catch {
529
+ // Bucket doesn't exist, good
530
+ }
531
+ }
532
+ }
533
+
534
+ // Generate CloudFormation template (without Route53)
535
+ // Build aliases list (include www for apex domains)
536
+ const aliases = wwwDomain ? [domain, wwwDomain] : [domain]
537
+
538
+ const template = generateExternalDnsStaticSiteTemplate({
539
+ bucketName: finalBucket,
540
+ domain,
541
+ aliases,
542
+ certificateArn,
543
+ defaultRootObject: config.defaultRootObject,
544
+ errorDocument: config.errorDocument,
545
+ })
546
+
547
+ // Build tags
548
+ const tags = Object.entries(config.tags || {}).map(([Key, Value]) => ({ Key, Value }))
549
+ tags.push({ Key: 'ManagedBy', Value: 'ts-cloud' })
550
+ tags.push({ Key: 'Application', Value: config.siteName })
551
+ if (dnsProvider) {
552
+ tags.push({ Key: 'DnsProvider', Value: dnsProvider.name })
553
+ }
554
+
555
+ // Create or update stack
556
+ let stackId: string
557
+ let isUpdate = false
558
+
559
+ if (stackExists) {
560
+ isUpdate = true
561
+ console.log(`Updating CloudFormation stack: ${stackName}`)
562
+ try {
563
+ const result = await cf.updateStack({
564
+ stackName,
565
+ templateBody: JSON.stringify(template),
566
+ capabilities: ['CAPABILITY_IAM'],
567
+ tags,
568
+ })
569
+ stackId = result.StackId
570
+ console.log(`Update initiated, stack ID: ${stackId}`)
571
+ }
572
+ catch (err: any) {
573
+ if (err.message?.includes('No updates are to be performed')) {
574
+ const stacks = await cf.describeStacks({ stackName })
575
+ stackId = stacks.Stacks[0].StackId
576
+ const outputs = stacks.Stacks[0]?.Outputs || []
577
+ const getOutput = (key: string) => outputs.find(o => o.OutputKey === key)?.OutputValue
578
+
579
+ // Still need to ensure DNS records exist
580
+ const distributionDomain = getOutput('DistributionDomain')
581
+ if (distributionDomain) {
582
+ await ensureDnsRecords(dnsProvider, domain, distributionDomain)
583
+ }
584
+
585
+ return {
586
+ success: true,
587
+ stackId,
588
+ stackName,
589
+ bucket: getOutput('BucketName') || finalBucket,
590
+ distributionId: getOutput('DistributionId'),
591
+ distributionDomain,
592
+ domain,
593
+ certificateArn,
594
+ message: 'Static site infrastructure is already up to date',
595
+ }
596
+ }
597
+ else {
598
+ throw err
599
+ }
600
+ }
601
+ }
602
+ else {
603
+ console.log(`Creating CloudFormation stack: ${stackName}`)
604
+ console.log(`Bucket name: ${finalBucket}`)
605
+ console.log(`Domain: ${domain}`)
606
+ console.log(`Certificate ARN: ${certificateArn}`)
607
+ const result = await cf.createStack({
608
+ stackName,
609
+ templateBody: JSON.stringify(template),
610
+ capabilities: ['CAPABILITY_IAM'],
611
+ tags,
612
+ onFailure: 'DELETE',
613
+ })
614
+ stackId = result.StackId
615
+ console.log(`Create initiated, stack ID: ${stackId}`)
616
+ }
617
+
618
+ // Wait for stack to complete
619
+ console.log(`Waiting for stack to reach ${isUpdate ? 'stack-update-complete' : 'stack-create-complete'}...`)
620
+ try {
621
+ await cf.waitForStack(stackName, isUpdate ? 'stack-update-complete' : 'stack-create-complete')
622
+ console.log('Stack operation completed successfully!')
623
+ }
624
+ catch (err: any) {
625
+ // Check for CloudFront account verification error
626
+ if (err.message?.includes('must be verified') || err.message?.includes('Access denied for operation')) {
627
+ console.log('CloudFront account verification required - checking for existing infrastructure...')
628
+
629
+ // Try to find an existing CloudFront distribution we can use
630
+ try {
631
+ const cloudfront = new CloudFrontClient()
632
+ const distributions = await cloudfront.listDistributions()
633
+
634
+ for (const dist of distributions) {
635
+ let aliases: string[] = []
636
+ if (dist.Aliases?.Items) {
637
+ if (Array.isArray(dist.Aliases.Items)) {
638
+ aliases = dist.Aliases.Items
639
+ }
640
+ else if (typeof dist.Aliases.Items === 'object') {
641
+ const cname = (dist.Aliases.Items as any).CNAME
642
+ if (typeof cname === 'string') {
643
+ aliases = [cname]
644
+ }
645
+ else if (Array.isArray(cname)) {
646
+ aliases = cname
647
+ }
648
+ }
649
+ }
650
+
651
+ if (aliases.includes(domain)) {
652
+ console.log(`Found existing CloudFront distribution ${dist.Id} with alias ${domain}`)
653
+ console.log(`Using existing infrastructure despite account verification requirement...`)
654
+
655
+ // Get the origin bucket from the distribution
656
+ const distConfig = await cloudfront.getDistributionConfig(dist.Id!)
657
+ const originsData = distConfig.DistributionConfig?.Origins?.Items
658
+ let originBucket: string | undefined
659
+
660
+ if (originsData) {
661
+ let originList: any[] = []
662
+ if (Array.isArray(originsData)) {
663
+ originList = originsData
664
+ }
665
+ else if (originsData.Origin) {
666
+ originList = Array.isArray(originsData.Origin) ? originsData.Origin : [originsData.Origin]
667
+ }
668
+ else {
669
+ originList = [originsData]
670
+ }
671
+
672
+ for (const origin of originList) {
673
+ const domainName = origin.DomainName || ''
674
+ const s3Match = domainName.match(/^([^.]+)\.s3[\.-]/)
675
+ if (s3Match) {
676
+ originBucket = s3Match[1]
677
+ break
678
+ }
679
+ }
680
+ }
681
+
682
+ if (originBucket) {
683
+ console.log(`Using existing S3 bucket: ${originBucket}`)
684
+
685
+ // Also ensure DNS records exist
686
+ const distributionDomain = dist.DomainName
687
+ if (distributionDomain) {
688
+ await ensureDnsRecords(dnsProvider, domain, distributionDomain)
689
+ }
690
+
691
+ return {
692
+ success: true,
693
+ stackName: `existing-${dist.Id}`,
694
+ bucket: originBucket,
695
+ distributionId: dist.Id,
696
+ distributionDomain: dist.DomainName,
697
+ domain,
698
+ certificateArn,
699
+ message: 'Using existing CloudFront distribution (account verification pending for new distributions)',
700
+ }
701
+ }
702
+ }
703
+ }
704
+ }
705
+ catch {
706
+ // Couldn't find existing infrastructure
707
+ }
708
+
709
+ // No existing infrastructure found, show verification message
710
+ const verificationMessage = `
711
+ ┌─────────────────────────────────────────────────────────────────────────────┐
712
+ │ AWS ACCOUNT VERIFICATION REQUIRED │
713
+ ├─────────────────────────────────────────────────────────────────────────────┤
714
+ │ Your AWS account must be verified before you can create CloudFront │
715
+ │ distributions. This is a one-time requirement for new AWS accounts. │
716
+ │ │
717
+ │ To verify your account: │
718
+ │ │
719
+ │ 1. Go to: https://console.aws.amazon.com/support/home#/ │
720
+ │ 2. Click "Create case" │
721
+ │ 3. Select "Service limit increase" │
722
+ │ 4. For Service: Select "CloudFront" │
723
+ │ 5. For Request: "Please verify my account for CloudFront access" │
724
+ │ 6. Submit the case │
725
+ │ │
726
+ │ Verification typically takes 1-2 business days. │
727
+ │ After verification, re-run: bunx bunpress deploy │
728
+ └─────────────────────────────────────────────────────────────────────────────┘`
729
+ console.log(verificationMessage)
730
+ return {
731
+ success: false,
732
+ stackId,
733
+ stackName,
734
+ bucket: finalBucket,
735
+ message: 'CloudFront account verification required. Please contact AWS Support.',
736
+ }
737
+ }
738
+
739
+ return {
740
+ success: false,
741
+ stackId,
742
+ stackName,
743
+ bucket: finalBucket,
744
+ message: `Stack deployment failed: ${err.message}`,
745
+ }
746
+ }
747
+
748
+ // Get stack outputs
749
+ const stacks = await cf.describeStacks({ stackName })
750
+ const outputs = stacks.Stacks[0]?.Outputs || []
751
+ const getOutput = (key: string) => outputs.find(o => o.OutputKey === key)?.OutputValue
752
+
753
+ const distributionDomain = getOutput('DistributionDomain')
754
+
755
+ // Create DNS records using external DNS provider
756
+ if (distributionDomain && dnsProvider) {
757
+ console.log(`Creating DNS records via ${dnsProvider.name}...`)
758
+ await ensureDnsRecords(dnsProvider, domain, distributionDomain)
759
+ }
760
+ else if (distributionDomain && !dnsProvider) {
761
+ console.log(`Skipping DNS record creation (DNS verification was skipped)`)
762
+ }
763
+
764
+ return {
765
+ success: true,
766
+ stackId,
767
+ stackName,
768
+ bucket: getOutput('BucketName') || finalBucket,
769
+ distributionId: getOutput('DistributionId'),
770
+ distributionDomain,
771
+ domain,
772
+ certificateArn,
773
+ message: 'Static site infrastructure deployed successfully with external DNS',
774
+ }
775
+ }
776
+
777
+ /**
778
+ * Create or update DNS records pointing to CloudFront
779
+ */
780
+ async function ensureDnsRecords(
781
+ dnsProvider: DnsProvider | null,
782
+ domain: string,
783
+ cloudfrontDomain: string,
784
+ ): Promise<void> {
785
+ // Skip DNS record creation if no provider (DNS verification was skipped)
786
+ if (!dnsProvider) {
787
+ console.log(`Skipping DNS record creation (DNS verification was skipped)`)
788
+ return
789
+ }
790
+ // Create CNAME record pointing to CloudFront
791
+ // Note: For apex domains (e.g., bunpress.org), some DNS providers support ALIAS/ANAME records
792
+ // Porkbun supports ALIAS records for apex domains
793
+
794
+ // Check if this is an apex domain (no subdomain)
795
+ const parts = domain.split('.')
796
+ const isApexDomain = parts.length === 2
797
+
798
+ if (isApexDomain) {
799
+ // For apex domains, create an ALIAS record (Porkbun supports this)
800
+ // Porkbun uses 'ALIAS' type for apex domain CNAME-like behavior
801
+ console.log(`Creating ALIAS record for apex domain ${domain} -> ${cloudfrontDomain}`)
802
+
803
+ const result = await dnsProvider.upsertRecord(domain, {
804
+ name: domain,
805
+ type: 'ALIAS' as any, // Porkbun-specific type for apex domains
806
+ content: cloudfrontDomain,
807
+ ttl: 600,
808
+ })
809
+
810
+ if (!result.success) {
811
+ // Fallback to A record using CloudFront IPs (not recommended but works)
812
+ console.log(`ALIAS record failed, trying CNAME with @ subdomain...`)
813
+ const cnameResult = await dnsProvider.upsertRecord(domain, {
814
+ name: domain,
815
+ type: 'CNAME',
816
+ content: cloudfrontDomain,
817
+ ttl: 600,
818
+ })
819
+
820
+ if (!cnameResult.success) {
821
+ console.warn(`Warning: Could not create DNS record: ${cnameResult.message}`)
822
+ console.warn(`Please manually create a CNAME or ALIAS record:`)
823
+ console.warn(` ${domain} -> ${cloudfrontDomain}`)
824
+ }
825
+ else {
826
+ console.log(`Created CNAME record: ${domain} -> ${cloudfrontDomain}`)
827
+ }
828
+ }
829
+ else {
830
+ console.log(`Created ALIAS record: ${domain} -> ${cloudfrontDomain}`)
831
+ }
832
+
833
+ // Also create CNAME for www subdomain
834
+ const wwwDomain = `www.${domain}`
835
+ console.log(`Creating CNAME record for ${wwwDomain} -> ${cloudfrontDomain}`)
836
+ const wwwResult = await dnsProvider.upsertRecord(domain, {
837
+ name: wwwDomain,
838
+ type: 'CNAME',
839
+ content: cloudfrontDomain,
840
+ ttl: 600,
841
+ })
842
+
843
+ if (!wwwResult.success) {
844
+ console.warn(`Warning: Could not create www DNS record: ${wwwResult.message}`)
845
+ console.warn(`Please manually create a CNAME record:`)
846
+ console.warn(` ${wwwDomain} -> ${cloudfrontDomain}`)
847
+ }
848
+ else {
849
+ console.log(`Created CNAME record: ${wwwDomain} -> ${cloudfrontDomain}`)
850
+ }
851
+ }
852
+ else {
853
+ // For subdomains, use standard CNAME
854
+ console.log(`Creating CNAME record for ${domain} -> ${cloudfrontDomain}`)
855
+
856
+ const result = await dnsProvider.upsertRecord(domain, {
857
+ name: domain,
858
+ type: 'CNAME',
859
+ content: cloudfrontDomain,
860
+ ttl: 600,
861
+ })
862
+
863
+ if (!result.success) {
864
+ console.warn(`Warning: Could not create DNS record: ${result.message}`)
865
+ console.warn(`Please manually create a CNAME record:`)
866
+ console.warn(` ${domain} -> ${cloudfrontDomain}`)
867
+ }
868
+ else {
869
+ console.log(`Created CNAME record: ${domain} -> ${cloudfrontDomain}`)
870
+ }
871
+ }
872
+ }
873
+
874
+ /**
875
+ * Full deployment with external DNS: infrastructure + files + cache invalidation
876
+ */
877
+ export async function deployStaticSiteWithExternalDnsFull(config: ExternalDnsStaticSiteConfig & {
878
+ sourceDir: string
879
+ cleanBucket?: boolean
880
+ onProgress?: (stage: string, detail?: string) => void
881
+ }): Promise<ExternalDnsDeployResult> {
882
+ const { sourceDir, cleanBucket = false, onProgress, ...siteConfig } = config
883
+
884
+ // Step 1: Deploy infrastructure
885
+ onProgress?.('infrastructure', 'Deploying CloudFormation stack...')
886
+ const infraResult = await deployStaticSiteWithExternalDns(siteConfig)
887
+
888
+ if (!infraResult.success) {
889
+ return infraResult
890
+ }
891
+
892
+ // Step 2: Clean bucket before upload
893
+ if (cleanBucket) {
894
+ onProgress?.('clean', 'Cleaning old files from S3...')
895
+ try {
896
+ const s3 = new S3Client(siteConfig.region || 'us-east-1')
897
+ await s3.emptyBucket(infraResult.bucket)
898
+ }
899
+ catch (err: any) {
900
+ console.log(`Note: Could not clean bucket: ${err.message}`)
901
+ }
902
+ }
903
+
904
+ // Step 3: Upload files
905
+ onProgress?.('upload', 'Uploading files to S3...')
906
+ const { uploadStaticFiles } = await import('./static-site')
907
+ const uploadResult = await uploadStaticFiles({
908
+ sourceDir,
909
+ bucket: infraResult.bucket,
910
+ region: siteConfig.region || 'us-east-1',
911
+ cacheControl: siteConfig.cacheControl,
912
+ onProgress: (uploaded, total, file) => {
913
+ onProgress?.('upload', `${uploaded}/${total}: ${file}`)
914
+ },
915
+ })
916
+
917
+ if (uploadResult.errors.length > 0) {
918
+ return {
919
+ ...infraResult,
920
+ success: false,
921
+ message: `Upload errors: ${uploadResult.errors.join(', ')}`,
922
+ filesUploaded: uploadResult.uploaded,
923
+ }
924
+ }
925
+
926
+ // Step 4: Invalidate cache (only if files were uploaded)
927
+ if (infraResult.distributionId && uploadResult.uploaded > 0) {
928
+ onProgress?.('invalidate', 'Invalidating CloudFront cache...')
929
+ const { invalidateCache } = await import('./static-site')
930
+ await invalidateCache(infraResult.distributionId)
931
+ }
932
+
933
+ onProgress?.('complete', 'Deployment complete!')
934
+
935
+ const message = uploadResult.skipped > 0
936
+ ? `Deployed ${uploadResult.uploaded} files (${uploadResult.skipped} unchanged) with external DNS`
937
+ : `Deployed ${uploadResult.uploaded} files successfully with external DNS`
938
+
939
+ return {
940
+ ...infraResult,
941
+ filesUploaded: uploadResult.uploaded,
942
+ filesSkipped: uploadResult.skipped,
943
+ message,
944
+ }
945
+ }