create-forgeon 0.3.14 → 0.3.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/package.json +4 -2
  2. package/src/core/docs.test.mjs +79 -40
  3. package/src/core/scaffold.test.mjs +99 -0
  4. package/src/modules/db-prisma.mjs +23 -55
  5. package/src/modules/executor.test.mjs +2575 -2419
  6. package/src/modules/files-access.mjs +27 -98
  7. package/src/modules/files-image.mjs +26 -100
  8. package/src/modules/files-quotas.mjs +67 -87
  9. package/src/modules/files.mjs +35 -104
  10. package/src/modules/i18n.mjs +17 -121
  11. package/src/modules/idempotency.test.mjs +174 -0
  12. package/src/modules/jwt-auth.mjs +90 -209
  13. package/src/modules/logger.mjs +0 -9
  14. package/src/modules/probes.test.mjs +202 -0
  15. package/src/modules/queue.mjs +325 -412
  16. package/src/modules/rate-limit.mjs +22 -66
  17. package/src/modules/rbac.mjs +27 -67
  18. package/src/modules/scheduler.mjs +44 -167
  19. package/src/modules/shared/nest-runtime-wiring.mjs +110 -0
  20. package/src/modules/shared/probes.mjs +235 -0
  21. package/src/modules/sync-integrations.mjs +54 -21
  22. package/src/modules/sync-integrations.test.mjs +220 -0
  23. package/src/run-add-module.test.mjs +153 -0
  24. package/templates/base/README.md +7 -55
  25. package/templates/base/apps/web/src/App.tsx +70 -42
  26. package/templates/base/apps/web/src/probes.ts +61 -0
  27. package/templates/base/apps/web/src/styles.css +86 -25
  28. package/templates/base/package.json +21 -15
  29. package/templates/base/scripts/forgeon-sync-integrations.mjs +55 -11
  30. package/templates/module-presets/files-quotas/packages/files-quotas/src/forgeon-files-quotas.module.ts +12 -4
  31. package/templates/module-presets/i18n/apps/web/src/App.tsx +68 -41
  32. package/templates/module-presets/logger/packages/logger/src/index.ts +0 -1
  33. package/templates/base/docs/AI/ARCHITECTURE.md +0 -85
  34. package/templates/base/docs/AI/MODULE_CHECKS.md +0 -28
  35. package/templates/base/docs/AI/MODULE_SPEC.md +0 -77
  36. package/templates/base/docs/AI/PROJECT.md +0 -43
  37. package/templates/base/docs/AI/ROADMAP.md +0 -171
  38. package/templates/base/docs/AI/TASKS.md +0 -60
  39. package/templates/base/docs/AI/VALIDATION.md +0 -31
  40. package/templates/base/docs/README.md +0 -18
  41. package/templates/module-presets/logger/packages/logger/src/http-logging.interceptor.ts +0 -94
@@ -13,6 +13,7 @@ import {
13
13
  ensureValidatorSchema,
14
14
  upsertEnvLines,
15
15
  } from './shared/patch-utils.mjs';
16
+ import { ensureWebProbeDefinition, resolveProbeTargets } from './shared/probes.mjs';
16
17
 
17
18
  function copyFromPreset(packageRoot, targetRoot, relativePath) {
18
19
  const source = path.join(packageRoot, 'templates', 'module-presets', 'files', relativePath);
@@ -234,7 +235,11 @@ function patchApiDockerfile(targetRoot) {
234
235
  fs.writeFileSync(dockerfilePath, `${content.trimEnd()}\n`, 'utf8');
235
236
  }
236
237
 
237
- function patchHealthController(targetRoot) {
238
+ function patchHealthController(targetRoot, probeTargets) {
239
+ if (!probeTargets.allowApi) {
240
+ return;
241
+ }
242
+
238
243
  const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts');
239
244
  if (!fs.existsSync(filePath)) {
240
245
  return;
@@ -303,107 +308,31 @@ function patchHealthController(targetRoot) {
303
308
  fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
304
309
  }
305
310
 
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');
311
+ function registerWebProbe(targetRoot, probeTargets) {
312
+ ensureWebProbeDefinition({
313
+ targetRoot,
314
+ probeTargets,
315
+ definition: {
316
+ id: 'files',
317
+ title: 'Files',
318
+ buttonLabel: 'Check files probe (create metadata)',
319
+ resultTitle: 'Files probe response',
320
+ path: '/health/files',
321
+ request: { method: 'POST' },
322
+ },
323
+ });
324
+
325
+ ensureWebProbeDefinition({
326
+ targetRoot,
327
+ probeTargets,
328
+ definition: {
329
+ id: 'files-variants',
330
+ title: 'Files Variants',
331
+ buttonLabel: 'Check files variants capability',
332
+ resultTitle: 'Files variants probe response',
333
+ path: '/health/files-variants',
334
+ },
335
+ });
407
336
  }
408
337
 
409
338
  function patchCompose(targetRoot) {
@@ -499,13 +428,15 @@ Key env:
499
428
 
500
429
  export function applyFilesModule({ packageRoot, targetRoot }) {
501
430
  copyFromPreset(packageRoot, targetRoot, path.join('packages', 'files'));
431
+ const probeTargets = resolveProbeTargets({ targetRoot, moduleId: 'files' });
432
+
502
433
 
503
434
  patchApiPackage(targetRoot);
504
435
  patchPrismaSchema(targetRoot);
505
436
  patchPrismaMigration(packageRoot, targetRoot);
506
437
  patchAppModule(targetRoot);
507
- patchHealthController(targetRoot);
508
- patchWebApp(targetRoot);
438
+ patchHealthController(targetRoot, probeTargets);
439
+ registerWebProbe(targetRoot, probeTargets);
509
440
  patchApiDockerfile(targetRoot);
510
441
  patchCompose(targetRoot);
511
442
  patchReadme(targetRoot);
@@ -13,6 +13,7 @@ import {
13
13
  ensureValidatorSchema,
14
14
  upsertEnvLines,
15
15
  } from './shared/patch-utils.mjs';
16
+ import { ensureWebProbeDefinition, readManagedWebProbeDefinitions } from './shared/probes.mjs';
16
17
 
17
18
  function copyFromBase(packageRoot, targetRoot, relativePath) {
18
19
  const source = path.join(packageRoot, 'templates', 'base', relativePath);
@@ -39,10 +40,6 @@ function patchApiDockerfile(targetRoot) {
39
40
  }
40
41
 
41
42
  let content = fs.readFileSync(dockerfilePath, 'utf8').replace(/\r\n/g, '\n');
42
- content = content.replace(
43
- /^COPY package\.json pnpm-workspace\.yaml tsconfig\.base\.json \.\/$/m,
44
- 'COPY package.json pnpm-workspace.yaml tsconfig.base.json tsconfig.base.node.json tsconfig.base.esm.json ./',
45
- );
46
43
 
47
44
  content = ensureLineAfter(
48
45
  content,
@@ -95,22 +92,6 @@ function patchProxyDockerfile(filePath) {
95
92
  }
96
93
 
97
94
  let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
98
-
99
- content = ensureLineAfter(
100
- content,
101
- 'COPY package.json pnpm-workspace.yaml ./',
102
- 'COPY tsconfig.base.json ./',
103
- );
104
- content = ensureLineAfter(
105
- content,
106
- 'COPY tsconfig.base.json ./',
107
- 'COPY tsconfig.base.node.json ./',
108
- );
109
- content = ensureLineAfter(
110
- content,
111
- 'COPY tsconfig.base.node.json ./',
112
- 'COPY tsconfig.base.esm.json ./',
113
- );
114
95
  content = ensureLineAfter(
115
96
  content,
116
97
  'COPY apps/web/package.json apps/web/package.json',
@@ -449,118 +430,32 @@ Module env:
449
430
  fs.writeFileSync(readmePath, `${content.trimEnd()}\n`, 'utf8');
450
431
  }
451
432
 
452
- function restoreKnownWebProbes(targetRoot, previousAppContent) {
453
- if (!previousAppContent) {
433
+ function restoreManagedWebProbes(targetRoot, definitions) {
434
+ if (!Array.isArray(definitions) || definitions.length === 0) {
454
435
  return;
455
436
  }
456
437
 
457
- const filePath = path.join(targetRoot, 'apps', 'web', 'src', 'App.tsx');
458
- if (!fs.existsSync(filePath)) {
438
+ const probesFilePath = path.join(targetRoot, 'apps', 'web', 'src', 'probes.ts');
439
+ if (!fs.existsSync(probesFilePath)) {
459
440
  return;
460
441
  }
461
442
 
462
- let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
463
-
464
- const ensureProbeState = (stateLine) => {
465
- if (content.includes(stateLine)) {
466
- return;
467
- }
468
- const anchors = [
469
- ' const [rbacProbeResult, setRbacProbeResult] = useState<ProbeResult | null>(null);',
470
- ' const [rateLimitProbeResult, setRateLimitProbeResult] = useState<ProbeResult | null>(null);',
471
- ' const [authProbeResult, setAuthProbeResult] = useState<ProbeResult | null>(null);',
472
- ' const [dbProbeResult, setDbProbeResult] = useState<ProbeResult | null>(null);',
473
- ' const [validationProbeResult, setValidationProbeResult] = useState<ProbeResult | null>(null);',
474
- ];
475
- const anchor = anchors.find((line) => content.includes(line));
476
- if (anchor) {
477
- content = ensureLineAfter(content, anchor, stateLine);
478
- }
479
- };
480
-
481
- const ensureProbeButton = (buttonText, buttonCode) => {
482
- if (content.includes(buttonText)) {
483
- return;
484
- }
485
- const actionsStart = content.indexOf('<div className="actions">');
486
- if (actionsStart < 0) {
487
- return;
488
- }
489
- const actionsEnd = content.indexOf('\n </div>', actionsStart);
490
- if (actionsEnd < 0) {
491
- return;
492
- }
493
- content = `${content.slice(0, actionsEnd)}\n${buttonCode}${content.slice(actionsEnd)}`;
494
- };
495
-
496
- const ensureProbeResult = (resultLine) => {
497
- if (content.includes(resultLine)) {
498
- return;
499
- }
500
- const networkLine = ' {networkError ? <p className="error">{networkError}</p> : null}';
501
- if (content.includes(networkLine)) {
502
- content = content.replace(networkLine, `${resultLine}\n${networkLine}`);
503
- return;
504
- }
505
- const anchors = [
506
- " {renderResult('RBAC probe response', rbacProbeResult)}",
507
- " {renderResult('Rate limit probe response', rateLimitProbeResult)}",
508
- " {renderResult('Auth probe response', authProbeResult)}",
509
- " {renderResult('DB probe response', dbProbeResult)}",
510
- " {renderResult('Validation probe response', validationProbeResult)}",
511
- ];
512
- const anchor = anchors.find((line) => content.includes(line));
513
- if (anchor) {
514
- content = ensureLineAfter(content, anchor, resultLine);
515
- }
443
+ const probeTargets = {
444
+ allowWeb: true,
445
+ probesFilePath,
516
446
  };
517
447
 
518
- if (previousAppContent.includes('Check database (create user)')) {
519
- ensureProbeState(' const [dbProbeResult, setDbProbeResult] = useState<ProbeResult | null>(null);');
520
- ensureProbeButton(
521
- 'Check database (create user)',
522
- " <button onClick={() => runProbe(setDbProbeResult, '/health/db', { method: 'POST' })}>\n Check database (create user)\n </button>",
523
- );
524
- ensureProbeResult(" {renderResult('DB probe response', dbProbeResult)}");
525
- }
526
-
527
- if (previousAppContent.includes('Check JWT auth probe')) {
528
- ensureProbeState(' const [authProbeResult, setAuthProbeResult] = useState<ProbeResult | null>(null);');
529
- ensureProbeButton(
530
- 'Check JWT auth probe',
531
- " <button onClick={() => runProbe(setAuthProbeResult, '/health/auth')}>Check JWT auth probe</button>",
532
- );
533
- ensureProbeResult(" {renderResult('Auth probe response', authProbeResult)}");
534
- }
535
-
536
- if (previousAppContent.includes('Check rate limit (click repeatedly)')) {
537
- ensureProbeState(
538
- ' const [rateLimitProbeResult, setRateLimitProbeResult] = useState<ProbeResult | null>(null);',
539
- );
540
- ensureProbeButton(
541
- 'Check rate limit (click repeatedly)',
542
- " <button onClick={() => runProbe(setRateLimitProbeResult, '/health/rate-limit')}>\n Check rate limit (click repeatedly)\n </button>",
543
- );
544
- ensureProbeResult(" {renderResult('Rate limit probe response', rateLimitProbeResult)}");
448
+ for (const definition of definitions) {
449
+ ensureWebProbeDefinition({
450
+ targetRoot,
451
+ probeTargets,
452
+ definition,
453
+ });
545
454
  }
546
-
547
- if (previousAppContent.includes('Check RBAC access')) {
548
- ensureProbeState(' const [rbacProbeResult, setRbacProbeResult] = useState<ProbeResult | null>(null);');
549
- ensureProbeButton(
550
- 'Check RBAC access',
551
- " <button\n onClick={() =>\n runProbe(setRbacProbeResult, '/health/rbac', {\n headers: { 'x-forgeon-permissions': 'health.rbac' },\n })\n }\n >\n Check RBAC access\n </button>",
552
- );
553
- ensureProbeResult(" {renderResult('RBAC probe response', rbacProbeResult)}");
554
- }
555
-
556
- fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
557
455
  }
558
456
 
559
457
  export function applyI18nModule({ packageRoot, targetRoot }) {
560
- const existingWebAppPath = path.join(targetRoot, 'apps', 'web', 'src', 'App.tsx');
561
- const previousAppContent = fs.existsSync(existingWebAppPath)
562
- ? fs.readFileSync(existingWebAppPath, 'utf8')
563
- : '';
458
+ const previousProbeDefinitions = readManagedWebProbeDefinitions(targetRoot);
564
459
 
565
460
  copyFromBase(packageRoot, targetRoot, path.join('scripts', 'i18n-add.mjs'));
566
461
  copyFromBase(packageRoot, targetRoot, path.join('packages', 'i18n'));
@@ -568,10 +463,11 @@ export function applyI18nModule({ packageRoot, targetRoot }) {
568
463
 
569
464
  copyFromPreset(packageRoot, targetRoot, path.join('packages', 'i18n-contracts'));
570
465
  copyFromPreset(packageRoot, targetRoot, path.join('packages', 'i18n-web'));
466
+ copyFromBase(packageRoot, targetRoot, path.join('apps', 'web', 'src', 'probes.ts'));
571
467
  copyFromPreset(packageRoot, targetRoot, path.join('apps', 'web', 'src', 'App.tsx'));
572
468
  copyFromPreset(packageRoot, targetRoot, path.join('apps', 'web', 'src', 'i18n.ts'));
573
469
  copyFromPreset(packageRoot, targetRoot, path.join('apps', 'web', 'src', 'main.tsx'));
574
- restoreKnownWebProbes(targetRoot, previousAppContent);
470
+ restoreManagedWebProbes(targetRoot, previousProbeDefinitions);
575
471
 
576
472
  patchI18nPackage(targetRoot);
577
473
  patchApiPackage(targetRoot);
@@ -0,0 +1,174 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { addModule } from './executor.mjs';
8
+ import { scaffoldProject } from '../core/scaffold.mjs';
9
+
10
+ function makeTempDir(prefix) {
11
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
12
+ }
13
+
14
+ function readFile(filePath) {
15
+ return fs.readFileSync(filePath, 'utf8');
16
+ }
17
+
18
+ function scaffoldBaseProject({ packageRoot, targetRoot, projectName, dbPrismaEnabled, proxy = 'caddy' }) {
19
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
20
+ scaffoldProject({
21
+ templateRoot,
22
+ packageRoot,
23
+ targetRoot,
24
+ projectName,
25
+ frontend: 'react',
26
+ db: 'prisma',
27
+ dbPrismaEnabled,
28
+ i18nEnabled: false,
29
+ proxy,
30
+ });
31
+ }
32
+
33
+ function readProjectSnapshot(projectRoot) {
34
+ const snapshot = {};
35
+ const queue = [projectRoot];
36
+ const skipDirs = new Set(['node_modules', '.git', 'dist', 'build']);
37
+
38
+ while (queue.length > 0) {
39
+ const currentDir = queue.shift();
40
+ const entries = fs
41
+ .readdirSync(currentDir, { withFileTypes: true })
42
+ .sort((left, right) => left.name.localeCompare(right.name));
43
+
44
+ for (const entry of entries) {
45
+ const nextPath = path.join(currentDir, entry.name);
46
+ if (entry.isDirectory()) {
47
+ if (!skipDirs.has(entry.name)) {
48
+ queue.push(nextPath);
49
+ }
50
+ continue;
51
+ }
52
+
53
+ if (!entry.isFile()) {
54
+ continue;
55
+ }
56
+
57
+ snapshot[path.relative(projectRoot, nextPath)] = readFile(nextPath);
58
+ }
59
+ }
60
+
61
+ return Object.fromEntries(
62
+ Object.entries(snapshot).sort(([left], [right]) => left.localeCompare(right)),
63
+ );
64
+ }
65
+
66
+ describe('addModule idempotency', () => {
67
+ const modulesDir = path.dirname(fileURLToPath(import.meta.url));
68
+ const packageRoot = path.resolve(modulesDir, '..', '..');
69
+ const scenarios = [
70
+ {
71
+ name: 'logger',
72
+ moduleId: 'logger',
73
+ dbPrismaEnabled: true,
74
+ setup: [],
75
+ verify(projectRoot) {
76
+ assert.equal(fs.existsSync(path.join(projectRoot, 'packages', 'logger', 'package.json')), true);
77
+ assert.match(
78
+ readFile(path.join(projectRoot, 'apps', 'api', 'src', 'main.ts')),
79
+ /app\.useLogger\(app\.get\(ForgeonLoggerService\)\);/,
80
+ );
81
+ },
82
+ },
83
+ {
84
+ name: 'jwt-auth',
85
+ moduleId: 'jwt-auth',
86
+ dbPrismaEnabled: false,
87
+ setup: [],
88
+ verify(projectRoot) {
89
+ assert.equal(fs.existsSync(path.join(projectRoot, 'packages', 'auth-api', 'package.json')), true);
90
+ assert.match(
91
+ readFile(path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts')),
92
+ /@Get\('auth'\)/,
93
+ );
94
+ assert.doesNotMatch(
95
+ readFile(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts')),
96
+ /PrismaAuthRefreshTokenStore/,
97
+ );
98
+ },
99
+ },
100
+ {
101
+ name: 'files-local',
102
+ moduleId: 'files-local',
103
+ dbPrismaEnabled: true,
104
+ setup: [],
105
+ verify(projectRoot) {
106
+ assert.equal(fs.existsSync(path.join(projectRoot, 'packages', 'files-local', 'package.json')), true);
107
+ assert.match(readFile(path.join(projectRoot, 'apps', 'api', '.env.example')), /FILES_LOCAL_ROOT=storage\/uploads/);
108
+ assert.match(readFile(path.join(projectRoot, 'infra', 'docker', 'compose.yml')), /^\s{2}files_data:\s*$/m);
109
+ },
110
+ },
111
+ {
112
+ name: 'files',
113
+ moduleId: 'files',
114
+ dbPrismaEnabled: true,
115
+ setup: ['files-local'],
116
+ verify(projectRoot) {
117
+ assert.equal(fs.existsSync(path.join(projectRoot, 'packages', 'files', 'package.json')), true);
118
+ assert.match(readFile(path.join(projectRoot, 'apps', 'api', '.env.example')), /FILES_STORAGE_DRIVER=local/);
119
+ assert.match(
120
+ readFile(path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts')),
121
+ /@Post\('files'\)/,
122
+ );
123
+ },
124
+ },
125
+ {
126
+ name: 'queue',
127
+ moduleId: 'queue',
128
+ dbPrismaEnabled: false,
129
+ setup: [],
130
+ verify(projectRoot) {
131
+ assert.equal(fs.existsSync(path.join(projectRoot, 'packages', 'queue', 'package.json')), true);
132
+ assert.match(readFile(path.join(projectRoot, 'infra', 'docker', 'compose.yml')), /^\s{2}redis:\s*$/m);
133
+ assert.match(
134
+ readFile(path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts')),
135
+ /@Get\('queue'\)/,
136
+ );
137
+ },
138
+ },
139
+ ];
140
+
141
+ it('reapplying representative implemented modules is a no-op on project files', () => {
142
+ for (const scenario of scenarios) {
143
+ const tempRoot = makeTempDir(`forgeon-idempotent-${scenario.name}-`);
144
+ const projectRoot = path.join(tempRoot, `demo-${scenario.name}`);
145
+
146
+ try {
147
+ scaffoldBaseProject({
148
+ packageRoot,
149
+ targetRoot: projectRoot,
150
+ projectName: `demo-${scenario.name}`,
151
+ dbPrismaEnabled: scenario.dbPrismaEnabled,
152
+ });
153
+
154
+ for (const moduleId of scenario.setup) {
155
+ addModule({ moduleId, targetRoot: projectRoot, packageRoot });
156
+ }
157
+
158
+ const firstResult = addModule({ moduleId: scenario.moduleId, targetRoot: projectRoot, packageRoot });
159
+ assert.equal(firstResult.applied, true);
160
+ scenario.verify(projectRoot);
161
+
162
+ const before = readProjectSnapshot(projectRoot);
163
+ const secondResult = addModule({ moduleId: scenario.moduleId, targetRoot: projectRoot, packageRoot });
164
+ const after = readProjectSnapshot(projectRoot);
165
+
166
+ assert.equal(secondResult.applied, true);
167
+ assert.deepEqual(after, before);
168
+ scenario.verify(projectRoot);
169
+ } finally {
170
+ fs.rmSync(tempRoot, { recursive: true, force: true });
171
+ }
172
+ }
173
+ });
174
+ });