@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,1175 @@
1
+ /**
2
+ * Static Site Deployment Module
3
+ * Deploys static sites to AWS using CloudFormation (S3 + CloudFront + Route53/External DNS + ACM)
4
+ */
5
+
6
+ import { CloudFormationClient } from '../aws/cloudformation'
7
+ import { S3Client } from '../aws/s3'
8
+ import { CloudFrontClient } from '../aws/cloudfront'
9
+ import { Route53Client } from '../aws/route53'
10
+ import { ACMClient, ACMDnsValidator } from '../aws/acm'
11
+ import type { DnsProviderConfig } from '../dns/types'
12
+ import { deployStaticSiteWithExternalDns, deployStaticSiteWithExternalDnsFull } from './static-site-external-dns'
13
+
14
+ export interface StaticSiteConfig {
15
+ /** Site name used for resource naming */
16
+ siteName: string
17
+ /** AWS region for S3 bucket */
18
+ region?: string
19
+ /** Custom domain (e.g., docs.example.com) */
20
+ domain?: string
21
+ /** Subdomain part (e.g., 'docs') - used with baseDomain */
22
+ subdomain?: string
23
+ /** Base domain (e.g., 'example.com') - must have Route53 hosted zone */
24
+ baseDomain?: string
25
+ /** S3 bucket name (auto-generated if not provided) */
26
+ bucket?: string
27
+ /** Route53 hosted zone ID (auto-detected if not provided) */
28
+ hostedZoneId?: string
29
+ /** ACM certificate ARN (auto-created if not provided) */
30
+ certificateArn?: string
31
+ /** CloudFormation stack name */
32
+ stackName?: string
33
+ /** Default root object */
34
+ defaultRootObject?: string
35
+ /** Error document */
36
+ errorDocument?: string
37
+ /** Cache control for assets */
38
+ cacheControl?: string
39
+ /** Tags to apply to resources */
40
+ tags?: Record<string, string>
41
+ /**
42
+ * External DNS provider configuration (optional)
43
+ * When provided, DNS records will be managed via the specified provider (Porkbun, GoDaddy, etc.)
44
+ * instead of Route53. Useful when your domain is registered outside AWS.
45
+ */
46
+ dnsProvider?: DnsProviderConfig
47
+ }
48
+
49
+ export interface DeployResult {
50
+ success: boolean
51
+ stackId?: string
52
+ stackName: string
53
+ bucket: string
54
+ distributionId?: string
55
+ distributionDomain?: string
56
+ domain?: string
57
+ certificateArn?: string
58
+ message: string
59
+ }
60
+
61
+ export interface UploadOptions {
62
+ /** Local directory containing built files */
63
+ sourceDir: string
64
+ /** S3 bucket name */
65
+ bucket: string
66
+ /** AWS region */
67
+ region: string
68
+ /** Cache control header */
69
+ cacheControl?: string
70
+ /** Callback for progress updates */
71
+ onProgress?: (uploaded: number, total: number, file: string) => void
72
+ }
73
+
74
+ /**
75
+ * Generate CloudFormation template for static site infrastructure
76
+ */
77
+ export function generateStaticSiteTemplate(config: {
78
+ bucketName: string
79
+ domain?: string
80
+ certificateArn?: string
81
+ hostedZoneId?: string
82
+ defaultRootObject?: string
83
+ errorDocument?: string
84
+ }): object {
85
+ const {
86
+ bucketName,
87
+ domain,
88
+ certificateArn,
89
+ hostedZoneId,
90
+ defaultRootObject = 'index.html',
91
+ errorDocument = '404.html',
92
+ } = config
93
+
94
+ const resources: Record<string, any> = {}
95
+ const outputs: Record<string, any> = {}
96
+
97
+ // S3 Bucket
98
+ resources.S3Bucket = {
99
+ Type: 'AWS::S3::Bucket',
100
+ Properties: {
101
+ BucketName: bucketName,
102
+ PublicAccessBlockConfiguration: {
103
+ BlockPublicAcls: true,
104
+ BlockPublicPolicy: false,
105
+ IgnorePublicAcls: true,
106
+ RestrictPublicBuckets: false,
107
+ },
108
+ WebsiteConfiguration: {
109
+ IndexDocument: defaultRootObject,
110
+ ErrorDocument: errorDocument,
111
+ },
112
+ },
113
+ }
114
+
115
+ outputs.BucketName = {
116
+ Description: 'S3 Bucket Name',
117
+ Value: { Ref: 'S3Bucket' },
118
+ }
119
+
120
+ outputs.BucketArn = {
121
+ Description: 'S3 Bucket ARN',
122
+ Value: { 'Fn::GetAtt': ['S3Bucket', 'Arn'] },
123
+ }
124
+
125
+ // Origin Access Control
126
+ resources.CloudFrontOAC = {
127
+ Type: 'AWS::CloudFront::OriginAccessControl',
128
+ Properties: {
129
+ OriginAccessControlConfig: {
130
+ Name: `OAC-${bucketName}`,
131
+ Description: `OAC for ${bucketName}`,
132
+ OriginAccessControlOriginType: 's3',
133
+ SigningBehavior: 'always',
134
+ SigningProtocol: 'sigv4',
135
+ },
136
+ },
137
+ }
138
+
139
+ // CloudFront Function for URL rewriting (append .html to URLs without extensions)
140
+ resources.UrlRewriteFunction = {
141
+ Type: 'AWS::CloudFront::Function',
142
+ Properties: {
143
+ Name: { 'Fn::Sub': '${AWS::StackName}-url-rewrite' },
144
+ AutoPublish: true,
145
+ FunctionConfig: {
146
+ Comment: 'Append .html extension to URLs without extensions',
147
+ Runtime: 'cloudfront-js-2.0',
148
+ },
149
+ FunctionCode: `function handler(event) {
150
+ var request = event.request;
151
+ var uri = request.uri;
152
+
153
+ // If URI ends with /, serve index.html
154
+ if (uri.endsWith('/')) {
155
+ request.uri = uri + 'index.html';
156
+ }
157
+ // If URI doesn't have an extension, append .html
158
+ else if (!uri.includes('.')) {
159
+ request.uri = uri + '.html';
160
+ }
161
+
162
+ return request;
163
+ }`,
164
+ },
165
+ }
166
+
167
+ // CloudFront Distribution
168
+ const distributionConfig: any = {
169
+ Enabled: true,
170
+ DefaultRootObject: defaultRootObject,
171
+ HttpVersion: 'http2and3',
172
+ IPV6Enabled: true,
173
+ PriceClass: 'PriceClass_100',
174
+ Origins: [
175
+ {
176
+ Id: `S3-${bucketName}`,
177
+ DomainName: { 'Fn::GetAtt': ['S3Bucket', 'RegionalDomainName'] },
178
+ S3OriginConfig: {
179
+ OriginAccessIdentity: '',
180
+ },
181
+ OriginAccessControlId: { 'Fn::GetAtt': ['CloudFrontOAC', 'Id'] },
182
+ },
183
+ ],
184
+ DefaultCacheBehavior: {
185
+ TargetOriginId: `S3-${bucketName}`,
186
+ ViewerProtocolPolicy: 'redirect-to-https',
187
+ AllowedMethods: ['GET', 'HEAD'],
188
+ CachedMethods: ['GET', 'HEAD'],
189
+ Compress: true,
190
+ CachePolicyId: '658327ea-f89d-4fab-a63d-7e88639e58f6', // Managed-CachingOptimized
191
+ FunctionAssociations: [
192
+ {
193
+ EventType: 'viewer-request',
194
+ FunctionARN: { 'Fn::GetAtt': ['UrlRewriteFunction', 'FunctionARN'] },
195
+ },
196
+ ],
197
+ },
198
+ CustomErrorResponses: [
199
+ {
200
+ ErrorCode: 403,
201
+ ResponseCode: 200,
202
+ ResponsePagePath: `/${defaultRootObject}`,
203
+ ErrorCachingMinTTL: 300,
204
+ },
205
+ {
206
+ ErrorCode: 404,
207
+ ResponseCode: 404,
208
+ ResponsePagePath: `/${errorDocument}`,
209
+ ErrorCachingMinTTL: 300,
210
+ },
211
+ ],
212
+ }
213
+
214
+ // Add custom domain configuration if provided
215
+ if (domain && certificateArn) {
216
+ distributionConfig.Aliases = [domain]
217
+ distributionConfig.ViewerCertificate = {
218
+ AcmCertificateArn: certificateArn,
219
+ SslSupportMethod: 'sni-only',
220
+ MinimumProtocolVersion: 'TLSv1.2_2021',
221
+ }
222
+ }
223
+ else {
224
+ distributionConfig.ViewerCertificate = {
225
+ CloudFrontDefaultCertificate: true,
226
+ }
227
+ }
228
+
229
+ resources.CloudFrontDistribution = {
230
+ Type: 'AWS::CloudFront::Distribution',
231
+ DependsOn: ['S3Bucket', 'CloudFrontOAC', 'UrlRewriteFunction'],
232
+ Properties: {
233
+ DistributionConfig: distributionConfig,
234
+ },
235
+ }
236
+
237
+ outputs.DistributionId = {
238
+ Description: 'CloudFront Distribution ID',
239
+ Value: { Ref: 'CloudFrontDistribution' },
240
+ }
241
+
242
+ outputs.DistributionDomain = {
243
+ Description: 'CloudFront Distribution Domain',
244
+ Value: { 'Fn::GetAtt': ['CloudFrontDistribution', 'DomainName'] },
245
+ }
246
+
247
+ // S3 Bucket Policy for CloudFront OAC
248
+ resources.S3BucketPolicy = {
249
+ Type: 'AWS::S3::BucketPolicy',
250
+ DependsOn: ['S3Bucket', 'CloudFrontDistribution'],
251
+ Properties: {
252
+ Bucket: { Ref: 'S3Bucket' },
253
+ PolicyDocument: {
254
+ Version: '2012-10-17',
255
+ Statement: [
256
+ {
257
+ Sid: 'AllowCloudFrontServicePrincipal',
258
+ Effect: 'Allow',
259
+ Principal: {
260
+ Service: 'cloudfront.amazonaws.com',
261
+ },
262
+ Action: 's3:GetObject',
263
+ Resource: { 'Fn::Sub': 'arn:aws:s3:::${S3Bucket}/*' },
264
+ Condition: {
265
+ StringEquals: {
266
+ 'AWS:SourceArn': {
267
+ 'Fn::Sub': 'arn:aws:cloudfront::${AWS::AccountId}:distribution/${CloudFrontDistribution}',
268
+ },
269
+ },
270
+ },
271
+ },
272
+ ],
273
+ },
274
+ },
275
+ }
276
+
277
+ // Route53 DNS Record if domain and hosted zone provided
278
+ if (domain && hostedZoneId) {
279
+ resources.DNSRecord = {
280
+ Type: 'AWS::Route53::RecordSet',
281
+ DependsOn: 'CloudFrontDistribution',
282
+ Properties: {
283
+ HostedZoneId: hostedZoneId,
284
+ Name: domain,
285
+ Type: 'A',
286
+ AliasTarget: {
287
+ DNSName: { 'Fn::GetAtt': ['CloudFrontDistribution', 'DomainName'] },
288
+ HostedZoneId: 'Z2FDTNDATAQYW2', // CloudFront hosted zone ID (global)
289
+ EvaluateTargetHealth: false,
290
+ },
291
+ },
292
+ }
293
+
294
+ // Also create AAAA record for IPv6
295
+ resources.DNSRecordIPv6 = {
296
+ Type: 'AWS::Route53::RecordSet',
297
+ DependsOn: 'CloudFrontDistribution',
298
+ Properties: {
299
+ HostedZoneId: hostedZoneId,
300
+ Name: domain,
301
+ Type: 'AAAA',
302
+ AliasTarget: {
303
+ DNSName: { 'Fn::GetAtt': ['CloudFrontDistribution', 'DomainName'] },
304
+ HostedZoneId: 'Z2FDTNDATAQYW2',
305
+ EvaluateTargetHealth: false,
306
+ },
307
+ },
308
+ }
309
+
310
+ outputs.SiteUrl = {
311
+ Description: 'Site URL',
312
+ Value: { 'Fn::Sub': 'https://${DNSRecord}' },
313
+ }
314
+ }
315
+
316
+ return {
317
+ AWSTemplateFormatVersion: '2010-09-09',
318
+ Description: `Static site infrastructure for ${domain || bucketName}`,
319
+ Resources: resources,
320
+ Outputs: outputs,
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Deploy a static site to AWS
326
+ * Automatically routes to external DNS deployment when a non-Route53 dnsProvider is configured
327
+ */
328
+ export async function deployStaticSite(config: StaticSiteConfig): Promise<DeployResult> {
329
+ // If using external DNS provider (not Route53), delegate to the external DNS deployment
330
+ if (config.dnsProvider && config.dnsProvider.provider !== 'route53') {
331
+ const domain = config.domain || (config.subdomain && config.baseDomain ? `${config.subdomain}.${config.baseDomain}` : undefined)
332
+ if (!domain) {
333
+ return {
334
+ success: false,
335
+ stackName: config.stackName || `${config.siteName}-static-site`,
336
+ bucket: config.bucket || `${config.siteName}-${Date.now()}`,
337
+ message: 'Domain is required when using external DNS provider',
338
+ }
339
+ }
340
+
341
+ return deployStaticSiteWithExternalDns({
342
+ siteName: config.siteName,
343
+ region: config.region,
344
+ domain,
345
+ bucket: config.bucket,
346
+ certificateArn: config.certificateArn,
347
+ stackName: config.stackName,
348
+ defaultRootObject: config.defaultRootObject,
349
+ errorDocument: config.errorDocument,
350
+ cacheControl: config.cacheControl,
351
+ tags: config.tags,
352
+ dnsProvider: config.dnsProvider,
353
+ })
354
+ }
355
+
356
+ const region = config.region || 'us-east-1'
357
+ const cfRegion = 'us-east-1' // CloudFormation for global resources must be in us-east-1
358
+
359
+ // Determine full domain
360
+ let domain: string | undefined
361
+ if (config.domain) {
362
+ domain = config.domain
363
+ }
364
+ else if (config.subdomain && config.baseDomain) {
365
+ domain = `${config.subdomain}.${config.baseDomain}`
366
+ }
367
+
368
+ // Generate bucket name if not provided
369
+ const bucket = config.bucket || (domain ? domain.replace(/\./g, '-') : `${config.siteName}-${Date.now()}`)
370
+
371
+ // Generate stack name
372
+ const stackName = config.stackName || `${config.siteName}-static-site`
373
+
374
+ // Initialize clients
375
+ const cf = new CloudFormationClient(cfRegion)
376
+ const route53 = new Route53Client()
377
+ const acm = new ACMClient('us-east-1') // ACM certs for CloudFront must be in us-east-1
378
+ const acmValidator = new ACMDnsValidator('us-east-1')
379
+
380
+ let hostedZoneId = config.hostedZoneId
381
+ let certificateArn = config.certificateArn
382
+
383
+ // Auto-detect hosted zone if domain is specified
384
+ if (domain && !hostedZoneId) {
385
+ const zone = await route53.findHostedZoneForDomain(domain)
386
+ if (zone) {
387
+ hostedZoneId = zone.Id.replace('/hostedzone/', '')
388
+ }
389
+ else {
390
+ return {
391
+ success: false,
392
+ stackName,
393
+ bucket,
394
+ message: `No Route53 hosted zone found for ${config.baseDomain || domain}. Please create one first.`,
395
+ }
396
+ }
397
+ }
398
+
399
+ // Auto-create SSL certificate if domain is specified
400
+ if (domain && !certificateArn && hostedZoneId) {
401
+ // Check for existing certificate
402
+ const existingCert = await acm.findCertificateByDomain(domain)
403
+ if (existingCert && existingCert.Status === 'ISSUED') {
404
+ certificateArn = existingCert.CertificateArn
405
+ }
406
+ else {
407
+ // Request and validate new certificate
408
+ const certResult = await acmValidator.requestAndValidate({
409
+ domainName: domain,
410
+ hostedZoneId,
411
+ waitForValidation: true,
412
+ maxWaitMinutes: 10,
413
+ })
414
+ certificateArn = certResult.certificateArn
415
+ }
416
+ }
417
+
418
+ // Check if stack already exists
419
+ let stackExists = false
420
+ let existingBucketName: string | undefined
421
+ try {
422
+ const existingStacks = await cf.describeStacks({ stackName })
423
+ if (existingStacks.Stacks.length > 0) {
424
+ const stack = existingStacks.Stacks[0]
425
+ const stackStatus = stack.StackStatus
426
+
427
+ // If stack is being deleted, wait for it to complete
428
+ if (stackStatus === 'DELETE_IN_PROGRESS') {
429
+ console.log('Previous stack is still being deleted, waiting...')
430
+ await cf.waitForStack(stackName, 'stack-delete-complete')
431
+ stackExists = false
432
+ }
433
+ else if (stackStatus === 'DELETE_COMPLETE') {
434
+ stackExists = false
435
+ }
436
+ else {
437
+ stackExists = true
438
+ // Get existing bucket name from stack outputs to ensure consistency during updates
439
+ const outputs = stack.Outputs || []
440
+ existingBucketName = outputs.find(o => o.OutputKey === 'BucketName')?.OutputValue
441
+ }
442
+ }
443
+ }
444
+ catch (err: any) {
445
+ // Stack doesn't exist - this is expected for new deployments
446
+ if (err.message?.includes('does not exist') || err.code === 'ValidationError') {
447
+ stackExists = false
448
+ }
449
+ else {
450
+ throw err
451
+ }
452
+ }
453
+
454
+ // If stack doesn't exist, check for orphaned resources and clean them up
455
+ // Use a unique bucket name suffix if cleanup fails
456
+ // If stack exists, use the existing bucket name to avoid CloudFormation trying to recreate resources
457
+ let finalBucket = existingBucketName || bucket
458
+ if (!stackExists) {
459
+ const s3 = new S3Client(region)
460
+ const cloudfront = new CloudFrontClient()
461
+
462
+ // Check for existing CloudFront distribution FIRST before cleaning up any "orphaned" buckets
463
+ // This prevents deleting buckets that are actively used by a CloudFront distribution
464
+ let hasExistingDistribution = false
465
+
466
+ // Check for existing CloudFront distribution that WE created for this domain
467
+ // Only reuse distributions that have our domain as an alias - NEVER use other projects' resources
468
+ if (domain) {
469
+ try {
470
+ console.log(`Checking for existing CloudFront distribution for ${domain}...`)
471
+ const distributions = await cloudfront.listDistributions()
472
+
473
+ for (const dist of distributions) {
474
+ // Handle various alias structures: Items can be an array, or Items.CNAME can be a string or array
475
+ let aliases: string[] = []
476
+ if (dist.Aliases?.Items) {
477
+ if (Array.isArray(dist.Aliases.Items)) {
478
+ aliases = dist.Aliases.Items
479
+ }
480
+ else if (typeof dist.Aliases.Items === 'object') {
481
+ // Items.CNAME can be a string or array
482
+ const cname = (dist.Aliases.Items as any).CNAME
483
+ if (typeof cname === 'string') {
484
+ aliases = [cname]
485
+ }
486
+ else if (Array.isArray(cname)) {
487
+ aliases = cname
488
+ }
489
+ }
490
+ }
491
+
492
+ // Only use distribution if it has OUR domain as an alias
493
+ if (aliases.includes(domain)) {
494
+ hasExistingDistribution = true
495
+ console.log(`Found existing CloudFront distribution ${dist.Id} for ${domain}`)
496
+
497
+ // Get the origin bucket from the distribution
498
+ const distConfig = await cloudfront.getDistributionConfig(dist.Id!)
499
+ const originsData = distConfig.DistributionConfig?.Origins?.Items
500
+ let originBucket: string | undefined
501
+
502
+ if (originsData) {
503
+ let originList: any[] = []
504
+ if (Array.isArray(originsData)) {
505
+ originList = originsData
506
+ }
507
+ else if (originsData.Origin) {
508
+ originList = Array.isArray(originsData.Origin) ? originsData.Origin : [originsData.Origin]
509
+ }
510
+ else {
511
+ originList = [originsData]
512
+ }
513
+
514
+ for (const origin of originList) {
515
+ const domainName = origin.DomainName || ''
516
+ // Extract bucket name from S3 domain
517
+ const s3Match = domainName.match(/^([^.]+)\.s3[\.-]/)
518
+ if (s3Match) {
519
+ originBucket = s3Match[1]
520
+ break
521
+ }
522
+ }
523
+ }
524
+
525
+ if (originBucket) {
526
+ // Verify this bucket name matches our expected naming convention
527
+ const expectedBucketPrefix = domain.replace(/\./g, '-')
528
+ if (!originBucket.startsWith(expectedBucketPrefix) && !originBucket.includes(config.siteName)) {
529
+ console.log(`Warning: Found distribution with mismatched bucket ${originBucket}, skipping...`)
530
+ continue
531
+ }
532
+
533
+ console.log(`Using existing S3 bucket: ${originBucket}`)
534
+
535
+ // Ensure Route53 records exist for this distribution
536
+ if (hostedZoneId && dist.DomainName) {
537
+ try {
538
+ console.log(`Ensuring Route53 records exist for ${domain}...`)
539
+ await route53.createAliasRecord({
540
+ HostedZoneId: hostedZoneId,
541
+ Name: domain,
542
+ Type: 'A',
543
+ TargetHostedZoneId: Route53Client.CloudFrontHostedZoneId,
544
+ TargetDNSName: dist.DomainName,
545
+ EvaluateTargetHealth: false,
546
+ })
547
+ await route53.createAliasRecord({
548
+ HostedZoneId: hostedZoneId,
549
+ Name: domain,
550
+ Type: 'AAAA',
551
+ TargetHostedZoneId: Route53Client.CloudFrontHostedZoneId,
552
+ TargetDNSName: dist.DomainName,
553
+ EvaluateTargetHealth: false,
554
+ })
555
+ console.log(`Route53 records ensured for ${domain}`)
556
+ }
557
+ catch (dnsErr: any) {
558
+ console.log(`Note: Could not update Route53 records: ${dnsErr.message}`)
559
+ }
560
+ }
561
+
562
+ return {
563
+ success: true,
564
+ stackName: `existing-${dist.Id}`,
565
+ bucket: originBucket,
566
+ distributionId: dist.Id,
567
+ distributionDomain: dist.DomainName,
568
+ domain,
569
+ certificateArn,
570
+ message: 'Using existing CloudFront distribution',
571
+ }
572
+ }
573
+ }
574
+ }
575
+ }
576
+ catch {
577
+ // No distributions or error listing them
578
+ }
579
+
580
+ // Check for orphaned Route53 records
581
+ if (hostedZoneId) {
582
+ try {
583
+ const recordsResult = await route53.listResourceRecordSets({ HostedZoneId: hostedZoneId })
584
+ const records = recordsResult.ResourceRecordSets || []
585
+ for (const record of records) {
586
+ if (record.Name === `${domain}.` && (record.Type === 'A' || record.Type === 'AAAA')) {
587
+ // This is an alias record, check if it points to a CloudFront distribution
588
+ if (record.AliasTarget) {
589
+ console.log(`Found orphaned Route53 ${record.Type} record for ${domain}, cleaning up...`)
590
+ try {
591
+ await route53.deleteRecord({
592
+ HostedZoneId: hostedZoneId,
593
+ RecordSet: record,
594
+ })
595
+ console.log(`Deleted orphaned Route53 ${record.Type} record for ${domain}`)
596
+ }
597
+ catch (recordErr: any) {
598
+ console.log(`Note: Could not delete Route53 record: ${recordErr.message}`)
599
+ }
600
+ }
601
+ }
602
+ }
603
+ }
604
+ catch {
605
+ // Error listing/deleting records
606
+ }
607
+ }
608
+ }
609
+
610
+ // Only clean up orphaned S3 buckets when no CloudFront distribution references them
611
+ if (!hasExistingDistribution) {
612
+ try {
613
+ const headResult = await s3.headBucket(bucket)
614
+ if (headResult.exists) {
615
+ console.log(`Found orphaned S3 bucket ${bucket}, cleaning up...`)
616
+ try {
617
+ const cleanupPromise = s3.emptyBucket(bucket).then(() => s3.deleteBucket(bucket))
618
+ const timeoutPromise = new Promise<never>((_, reject) =>
619
+ setTimeout(() => reject(new Error('Bucket cleanup timeout')), 30000),
620
+ )
621
+ await Promise.race([cleanupPromise, timeoutPromise])
622
+ console.log(`Deleted orphaned S3 bucket ${bucket}`)
623
+ }
624
+ catch (cleanupErr: any) {
625
+ console.log(`Note: Could not clean up S3 bucket: ${cleanupErr.message}`)
626
+ const suffix = Date.now().toString(36)
627
+ finalBucket = `${bucket}-${suffix}`
628
+ console.log(`Using alternative bucket name: ${finalBucket}`)
629
+ }
630
+ }
631
+ }
632
+ catch {
633
+ // Bucket doesn't exist, good
634
+ }
635
+ }
636
+ }
637
+
638
+ // Generate CloudFormation template with final bucket name
639
+ const template = generateStaticSiteTemplate({
640
+ bucketName: finalBucket,
641
+ domain,
642
+ certificateArn,
643
+ hostedZoneId,
644
+ defaultRootObject: config.defaultRootObject,
645
+ errorDocument: config.errorDocument,
646
+ })
647
+
648
+ // Build tags
649
+ const tags = Object.entries(config.tags || {}).map(([Key, Value]) => ({ Key, Value }))
650
+ tags.push({ Key: 'ManagedBy', Value: 'ts-cloud' })
651
+ tags.push({ Key: 'Application', Value: config.siteName })
652
+
653
+ // Create or update stack
654
+ let stackId: string
655
+ let isUpdate = false
656
+
657
+ if (stackExists) {
658
+ isUpdate = true
659
+ console.log(`Updating CloudFormation stack: ${stackName}`)
660
+ console.log(`Using existing bucket: ${finalBucket}`)
661
+ console.log(`Domain: ${domain || 'not specified'}`)
662
+ console.log(`Certificate ARN: ${certificateArn || 'not specified'}`)
663
+ try {
664
+ const result = await cf.updateStack({
665
+ stackName,
666
+ templateBody: JSON.stringify(template),
667
+ capabilities: ['CAPABILITY_IAM'],
668
+ tags,
669
+ })
670
+ stackId = result.StackId
671
+ console.log(`Update initiated, stack ID: ${stackId}`)
672
+ }
673
+ catch (err: any) {
674
+ // No updates needed is not an error
675
+ if (err.message?.includes('No updates are to be performed')) {
676
+ const stacks = await cf.describeStacks({ stackName })
677
+ stackId = stacks.Stacks[0].StackId
678
+ // No actual update needed, return success with existing stack info
679
+ const outputs = stacks.Stacks[0]?.Outputs || []
680
+ const getOutput = (key: string) => outputs.find(o => o.OutputKey === key)?.OutputValue
681
+
682
+ return {
683
+ success: true,
684
+ stackId,
685
+ stackName,
686
+ bucket: getOutput('BucketName') || finalBucket,
687
+ distributionId: getOutput('DistributionId'),
688
+ distributionDomain: getOutput('DistributionDomain'),
689
+ domain,
690
+ certificateArn,
691
+ message: 'Static site infrastructure is already up to date',
692
+ }
693
+ }
694
+ else {
695
+ throw err
696
+ }
697
+ }
698
+ }
699
+ else {
700
+ console.log(`Creating CloudFormation stack: ${stackName}`)
701
+ console.log(`Bucket name: ${finalBucket}`)
702
+ console.log(`Domain: ${domain || 'not specified'}`)
703
+ console.log(`Certificate ARN: ${certificateArn || 'not specified'}`)
704
+ console.log('Stack does not exist, creating...')
705
+ const result = await cf.createStack({
706
+ stackName,
707
+ templateBody: JSON.stringify(template),
708
+ capabilities: ['CAPABILITY_IAM'],
709
+ tags,
710
+ onFailure: 'DELETE',
711
+ })
712
+ stackId = result.StackId
713
+ console.log(`Create initiated, stack ID: ${stackId}`)
714
+ }
715
+
716
+ // Wait for stack to complete using the appropriate wait type
717
+ console.log(`Waiting for stack to reach ${isUpdate ? 'stack-update-complete' : 'stack-create-complete'}...`)
718
+ try {
719
+ await cf.waitForStack(stackName, isUpdate ? 'stack-update-complete' : 'stack-create-complete')
720
+ console.log('Stack operation completed successfully!')
721
+ }
722
+ catch (err: any) {
723
+ // CloudFormation failed - try direct API creation instead
724
+ // This handles cases where CloudFormation has stricter validation than direct API calls
725
+ if (err.message?.includes('must be verified') || err.message?.includes('Access denied for operation') || err.message?.includes('failed')) {
726
+ console.log('CloudFormation deployment failed, trying direct API creation...')
727
+
728
+ const cloudfront = new CloudFrontClient()
729
+
730
+ // First check if we already have a distribution for this domain
731
+ if (domain) {
732
+ try {
733
+ const distributions = await cloudfront.listDistributions()
734
+
735
+ for (const dist of distributions) {
736
+ let aliases: string[] = []
737
+ if (dist.Aliases?.Items) {
738
+ if (Array.isArray(dist.Aliases.Items)) {
739
+ aliases = dist.Aliases.Items
740
+ }
741
+ else if (typeof dist.Aliases.Items === 'object') {
742
+ const cname = (dist.Aliases.Items as any).CNAME
743
+ if (typeof cname === 'string') {
744
+ aliases = [cname]
745
+ }
746
+ else if (Array.isArray(cname)) {
747
+ aliases = cname
748
+ }
749
+ }
750
+ }
751
+
752
+ if (aliases.includes(domain)) {
753
+ console.log(`Found existing CloudFront distribution ${dist.Id} with alias ${domain}`)
754
+
755
+ // Get the origin bucket from the distribution
756
+ const distConfig = await cloudfront.getDistributionConfig(dist.Id!)
757
+ const originsData = distConfig.DistributionConfig?.Origins?.Items
758
+ let originBucket: string | undefined
759
+
760
+ if (originsData) {
761
+ let originList: any[] = []
762
+ if (Array.isArray(originsData)) {
763
+ originList = originsData
764
+ }
765
+ else if (originsData.Origin) {
766
+ originList = Array.isArray(originsData.Origin) ? originsData.Origin : [originsData.Origin]
767
+ }
768
+ else {
769
+ originList = [originsData]
770
+ }
771
+
772
+ for (const origin of originList) {
773
+ const domainName = origin.DomainName || ''
774
+ const s3Match = domainName.match(/^([^.]+)\.s3[\.-]/)
775
+ if (s3Match) {
776
+ originBucket = s3Match[1]
777
+ break
778
+ }
779
+ }
780
+ }
781
+
782
+ if (originBucket) {
783
+ console.log(`Using existing S3 bucket: ${originBucket}`)
784
+ return {
785
+ success: true,
786
+ stackName: `existing-${dist.Id}`,
787
+ bucket: originBucket,
788
+ distributionId: dist.Id,
789
+ distributionDomain: dist.DomainName,
790
+ domain,
791
+ certificateArn,
792
+ message: 'Using existing CloudFront distribution (account verification pending for new distributions)',
793
+ }
794
+ }
795
+ }
796
+ }
797
+ }
798
+ catch {
799
+ // Couldn't find existing infrastructure
800
+ }
801
+ }
802
+
803
+ // No existing infrastructure found - try to create directly via API calls
804
+ // This often bypasses CloudFormation's stricter validation
805
+ console.log('No existing infrastructure found, creating via direct API calls...')
806
+
807
+ try {
808
+ const s3Direct = new S3Client(region)
809
+
810
+ // Step 1: Create or reuse S3 bucket
811
+ const bucketExists = await s3Direct.headBucket(finalBucket)
812
+ if (bucketExists.exists) {
813
+ console.log(`Using existing S3 bucket: ${finalBucket}`)
814
+ }
815
+ else {
816
+ console.log(`Creating S3 bucket: ${finalBucket}...`)
817
+ await s3Direct.createBucket(finalBucket)
818
+ }
819
+
820
+ // Configure bucket for static website hosting
821
+ await s3Direct.putBucketWebsite(finalBucket, {
822
+ IndexDocument: config.defaultRootObject || 'index.html',
823
+ ErrorDocument: config.errorDocument || '404.html',
824
+ })
825
+
826
+ // Block public access (we'll use CloudFront OAC)
827
+ await s3Direct.putPublicAccessBlock(finalBucket, {
828
+ BlockPublicAcls: true,
829
+ IgnorePublicAcls: true,
830
+ BlockPublicPolicy: false,
831
+ RestrictPublicBuckets: false,
832
+ })
833
+ console.log(`S3 bucket ${finalBucket} configured`)
834
+
835
+ // Step 2: Create Origin Access Control
836
+ const oacName = `OAC-${finalBucket}`
837
+ console.log(`Creating Origin Access Control: ${oacName}...`)
838
+ const oac = await cloudfront.findOrCreateOriginAccessControl(oacName)
839
+ console.log(`Origin Access Control ${oac.Id} ready`)
840
+
841
+ // Step 3: Create CloudFront distribution
842
+ console.log(`Creating CloudFront distribution...`)
843
+ const distResult = await cloudfront.createDistributionForS3({
844
+ bucketName: finalBucket,
845
+ bucketRegion: region,
846
+ originAccessControlId: oac.Id,
847
+ aliases: domain ? [domain] : [],
848
+ certificateArn: certificateArn,
849
+ defaultRootObject: config.defaultRootObject || 'index.html',
850
+ comment: `Distribution for ${domain || finalBucket}`,
851
+ })
852
+ console.log(`CloudFront distribution ${distResult.Id} created`)
853
+
854
+ // Step 4: Update S3 bucket policy for CloudFront access
855
+ console.log(`Updating S3 bucket policy...`)
856
+ const bucketPolicy = CloudFrontClient.getS3BucketPolicyForCloudFront(finalBucket, distResult.ARN)
857
+ await s3Direct.putBucketPolicy(finalBucket, bucketPolicy)
858
+ console.log(`S3 bucket policy updated`)
859
+
860
+ // Step 5: Create Route53 records
861
+ if (domain && hostedZoneId) {
862
+ console.log(`Creating Route53 records for ${domain}...`)
863
+ try {
864
+ await route53.createAliasRecord({
865
+ HostedZoneId: hostedZoneId,
866
+ Name: domain,
867
+ Type: 'A',
868
+ TargetHostedZoneId: Route53Client.CloudFrontHostedZoneId,
869
+ TargetDNSName: distResult.DomainName,
870
+ EvaluateTargetHealth: false,
871
+ })
872
+ await route53.createAliasRecord({
873
+ HostedZoneId: hostedZoneId,
874
+ Name: domain,
875
+ Type: 'AAAA',
876
+ TargetHostedZoneId: Route53Client.CloudFrontHostedZoneId,
877
+ TargetDNSName: distResult.DomainName,
878
+ EvaluateTargetHealth: false,
879
+ })
880
+ console.log(`Route53 records created for ${domain}`)
881
+ }
882
+ catch (dnsErr: any) {
883
+ console.log(`Note: Could not create Route53 records: ${dnsErr.message}`)
884
+ }
885
+ }
886
+
887
+ return {
888
+ success: true,
889
+ stackName: `direct-${distResult.Id}`,
890
+ bucket: finalBucket,
891
+ distributionId: distResult.Id,
892
+ distributionDomain: distResult.DomainName,
893
+ domain,
894
+ certificateArn,
895
+ message: 'Static site infrastructure created via direct API calls',
896
+ }
897
+ }
898
+ catch (directErr: any) {
899
+ console.log(`Direct API creation failed: ${directErr.message}`)
900
+ return {
901
+ success: false,
902
+ stackId,
903
+ stackName,
904
+ bucket: finalBucket,
905
+ message: `Deployment failed: ${directErr.message}`,
906
+ }
907
+ }
908
+ }
909
+
910
+ return {
911
+ success: false,
912
+ stackId,
913
+ stackName,
914
+ bucket: finalBucket,
915
+ message: `Stack deployment failed: ${err.message}`,
916
+ }
917
+ }
918
+
919
+ // Get stack outputs
920
+ const stacks = await cf.describeStacks({ stackName })
921
+ const outputs = stacks.Stacks[0]?.Outputs || []
922
+ const getOutput = (key: string) => outputs.find(o => o.OutputKey === key)?.OutputValue
923
+
924
+ return {
925
+ success: true,
926
+ stackId,
927
+ stackName,
928
+ bucket: getOutput('BucketName') || finalBucket,
929
+ distributionId: getOutput('DistributionId'),
930
+ distributionDomain: getOutput('DistributionDomain'),
931
+ domain,
932
+ certificateArn,
933
+ message: 'Static site infrastructure deployed successfully',
934
+ }
935
+ }
936
+
937
+ /**
938
+ * Upload files to S3 bucket (only uploads changed files)
939
+ */
940
+ export async function uploadStaticFiles(options: UploadOptions): Promise<{ uploaded: number; skipped: number; errors: string[] }> {
941
+ const { sourceDir, bucket, region, cacheControl = 'max-age=31536000, public', onProgress } = options
942
+ const s3 = new S3Client(region)
943
+
944
+ const { readdir } = await import('node:fs/promises')
945
+ const { join, relative } = await import('node:path')
946
+ const { createHash } = await import('node:crypto')
947
+
948
+ // Recursively list files
949
+ async function listFiles(dir: string): Promise<string[]> {
950
+ const files: string[] = []
951
+ const entries = await readdir(dir, { withFileTypes: true })
952
+
953
+ for (const entry of entries) {
954
+ const fullPath = join(dir, entry.name)
955
+ if (entry.isDirectory()) {
956
+ files.push(...await listFiles(fullPath))
957
+ }
958
+ else {
959
+ files.push(fullPath)
960
+ }
961
+ }
962
+
963
+ return files
964
+ }
965
+
966
+ // Get content type
967
+ function getContentType(filePath: string): string {
968
+ const ext = filePath.split('.').pop()?.toLowerCase()
969
+ const types: Record<string, string> = {
970
+ 'html': 'text/html; charset=utf-8',
971
+ 'css': 'text/css; charset=utf-8',
972
+ 'js': 'application/javascript; charset=utf-8',
973
+ 'json': 'application/json; charset=utf-8',
974
+ 'png': 'image/png',
975
+ 'jpg': 'image/jpeg',
976
+ 'jpeg': 'image/jpeg',
977
+ 'gif': 'image/gif',
978
+ 'svg': 'image/svg+xml',
979
+ 'ico': 'image/x-icon',
980
+ 'webp': 'image/webp',
981
+ 'woff': 'font/woff',
982
+ 'woff2': 'font/woff2',
983
+ 'ttf': 'font/ttf',
984
+ 'xml': 'application/xml',
985
+ 'txt': 'text/plain; charset=utf-8',
986
+ }
987
+ return types[ext || ''] || 'application/octet-stream'
988
+ }
989
+
990
+ // Compute MD5 hash of content (matches S3 ETag format)
991
+ function computeMD5(content: Buffer): string {
992
+ return createHash('md5').update(content).digest('hex')
993
+ }
994
+
995
+ // Fetch all existing S3 objects to build ETag map
996
+ async function getExistingETags(): Promise<Map<string, string>> {
997
+ const etagMap = new Map<string, string>()
998
+ let continuationToken: string | undefined
999
+
1000
+ do {
1001
+ const result = await s3.listObjects({
1002
+ bucket,
1003
+ maxKeys: 1000,
1004
+ continuationToken,
1005
+ })
1006
+
1007
+ for (const obj of result.objects) {
1008
+ // S3 ETags are quoted, remove quotes for comparison
1009
+ const etag = obj.ETag?.replace(/"/g, '') || ''
1010
+ etagMap.set(obj.Key, etag)
1011
+ }
1012
+
1013
+ continuationToken = result.nextContinuationToken
1014
+ } while (continuationToken)
1015
+
1016
+ return etagMap
1017
+ }
1018
+
1019
+ const files = await listFiles(sourceDir)
1020
+ const errors: string[] = []
1021
+ let uploaded = 0
1022
+ let skipped = 0
1023
+
1024
+ // Get existing ETags from S3
1025
+ const existingETags = await getExistingETags()
1026
+
1027
+ for (const file of files) {
1028
+ const key = relative(sourceDir, file)
1029
+ const contentType = getContentType(file)
1030
+ const fileCacheControl = file.endsWith('.html') ? 'max-age=3600, public' : cacheControl
1031
+
1032
+ try {
1033
+ const content = Buffer.from(await Bun.file(file).arrayBuffer())
1034
+ const localMD5 = computeMD5(content)
1035
+ const existingETag = existingETags.get(key)
1036
+
1037
+ // Skip upload if file hasn't changed
1038
+ if (existingETag && existingETag === localMD5) {
1039
+ skipped++
1040
+ onProgress?.(uploaded + skipped, files.length, key)
1041
+ continue
1042
+ }
1043
+
1044
+ await s3.putObject({
1045
+ bucket,
1046
+ key,
1047
+ body: content,
1048
+ contentType,
1049
+ cacheControl: fileCacheControl,
1050
+ })
1051
+
1052
+ uploaded++
1053
+ onProgress?.(uploaded + skipped, files.length, key)
1054
+ }
1055
+ catch (err: any) {
1056
+ errors.push(`Failed to upload ${key}: ${err.message}`)
1057
+ }
1058
+ }
1059
+
1060
+ return { uploaded, skipped, errors }
1061
+ }
1062
+
1063
+ /**
1064
+ * Invalidate CloudFront cache
1065
+ */
1066
+ export async function invalidateCache(distributionId: string): Promise<{ invalidationId: string }> {
1067
+ const cloudfront = new CloudFrontClient()
1068
+ const result = await cloudfront.invalidateAll(distributionId)
1069
+ return { invalidationId: result.Id }
1070
+ }
1071
+
1072
+ /**
1073
+ * Delete static site infrastructure
1074
+ */
1075
+ export async function deleteStaticSite(stackName: string, region: string = 'us-east-1'): Promise<{ success: boolean; message: string }> {
1076
+ const cf = new CloudFormationClient(region)
1077
+
1078
+ // First, empty the S3 bucket (CloudFormation can't delete non-empty buckets)
1079
+ try {
1080
+ const stacks = await cf.describeStacks({ stackName })
1081
+ const outputs = stacks.Stacks[0]?.Outputs || []
1082
+ const bucketName = outputs.find(o => o.OutputKey === 'BucketName')?.OutputValue
1083
+
1084
+ if (bucketName) {
1085
+ const s3 = new S3Client(region)
1086
+ await s3.emptyBucket(bucketName)
1087
+ }
1088
+ }
1089
+ catch {
1090
+ // Bucket might not exist or already be empty
1091
+ }
1092
+
1093
+ // Delete the stack
1094
+ await cf.deleteStack(stackName)
1095
+
1096
+ // Wait for deletion
1097
+ const result = await cf.waitForStackComplete(stackName, 60, 10000)
1098
+
1099
+ return {
1100
+ success: result.success || result.status === 'DELETE_COMPLETE',
1101
+ message: result.success ? 'Static site deleted successfully' : `Deletion failed: ${result.status}`,
1102
+ }
1103
+ }
1104
+
1105
+ /**
1106
+ * Full deployment: infrastructure + files + cache invalidation
1107
+ */
1108
+ export async function deployStaticSiteFull(config: StaticSiteConfig & {
1109
+ sourceDir: string
1110
+ cleanBucket?: boolean
1111
+ onProgress?: (stage: string, detail?: string) => void
1112
+ }): Promise<DeployResult & { filesUploaded?: number; filesSkipped?: number }> {
1113
+ const { sourceDir, cleanBucket = false, onProgress, ...siteConfig } = config
1114
+
1115
+ // Step 1: Deploy infrastructure
1116
+ onProgress?.('infrastructure', 'Deploying CloudFormation stack...')
1117
+ const infraResult = await deployStaticSite(siteConfig)
1118
+
1119
+ if (!infraResult.success) {
1120
+ return infraResult
1121
+ }
1122
+
1123
+ // Step 2: Clean bucket before upload (ensures no stale files)
1124
+ if (cleanBucket) {
1125
+ onProgress?.('clean', 'Cleaning old files from S3...')
1126
+ try {
1127
+ const s3 = new S3Client(siteConfig.region || 'us-east-1')
1128
+ await s3.emptyBucket(infraResult.bucket)
1129
+ }
1130
+ catch (err: any) {
1131
+ // Log but don't fail - bucket might be empty
1132
+ console.log(`Note: Could not clean bucket: ${err.message}`)
1133
+ }
1134
+ }
1135
+
1136
+ // Step 3: Upload files
1137
+ onProgress?.('upload', 'Uploading files to S3...')
1138
+ const uploadResult = await uploadStaticFiles({
1139
+ sourceDir,
1140
+ bucket: infraResult.bucket,
1141
+ region: siteConfig.region || 'us-east-1',
1142
+ cacheControl: siteConfig.cacheControl,
1143
+ onProgress: (uploaded, total, file) => {
1144
+ onProgress?.('upload', `${uploaded}/${total}: ${file}`)
1145
+ },
1146
+ })
1147
+
1148
+ if (uploadResult.errors.length > 0) {
1149
+ return {
1150
+ ...infraResult,
1151
+ success: false,
1152
+ message: `Upload errors: ${uploadResult.errors.join(', ')}`,
1153
+ filesUploaded: uploadResult.uploaded,
1154
+ }
1155
+ }
1156
+
1157
+ // Step 4: Invalidate cache (only if files were uploaded)
1158
+ if (infraResult.distributionId && uploadResult.uploaded > 0) {
1159
+ onProgress?.('invalidate', 'Invalidating CloudFront cache...')
1160
+ await invalidateCache(infraResult.distributionId)
1161
+ }
1162
+
1163
+ onProgress?.('complete', 'Deployment complete!')
1164
+
1165
+ const message = uploadResult.skipped > 0
1166
+ ? `Deployed ${uploadResult.uploaded} files (${uploadResult.skipped} unchanged)`
1167
+ : `Deployed ${uploadResult.uploaded} files successfully`
1168
+
1169
+ return {
1170
+ ...infraResult,
1171
+ filesUploaded: uploadResult.uploaded,
1172
+ filesSkipped: uploadResult.skipped,
1173
+ message,
1174
+ }
1175
+ }