@fnd-platform/cli 1.0.0-alpha.1 → 1.0.0-alpha.11

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.
@@ -1,6 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.generateInfraPackage = generateInfraPackage;
4
+ exports.getFrontendStackTemplate = getFrontendStackTemplate;
5
+ exports.getCmsStackTemplate = getCmsStackTemplate;
6
+ exports.updateAppTsForStack = updateAppTsForStack;
7
+ exports.generateFrontendStack = generateFrontendStack;
8
+ exports.generateCmsStack = generateCmsStack;
4
9
  const fs_1 = require("fs");
5
10
  const path_1 = require("path");
6
11
  const logger_js_1 = require("./logger.js");
@@ -20,13 +25,18 @@ function generateInfraPackage(options) {
20
25
  logger_js_1.logger.info('Generating infrastructure package...');
21
26
  // Create directory structure
22
27
  (0, fs_1.mkdirSync)((0, path_1.join)(infraDir, 'src/stacks'), { recursive: true });
23
- // Generate files
28
+ // Generate package config files
24
29
  (0, fs_1.writeFileSync)((0, path_1.join)(infraDir, 'package.json'), getPackageJsonTemplate(options));
25
30
  (0, fs_1.writeFileSync)((0, path_1.join)(infraDir, 'tsconfig.json'), getTsconfigTemplate());
26
31
  (0, fs_1.writeFileSync)((0, path_1.join)(infraDir, 'cdk.json'), getCdkJsonTemplate());
32
+ // Generate app entry point
27
33
  (0, fs_1.writeFileSync)((0, path_1.join)(infraDir, 'src/app.ts'), getAppTemplate(options));
34
+ // Generate stack files
35
+ (0, fs_1.writeFileSync)((0, path_1.join)(infraDir, 'src/stacks/database-stack.ts'), getDatabaseStackTemplate(options));
36
+ (0, fs_1.writeFileSync)((0, path_1.join)(infraDir, 'src/stacks/media-stack.ts'), getMediaStackTemplate(options));
28
37
  (0, fs_1.writeFileSync)((0, path_1.join)(infraDir, 'src/stacks/api-stack.ts'), getApiStackTemplate(options));
29
38
  logger_js_1.logger.success('Infrastructure package generated at packages/infra');
39
+ logger_js_1.logger.info('Generated stacks: DatabaseStack, MediaStack, ApiStack');
30
40
  }
31
41
  /**
32
42
  * Returns the package.json template.
@@ -45,13 +55,16 @@ function getPackageJsonTemplate(options) {
45
55
  diff: 'cdk diff',
46
56
  },
47
57
  dependencies: {
48
- '@fnd-platform/constructs': 'workspace:*',
58
+ '@fnd-platform/constructs': '^1.0.0-alpha.1',
49
59
  'aws-cdk-lib': '^2.130.0',
50
60
  constructs: '^10.3.0',
61
+ 'source-map-support': '^0.5.21',
51
62
  },
52
63
  devDependencies: {
53
64
  '@types/node': '^20.0.0',
65
+ '@types/source-map-support': '^0.5.10',
54
66
  'aws-cdk': '^2.130.0',
67
+ esbuild: '^0.20.0',
55
68
  typescript: '^5.6.3',
56
69
  },
57
70
  }, null, 2);
@@ -103,80 +116,233 @@ function getCdkJsonTemplate() {
103
116
  * Returns the app.ts template.
104
117
  */
105
118
  function getAppTemplate(options) {
106
- // Convert project name to PascalCase for class naming
107
- const stackClassName = toPascalCase(options.projectName) + 'Api';
108
- return `#!/usr/bin/env npx ts-node
109
- import { App } from 'aws-cdk-lib';
110
- import { ApiStack } from './stacks/api-stack.js';
119
+ const pascalName = toPascalCase(options.projectName);
120
+ return `#!/usr/bin/env node
121
+ /**
122
+ * CDK Application Entry Point for ${options.projectName}
123
+ */
124
+
125
+ import 'source-map-support/register';
126
+ import * as cdk from 'aws-cdk-lib';
127
+
128
+ import { DatabaseStack } from './stacks/database-stack';
129
+ import { MediaStack } from './stacks/media-stack';
130
+ import { ApiStack } from './stacks/api-stack';
111
131
 
112
- const app = new App();
132
+ // Create the CDK app
133
+ const app = new cdk.App();
113
134
 
114
- // Get stage from context (default to 'dev')
135
+ // Get deployment stage from context (default: dev)
115
136
  const stage = app.node.tryGetContext('stage') || 'dev';
116
137
 
117
- new ApiStack(app, '${stackClassName}', {
138
+ // Validate stage
139
+ const validStages = ['dev', 'staging', 'prod'];
140
+ if (!validStages.includes(stage)) {
141
+ throw new Error(\`Invalid stage '\${stage}'. Must be one of: \${validStages.join(', ')}\`);
142
+ }
143
+
144
+ // Environment configuration
145
+ const env: cdk.Environment = {
146
+ account: process.env.CDK_DEFAULT_ACCOUNT,
147
+ region: process.env.CDK_DEFAULT_REGION || 'us-east-1',
148
+ };
149
+
150
+ // Stack naming convention
151
+ const stackName = (name: string) => \`${pascalName}\${name}Stack-\${stage}\`;
152
+
153
+ /**
154
+ * Database Stack
155
+ * Creates DynamoDB table with GSIs
156
+ */
157
+ const databaseStack = new DatabaseStack(app, stackName('Database'), {
158
+ env,
159
+ stage,
160
+ description: '${options.projectName}: DynamoDB table',
161
+ });
162
+
163
+ /**
164
+ * Media Stack
165
+ * Creates S3 bucket and CloudFront for media
166
+ */
167
+ const mediaStack = new MediaStack(app, stackName('Media'), {
168
+ env,
169
+ stage,
170
+ description: '${options.projectName}: Media storage',
171
+ });
172
+
173
+ /**
174
+ * API Stack
175
+ * Creates Lambda functions and API Gateway
176
+ */
177
+ const apiStack = new ApiStack(app, stackName('Api'), {
178
+ env,
118
179
  stage,
119
- env: {
120
- account: process.env.CDK_DEFAULT_ACCOUNT,
121
- region: process.env.CDK_DEFAULT_REGION,
122
- },
180
+ table: databaseStack.table,
181
+ mediaBucket: mediaStack.bucket,
182
+ description: '${options.projectName}: API',
123
183
  });
124
184
 
125
- app.synth();
185
+ // API depends on database and media
186
+ apiStack.addDependency(databaseStack);
187
+ apiStack.addDependency(mediaStack);
188
+
189
+ // Add tags to all stacks
190
+ const tags = {
191
+ Project: '${options.projectName}',
192
+ Stage: stage,
193
+ ManagedBy: 'cdk',
194
+ CreatedBy: 'fnd-platform',
195
+ };
196
+
197
+ Object.entries(tags).forEach(([key, value]) => {
198
+ cdk.Tags.of(app).add(key, value);
199
+ });
200
+
201
+ // Output summary
202
+ console.log(\`
203
+ ╔════════════════════════════════════════════════════════════╗
204
+ ║ ${options.projectName} Infrastructure
205
+ ╠════════════════════════════════════════════════════════════╣
206
+ ║ Stage: \${stage}
207
+ ║ Region: \${env.region || 'us-east-1'}
208
+
209
+ ║ Stacks:
210
+ ║ - \${stackName('Database')}
211
+ ║ - \${stackName('Media')}
212
+ ║ - \${stackName('Api')}
213
+ ╚════════════════════════════════════════════════════════════╝
214
+ \`);
126
215
  `;
127
216
  }
128
217
  /**
129
218
  * Returns the api-stack.ts template.
130
219
  */
131
220
  function getApiStackTemplate(options) {
132
- const apiName = options.projectName.replace(/-/g, '_');
133
- return `import {
134
- FndApiStack,
135
- FndApiStackProps,
221
+ const pascalName = toPascalCase(options.projectName);
222
+ const lowerName = options.projectName.toLowerCase();
223
+ return `/**
224
+ * API Stack for ${options.projectName}
225
+ *
226
+ * Creates Lambda functions and API Gateway with content CRUD endpoints.
227
+ */
228
+
229
+ import * as cdk from 'aws-cdk-lib';
230
+ import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
231
+ import * as s3 from 'aws-cdk-lib/aws-s3';
232
+ import * as path from 'path';
233
+ import { Construct } from 'constructs';
234
+ import {
136
235
  FndLambdaFunction,
137
236
  FndApiGateway,
138
237
  } from '@fnd-platform/constructs';
139
- import { Construct } from 'constructs';
140
- import * as path from 'path';
141
238
 
142
- /**
143
- * API Stack for ${options.projectName}.
144
- *
145
- * This stack creates:
146
- * - Lambda functions for API handlers
147
- * - API Gateway REST API with routes
148
- */
149
- export class ApiStack extends FndApiStack {
150
- constructor(scope: Construct, id: string, props: FndApiStackProps) {
239
+ export interface ApiStackProps extends cdk.StackProps {
240
+ stage: string;
241
+ table: dynamodb.ITable;
242
+ mediaBucket: s3.IBucket;
243
+ }
244
+
245
+ export class ApiStack extends cdk.Stack {
246
+ /**
247
+ * The API URL
248
+ */
249
+ public readonly apiUrl: string;
250
+
251
+ constructor(scope: Construct, id: string, props: ApiStackProps) {
151
252
  super(scope, id, props);
152
253
 
153
- // Path to the API package
154
- const apiPackagePath = path.resolve(__dirname, '../../${options.apiPackageName}');
254
+ const { stage, table, mediaBucket } = props;
155
255
 
156
256
  // Health check handler
157
257
  const healthHandler = new FndLambdaFunction(this, 'HealthHandler', {
158
- entry: path.join(apiPackagePath, 'src/handlers/health.ts'),
159
- stage: this.stage,
258
+ entry: path.join(__dirname, '../../../${options.apiPackageName}/src/handlers/health.ts'),
259
+ stage,
160
260
  description: 'Health check endpoint',
261
+ environment: {
262
+ TABLE_NAME: table.tableName,
263
+ MEDIA_BUCKET: mediaBucket.bucketName,
264
+ },
161
265
  });
162
266
 
163
- // API Gateway
267
+ // Content CRUD handler (single handler for all content operations)
268
+ const contentHandler = new FndLambdaFunction(this, 'ContentHandler', {
269
+ entry: path.join(__dirname, '../../../${options.apiPackageName}/src/handlers/content.ts'),
270
+ stage,
271
+ description: 'Content CRUD operations',
272
+ environment: {
273
+ TABLE_NAME: table.tableName,
274
+ MEDIA_BUCKET: mediaBucket.bucketName,
275
+ },
276
+ });
277
+
278
+ // Grant DynamoDB access to handlers
279
+ table.grantReadWriteData(healthHandler.function);
280
+ table.grantReadWriteData(contentHandler.function);
281
+
282
+ // Grant S3 access for media operations
283
+ mediaBucket.grantReadWrite(healthHandler.function);
284
+ mediaBucket.grantReadWrite(contentHandler.function);
285
+
286
+ // API Gateway with all routes
164
287
  const api = new FndApiGateway(this, 'Api', {
165
- name: '${apiName}',
166
- stage: this.stage,
288
+ name: '${lowerName}-api',
289
+ stage,
167
290
  routes: [
291
+ // Health check (public)
168
292
  {
169
293
  method: 'GET',
170
294
  path: '/health',
171
295
  handler: healthHandler.function,
172
296
  requiresAuth: false,
173
297
  },
174
- // Add more routes here as you create handlers
298
+ // Content routes
299
+ {
300
+ method: 'GET',
301
+ path: '/content',
302
+ handler: contentHandler.function,
303
+ requiresAuth: false,
304
+ },
305
+ {
306
+ method: 'POST',
307
+ path: '/content',
308
+ handler: contentHandler.function,
309
+ requiresAuth: true,
310
+ },
311
+ {
312
+ method: 'GET',
313
+ path: '/content/{id}',
314
+ handler: contentHandler.function,
315
+ requiresAuth: false,
316
+ },
317
+ {
318
+ method: 'PUT',
319
+ path: '/content/{id}',
320
+ handler: contentHandler.function,
321
+ requiresAuth: true,
322
+ },
323
+ {
324
+ method: 'DELETE',
325
+ path: '/content/{id}',
326
+ handler: contentHandler.function,
327
+ requiresAuth: true,
328
+ },
329
+ {
330
+ method: 'GET',
331
+ path: '/content/slug/{slug}',
332
+ handler: contentHandler.function,
333
+ requiresAuth: false,
334
+ },
175
335
  ],
176
336
  });
177
337
 
178
- // Stack outputs
179
- this.addOutput('ApiUrl', api.url, 'API Gateway URL');
338
+ this.apiUrl = api.url;
339
+
340
+ // Outputs
341
+ new cdk.CfnOutput(this, 'ApiUrl', {
342
+ value: api.url,
343
+ description: 'API Gateway URL',
344
+ exportName: \`${pascalName}-ApiUrl-\${stage}\`,
345
+ });
180
346
  }
181
347
  }
182
348
  `;
@@ -190,4 +356,710 @@ function toPascalCase(str) {
190
356
  .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
191
357
  .join('');
192
358
  }
359
+ /**
360
+ * Returns the database-stack.ts template.
361
+ */
362
+ function getDatabaseStackTemplate(options) {
363
+ const pascalName = toPascalCase(options.projectName);
364
+ const lowerName = options.projectName.toLowerCase();
365
+ return `/**
366
+ * Database Stack for ${options.projectName}
367
+ *
368
+ * Creates DynamoDB table with GSIs for single-table design.
369
+ */
370
+
371
+ import * as cdk from 'aws-cdk-lib';
372
+ import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
373
+ import { Construct } from 'constructs';
374
+
375
+ export interface DatabaseStackProps extends cdk.StackProps {
376
+ stage: string;
377
+ }
378
+
379
+ export class DatabaseStack extends cdk.Stack {
380
+ /**
381
+ * The main DynamoDB table
382
+ */
383
+ public readonly table: dynamodb.Table;
384
+
385
+ constructor(scope: Construct, id: string, props: DatabaseStackProps) {
386
+ super(scope, id, props);
387
+
388
+ const { stage } = props;
389
+ const isProd = stage === 'prod';
390
+
391
+ // Create the main table with single-table design
392
+ this.table = new dynamodb.Table(this, 'Table', {
393
+ tableName: \`${lowerName}-\${stage}\`,
394
+ partitionKey: {
395
+ name: 'PK',
396
+ type: dynamodb.AttributeType.STRING,
397
+ },
398
+ sortKey: {
399
+ name: 'SK',
400
+ type: dynamodb.AttributeType.STRING,
401
+ },
402
+ billingMode: isProd
403
+ ? dynamodb.BillingMode.PROVISIONED
404
+ : dynamodb.BillingMode.PAY_PER_REQUEST,
405
+ ...(isProd && {
406
+ readCapacity: 5,
407
+ writeCapacity: 5,
408
+ }),
409
+ removalPolicy: isProd
410
+ ? cdk.RemovalPolicy.RETAIN
411
+ : cdk.RemovalPolicy.DESTROY,
412
+ pointInTimeRecovery: isProd,
413
+ encryption: dynamodb.TableEncryption.AWS_MANAGED,
414
+ });
415
+
416
+ // GSI1: Lookup by slug or secondary key
417
+ this.table.addGlobalSecondaryIndex({
418
+ indexName: 'GSI1',
419
+ partitionKey: {
420
+ name: 'GSI1PK',
421
+ type: dynamodb.AttributeType.STRING,
422
+ },
423
+ sortKey: {
424
+ name: 'GSI1SK',
425
+ type: dynamodb.AttributeType.STRING,
426
+ },
427
+ projectionType: dynamodb.ProjectionType.ALL,
428
+ });
429
+
430
+ // GSI2: List by type/status with timestamp sorting
431
+ this.table.addGlobalSecondaryIndex({
432
+ indexName: 'GSI2',
433
+ partitionKey: {
434
+ name: 'GSI2PK',
435
+ type: dynamodb.AttributeType.STRING,
436
+ },
437
+ sortKey: {
438
+ name: 'GSI2SK',
439
+ type: dynamodb.AttributeType.STRING,
440
+ },
441
+ projectionType: dynamodb.ProjectionType.ALL,
442
+ });
443
+
444
+ // Enable auto-scaling for production
445
+ if (isProd) {
446
+ const readScaling = this.table.autoScaleReadCapacity({
447
+ minCapacity: 5,
448
+ maxCapacity: 100,
449
+ });
450
+ readScaling.scaleOnUtilization({
451
+ targetUtilizationPercent: 70,
452
+ });
453
+
454
+ const writeScaling = this.table.autoScaleWriteCapacity({
455
+ minCapacity: 5,
456
+ maxCapacity: 100,
457
+ });
458
+ writeScaling.scaleOnUtilization({
459
+ targetUtilizationPercent: 70,
460
+ });
461
+ }
462
+
463
+ // Outputs
464
+ new cdk.CfnOutput(this, 'TableName', {
465
+ value: this.table.tableName,
466
+ description: 'DynamoDB table name',
467
+ exportName: \`${pascalName}-TableName-\${stage}\`,
468
+ });
469
+
470
+ new cdk.CfnOutput(this, 'TableArn', {
471
+ value: this.table.tableArn,
472
+ description: 'DynamoDB table ARN',
473
+ exportName: \`${pascalName}-TableArn-\${stage}\`,
474
+ });
475
+ }
476
+ }
477
+ `;
478
+ }
479
+ /**
480
+ * Returns the media-stack.ts template.
481
+ */
482
+ function getMediaStackTemplate(options) {
483
+ const pascalName = toPascalCase(options.projectName);
484
+ const lowerName = options.projectName.toLowerCase();
485
+ return `/**
486
+ * Media Stack for ${options.projectName}
487
+ *
488
+ * Creates S3 bucket and CloudFront distribution for media uploads.
489
+ */
490
+
491
+ import * as cdk from 'aws-cdk-lib';
492
+ import * as s3 from 'aws-cdk-lib/aws-s3';
493
+ import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
494
+ import * as origins from 'aws-cdk-lib/aws-cloudfront-origins';
495
+ import { Construct } from 'constructs';
496
+
497
+ export interface MediaStackProps extends cdk.StackProps {
498
+ stage: string;
499
+ }
500
+
501
+ export class MediaStack extends cdk.Stack {
502
+ /**
503
+ * The S3 bucket for media storage
504
+ */
505
+ public readonly bucket: s3.Bucket;
506
+
507
+ /**
508
+ * The CloudFront distribution for media delivery
509
+ */
510
+ public readonly distribution: cloudfront.Distribution;
511
+
512
+ /**
513
+ * The media CDN URL
514
+ */
515
+ public readonly mediaUrl: string;
516
+
517
+ constructor(scope: Construct, id: string, props: MediaStackProps) {
518
+ super(scope, id, props);
519
+
520
+ const { stage } = props;
521
+ const isProd = stage === 'prod';
522
+
523
+ // Create S3 bucket for media storage
524
+ this.bucket = new s3.Bucket(this, 'MediaBucket', {
525
+ bucketName: \`${lowerName}-media-\${stage}-\${this.account}\`,
526
+ removalPolicy: isProd
527
+ ? cdk.RemovalPolicy.RETAIN
528
+ : cdk.RemovalPolicy.DESTROY,
529
+ autoDeleteObjects: !isProd,
530
+ blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
531
+ encryption: s3.BucketEncryption.S3_MANAGED,
532
+ cors: [
533
+ {
534
+ allowedMethods: [
535
+ s3.HttpMethods.GET,
536
+ s3.HttpMethods.PUT,
537
+ s3.HttpMethods.POST,
538
+ ],
539
+ allowedOrigins: ['*'],
540
+ allowedHeaders: ['*'],
541
+ maxAge: 3000,
542
+ },
543
+ ],
544
+ lifecycleRules: [
545
+ {
546
+ id: 'DeleteIncompleteUploads',
547
+ abortIncompleteMultipartUploadAfter: cdk.Duration.days(7),
548
+ },
549
+ ],
550
+ });
551
+
552
+ // Create CloudFront distribution for media
553
+ this.distribution = new cloudfront.Distribution(this, 'Distribution', {
554
+ comment: \`${options.projectName} Media CDN (\${stage})\`,
555
+ defaultBehavior: {
556
+ origin: origins.S3BucketOrigin.withOriginAccessControl(this.bucket),
557
+ viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
558
+ cachePolicy: new cloudfront.CachePolicy(this, 'MediaCachePolicy', {
559
+ cachePolicyName: \`${lowerName}-media-cache-\${stage}\`,
560
+ defaultTtl: cdk.Duration.days(30),
561
+ maxTtl: cdk.Duration.days(365),
562
+ minTtl: cdk.Duration.days(1),
563
+ headerBehavior: cloudfront.CacheHeaderBehavior.none(),
564
+ queryStringBehavior: cloudfront.CacheQueryStringBehavior.none(),
565
+ cookieBehavior: cloudfront.CacheCookieBehavior.none(),
566
+ enableAcceptEncodingGzip: true,
567
+ enableAcceptEncodingBrotli: true,
568
+ }),
569
+ allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
570
+ compress: true,
571
+ },
572
+ priceClass: isProd
573
+ ? cloudfront.PriceClass.PRICE_CLASS_ALL
574
+ : cloudfront.PriceClass.PRICE_CLASS_100,
575
+ enabled: true,
576
+ httpVersion: cloudfront.HttpVersion.HTTP2_AND_3,
577
+ });
578
+
579
+ // Store media URL
580
+ this.mediaUrl = \`https://\${this.distribution.distributionDomainName}\`;
581
+
582
+ // Outputs
583
+ new cdk.CfnOutput(this, 'MediaUrl', {
584
+ value: this.mediaUrl,
585
+ description: 'Media CDN URL',
586
+ exportName: \`${pascalName}-MediaUrl-\${stage}\`,
587
+ });
588
+
589
+ new cdk.CfnOutput(this, 'MediaBucketName', {
590
+ value: this.bucket.bucketName,
591
+ description: 'Media S3 bucket name',
592
+ exportName: \`${pascalName}-MediaBucket-\${stage}\`,
593
+ });
594
+
595
+ new cdk.CfnOutput(this, 'MediaBucketArn', {
596
+ value: this.bucket.bucketArn,
597
+ description: 'Media S3 bucket ARN',
598
+ exportName: \`${pascalName}-MediaBucketArn-\${stage}\`,
599
+ });
600
+
601
+ new cdk.CfnOutput(this, 'MediaDistributionId', {
602
+ value: this.distribution.distributionId,
603
+ description: 'Media CloudFront distribution ID',
604
+ exportName: \`${pascalName}-MediaDistributionId-\${stage}\`,
605
+ });
606
+ }
607
+ }
608
+ `;
609
+ }
610
+ /**
611
+ * Returns the frontend-stack.ts template.
612
+ *
613
+ * @param projectName - Name of the project
614
+ * @returns Generated TypeScript code for frontend stack
615
+ */
616
+ function getFrontendStackTemplate(projectName) {
617
+ const pascalName = toPascalCase(projectName);
618
+ const lowerName = projectName.toLowerCase();
619
+ return `/**
620
+ * Frontend Stack for ${projectName}
621
+ *
622
+ * Creates S3 bucket and CloudFront distribution for the frontend application.
623
+ */
624
+
625
+ import * as cdk from 'aws-cdk-lib';
626
+ import * as s3 from 'aws-cdk-lib/aws-s3';
627
+ import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
628
+ import * as origins from 'aws-cdk-lib/aws-cloudfront-origins';
629
+ import { Construct } from 'constructs';
630
+
631
+ export interface FrontendStackProps extends cdk.StackProps {
632
+ stage: string;
633
+ apiUrl: string;
634
+ }
635
+
636
+ export class FrontendStack extends cdk.Stack {
637
+ /**
638
+ * The S3 bucket for frontend assets
639
+ */
640
+ public readonly bucket: s3.Bucket;
641
+
642
+ /**
643
+ * The CloudFront distribution for frontend delivery
644
+ */
645
+ public readonly distribution: cloudfront.Distribution;
646
+
647
+ /**
648
+ * The frontend URL
649
+ */
650
+ public readonly url: string;
651
+
652
+ constructor(scope: Construct, id: string, props: FrontendStackProps) {
653
+ super(scope, id, props);
654
+
655
+ const { stage, apiUrl } = props;
656
+ const isProd = stage === 'prod';
657
+
658
+ // Create S3 bucket for frontend assets
659
+ this.bucket = new s3.Bucket(this, 'FrontendBucket', {
660
+ bucketName: \`${lowerName}-frontend-\${stage}-\${this.account}\`,
661
+ removalPolicy: isProd
662
+ ? cdk.RemovalPolicy.RETAIN
663
+ : cdk.RemovalPolicy.DESTROY,
664
+ autoDeleteObjects: !isProd,
665
+ blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
666
+ encryption: s3.BucketEncryption.S3_MANAGED,
667
+ });
668
+
669
+ // Create CloudFront distribution for frontend
670
+ this.distribution = new cloudfront.Distribution(this, 'Distribution', {
671
+ comment: \`${projectName} Frontend (\${stage})\`,
672
+ defaultBehavior: {
673
+ origin: origins.S3BucketOrigin.withOriginAccessControl(this.bucket),
674
+ viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
675
+ cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
676
+ allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
677
+ compress: true,
678
+ },
679
+ defaultRootObject: 'index.html',
680
+ // SPA routing - return index.html for 404 and 403 errors
681
+ errorResponses: [
682
+ {
683
+ httpStatus: 404,
684
+ responseHttpStatus: 200,
685
+ responsePagePath: '/index.html',
686
+ ttl: cdk.Duration.minutes(5),
687
+ },
688
+ {
689
+ httpStatus: 403,
690
+ responseHttpStatus: 200,
691
+ responsePagePath: '/index.html',
692
+ ttl: cdk.Duration.minutes(5),
693
+ },
694
+ ],
695
+ priceClass: isProd
696
+ ? cloudfront.PriceClass.PRICE_CLASS_ALL
697
+ : cloudfront.PriceClass.PRICE_CLASS_100,
698
+ enabled: true,
699
+ httpVersion: cloudfront.HttpVersion.HTTP2_AND_3,
700
+ });
701
+
702
+ // Store frontend URL
703
+ this.url = \`https://\${this.distribution.distributionDomainName}\`;
704
+
705
+ // Outputs
706
+ new cdk.CfnOutput(this, 'FrontendUrl', {
707
+ value: this.url,
708
+ description: 'Frontend URL',
709
+ exportName: \`${pascalName}-FrontendUrl-\${stage}\`,
710
+ });
711
+
712
+ new cdk.CfnOutput(this, 'FrontendBucketName', {
713
+ value: this.bucket.bucketName,
714
+ description: 'Frontend S3 bucket name',
715
+ exportName: \`${pascalName}-FrontendBucket-\${stage}\`,
716
+ });
717
+
718
+ new cdk.CfnOutput(this, 'FrontendDistributionId', {
719
+ value: this.distribution.distributionId,
720
+ description: 'Frontend CloudFront distribution ID',
721
+ exportName: \`${pascalName}-FrontendDistributionId-\${stage}\`,
722
+ });
723
+
724
+ new cdk.CfnOutput(this, 'ApiUrl', {
725
+ value: apiUrl,
726
+ description: 'API URL (for reference)',
727
+ exportName: \`${pascalName}-Frontend-ApiUrl-\${stage}\`,
728
+ });
729
+ }
730
+ }
731
+ `;
732
+ }
733
+ /**
734
+ * Returns the cms-stack.ts template.
735
+ *
736
+ * @param projectName - Name of the project
737
+ * @returns Generated TypeScript code for CMS stack
738
+ */
739
+ function getCmsStackTemplate(projectName) {
740
+ const pascalName = toPascalCase(projectName);
741
+ const lowerName = projectName.toLowerCase();
742
+ return `/**
743
+ * CMS Stack for ${projectName}
744
+ *
745
+ * Creates S3 bucket and CloudFront distribution for the CMS admin interface.
746
+ * Always uses PRICE_CLASS_100 as CMS is an internal admin tool.
747
+ */
748
+
749
+ import * as cdk from 'aws-cdk-lib';
750
+ import * as s3 from 'aws-cdk-lib/aws-s3';
751
+ import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
752
+ import * as origins from 'aws-cdk-lib/aws-cloudfront-origins';
753
+ import { Construct } from 'constructs';
754
+
755
+ export interface CmsStackProps extends cdk.StackProps {
756
+ stage: string;
757
+ apiUrl: string;
758
+ }
759
+
760
+ export class CmsStack extends cdk.Stack {
761
+ /**
762
+ * The S3 bucket for CMS assets
763
+ */
764
+ public readonly bucket: s3.Bucket;
765
+
766
+ /**
767
+ * The CloudFront distribution for CMS delivery
768
+ */
769
+ public readonly distribution: cloudfront.Distribution;
770
+
771
+ /**
772
+ * The CMS URL
773
+ */
774
+ public readonly url: string;
775
+
776
+ constructor(scope: Construct, id: string, props: CmsStackProps) {
777
+ super(scope, id, props);
778
+
779
+ const { stage, apiUrl } = props;
780
+ const isProd = stage === 'prod';
781
+
782
+ // Create S3 bucket for CMS assets
783
+ this.bucket = new s3.Bucket(this, 'CmsBucket', {
784
+ bucketName: \`${lowerName}-cms-\${stage}-\${this.account}\`,
785
+ removalPolicy: isProd
786
+ ? cdk.RemovalPolicy.RETAIN
787
+ : cdk.RemovalPolicy.DESTROY,
788
+ autoDeleteObjects: !isProd,
789
+ blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
790
+ encryption: s3.BucketEncryption.S3_MANAGED,
791
+ });
792
+
793
+ // Create CloudFront distribution for CMS
794
+ // Always use PRICE_CLASS_100 - CMS is internal admin tool, not public-facing
795
+ this.distribution = new cloudfront.Distribution(this, 'Distribution', {
796
+ comment: \`${projectName} CMS (\${stage})\`,
797
+ defaultBehavior: {
798
+ origin: origins.S3BucketOrigin.withOriginAccessControl(this.bucket),
799
+ viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
800
+ cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
801
+ allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
802
+ compress: true,
803
+ },
804
+ defaultRootObject: 'index.html',
805
+ // SPA routing - return index.html for 404 and 403 errors
806
+ errorResponses: [
807
+ {
808
+ httpStatus: 404,
809
+ responseHttpStatus: 200,
810
+ responsePagePath: '/index.html',
811
+ ttl: cdk.Duration.minutes(5),
812
+ },
813
+ {
814
+ httpStatus: 403,
815
+ responseHttpStatus: 200,
816
+ responsePagePath: '/index.html',
817
+ ttl: cdk.Duration.minutes(5),
818
+ },
819
+ ],
820
+ // Always use PRICE_CLASS_100 for CMS (internal admin tool)
821
+ priceClass: cloudfront.PriceClass.PRICE_CLASS_100,
822
+ enabled: true,
823
+ httpVersion: cloudfront.HttpVersion.HTTP2_AND_3,
824
+ });
825
+
826
+ // Store CMS URL
827
+ this.url = \`https://\${this.distribution.distributionDomainName}\`;
828
+
829
+ // Outputs
830
+ new cdk.CfnOutput(this, 'CmsUrl', {
831
+ value: this.url,
832
+ description: 'CMS URL',
833
+ exportName: \`${pascalName}-CmsUrl-\${stage}\`,
834
+ });
835
+
836
+ new cdk.CfnOutput(this, 'CmsBucketName', {
837
+ value: this.bucket.bucketName,
838
+ description: 'CMS S3 bucket name',
839
+ exportName: \`${pascalName}-CmsBucket-\${stage}\`,
840
+ });
841
+
842
+ new cdk.CfnOutput(this, 'CmsDistributionId', {
843
+ value: this.distribution.distributionId,
844
+ description: 'CMS CloudFront distribution ID',
845
+ exportName: \`${pascalName}-CmsDistributionId-\${stage}\`,
846
+ });
847
+
848
+ new cdk.CfnOutput(this, 'ApiUrl', {
849
+ value: apiUrl,
850
+ description: 'API URL (for reference)',
851
+ exportName: \`${pascalName}-Cms-ApiUrl-\${stage}\`,
852
+ });
853
+ }
854
+ }
855
+ `;
856
+ }
857
+ /**
858
+ * Updates app.ts to add a new stack.
859
+ *
860
+ * This function performs incremental updates to the existing app.ts file:
861
+ * 1. Adds import statement for the new stack
862
+ * 2. Adds stack instantiation code
863
+ * 3. Adds dependency declarations
864
+ * 4. Updates the output summary if present
865
+ *
866
+ * @param appTsPath - Path to the app.ts file
867
+ * @param config - Stack configuration
868
+ * @param projectName - Name of the project (for console output)
869
+ * @returns true if update was successful, false if stack already exists
870
+ */
871
+ function updateAppTsForStack(appTsPath, config, projectName) {
872
+ if (!(0, fs_1.existsSync)(appTsPath)) {
873
+ throw new Error(`app.ts not found at ${appTsPath}`);
874
+ }
875
+ const content = (0, fs_1.readFileSync)(appTsPath, 'utf-8');
876
+ // Check if stack already exists (prevent duplicates)
877
+ if (content.includes(`import { ${config.className} }`)) {
878
+ logger_js_1.logger.warn(`${config.className} is already imported in app.ts`);
879
+ return false;
880
+ }
881
+ const lines = content.split('\n');
882
+ const result = [];
883
+ let importInserted = false;
884
+ let stackInstantiated = false;
885
+ let summaryUpdated = false;
886
+ // Find the last stack import to insert after it
887
+ let lastStackImportIndex = -1;
888
+ for (let i = 0; i < lines.length; i++) {
889
+ if (lines[i].match(/import \{ \w+Stack \} from '\.\/stacks\/[\w-]+';/)) {
890
+ lastStackImportIndex = i;
891
+ }
892
+ }
893
+ // Find the apiStack variable name (might be renamed by user)
894
+ const apiStackMatch = content.match(/const\s+(\w+)\s*=\s*new\s+ApiStack/);
895
+ const apiStackVarName = apiStackMatch ? apiStackMatch[1] : 'apiStack';
896
+ for (let i = 0; i < lines.length; i++) {
897
+ const line = lines[i];
898
+ // Insert import after last stack import
899
+ if (!importInserted && i === lastStackImportIndex) {
900
+ result.push(line);
901
+ result.push(`import { ${config.className} } from '${config.importPath}';`);
902
+ importInserted = true;
903
+ continue;
904
+ }
905
+ // Insert stack instantiation after apiStack dependency declarations
906
+ // Look for the pattern: apiStack.addDependency(mediaStack);
907
+ if (!stackInstantiated &&
908
+ line.includes(`${apiStackVarName}.addDependency(mediaStack)`)) {
909
+ result.push(line);
910
+ result.push('');
911
+ result.push(`/**`);
912
+ result.push(` * ${config.name} Stack`);
913
+ result.push(` * ${config.description}`);
914
+ result.push(` */`);
915
+ result.push(`const ${config.variableName} = new ${config.className}(app, stackName('${config.name}'), ${config.props});`);
916
+ result.push('');
917
+ // Add dependencies
918
+ for (const dep of config.dependsOn) {
919
+ result.push(`${config.variableName}.addDependency(${dep});`);
920
+ }
921
+ stackInstantiated = true;
922
+ continue;
923
+ }
924
+ // Update summary output if present
925
+ if (!summaryUpdated && line.includes('║ Stacks:')) {
926
+ result.push(line);
927
+ // Find the next lines with stack names and add our stack after ApiStack
928
+ let j = i + 1;
929
+ while (j < lines.length && lines[j].includes('║ -')) {
930
+ result.push(lines[j]);
931
+ if (lines[j].includes("stackName('Api')")) {
932
+ result.push(`║ - \${stackName('${config.name}')}`);
933
+ summaryUpdated = true;
934
+ }
935
+ j++;
936
+ }
937
+ // Skip the lines we've already processed
938
+ i = j - 1;
939
+ continue;
940
+ }
941
+ result.push(line);
942
+ }
943
+ // Write the updated content
944
+ (0, fs_1.writeFileSync)(appTsPath, result.join('\n'));
945
+ return true;
946
+ }
947
+ /**
948
+ * Validates that all required dependencies exist in app.ts content.
949
+ *
950
+ * @param content - app.ts file content
951
+ * @param requiredVars - Array of variable names that must exist
952
+ * @throws Error if any required variable is not found
953
+ */
954
+ function validateDependencies(content, requiredVars) {
955
+ const missingDeps = [];
956
+ for (const varName of requiredVars) {
957
+ const pattern = new RegExp(`const\\s+${varName}\\s*=`);
958
+ if (!pattern.test(content)) {
959
+ missingDeps.push(varName);
960
+ }
961
+ }
962
+ if (missingDeps.length > 0) {
963
+ throw new Error(`Missing required dependencies: ${missingDeps.join(', ')}. ` +
964
+ `Ensure these stacks are created before adding dependent stacks.`);
965
+ }
966
+ }
967
+ /**
968
+ * Generates the frontend stack and updates app.ts.
969
+ *
970
+ * @param options - Generation options
971
+ */
972
+ async function generateFrontendStack(options) {
973
+ const infraDir = (0, path_1.join)(options.projectPath, 'packages/infra');
974
+ const stacksDir = (0, path_1.join)(infraDir, 'src/stacks');
975
+ const appTsPath = (0, path_1.join)(infraDir, 'src/app.ts');
976
+ // Check if infra directory exists
977
+ if (!(0, fs_1.existsSync)(infraDir)) {
978
+ throw new Error('Infrastructure package not found at packages/infra. Run `fnd add api` first.');
979
+ }
980
+ // Validate apiStack exists in app.ts
981
+ const appTsContent = (0, fs_1.readFileSync)(appTsPath, 'utf-8');
982
+ validateDependencies(appTsContent, ['apiStack']);
983
+ // Ensure stacks directory exists
984
+ if (!(0, fs_1.existsSync)(stacksDir)) {
985
+ (0, fs_1.mkdirSync)(stacksDir, { recursive: true });
986
+ }
987
+ // Write frontend-stack.ts
988
+ const stackPath = (0, path_1.join)(stacksDir, 'frontend-stack.ts');
989
+ if ((0, fs_1.existsSync)(stackPath)) {
990
+ logger_js_1.logger.warn('frontend-stack.ts already exists, skipping creation');
991
+ }
992
+ else {
993
+ (0, fs_1.writeFileSync)(stackPath, getFrontendStackTemplate(options.projectName));
994
+ logger_js_1.logger.success('Created frontend-stack.ts');
995
+ }
996
+ // Update app.ts
997
+ const stackConfig = {
998
+ name: 'Frontend',
999
+ className: 'FrontendStack',
1000
+ importPath: './stacks/frontend-stack',
1001
+ variableName: 'frontendStack',
1002
+ dependsOn: ['apiStack'],
1003
+ props: `{
1004
+ env,
1005
+ stage,
1006
+ apiUrl: apiStack.apiUrl,
1007
+ description: '${options.projectName}: Frontend',
1008
+ }`,
1009
+ description: 'Creates S3 bucket and CloudFront for frontend',
1010
+ };
1011
+ const updated = updateAppTsForStack(appTsPath, stackConfig, options.projectName);
1012
+ if (updated) {
1013
+ logger_js_1.logger.success('Updated app.ts with FrontendStack');
1014
+ }
1015
+ }
1016
+ /**
1017
+ * Generates the CMS stack and updates app.ts.
1018
+ *
1019
+ * @param options - Generation options
1020
+ */
1021
+ async function generateCmsStack(options) {
1022
+ const infraDir = (0, path_1.join)(options.projectPath, 'packages/infra');
1023
+ const stacksDir = (0, path_1.join)(infraDir, 'src/stacks');
1024
+ const appTsPath = (0, path_1.join)(infraDir, 'src/app.ts');
1025
+ // Check if infra directory exists
1026
+ if (!(0, fs_1.existsSync)(infraDir)) {
1027
+ throw new Error('Infrastructure package not found at packages/infra. Run `fnd add api` first.');
1028
+ }
1029
+ // Validate apiStack exists in app.ts
1030
+ const appTsContent = (0, fs_1.readFileSync)(appTsPath, 'utf-8');
1031
+ validateDependencies(appTsContent, ['apiStack']);
1032
+ // Ensure stacks directory exists
1033
+ if (!(0, fs_1.existsSync)(stacksDir)) {
1034
+ (0, fs_1.mkdirSync)(stacksDir, { recursive: true });
1035
+ }
1036
+ // Write cms-stack.ts
1037
+ const stackPath = (0, path_1.join)(stacksDir, 'cms-stack.ts');
1038
+ if ((0, fs_1.existsSync)(stackPath)) {
1039
+ logger_js_1.logger.warn('cms-stack.ts already exists, skipping creation');
1040
+ }
1041
+ else {
1042
+ (0, fs_1.writeFileSync)(stackPath, getCmsStackTemplate(options.projectName));
1043
+ logger_js_1.logger.success('Created cms-stack.ts');
1044
+ }
1045
+ // Update app.ts
1046
+ const stackConfig = {
1047
+ name: 'Cms',
1048
+ className: 'CmsStack',
1049
+ importPath: './stacks/cms-stack',
1050
+ variableName: 'cmsStack',
1051
+ dependsOn: ['apiStack'],
1052
+ props: `{
1053
+ env,
1054
+ stage,
1055
+ apiUrl: apiStack.apiUrl,
1056
+ description: '${options.projectName}: CMS',
1057
+ }`,
1058
+ description: 'Creates S3 bucket and CloudFront for CMS admin',
1059
+ };
1060
+ const updated = updateAppTsForStack(appTsPath, stackConfig, options.projectName);
1061
+ if (updated) {
1062
+ logger_js_1.logger.success('Updated app.ts with CmsStack');
1063
+ }
1064
+ }
193
1065
  //# sourceMappingURL=infra-generator.js.map