@fnd-platform/cli 1.0.0-alpha.2 → 1.0.0-alpha.20

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