create-forgeon 0.3.5 → 0.3.7

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 (109) 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 +752 -14
  9. package/src/modules/files-access.mjs +446 -0
  10. package/src/modules/files-image.mjs +540 -0
  11. package/src/modules/files-local.mjs +221 -0
  12. package/src/modules/files-quotas.mjs +402 -0
  13. package/src/modules/files-s3.mjs +266 -0
  14. package/src/modules/files.mjs +527 -0
  15. package/src/modules/queue.mjs +410 -0
  16. package/src/modules/registry.mjs +93 -3
  17. package/src/modules/shared/patch-utils.mjs +25 -0
  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 +90 -0
  54. package/templates/module-presets/files/packages/files/src/files.service.ts +762 -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/queue/packages/queue/package.json +21 -0
  101. package/templates/module-presets/queue/packages/queue/src/forgeon-queue.module.ts +10 -0
  102. package/templates/module-presets/queue/packages/queue/src/index.ts +6 -0
  103. package/templates/module-presets/queue/packages/queue/src/queue-config.loader.ts +24 -0
  104. package/templates/module-presets/queue/packages/queue/src/queue-config.module.ts +10 -0
  105. package/templates/module-presets/queue/packages/queue/src/queue-config.service.ts +69 -0
  106. package/templates/module-presets/queue/packages/queue/src/queue-env.schema.ts +17 -0
  107. package/templates/module-presets/queue/packages/queue/src/queue.service.ts +88 -0
  108. package/templates/module-presets/queue/packages/queue/tsconfig.json +9 -0
  109. package/templates/module-fragments/queue/90_status_planned.md +0 -3
@@ -0,0 +1,221 @@
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
+ ensureDependency,
7
+ ensureImportLine,
8
+ ensureLineAfter,
9
+ ensureLineBefore,
10
+ ensureLoadItem,
11
+ ensureValidatorSchema,
12
+ upsertEnvLines,
13
+ } from './shared/patch-utils.mjs';
14
+
15
+ function copyFromPreset(packageRoot, targetRoot, relativePath) {
16
+ const source = path.join(packageRoot, 'templates', 'module-presets', 'files-local', relativePath);
17
+ if (!fs.existsSync(source)) {
18
+ throw new Error(`Missing files-local preset template: ${source}`);
19
+ }
20
+ const destination = path.join(targetRoot, relativePath);
21
+ copyRecursive(source, destination);
22
+ }
23
+
24
+ function patchApiPackage(targetRoot) {
25
+ const packagePath = path.join(targetRoot, 'apps', 'api', 'package.json');
26
+ if (!fs.existsSync(packagePath)) {
27
+ return;
28
+ }
29
+
30
+ const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
31
+ ensureDependency(packageJson, '@forgeon/files-local', 'workspace:*');
32
+ ensureBuildSteps(packageJson, 'predev', ['pnpm --filter @forgeon/files-local build']);
33
+ writeJson(packagePath, packageJson);
34
+ }
35
+
36
+ function patchAppModule(targetRoot) {
37
+ const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'app.module.ts');
38
+ if (!fs.existsSync(filePath)) {
39
+ return;
40
+ }
41
+
42
+ let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
43
+ content = ensureImportLine(
44
+ content,
45
+ "import { filesLocalConfig, filesLocalEnvSchemaZod, FilesLocalConfigModule } from '@forgeon/files-local';",
46
+ );
47
+ content = ensureLoadItem(content, 'filesLocalConfig');
48
+ content = ensureValidatorSchema(content, 'filesLocalEnvSchemaZod');
49
+
50
+ if (!content.includes(' FilesLocalConfigModule,')) {
51
+ if (content.includes(' ForgeonFilesModule,')) {
52
+ content = ensureLineAfter(content, ' ForgeonFilesModule,', ' FilesLocalConfigModule,');
53
+ } else if (content.includes(' ForgeonI18nModule.register({')) {
54
+ content = ensureLineBefore(content, ' ForgeonI18nModule.register({', ' FilesLocalConfigModule,');
55
+ } else if (content.includes(' DbPrismaModule,')) {
56
+ content = ensureLineAfter(content, ' DbPrismaModule,', ' FilesLocalConfigModule,');
57
+ } else if (content.includes(' ForgeonLoggerModule,')) {
58
+ content = ensureLineAfter(content, ' ForgeonLoggerModule,', ' FilesLocalConfigModule,');
59
+ } else {
60
+ content = ensureLineAfter(content, ' CoreErrorsModule,', ' FilesLocalConfigModule,');
61
+ }
62
+ }
63
+
64
+ fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
65
+ }
66
+
67
+ function patchApiDockerfile(targetRoot) {
68
+ const dockerfilePath = path.join(targetRoot, 'apps', 'api', 'Dockerfile');
69
+ if (!fs.existsSync(dockerfilePath)) {
70
+ return;
71
+ }
72
+
73
+ let content = fs.readFileSync(dockerfilePath, 'utf8').replace(/\r\n/g, '\n');
74
+
75
+ const packageAnchors = [
76
+ 'COPY packages/files/package.json packages/files/package.json',
77
+ 'COPY packages/auth-api/package.json packages/auth-api/package.json',
78
+ 'COPY packages/rbac/package.json packages/rbac/package.json',
79
+ 'COPY packages/rate-limit/package.json packages/rate-limit/package.json',
80
+ 'COPY packages/logger/package.json packages/logger/package.json',
81
+ 'COPY packages/swagger/package.json packages/swagger/package.json',
82
+ 'COPY packages/i18n/package.json packages/i18n/package.json',
83
+ 'COPY packages/db-prisma/package.json packages/db-prisma/package.json',
84
+ 'COPY packages/core/package.json packages/core/package.json',
85
+ ];
86
+ const packageAnchor = packageAnchors.find((line) => content.includes(line)) ?? packageAnchors.at(-1);
87
+ content = ensureLineAfter(
88
+ content,
89
+ packageAnchor,
90
+ 'COPY packages/files-local/package.json packages/files-local/package.json',
91
+ );
92
+
93
+ const sourceAnchors = [
94
+ 'COPY packages/files packages/files',
95
+ 'COPY packages/auth-api packages/auth-api',
96
+ 'COPY packages/rbac packages/rbac',
97
+ 'COPY packages/rate-limit packages/rate-limit',
98
+ 'COPY packages/logger packages/logger',
99
+ 'COPY packages/swagger packages/swagger',
100
+ 'COPY packages/i18n packages/i18n',
101
+ 'COPY packages/db-prisma packages/db-prisma',
102
+ 'COPY packages/core packages/core',
103
+ ];
104
+ const sourceAnchor = sourceAnchors.find((line) => content.includes(line)) ?? sourceAnchors.at(-1);
105
+ content = ensureLineAfter(content, sourceAnchor, 'COPY packages/files-local packages/files-local');
106
+
107
+ content = content.replace(/^RUN pnpm --filter @forgeon\/files-local build\r?\n?/gm, '');
108
+ const buildAnchor = content.includes('RUN pnpm --filter @forgeon/api prisma:generate')
109
+ ? 'RUN pnpm --filter @forgeon/api prisma:generate'
110
+ : 'RUN pnpm --filter @forgeon/api build';
111
+ content = ensureLineBefore(content, buildAnchor, 'RUN pnpm --filter @forgeon/files-local build');
112
+
113
+ fs.writeFileSync(dockerfilePath, `${content.trimEnd()}\n`, 'utf8');
114
+ }
115
+
116
+ function patchCompose(targetRoot) {
117
+ const composePath = path.join(targetRoot, 'infra', 'docker', 'compose.yml');
118
+ if (!fs.existsSync(composePath)) {
119
+ return;
120
+ }
121
+
122
+ let content = fs.readFileSync(composePath, 'utf8').replace(/\r\n/g, '\n');
123
+ if (!content.includes('FILES_LOCAL_ROOT: ${FILES_LOCAL_ROOT}')) {
124
+ const anchors = [
125
+ /^(\s+FILES_PUBLIC_BASE_PATH:.*)$/m,
126
+ /^(\s+FILES_STORAGE_DRIVER:.*)$/m,
127
+ /^(\s+FILES_ENABLED:.*)$/m,
128
+ /^(\s+API_PREFIX:.*)$/m,
129
+ ];
130
+ const anchorPattern = anchors.find((pattern) => pattern.test(content)) ?? anchors.at(-1);
131
+ content = content.replace(
132
+ anchorPattern,
133
+ `$1
134
+ FILES_LOCAL_ROOT: \${FILES_LOCAL_ROOT}`,
135
+ );
136
+ }
137
+
138
+ if (!content.includes('files_data:/app/storage')) {
139
+ content = content.replace(
140
+ /^(\s{2}api:\n[\s\S]*?^\s{4}environment:\n(?:\s{6}.+\n)+)/m,
141
+ `$1 volumes:
142
+ - files_data:/app/storage
143
+ `,
144
+ );
145
+ }
146
+
147
+ if (!/^volumes:\n/m.test(content)) {
148
+ content = `${content.trimEnd()}\n\nvolumes:\n files_data:\n`;
149
+ } else if (!/^\s{2}files_data:\s*$/m.test(content)) {
150
+ content = `${content.trimEnd()}\n files_data:\n`;
151
+ }
152
+
153
+ fs.writeFileSync(composePath, `${content.trimEnd()}\n`, 'utf8');
154
+ }
155
+
156
+ function patchReadme(targetRoot) {
157
+ const readmePath = path.join(targetRoot, 'README.md');
158
+ if (!fs.existsSync(readmePath)) {
159
+ return;
160
+ }
161
+
162
+ const marker = '## Files Local Adapter Module';
163
+ let content = fs.readFileSync(readmePath, 'utf8').replace(/\r\n/g, '\n');
164
+ if (content.includes(marker)) {
165
+ return;
166
+ }
167
+
168
+ const section = `## Files Local Adapter Module
169
+
170
+ The files-local module provides the \`files-storage-adapter\` capability via local disk configuration.
171
+
172
+ Configuration:
173
+ - \`FILES_LOCAL_ROOT=storage/uploads\`
174
+
175
+ Notes:
176
+ - this adapter is used by \`@forgeon/files\` when \`FILES_STORAGE_DRIVER=local\`
177
+ - saved files are stored in \`FILES_LOCAL_ROOT\` relative to the project root
178
+ - Docker compose mounts named volume \`files_data\` to \`/app/storage\` for persistence`;
179
+
180
+ if (content.includes('## Prisma In Docker Start')) {
181
+ content = content.replace('## Prisma In Docker Start', `${section}\n\n## Prisma In Docker Start`);
182
+ } else {
183
+ content = `${content.trimEnd()}\n\n${section}\n`;
184
+ }
185
+
186
+ fs.writeFileSync(readmePath, `${content.trimEnd()}\n`, 'utf8');
187
+ }
188
+
189
+ function patchGitignore(targetRoot) {
190
+ const gitignorePath = path.join(targetRoot, '.gitignore');
191
+ if (!fs.existsSync(gitignorePath)) {
192
+ return;
193
+ }
194
+
195
+ const marker = 'storage/';
196
+ let content = fs.readFileSync(gitignorePath, 'utf8').replace(/\r\n/g, '\n');
197
+ if (content.includes(marker)) {
198
+ return;
199
+ }
200
+
201
+ content = `${content.trimEnd()}\n${marker}\n`;
202
+ fs.writeFileSync(gitignorePath, content, 'utf8');
203
+ }
204
+
205
+ export function applyFilesLocalModule({ packageRoot, targetRoot }) {
206
+ copyFromPreset(packageRoot, targetRoot, path.join('packages', 'files-local'));
207
+
208
+ patchApiPackage(targetRoot);
209
+ patchAppModule(targetRoot);
210
+ patchApiDockerfile(targetRoot);
211
+ patchCompose(targetRoot);
212
+ patchReadme(targetRoot);
213
+ patchGitignore(targetRoot);
214
+
215
+ upsertEnvLines(path.join(targetRoot, 'apps', 'api', '.env.example'), [
216
+ 'FILES_LOCAL_ROOT=storage/uploads',
217
+ ]);
218
+ upsertEnvLines(path.join(targetRoot, 'infra', 'docker', '.env.example'), [
219
+ 'FILES_LOCAL_ROOT=storage/uploads',
220
+ ]);
221
+ }
@@ -0,0 +1,402 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { copyRecursive, writeJson } from '../utils/fs.mjs';
4
+ import {
5
+ ensureBuildStepBefore,
6
+ ensureBuildSteps,
7
+ ensureClassMember,
8
+ ensureDependency,
9
+ ensureImportLine,
10
+ ensureLineAfter,
11
+ ensureLineBefore,
12
+ ensureLoadItem,
13
+ ensureNestCommonImport,
14
+ ensureValidatorSchema,
15
+ upsertEnvLines,
16
+ } from './shared/patch-utils.mjs';
17
+
18
+ function copyFromPreset(packageRoot, targetRoot, relativePath) {
19
+ const source = path.join(packageRoot, 'templates', 'module-presets', 'files-quotas', relativePath);
20
+ if (!fs.existsSync(source)) {
21
+ throw new Error(`Missing files-quotas preset template: ${source}`);
22
+ }
23
+ const destination = path.join(targetRoot, relativePath);
24
+ copyRecursive(source, destination);
25
+ }
26
+
27
+ function patchApiPackage(targetRoot) {
28
+ const packagePath = path.join(targetRoot, 'apps', 'api', 'package.json');
29
+ if (!fs.existsSync(packagePath)) {
30
+ return;
31
+ }
32
+
33
+ const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
34
+ ensureDependency(packageJson, '@forgeon/files-quotas', 'workspace:*');
35
+ ensureBuildSteps(packageJson, 'predev', ['pnpm --filter @forgeon/files-quotas build']);
36
+ ensureBuildStepBefore(
37
+ packageJson,
38
+ 'predev',
39
+ 'pnpm --filter @forgeon/files-quotas build',
40
+ 'pnpm --filter @forgeon/files build',
41
+ );
42
+ writeJson(packagePath, packageJson);
43
+ }
44
+
45
+ function patchFilesPackage(targetRoot) {
46
+ const packagePath = path.join(targetRoot, 'packages', 'files', 'package.json');
47
+ if (!fs.existsSync(packagePath)) {
48
+ return;
49
+ }
50
+
51
+ const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
52
+ ensureDependency(packageJson, '@forgeon/files-quotas', 'workspace:*');
53
+ writeJson(packagePath, packageJson);
54
+ }
55
+
56
+ function patchAppModule(targetRoot) {
57
+ const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'app.module.ts');
58
+ if (!fs.existsSync(filePath)) {
59
+ return;
60
+ }
61
+
62
+ let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
63
+ content = ensureImportLine(
64
+ content,
65
+ "import { filesQuotasConfig, filesQuotasEnvSchema, ForgeonFilesQuotasModule } from '@forgeon/files-quotas';",
66
+ );
67
+ content = ensureLoadItem(content, 'filesQuotasConfig');
68
+ content = ensureValidatorSchema(content, 'filesQuotasEnvSchema');
69
+
70
+ if (!content.includes(' ForgeonFilesQuotasModule,')) {
71
+ if (content.includes(' ForgeonFilesAccessModule,')) {
72
+ content = ensureLineAfter(content, ' ForgeonFilesAccessModule,', ' ForgeonFilesQuotasModule,');
73
+ } else if (content.includes(' ForgeonFilesModule,')) {
74
+ content = ensureLineAfter(content, ' ForgeonFilesModule,', ' ForgeonFilesQuotasModule,');
75
+ } else if (content.includes(' ForgeonI18nModule.register({')) {
76
+ content = ensureLineBefore(content, ' ForgeonI18nModule.register({', ' ForgeonFilesQuotasModule,');
77
+ } else if (content.includes(' ForgeonAuthModule.register({')) {
78
+ content = ensureLineBefore(content, ' ForgeonAuthModule.register({', ' ForgeonFilesQuotasModule,');
79
+ } else if (content.includes(' ForgeonAuthModule.register(),')) {
80
+ content = ensureLineBefore(content, ' ForgeonAuthModule.register(),', ' ForgeonFilesQuotasModule,');
81
+ } else if (content.includes(' DbPrismaModule,')) {
82
+ content = ensureLineAfter(content, ' DbPrismaModule,', ' ForgeonFilesQuotasModule,');
83
+ } else if (content.includes(' ForgeonLoggerModule,')) {
84
+ content = ensureLineAfter(content, ' ForgeonLoggerModule,', ' ForgeonFilesQuotasModule,');
85
+ } else if (content.includes(' ForgeonSwaggerModule,')) {
86
+ content = ensureLineAfter(content, ' ForgeonSwaggerModule,', ' ForgeonFilesQuotasModule,');
87
+ } else {
88
+ content = ensureLineAfter(content, ' CoreErrorsModule,', ' ForgeonFilesQuotasModule,');
89
+ }
90
+ }
91
+
92
+ fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
93
+ }
94
+
95
+ function patchFilesController(targetRoot) {
96
+ const filePath = path.join(targetRoot, 'packages', 'files', 'src', 'files.controller.ts');
97
+ if (!fs.existsSync(filePath)) {
98
+ return;
99
+ }
100
+
101
+ let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
102
+ content = ensureImportLine(content, "import { FilesQuotasService } from '@forgeon/files-quotas';");
103
+
104
+ if (!content.includes('private readonly filesQuotasService: FilesQuotasService')) {
105
+ const constructorMatch = content.match(/constructor\(([\s\S]*?)\)\s*\{/m);
106
+ if (constructorMatch) {
107
+ const original = constructorMatch[0];
108
+ const inner = constructorMatch[1].trimEnd();
109
+ const normalizedInner = inner.replace(/,\s*$/, '');
110
+ const separator = normalizedInner.length > 0 ? ',' : '';
111
+ const next = `constructor(${normalizedInner}${separator}
112
+ private readonly filesQuotasService: FilesQuotasService,
113
+ ) {`;
114
+ content = content.replace(original, next);
115
+ }
116
+ }
117
+
118
+ if (!content.includes('filesQuotasService.assertUploadAllowed')) {
119
+ content = content.replace(
120
+ ' return this.filesService.create({',
121
+ ` await this.filesQuotasService.assertUploadAllowed({
122
+ ownerType: body.ownerType ?? 'system',
123
+ ownerId: body.ownerId ?? null,
124
+ fileSize: file.size,
125
+ });
126
+
127
+ return this.filesService.create({`,
128
+ );
129
+ }
130
+
131
+ fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
132
+ }
133
+
134
+ function patchHealthController(targetRoot) {
135
+ const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts');
136
+ if (!fs.existsSync(filePath)) {
137
+ return;
138
+ }
139
+
140
+ let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
141
+ content = ensureNestCommonImport(content, 'Query');
142
+ content = ensureImportLine(content, "import { FilesQuotasService } from '@forgeon/files-quotas';");
143
+
144
+ if (!content.includes('private readonly filesQuotasService: FilesQuotasService')) {
145
+ const constructorMatch = content.match(/constructor\(([\s\S]*?)\)\s*\{/m);
146
+ if (constructorMatch) {
147
+ const original = constructorMatch[0];
148
+ const inner = constructorMatch[1].trimEnd();
149
+ const normalizedInner = inner.replace(/,\s*$/, '');
150
+ const separator = normalizedInner.length > 0 ? ',' : '';
151
+ const next = `constructor(${normalizedInner}${separator}
152
+ private readonly filesQuotasService: FilesQuotasService,
153
+ ) {`;
154
+ content = content.replace(original, next);
155
+ }
156
+ }
157
+
158
+ if (!content.includes("@Get('files-quotas')")) {
159
+ const method = `
160
+ @Get('files-quotas')
161
+ async getFilesQuotasProbe(
162
+ @Query('ownerType') ownerType = 'user',
163
+ @Query('ownerId') ownerId = 'probe-owner',
164
+ @Query('size') size = '1024',
165
+ ) {
166
+ const parsedSize = Number.isFinite(Number(size)) ? Math.max(1, Number(size)) : 1024;
167
+ return this.filesQuotasService.getProbeStatus({
168
+ ownerType,
169
+ ownerId: ownerId || null,
170
+ fileSize: parsedSize,
171
+ });
172
+ }
173
+ `;
174
+ content = ensureClassMember(content, 'HealthController', method, { beforeNeedle: 'private translate(' });
175
+ }
176
+
177
+ fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
178
+ }
179
+
180
+ function patchWebApp(targetRoot) {
181
+ const filePath = path.join(targetRoot, 'apps', 'web', 'src', 'App.tsx');
182
+ if (!fs.existsSync(filePath)) {
183
+ return;
184
+ }
185
+
186
+ let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
187
+ content = content
188
+ .replace(/^\s*\{\/\* forgeon:probes:actions:start \*\/\}\r?\n?/gm, '')
189
+ .replace(/^\s*\{\/\* forgeon:probes:actions:end \*\/\}\r?\n?/gm, '')
190
+ .replace(/^\s*\{\/\* forgeon:probes:results:start \*\/\}\r?\n?/gm, '')
191
+ .replace(/^\s*\{\/\* forgeon:probes:results:end \*\/\}\r?\n?/gm, '');
192
+
193
+ if (!content.includes('filesQuotasProbeResult')) {
194
+ const stateAnchors = [
195
+ ' const [filesAccessProbeResult, setFilesAccessProbeResult] = useState<ProbeResult | null>(null);',
196
+ ' const [filesProbeResult, setFilesProbeResult] = useState<ProbeResult | null>(null);',
197
+ ' const [rbacProbeResult, setRbacProbeResult] = useState<ProbeResult | null>(null);',
198
+ ' const [rateLimitProbeResult, setRateLimitProbeResult] = useState<ProbeResult | null>(null);',
199
+ ' const [dbProbeResult, setDbProbeResult] = useState<ProbeResult | null>(null);',
200
+ ' const [authProbeResult, setAuthProbeResult] = useState<ProbeResult | null>(null);',
201
+ ' const [validationProbeResult, setValidationProbeResult] = useState<ProbeResult | null>(null);',
202
+ ];
203
+ const stateAnchor = stateAnchors.find((line) => content.includes(line));
204
+ if (stateAnchor) {
205
+ content = ensureLineAfter(
206
+ content,
207
+ stateAnchor,
208
+ ' const [filesQuotasProbeResult, setFilesQuotasProbeResult] = useState<ProbeResult | null>(null);',
209
+ );
210
+ }
211
+ }
212
+
213
+ if (!content.includes('Check files quotas')) {
214
+ const probePath = content.includes("runProbe(setHealthResult, '/health')")
215
+ ? '/health/files-quotas'
216
+ : '/api/health/files-quotas';
217
+ const button = ` <button onClick={() => runProbe(setFilesQuotasProbeResult, '${probePath}')}>
218
+ Check files quotas
219
+ </button>`;
220
+
221
+ const actionsStart = content.indexOf('<div className="actions">');
222
+ if (actionsStart >= 0) {
223
+ const actionsEnd = content.indexOf('\n </div>', actionsStart);
224
+ if (actionsEnd >= 0) {
225
+ content = `${content.slice(0, actionsEnd)}\n${button}${content.slice(actionsEnd)}`;
226
+ }
227
+ }
228
+ }
229
+
230
+ if (!content.includes("{renderResult('Files quotas probe response', filesQuotasProbeResult)}")) {
231
+ const resultLine = " {renderResult('Files quotas probe response', filesQuotasProbeResult)}";
232
+ const networkLine = ' {networkError ? <p className="error">{networkError}</p> : null}';
233
+ if (content.includes(networkLine)) {
234
+ content = content.replace(networkLine, `${resultLine}\n${networkLine}`);
235
+ } else {
236
+ const anchors = [
237
+ " {renderResult('Files access probe response', filesAccessProbeResult)}",
238
+ " {renderResult('Files probe response', filesProbeResult)}",
239
+ " {renderResult('RBAC probe response', rbacProbeResult)}",
240
+ " {renderResult('Rate limit probe response', rateLimitProbeResult)}",
241
+ " {renderResult('Auth probe response', authProbeResult)}",
242
+ " {renderResult('DB probe response', dbProbeResult)}",
243
+ " {renderResult('Validation probe response', validationProbeResult)}",
244
+ ];
245
+ const anchor = anchors.find((line) => content.includes(line));
246
+ if (anchor) {
247
+ content = ensureLineAfter(content, anchor, resultLine);
248
+ }
249
+ }
250
+ }
251
+
252
+ fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
253
+ }
254
+
255
+ function patchApiDockerfile(targetRoot) {
256
+ const dockerfilePath = path.join(targetRoot, 'apps', 'api', 'Dockerfile');
257
+ if (!fs.existsSync(dockerfilePath)) {
258
+ return;
259
+ }
260
+
261
+ let content = fs.readFileSync(dockerfilePath, 'utf8').replace(/\r\n/g, '\n');
262
+ const packageAnchors = [
263
+ 'COPY packages/files-access/package.json packages/files-access/package.json',
264
+ 'COPY packages/files/package.json packages/files/package.json',
265
+ 'COPY packages/files-local/package.json packages/files-local/package.json',
266
+ 'COPY packages/files-s3/package.json packages/files-s3/package.json',
267
+ 'COPY packages/auth-api/package.json packages/auth-api/package.json',
268
+ 'COPY packages/rbac/package.json packages/rbac/package.json',
269
+ 'COPY packages/rate-limit/package.json packages/rate-limit/package.json',
270
+ 'COPY packages/logger/package.json packages/logger/package.json',
271
+ 'COPY packages/swagger/package.json packages/swagger/package.json',
272
+ 'COPY packages/i18n/package.json packages/i18n/package.json',
273
+ 'COPY packages/db-prisma/package.json packages/db-prisma/package.json',
274
+ 'COPY packages/core/package.json packages/core/package.json',
275
+ ];
276
+ const packageAnchor = packageAnchors.find((line) => content.includes(line)) ?? packageAnchors.at(-1);
277
+ content = ensureLineAfter(
278
+ content,
279
+ packageAnchor,
280
+ 'COPY packages/files-quotas/package.json packages/files-quotas/package.json',
281
+ );
282
+
283
+ const sourceAnchors = [
284
+ 'COPY packages/files-access packages/files-access',
285
+ 'COPY packages/files packages/files',
286
+ 'COPY packages/files-local packages/files-local',
287
+ 'COPY packages/files-s3 packages/files-s3',
288
+ 'COPY packages/auth-api packages/auth-api',
289
+ 'COPY packages/rbac packages/rbac',
290
+ 'COPY packages/rate-limit packages/rate-limit',
291
+ 'COPY packages/logger packages/logger',
292
+ 'COPY packages/swagger packages/swagger',
293
+ 'COPY packages/i18n packages/i18n',
294
+ 'COPY packages/db-prisma packages/db-prisma',
295
+ 'COPY packages/core packages/core',
296
+ ];
297
+ const sourceAnchor = sourceAnchors.find((line) => content.includes(line)) ?? sourceAnchors.at(-1);
298
+ content = ensureLineAfter(content, sourceAnchor, 'COPY packages/files-quotas packages/files-quotas');
299
+
300
+ content = content.replace(/^RUN pnpm --filter @forgeon\/files-quotas build\r?\n?/gm, '');
301
+ const buildAnchor = content.includes('RUN pnpm --filter @forgeon/files build')
302
+ ? 'RUN pnpm --filter @forgeon/files build'
303
+ : content.includes('RUN pnpm --filter @forgeon/api prisma:generate')
304
+ ? 'RUN pnpm --filter @forgeon/api prisma:generate'
305
+ : 'RUN pnpm --filter @forgeon/api build';
306
+ content = ensureLineBefore(content, buildAnchor, 'RUN pnpm --filter @forgeon/files-quotas build');
307
+
308
+ fs.writeFileSync(dockerfilePath, `${content.trimEnd()}\n`, 'utf8');
309
+ }
310
+
311
+ function patchCompose(targetRoot) {
312
+ const composePath = path.join(targetRoot, 'infra', 'docker', 'compose.yml');
313
+ if (!fs.existsSync(composePath)) {
314
+ return;
315
+ }
316
+
317
+ let content = fs.readFileSync(composePath, 'utf8').replace(/\r\n/g, '\n');
318
+ if (!content.includes('FILES_QUOTAS_ENABLED: ${FILES_QUOTAS_ENABLED}')) {
319
+ const anchors = [
320
+ /^(\s+FILES_ALLOWED_MIME_PREFIXES:.*)$/m,
321
+ /^(\s+FILES_MAX_FILE_SIZE_BYTES:.*)$/m,
322
+ /^(\s+FILES_PUBLIC_BASE_PATH:.*)$/m,
323
+ /^(\s+FILES_STORAGE_DRIVER:.*)$/m,
324
+ /^(\s+FILES_ENABLED:.*)$/m,
325
+ /^(\s+API_PREFIX:.*)$/m,
326
+ ];
327
+ const anchorPattern = anchors.find((pattern) => pattern.test(content)) ?? anchors.at(-1);
328
+ content = content.replace(
329
+ anchorPattern,
330
+ `$1
331
+ FILES_QUOTAS_ENABLED: \${FILES_QUOTAS_ENABLED}
332
+ FILES_QUOTA_MAX_FILES_PER_OWNER: \${FILES_QUOTA_MAX_FILES_PER_OWNER}
333
+ FILES_QUOTA_MAX_BYTES_PER_OWNER: \${FILES_QUOTA_MAX_BYTES_PER_OWNER}`,
334
+ );
335
+ }
336
+
337
+ fs.writeFileSync(composePath, `${content.trimEnd()}\n`, 'utf8');
338
+ }
339
+
340
+ function patchReadme(targetRoot) {
341
+ const readmePath = path.join(targetRoot, 'README.md');
342
+ if (!fs.existsSync(readmePath)) {
343
+ return;
344
+ }
345
+
346
+ const marker = '## Files Quotas Module';
347
+ let content = fs.readFileSync(readmePath, 'utf8').replace(/\r\n/g, '\n');
348
+ if (content.includes(marker)) {
349
+ return;
350
+ }
351
+
352
+ const section = `## Files Quotas Module
353
+
354
+ The files-quotas module adds owner-based limits for file upload attempts.
355
+
356
+ What it adds:
357
+ - \`@forgeon/files-quotas\` package
358
+ - upload pre-check in files controller
359
+ - probe endpoint: \`GET /api/health/files-quotas\`
360
+
361
+ Current quota model:
362
+ - max files per owner
363
+ - max total bytes per owner
364
+ - owner identity from file payload (\`ownerType\`, \`ownerId\`)
365
+
366
+ Key env:
367
+ - \`FILES_QUOTAS_ENABLED=true\`
368
+ - \`FILES_QUOTA_MAX_FILES_PER_OWNER=100\`
369
+ - \`FILES_QUOTA_MAX_BYTES_PER_OWNER=104857600\``;
370
+
371
+ if (content.includes('## Prisma In Docker Start')) {
372
+ content = content.replace('## Prisma In Docker Start', `${section}\n\n## Prisma In Docker Start`);
373
+ } else {
374
+ content = `${content.trimEnd()}\n\n${section}\n`;
375
+ }
376
+
377
+ fs.writeFileSync(readmePath, `${content.trimEnd()}\n`, 'utf8');
378
+ }
379
+
380
+ export function applyFilesQuotasModule({ packageRoot, targetRoot }) {
381
+ copyFromPreset(packageRoot, targetRoot, path.join('packages', 'files-quotas'));
382
+ patchApiPackage(targetRoot);
383
+ patchFilesPackage(targetRoot);
384
+ patchAppModule(targetRoot);
385
+ patchFilesController(targetRoot);
386
+ patchHealthController(targetRoot);
387
+ patchWebApp(targetRoot);
388
+ patchApiDockerfile(targetRoot);
389
+ patchCompose(targetRoot);
390
+ patchReadme(targetRoot);
391
+
392
+ upsertEnvLines(path.join(targetRoot, 'apps', 'api', '.env.example'), [
393
+ 'FILES_QUOTAS_ENABLED=true',
394
+ 'FILES_QUOTA_MAX_FILES_PER_OWNER=100',
395
+ 'FILES_QUOTA_MAX_BYTES_PER_OWNER=104857600',
396
+ ]);
397
+ upsertEnvLines(path.join(targetRoot, 'infra', 'docker', '.env.example'), [
398
+ 'FILES_QUOTAS_ENABLED=true',
399
+ 'FILES_QUOTA_MAX_FILES_PER_OWNER=100',
400
+ 'FILES_QUOTA_MAX_BYTES_PER_OWNER=104857600',
401
+ ]);
402
+ }