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.
- package/package.json +4 -2
- package/src/core/docs.test.mjs +79 -40
- package/src/core/scaffold.test.mjs +99 -0
- package/src/modules/db-prisma.mjs +23 -55
- package/src/modules/executor.test.mjs +2575 -2419
- package/src/modules/files-access.mjs +27 -98
- package/src/modules/files-image.mjs +26 -100
- package/src/modules/files-quotas.mjs +67 -87
- package/src/modules/files.mjs +35 -104
- package/src/modules/i18n.mjs +17 -121
- package/src/modules/idempotency.test.mjs +174 -0
- package/src/modules/jwt-auth.mjs +90 -209
- package/src/modules/logger.mjs +0 -9
- package/src/modules/probes.test.mjs +202 -0
- package/src/modules/queue.mjs +325 -412
- package/src/modules/rate-limit.mjs +22 -66
- package/src/modules/rbac.mjs +27 -67
- package/src/modules/scheduler.mjs +44 -167
- package/src/modules/shared/nest-runtime-wiring.mjs +110 -0
- package/src/modules/shared/probes.mjs +235 -0
- package/src/modules/sync-integrations.mjs +54 -21
- package/src/modules/sync-integrations.test.mjs +220 -0
- package/src/run-add-module.test.mjs +153 -0
- package/templates/base/README.md +7 -55
- package/templates/base/apps/web/src/App.tsx +70 -42
- package/templates/base/apps/web/src/probes.ts +61 -0
- package/templates/base/apps/web/src/styles.css +86 -25
- package/templates/base/package.json +21 -15
- package/templates/base/scripts/forgeon-sync-integrations.mjs +55 -11
- package/templates/module-presets/files-quotas/packages/files-quotas/src/forgeon-files-quotas.module.ts +12 -4
- package/templates/module-presets/i18n/apps/web/src/App.tsx +68 -41
- package/templates/module-presets/logger/packages/logger/src/index.ts +0 -1
- package/templates/base/docs/AI/ARCHITECTURE.md +0 -85
- package/templates/base/docs/AI/MODULE_CHECKS.md +0 -28
- package/templates/base/docs/AI/MODULE_SPEC.md +0 -77
- package/templates/base/docs/AI/PROJECT.md +0 -43
- package/templates/base/docs/AI/ROADMAP.md +0 -171
- package/templates/base/docs/AI/TASKS.md +0 -60
- package/templates/base/docs/AI/VALIDATION.md +0 -31
- package/templates/base/docs/README.md +0 -18
- package/templates/module-presets/logger/packages/logger/src/http-logging.interceptor.ts +0 -94
package/src/modules/jwt-auth.mjs
CHANGED
|
@@ -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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
312
|
-
'
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
-
|
|
335
|
-
-
|
|
336
|
-
-
|
|
337
|
-
|
|
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
|
-
|
|
246
|
+
patchHealthController(targetRoot, probeTargets);
|
|
247
|
+
registerWebProbe(targetRoot, probeTargets);
|
|
367
248
|
patchApiDockerfile(targetRoot);
|
|
368
249
|
patchCompose(targetRoot);
|
|
369
250
|
patchReadme(targetRoot);
|
package/src/modules/logger.mjs
CHANGED
|
@@ -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
|
+
|