@fnd-platform/cli 1.0.0-alpha.8 → 1.0.0-alpha.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.
@@ -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.
@@ -48,9 +58,11 @@ function getPackageJsonTemplate(options) {
48
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',
55
67
  esbuild: '^0.20.0',
56
68
  typescript: '^5.6.3',
@@ -104,80 +116,233 @@ function getCdkJsonTemplate() {
104
116
  * Returns the app.ts template.
105
117
  */
106
118
  function getAppTemplate(options) {
107
- // Convert project name to PascalCase for class naming
108
- const stackClassName = toPascalCase(options.projectName) + 'Api';
109
- return `#!/usr/bin/env npx ts-node
110
- import { App } from 'aws-cdk-lib';
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';
111
130
  import { ApiStack } from './stacks/api-stack';
112
131
 
113
- const app = new App();
132
+ // Create the CDK app
133
+ const app = new cdk.App();
114
134
 
115
- // Get stage from context (default to 'dev')
135
+ // Get deployment stage from context (default: dev)
116
136
  const stage = app.node.tryGetContext('stage') || 'dev';
117
137
 
118
- 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,
119
179
  stage,
120
- env: {
121
- account: process.env.CDK_DEFAULT_ACCOUNT,
122
- region: process.env.CDK_DEFAULT_REGION,
123
- },
180
+ table: databaseStack.table,
181
+ mediaBucket: mediaStack.bucket,
182
+ description: '${options.projectName}: API',
124
183
  });
125
184
 
126
- 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
+ \`);
127
215
  `;
128
216
  }
129
217
  /**
130
218
  * Returns the api-stack.ts template.
131
219
  */
132
220
  function getApiStackTemplate(options) {
133
- const apiName = options.projectName.replace(/-/g, '_');
134
- return `import {
135
- FndApiStack,
136
- 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 {
137
235
  FndLambdaFunction,
138
236
  FndApiGateway,
139
237
  } from '@fnd-platform/constructs';
140
- import { Construct } from 'constructs';
141
- import * as path from 'path';
142
238
 
143
- /**
144
- * API Stack for ${options.projectName}.
145
- *
146
- * This stack creates:
147
- * - Lambda functions for API handlers
148
- * - API Gateway REST API with routes
149
- */
150
- export class ApiStack extends FndApiStack {
151
- 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) {
152
252
  super(scope, id, props);
153
253
 
154
- // Path to the API package (../../../ from packages/infra/src/stacks/)
155
- const apiPackagePath = path.resolve(__dirname, '../../../${options.apiPackageName}');
254
+ const { stage, table, mediaBucket } = props;
156
255
 
157
256
  // Health check handler
158
257
  const healthHandler = new FndLambdaFunction(this, 'HealthHandler', {
159
- entry: path.join(apiPackagePath, 'src/handlers/health.ts'),
160
- stage: this.stage,
258
+ entry: path.join(__dirname, '../../../${options.apiPackageName}/src/handlers/health.ts'),
259
+ stage,
161
260
  description: 'Health check endpoint',
261
+ environment: {
262
+ TABLE_NAME: table.tableName,
263
+ MEDIA_BUCKET: mediaBucket.bucketName,
264
+ },
162
265
  });
163
266
 
164
- // 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
165
287
  const api = new FndApiGateway(this, 'Api', {
166
- name: '${apiName}',
167
- stage: this.stage,
288
+ name: '${lowerName}-api',
289
+ stage,
168
290
  routes: [
291
+ // Health check (public)
169
292
  {
170
293
  method: 'GET',
171
294
  path: '/health',
172
295
  handler: healthHandler.function,
173
296
  requiresAuth: false,
174
297
  },
175
- // 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
+ },
176
335
  ],
177
336
  });
178
337
 
179
- // Stack outputs
180
- 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
+ });
181
346
  }
182
347
  }
183
348
  `;
@@ -191,4 +356,710 @@ function toPascalCase(str) {
191
356
  .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
192
357
  .join('');
193
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
+ }
194
1065
  //# sourceMappingURL=infra-generator.js.map