create-forgeon 0.1.27 → 0.1.30
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 +1 -1
- package/src/modules/executor.mjs +2 -0
- package/src/modules/executor.test.mjs +87 -4
- package/src/modules/logger.mjs +241 -0
- package/src/modules/registry.mjs +8 -0
- package/templates/base/apps/api/src/common/dto/echo-query.dto.ts +4 -4
- package/templates/base/apps/api/src/health/health.controller.ts +5 -5
- package/templates/base/docs/AI/MODULE_SPEC.md +2 -0
- package/templates/base/docs/AI/ROADMAP.md +171 -0
- package/templates/base/docs/AI/TASKS.md +1 -0
- package/templates/base/docs/README.md +1 -0
- package/templates/base/packages/core/src/errors/core-exception.filter.ts +18 -6
- package/templates/base/resources/i18n/en/common.json +39 -11
- package/templates/base/resources/i18n/en/errors.json +31 -3
- package/templates/base/resources/i18n/en/meta.json +8 -0
- package/templates/base/resources/i18n/en/notifications.json +21 -0
- package/templates/base/resources/i18n/en/ui.json +31 -0
- package/templates/base/resources/i18n/en/validation.json +30 -1
- package/templates/base/scripts/i18n-add.mjs +6 -3
- package/templates/docs-fragments/README/40_i18n.md +1 -0
- package/templates/module-fragments/i18n/10_overview.md +1 -0
- package/templates/module-fragments/logger/00_title.md +6 -0
- package/templates/module-fragments/logger/10_overview.md +10 -0
- package/templates/module-fragments/logger/20_scope.md +11 -0
- package/templates/module-fragments/logger/90_status_implemented.md +4 -0
- package/templates/module-presets/i18n/apps/web/src/App.tsx +7 -7
- package/templates/module-presets/i18n/apps/web/src/i18n.ts +6 -0
- package/templates/module-presets/i18n/packages/i18n-contracts/src/generated-keys.d.ts +97 -12
- package/templates/module-presets/i18n/packages/i18n-contracts/src/generated.ts +8 -1
- package/templates/module-presets/logger/packages/logger/package.json +21 -0
- package/templates/module-presets/logger/packages/logger/src/forgeon-logger.module.ts +17 -0
- package/templates/module-presets/logger/packages/logger/src/forgeon-logger.service.ts +51 -0
- package/templates/module-presets/logger/packages/logger/src/http-logging.interceptor.ts +90 -0
- package/templates/module-presets/logger/packages/logger/src/index.ts +9 -0
- package/templates/module-presets/logger/packages/logger/src/logger-config.loader.ts +20 -0
- package/templates/module-presets/logger/packages/logger/src/logger-config.module.ts +11 -0
- package/templates/module-presets/logger/packages/logger/src/logger-config.service.ts +23 -0
- package/templates/module-presets/logger/packages/logger/src/logger-env.schema.ts +19 -0
- package/templates/module-presets/logger/packages/logger/src/request-id.middleware.ts +52 -0
- package/templates/module-presets/logger/packages/logger/tsconfig.json +10 -0
package/package.json
CHANGED
package/src/modules/executor.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { ensureModuleExists } from './registry.mjs';
|
|
4
4
|
import { writeModuleDocs } from './docs.mjs';
|
|
5
5
|
import { applyI18nModule } from './i18n.mjs';
|
|
6
|
+
import { applyLoggerModule } from './logger.mjs';
|
|
6
7
|
|
|
7
8
|
function ensureForgeonLikeProject(targetRoot) {
|
|
8
9
|
const requiredPaths = [
|
|
@@ -22,6 +23,7 @@ function ensureForgeonLikeProject(targetRoot) {
|
|
|
22
23
|
|
|
23
24
|
const MODULE_APPLIERS = {
|
|
24
25
|
i18n: applyI18nModule,
|
|
26
|
+
logger: applyLoggerModule,
|
|
25
27
|
};
|
|
26
28
|
|
|
27
29
|
export function applyModulePreset({ moduleId, targetRoot, packageRoot }) {
|
|
@@ -140,7 +140,7 @@ describe('addModule', () => {
|
|
|
140
140
|
const appTsx = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'App.tsx'), 'utf8');
|
|
141
141
|
assert.match(appTsx, /@forgeon\/i18n-web/);
|
|
142
142
|
assert.match(appTsx, /react-i18next/);
|
|
143
|
-
assert.match(appTsx, /
|
|
143
|
+
assert.match(appTsx, /ui:labels\.language/);
|
|
144
144
|
|
|
145
145
|
const i18nWebPackage = fs.readFileSync(
|
|
146
146
|
path.join(projectRoot, 'packages', 'i18n-web', 'package.json'),
|
|
@@ -184,13 +184,14 @@ describe('addModule', () => {
|
|
|
184
184
|
const enCommon = JSON.parse(
|
|
185
185
|
fs.readFileSync(path.join(projectRoot, 'resources', 'i18n', 'en', 'common.json'), 'utf8'),
|
|
186
186
|
);
|
|
187
|
-
assert.equal(enCommon.
|
|
188
|
-
assert.equal(enCommon.
|
|
187
|
+
assert.equal(enCommon.actions.ok, 'OK');
|
|
188
|
+
assert.equal(enCommon.nav.next, 'Next');
|
|
189
189
|
|
|
190
190
|
const enErrors = JSON.parse(
|
|
191
191
|
fs.readFileSync(path.join(projectRoot, 'resources', 'i18n', 'en', 'errors.json'), 'utf8'),
|
|
192
192
|
);
|
|
193
|
-
assert.equal(enErrors.
|
|
193
|
+
assert.equal(enErrors.http.NOT_FOUND, 'Resource not found');
|
|
194
|
+
assert.equal(enErrors.validation.VALIDATION_ERROR, 'Validation error');
|
|
194
195
|
|
|
195
196
|
const webPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'package.json'), 'utf8');
|
|
196
197
|
assert.match(webPackage, /"i18next":/);
|
|
@@ -202,6 +203,7 @@ describe('addModule', () => {
|
|
|
202
203
|
const i18nTs = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'i18n.ts'), 'utf8');
|
|
203
204
|
assert.match(i18nTs, /initReactI18next/);
|
|
204
205
|
assert.match(i18nTs, /\.\.\/\.\.\/\.\.\/resources\/i18n\/en\/common\.json/);
|
|
206
|
+
assert.match(i18nTs, /\.\.\/\.\.\/\.\.\/resources\/i18n\/en\/ui\.json/);
|
|
205
207
|
assert.doesNotMatch(i18nTs, /I18N_DEFAULT_LANG/);
|
|
206
208
|
|
|
207
209
|
const rootPackage = fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8');
|
|
@@ -241,4 +243,85 @@ describe('addModule', () => {
|
|
|
241
243
|
fs.rmSync(targetRoot, { recursive: true, force: true });
|
|
242
244
|
}
|
|
243
245
|
});
|
|
246
|
+
|
|
247
|
+
it('applies logger module on top of scaffold without i18n', () => {
|
|
248
|
+
const targetRoot = mkTmp('forgeon-module-logger-');
|
|
249
|
+
const projectRoot = path.join(targetRoot, 'demo-logger');
|
|
250
|
+
const templateRoot = path.join(packageRoot, 'templates', 'base');
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
scaffoldProject({
|
|
254
|
+
templateRoot,
|
|
255
|
+
packageRoot,
|
|
256
|
+
targetRoot: projectRoot,
|
|
257
|
+
projectName: 'demo-logger',
|
|
258
|
+
frontend: 'react',
|
|
259
|
+
db: 'prisma',
|
|
260
|
+
i18nEnabled: false,
|
|
261
|
+
proxy: 'caddy',
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const result = addModule({
|
|
265
|
+
moduleId: 'logger',
|
|
266
|
+
targetRoot: projectRoot,
|
|
267
|
+
packageRoot,
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
assert.equal(result.applied, true);
|
|
271
|
+
assert.match(result.message, /applied/);
|
|
272
|
+
assert.equal(
|
|
273
|
+
fs.existsSync(path.join(projectRoot, 'packages', 'logger', 'package.json')),
|
|
274
|
+
true,
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
|
|
278
|
+
assert.match(apiPackage, /@forgeon\/logger/);
|
|
279
|
+
assert.match(apiPackage, /pnpm --filter @forgeon\/logger build/);
|
|
280
|
+
|
|
281
|
+
const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
|
|
282
|
+
assert.match(appModule, /@forgeon\/logger/);
|
|
283
|
+
assert.match(appModule, /loggerConfig/);
|
|
284
|
+
assert.match(appModule, /loggerEnvSchema/);
|
|
285
|
+
assert.match(appModule, /ForgeonLoggerModule/);
|
|
286
|
+
|
|
287
|
+
const mainTs = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'main.ts'), 'utf8');
|
|
288
|
+
assert.match(mainTs, /ForgeonLoggerService/);
|
|
289
|
+
assert.match(mainTs, /ForgeonHttpLoggingInterceptor/);
|
|
290
|
+
assert.match(mainTs, /bufferLogs: true/);
|
|
291
|
+
assert.match(mainTs, /app\.useLogger\(app\.get\(ForgeonLoggerService\)\);/);
|
|
292
|
+
assert.match(mainTs, /app\.useGlobalInterceptors\(app\.get\(ForgeonHttpLoggingInterceptor\)\);/);
|
|
293
|
+
|
|
294
|
+
const apiDockerfile = fs.readFileSync(
|
|
295
|
+
path.join(projectRoot, 'apps', 'api', 'Dockerfile'),
|
|
296
|
+
'utf8',
|
|
297
|
+
);
|
|
298
|
+
assert.match(apiDockerfile, /COPY packages\/logger\/package\.json packages\/logger\/package\.json/);
|
|
299
|
+
assert.match(apiDockerfile, /COPY packages\/logger packages\/logger/);
|
|
300
|
+
assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/logger build/);
|
|
301
|
+
|
|
302
|
+
const apiEnv = fs.readFileSync(path.join(projectRoot, 'apps', 'api', '.env.example'), 'utf8');
|
|
303
|
+
assert.match(apiEnv, /LOGGER_LEVEL=log/);
|
|
304
|
+
assert.match(apiEnv, /LOGGER_HTTP_ENABLED=true/);
|
|
305
|
+
assert.match(apiEnv, /LOGGER_REQUEST_ID_HEADER=x-request-id/);
|
|
306
|
+
|
|
307
|
+
const dockerEnv = fs.readFileSync(
|
|
308
|
+
path.join(projectRoot, 'infra', 'docker', '.env.example'),
|
|
309
|
+
'utf8',
|
|
310
|
+
);
|
|
311
|
+
assert.match(dockerEnv, /LOGGER_LEVEL=log/);
|
|
312
|
+
assert.match(dockerEnv, /LOGGER_HTTP_ENABLED=true/);
|
|
313
|
+
assert.match(dockerEnv, /LOGGER_REQUEST_ID_HEADER=x-request-id/);
|
|
314
|
+
|
|
315
|
+
const compose = fs.readFileSync(path.join(projectRoot, 'infra', 'docker', 'compose.yml'), 'utf8');
|
|
316
|
+
assert.match(compose, /LOGGER_LEVEL: \$\{LOGGER_LEVEL\}/);
|
|
317
|
+
assert.match(compose, /LOGGER_HTTP_ENABLED: \$\{LOGGER_HTTP_ENABLED\}/);
|
|
318
|
+
assert.match(compose, /LOGGER_REQUEST_ID_HEADER: \$\{LOGGER_REQUEST_ID_HEADER\}/);
|
|
319
|
+
|
|
320
|
+
const moduleDoc = fs.readFileSync(result.docsPath, 'utf8');
|
|
321
|
+
assert.match(moduleDoc, /Logger/);
|
|
322
|
+
assert.match(moduleDoc, /Status: implemented/);
|
|
323
|
+
} finally {
|
|
324
|
+
fs.rmSync(targetRoot, { recursive: true, force: true });
|
|
325
|
+
}
|
|
326
|
+
});
|
|
244
327
|
});
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { copyRecursive, writeJson } from '../utils/fs.mjs';
|
|
4
|
+
|
|
5
|
+
function copyFromPreset(packageRoot, targetRoot, relativePath) {
|
|
6
|
+
const source = path.join(packageRoot, 'templates', 'module-presets', 'logger', relativePath);
|
|
7
|
+
if (!fs.existsSync(source)) {
|
|
8
|
+
throw new Error(`Missing logger preset template: ${source}`);
|
|
9
|
+
}
|
|
10
|
+
const destination = path.join(targetRoot, relativePath);
|
|
11
|
+
copyRecursive(source, destination);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function ensureDependency(packageJson, name, version) {
|
|
15
|
+
if (!packageJson.dependencies) {
|
|
16
|
+
packageJson.dependencies = {};
|
|
17
|
+
}
|
|
18
|
+
packageJson.dependencies[name] = version;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function ensureLineAfter(content, anchorLine, lineToInsert) {
|
|
22
|
+
if (content.includes(lineToInsert)) {
|
|
23
|
+
return content;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const index = content.indexOf(anchorLine);
|
|
27
|
+
if (index < 0) {
|
|
28
|
+
return `${content.trimEnd()}\n${lineToInsert}\n`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const insertAt = index + anchorLine.length;
|
|
32
|
+
return `${content.slice(0, insertAt)}\n${lineToInsert}${content.slice(insertAt)}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function ensureLineBefore(content, anchorLine, lineToInsert) {
|
|
36
|
+
if (content.includes(lineToInsert)) {
|
|
37
|
+
return content;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const index = content.indexOf(anchorLine);
|
|
41
|
+
if (index < 0) {
|
|
42
|
+
return `${content.trimEnd()}\n${lineToInsert}\n`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return `${content.slice(0, index)}${lineToInsert}\n${content.slice(index)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function upsertEnvLines(filePath, lines) {
|
|
49
|
+
let content = '';
|
|
50
|
+
if (fs.existsSync(filePath)) {
|
|
51
|
+
content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const keys = new Set(
|
|
55
|
+
content
|
|
56
|
+
.split('\n')
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.map((line) => line.split('=')[0]),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const append = [];
|
|
62
|
+
for (const line of lines) {
|
|
63
|
+
const key = line.split('=')[0];
|
|
64
|
+
if (!keys.has(key)) {
|
|
65
|
+
append.push(line);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const next =
|
|
70
|
+
append.length > 0 ? `${content.trimEnd()}\n${append.join('\n')}\n` : `${content.trimEnd()}\n`;
|
|
71
|
+
fs.writeFileSync(filePath, next.replace(/^\n/, ''), 'utf8');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function patchApiPackage(targetRoot) {
|
|
75
|
+
const packagePath = path.join(targetRoot, 'apps', 'api', 'package.json');
|
|
76
|
+
if (!fs.existsSync(packagePath)) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
|
81
|
+
if (!packageJson.scripts) {
|
|
82
|
+
packageJson.scripts = {};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const loggerBuild = 'pnpm --filter @forgeon/logger build';
|
|
86
|
+
const currentPredev = packageJson.scripts.predev;
|
|
87
|
+
if (typeof currentPredev === 'string') {
|
|
88
|
+
if (!currentPredev.includes(loggerBuild)) {
|
|
89
|
+
if (currentPredev.includes('pnpm --filter @forgeon/core build')) {
|
|
90
|
+
packageJson.scripts.predev = currentPredev.replace(
|
|
91
|
+
'pnpm --filter @forgeon/core build',
|
|
92
|
+
`pnpm --filter @forgeon/core build && ${loggerBuild}`,
|
|
93
|
+
);
|
|
94
|
+
} else {
|
|
95
|
+
packageJson.scripts.predev = `${loggerBuild} && ${currentPredev}`;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
} else {
|
|
99
|
+
packageJson.scripts.predev = loggerBuild;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
ensureDependency(packageJson, '@forgeon/logger', 'workspace:*');
|
|
103
|
+
writeJson(packagePath, packageJson);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function patchMain(targetRoot) {
|
|
107
|
+
const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'main.ts');
|
|
108
|
+
if (!fs.existsSync(filePath)) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
|
|
113
|
+
content = ensureLineBefore(
|
|
114
|
+
content,
|
|
115
|
+
"import { NestFactory } from '@nestjs/core';",
|
|
116
|
+
"import { ForgeonHttpLoggingInterceptor, ForgeonLoggerService } from '@forgeon/logger';",
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
content = content.replace(
|
|
120
|
+
'const app = await NestFactory.create(AppModule);',
|
|
121
|
+
'const app = await NestFactory.create(AppModule, { bufferLogs: true });',
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
if (!content.includes('app.useLogger(app.get(ForgeonLoggerService));')) {
|
|
125
|
+
content = content.replace(
|
|
126
|
+
' const coreConfigService = app.get(CoreConfigService);',
|
|
127
|
+
` const coreConfigService = app.get(CoreConfigService);
|
|
128
|
+
app.useLogger(app.get(ForgeonLoggerService));
|
|
129
|
+
app.useGlobalInterceptors(app.get(ForgeonHttpLoggingInterceptor));`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function patchAppModule(targetRoot) {
|
|
137
|
+
const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'app.module.ts');
|
|
138
|
+
if (!fs.existsSync(filePath)) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
|
|
143
|
+
|
|
144
|
+
content = ensureLineAfter(
|
|
145
|
+
content,
|
|
146
|
+
"import { dbPrismaConfig, dbPrismaEnvSchema, DbPrismaModule } from '@forgeon/db-prisma';",
|
|
147
|
+
"import { ForgeonLoggerModule, loggerConfig, loggerEnvSchema } from '@forgeon/logger';",
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
content = content.replace(
|
|
151
|
+
'load: [coreConfig, dbPrismaConfig, i18nConfig],',
|
|
152
|
+
'load: [coreConfig, dbPrismaConfig, i18nConfig, loggerConfig],',
|
|
153
|
+
);
|
|
154
|
+
content = content.replace(
|
|
155
|
+
'load: [coreConfig, dbPrismaConfig],',
|
|
156
|
+
'load: [coreConfig, dbPrismaConfig, loggerConfig],',
|
|
157
|
+
);
|
|
158
|
+
content = content.replace(
|
|
159
|
+
'validate: createEnvValidator([coreEnvSchema, dbPrismaEnvSchema, i18nEnvSchema]),',
|
|
160
|
+
'validate: createEnvValidator([coreEnvSchema, dbPrismaEnvSchema, i18nEnvSchema, loggerEnvSchema]),',
|
|
161
|
+
);
|
|
162
|
+
content = content.replace(
|
|
163
|
+
'validate: createEnvValidator([coreEnvSchema, dbPrismaEnvSchema]),',
|
|
164
|
+
'validate: createEnvValidator([coreEnvSchema, dbPrismaEnvSchema, loggerEnvSchema]),',
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
content = ensureLineAfter(content, ' CoreErrorsModule,', ' ForgeonLoggerModule,');
|
|
168
|
+
|
|
169
|
+
fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function patchApiDockerfile(targetRoot) {
|
|
173
|
+
const dockerfilePath = path.join(targetRoot, 'apps', 'api', 'Dockerfile');
|
|
174
|
+
if (!fs.existsSync(dockerfilePath)) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let content = fs.readFileSync(dockerfilePath, 'utf8').replace(/\r\n/g, '\n');
|
|
179
|
+
|
|
180
|
+
content = ensureLineAfter(
|
|
181
|
+
content,
|
|
182
|
+
'COPY packages/db-prisma/package.json packages/db-prisma/package.json',
|
|
183
|
+
'COPY packages/logger/package.json packages/logger/package.json',
|
|
184
|
+
);
|
|
185
|
+
content = ensureLineAfter(
|
|
186
|
+
content,
|
|
187
|
+
'COPY packages/db-prisma packages/db-prisma',
|
|
188
|
+
'COPY packages/logger packages/logger',
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
content = content.replace(/^RUN pnpm --filter @forgeon\/logger build\r?\n?/gm, '');
|
|
192
|
+
content = ensureLineBefore(
|
|
193
|
+
content,
|
|
194
|
+
'RUN pnpm --filter @forgeon/api prisma:generate',
|
|
195
|
+
'RUN pnpm --filter @forgeon/logger build',
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
fs.writeFileSync(dockerfilePath, `${content.trimEnd()}\n`, 'utf8');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function patchCompose(targetRoot) {
|
|
202
|
+
const composePath = path.join(targetRoot, 'infra', 'docker', 'compose.yml');
|
|
203
|
+
if (!fs.existsSync(composePath)) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
let content = fs.readFileSync(composePath, 'utf8').replace(/\r\n/g, '\n');
|
|
208
|
+
if (!content.includes('LOGGER_LEVEL: ${LOGGER_LEVEL}')) {
|
|
209
|
+
content = content.replace(
|
|
210
|
+
/^(\s+API_PREFIX:.*)$/m,
|
|
211
|
+
`$1
|
|
212
|
+
LOGGER_LEVEL: \${LOGGER_LEVEL}
|
|
213
|
+
LOGGER_HTTP_ENABLED: \${LOGGER_HTTP_ENABLED}
|
|
214
|
+
LOGGER_REQUEST_ID_HEADER: \${LOGGER_REQUEST_ID_HEADER}`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
fs.writeFileSync(composePath, `${content.trimEnd()}\n`, 'utf8');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function applyLoggerModule({ packageRoot, targetRoot }) {
|
|
222
|
+
copyFromPreset(packageRoot, targetRoot, path.join('packages', 'logger'));
|
|
223
|
+
patchApiPackage(targetRoot);
|
|
224
|
+
patchMain(targetRoot);
|
|
225
|
+
patchAppModule(targetRoot);
|
|
226
|
+
patchApiDockerfile(targetRoot);
|
|
227
|
+
patchCompose(targetRoot);
|
|
228
|
+
|
|
229
|
+
upsertEnvLines(path.join(targetRoot, 'apps', 'api', '.env.example'), [
|
|
230
|
+
'LOGGER_LEVEL=log',
|
|
231
|
+
'LOGGER_HTTP_ENABLED=true',
|
|
232
|
+
'LOGGER_REQUEST_ID_HEADER=x-request-id',
|
|
233
|
+
]);
|
|
234
|
+
|
|
235
|
+
upsertEnvLines(path.join(targetRoot, 'infra', 'docker', '.env.example'), [
|
|
236
|
+
'LOGGER_LEVEL=log',
|
|
237
|
+
'LOGGER_HTTP_ENABLED=true',
|
|
238
|
+
'LOGGER_REQUEST_ID_HEADER=x-request-id',
|
|
239
|
+
]);
|
|
240
|
+
}
|
|
241
|
+
|
package/src/modules/registry.mjs
CHANGED
|
@@ -7,6 +7,14 @@ const MODULE_PRESETS = {
|
|
|
7
7
|
description: 'Backend/frontend i18n wiring with locale contracts and translation resources.',
|
|
8
8
|
docFragments: ['00_title', '10_overview', '20_scope', '90_status_implemented'],
|
|
9
9
|
},
|
|
10
|
+
logger: {
|
|
11
|
+
id: 'logger',
|
|
12
|
+
label: 'Logger',
|
|
13
|
+
category: 'observability',
|
|
14
|
+
implemented: true,
|
|
15
|
+
description: 'Structured API logger with request id middleware and HTTP logging interceptor.',
|
|
16
|
+
docFragments: ['00_title', '10_overview', '20_scope', '90_status_implemented'],
|
|
17
|
+
},
|
|
10
18
|
'jwt-auth': {
|
|
11
19
|
id: 'jwt-auth',
|
|
12
20
|
label: 'JWT Auth',
|
|
@@ -13,19 +13,19 @@ export class HealthController {
|
|
|
13
13
|
getHealth(@Query('lang') lang?: string) {
|
|
14
14
|
return {
|
|
15
15
|
status: 'ok',
|
|
16
|
-
message: this.translate('common.ok', lang),
|
|
17
|
-
i18n:
|
|
16
|
+
message: this.translate('common.actions.ok', lang),
|
|
17
|
+
i18n: 'en',
|
|
18
18
|
};
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
@Get('error')
|
|
22
22
|
getErrorProbe(@Query('lang') lang?: string) {
|
|
23
23
|
throw new ConflictException({
|
|
24
|
-
message: this.translate('errors.
|
|
24
|
+
message: this.translate('errors.http.CONFLICT', lang),
|
|
25
25
|
details: {
|
|
26
26
|
feature: 'core-errors',
|
|
27
27
|
probeId: 'health.error',
|
|
28
|
-
probe:
|
|
28
|
+
probe: 'Error envelope probe',
|
|
29
29
|
},
|
|
30
30
|
});
|
|
31
31
|
}
|
|
@@ -33,7 +33,7 @@ export class HealthController {
|
|
|
33
33
|
@Get('validation')
|
|
34
34
|
getValidationProbe(@Query('value') value?: string, @Query('lang') lang?: string) {
|
|
35
35
|
if (!value || value.trim().length === 0) {
|
|
36
|
-
const translatedMessage = this.translate('validation.required', lang);
|
|
36
|
+
const translatedMessage = this.translate('validation.generic.required', lang);
|
|
37
37
|
throw new BadRequestException({
|
|
38
38
|
message: translatedMessage,
|
|
39
39
|
details: [{ field: 'value', message: translatedMessage }],
|
|
@@ -64,3 +64,5 @@ Must contain:
|
|
|
64
64
|
- Contracts package exports are stable from `dist/index` entrypoint.
|
|
65
65
|
- Module has docs under `docs/AI/MODULES/<module-id>.md`.
|
|
66
66
|
- If module behavior can be runtime-checked, it also includes API+Web probe hooks (see `docs/AI/MODULE_CHECKS.md`).
|
|
67
|
+
- If i18n is enabled, module-specific namespaces must be created and wired for both API and web.
|
|
68
|
+
- If module is added before i18n, namespace templates must still be prepared and applied when i18n is installed later.
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# ROADMAP
|
|
2
|
+
|
|
3
|
+
This is a living plan. Scope and priorities may change.
|
|
4
|
+
|
|
5
|
+
## Current Foundation (Implemented)
|
|
6
|
+
|
|
7
|
+
- [x] Canonical scaffold: NestJS API + React web + Prisma/Postgres + Docker
|
|
8
|
+
- [x] Proxy preset selection: `caddy | nginx | none`
|
|
9
|
+
- [x] `@forgeon/core`:
|
|
10
|
+
- [x] `core-config` (typed env config + validation)
|
|
11
|
+
- [x] `core-errors` (global envelope + exception filter)
|
|
12
|
+
- [x] `core-validation` (global validation pipe)
|
|
13
|
+
- [x] `@forgeon/db-prisma` as default-applied DB module
|
|
14
|
+
- [x] i18n add-module baseline:
|
|
15
|
+
- [x] `@forgeon/i18n`, `@forgeon/i18n-contracts`, `@forgeon/i18n-web`
|
|
16
|
+
- [x] shared dictionaries in `resources/i18n/*`
|
|
17
|
+
- [x] tooling: `i18n:sync`, `i18n:check`, `i18n:types`, `i18n:add`
|
|
18
|
+
- [x] module diagnostics probes pattern (`/api/health/*` + web test buttons)
|
|
19
|
+
|
|
20
|
+
## Standards (Accepted)
|
|
21
|
+
|
|
22
|
+
- [x] `*-contracts` and `*-web` packages are ESM-first
|
|
23
|
+
- [x] API runtime modules use Node-oriented TS config
|
|
24
|
+
- [x] no cross-package imports via `/src/*`; only package entrypoints
|
|
25
|
+
|
|
26
|
+
## Updated Priority Backlog
|
|
27
|
+
|
|
28
|
+
### P0 (Immediate Must-Have)
|
|
29
|
+
|
|
30
|
+
- [ ] `logger`
|
|
31
|
+
- [ ] canonical logger module
|
|
32
|
+
- [ ] requestId / correlationId propagation
|
|
33
|
+
- [ ] structured log conventions
|
|
34
|
+
|
|
35
|
+
- [ ] `openapi / swagger`
|
|
36
|
+
- [ ] env toggle: `SWAGGER_ENABLED`
|
|
37
|
+
- [ ] standard setup
|
|
38
|
+
- [ ] bearer integration hook for jwt-auth
|
|
39
|
+
- [ ] `/docs` route
|
|
40
|
+
|
|
41
|
+
- [ ] `jwt-auth`
|
|
42
|
+
- [ ] module split: contracts/api/web
|
|
43
|
+
- [ ] access + refresh baseline
|
|
44
|
+
- [ ] guards/strategy integration
|
|
45
|
+
|
|
46
|
+
- [ ] `rbac / permissions`
|
|
47
|
+
- [ ] decorators: `@Roles()`, `@Permissions()`
|
|
48
|
+
- [ ] guard + policy helper
|
|
49
|
+
- [ ] contracts: `Role`, `Permission`
|
|
50
|
+
- [ ] integration with jwt-auth claims
|
|
51
|
+
|
|
52
|
+
- [ ] `redis/queue foundation`
|
|
53
|
+
- [ ] base Redis config/service
|
|
54
|
+
- [ ] queue baseline (BullMQ or equivalent)
|
|
55
|
+
- [ ] retry and dead-letter conventions
|
|
56
|
+
|
|
57
|
+
- [ ] `rate-limit`
|
|
58
|
+
- [ ] Nest Throttler add-module
|
|
59
|
+
- [ ] policies: route / user / ip
|
|
60
|
+
- [ ] error code: `TOO_MANY_REQUESTS`
|
|
61
|
+
- [ ] reverse-proxy-aware mode (`trust proxy`)
|
|
62
|
+
|
|
63
|
+
- [ ] `files` (upload + storage)
|
|
64
|
+
- [ ] upload endpoints + DTO + guards
|
|
65
|
+
- [ ] storage presets: local + S3-compatible (MinIO/R2)
|
|
66
|
+
- [ ] MIME/size validation
|
|
67
|
+
- [ ] optional image processing subpackage (`sharp`)
|
|
68
|
+
- [ ] error codes: `UPLOAD_INVALID_TYPE`, `UPLOAD_TOO_LARGE`, `UPLOAD_QUOTA`
|
|
69
|
+
|
|
70
|
+
### P1 (Strongly Recommended)
|
|
71
|
+
|
|
72
|
+
- [ ] `testing baseline`
|
|
73
|
+
- [ ] unit + e2e presets
|
|
74
|
+
- [ ] test helpers for add-modules
|
|
75
|
+
- [ ] smoke test template for generated project
|
|
76
|
+
|
|
77
|
+
- [ ] `CI quality gates`
|
|
78
|
+
- [ ] `typecheck`, `lint`, `test`, docker build smoke
|
|
79
|
+
- [ ] release gate checklist
|
|
80
|
+
|
|
81
|
+
- [ ] `cache` (Redis)
|
|
82
|
+
- [ ] CacheModule preset
|
|
83
|
+
- [ ] key naming conventions
|
|
84
|
+
- [ ] shared wrapper/service
|
|
85
|
+
|
|
86
|
+
- [ ] `scheduler`
|
|
87
|
+
- [ ] `@nestjs/schedule` integration
|
|
88
|
+
- [ ] task template
|
|
89
|
+
- [ ] optional distributed lock (Redis)
|
|
90
|
+
|
|
91
|
+
- [ ] `mail`
|
|
92
|
+
- [ ] at least one provider preset (SMTP/Resend/SendGrid)
|
|
93
|
+
- [ ] templates: verify email, reset password
|
|
94
|
+
- [ ] optional outbox with queue
|
|
95
|
+
|
|
96
|
+
- [ ] workspace `eslint/prettier` config package
|
|
97
|
+
|
|
98
|
+
### P2 (Later)
|
|
99
|
+
|
|
100
|
+
- [ ] frontend `http-client` module
|
|
101
|
+
- [ ] frontend UI kit package
|
|
102
|
+
- [ ] migrate reusable parts from `eso-dt` (when available)
|
|
103
|
+
- [ ] extend missing primitives
|
|
104
|
+
- [ ] `realtime` (ws)
|
|
105
|
+
- [ ] gateway baseline
|
|
106
|
+
- [ ] jwt auth for ws
|
|
107
|
+
- [ ] rooms + basic events
|
|
108
|
+
- [ ] `webhooks` module (subject to scope validation)
|
|
109
|
+
- [ ] signed inbound verify (HMAC)
|
|
110
|
+
- [ ] signed outbound sender
|
|
111
|
+
- [ ] replay protection (timestamp/nonce)
|
|
112
|
+
|
|
113
|
+
## Execution Plan (3 Sprints)
|
|
114
|
+
|
|
115
|
+
### Sprint 1: Platform Baseline and Security Start
|
|
116
|
+
|
|
117
|
+
Scope:
|
|
118
|
+
- `logger`
|
|
119
|
+
- `openapi/swagger`
|
|
120
|
+
- `jwt-auth`
|
|
121
|
+
- `testing baseline`
|
|
122
|
+
- `CI quality gates`
|
|
123
|
+
|
|
124
|
+
Definition of Done:
|
|
125
|
+
- add-modules install cleanly via `create-forgeon add <module>`
|
|
126
|
+
- local dev (`pnpm dev`) and docker build both pass on fresh generated project
|
|
127
|
+
- each module has probe endpoint and web probe UI hook when applicable
|
|
128
|
+
- docs updated in both root and template docs
|
|
129
|
+
|
|
130
|
+
### Sprint 2: Authorization and Traffic Control
|
|
131
|
+
|
|
132
|
+
Scope:
|
|
133
|
+
- `rbac/permissions`
|
|
134
|
+
- `redis/queue foundation`
|
|
135
|
+
- `rate-limit`
|
|
136
|
+
- `files`
|
|
137
|
+
- `cache`
|
|
138
|
+
|
|
139
|
+
Definition of Done:
|
|
140
|
+
- claims/roles/permissions flow validated end-to-end (api + web contracts)
|
|
141
|
+
- rate-limit and files include standardized error codes and envelope mapping
|
|
142
|
+
- Redis-backed modules run in docker profile with documented env keys
|
|
143
|
+
- at least one e2e happy-path per module
|
|
144
|
+
|
|
145
|
+
### Sprint 3: Async Integrations and Frontend Foundation
|
|
146
|
+
|
|
147
|
+
Scope:
|
|
148
|
+
- `scheduler`
|
|
149
|
+
- `mail`
|
|
150
|
+
- workspace `eslint/prettier` config package
|
|
151
|
+
- frontend `http-client`
|
|
152
|
+
|
|
153
|
+
Definition of Done:
|
|
154
|
+
- queue/scheduler/mail basic scenarios work in local + docker
|
|
155
|
+
- frontend http-client consumes api contracts with typed errors
|
|
156
|
+
- lint/typecheck/test/build pass through CI gate preset
|
|
157
|
+
- docs include migration notes and extension points
|
|
158
|
+
|
|
159
|
+
## Explicit Dependencies and Order Constraints
|
|
160
|
+
|
|
161
|
+
- `rbac` depends on `jwt-auth`
|
|
162
|
+
- `rate-limit` should follow Redis/queue foundation for scalable mode
|
|
163
|
+
- `mail` should reuse queue foundation where possible
|
|
164
|
+
- `openapi` is most useful before/with `jwt-auth` and `http-client`
|
|
165
|
+
- `realtime` and `webhooks` stay post-MVP unless a concrete use-case appears
|
|
166
|
+
|
|
167
|
+
## i18n Policy For Add-Modules
|
|
168
|
+
|
|
169
|
+
- [ ] each add-module that introduces user-facing text defines its own namespace templates
|
|
170
|
+
- [ ] if i18n is already enabled, namespace files are added during module installation
|
|
171
|
+
- [ ] if module is installed first and i18n later, namespaces are merged during i18n installation
|
|
@@ -53,6 +53,7 @@ Requirements:
|
|
|
53
53
|
- split module into contracts/api/web packages
|
|
54
54
|
- contracts is source of truth for routes, DTOs, errors
|
|
55
55
|
- if feasible, add module probe hooks in API (`/api/health/*`) and web diagnostics UI
|
|
56
|
+
- if i18n is enabled, add module namespace files and wire them for both API and web
|
|
56
57
|
- add docs note under docs/AI/MODULES/<module-id>.md
|
|
57
58
|
- keep backward compatibility
|
|
58
59
|
```
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
- `AI/PROJECT.md` - project overview and run modes
|
|
4
4
|
- `AI/ARCHITECTURE.md` - monorepo design and extension model
|
|
5
|
+
- `AI/ROADMAP.md` - implementation roadmap and feature priorities
|
|
5
6
|
- `AI/MODULE_SPEC.md` - fullstack module contract (`contracts/api/web`)
|
|
6
7
|
- `AI/MODULE_CHECKS.md` - required runtime probe hooks for modules
|
|
7
8
|
- `AI/VALIDATION.md` - DTO/env validation standards
|
|
@@ -51,17 +51,29 @@ export class CoreExceptionFilter implements ExceptionFilter {
|
|
|
51
51
|
private resolveCode(status: number): string {
|
|
52
52
|
switch (status) {
|
|
53
53
|
case HttpStatus.BAD_REQUEST:
|
|
54
|
-
return '
|
|
54
|
+
return 'BAD_REQUEST';
|
|
55
55
|
case HttpStatus.UNAUTHORIZED:
|
|
56
|
-
return '
|
|
56
|
+
return 'UNAUTHORIZED';
|
|
57
57
|
case HttpStatus.FORBIDDEN:
|
|
58
|
-
return '
|
|
58
|
+
return 'FORBIDDEN';
|
|
59
59
|
case HttpStatus.NOT_FOUND:
|
|
60
|
-
return '
|
|
60
|
+
return 'NOT_FOUND';
|
|
61
61
|
case HttpStatus.CONFLICT:
|
|
62
|
-
return '
|
|
62
|
+
return 'CONFLICT';
|
|
63
|
+
case HttpStatus.TOO_MANY_REQUESTS:
|
|
64
|
+
return 'TOO_MANY_REQUESTS';
|
|
65
|
+
case HttpStatus.METHOD_NOT_ALLOWED:
|
|
66
|
+
return 'METHOD_NOT_ALLOWED';
|
|
67
|
+
case HttpStatus.UNPROCESSABLE_ENTITY:
|
|
68
|
+
return 'UNPROCESSABLE_ENTITY';
|
|
69
|
+
case HttpStatus.SERVICE_UNAVAILABLE:
|
|
70
|
+
return 'SERVICE_UNAVAILABLE';
|
|
71
|
+
case HttpStatus.BAD_GATEWAY:
|
|
72
|
+
return 'BAD_GATEWAY';
|
|
73
|
+
case HttpStatus.GATEWAY_TIMEOUT:
|
|
74
|
+
return 'GATEWAY_TIMEOUT';
|
|
63
75
|
default:
|
|
64
|
-
return '
|
|
76
|
+
return 'INTERNAL_ERROR';
|
|
65
77
|
}
|
|
66
78
|
}
|
|
67
79
|
|