create-forgeon 0.3.11 → 0.3.14

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.
@@ -1,410 +1,412 @@
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
- ensureValidatorSchema,
13
- upsertEnvLines,
14
- } from './shared/patch-utils.mjs';
15
-
16
- function copyFromPreset(packageRoot, targetRoot, relativePath) {
17
- const source = path.join(packageRoot, 'templates', 'module-presets', 'queue', relativePath);
18
- if (!fs.existsSync(source)) {
19
- throw new Error(`Missing queue preset template: ${source}`);
20
- }
21
- const destination = path.join(targetRoot, relativePath);
22
- copyRecursive(source, destination);
23
- }
24
-
25
- function patchApiPackage(targetRoot) {
26
- const packagePath = path.join(targetRoot, 'apps', 'api', 'package.json');
27
- if (!fs.existsSync(packagePath)) {
28
- return;
29
- }
30
-
31
- const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
32
- ensureDependency(packageJson, '@forgeon/queue', 'workspace:*');
33
- ensureBuildSteps(packageJson, 'predev', ['pnpm --filter @forgeon/queue build']);
34
- writeJson(packagePath, packageJson);
35
- }
36
-
37
- function patchAppModule(targetRoot) {
38
- const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'app.module.ts');
39
- if (!fs.existsSync(filePath)) {
40
- return;
41
- }
42
-
43
- let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
44
- content = ensureImportLine(
45
- content,
46
- "import { ForgeonQueueModule, queueConfig, queueEnvSchema } from '@forgeon/queue';",
47
- );
48
- content = ensureLoadItem(content, 'queueConfig');
49
- content = ensureValidatorSchema(content, 'queueEnvSchema');
50
-
51
- if (!content.includes(' ForgeonQueueModule,')) {
52
- if (content.includes(' ForgeonI18nModule.register({')) {
53
- content = ensureLineBefore(content, ' ForgeonI18nModule.register({', ' ForgeonQueueModule,');
54
- } else if (content.includes(' ForgeonAuthModule.register({')) {
55
- content = ensureLineBefore(content, ' ForgeonAuthModule.register({', ' ForgeonQueueModule,');
56
- } else if (content.includes(' ForgeonAuthModule.register(),')) {
57
- content = ensureLineBefore(content, ' ForgeonAuthModule.register(),', ' ForgeonQueueModule,');
58
- } else if (content.includes(' DbPrismaModule,')) {
59
- content = ensureLineAfter(content, ' DbPrismaModule,', ' ForgeonQueueModule,');
60
- } else if (content.includes(' ForgeonLoggerModule,')) {
61
- content = ensureLineAfter(content, ' ForgeonLoggerModule,', ' ForgeonQueueModule,');
62
- } else if (content.includes(' ForgeonSwaggerModule,')) {
63
- content = ensureLineAfter(content, ' ForgeonSwaggerModule,', ' ForgeonQueueModule,');
64
- } else {
65
- content = ensureLineAfter(content, ' CoreErrorsModule,', ' ForgeonQueueModule,');
66
- }
67
- }
68
-
69
- fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
70
- }
71
-
72
- function patchHealthController(targetRoot) {
73
- const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts');
74
- if (!fs.existsSync(filePath)) {
75
- return;
76
- }
77
-
78
- let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
79
- content = ensureImportLine(content, "import { QueueService } from '@forgeon/queue';");
80
-
81
- if (!content.includes('private readonly queueService: QueueService')) {
82
- const constructorMatch = content.match(/constructor\(([\s\S]*?)\)\s*\{/m);
83
- if (constructorMatch) {
84
- const original = constructorMatch[0];
85
- const inner = constructorMatch[1].trimEnd();
86
- const normalizedInner = inner.replace(/,\s*$/, '');
87
- const separator = normalizedInner.length > 0 ? ',' : '';
88
- const next = `constructor(${normalizedInner}${separator}
89
- private readonly queueService: QueueService,
90
- ) {`;
91
- content = content.replace(original, next);
92
- } else {
93
- const classAnchor = 'export class HealthController {';
94
- if (content.includes(classAnchor)) {
95
- content = content.replace(
96
- classAnchor,
97
- `${classAnchor}
98
- constructor(private readonly queueService: QueueService) {}
99
- `,
100
- );
101
- }
102
- }
103
- }
104
-
105
- if (!content.includes("@Get('queue')")) {
106
- const method = `
107
- @Get('queue')
108
- async getQueueProbe() {
109
- return this.queueService.getProbeStatus();
110
- }
111
- `;
112
- content = ensureClassMember(content, 'HealthController', method, { beforeNeedle: 'private translate(' });
113
- }
114
-
115
- fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
116
- }
117
-
118
- function patchWebApp(targetRoot) {
119
- const filePath = path.join(targetRoot, 'apps', 'web', 'src', 'App.tsx');
120
- if (!fs.existsSync(filePath)) {
121
- return;
122
- }
123
-
124
- let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
125
- content = content
126
- .replace(/^\s*\{\/\* forgeon:probes:actions:start \*\/\}\r?\n?/gm, '')
127
- .replace(/^\s*\{\/\* forgeon:probes:actions:end \*\/\}\r?\n?/gm, '')
128
- .replace(/^\s*\{\/\* forgeon:probes:results:start \*\/\}\r?\n?/gm, '')
129
- .replace(/^\s*\{\/\* forgeon:probes:results:end \*\/\}\r?\n?/gm, '');
130
-
131
- if (!content.includes('queueProbeResult')) {
132
- const stateAnchors = [
133
- ' const [filesImageProbeResult, setFilesImageProbeResult] = useState<ProbeResult | null>(null);',
134
- ' const [filesQuotasProbeResult, setFilesQuotasProbeResult] = useState<ProbeResult | null>(null);',
135
- ' const [filesAccessProbeResult, setFilesAccessProbeResult] = useState<ProbeResult | null>(null);',
136
- ' const [filesVariantsProbeResult, setFilesVariantsProbeResult] = useState<ProbeResult | null>(null);',
137
- ' const [filesProbeResult, setFilesProbeResult] = useState<ProbeResult | null>(null);',
138
- ' const [rbacProbeResult, setRbacProbeResult] = useState<ProbeResult | null>(null);',
139
- ' const [rateLimitProbeResult, setRateLimitProbeResult] = useState<ProbeResult | null>(null);',
140
- ' const [authProbeResult, setAuthProbeResult] = useState<ProbeResult | null>(null);',
141
- ' const [dbProbeResult, setDbProbeResult] = useState<ProbeResult | null>(null);',
142
- ' const [validationProbeResult, setValidationProbeResult] = useState<ProbeResult | null>(null);',
143
- ];
144
- const stateAnchor = stateAnchors.find((line) => content.includes(line));
145
- if (stateAnchor) {
146
- content = ensureLineAfter(
147
- content,
148
- stateAnchor,
149
- ' const [queueProbeResult, setQueueProbeResult] = useState<ProbeResult | null>(null);',
150
- );
151
- }
152
- }
153
-
154
- if (!content.includes('Check queue health')) {
155
- const probePath = content.includes("runProbe(setHealthResult, '/health')")
156
- ? '/health/queue'
157
- : '/api/health/queue';
158
- const button = ` <button onClick={() => runProbe(setQueueProbeResult, '${probePath}')}>\n Check queue health\n </button>`;
159
-
160
- const actionsStart = content.indexOf('<div className="actions">');
161
- if (actionsStart >= 0) {
162
- const actionsEnd = content.indexOf('\n </div>', actionsStart);
163
- if (actionsEnd >= 0) {
164
- content = `${content.slice(0, actionsEnd)}\n${button}${content.slice(actionsEnd)}`;
165
- }
166
- }
167
- }
168
-
169
- if (!content.includes("{renderResult('Queue probe response', queueProbeResult)}")) {
170
- const resultLine = " {renderResult('Queue probe response', queueProbeResult)}";
171
- const networkLine = ' {networkError ? <p className="error">{networkError}</p> : null}';
172
- if (content.includes(networkLine)) {
173
- content = content.replace(networkLine, `${resultLine}\n${networkLine}`);
174
- } else {
175
- const anchors = [
176
- "{renderResult('Files image probe response', filesImageProbeResult)}",
177
- "{renderResult('Files quotas probe response', filesQuotasProbeResult)}",
178
- "{renderResult('Files access probe response', filesAccessProbeResult)}",
179
- "{renderResult('Files variants probe response', filesVariantsProbeResult)}",
180
- "{renderResult('Files probe response', filesProbeResult)}",
181
- "{renderResult('RBAC probe response', rbacProbeResult)}",
182
- "{renderResult('Rate limit probe response', rateLimitProbeResult)}",
183
- "{renderResult('Auth probe response', authProbeResult)}",
184
- "{renderResult('DB probe response', dbProbeResult)}",
185
- "{renderResult('Validation probe response', validationProbeResult)}",
186
- ];
187
- const anchor = anchors.find((line) => content.includes(line));
188
- if (anchor) {
189
- content = ensureLineAfter(content, anchor, resultLine);
190
- }
191
- }
192
- }
193
-
194
- fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
195
- }
196
-
197
- function patchApiDockerfile(targetRoot) {
198
- const dockerfilePath = path.join(targetRoot, 'apps', 'api', 'Dockerfile');
199
- if (!fs.existsSync(dockerfilePath)) {
200
- return;
201
- }
202
-
203
- let content = fs.readFileSync(dockerfilePath, 'utf8').replace(/\r\n/g, '\n');
204
- const packageAnchors = [
205
- 'COPY packages/files-image/package.json packages/files-image/package.json',
206
- 'COPY packages/files-quotas/package.json packages/files-quotas/package.json',
207
- 'COPY packages/files-access/package.json packages/files-access/package.json',
208
- 'COPY packages/files-s3/package.json packages/files-s3/package.json',
209
- 'COPY packages/files-local/package.json packages/files-local/package.json',
210
- 'COPY packages/files/package.json packages/files/package.json',
211
- 'COPY packages/auth-api/package.json packages/auth-api/package.json',
212
- 'COPY packages/rbac/package.json packages/rbac/package.json',
213
- 'COPY packages/rate-limit/package.json packages/rate-limit/package.json',
214
- 'COPY packages/logger/package.json packages/logger/package.json',
215
- 'COPY packages/swagger/package.json packages/swagger/package.json',
216
- 'COPY packages/i18n/package.json packages/i18n/package.json',
217
- 'COPY packages/db-prisma/package.json packages/db-prisma/package.json',
218
- 'COPY packages/core/package.json packages/core/package.json',
219
- ];
220
- const packageAnchor = packageAnchors.find((line) => content.includes(line)) ?? packageAnchors.at(-1);
221
- content = ensureLineAfter(content, packageAnchor, 'COPY packages/queue/package.json packages/queue/package.json');
222
-
223
- const sourceAnchors = [
224
- 'COPY packages/files-image packages/files-image',
225
- 'COPY packages/files-quotas packages/files-quotas',
226
- 'COPY packages/files-access packages/files-access',
227
- 'COPY packages/files-s3 packages/files-s3',
228
- 'COPY packages/files-local packages/files-local',
229
- 'COPY packages/files packages/files',
230
- 'COPY packages/auth-api packages/auth-api',
231
- 'COPY packages/rbac packages/rbac',
232
- 'COPY packages/rate-limit packages/rate-limit',
233
- 'COPY packages/logger packages/logger',
234
- 'COPY packages/swagger packages/swagger',
235
- 'COPY packages/i18n packages/i18n',
236
- 'COPY packages/db-prisma packages/db-prisma',
237
- 'COPY packages/core packages/core',
238
- ];
239
- const sourceAnchor = sourceAnchors.find((line) => content.includes(line)) ?? sourceAnchors.at(-1);
240
- content = ensureLineAfter(content, sourceAnchor, 'COPY packages/queue packages/queue');
241
-
242
- content = content.replace(/^RUN pnpm --filter @forgeon\/queue build\r?\n?/gm, '');
243
- const buildAnchor = content.includes('RUN pnpm --filter @forgeon/api prisma:generate')
244
- ? 'RUN pnpm --filter @forgeon/api prisma:generate'
245
- : 'RUN pnpm --filter @forgeon/api build';
246
- content = ensureLineBefore(content, buildAnchor, 'RUN pnpm --filter @forgeon/queue build');
247
-
248
- fs.writeFileSync(dockerfilePath, `${content.trimEnd()}\n`, 'utf8');
249
- }
250
-
251
- function patchCompose(targetRoot) {
252
- const composePath = path.join(targetRoot, 'infra', 'docker', 'compose.yml');
253
- if (!fs.existsSync(composePath)) {
254
- return;
255
- }
256
-
257
- let content = fs.readFileSync(composePath, 'utf8').replace(/\r\n/g, '\n');
258
-
259
- const redisServiceBlock = ` redis:
260
- image: redis:7-alpine
261
- restart: unless-stopped
262
- command: ["redis-server", "--save", "", "--appendonly", "no"]
263
- ports:
264
- - "6379:6379"
265
- healthcheck:
266
- test: ["CMD", "redis-cli", "ping"]
267
- interval: 10s
268
- timeout: 5s
269
- retries: 10`;
270
-
271
- if (!/\n\s{2}redis:\n/.test(content)) {
272
- content = content.replace(/^services:\n/m, `services:\n${redisServiceBlock}\n\n`);
273
- }
274
-
275
- if (!content.includes('QUEUE_ENABLED: ${QUEUE_ENABLED}')) {
276
- const anchors = [
277
- /^(\s+FILES_IMAGE_STRIP_METADATA:.*)$/m,
278
- /^(\s+FILES_QUOTA_MAX_BYTES_PER_OWNER:.*)$/m,
279
- /^(\s+FILES_ACCESS_DEFAULT_VISIBILITY:.*)$/m,
280
- /^(\s+FILES_S3_MAX_ATTEMPTS:.*)$/m,
281
- /^(\s+FILES_LOCAL_ROOT:.*)$/m,
282
- /^(\s+FILES_PUBLIC_BASE_PATH:.*)$/m,
283
- /^(\s+AUTH_DEMO_PASSWORD:.*)$/m,
284
- /^(\s+THROTTLE_TRUST_PROXY:.*)$/m,
285
- /^(\s+LOGGER_REQUEST_ID_HEADER:.*)$/m,
286
- /^(\s+SWAGGER_DOCS_PATH:.*)$/m,
287
- /^(\s+I18N_FALLBACK_LANG:.*)$/m,
288
- /^(\s+DATABASE_URL:.*)$/m,
289
- /^(\s+API_PREFIX:.*)$/m,
290
- ];
291
- const anchorPattern = anchors.find((pattern) => pattern.test(content)) ?? anchors.at(-1);
292
- content = content.replace(
293
- anchorPattern,
294
- `$1
295
- QUEUE_ENABLED: \${QUEUE_ENABLED}
296
- QUEUE_REDIS_URL: \${QUEUE_REDIS_URL}
297
- QUEUE_PREFIX: \${QUEUE_PREFIX}
298
- QUEUE_DEFAULT_ATTEMPTS: \${QUEUE_DEFAULT_ATTEMPTS}
299
- QUEUE_DEFAULT_BACKOFF_MS: \${QUEUE_DEFAULT_BACKOFF_MS}`,
300
- );
301
- }
302
-
303
- const apiBlockMatch = content.match(/^ api:\n[\s\S]*?(?=^ [a-zA-Z0-9_-]+:\n|^volumes:\n|$)/m);
304
- if (apiBlockMatch) {
305
- let apiBlock = apiBlockMatch[0];
306
- if (!/^\s{6}redis:\s*$/m.test(apiBlock) && !/^\s{6}-\s*redis\s*$/m.test(apiBlock)) {
307
- if (/^\s{4}depends_on:\s*$/m.test(apiBlock)) {
308
- if (/^\s{6}-\s+/m.test(apiBlock)) {
309
- apiBlock = apiBlock.replace(
310
- /^(\s{4}depends_on:\n(?:\s{6}-\s+.+\n)+)/m,
311
- `$1 - redis
312
- `,
313
- );
314
- } else {
315
- apiBlock = apiBlock.replace(
316
- /^(\s{4}depends_on:\n)/m,
317
- `$1 redis:
318
- condition: service_healthy
319
- `,
320
- );
321
- }
322
- } else {
323
- const withDependsOn = apiBlock.replace(
324
- /^(\s{4}environment:\n(?:\s{6}.+\n)+)/m,
325
- `$1 depends_on:
326
- redis:
327
- condition: service_healthy
328
- `,
329
- );
330
- apiBlock =
331
- withDependsOn === apiBlock
332
- ? `${apiBlock.trimEnd()}\n depends_on:\n redis:\n condition: service_healthy\n`
333
- : withDependsOn;
334
- }
335
- }
336
- content = content.replace(apiBlockMatch[0], apiBlock);
337
- }
338
-
339
- fs.writeFileSync(composePath, `${content.trimEnd()}\n`, 'utf8');
340
- }
341
-
342
- function patchReadme(targetRoot) {
343
- const readmePath = path.join(targetRoot, 'README.md');
344
- if (!fs.existsSync(readmePath)) {
345
- return;
346
- }
347
-
348
- const marker = '## Queue Module';
349
- let content = fs.readFileSync(readmePath, 'utf8').replace(/\r\n/g, '\n');
350
- if (content.includes(marker)) {
351
- return;
352
- }
353
-
354
- const section = `## Queue Module
355
-
356
- The queue add-module provides an async job runtime baseline backed by Redis.
357
-
358
- What it adds:
359
- - \`@forgeon/queue\` package
360
- - typed queue env config (module-owned)
361
- - queue probe endpoint: \`GET /api/health/queue\`
362
- - web probe button for quick runtime verification
363
- - Redis service wiring in Docker Compose
364
-
365
- Configuration (env):
366
- - \`QUEUE_ENABLED=true\`
367
- - \`QUEUE_REDIS_URL=redis://localhost:6379\`
368
- - \`QUEUE_PREFIX=forgeon\`
369
- - \`QUEUE_DEFAULT_ATTEMPTS=3\`
370
- - \`QUEUE_DEFAULT_BACKOFF_MS=1000\`
371
-
372
- Operational notes:
373
- - this stage is the queue foundation (runtime + connectivity)
374
- - worker/cron orchestration is intentionally deferred to later modules`;
375
-
376
- if (content.includes('## Prisma In Docker Start')) {
377
- content = content.replace('## Prisma In Docker Start', `${section}\n\n## Prisma In Docker Start`);
378
- } else {
379
- content = `${content.trimEnd()}\n\n${section}\n`;
380
- }
381
-
382
- fs.writeFileSync(readmePath, `${content.trimEnd()}\n`, 'utf8');
383
- }
384
-
385
- export function applyQueueModule({ packageRoot, targetRoot }) {
386
- copyFromPreset(packageRoot, targetRoot, path.join('packages', 'queue'));
387
-
388
- patchApiPackage(targetRoot);
389
- patchAppModule(targetRoot);
390
- patchHealthController(targetRoot);
391
- patchWebApp(targetRoot);
392
- patchApiDockerfile(targetRoot);
393
- patchCompose(targetRoot);
394
- patchReadme(targetRoot);
395
-
396
- upsertEnvLines(path.join(targetRoot, 'apps', 'api', '.env.example'), [
397
- 'QUEUE_ENABLED=true',
398
- 'QUEUE_REDIS_URL=redis://localhost:6379',
399
- 'QUEUE_PREFIX=forgeon',
400
- 'QUEUE_DEFAULT_ATTEMPTS=3',
401
- 'QUEUE_DEFAULT_BACKOFF_MS=1000',
402
- ]);
403
- upsertEnvLines(path.join(targetRoot, 'infra', 'docker', '.env.example'), [
404
- 'QUEUE_ENABLED=true',
405
- 'QUEUE_REDIS_URL=redis://redis:6379',
406
- 'QUEUE_PREFIX=forgeon',
407
- 'QUEUE_DEFAULT_ATTEMPTS=3',
408
- 'QUEUE_DEFAULT_BACKOFF_MS=1000',
409
- ]);
410
- }
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
+ ensureValidatorSchema,
13
+ upsertEnvLines,
14
+ } from './shared/patch-utils.mjs';
15
+
16
+ function copyFromPreset(packageRoot, targetRoot, relativePath) {
17
+ const source = path.join(packageRoot, 'templates', 'module-presets', 'queue', relativePath);
18
+ if (!fs.existsSync(source)) {
19
+ throw new Error(`Missing queue preset template: ${source}`);
20
+ }
21
+ const destination = path.join(targetRoot, relativePath);
22
+ copyRecursive(source, destination);
23
+ }
24
+
25
+ function patchApiPackage(targetRoot) {
26
+ const packagePath = path.join(targetRoot, 'apps', 'api', 'package.json');
27
+ if (!fs.existsSync(packagePath)) {
28
+ return;
29
+ }
30
+
31
+ const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
32
+ ensureDependency(packageJson, '@forgeon/queue', 'workspace:*');
33
+ ensureBuildSteps(packageJson, 'predev', ['pnpm --filter @forgeon/queue build']);
34
+ writeJson(packagePath, packageJson);
35
+ }
36
+
37
+ function patchAppModule(targetRoot) {
38
+ const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'app.module.ts');
39
+ if (!fs.existsSync(filePath)) {
40
+ return;
41
+ }
42
+
43
+ let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
44
+ content = ensureImportLine(
45
+ content,
46
+ "import { ForgeonQueueModule, queueConfig, queueEnvSchema } from '@forgeon/queue';",
47
+ );
48
+ content = ensureLoadItem(content, 'queueConfig');
49
+ content = ensureValidatorSchema(content, 'queueEnvSchema');
50
+
51
+ if (!content.includes(' ForgeonQueueModule,')) {
52
+ if (content.includes(' ForgeonI18nModule.register({')) {
53
+ content = ensureLineBefore(content, ' ForgeonI18nModule.register({', ' ForgeonQueueModule,');
54
+ } else if (content.includes(' ForgeonAuthModule.register({')) {
55
+ content = ensureLineBefore(content, ' ForgeonAuthModule.register({', ' ForgeonQueueModule,');
56
+ } else if (content.includes(' ForgeonAuthModule.register(),')) {
57
+ content = ensureLineBefore(content, ' ForgeonAuthModule.register(),', ' ForgeonQueueModule,');
58
+ } else if (content.includes(' DbPrismaModule,')) {
59
+ content = ensureLineAfter(content, ' DbPrismaModule,', ' ForgeonQueueModule,');
60
+ } else if (content.includes(' ForgeonLoggerModule,')) {
61
+ content = ensureLineAfter(content, ' ForgeonLoggerModule,', ' ForgeonQueueModule,');
62
+ } else if (content.includes(' ForgeonSwaggerModule,')) {
63
+ content = ensureLineAfter(content, ' ForgeonSwaggerModule,', ' ForgeonQueueModule,');
64
+ } else {
65
+ content = ensureLineAfter(content, ' CoreErrorsModule,', ' ForgeonQueueModule,');
66
+ }
67
+ }
68
+
69
+ fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
70
+ }
71
+
72
+ function patchHealthController(targetRoot) {
73
+ const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts');
74
+ if (!fs.existsSync(filePath)) {
75
+ return;
76
+ }
77
+
78
+ let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
79
+ content = ensureImportLine(content, "import { QueueService } from '@forgeon/queue';");
80
+
81
+ if (!content.includes('private readonly queueService: QueueService')) {
82
+ const constructorMatch = content.match(/constructor\(([\s\S]*?)\)\s*\{/m);
83
+ if (constructorMatch) {
84
+ const original = constructorMatch[0];
85
+ const inner = constructorMatch[1].trimEnd();
86
+ const normalizedInner = inner.replace(/,\s*$/, '');
87
+ const separator = normalizedInner.length > 0 ? ',' : '';
88
+ const next = `constructor(${normalizedInner}${separator}
89
+ private readonly queueService: QueueService,
90
+ ) {`;
91
+ content = content.replace(original, next);
92
+ } else {
93
+ const classAnchor = 'export class HealthController {';
94
+ if (content.includes(classAnchor)) {
95
+ content = content.replace(
96
+ classAnchor,
97
+ `${classAnchor}
98
+ constructor(private readonly queueService: QueueService) {}
99
+ `,
100
+ );
101
+ }
102
+ }
103
+ }
104
+
105
+ if (!content.includes("@Get('queue')")) {
106
+ const method = `
107
+ @Get('queue')
108
+ async getQueueProbe() {
109
+ return this.queueService.getProbeStatus();
110
+ }
111
+ `;
112
+ content = ensureClassMember(content, 'HealthController', method, { beforeNeedle: 'private translate(' });
113
+ }
114
+
115
+ fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
116
+ }
117
+
118
+ function patchWebApp(targetRoot) {
119
+ const filePath = path.join(targetRoot, 'apps', 'web', 'src', 'App.tsx');
120
+ if (!fs.existsSync(filePath)) {
121
+ return;
122
+ }
123
+
124
+ let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
125
+ content = content
126
+ .replace(/^\s*\{\/\* forgeon:probes:actions:start \*\/\}\r?\n?/gm, '')
127
+ .replace(/^\s*\{\/\* forgeon:probes:actions:end \*\/\}\r?\n?/gm, '')
128
+ .replace(/^\s*\{\/\* forgeon:probes:results:start \*\/\}\r?\n?/gm, '')
129
+ .replace(/^\s*\{\/\* forgeon:probes:results:end \*\/\}\r?\n?/gm, '');
130
+
131
+ if (!content.includes('queueProbeResult')) {
132
+ const stateAnchors = [
133
+ ' const [filesImageProbeResult, setFilesImageProbeResult] = useState<ProbeResult | null>(null);',
134
+ ' const [filesQuotasProbeResult, setFilesQuotasProbeResult] = useState<ProbeResult | null>(null);',
135
+ ' const [filesAccessProbeResult, setFilesAccessProbeResult] = useState<ProbeResult | null>(null);',
136
+ ' const [filesVariantsProbeResult, setFilesVariantsProbeResult] = useState<ProbeResult | null>(null);',
137
+ ' const [filesProbeResult, setFilesProbeResult] = useState<ProbeResult | null>(null);',
138
+ ' const [rbacProbeResult, setRbacProbeResult] = useState<ProbeResult | null>(null);',
139
+ ' const [rateLimitProbeResult, setRateLimitProbeResult] = useState<ProbeResult | null>(null);',
140
+ ' const [authProbeResult, setAuthProbeResult] = useState<ProbeResult | null>(null);',
141
+ ' const [dbProbeResult, setDbProbeResult] = useState<ProbeResult | null>(null);',
142
+ ' const [validationProbeResult, setValidationProbeResult] = useState<ProbeResult | null>(null);',
143
+ ];
144
+ const stateAnchor = stateAnchors.find((line) => content.includes(line));
145
+ if (stateAnchor) {
146
+ content = ensureLineAfter(
147
+ content,
148
+ stateAnchor,
149
+ ' const [queueProbeResult, setQueueProbeResult] = useState<ProbeResult | null>(null);',
150
+ );
151
+ }
152
+ }
153
+
154
+ if (!content.includes('Check queue health')) {
155
+ const probePath = content.includes("runProbe(setHealthResult, '/health')")
156
+ ? '/health/queue'
157
+ : '/api/health/queue';
158
+ const button = ` <button onClick={() => runProbe(setQueueProbeResult, '${probePath}')}>\n Check queue health\n </button>`;
159
+
160
+ const actionsStart = content.indexOf('<div className="actions">');
161
+ if (actionsStart >= 0) {
162
+ const actionsEnd = content.indexOf('\n </div>', actionsStart);
163
+ if (actionsEnd >= 0) {
164
+ content = `${content.slice(0, actionsEnd)}\n${button}${content.slice(actionsEnd)}`;
165
+ }
166
+ }
167
+ }
168
+
169
+ if (!content.includes("{renderResult('Queue probe response', queueProbeResult)}")) {
170
+ const resultLine = " {renderResult('Queue probe response', queueProbeResult)}";
171
+ const networkLine = ' {networkError ? <p className="error">{networkError}</p> : null}';
172
+ if (content.includes(networkLine)) {
173
+ content = content.replace(networkLine, `${resultLine}\n${networkLine}`);
174
+ } else {
175
+ const anchors = [
176
+ "{renderResult('Files image probe response', filesImageProbeResult)}",
177
+ "{renderResult('Files quotas probe response', filesQuotasProbeResult)}",
178
+ "{renderResult('Files access probe response', filesAccessProbeResult)}",
179
+ "{renderResult('Files variants probe response', filesVariantsProbeResult)}",
180
+ "{renderResult('Files probe response', filesProbeResult)}",
181
+ "{renderResult('RBAC probe response', rbacProbeResult)}",
182
+ "{renderResult('Rate limit probe response', rateLimitProbeResult)}",
183
+ "{renderResult('Auth probe response', authProbeResult)}",
184
+ "{renderResult('DB probe response', dbProbeResult)}",
185
+ "{renderResult('Validation probe response', validationProbeResult)}",
186
+ ];
187
+ const anchor = anchors.find((line) => content.includes(line));
188
+ if (anchor) {
189
+ content = ensureLineAfter(content, anchor, resultLine);
190
+ }
191
+ }
192
+ }
193
+
194
+ fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
195
+ }
196
+
197
+ function patchApiDockerfile(targetRoot) {
198
+ const dockerfilePath = path.join(targetRoot, 'apps', 'api', 'Dockerfile');
199
+ if (!fs.existsSync(dockerfilePath)) {
200
+ return;
201
+ }
202
+
203
+ let content = fs.readFileSync(dockerfilePath, 'utf8').replace(/\r\n/g, '\n');
204
+ const packageAnchors = [
205
+ 'COPY packages/files-image/package.json packages/files-image/package.json',
206
+ 'COPY packages/files-quotas/package.json packages/files-quotas/package.json',
207
+ 'COPY packages/files-access/package.json packages/files-access/package.json',
208
+ 'COPY packages/files-s3/package.json packages/files-s3/package.json',
209
+ 'COPY packages/files-local/package.json packages/files-local/package.json',
210
+ 'COPY packages/files/package.json packages/files/package.json',
211
+ 'COPY packages/auth-api/package.json packages/auth-api/package.json',
212
+ 'COPY packages/rbac/package.json packages/rbac/package.json',
213
+ 'COPY packages/rate-limit/package.json packages/rate-limit/package.json',
214
+ 'COPY packages/logger/package.json packages/logger/package.json',
215
+ 'COPY packages/swagger/package.json packages/swagger/package.json',
216
+ 'COPY packages/i18n/package.json packages/i18n/package.json',
217
+ 'COPY packages/db-prisma/package.json packages/db-prisma/package.json',
218
+ 'COPY packages/core/package.json packages/core/package.json',
219
+ ];
220
+ const packageAnchor = packageAnchors.find((line) => content.includes(line)) ?? packageAnchors.at(-1);
221
+ content = ensureLineAfter(content, packageAnchor, 'COPY packages/queue/package.json packages/queue/package.json');
222
+
223
+ const sourceAnchors = [
224
+ 'COPY packages/files-image packages/files-image',
225
+ 'COPY packages/files-quotas packages/files-quotas',
226
+ 'COPY packages/files-access packages/files-access',
227
+ 'COPY packages/files-s3 packages/files-s3',
228
+ 'COPY packages/files-local packages/files-local',
229
+ 'COPY packages/files packages/files',
230
+ 'COPY packages/auth-api packages/auth-api',
231
+ 'COPY packages/rbac packages/rbac',
232
+ 'COPY packages/rate-limit packages/rate-limit',
233
+ 'COPY packages/logger packages/logger',
234
+ 'COPY packages/swagger packages/swagger',
235
+ 'COPY packages/i18n packages/i18n',
236
+ 'COPY packages/db-prisma packages/db-prisma',
237
+ 'COPY packages/core packages/core',
238
+ ];
239
+ const sourceAnchor = sourceAnchors.find((line) => content.includes(line)) ?? sourceAnchors.at(-1);
240
+ content = ensureLineAfter(content, sourceAnchor, 'COPY packages/queue packages/queue');
241
+
242
+ content = content.replace(/^RUN pnpm --filter @forgeon\/queue build\r?\n?/gm, '');
243
+ const buildAnchor = content.includes('RUN pnpm --filter @forgeon/api prisma:generate')
244
+ ? 'RUN pnpm --filter @forgeon/api prisma:generate'
245
+ : 'RUN pnpm --filter @forgeon/api build';
246
+ content = ensureLineBefore(content, buildAnchor, 'RUN pnpm --filter @forgeon/queue build');
247
+
248
+ fs.writeFileSync(dockerfilePath, `${content.trimEnd()}\n`, 'utf8');
249
+ }
250
+
251
+ function patchCompose(targetRoot) {
252
+ const composePath = path.join(targetRoot, 'infra', 'docker', 'compose.yml');
253
+ if (!fs.existsSync(composePath)) {
254
+ return;
255
+ }
256
+
257
+ let content = fs.readFileSync(composePath, 'utf8').replace(/\r\n/g, '\n');
258
+
259
+ const redisServiceBlock = ` redis:
260
+ image: redis:7-alpine
261
+ restart: unless-stopped
262
+ command: ["redis-server", "--save", "", "--appendonly", "no"]
263
+ ports:
264
+ - "6379:6379"
265
+ healthcheck:
266
+ test: ["CMD", "redis-cli", "ping"]
267
+ interval: 10s
268
+ timeout: 5s
269
+ retries: 10`;
270
+
271
+ if (!/\n\s{2}redis:\n/.test(content)) {
272
+ content = content.replace(/^services:\n/m, `services:\n${redisServiceBlock}\n\n`);
273
+ }
274
+
275
+ if (!content.includes('QUEUE_ENABLED: ${QUEUE_ENABLED}')) {
276
+ const anchors = [
277
+ /^(\s+FILES_IMAGE_STRIP_METADATA:.*)$/m,
278
+ /^(\s+FILES_QUOTA_MAX_BYTES_PER_OWNER:.*)$/m,
279
+ /^(\s+FILES_ACCESS_DEFAULT_VISIBILITY:.*)$/m,
280
+ /^(\s+FILES_S3_MAX_ATTEMPTS:.*)$/m,
281
+ /^(\s+FILES_LOCAL_ROOT:.*)$/m,
282
+ /^(\s+FILES_PUBLIC_BASE_PATH:.*)$/m,
283
+ /^(\s+AUTH_DEMO_PASSWORD:.*)$/m,
284
+ /^(\s+THROTTLE_TRUST_PROXY:.*)$/m,
285
+ /^(\s+LOGGER_REQUEST_ID_HEADER:.*)$/m,
286
+ /^(\s+SWAGGER_DOCS_PATH:.*)$/m,
287
+ /^(\s+I18N_FALLBACK_LANG:.*)$/m,
288
+ /^(\s+DATABASE_URL:.*)$/m,
289
+ /^(\s+API_PREFIX:.*)$/m,
290
+ ];
291
+ const anchorPattern = anchors.find((pattern) => pattern.test(content)) ?? anchors.at(-1);
292
+ content = content.replace(
293
+ anchorPattern,
294
+ `$1
295
+ QUEUE_ENABLED: \${QUEUE_ENABLED}
296
+ QUEUE_REDIS_URL: \${QUEUE_REDIS_URL}
297
+ QUEUE_PREFIX: \${QUEUE_PREFIX}
298
+ QUEUE_DEFAULT_ATTEMPTS: \${QUEUE_DEFAULT_ATTEMPTS}
299
+ QUEUE_DEFAULT_BACKOFF_MS: \${QUEUE_DEFAULT_BACKOFF_MS}`,
300
+ );
301
+ }
302
+
303
+ const apiBlockMatch = content.match(/^ api:\n[\s\S]*?(?=^ [a-zA-Z0-9_-]+:\n|^volumes:\n|$)/m);
304
+ if (apiBlockMatch) {
305
+ let apiBlock = apiBlockMatch[0];
306
+ if (!/^\s{6}redis:\s*$/m.test(apiBlock) && !/^\s{6}-\s*redis\s*$/m.test(apiBlock)) {
307
+ if (/^\s{4}depends_on:\s*$/m.test(apiBlock)) {
308
+ if (/^\s{6}-\s+/m.test(apiBlock)) {
309
+ apiBlock = apiBlock.replace(
310
+ /^(\s{4}depends_on:\n(?:\s{6}-\s+.+\n)+)/m,
311
+ `$1 - redis
312
+ `,
313
+ );
314
+ } else {
315
+ apiBlock = apiBlock.replace(
316
+ /^(\s{4}depends_on:\n)/m,
317
+ `$1 redis:
318
+ condition: service_healthy
319
+ `,
320
+ );
321
+ }
322
+ } else {
323
+ const withDependsOn = apiBlock.replace(
324
+ /^(\s{4}environment:\n(?:\s{6}.+\n)+)/m,
325
+ `$1 depends_on:
326
+ redis:
327
+ condition: service_healthy
328
+ `,
329
+ );
330
+ apiBlock =
331
+ withDependsOn === apiBlock
332
+ ? `${apiBlock.trimEnd()}\n depends_on:\n redis:\n condition: service_healthy\n`
333
+ : withDependsOn;
334
+ }
335
+ }
336
+ content = content.replace(apiBlockMatch[0], apiBlock);
337
+ }
338
+
339
+ fs.writeFileSync(composePath, `${content.trimEnd()}\n`, 'utf8');
340
+ }
341
+
342
+ function patchReadme(targetRoot) {
343
+ const readmePath = path.join(targetRoot, 'README.md');
344
+ if (!fs.existsSync(readmePath)) {
345
+ return;
346
+ }
347
+
348
+ const marker = '## Queue Module';
349
+ let content = fs.readFileSync(readmePath, 'utf8').replace(/\r\n/g, '\n');
350
+ if (content.includes(marker)) {
351
+ return;
352
+ }
353
+
354
+ const section = `## Queue Module
355
+
356
+ The queue add-module provides an async job runtime baseline backed by Redis.
357
+
358
+ What it adds:
359
+ - \`@forgeon/queue\` package
360
+ - typed queue env config (module-owned)
361
+ - queue probe endpoint: \`GET /api/health/queue\`
362
+ - web probe button for quick runtime verification
363
+ - Redis service wiring in Docker Compose
364
+
365
+ Configuration (env):
366
+ - \`QUEUE_ENABLED=true\`
367
+ - \`QUEUE_REDIS_URL=redis://localhost:6379\`
368
+ - \`QUEUE_PREFIX=forgeon\`
369
+ - \`QUEUE_DEFAULT_ATTEMPTS=3\`
370
+ - \`QUEUE_DEFAULT_BACKOFF_MS=1000\`
371
+
372
+ Operational notes:
373
+ - this stage is the queue foundation (runtime + connectivity)
374
+ - cron orchestration is available through the scheduler module
375
+ - job execution workers remain intentionally deferred to a later module`;
376
+
377
+ if (content.includes('## Prisma In Docker Start')) {
378
+ content = content.replace('## Prisma In Docker Start', `${section}\n\n## Prisma In Docker Start`);
379
+ } else {
380
+ content = `${content.trimEnd()}\n\n${section}\n`;
381
+ }
382
+
383
+ fs.writeFileSync(readmePath, `${content.trimEnd()}\n`, 'utf8');
384
+ }
385
+
386
+ export function applyQueueModule({ packageRoot, targetRoot }) {
387
+ copyFromPreset(packageRoot, targetRoot, path.join('packages', 'queue'));
388
+
389
+ patchApiPackage(targetRoot);
390
+ patchAppModule(targetRoot);
391
+ patchHealthController(targetRoot);
392
+ patchWebApp(targetRoot);
393
+ patchApiDockerfile(targetRoot);
394
+ patchCompose(targetRoot);
395
+ patchReadme(targetRoot);
396
+
397
+ upsertEnvLines(path.join(targetRoot, 'apps', 'api', '.env.example'), [
398
+ 'QUEUE_ENABLED=true',
399
+ 'QUEUE_REDIS_URL=redis://localhost:6379',
400
+ 'QUEUE_PREFIX=forgeon',
401
+ 'QUEUE_DEFAULT_ATTEMPTS=3',
402
+ 'QUEUE_DEFAULT_BACKOFF_MS=1000',
403
+ ]);
404
+ upsertEnvLines(path.join(targetRoot, 'infra', 'docker', '.env.example'), [
405
+ 'QUEUE_ENABLED=true',
406
+ 'QUEUE_REDIS_URL=redis://redis:6379',
407
+ 'QUEUE_PREFIX=forgeon',
408
+ 'QUEUE_DEFAULT_ATTEMPTS=3',
409
+ 'QUEUE_DEFAULT_BACKOFF_MS=1000',
410
+ ]);
411
+ }
412
+