create-forgeon 0.3.15 → 0.3.17

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 (129) hide show
  1. package/package.json +4 -2
  2. package/src/cli/add-options.test.mjs +5 -2
  3. package/src/cli/options.test.mjs +1 -0
  4. package/src/cli/prompt-select.test.mjs +1 -0
  5. package/src/core/docs.test.mjs +80 -40
  6. package/src/core/scaffold.test.mjs +100 -0
  7. package/src/core/validate.test.mjs +1 -0
  8. package/src/modules/accounts.mjs +416 -0
  9. package/src/modules/db-prisma.mjs +23 -55
  10. package/src/modules/dependencies.test.mjs +71 -29
  11. package/src/modules/executor.mjs +3 -2
  12. package/src/modules/executor.test.mjs +631 -500
  13. package/src/modules/files-access.mjs +36 -105
  14. package/src/modules/files-image.mjs +35 -107
  15. package/src/modules/files-local.mjs +15 -6
  16. package/src/modules/files-quotas.mjs +75 -93
  17. package/src/modules/files-s3.mjs +17 -6
  18. package/src/modules/files.mjs +56 -125
  19. package/src/modules/i18n.mjs +17 -121
  20. package/src/modules/idempotency.test.mjs +180 -0
  21. package/src/modules/logger.mjs +0 -9
  22. package/src/modules/probes.test.mjs +204 -0
  23. package/src/modules/queue.mjs +325 -440
  24. package/src/modules/rate-limit.mjs +36 -76
  25. package/src/modules/rbac.mjs +39 -78
  26. package/src/modules/registry.mjs +22 -35
  27. package/src/modules/scheduler.mjs +51 -171
  28. package/src/modules/shared/files-runtime-wiring.mjs +81 -0
  29. package/src/modules/shared/nest-runtime-wiring.mjs +110 -0
  30. package/src/modules/shared/patch-utils.mjs +29 -1
  31. package/src/modules/shared/probes.mjs +235 -0
  32. package/src/modules/sync-integrations.mjs +109 -396
  33. package/src/modules/sync-integrations.test.mjs +141 -0
  34. package/src/run-add-module.test.mjs +154 -0
  35. package/templates/base/README.md +7 -55
  36. package/templates/base/apps/web/src/App.tsx +70 -42
  37. package/templates/base/apps/web/src/probes.ts +61 -0
  38. package/templates/base/apps/web/src/styles.css +86 -25
  39. package/templates/base/package.json +21 -15
  40. package/templates/base/scripts/forgeon-sync-integrations.mjs +65 -281
  41. package/templates/module-fragments/{jwt-auth → accounts}/00_title.md +2 -1
  42. package/templates/module-fragments/{jwt-auth → accounts}/10_overview.md +5 -5
  43. package/templates/module-fragments/accounts/20_scope.md +29 -0
  44. package/templates/module-fragments/accounts/90_status_implemented.md +8 -0
  45. package/templates/module-fragments/accounts/90_status_planned.md +7 -0
  46. package/templates/module-fragments/rbac/30_what_it_adds.md +3 -2
  47. package/templates/module-fragments/rbac/40_how_it_works.md +2 -1
  48. package/templates/module-fragments/rbac/50_how_to_use.md +2 -1
  49. package/templates/module-fragments/swagger/20_scope.md +2 -1
  50. package/templates/module-presets/accounts/apps/api/prisma/migrations/0002_accounts_core/migration.sql +97 -0
  51. package/templates/module-presets/accounts/apps/api/src/accounts/forgeon-accounts-db-prisma.module.ts +17 -0
  52. package/templates/module-presets/accounts/apps/api/src/accounts/prisma-accounts-persistence.store.ts +332 -0
  53. package/templates/module-presets/{jwt-auth/packages/auth-api → accounts/packages/accounts-api}/package.json +5 -5
  54. package/templates/module-presets/accounts/packages/accounts-api/src/accounts-email.port.ts +13 -0
  55. package/templates/module-presets/accounts/packages/accounts-api/src/accounts-persistence.port.ts +67 -0
  56. package/templates/module-presets/accounts/packages/accounts-api/src/accounts-rbac.port.ts +14 -0
  57. package/templates/module-presets/{jwt-auth/packages/auth-api → accounts/packages/accounts-api}/src/auth-config.loader.ts +7 -7
  58. package/templates/module-presets/{jwt-auth/packages/auth-api → accounts/packages/accounts-api}/src/auth-config.service.ts +7 -7
  59. package/templates/module-presets/accounts/packages/accounts-api/src/auth-core.service.ts +318 -0
  60. package/templates/module-presets/{jwt-auth/packages/auth-api → accounts/packages/accounts-api}/src/auth-env.schema.ts +4 -4
  61. package/templates/module-presets/accounts/packages/accounts-api/src/auth-jwt.service.ts +58 -0
  62. package/templates/module-presets/accounts/packages/accounts-api/src/auth-password.service.ts +21 -0
  63. package/templates/module-presets/accounts/packages/accounts-api/src/auth.controller.ts +93 -0
  64. package/templates/module-presets/accounts/packages/accounts-api/src/auth.service.ts +48 -0
  65. package/templates/module-presets/accounts/packages/accounts-api/src/auth.types.ts +17 -0
  66. package/templates/module-presets/accounts/packages/accounts-api/src/dto/change-password.dto.ts +13 -0
  67. package/templates/module-presets/accounts/packages/accounts-api/src/dto/confirm-password-reset.dto.ts +12 -0
  68. package/templates/module-presets/accounts/packages/accounts-api/src/dto/index.ts +10 -0
  69. package/templates/module-presets/{jwt-auth/packages/auth-api → accounts/packages/accounts-api}/src/dto/login.dto.ts +1 -1
  70. package/templates/module-presets/{jwt-auth/packages/auth-api → accounts/packages/accounts-api}/src/dto/refresh.dto.ts +1 -1
  71. package/templates/module-presets/accounts/packages/accounts-api/src/dto/register.dto.ts +23 -0
  72. package/templates/module-presets/accounts/packages/accounts-api/src/dto/request-password-reset.dto.ts +7 -0
  73. package/templates/module-presets/accounts/packages/accounts-api/src/dto/update-user-profile.dto.ts +16 -0
  74. package/templates/module-presets/accounts/packages/accounts-api/src/dto/update-user-settings.dto.ts +16 -0
  75. package/templates/module-presets/accounts/packages/accounts-api/src/dto/update-user.dto.ts +8 -0
  76. package/templates/module-presets/accounts/packages/accounts-api/src/dto/verify-email.dto.ts +8 -0
  77. package/templates/module-presets/accounts/packages/accounts-api/src/forgeon-accounts.module.ts +82 -0
  78. package/templates/module-presets/accounts/packages/accounts-api/src/index.ts +24 -0
  79. package/templates/module-presets/{jwt-auth/packages/auth-api → accounts/packages/accounts-api}/src/jwt.strategy.ts +3 -3
  80. package/templates/module-presets/accounts/packages/accounts-api/src/owner-access.guard.ts +39 -0
  81. package/templates/module-presets/accounts/packages/accounts-api/src/users-config.ts +13 -0
  82. package/templates/module-presets/accounts/packages/accounts-api/src/users.controller.ts +65 -0
  83. package/templates/module-presets/accounts/packages/accounts-api/src/users.service.ts +87 -0
  84. package/templates/module-presets/accounts/packages/accounts-api/src/users.types.ts +65 -0
  85. package/templates/module-presets/{jwt-auth/packages/auth-contracts → accounts/packages/accounts-contracts}/package.json +1 -1
  86. package/templates/module-presets/accounts/packages/accounts-contracts/src/index.ts +119 -0
  87. package/templates/module-presets/files/apps/api/src/files/forgeon-files-db-prisma.module.ts +17 -0
  88. package/templates/module-presets/files/apps/api/src/files/prisma-files-persistence.store.ts +164 -0
  89. package/templates/module-presets/files/packages/files/package.json +1 -2
  90. package/templates/module-presets/files/packages/files/src/files.ports.ts +107 -0
  91. package/templates/module-presets/files/packages/files/src/files.service.ts +81 -395
  92. package/templates/module-presets/files/packages/files/src/forgeon-files.module.ts +126 -2
  93. package/templates/module-presets/files/packages/files/src/index.ts +2 -1
  94. package/templates/module-presets/files-local/packages/files-local/src/forgeon-files-local-storage.module.ts +18 -0
  95. package/templates/module-presets/files-local/packages/files-local/src/index.ts +2 -0
  96. package/templates/module-presets/files-local/packages/files-local/src/local-files-storage.adapter.ts +53 -0
  97. package/templates/module-presets/files-quotas/packages/files-quotas/src/forgeon-files-quotas.module.ts +12 -4
  98. package/templates/module-presets/files-s3/packages/files-s3/src/forgeon-files-s3-storage.module.ts +18 -0
  99. package/templates/module-presets/files-s3/packages/files-s3/src/index.ts +2 -0
  100. package/templates/module-presets/files-s3/packages/files-s3/src/s3-files-storage.adapter.ts +130 -0
  101. package/templates/module-presets/i18n/apps/web/src/App.tsx +68 -41
  102. package/templates/module-presets/logger/packages/logger/src/index.ts +0 -1
  103. package/src/modules/jwt-auth.mjs +0 -390
  104. package/templates/base/docs/AI/ARCHITECTURE.md +0 -85
  105. package/templates/base/docs/AI/MODULE_CHECKS.md +0 -28
  106. package/templates/base/docs/AI/MODULE_SPEC.md +0 -77
  107. package/templates/base/docs/AI/PROJECT.md +0 -43
  108. package/templates/base/docs/AI/ROADMAP.md +0 -171
  109. package/templates/base/docs/AI/TASKS.md +0 -60
  110. package/templates/base/docs/AI/VALIDATION.md +0 -31
  111. package/templates/base/docs/README.md +0 -18
  112. package/templates/module-fragments/jwt-auth/20_scope.md +0 -19
  113. package/templates/module-fragments/jwt-auth/90_status_implemented.md +0 -8
  114. package/templates/module-fragments/jwt-auth/90_status_planned.md +0 -3
  115. package/templates/module-presets/jwt-auth/apps/api/prisma/migrations/0002_auth_refresh_token_hash/migration.sql +0 -3
  116. package/templates/module-presets/jwt-auth/apps/api/src/auth/prisma-auth-refresh-token.store.ts +0 -36
  117. package/templates/module-presets/jwt-auth/packages/auth-api/src/auth-refresh-token.store.ts +0 -23
  118. package/templates/module-presets/jwt-auth/packages/auth-api/src/auth.controller.ts +0 -71
  119. package/templates/module-presets/jwt-auth/packages/auth-api/src/auth.service.ts +0 -175
  120. package/templates/module-presets/jwt-auth/packages/auth-api/src/auth.types.ts +0 -6
  121. package/templates/module-presets/jwt-auth/packages/auth-api/src/dto/index.ts +0 -2
  122. package/templates/module-presets/jwt-auth/packages/auth-api/src/forgeon-auth.module.ts +0 -47
  123. package/templates/module-presets/jwt-auth/packages/auth-api/src/index.ts +0 -12
  124. package/templates/module-presets/jwt-auth/packages/auth-contracts/src/index.ts +0 -47
  125. package/templates/module-presets/logger/packages/logger/src/http-logging.interceptor.ts +0 -94
  126. /package/templates/module-presets/{jwt-auth/packages/auth-api/src/jwt-auth.guard.ts → accounts/packages/accounts-api/src/access-token.guard.ts} +0 -0
  127. /package/templates/module-presets/{jwt-auth/packages/auth-api → accounts/packages/accounts-api}/src/auth-config.module.ts +0 -0
  128. /package/templates/module-presets/{jwt-auth/packages/auth-api → accounts/packages/accounts-api}/tsconfig.json +0 -0
  129. /package/templates/module-presets/{jwt-auth/packages/auth-contracts → accounts/packages/accounts-contracts}/tsconfig.json +0 -0
@@ -13,6 +13,7 @@ import {
13
13
  ensureValidatorSchema,
14
14
  upsertEnvLines,
15
15
  } from './shared/patch-utils.mjs';
16
+ import { ensureWebProbeDefinition, readManagedWebProbeDefinitions } from './shared/probes.mjs';
16
17
 
17
18
  function copyFromBase(packageRoot, targetRoot, relativePath) {
18
19
  const source = path.join(packageRoot, 'templates', 'base', relativePath);
@@ -39,10 +40,6 @@ function patchApiDockerfile(targetRoot) {
39
40
  }
40
41
 
41
42
  let content = fs.readFileSync(dockerfilePath, 'utf8').replace(/\r\n/g, '\n');
42
- content = content.replace(
43
- /^COPY package\.json pnpm-workspace\.yaml tsconfig\.base\.json \.\/$/m,
44
- 'COPY package.json pnpm-workspace.yaml tsconfig.base.json tsconfig.base.node.json tsconfig.base.esm.json ./',
45
- );
46
43
 
47
44
  content = ensureLineAfter(
48
45
  content,
@@ -95,22 +92,6 @@ function patchProxyDockerfile(filePath) {
95
92
  }
96
93
 
97
94
  let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
98
-
99
- content = ensureLineAfter(
100
- content,
101
- 'COPY package.json pnpm-workspace.yaml ./',
102
- 'COPY tsconfig.base.json ./',
103
- );
104
- content = ensureLineAfter(
105
- content,
106
- 'COPY tsconfig.base.json ./',
107
- 'COPY tsconfig.base.node.json ./',
108
- );
109
- content = ensureLineAfter(
110
- content,
111
- 'COPY tsconfig.base.node.json ./',
112
- 'COPY tsconfig.base.esm.json ./',
113
- );
114
95
  content = ensureLineAfter(
115
96
  content,
116
97
  'COPY apps/web/package.json apps/web/package.json',
@@ -449,118 +430,32 @@ Module env:
449
430
  fs.writeFileSync(readmePath, `${content.trimEnd()}\n`, 'utf8');
450
431
  }
451
432
 
452
- function restoreKnownWebProbes(targetRoot, previousAppContent) {
453
- if (!previousAppContent) {
433
+ function restoreManagedWebProbes(targetRoot, definitions) {
434
+ if (!Array.isArray(definitions) || definitions.length === 0) {
454
435
  return;
455
436
  }
456
437
 
457
- const filePath = path.join(targetRoot, 'apps', 'web', 'src', 'App.tsx');
458
- if (!fs.existsSync(filePath)) {
438
+ const probesFilePath = path.join(targetRoot, 'apps', 'web', 'src', 'probes.ts');
439
+ if (!fs.existsSync(probesFilePath)) {
459
440
  return;
460
441
  }
461
442
 
462
- let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
463
-
464
- const ensureProbeState = (stateLine) => {
465
- if (content.includes(stateLine)) {
466
- return;
467
- }
468
- const anchors = [
469
- ' const [rbacProbeResult, setRbacProbeResult] = useState<ProbeResult | null>(null);',
470
- ' const [rateLimitProbeResult, setRateLimitProbeResult] = useState<ProbeResult | null>(null);',
471
- ' const [authProbeResult, setAuthProbeResult] = useState<ProbeResult | null>(null);',
472
- ' const [dbProbeResult, setDbProbeResult] = useState<ProbeResult | null>(null);',
473
- ' const [validationProbeResult, setValidationProbeResult] = useState<ProbeResult | null>(null);',
474
- ];
475
- const anchor = anchors.find((line) => content.includes(line));
476
- if (anchor) {
477
- content = ensureLineAfter(content, anchor, stateLine);
478
- }
479
- };
480
-
481
- const ensureProbeButton = (buttonText, buttonCode) => {
482
- if (content.includes(buttonText)) {
483
- return;
484
- }
485
- const actionsStart = content.indexOf('<div className="actions">');
486
- if (actionsStart < 0) {
487
- return;
488
- }
489
- const actionsEnd = content.indexOf('\n </div>', actionsStart);
490
- if (actionsEnd < 0) {
491
- return;
492
- }
493
- content = `${content.slice(0, actionsEnd)}\n${buttonCode}${content.slice(actionsEnd)}`;
494
- };
495
-
496
- const ensureProbeResult = (resultLine) => {
497
- if (content.includes(resultLine)) {
498
- return;
499
- }
500
- const networkLine = ' {networkError ? <p className="error">{networkError}</p> : null}';
501
- if (content.includes(networkLine)) {
502
- content = content.replace(networkLine, `${resultLine}\n${networkLine}`);
503
- return;
504
- }
505
- const anchors = [
506
- " {renderResult('RBAC probe response', rbacProbeResult)}",
507
- " {renderResult('Rate limit probe response', rateLimitProbeResult)}",
508
- " {renderResult('Auth probe response', authProbeResult)}",
509
- " {renderResult('DB probe response', dbProbeResult)}",
510
- " {renderResult('Validation probe response', validationProbeResult)}",
511
- ];
512
- const anchor = anchors.find((line) => content.includes(line));
513
- if (anchor) {
514
- content = ensureLineAfter(content, anchor, resultLine);
515
- }
443
+ const probeTargets = {
444
+ allowWeb: true,
445
+ probesFilePath,
516
446
  };
517
447
 
518
- if (previousAppContent.includes('Check database (create user)')) {
519
- ensureProbeState(' const [dbProbeResult, setDbProbeResult] = useState<ProbeResult | null>(null);');
520
- ensureProbeButton(
521
- 'Check database (create user)',
522
- " <button onClick={() => runProbe(setDbProbeResult, '/health/db', { method: 'POST' })}>\n Check database (create user)\n </button>",
523
- );
524
- ensureProbeResult(" {renderResult('DB probe response', dbProbeResult)}");
525
- }
526
-
527
- if (previousAppContent.includes('Check JWT auth probe')) {
528
- ensureProbeState(' const [authProbeResult, setAuthProbeResult] = useState<ProbeResult | null>(null);');
529
- ensureProbeButton(
530
- 'Check JWT auth probe',
531
- " <button onClick={() => runProbe(setAuthProbeResult, '/health/auth')}>Check JWT auth probe</button>",
532
- );
533
- ensureProbeResult(" {renderResult('Auth probe response', authProbeResult)}");
534
- }
535
-
536
- if (previousAppContent.includes('Check rate limit (click repeatedly)')) {
537
- ensureProbeState(
538
- ' const [rateLimitProbeResult, setRateLimitProbeResult] = useState<ProbeResult | null>(null);',
539
- );
540
- ensureProbeButton(
541
- 'Check rate limit (click repeatedly)',
542
- " <button onClick={() => runProbe(setRateLimitProbeResult, '/health/rate-limit')}>\n Check rate limit (click repeatedly)\n </button>",
543
- );
544
- ensureProbeResult(" {renderResult('Rate limit probe response', rateLimitProbeResult)}");
448
+ for (const definition of definitions) {
449
+ ensureWebProbeDefinition({
450
+ targetRoot,
451
+ probeTargets,
452
+ definition,
453
+ });
545
454
  }
546
-
547
- if (previousAppContent.includes('Check RBAC access')) {
548
- ensureProbeState(' const [rbacProbeResult, setRbacProbeResult] = useState<ProbeResult | null>(null);');
549
- ensureProbeButton(
550
- 'Check RBAC access',
551
- " <button\n onClick={() =>\n runProbe(setRbacProbeResult, '/health/rbac', {\n headers: { 'x-forgeon-permissions': 'health.rbac' },\n })\n }\n >\n Check RBAC access\n </button>",
552
- );
553
- ensureProbeResult(" {renderResult('RBAC probe response', rbacProbeResult)}");
554
- }
555
-
556
- fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
557
455
  }
558
456
 
559
457
  export function applyI18nModule({ packageRoot, targetRoot }) {
560
- const existingWebAppPath = path.join(targetRoot, 'apps', 'web', 'src', 'App.tsx');
561
- const previousAppContent = fs.existsSync(existingWebAppPath)
562
- ? fs.readFileSync(existingWebAppPath, 'utf8')
563
- : '';
458
+ const previousProbeDefinitions = readManagedWebProbeDefinitions(targetRoot);
564
459
 
565
460
  copyFromBase(packageRoot, targetRoot, path.join('scripts', 'i18n-add.mjs'));
566
461
  copyFromBase(packageRoot, targetRoot, path.join('packages', 'i18n'));
@@ -568,10 +463,11 @@ export function applyI18nModule({ packageRoot, targetRoot }) {
568
463
 
569
464
  copyFromPreset(packageRoot, targetRoot, path.join('packages', 'i18n-contracts'));
570
465
  copyFromPreset(packageRoot, targetRoot, path.join('packages', 'i18n-web'));
466
+ copyFromBase(packageRoot, targetRoot, path.join('apps', 'web', 'src', 'probes.ts'));
571
467
  copyFromPreset(packageRoot, targetRoot, path.join('apps', 'web', 'src', 'App.tsx'));
572
468
  copyFromPreset(packageRoot, targetRoot, path.join('apps', 'web', 'src', 'i18n.ts'));
573
469
  copyFromPreset(packageRoot, targetRoot, path.join('apps', 'web', 'src', 'main.tsx'));
574
- restoreKnownWebProbes(targetRoot, previousAppContent);
470
+ restoreManagedWebProbes(targetRoot, previousProbeDefinitions);
575
471
 
576
472
  patchI18nPackage(targetRoot);
577
473
  patchApiPackage(targetRoot);
@@ -0,0 +1,180 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { addModule } from './executor.mjs';
8
+ import { scaffoldProject } from '../core/scaffold.mjs';
9
+
10
+ function makeTempDir(prefix) {
11
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
12
+ }
13
+
14
+ function readFile(filePath) {
15
+ return fs.readFileSync(filePath, 'utf8');
16
+ }
17
+
18
+ function scaffoldBaseProject({ packageRoot, targetRoot, projectName, dbPrismaEnabled, proxy = 'caddy' }) {
19
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
20
+ scaffoldProject({
21
+ templateRoot,
22
+ packageRoot,
23
+ targetRoot,
24
+ projectName,
25
+ frontend: 'react',
26
+ db: 'prisma',
27
+ dbPrismaEnabled,
28
+ i18nEnabled: false,
29
+ proxy,
30
+ });
31
+ }
32
+
33
+ function readProjectSnapshot(projectRoot) {
34
+ const snapshot = {};
35
+ const queue = [projectRoot];
36
+ const skipDirs = new Set(['node_modules', '.git', 'dist', 'build']);
37
+
38
+ while (queue.length > 0) {
39
+ const currentDir = queue.shift();
40
+ const entries = fs
41
+ .readdirSync(currentDir, { withFileTypes: true })
42
+ .sort((left, right) => left.name.localeCompare(right.name));
43
+
44
+ for (const entry of entries) {
45
+ const nextPath = path.join(currentDir, entry.name);
46
+ if (entry.isDirectory()) {
47
+ if (!skipDirs.has(entry.name)) {
48
+ queue.push(nextPath);
49
+ }
50
+ continue;
51
+ }
52
+
53
+ if (!entry.isFile()) {
54
+ continue;
55
+ }
56
+
57
+ snapshot[path.relative(projectRoot, nextPath)] = readFile(nextPath);
58
+ }
59
+ }
60
+
61
+ return Object.fromEntries(
62
+ Object.entries(snapshot).sort(([left], [right]) => left.localeCompare(right)),
63
+ );
64
+ }
65
+
66
+ describe('addModule idempotency', () => {
67
+ const modulesDir = path.dirname(fileURLToPath(import.meta.url));
68
+ const packageRoot = path.resolve(modulesDir, '..', '..');
69
+ const scenarios = [
70
+ {
71
+ name: 'logger',
72
+ moduleId: 'logger',
73
+ dbPrismaEnabled: true,
74
+ setup: [],
75
+ verify(projectRoot) {
76
+ assert.equal(fs.existsSync(path.join(projectRoot, 'packages', 'logger', 'package.json')), true);
77
+ assert.match(
78
+ readFile(path.join(projectRoot, 'apps', 'api', 'src', 'main.ts')),
79
+ /app\.useLogger\(app\.get\(ForgeonLoggerService\)\);/,
80
+ );
81
+ },
82
+ },
83
+ {
84
+ name: 'accounts',
85
+ moduleId: 'accounts',
86
+ dbPrismaEnabled: true,
87
+ setup: [],
88
+ verify(projectRoot) {
89
+ assert.equal(fs.existsSync(path.join(projectRoot, 'packages', 'accounts-api', 'package.json')), true);
90
+ assert.equal(
91
+ fs.existsSync(
92
+ path.join(projectRoot, 'apps', 'api', 'src', 'accounts', 'prisma-accounts-persistence.store.ts'),
93
+ ),
94
+ true,
95
+ );
96
+ assert.match(
97
+ readFile(path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts')),
98
+ /@Get\('auth'\)/,
99
+ );
100
+ assert.match(
101
+ readFile(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts')),
102
+ /ForgeonAccountsModule\.register\(/,
103
+ );
104
+ },
105
+ },
106
+ {
107
+ name: 'files-local',
108
+ moduleId: 'files-local',
109
+ dbPrismaEnabled: true,
110
+ setup: [],
111
+ verify(projectRoot) {
112
+ assert.equal(fs.existsSync(path.join(projectRoot, 'packages', 'files-local', 'package.json')), true);
113
+ assert.match(readFile(path.join(projectRoot, 'apps', 'api', '.env.example')), /FILES_LOCAL_ROOT=storage\/uploads/);
114
+ assert.match(readFile(path.join(projectRoot, 'infra', 'docker', 'compose.yml')), /^\s{2}files_data:\s*$/m);
115
+ },
116
+ },
117
+ {
118
+ name: 'files',
119
+ moduleId: 'files',
120
+ dbPrismaEnabled: true,
121
+ setup: ['files-local'],
122
+ verify(projectRoot) {
123
+ assert.equal(fs.existsSync(path.join(projectRoot, 'packages', 'files', 'package.json')), true);
124
+ assert.match(readFile(path.join(projectRoot, 'apps', 'api', '.env.example')), /FILES_STORAGE_DRIVER=local/);
125
+ assert.match(
126
+ readFile(path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts')),
127
+ /@Post\('files'\)/,
128
+ );
129
+ },
130
+ },
131
+ {
132
+ name: 'queue',
133
+ moduleId: 'queue',
134
+ dbPrismaEnabled: false,
135
+ setup: [],
136
+ verify(projectRoot) {
137
+ assert.equal(fs.existsSync(path.join(projectRoot, 'packages', 'queue', 'package.json')), true);
138
+ assert.match(readFile(path.join(projectRoot, 'infra', 'docker', 'compose.yml')), /^\s{2}redis:\s*$/m);
139
+ assert.match(
140
+ readFile(path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts')),
141
+ /@Get\('queue'\)/,
142
+ );
143
+ },
144
+ },
145
+ ];
146
+
147
+ it('reapplying representative implemented modules is a no-op on project files', () => {
148
+ for (const scenario of scenarios) {
149
+ const tempRoot = makeTempDir(`forgeon-idempotent-${scenario.name}-`);
150
+ const projectRoot = path.join(tempRoot, `demo-${scenario.name}`);
151
+
152
+ try {
153
+ scaffoldBaseProject({
154
+ packageRoot,
155
+ targetRoot: projectRoot,
156
+ projectName: `demo-${scenario.name}`,
157
+ dbPrismaEnabled: scenario.dbPrismaEnabled,
158
+ });
159
+
160
+ for (const moduleId of scenario.setup) {
161
+ addModule({ moduleId, targetRoot: projectRoot, packageRoot });
162
+ }
163
+
164
+ const firstResult = addModule({ moduleId: scenario.moduleId, targetRoot: projectRoot, packageRoot });
165
+ assert.equal(firstResult.applied, true);
166
+ scenario.verify(projectRoot);
167
+
168
+ const before = readProjectSnapshot(projectRoot);
169
+ const secondResult = addModule({ moduleId: scenario.moduleId, targetRoot: projectRoot, packageRoot });
170
+ const after = readProjectSnapshot(projectRoot);
171
+
172
+ assert.equal(secondResult.applied, true);
173
+ assert.deepEqual(after, before);
174
+ scenario.verify(projectRoot);
175
+ } finally {
176
+ fs.rmSync(tempRoot, { recursive: true, force: true });
177
+ }
178
+ }
179
+ });
180
+ });
@@ -44,10 +44,6 @@ function patchMain(targetRoot) {
44
44
  }
45
45
 
46
46
  let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
47
- content = content.replace(
48
- "import { ForgeonHttpLoggingInterceptor, ForgeonLoggerService } from '@forgeon/logger';",
49
- "import { ForgeonLoggerService } from '@forgeon/logger';",
50
- );
51
47
  content = ensureLineBefore(
52
48
  content,
53
49
  "import { NestFactory } from '@nestjs/core';",
@@ -59,11 +55,6 @@ function patchMain(targetRoot) {
59
55
  'const app = await NestFactory.create(AppModule, { bufferLogs: true });',
60
56
  );
61
57
 
62
- content = content.replace(
63
- /\n\s*app\.useGlobalInterceptors\(app\.get\(ForgeonHttpLoggingInterceptor\)\);\s*/g,
64
- '\n',
65
- );
66
-
67
58
  if (!content.includes('app.useLogger(app.get(ForgeonLoggerService));')) {
68
59
  content = content.replace(
69
60
  ' const coreConfigService = app.get(CoreConfigService);',
@@ -0,0 +1,204 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { addModule } from './executor.mjs';
8
+ import { scaffoldProject } from '../core/scaffold.mjs';
9
+
10
+ function makeTempDir(prefix) {
11
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
12
+ }
13
+
14
+ function readFile(filePath) {
15
+ return fs.readFileSync(filePath, 'utf8');
16
+ }
17
+
18
+ function writeJson(filePath, value) {
19
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
20
+ }
21
+
22
+ function scaffoldBaseProject({ packageRoot, targetRoot, projectName }) {
23
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
24
+ scaffoldProject({
25
+ templateRoot,
26
+ packageRoot,
27
+ targetRoot,
28
+ projectName,
29
+ frontend: 'react',
30
+ db: 'prisma',
31
+ dbPrismaEnabled: false,
32
+ i18nEnabled: false,
33
+ proxy: 'caddy',
34
+ });
35
+ }
36
+
37
+ function readManagedProbeIds(projectRoot) {
38
+ const probesTs = readFile(path.join(projectRoot, 'apps', 'web', 'src', 'probes.ts'));
39
+ const match = probesTs.match(/forgeon:module-probes:start(?:\n([\s\S]*?))?\n \/\/ forgeon:module-probes:end/);
40
+ const block = match?.[1] ?? '';
41
+ return [...block.matchAll(/"id": "([^"]+)"/g)].map((item) => item[1]);
42
+ }
43
+
44
+ function captureLogs(work) {
45
+ const lines = [];
46
+ const originalLog = console.log;
47
+ console.log = (...args) => {
48
+ lines.push(args.join(' '));
49
+ };
50
+
51
+ try {
52
+ work();
53
+ } finally {
54
+ console.log = originalLog;
55
+ }
56
+
57
+ return lines.join('\n');
58
+ }
59
+
60
+ describe('probe wiring', () => {
61
+ const modulesDir = path.dirname(fileURLToPath(import.meta.url));
62
+ const packageRoot = path.resolve(modulesDir, '..', '..');
63
+
64
+ it('skips API and web probe wiring when probes are disabled in package.json', () => {
65
+ const tempRoot = makeTempDir('forgeon-probes-disabled-');
66
+ const projectRoot = path.join(tempRoot, 'demo-probes-disabled');
67
+
68
+ try {
69
+ scaffoldBaseProject({
70
+ packageRoot,
71
+ targetRoot: projectRoot,
72
+ projectName: 'demo-probes-disabled',
73
+ });
74
+
75
+ const packagePath = path.join(projectRoot, 'package.json');
76
+ const packageJson = JSON.parse(readFile(packagePath));
77
+ packageJson.forgeon.diagnostics.probes.enabled = false;
78
+ writeJson(packagePath, packageJson);
79
+
80
+ const output = captureLogs(() => {
81
+ addModule({ moduleId: 'queue', targetRoot: projectRoot, packageRoot });
82
+ });
83
+
84
+ const healthController = readFile(path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'));
85
+ assert.doesNotMatch(healthController, /@Get\('queue'\)/);
86
+ assert.doesNotMatch(readFile(path.join(projectRoot, 'apps', 'web', 'src', 'probes.ts')), /"id": "queue"/);
87
+ assert.match(output, /forgeon\.diagnostics\.probes\.enabled=false/);
88
+ } finally {
89
+ fs.rmSync(tempRoot, { recursive: true, force: true });
90
+ }
91
+ });
92
+
93
+ it('skips probe wiring entirely when HealthController is missing', () => {
94
+ const tempRoot = makeTempDir('forgeon-probes-no-health-');
95
+ const projectRoot = path.join(tempRoot, 'demo-probes-no-health');
96
+
97
+ try {
98
+ scaffoldBaseProject({
99
+ packageRoot,
100
+ targetRoot: projectRoot,
101
+ projectName: 'demo-probes-no-health',
102
+ });
103
+
104
+ fs.rmSync(path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'));
105
+
106
+ const output = captureLogs(() => {
107
+ addModule({ moduleId: 'queue', targetRoot: projectRoot, packageRoot });
108
+ });
109
+
110
+ assert.equal(
111
+ fs.existsSync(path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts')),
112
+ false,
113
+ );
114
+ assert.doesNotMatch(readFile(path.join(projectRoot, 'apps', 'web', 'src', 'probes.ts')), /"id": "queue"/);
115
+ assert.match(output, /health\.controller\.ts is missing/);
116
+ assert.doesNotMatch(output, /App\.tsx/);
117
+ assert.doesNotMatch(output, /#probes/);
118
+ } finally {
119
+ fs.rmSync(tempRoot, { recursive: true, force: true });
120
+ }
121
+ });
122
+
123
+ it('adds only API probes when App.tsx is missing', () => {
124
+ const tempRoot = makeTempDir('forgeon-probes-no-app-');
125
+ const projectRoot = path.join(tempRoot, 'demo-probes-no-app');
126
+
127
+ try {
128
+ scaffoldBaseProject({
129
+ packageRoot,
130
+ targetRoot: projectRoot,
131
+ projectName: 'demo-probes-no-app',
132
+ });
133
+
134
+ fs.rmSync(path.join(projectRoot, 'apps', 'web', 'src', 'App.tsx'));
135
+
136
+ const output = captureLogs(() => {
137
+ addModule({ moduleId: 'queue', targetRoot: projectRoot, packageRoot });
138
+ });
139
+
140
+ const healthController = readFile(path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'));
141
+ assert.match(healthController, /@Get\('queue'\)/);
142
+ assert.doesNotMatch(readFile(path.join(projectRoot, 'apps', 'web', 'src', 'probes.ts')), /"id": "queue"/);
143
+ assert.match(output, /App\.tsx is missing/);
144
+ } finally {
145
+ fs.rmSync(tempRoot, { recursive: true, force: true });
146
+ }
147
+ });
148
+
149
+ it('adds only API probes when the #probes container is missing', () => {
150
+ const tempRoot = makeTempDir('forgeon-probes-no-container-');
151
+ const projectRoot = path.join(tempRoot, 'demo-probes-no-container');
152
+
153
+ try {
154
+ scaffoldBaseProject({
155
+ packageRoot,
156
+ targetRoot: projectRoot,
157
+ projectName: 'demo-probes-no-container',
158
+ });
159
+
160
+ const appPath = path.join(projectRoot, 'apps', 'web', 'src', 'App.tsx');
161
+ const appTsx = readFile(appPath).replace('id="probes"', 'id="diagnostics"');
162
+ fs.writeFileSync(appPath, appTsx, 'utf8');
163
+
164
+ const output = captureLogs(() => {
165
+ addModule({ moduleId: 'rate-limit', targetRoot: projectRoot, packageRoot });
166
+ });
167
+
168
+ const healthController = readFile(path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'));
169
+ assert.match(healthController, /@Get\('rate-limit'\)/);
170
+ assert.doesNotMatch(readFile(path.join(projectRoot, 'apps', 'web', 'src', 'probes.ts')), /"id": "rate-limit"/);
171
+ assert.match(output, /#probes container/);
172
+ } finally {
173
+ fs.rmSync(tempRoot, { recursive: true, force: true });
174
+ }
175
+ });
176
+
177
+ it('keeps managed probe order stable regardless of install order', () => {
178
+ const tempRoot = makeTempDir('forgeon-probes-order-');
179
+ const projectRoot = path.join(tempRoot, 'demo-probes-order');
180
+
181
+ try {
182
+ scaffoldBaseProject({
183
+ packageRoot,
184
+ targetRoot: projectRoot,
185
+ projectName: 'demo-probes-order',
186
+ });
187
+
188
+ addModule({ moduleId: 'queue', targetRoot: projectRoot, packageRoot });
189
+ addModule({ moduleId: 'scheduler', targetRoot: projectRoot, packageRoot });
190
+ addModule({ moduleId: 'db-prisma', targetRoot: projectRoot, packageRoot });
191
+ addModule({ moduleId: 'accounts', targetRoot: projectRoot, packageRoot });
192
+ addModule({ moduleId: 'rate-limit', targetRoot: projectRoot, packageRoot });
193
+
194
+ assert.deepEqual(readManagedProbeIds(projectRoot), ['db', 'auth', 'rate-limit', 'queue', 'scheduler']);
195
+ } finally {
196
+ fs.rmSync(tempRoot, { recursive: true, force: true });
197
+ }
198
+ });
199
+ });
200
+
201
+
202
+
203
+
204
+