create-forgeon 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/src/modules/executor.mjs +4 -0
  3. package/src/modules/executor.test.mjs +214 -0
  4. package/src/modules/i18n.mjs +113 -0
  5. package/src/modules/rate-limit.mjs +346 -0
  6. package/src/modules/rbac.mjs +324 -0
  7. package/src/modules/registry.mjs +39 -3
  8. package/src/run-add-module.mjs +83 -6
  9. package/templates/base/README.md +2 -2
  10. package/templates/base/docs/AI/MODULE_SPEC.md +9 -4
  11. package/templates/module-fragments/rate-limit/00_title.md +1 -0
  12. package/templates/module-fragments/rate-limit/10_overview.md +6 -0
  13. package/templates/module-fragments/rate-limit/20_idea.md +11 -0
  14. package/templates/module-fragments/rate-limit/30_what_it_adds.md +10 -0
  15. package/templates/module-fragments/rate-limit/40_how_it_works.md +13 -0
  16. package/templates/module-fragments/rate-limit/50_how_to_use.md +21 -0
  17. package/templates/module-fragments/rate-limit/60_configuration.md +15 -0
  18. package/templates/module-fragments/rate-limit/70_operational_notes.md +10 -0
  19. package/templates/module-fragments/rate-limit/90_status_implemented.md +3 -0
  20. package/templates/module-fragments/rbac/00_title.md +1 -0
  21. package/templates/module-fragments/rbac/10_overview.md +6 -0
  22. package/templates/module-fragments/rbac/20_idea.md +9 -0
  23. package/templates/module-fragments/rbac/30_what_it_adds.md +11 -0
  24. package/templates/module-fragments/rbac/40_how_it_works.md +20 -0
  25. package/templates/module-fragments/rbac/50_how_to_use.md +19 -0
  26. package/templates/module-fragments/rbac/60_configuration.md +9 -0
  27. package/templates/module-fragments/rbac/70_operational_notes.md +10 -0
  28. package/templates/module-fragments/rbac/90_status_implemented.md +3 -0
  29. package/templates/module-presets/rate-limit/packages/rate-limit/package.json +22 -0
  30. package/templates/module-presets/rate-limit/packages/rate-limit/src/forgeon-rate-limit.module.ts +50 -0
  31. package/templates/module-presets/rate-limit/packages/rate-limit/src/index.ts +5 -0
  32. package/templates/module-presets/rate-limit/packages/rate-limit/src/rate-limit-config.loader.ts +25 -0
  33. package/templates/module-presets/rate-limit/packages/rate-limit/src/rate-limit-config.module.ts +8 -0
  34. package/templates/module-presets/rate-limit/packages/rate-limit/src/rate-limit-config.service.ts +35 -0
  35. package/templates/module-presets/rate-limit/packages/rate-limit/src/rate-limit-env.schema.ts +16 -0
  36. package/templates/module-presets/rate-limit/packages/rate-limit/tsconfig.json +9 -0
  37. package/templates/module-presets/rbac/packages/rbac/package.json +19 -0
  38. package/templates/module-presets/rbac/packages/rbac/src/forgeon-rbac.guard.ts +91 -0
  39. package/templates/module-presets/rbac/packages/rbac/src/forgeon-rbac.module.ts +8 -0
  40. package/templates/module-presets/rbac/packages/rbac/src/index.ts +6 -0
  41. package/templates/module-presets/rbac/packages/rbac/src/rbac.constants.ts +4 -0
  42. package/templates/module-presets/rbac/packages/rbac/src/rbac.decorators.ts +11 -0
  43. package/templates/module-presets/rbac/packages/rbac/src/rbac.helpers.ts +31 -0
  44. package/templates/module-presets/rbac/packages/rbac/src/rbac.types.ts +7 -0
  45. package/templates/module-presets/rbac/packages/rbac/tsconfig.json +9 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-forgeon",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "Forgeon project generator CLI",
5
5
  "license": "MIT",
6
6
  "author": "Forgeon",
@@ -6,6 +6,8 @@ import { applyDbPrismaModule } from './db-prisma.mjs';
6
6
  import { applyI18nModule } from './i18n.mjs';
7
7
  import { applyJwtAuthModule } from './jwt-auth.mjs';
8
8
  import { applyLoggerModule } from './logger.mjs';
9
+ import { applyRateLimitModule } from './rate-limit.mjs';
10
+ import { applyRbacModule } from './rbac.mjs';
9
11
  import { applySwaggerModule } from './swagger.mjs';
10
12
 
11
13
  function ensureForgeonLikeProject(targetRoot) {
@@ -29,6 +31,8 @@ const MODULE_APPLIERS = {
29
31
  i18n: applyI18nModule,
30
32
  'jwt-auth': applyJwtAuthModule,
31
33
  logger: applyLoggerModule,
34
+ 'rate-limit': applyRateLimitModule,
35
+ rbac: applyRbacModule,
32
36
  swagger: applySwaggerModule,
33
37
  };
34
38
 
@@ -42,6 +42,76 @@ function assertDbPrismaWiring(projectRoot) {
42
42
  assert.match(healthController, /PrismaService/);
43
43
  }
44
44
 
45
+ function assertRateLimitWiring(projectRoot) {
46
+ const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
47
+ assert.match(appModule, /rateLimitConfig/);
48
+ assert.match(appModule, /rateLimitEnvSchema/);
49
+ assert.match(appModule, /ForgeonRateLimitModule/);
50
+
51
+ const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
52
+ assert.match(apiPackage, /@forgeon\/rate-limit/);
53
+ assert.match(apiPackage, /pnpm --filter @forgeon\/rate-limit build/);
54
+
55
+ const apiDockerfile = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'Dockerfile'), 'utf8');
56
+ assert.match(apiDockerfile, /COPY packages\/rate-limit\/package\.json packages\/rate-limit\/package\.json/);
57
+ assert.match(apiDockerfile, /COPY packages\/rate-limit packages\/rate-limit/);
58
+ assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/rate-limit build/);
59
+
60
+ const compose = fs.readFileSync(path.join(projectRoot, 'infra', 'docker', 'compose.yml'), 'utf8');
61
+ assert.match(compose, /THROTTLE_ENABLED: \$\{THROTTLE_ENABLED\}/);
62
+ assert.match(compose, /THROTTLE_LIMIT: \$\{THROTTLE_LIMIT\}/);
63
+
64
+ const apiEnv = fs.readFileSync(path.join(projectRoot, 'apps', 'api', '.env.example'), 'utf8');
65
+ assert.match(apiEnv, /THROTTLE_ENABLED=true/);
66
+ assert.match(apiEnv, /THROTTLE_TTL=10/);
67
+ assert.match(apiEnv, /THROTTLE_LIMIT=3/);
68
+
69
+ const healthController = fs.readFileSync(
70
+ path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
71
+ 'utf8',
72
+ );
73
+ assert.match(healthController, /@Get\('rate-limit'\)/);
74
+ assert.match(healthController, /TOO_MANY_REQUESTS/);
75
+
76
+ const appTsx = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'App.tsx'), 'utf8');
77
+ assert.match(appTsx, /Check rate limit \(click repeatedly\)/);
78
+ assert.match(appTsx, /Rate limit probe response/);
79
+
80
+ const readme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
81
+ assert.match(readme, /## Rate Limit Module/);
82
+ }
83
+
84
+ function assertRbacWiring(projectRoot) {
85
+ const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
86
+ assert.match(appModule, /ForgeonRbacModule/);
87
+
88
+ const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
89
+ assert.match(apiPackage, /@forgeon\/rbac/);
90
+ assert.match(apiPackage, /pnpm --filter @forgeon\/rbac build/);
91
+
92
+ const apiDockerfile = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'Dockerfile'), 'utf8');
93
+ assert.match(apiDockerfile, /COPY packages\/rbac\/package\.json packages\/rbac\/package\.json/);
94
+ assert.match(apiDockerfile, /COPY packages\/rbac packages\/rbac/);
95
+ assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/rbac build/);
96
+
97
+ const healthController = fs.readFileSync(
98
+ path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
99
+ 'utf8',
100
+ );
101
+ assert.match(healthController, /UseGuards/);
102
+ assert.match(healthController, /ForgeonRbacGuard/);
103
+ assert.match(healthController, /@Get\('rbac'\)/);
104
+ assert.match(healthController, /@Permissions\('health\.rbac'\)/);
105
+
106
+ const appTsx = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'App.tsx'), 'utf8');
107
+ assert.match(appTsx, /Check RBAC access/);
108
+ assert.match(appTsx, /RBAC probe response/);
109
+ assert.match(appTsx, /x-forgeon-permissions/);
110
+
111
+ const readme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
112
+ assert.match(readme, /## RBAC \/ Permissions Module/);
113
+ }
114
+
45
115
  function assertJwtAuthWiring(projectRoot, withPrismaStore) {
46
116
  const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
47
117
  assert.match(apiPackage, /@forgeon\/auth-api/);
@@ -529,6 +599,76 @@ describe('addModule', () => {
529
599
  }
530
600
  });
531
601
 
602
+ it('applies rate-limit module on top of scaffold without i18n', () => {
603
+ const targetRoot = mkTmp('forgeon-module-rate-limit-');
604
+ const projectRoot = path.join(targetRoot, 'demo-rate-limit');
605
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
606
+
607
+ try {
608
+ scaffoldProject({
609
+ templateRoot,
610
+ packageRoot,
611
+ targetRoot: projectRoot,
612
+ projectName: 'demo-rate-limit',
613
+ frontend: 'react',
614
+ db: 'prisma',
615
+ dbPrismaEnabled: false,
616
+ i18nEnabled: false,
617
+ proxy: 'caddy',
618
+ });
619
+
620
+ const result = addModule({
621
+ moduleId: 'rate-limit',
622
+ targetRoot: projectRoot,
623
+ packageRoot,
624
+ });
625
+
626
+ assert.equal(result.applied, true);
627
+ assertRateLimitWiring(projectRoot);
628
+
629
+ const moduleDoc = fs.readFileSync(result.docsPath, 'utf8');
630
+ assert.match(moduleDoc, /## Idea \/ Why/);
631
+ assert.match(moduleDoc, /## Configuration/);
632
+ } finally {
633
+ fs.rmSync(targetRoot, { recursive: true, force: true });
634
+ }
635
+ });
636
+
637
+ it('applies rbac module on top of scaffold without i18n', () => {
638
+ const targetRoot = mkTmp('forgeon-module-rbac-');
639
+ const projectRoot = path.join(targetRoot, 'demo-rbac');
640
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
641
+
642
+ try {
643
+ scaffoldProject({
644
+ templateRoot,
645
+ packageRoot,
646
+ targetRoot: projectRoot,
647
+ projectName: 'demo-rbac',
648
+ frontend: 'react',
649
+ db: 'prisma',
650
+ dbPrismaEnabled: false,
651
+ i18nEnabled: false,
652
+ proxy: 'caddy',
653
+ });
654
+
655
+ const result = addModule({
656
+ moduleId: 'rbac',
657
+ targetRoot: projectRoot,
658
+ packageRoot,
659
+ });
660
+
661
+ assert.equal(result.applied, true);
662
+ assertRbacWiring(projectRoot);
663
+
664
+ const moduleDoc = fs.readFileSync(result.docsPath, 'utf8');
665
+ assert.match(moduleDoc, /## Idea \/ Why/);
666
+ assert.match(moduleDoc, /## How It Works/);
667
+ } finally {
668
+ fs.rmSync(targetRoot, { recursive: true, force: true });
669
+ }
670
+ });
671
+
532
672
  it('applies swagger module on top of scaffold without i18n', () => {
533
673
  const targetRoot = mkTmp('forgeon-module-swagger-');
534
674
  const projectRoot = path.join(targetRoot, 'demo-swagger');
@@ -1144,6 +1284,80 @@ describe('addModule', () => {
1144
1284
  }
1145
1285
  });
1146
1286
 
1287
+ it('keeps rate-limit wiring valid after mixed module installation order', () => {
1288
+ const targetRoot = mkTmp('forgeon-module-rate-limit-order-');
1289
+ const projectRoot = path.join(targetRoot, 'demo-rate-limit-order');
1290
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
1291
+
1292
+ try {
1293
+ scaffoldProject({
1294
+ templateRoot,
1295
+ packageRoot,
1296
+ targetRoot: projectRoot,
1297
+ projectName: 'demo-rate-limit-order',
1298
+ frontend: 'react',
1299
+ db: 'prisma',
1300
+ dbPrismaEnabled: false,
1301
+ i18nEnabled: false,
1302
+ proxy: 'caddy',
1303
+ });
1304
+
1305
+ for (const moduleId of ['jwt-auth', 'logger', 'swagger', 'rate-limit', 'i18n', 'db-prisma']) {
1306
+ addModule({ moduleId, targetRoot: projectRoot, packageRoot });
1307
+ }
1308
+
1309
+ assertRateLimitWiring(projectRoot);
1310
+
1311
+ const healthController = fs.readFileSync(
1312
+ path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
1313
+ 'utf8',
1314
+ );
1315
+ const classStart = healthController.indexOf('export class HealthController {');
1316
+ const classEnd = healthController.lastIndexOf('\n}');
1317
+ const rateLimitProbe = healthController.indexOf("@Get('rate-limit')");
1318
+ assert.equal(rateLimitProbe > classStart && rateLimitProbe < classEnd, true);
1319
+ } finally {
1320
+ fs.rmSync(targetRoot, { recursive: true, force: true });
1321
+ }
1322
+ });
1323
+
1324
+ it('keeps rbac wiring valid after mixed module installation order', () => {
1325
+ const targetRoot = mkTmp('forgeon-module-rbac-order-');
1326
+ const projectRoot = path.join(targetRoot, 'demo-rbac-order');
1327
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
1328
+
1329
+ try {
1330
+ scaffoldProject({
1331
+ templateRoot,
1332
+ packageRoot,
1333
+ targetRoot: projectRoot,
1334
+ projectName: 'demo-rbac-order',
1335
+ frontend: 'react',
1336
+ db: 'prisma',
1337
+ dbPrismaEnabled: false,
1338
+ i18nEnabled: false,
1339
+ proxy: 'caddy',
1340
+ });
1341
+
1342
+ for (const moduleId of ['jwt-auth', 'logger', 'rate-limit', 'rbac', 'swagger', 'i18n', 'db-prisma']) {
1343
+ addModule({ moduleId, targetRoot: projectRoot, packageRoot });
1344
+ }
1345
+
1346
+ assertRbacWiring(projectRoot);
1347
+
1348
+ const healthController = fs.readFileSync(
1349
+ path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
1350
+ 'utf8',
1351
+ );
1352
+ const classStart = healthController.indexOf('export class HealthController {');
1353
+ const classEnd = healthController.lastIndexOf('\n}');
1354
+ const rbacProbe = healthController.indexOf("@Get('rbac')");
1355
+ assert.equal(rbacProbe > classStart && rbacProbe < classEnd, true);
1356
+ } finally {
1357
+ fs.rmSync(targetRoot, { recursive: true, force: true });
1358
+ }
1359
+ });
1360
+
1147
1361
  it('keeps db-prisma wiring across module installation orders', () => {
1148
1362
  const sequences = [
1149
1363
  ['logger', 'swagger', 'i18n'],
@@ -405,7 +405,119 @@ function patchRootPackage(targetRoot) {
405
405
  writeJson(packagePath, packageJson);
406
406
  }
407
407
 
408
+ function restoreKnownWebProbes(targetRoot, previousAppContent) {
409
+ if (!previousAppContent) {
410
+ return;
411
+ }
412
+
413
+ const filePath = path.join(targetRoot, 'apps', 'web', 'src', 'App.tsx');
414
+ if (!fs.existsSync(filePath)) {
415
+ return;
416
+ }
417
+
418
+ let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
419
+
420
+ const ensureProbeState = (stateLine) => {
421
+ if (content.includes(stateLine)) {
422
+ return;
423
+ }
424
+ const anchors = [
425
+ ' const [rbacProbeResult, setRbacProbeResult] = useState<ProbeResult | null>(null);',
426
+ ' const [rateLimitProbeResult, setRateLimitProbeResult] = useState<ProbeResult | null>(null);',
427
+ ' const [authProbeResult, setAuthProbeResult] = useState<ProbeResult | null>(null);',
428
+ ' const [dbProbeResult, setDbProbeResult] = useState<ProbeResult | null>(null);',
429
+ ' const [validationProbeResult, setValidationProbeResult] = useState<ProbeResult | null>(null);',
430
+ ];
431
+ const anchor = anchors.find((line) => content.includes(line));
432
+ if (anchor) {
433
+ content = ensureLineAfter(content, anchor, stateLine);
434
+ }
435
+ };
436
+
437
+ const ensureProbeButton = (buttonText, buttonCode) => {
438
+ if (content.includes(buttonText)) {
439
+ return;
440
+ }
441
+ const actionsStart = content.indexOf('<div className="actions">');
442
+ if (actionsStart < 0) {
443
+ return;
444
+ }
445
+ const actionsEnd = content.indexOf('\n </div>', actionsStart);
446
+ if (actionsEnd < 0) {
447
+ return;
448
+ }
449
+ content = `${content.slice(0, actionsEnd)}\n${buttonCode}${content.slice(actionsEnd)}`;
450
+ };
451
+
452
+ const ensureProbeResult = (resultLine) => {
453
+ if (content.includes(resultLine)) {
454
+ return;
455
+ }
456
+ const networkLine = ' {networkError ? <p className="error">{networkError}</p> : null}';
457
+ if (content.includes(networkLine)) {
458
+ content = content.replace(networkLine, `${resultLine}\n${networkLine}`);
459
+ return;
460
+ }
461
+ const anchors = [
462
+ " {renderResult('RBAC probe response', rbacProbeResult)}",
463
+ " {renderResult('Rate limit probe response', rateLimitProbeResult)}",
464
+ " {renderResult('Auth probe response', authProbeResult)}",
465
+ " {renderResult('DB probe response', dbProbeResult)}",
466
+ " {renderResult('Validation probe response', validationProbeResult)}",
467
+ ];
468
+ const anchor = anchors.find((line) => content.includes(line));
469
+ if (anchor) {
470
+ content = ensureLineAfter(content, anchor, resultLine);
471
+ }
472
+ };
473
+
474
+ if (previousAppContent.includes('Check database (create user)')) {
475
+ ensureProbeState(' const [dbProbeResult, setDbProbeResult] = useState<ProbeResult | null>(null);');
476
+ ensureProbeButton(
477
+ 'Check database (create user)',
478
+ " <button onClick={() => runProbe(setDbProbeResult, '/health/db', { method: 'POST' })}>\n Check database (create user)\n </button>",
479
+ );
480
+ ensureProbeResult(" {renderResult('DB probe response', dbProbeResult)}");
481
+ }
482
+
483
+ if (previousAppContent.includes('Check JWT auth probe')) {
484
+ ensureProbeState(' const [authProbeResult, setAuthProbeResult] = useState<ProbeResult | null>(null);');
485
+ ensureProbeButton(
486
+ 'Check JWT auth probe',
487
+ " <button onClick={() => runProbe(setAuthProbeResult, '/health/auth')}>Check JWT auth probe</button>",
488
+ );
489
+ ensureProbeResult(" {renderResult('Auth probe response', authProbeResult)}");
490
+ }
491
+
492
+ if (previousAppContent.includes('Check rate limit (click repeatedly)')) {
493
+ ensureProbeState(
494
+ ' const [rateLimitProbeResult, setRateLimitProbeResult] = useState<ProbeResult | null>(null);',
495
+ );
496
+ ensureProbeButton(
497
+ 'Check rate limit (click repeatedly)',
498
+ " <button onClick={() => runProbe(setRateLimitProbeResult, '/health/rate-limit')}>\n Check rate limit (click repeatedly)\n </button>",
499
+ );
500
+ ensureProbeResult(" {renderResult('Rate limit probe response', rateLimitProbeResult)}");
501
+ }
502
+
503
+ if (previousAppContent.includes('Check RBAC access')) {
504
+ ensureProbeState(' const [rbacProbeResult, setRbacProbeResult] = useState<ProbeResult | null>(null);');
505
+ ensureProbeButton(
506
+ 'Check RBAC access',
507
+ " <button\n onClick={() =>\n runProbe(setRbacProbeResult, '/health/rbac', {\n headers: { 'x-forgeon-permissions': 'health.rbac' },\n })\n }\n >\n Check RBAC access\n </button>",
508
+ );
509
+ ensureProbeResult(" {renderResult('RBAC probe response', rbacProbeResult)}");
510
+ }
511
+
512
+ fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
513
+ }
514
+
408
515
  export function applyI18nModule({ packageRoot, targetRoot }) {
516
+ const existingWebAppPath = path.join(targetRoot, 'apps', 'web', 'src', 'App.tsx');
517
+ const previousAppContent = fs.existsSync(existingWebAppPath)
518
+ ? fs.readFileSync(existingWebAppPath, 'utf8')
519
+ : '';
520
+
409
521
  copyFromBase(packageRoot, targetRoot, path.join('scripts', 'i18n-add.mjs'));
410
522
  copyFromBase(packageRoot, targetRoot, path.join('packages', 'i18n'));
411
523
  copyFromBase(packageRoot, targetRoot, path.join('resources', 'i18n'));
@@ -415,6 +527,7 @@ export function applyI18nModule({ packageRoot, targetRoot }) {
415
527
  copyFromPreset(packageRoot, targetRoot, path.join('apps', 'web', 'src', 'App.tsx'));
416
528
  copyFromPreset(packageRoot, targetRoot, path.join('apps', 'web', 'src', 'i18n.ts'));
417
529
  copyFromPreset(packageRoot, targetRoot, path.join('apps', 'web', 'src', 'main.tsx'));
530
+ restoreKnownWebProbes(targetRoot, previousAppContent);
418
531
 
419
532
  patchI18nPackage(targetRoot);
420
533
  patchApiPackage(targetRoot);