@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.
- package/LICENSE +21 -0
- package/lib/commands/add.d.ts.map +1 -1
- package/lib/commands/add.js +73 -3
- package/lib/commands/add.js.map +1 -1
- package/lib/commands/deploy.d.ts +22 -5
- package/lib/commands/deploy.d.ts.map +1 -1
- package/lib/commands/deploy.js +190 -15
- package/lib/commands/deploy.js.map +1 -1
- package/lib/commands/destroy.d.ts +69 -0
- package/lib/commands/destroy.d.ts.map +1 -0
- package/lib/commands/destroy.js +177 -0
- package/lib/commands/destroy.js.map +1 -0
- package/lib/commands/index.d.ts +1 -0
- package/lib/commands/index.d.ts.map +1 -1
- package/lib/commands/index.js +3 -1
- package/lib/commands/index.js.map +1 -1
- package/lib/commands/new.js +1 -1
- package/lib/commands/new.js.map +1 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +7 -2
- package/lib/index.js.map +1 -1
- package/lib/lib/deploy-utils.d.ts +190 -0
- package/lib/lib/deploy-utils.d.ts.map +1 -0
- package/lib/lib/deploy-utils.js +371 -0
- package/lib/lib/deploy-utils.js.map +1 -0
- package/lib/lib/infra-generator.d.ts +118 -0
- package/lib/lib/infra-generator.d.ts.map +1 -1
- package/lib/lib/infra-generator.js +993 -40
- package/lib/lib/infra-generator.js.map +1 -1
- package/lib/lib/templates.d.ts.map +1 -1
- package/lib/lib/templates.js +1 -0
- package/lib/lib/templates.js.map +1 -1
- package/package.json +14 -14
|
@@ -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': '
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
export class ApiStack extends
|
|
150
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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(
|
|
159
|
-
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
|
-
//
|
|
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: '${
|
|
166
|
-
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
|
-
//
|
|
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
|
-
|
|
179
|
-
|
|
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
|