create-forgeon 0.3.14 → 0.3.16

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 (41) hide show
  1. package/package.json +4 -2
  2. package/src/core/docs.test.mjs +79 -40
  3. package/src/core/scaffold.test.mjs +99 -0
  4. package/src/modules/db-prisma.mjs +23 -55
  5. package/src/modules/executor.test.mjs +2575 -2419
  6. package/src/modules/files-access.mjs +27 -98
  7. package/src/modules/files-image.mjs +26 -100
  8. package/src/modules/files-quotas.mjs +67 -87
  9. package/src/modules/files.mjs +35 -104
  10. package/src/modules/i18n.mjs +17 -121
  11. package/src/modules/idempotency.test.mjs +174 -0
  12. package/src/modules/jwt-auth.mjs +90 -209
  13. package/src/modules/logger.mjs +0 -9
  14. package/src/modules/probes.test.mjs +202 -0
  15. package/src/modules/queue.mjs +325 -412
  16. package/src/modules/rate-limit.mjs +22 -66
  17. package/src/modules/rbac.mjs +27 -67
  18. package/src/modules/scheduler.mjs +44 -167
  19. package/src/modules/shared/nest-runtime-wiring.mjs +110 -0
  20. package/src/modules/shared/probes.mjs +235 -0
  21. package/src/modules/sync-integrations.mjs +54 -21
  22. package/src/modules/sync-integrations.test.mjs +220 -0
  23. package/src/run-add-module.test.mjs +153 -0
  24. package/templates/base/README.md +7 -55
  25. package/templates/base/apps/web/src/App.tsx +70 -42
  26. package/templates/base/apps/web/src/probes.ts +61 -0
  27. package/templates/base/apps/web/src/styles.css +86 -25
  28. package/templates/base/package.json +21 -15
  29. package/templates/base/scripts/forgeon-sync-integrations.mjs +55 -11
  30. package/templates/module-presets/files-quotas/packages/files-quotas/src/forgeon-files-quotas.module.ts +12 -4
  31. package/templates/module-presets/i18n/apps/web/src/App.tsx +68 -41
  32. package/templates/module-presets/logger/packages/logger/src/index.ts +0 -1
  33. package/templates/base/docs/AI/ARCHITECTURE.md +0 -85
  34. package/templates/base/docs/AI/MODULE_CHECKS.md +0 -28
  35. package/templates/base/docs/AI/MODULE_SPEC.md +0 -77
  36. package/templates/base/docs/AI/PROJECT.md +0 -43
  37. package/templates/base/docs/AI/ROADMAP.md +0 -171
  38. package/templates/base/docs/AI/TASKS.md +0 -60
  39. package/templates/base/docs/AI/VALIDATION.md +0 -31
  40. package/templates/base/docs/README.md +0 -18
  41. package/templates/module-presets/logger/packages/logger/src/http-logging.interceptor.ts +0 -94
@@ -3,15 +3,33 @@ import path from 'node:path';
3
3
  import { copyRecursive, writeJson } from '../utils/fs.mjs';
4
4
  import {
5
5
  ensureBuildSteps,
6
- ensureClassMember,
7
6
  ensureDependency,
8
- ensureImportLine,
9
7
  ensureLineAfter,
10
8
  ensureLineBefore,
11
- ensureLoadItem,
12
- ensureValidatorSchema,
13
9
  upsertEnvLines,
14
10
  } from './shared/patch-utils.mjs';
11
+ import { patchAppModuleRegistration, patchHealthControllerServiceProbe } from './shared/nest-runtime-wiring.mjs';
12
+ import { ensureWebProbeDefinition, resolveProbeTargets } from './shared/probes.mjs';
13
+
14
+ const JWT_AUTH_PERSISTENCE_MARKERS = {
15
+ start: '<!-- forgeon:jwt-auth:persistence:start -->',
16
+ end: '<!-- forgeon:jwt-auth:persistence:end -->',
17
+ };
18
+
19
+ const JWT_AUTH_RBAC_MARKERS = {
20
+ start: '<!-- forgeon:jwt-auth:rbac:start -->',
21
+ end: '<!-- forgeon:jwt-auth:rbac:end -->',
22
+ };
23
+
24
+ const JWT_AUTH_DEFAULT_PERSISTENCE_BLOCK = [
25
+ '- refresh token persistence: disabled by default (stateless mode; enable it later through a `db-adapter` provider + integration sync)',
26
+ '- to enable persistence later:',
27
+ ' 1. install a DB adapter provider first (current provider: `create-forgeon add db-prisma --project .`);',
28
+ ' 2. run `pnpm forgeon:sync-integrations` to wire auth persistence to the active DB adapter implementation.',
29
+ ].join('\n');
30
+
31
+ const JWT_AUTH_DEFAULT_RBAC_BLOCK =
32
+ '- RBAC integration: not enabled by default (add `rbac` and run `pnpm forgeon:sync-integrations` to include demo `health.rbac` claims).';
15
33
 
16
34
  function copyFromPreset(packageRoot, targetRoot, relativePath) {
17
35
  const source = path.join(packageRoot, 'templates', 'module-presets', 'jwt-auth', relativePath);
@@ -42,186 +60,47 @@ function patchApiPackage(targetRoot) {
42
60
  }
43
61
 
44
62
  function patchAppModule(targetRoot) {
45
- const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'app.module.ts');
46
- if (!fs.existsSync(filePath)) {
47
- return;
48
- }
49
-
50
- let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
51
- if (!content.includes("from '@forgeon/auth-api';")) {
52
- if (content.includes("import { ForgeonI18nModule, i18nConfig, i18nEnvSchema } from '@forgeon/i18n';")) {
53
- content = ensureLineAfter(
54
- content,
55
- "import { ForgeonI18nModule, i18nConfig, i18nEnvSchema } from '@forgeon/i18n';",
56
- "import { authConfig, authEnvSchema, ForgeonAuthModule } from '@forgeon/auth-api';",
57
- );
58
- } else if (
59
- content.includes("import { ForgeonLoggerModule, loggerConfig, loggerEnvSchema } from '@forgeon/logger';")
60
- ) {
61
- content = ensureLineAfter(
62
- content,
63
- "import { ForgeonLoggerModule, loggerConfig, loggerEnvSchema } from '@forgeon/logger';",
64
- "import { authConfig, authEnvSchema, ForgeonAuthModule } from '@forgeon/auth-api';",
65
- );
66
- } else if (
67
- content.includes("import { ForgeonSwaggerModule, swaggerConfig, swaggerEnvSchema } from '@forgeon/swagger';")
68
- ) {
69
- content = ensureLineAfter(
70
- content,
71
- "import { ForgeonSwaggerModule, swaggerConfig, swaggerEnvSchema } from '@forgeon/swagger';",
72
- "import { authConfig, authEnvSchema, ForgeonAuthModule } from '@forgeon/auth-api';",
73
- );
74
- } else if (
75
- content.includes("import { dbPrismaConfig, dbPrismaEnvSchema, DbPrismaModule } from '@forgeon/db-prisma';")
76
- ) {
77
- content = ensureLineAfter(
78
- content,
79
- "import { dbPrismaConfig, dbPrismaEnvSchema, DbPrismaModule } from '@forgeon/db-prisma';",
80
- "import { authConfig, authEnvSchema, ForgeonAuthModule } from '@forgeon/auth-api';",
81
- );
82
- } else {
83
- content = ensureLineAfter(
84
- content,
85
- "import { ConfigModule } from '@nestjs/config';",
86
- "import { authConfig, authEnvSchema, ForgeonAuthModule } from '@forgeon/auth-api';",
87
- );
88
- }
89
- }
90
-
91
- content = ensureLoadItem(content, 'authConfig');
92
- content = ensureValidatorSchema(content, 'authEnvSchema');
93
-
94
- if (!content.includes('ForgeonAuthModule.register(')) {
95
- const moduleBlock = ' ForgeonAuthModule.register(),';
96
-
97
- if (content.includes(' ForgeonI18nModule.register({')) {
98
- content = ensureLineBefore(content, ' ForgeonI18nModule.register({', moduleBlock);
99
- } else if (content.includes(' DbPrismaModule,')) {
100
- content = ensureLineAfter(content, ' DbPrismaModule,', moduleBlock);
101
- } else if (content.includes(' ForgeonLoggerModule,')) {
102
- content = ensureLineAfter(content, ' ForgeonLoggerModule,', moduleBlock);
103
- } else if (content.includes(' ForgeonSwaggerModule,')) {
104
- content = ensureLineAfter(content, ' ForgeonSwaggerModule,', moduleBlock);
105
- } else {
106
- content = ensureLineAfter(content, ' CoreErrorsModule,', moduleBlock);
107
- }
108
- }
109
-
110
- fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
63
+ patchAppModuleRegistration(targetRoot, {
64
+ importLine: "import { authConfig, authEnvSchema, ForgeonAuthModule } from '@forgeon/auth-api';",
65
+ loadItem: 'authConfig',
66
+ envSchema: 'authEnvSchema',
67
+ moduleLine: ' ForgeonAuthModule.register(),',
68
+ beforeAnchors: [
69
+ ' ForgeonI18nModule.register({',
70
+ ],
71
+ afterAnchors: [
72
+ ' DbPrismaModule,',
73
+ ' ForgeonLoggerModule,',
74
+ ' ForgeonSwaggerModule,',
75
+ ],
76
+ fallbackAnchor: ' CoreErrorsModule,',
77
+ });
111
78
  }
112
79
 
113
- function patchHealthController(targetRoot) {
114
- const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts');
115
- if (!fs.existsSync(filePath)) {
116
- return;
117
- }
118
-
119
- let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
120
-
121
- if (!content.includes("from '@forgeon/auth-api';")) {
122
- content = ensureImportLine(content, "import { AuthService } from '@forgeon/auth-api';");
123
- }
124
-
125
- if (!content.includes('private readonly authService: AuthService')) {
126
- const constructorMatch = content.match(/constructor\(([\s\S]*?)\)\s*\{/m);
127
- if (constructorMatch) {
128
- const original = constructorMatch[0];
129
- const inner = constructorMatch[1].trimEnd();
130
- const normalizedInner = inner.replace(/,\s*$/, '');
131
- const separator = normalizedInner.length > 0 ? ',' : '';
132
- const next = `constructor(${normalizedInner}${separator}
133
- private readonly authService: AuthService,
134
- ) {`;
135
- content = content.replace(original, next);
136
- } else {
137
- const classAnchor = 'export class HealthController {';
138
- if (content.includes(classAnchor)) {
139
- content = content.replace(
140
- classAnchor,
141
- `${classAnchor}
142
- constructor(private readonly authService: AuthService) {}
143
- `,
144
- );
145
- }
146
- }
147
- }
148
-
149
- if (!content.includes("@Get('auth')")) {
150
- const method = `
151
- @Get('auth')
152
- getAuthProbe() {
153
- return this.authService.getProbeStatus();
154
- }
155
- `;
156
- const beforeNeedle = content.includes("@Post('db')") ? "@Post('db')" : 'private translate(';
157
- content = ensureClassMember(content, 'HealthController', method, { beforeNeedle });
158
- }
159
-
160
- fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
80
+ function patchHealthController(targetRoot, probeTargets) {
81
+ patchHealthControllerServiceProbe(targetRoot, probeTargets, {
82
+ importLine: "import { AuthService } from '@forgeon/auth-api';",
83
+ constructorMember: 'private readonly authService: AuthService',
84
+ routePath: 'auth',
85
+ methodName: 'getAuthProbe',
86
+ serviceCall: 'this.authService.getProbeStatus()',
87
+ beforeNeedles: ["@Post('db')"],
88
+ beforeNeedle: 'private translate(',
89
+ });
161
90
  }
162
91
 
163
- function patchWebApp(targetRoot) {
164
- const filePath = path.join(targetRoot, 'apps', 'web', 'src', 'App.tsx');
165
- if (!fs.existsSync(filePath)) {
166
- return;
167
- }
168
-
169
- let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
170
- content = content
171
- .replace(/^\s*\{\/\* forgeon:probes:actions:start \*\/\}\r?\n?/gm, '')
172
- .replace(/^\s*\{\/\* forgeon:probes:actions:end \*\/\}\r?\n?/gm, '')
173
- .replace(/^\s*\{\/\* forgeon:probes:results:start \*\/\}\r?\n?/gm, '')
174
- .replace(/^\s*\{\/\* forgeon:probes:results:end \*\/\}\r?\n?/gm, '');
175
-
176
- if (!content.includes('authProbeResult')) {
177
- if (content.includes(' const [dbProbeResult, setDbProbeResult] = useState<ProbeResult | null>(null);')) {
178
- content = content.replace(
179
- ' const [dbProbeResult, setDbProbeResult] = useState<ProbeResult | null>(null);',
180
- ` const [dbProbeResult, setDbProbeResult] = useState<ProbeResult | null>(null);
181
- const [authProbeResult, setAuthProbeResult] = useState<ProbeResult | null>(null);`,
182
- );
183
- } else if (content.includes(' const [validationProbeResult, setValidationProbeResult] = useState<ProbeResult | null>(null);')) {
184
- content = content.replace(
185
- ' const [validationProbeResult, setValidationProbeResult] = useState<ProbeResult | null>(null);',
186
- ` const [validationProbeResult, setValidationProbeResult] = useState<ProbeResult | null>(null);
187
- const [authProbeResult, setAuthProbeResult] = useState<ProbeResult | null>(null);`,
188
- );
189
- }
190
- }
191
-
192
- if (!content.includes('Check JWT auth probe')) {
193
- const path = content.includes("runProbe(setHealthResult, '/health')") ? '/health/auth' : '/api/health/auth';
194
- const authButton = ` <button onClick={() => runProbe(setAuthProbeResult, '${path}')}>Check JWT auth probe</button>`;
195
- const actionsStart = content.indexOf('<div className="actions">');
196
- if (actionsStart >= 0) {
197
- const actionsEnd = content.indexOf('\n </div>', actionsStart);
198
- if (actionsEnd >= 0) {
199
- content = `${content.slice(0, actionsEnd)}\n${authButton}${content.slice(actionsEnd)}`;
200
- }
201
- }
202
- }
203
-
204
- if (!content.includes("renderResult('Auth probe response', authProbeResult)")) {
205
- const authResultLine = " {renderResult('Auth probe response', authProbeResult)}";
206
- const networkLine = ' {networkError ? <p className="error">{networkError}</p> : null}';
207
- if (content.includes(networkLine)) {
208
- content = content.replace(networkLine, `${authResultLine}\n${networkLine}`);
209
- } else if (content.includes("{renderResult('DB probe response', dbProbeResult)}")) {
210
- content = content.replace(
211
- "{renderResult('DB probe response', dbProbeResult)}",
212
- `{renderResult('DB probe response', dbProbeResult)}
213
- {renderResult('Auth probe response', authProbeResult)}`,
214
- );
215
- } else if (content.includes("{renderResult('Validation probe response', validationProbeResult)}")) {
216
- content = content.replace(
217
- "{renderResult('Validation probe response', validationProbeResult)}",
218
- `{renderResult('Validation probe response', validationProbeResult)}
219
- {renderResult('Auth probe response', authProbeResult)}`,
220
- );
221
- }
222
- }
223
-
224
- fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
92
+ function registerWebProbe(targetRoot, probeTargets) {
93
+ ensureWebProbeDefinition({
94
+ targetRoot,
95
+ probeTargets,
96
+ definition: {
97
+ id: 'auth',
98
+ title: 'JWT Auth',
99
+ buttonLabel: 'Check JWT auth probe',
100
+ resultTitle: 'Auth probe response',
101
+ path: '/health/auth',
102
+ },
103
+ });
225
104
  }
226
105
 
227
106
  function patchApiDockerfile(targetRoot) {
@@ -308,33 +187,33 @@ function patchReadme(targetRoot) {
308
187
  return;
309
188
  }
310
189
 
311
- const persistenceSummary =
312
- '- refresh token persistence: disabled by default (stateless mode; enable it later through a `db-adapter` provider + integration sync)';
313
- const dbFollowUp = `- to enable persistence later:
314
- 1. install a DB adapter provider first (current provider: \`create-forgeon add db-prisma --project .\`);
315
- 2. run \`pnpm forgeon:sync-integrations\` to wire auth persistence to the active DB adapter implementation.`;
316
-
317
- const section = `## JWT Auth Module
318
-
319
- The jwt-auth add-module provides:
320
- - \`@forgeon/auth-contracts\` shared auth routes/types/error codes
321
- - \`@forgeon/auth-api\` Nest auth module (\`login\`, \`refresh\`, \`logout\`, \`me\`)
322
- - JWT guard + passport strategy
323
- - auth probe endpoint: \`GET /api/health/auth\`
324
-
325
- Current mode:
326
- ${persistenceSummary}
327
- ${dbFollowUp}
328
-
329
- Default demo credentials:
330
- - \`AUTH_DEMO_EMAIL=demo@forgeon.local\`
331
- - \`AUTH_DEMO_PASSWORD=forgeon-demo-password\`
332
-
333
- Default routes:
334
- - \`POST /api/auth/login\`
335
- - \`POST /api/auth/refresh\`
336
- - \`POST /api/auth/logout\`
337
- - \`GET /api/auth/me\``;
190
+ const section = [
191
+ '## JWT Auth Module',
192
+ '',
193
+ 'The jwt-auth add-module provides:',
194
+ '- `@forgeon/auth-contracts` shared auth routes/types/error codes',
195
+ '- `@forgeon/auth-api` Nest auth module (`login`, `refresh`, `logout`, `me`)',
196
+ '- JWT guard + passport strategy',
197
+ '- auth probe endpoint: `GET /api/health/auth`',
198
+ '',
199
+ 'Current mode:',
200
+ JWT_AUTH_PERSISTENCE_MARKERS.start,
201
+ JWT_AUTH_DEFAULT_PERSISTENCE_BLOCK,
202
+ JWT_AUTH_PERSISTENCE_MARKERS.end,
203
+ JWT_AUTH_RBAC_MARKERS.start,
204
+ JWT_AUTH_DEFAULT_RBAC_BLOCK,
205
+ JWT_AUTH_RBAC_MARKERS.end,
206
+ '',
207
+ 'Default demo credentials:',
208
+ '- `AUTH_DEMO_EMAIL=demo@forgeon.local`',
209
+ '- `AUTH_DEMO_PASSWORD=forgeon-demo-password`',
210
+ '',
211
+ 'Default routes:',
212
+ '- `POST /api/auth/login`',
213
+ '- `POST /api/auth/refresh`',
214
+ '- `POST /api/auth/logout`',
215
+ '- `GET /api/auth/me`',
216
+ ].join('\n');
338
217
 
339
218
  let content = fs.readFileSync(readmePath, 'utf8').replace(/\r\n/g, '\n');
340
219
  const sectionHeading = '## JWT Auth Module';
@@ -360,10 +239,12 @@ export function applyJwtAuthModule({ packageRoot, targetRoot }) {
360
239
  copyFromPreset(packageRoot, targetRoot, path.join('packages', 'auth-contracts'));
361
240
  copyFromPreset(packageRoot, targetRoot, path.join('packages', 'auth-api'));
362
241
 
242
+ const probeTargets = resolveProbeTargets({ targetRoot, moduleId: 'jwt-auth' });
243
+
363
244
  patchApiPackage(targetRoot);
364
245
  patchAppModule(targetRoot);
365
- patchHealthController(targetRoot);
366
- patchWebApp(targetRoot);
246
+ patchHealthController(targetRoot, probeTargets);
247
+ registerWebProbe(targetRoot, probeTargets);
367
248
  patchApiDockerfile(targetRoot);
368
249
  patchCompose(targetRoot);
369
250
  patchReadme(targetRoot);
@@ -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,202 @@
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: 'jwt-auth', 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
+