create-forgeon 0.3.4 → 0.3.6

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 (112) hide show
  1. package/package.json +1 -1
  2. package/src/cli/add-help.mjs +1 -0
  3. package/src/cli/add-options.mjs +6 -0
  4. package/src/cli/add-options.test.mjs +2 -0
  5. package/src/modules/dependencies.mjs +31 -0
  6. package/src/modules/dependencies.test.mjs +207 -5
  7. package/src/modules/executor.mjs +14 -0
  8. package/src/modules/executor.test.mjs +729 -18
  9. package/src/modules/files-access.mjs +437 -0
  10. package/src/modules/files-image.mjs +531 -0
  11. package/src/modules/files-local.mjs +221 -0
  12. package/src/modules/files-quotas.mjs +381 -0
  13. package/src/modules/files-s3.mjs +266 -0
  14. package/src/modules/files.mjs +527 -0
  15. package/src/modules/logger.mjs +12 -3
  16. package/src/modules/queue.mjs +410 -0
  17. package/src/modules/registry.mjs +93 -3
  18. package/src/run-add-module.mjs +89 -2
  19. package/templates/module-fragments/files/00_title.md +1 -0
  20. package/templates/module-fragments/files/10_overview.md +17 -0
  21. package/templates/module-fragments/files/20_scope.md +13 -0
  22. package/templates/module-fragments/files/90_status_implemented.md +3 -0
  23. package/templates/module-fragments/files-access/00_title.md +1 -0
  24. package/templates/module-fragments/files-access/10_overview.md +9 -0
  25. package/templates/module-fragments/files-access/20_scope.md +20 -0
  26. package/templates/module-fragments/files-access/90_status_implemented.md +3 -0
  27. package/templates/module-fragments/files-image/00_title.md +1 -0
  28. package/templates/module-fragments/files-image/10_overview.md +10 -0
  29. package/templates/module-fragments/files-image/20_scope.md +20 -0
  30. package/templates/module-fragments/files-image/90_status_implemented.md +3 -0
  31. package/templates/module-fragments/files-local/00_title.md +1 -0
  32. package/templates/module-fragments/files-local/10_overview.md +9 -0
  33. package/templates/module-fragments/files-local/20_scope.md +10 -0
  34. package/templates/module-fragments/files-local/90_status_implemented.md +3 -0
  35. package/templates/module-fragments/files-quotas/00_title.md +1 -0
  36. package/templates/module-fragments/files-quotas/10_overview.md +9 -0
  37. package/templates/module-fragments/files-quotas/20_scope.md +20 -0
  38. package/templates/module-fragments/files-quotas/90_status_implemented.md +3 -0
  39. package/templates/module-fragments/files-s3/00_title.md +1 -0
  40. package/templates/module-fragments/files-s3/10_overview.md +17 -0
  41. package/templates/module-fragments/files-s3/20_scope.md +11 -0
  42. package/templates/module-fragments/files-s3/90_status_implemented.md +5 -0
  43. package/templates/module-fragments/queue/20_scope.md +8 -7
  44. package/templates/module-fragments/queue/90_status_implemented.md +3 -0
  45. package/templates/module-presets/files/apps/api/prisma/migrations/20260306_files_file_record/migration.sql +30 -0
  46. package/templates/module-presets/files/apps/api/prisma/migrations/20260306_files_file_variant/migration.sql +55 -0
  47. package/templates/module-presets/files/packages/files/package.json +24 -0
  48. package/templates/module-presets/files/packages/files/src/dto/create-file.dto.ts +30 -0
  49. package/templates/module-presets/files/packages/files/src/files-config.loader.ts +21 -0
  50. package/templates/module-presets/files/packages/files/src/files-config.module.ts +12 -0
  51. package/templates/module-presets/files/packages/files/src/files-config.service.ts +32 -0
  52. package/templates/module-presets/files/packages/files/src/files-env.schema.ts +30 -0
  53. package/templates/module-presets/files/packages/files/src/files.controller.ts +89 -0
  54. package/templates/module-presets/files/packages/files/src/files.service.ts +744 -0
  55. package/templates/module-presets/files/packages/files/src/files.types.ts +35 -0
  56. package/templates/module-presets/files/packages/files/src/forgeon-files.module.ts +12 -0
  57. package/templates/module-presets/files/packages/files/src/index.ts +9 -0
  58. package/templates/module-presets/files/packages/files/tsconfig.json +9 -0
  59. package/templates/module-presets/files-access/packages/files-access/package.json +17 -0
  60. package/templates/module-presets/files-access/packages/files-access/src/files-access.service.ts +59 -0
  61. package/templates/module-presets/files-access/packages/files-access/src/files-access.subject.ts +45 -0
  62. package/templates/module-presets/files-access/packages/files-access/src/files-access.types.ts +14 -0
  63. package/templates/module-presets/files-access/packages/files-access/src/forgeon-files-access.module.ts +8 -0
  64. package/templates/module-presets/files-access/packages/files-access/src/index.ts +4 -0
  65. package/templates/module-presets/files-access/packages/files-access/tsconfig.json +9 -0
  66. package/templates/module-presets/files-image/packages/files-image/package.json +21 -0
  67. package/templates/module-presets/files-image/packages/files-image/src/files-image-config.loader.ts +32 -0
  68. package/templates/module-presets/files-image/packages/files-image/src/files-image-config.module.ts +11 -0
  69. package/templates/module-presets/files-image/packages/files-image/src/files-image-config.service.ts +55 -0
  70. package/templates/module-presets/files-image/packages/files-image/src/files-image-env.schema.ts +28 -0
  71. package/templates/module-presets/files-image/packages/files-image/src/files-image.service.ts +420 -0
  72. package/templates/module-presets/files-image/packages/files-image/src/files-image.types.ts +18 -0
  73. package/templates/module-presets/files-image/packages/files-image/src/forgeon-files-image.module.ts +10 -0
  74. package/templates/module-presets/files-image/packages/files-image/src/index.ts +7 -0
  75. package/templates/module-presets/files-image/packages/files-image/tsconfig.json +9 -0
  76. package/templates/module-presets/files-local/packages/files-local/package.json +19 -0
  77. package/templates/module-presets/files-local/packages/files-local/src/files-local-config.loader.ts +13 -0
  78. package/templates/module-presets/files-local/packages/files-local/src/files-local-config.module.ts +12 -0
  79. package/templates/module-presets/files-local/packages/files-local/src/files-local-config.service.ts +11 -0
  80. package/templates/module-presets/files-local/packages/files-local/src/files-local-env.schema.ts +13 -0
  81. package/templates/module-presets/files-local/packages/files-local/src/index.ts +4 -0
  82. package/templates/module-presets/files-local/packages/files-local/tsconfig.json +9 -0
  83. package/templates/module-presets/files-quotas/packages/files-quotas/package.json +20 -0
  84. package/templates/module-presets/files-quotas/packages/files-quotas/src/files-quotas-config.loader.ts +22 -0
  85. package/templates/module-presets/files-quotas/packages/files-quotas/src/files-quotas-config.module.ts +11 -0
  86. package/templates/module-presets/files-quotas/packages/files-quotas/src/files-quotas-config.service.ts +27 -0
  87. package/templates/module-presets/files-quotas/packages/files-quotas/src/files-quotas-env.schema.ts +15 -0
  88. package/templates/module-presets/files-quotas/packages/files-quotas/src/files-quotas.service.ts +118 -0
  89. package/templates/module-presets/files-quotas/packages/files-quotas/src/files-quotas.types.ts +22 -0
  90. package/templates/module-presets/files-quotas/packages/files-quotas/src/forgeon-files-quotas.module.ts +11 -0
  91. package/templates/module-presets/files-quotas/packages/files-quotas/src/index.ts +7 -0
  92. package/templates/module-presets/files-quotas/packages/files-quotas/tsconfig.json +9 -0
  93. package/templates/module-presets/files-s3/packages/files-s3/package.json +20 -0
  94. package/templates/module-presets/files-s3/packages/files-s3/src/files-s3-config.loader.ts +57 -0
  95. package/templates/module-presets/files-s3/packages/files-s3/src/files-s3-config.module.ts +12 -0
  96. package/templates/module-presets/files-s3/packages/files-s3/src/files-s3-config.service.ts +44 -0
  97. package/templates/module-presets/files-s3/packages/files-s3/src/files-s3-env.schema.ts +51 -0
  98. package/templates/module-presets/files-s3/packages/files-s3/src/index.ts +4 -0
  99. package/templates/module-presets/files-s3/packages/files-s3/tsconfig.json +9 -0
  100. package/templates/module-presets/logger/packages/logger/src/forgeon-logger.module.ts +4 -5
  101. package/templates/module-presets/logger/packages/logger/src/http-logging.middleware.ts +74 -0
  102. package/templates/module-presets/logger/packages/logger/src/index.ts +1 -1
  103. package/templates/module-presets/queue/packages/queue/package.json +21 -0
  104. package/templates/module-presets/queue/packages/queue/src/forgeon-queue.module.ts +10 -0
  105. package/templates/module-presets/queue/packages/queue/src/index.ts +6 -0
  106. package/templates/module-presets/queue/packages/queue/src/queue-config.loader.ts +24 -0
  107. package/templates/module-presets/queue/packages/queue/src/queue-config.module.ts +10 -0
  108. package/templates/module-presets/queue/packages/queue/src/queue-config.service.ts +69 -0
  109. package/templates/module-presets/queue/packages/queue/src/queue-env.schema.ts +17 -0
  110. package/templates/module-presets/queue/packages/queue/src/queue.service.ts +88 -0
  111. package/templates/module-presets/queue/packages/queue/tsconfig.json +9 -0
  112. package/templates/module-fragments/queue/90_status_planned.md +0 -3
@@ -0,0 +1,527 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { copyRecursive, writeJson } from '../utils/fs.mjs';
4
+ import {
5
+ ensureBuildSteps,
6
+ ensureClassMember,
7
+ ensureDependency,
8
+ ensureImportLine,
9
+ ensureLineAfter,
10
+ ensureLineBefore,
11
+ ensureLoadItem,
12
+ ensureNestCommonImport,
13
+ ensureValidatorSchema,
14
+ upsertEnvLines,
15
+ } from './shared/patch-utils.mjs';
16
+
17
+ function copyFromPreset(packageRoot, targetRoot, relativePath) {
18
+ const source = path.join(packageRoot, 'templates', 'module-presets', 'files', relativePath);
19
+ if (!fs.existsSync(source)) {
20
+ throw new Error(`Missing files preset template: ${source}`);
21
+ }
22
+ const destination = path.join(targetRoot, relativePath);
23
+ copyRecursive(source, destination);
24
+ }
25
+
26
+ function patchApiPackage(targetRoot) {
27
+ const packagePath = path.join(targetRoot, 'apps', 'api', 'package.json');
28
+ if (!fs.existsSync(packagePath)) {
29
+ return;
30
+ }
31
+
32
+ const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
33
+ ensureDependency(packageJson, '@forgeon/files', 'workspace:*');
34
+ ensureBuildSteps(packageJson, 'predev', ['pnpm --filter @forgeon/files build']);
35
+ writeJson(packagePath, packageJson);
36
+ }
37
+
38
+ function patchPrismaSchema(targetRoot) {
39
+ const schemaPath = path.join(targetRoot, 'apps', 'api', 'prisma', 'schema.prisma');
40
+ if (!fs.existsSync(schemaPath)) {
41
+ return;
42
+ }
43
+
44
+ let content = fs.readFileSync(schemaPath, 'utf8').replace(/\r\n/g, '\n');
45
+ if (!content.includes('model FileRecord {')) {
46
+ const model = `
47
+ model FileRecord {
48
+ id String @id @default(cuid())
49
+ publicId String @unique
50
+ storageKey String
51
+ originalName String
52
+ mimeType String
53
+ size Int
54
+ storageDriver String
55
+ ownerType String @default("system")
56
+ ownerId String?
57
+ visibility String @default("private")
58
+ createdById String?
59
+ createdAt DateTime @default(now())
60
+ updatedAt DateTime @updatedAt
61
+ variants FileVariant[]
62
+
63
+ @@index([ownerType, ownerId, createdAt])
64
+ @@index([createdById, createdAt])
65
+ @@index([visibility, createdAt])
66
+ }
67
+ `;
68
+ content = `${content.trimEnd()}\n\n${model.trim()}\n`;
69
+ }
70
+
71
+ content = content.replace(/storageKey\s+String\s+@unique/g, 'storageKey String');
72
+
73
+ if (!content.includes('variants FileVariant[]')) {
74
+ content = content.replace(
75
+ /(model FileRecord \{[\s\S]*?updatedAt\s+DateTime @updatedAt)([\s\S]*?\n\})/m,
76
+ '$1\n variants FileVariant[]$2',
77
+ );
78
+ }
79
+
80
+ if (!content.includes('model FileVariant {')) {
81
+ const model = `
82
+ model FileVariant {
83
+ id String @id @default(cuid())
84
+ fileId String
85
+ variantKey String
86
+ blobId String
87
+ mimeType String
88
+ size Int
89
+ status String @default("ready")
90
+ createdAt DateTime @default(now())
91
+ updatedAt DateTime @updatedAt
92
+
93
+ file FileRecord @relation(fields: [fileId], references: [id], onDelete: Cascade)
94
+ blob FileBlob @relation(fields: [blobId], references: [id], onDelete: Restrict)
95
+
96
+ @@unique([fileId, variantKey])
97
+ @@index([blobId])
98
+ @@index([variantKey, status])
99
+ }
100
+ `;
101
+ content = `${content.trimEnd()}\n\n${model.trim()}\n`;
102
+ }
103
+
104
+ content = content.replace(/(\n\s*variantKey\s+String\s*\n)\s*storageDriver\s+String\s*\n\s*storageKey\s+String\s*\n/m, '$1 blobId String\n');
105
+ if (content.includes('model FileVariant {') && !content.includes('blob FileBlob')) {
106
+ content = content.replace(
107
+ /(\n\s*file\s+FileRecord\s+@relation\(fields:\s*\[fileId\],\s*references:\s*\[id\],\s*onDelete:\s*Cascade\)\n)/m,
108
+ '$1 blob FileBlob @relation(fields: [blobId], references: [id], onDelete: Restrict)\n',
109
+ );
110
+ }
111
+ if (content.includes('model FileVariant {') && !content.includes('@@index([blobId])')) {
112
+ content = content.replace(/(\n\s*@@unique\(\[fileId,\s*variantKey\]\)\n)/m, '$1 @@index([blobId])\n');
113
+ }
114
+
115
+ if (!content.includes('model FileBlob {')) {
116
+ const model = `
117
+ model FileBlob {
118
+ id String @id @default(cuid())
119
+ hash String
120
+ size Int
121
+ mimeType String
122
+ storageDriver String
123
+ storageKey String
124
+ createdAt DateTime @default(now())
125
+ updatedAt DateTime @updatedAt
126
+ variants FileVariant[]
127
+
128
+ @@unique([hash, size, mimeType, storageDriver])
129
+ @@index([storageDriver, createdAt])
130
+ }
131
+ `;
132
+ content = `${content.trimEnd()}\n\n${model.trim()}\n`;
133
+ }
134
+
135
+ fs.writeFileSync(schemaPath, content, 'utf8');
136
+ }
137
+
138
+ function copyMigrationFolder(packageRoot, targetRoot, migrationName) {
139
+ const migrationDir = path.join(targetRoot, 'apps', 'api', 'prisma', 'migrations', migrationName);
140
+ const migrationFile = path.join(migrationDir, 'migration.sql');
141
+ if (fs.existsSync(migrationFile)) {
142
+ return;
143
+ }
144
+
145
+ const sourceDir = path.join(packageRoot, 'templates', 'module-presets', 'files', 'apps', 'api', 'prisma', 'migrations', migrationName);
146
+ if (!fs.existsSync(sourceDir)) {
147
+ return;
148
+ }
149
+
150
+ fs.mkdirSync(path.dirname(migrationDir), { recursive: true });
151
+ copyRecursive(sourceDir, migrationDir);
152
+ }
153
+
154
+ function patchPrismaMigration(packageRoot, targetRoot) {
155
+ copyMigrationFolder(packageRoot, targetRoot, '20260306_files_file_record');
156
+ copyMigrationFolder(packageRoot, targetRoot, '20260306_files_file_variant');
157
+ }
158
+
159
+ function patchAppModule(targetRoot) {
160
+ const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'app.module.ts');
161
+ if (!fs.existsSync(filePath)) {
162
+ return;
163
+ }
164
+
165
+ let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
166
+ content = ensureImportLine(
167
+ content,
168
+ "import { filesConfig, filesEnvSchema, ForgeonFilesModule } from '@forgeon/files';",
169
+ );
170
+ content = ensureLoadItem(content, 'filesConfig');
171
+ content = ensureValidatorSchema(content, 'filesEnvSchema');
172
+
173
+ if (!content.includes(' ForgeonFilesModule,')) {
174
+ if (content.includes(' ForgeonI18nModule.register({')) {
175
+ content = ensureLineBefore(content, ' ForgeonI18nModule.register({', ' ForgeonFilesModule,');
176
+ } else if (content.includes(' ForgeonAuthModule.register({')) {
177
+ content = ensureLineBefore(content, ' ForgeonAuthModule.register({', ' ForgeonFilesModule,');
178
+ } else if (content.includes(' ForgeonAuthModule.register(),')) {
179
+ content = ensureLineBefore(content, ' ForgeonAuthModule.register(),', ' ForgeonFilesModule,');
180
+ } else if (content.includes(' DbPrismaModule,')) {
181
+ content = ensureLineAfter(content, ' DbPrismaModule,', ' ForgeonFilesModule,');
182
+ } else if (content.includes(' ForgeonLoggerModule,')) {
183
+ content = ensureLineAfter(content, ' ForgeonLoggerModule,', ' ForgeonFilesModule,');
184
+ } else if (content.includes(' ForgeonSwaggerModule,')) {
185
+ content = ensureLineAfter(content, ' ForgeonSwaggerModule,', ' ForgeonFilesModule,');
186
+ } else {
187
+ content = ensureLineAfter(content, ' CoreErrorsModule,', ' ForgeonFilesModule,');
188
+ }
189
+ }
190
+
191
+ fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
192
+ }
193
+
194
+ function patchApiDockerfile(targetRoot) {
195
+ const dockerfilePath = path.join(targetRoot, 'apps', 'api', 'Dockerfile');
196
+ if (!fs.existsSync(dockerfilePath)) {
197
+ return;
198
+ }
199
+
200
+ let content = fs.readFileSync(dockerfilePath, 'utf8').replace(/\r\n/g, '\n');
201
+
202
+ const packageAnchors = [
203
+ 'COPY packages/auth-api/package.json packages/auth-api/package.json',
204
+ 'COPY packages/rbac/package.json packages/rbac/package.json',
205
+ 'COPY packages/rate-limit/package.json packages/rate-limit/package.json',
206
+ 'COPY packages/logger/package.json packages/logger/package.json',
207
+ 'COPY packages/swagger/package.json packages/swagger/package.json',
208
+ 'COPY packages/i18n/package.json packages/i18n/package.json',
209
+ 'COPY packages/db-prisma/package.json packages/db-prisma/package.json',
210
+ 'COPY packages/core/package.json packages/core/package.json',
211
+ ];
212
+ const packageAnchor = packageAnchors.find((line) => content.includes(line)) ?? packageAnchors.at(-1);
213
+ content = ensureLineAfter(content, packageAnchor, 'COPY packages/files/package.json packages/files/package.json');
214
+
215
+ const sourceAnchors = [
216
+ 'COPY packages/auth-api packages/auth-api',
217
+ 'COPY packages/rbac packages/rbac',
218
+ 'COPY packages/rate-limit packages/rate-limit',
219
+ 'COPY packages/logger packages/logger',
220
+ 'COPY packages/swagger packages/swagger',
221
+ 'COPY packages/i18n packages/i18n',
222
+ 'COPY packages/db-prisma packages/db-prisma',
223
+ 'COPY packages/core packages/core',
224
+ ];
225
+ const sourceAnchor = sourceAnchors.find((line) => content.includes(line)) ?? sourceAnchors.at(-1);
226
+ content = ensureLineAfter(content, sourceAnchor, 'COPY packages/files packages/files');
227
+
228
+ content = content.replace(/^RUN pnpm --filter @forgeon\/files build\r?\n?/gm, '');
229
+ const buildAnchor = content.includes('RUN pnpm --filter @forgeon/api prisma:generate')
230
+ ? 'RUN pnpm --filter @forgeon/api prisma:generate'
231
+ : 'RUN pnpm --filter @forgeon/api build';
232
+ content = ensureLineBefore(content, buildAnchor, 'RUN pnpm --filter @forgeon/files build');
233
+
234
+ fs.writeFileSync(dockerfilePath, `${content.trimEnd()}\n`, 'utf8');
235
+ }
236
+
237
+ function patchHealthController(targetRoot) {
238
+ const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts');
239
+ if (!fs.existsSync(filePath)) {
240
+ return;
241
+ }
242
+
243
+ let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
244
+ content = ensureNestCommonImport(content, 'Post');
245
+ content = ensureNestCommonImport(content, 'Get');
246
+ content = ensureImportLine(content, "import { FilesService } from '@forgeon/files';");
247
+
248
+ if (!content.includes('private readonly filesService: FilesService')) {
249
+ const constructorMatch = content.match(/constructor\(([\s\S]*?)\)\s*\{/m);
250
+ if (constructorMatch) {
251
+ const original = constructorMatch[0];
252
+ const inner = constructorMatch[1].trimEnd();
253
+ const normalizedInner = inner.replace(/,\s*$/, '');
254
+ const separator = normalizedInner.length > 0 ? ',' : '';
255
+ const next = `constructor(${normalizedInner}${separator}
256
+ private readonly filesService: FilesService,
257
+ ) {`;
258
+ content = content.replace(original, next);
259
+ } else {
260
+ const classAnchor = 'export class HealthController {';
261
+ if (content.includes(classAnchor)) {
262
+ content = content.replace(
263
+ classAnchor,
264
+ `${classAnchor}
265
+ constructor(private readonly filesService: FilesService) {}
266
+ `,
267
+ );
268
+ }
269
+ }
270
+ }
271
+
272
+ if (!content.includes("@Post('files')")) {
273
+ const method = `
274
+ @Post('files')
275
+ async getFilesProbe() {
276
+ const record = await this.filesService.createProbeRecord();
277
+ await this.filesService.deleteByPublicId(record.publicId);
278
+ return {
279
+ status: 'ok',
280
+ feature: 'files',
281
+ file: {
282
+ publicId: record.publicId,
283
+ mimeType: record.mimeType,
284
+ size: record.size,
285
+ },
286
+ cleanup: 'done',
287
+ };
288
+ }
289
+ `;
290
+ content = ensureClassMember(content, 'HealthController', method, { beforeNeedle: 'private translate(' });
291
+ }
292
+
293
+ if (!content.includes("@Get('files-variants')")) {
294
+ const method = `
295
+ @Get('files-variants')
296
+ async getFilesVariantsProbe() {
297
+ return this.filesService.getVariantsProbeStatus();
298
+ }
299
+ `;
300
+ content = ensureClassMember(content, 'HealthController', method, { beforeNeedle: 'private translate(' });
301
+ }
302
+
303
+ fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
304
+ }
305
+
306
+ function patchWebApp(targetRoot) {
307
+ const filePath = path.join(targetRoot, 'apps', 'web', 'src', 'App.tsx');
308
+ if (!fs.existsSync(filePath)) {
309
+ return;
310
+ }
311
+
312
+ let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
313
+ content = content
314
+ .replace(/^\s*\{\/\* forgeon:probes:actions:start \*\/\}\r?\n?/gm, '')
315
+ .replace(/^\s*\{\/\* forgeon:probes:actions:end \*\/\}\r?\n?/gm, '')
316
+ .replace(/^\s*\{\/\* forgeon:probes:results:start \*\/\}\r?\n?/gm, '')
317
+ .replace(/^\s*\{\/\* forgeon:probes:results:end \*\/\}\r?\n?/gm, '');
318
+
319
+ if (!content.includes('filesProbeResult')) {
320
+ const stateAnchors = [
321
+ ' const [dbProbeResult, setDbProbeResult] = useState<ProbeResult | null>(null);',
322
+ ' const [authProbeResult, setAuthProbeResult] = useState<ProbeResult | null>(null);',
323
+ ' const [validationProbeResult, setValidationProbeResult] = useState<ProbeResult | null>(null);',
324
+ ];
325
+ const stateAnchor = stateAnchors.find((line) => content.includes(line));
326
+ if (stateAnchor) {
327
+ content = ensureLineAfter(
328
+ content,
329
+ stateAnchor,
330
+ ' const [filesProbeResult, setFilesProbeResult] = useState<ProbeResult | null>(null);',
331
+ );
332
+ }
333
+ }
334
+
335
+ if (!content.includes('filesVariantsProbeResult')) {
336
+ const stateAnchors = [
337
+ ' const [filesProbeResult, setFilesProbeResult] = useState<ProbeResult | null>(null);',
338
+ ' const [validationProbeResult, setValidationProbeResult] = useState<ProbeResult | null>(null);',
339
+ ];
340
+ const stateAnchor = stateAnchors.find((line) => content.includes(line));
341
+ if (stateAnchor) {
342
+ content = ensureLineAfter(
343
+ content,
344
+ stateAnchor,
345
+ ' const [filesVariantsProbeResult, setFilesVariantsProbeResult] = useState<ProbeResult | null>(null);',
346
+ );
347
+ }
348
+ }
349
+
350
+ if (!content.includes('Check files probe (create metadata)')) {
351
+ const probePath = content.includes("runProbe(setHealthResult, '/health')")
352
+ ? '/health/files'
353
+ : '/api/health/files';
354
+ const button = ` <button onClick={() => runProbe(setFilesProbeResult, '${probePath}', { method: 'POST' })}>\n Check files probe (create metadata)\n </button>`;
355
+
356
+ const actionsStart = content.indexOf('<div className="actions">');
357
+ if (actionsStart >= 0) {
358
+ const actionsEnd = content.indexOf('\n </div>', actionsStart);
359
+ if (actionsEnd >= 0) {
360
+ content = `${content.slice(0, actionsEnd)}\n${button}${content.slice(actionsEnd)}`;
361
+ }
362
+ }
363
+ }
364
+
365
+ if (!content.includes("{renderResult('Files probe response', filesProbeResult)}")) {
366
+ const resultLine = " {renderResult('Files probe response', filesProbeResult)}";
367
+ const networkLine = ' {networkError ? <p className="error">{networkError}</p> : null}';
368
+ if (content.includes(networkLine)) {
369
+ content = content.replace(networkLine, `${resultLine}\n${networkLine}`);
370
+ } else {
371
+ const anchor = "{renderResult('Validation probe response', validationProbeResult)}";
372
+ if (content.includes(anchor)) {
373
+ content = ensureLineAfter(content, anchor, resultLine);
374
+ }
375
+ }
376
+ }
377
+
378
+ if (!content.includes('Check files variants capability')) {
379
+ const probePath = content.includes("runProbe(setHealthResult, '/health')")
380
+ ? '/health/files-variants'
381
+ : '/api/health/files-variants';
382
+ const button = ` <button onClick={() => runProbe(setFilesVariantsProbeResult, '${probePath}')}>\n Check files variants capability\n </button>`;
383
+
384
+ const actionsStart = content.indexOf('<div className="actions">');
385
+ if (actionsStart >= 0) {
386
+ const actionsEnd = content.indexOf('\n </div>', actionsStart);
387
+ if (actionsEnd >= 0) {
388
+ content = `${content.slice(0, actionsEnd)}\n${button}${content.slice(actionsEnd)}`;
389
+ }
390
+ }
391
+ }
392
+
393
+ if (!content.includes("{renderResult('Files variants probe response', filesVariantsProbeResult)}")) {
394
+ const resultLine = " {renderResult('Files variants probe response', filesVariantsProbeResult)}";
395
+ const networkLine = ' {networkError ? <p className="error">{networkError}</p> : null}';
396
+ if (content.includes(networkLine)) {
397
+ content = content.replace(networkLine, `${resultLine}\n${networkLine}`);
398
+ } else {
399
+ const anchor = "{renderResult('Files probe response', filesProbeResult)}";
400
+ if (content.includes(anchor)) {
401
+ content = ensureLineAfter(content, anchor, resultLine);
402
+ }
403
+ }
404
+ }
405
+
406
+ fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
407
+ }
408
+
409
+ function patchCompose(targetRoot) {
410
+ const composePath = path.join(targetRoot, 'infra', 'docker', 'compose.yml');
411
+ if (!fs.existsSync(composePath)) {
412
+ return;
413
+ }
414
+
415
+ let content = fs.readFileSync(composePath, 'utf8').replace(/\r\n/g, '\n');
416
+ if (!content.includes('FILES_ENABLED: ${FILES_ENABLED}')) {
417
+ const anchors = [
418
+ /^(\s+AUTH_DEMO_PASSWORD:.*)$/m,
419
+ /^(\s+THROTTLE_TRUST_PROXY:.*)$/m,
420
+ /^(\s+LOGGER_LEVEL:.*)$/m,
421
+ /^(\s+SWAGGER_ENABLED:.*)$/m,
422
+ /^(\s+I18N_FALLBACK_LANG:.*)$/m,
423
+ /^(\s+DATABASE_URL:.*)$/m,
424
+ /^(\s+API_PREFIX:.*)$/m,
425
+ ];
426
+ const anchorPattern = anchors.find((pattern) => pattern.test(content)) ?? anchors.at(-1);
427
+ content = content.replace(
428
+ anchorPattern,
429
+ `$1
430
+ FILES_ENABLED: \${FILES_ENABLED}
431
+ FILES_STORAGE_DRIVER: \${FILES_STORAGE_DRIVER}
432
+ FILES_PUBLIC_BASE_PATH: \${FILES_PUBLIC_BASE_PATH}
433
+ FILES_MAX_FILE_SIZE_BYTES: \${FILES_MAX_FILE_SIZE_BYTES}
434
+ FILES_ALLOWED_MIME_PREFIXES: \${FILES_ALLOWED_MIME_PREFIXES}`,
435
+ );
436
+ }
437
+
438
+ fs.writeFileSync(composePath, `${content.trimEnd()}\n`, 'utf8');
439
+ }
440
+
441
+ function patchReadme(targetRoot) {
442
+ const readmePath = path.join(targetRoot, 'README.md');
443
+ if (!fs.existsSync(readmePath)) {
444
+ return;
445
+ }
446
+
447
+ const marker = '## Files Module';
448
+ let content = fs.readFileSync(readmePath, 'utf8').replace(/\r\n/g, '\n');
449
+ if (content.includes(marker)) {
450
+ return;
451
+ }
452
+
453
+ const section = `## Files Module
454
+
455
+ The files module adds runtime file endpoints with DB-backed metadata and storage-driver-aware behavior.
456
+
457
+ What it currently adds:
458
+ - \`@forgeon/files\` package with:
459
+ - upload endpoint (\`POST /api/files/upload\`)
460
+ - metadata endpoint (\`GET /api/files/:publicId\`)
461
+ - download endpoint (\`GET /api/files/:publicId/download?variant=original|preview\`)
462
+ - delete endpoint (\`DELETE /api/files/:publicId\`)
463
+ - Prisma \`FileRecord\` model and migration template (with owner/visibility indexes)
464
+ - Prisma \`FileVariant\` model for \`original\` and optional \`preview\` variants
465
+ - Prisma \`FileBlob\` model for dedup and shared physical content
466
+ - probe endpoints:
467
+ - \`POST /api/health/files\`
468
+ - \`GET /api/health/files-variants\`
469
+
470
+ Current limits:
471
+ - local storage runtime is implemented
472
+ - S3 runtime is available when \`files-s3\` module is installed and \`FILES_STORAGE_DRIVER=s3\`
473
+ - strict access control and quotas are provided by separate add-modules (\`files-access\`, \`files-quotas\`)
474
+ - image sanitize pipeline is provided by separate add-module (\`files-image\`)
475
+ - \`create-forgeon add files\` recommends installing \`files-image\` during the same flow (TTY default: Yes)
476
+ - \`preview\` variant is generated only when \`files-image\` is installed
477
+ - dedup is applied to \`original\` uploads by content hash (\`sha256 + size + mime + driver\`)
478
+ - files probe does create+cleanup to avoid leftover storage artifacts
479
+
480
+ Dependency model:
481
+ - requires \`db-adapter\`
482
+ - requires \`files-storage-adapter\` (for example: \`files-local\` or \`files-s3\`)
483
+
484
+ Key env:
485
+ - \`FILES_ENABLED=true\`
486
+ - \`FILES_STORAGE_DRIVER=local\`
487
+ - \`FILES_PUBLIC_BASE_PATH=/files\`
488
+ - \`FILES_MAX_FILE_SIZE_BYTES=10485760\`
489
+ - \`FILES_ALLOWED_MIME_PREFIXES=image/,application/pdf,text/\``;
490
+
491
+ if (content.includes('## Prisma In Docker Start')) {
492
+ content = content.replace('## Prisma In Docker Start', `${section}\n\n## Prisma In Docker Start`);
493
+ } else {
494
+ content = `${content.trimEnd()}\n\n${section}\n`;
495
+ }
496
+
497
+ fs.writeFileSync(readmePath, `${content.trimEnd()}\n`, 'utf8');
498
+ }
499
+
500
+ export function applyFilesModule({ packageRoot, targetRoot }) {
501
+ copyFromPreset(packageRoot, targetRoot, path.join('packages', 'files'));
502
+
503
+ patchApiPackage(targetRoot);
504
+ patchPrismaSchema(targetRoot);
505
+ patchPrismaMigration(packageRoot, targetRoot);
506
+ patchAppModule(targetRoot);
507
+ patchHealthController(targetRoot);
508
+ patchWebApp(targetRoot);
509
+ patchApiDockerfile(targetRoot);
510
+ patchCompose(targetRoot);
511
+ patchReadme(targetRoot);
512
+
513
+ upsertEnvLines(path.join(targetRoot, 'apps', 'api', '.env.example'), [
514
+ 'FILES_ENABLED=true',
515
+ 'FILES_STORAGE_DRIVER=local',
516
+ 'FILES_PUBLIC_BASE_PATH=/files',
517
+ 'FILES_MAX_FILE_SIZE_BYTES=10485760',
518
+ 'FILES_ALLOWED_MIME_PREFIXES=image/,application/pdf,text/',
519
+ ]);
520
+ upsertEnvLines(path.join(targetRoot, 'infra', 'docker', '.env.example'), [
521
+ 'FILES_ENABLED=true',
522
+ 'FILES_STORAGE_DRIVER=local',
523
+ 'FILES_PUBLIC_BASE_PATH=/files',
524
+ 'FILES_MAX_FILE_SIZE_BYTES=10485760',
525
+ 'FILES_ALLOWED_MIME_PREFIXES=image/,application/pdf,text/',
526
+ ]);
527
+ }
@@ -44,10 +44,14 @@ 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
+ );
47
51
  content = ensureLineBefore(
48
52
  content,
49
53
  "import { NestFactory } from '@nestjs/core';",
50
- "import { ForgeonHttpLoggingInterceptor, ForgeonLoggerService } from '@forgeon/logger';",
54
+ "import { ForgeonLoggerService } from '@forgeon/logger';",
51
55
  );
52
56
 
53
57
  content = content.replace(
@@ -55,12 +59,16 @@ function patchMain(targetRoot) {
55
59
  'const app = await NestFactory.create(AppModule, { bufferLogs: true });',
56
60
  );
57
61
 
62
+ content = content.replace(
63
+ /\n\s*app\.useGlobalInterceptors\(app\.get\(ForgeonHttpLoggingInterceptor\)\);\s*/g,
64
+ '\n',
65
+ );
66
+
58
67
  if (!content.includes('app.useLogger(app.get(ForgeonLoggerService));')) {
59
68
  content = content.replace(
60
69
  ' const coreConfigService = app.get(CoreConfigService);',
61
70
  ` const coreConfigService = app.get(CoreConfigService);
62
- app.useLogger(app.get(ForgeonLoggerService));
63
- app.useGlobalInterceptors(app.get(ForgeonHttpLoggingInterceptor));`,
71
+ app.useLogger(app.get(ForgeonLoggerService));`,
64
72
  );
65
73
  }
66
74
 
@@ -179,6 +187,7 @@ function patchReadme(targetRoot) {
179
187
  The logger add-module provides:
180
188
  - request id middleware (default header: \`x-request-id\`)
181
189
  - HTTP access logs with method/path/status/duration/ip/requestId
190
+ - HTTP access logs are emitted from middleware, so requests rejected by guards (for example 429 from rate-limit) are still logged
182
191
  - Nest logger integration via \`app.useLogger(...)\`
183
192
 
184
193
  It installs independently and intentionally does not add a dedicated API/web probe.