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.
- package/package.json +4 -2
- package/src/core/docs.test.mjs +79 -40
- package/src/core/scaffold.test.mjs +99 -0
- package/src/modules/db-prisma.mjs +23 -55
- package/src/modules/executor.test.mjs +2575 -2419
- package/src/modules/files-access.mjs +27 -98
- package/src/modules/files-image.mjs +26 -100
- package/src/modules/files-quotas.mjs +67 -87
- package/src/modules/files.mjs +35 -104
- package/src/modules/i18n.mjs +17 -121
- package/src/modules/idempotency.test.mjs +174 -0
- package/src/modules/jwt-auth.mjs +90 -209
- package/src/modules/logger.mjs +0 -9
- package/src/modules/probes.test.mjs +202 -0
- package/src/modules/queue.mjs +325 -412
- package/src/modules/rate-limit.mjs +22 -66
- package/src/modules/rbac.mjs +27 -67
- package/src/modules/scheduler.mjs +44 -167
- package/src/modules/shared/nest-runtime-wiring.mjs +110 -0
- package/src/modules/shared/probes.mjs +235 -0
- package/src/modules/sync-integrations.mjs +54 -21
- package/src/modules/sync-integrations.test.mjs +220 -0
- package/src/run-add-module.test.mjs +153 -0
- package/templates/base/README.md +7 -55
- package/templates/base/apps/web/src/App.tsx +70 -42
- package/templates/base/apps/web/src/probes.ts +61 -0
- package/templates/base/apps/web/src/styles.css +86 -25
- package/templates/base/package.json +21 -15
- package/templates/base/scripts/forgeon-sync-integrations.mjs +55 -11
- package/templates/module-presets/files-quotas/packages/files-quotas/src/forgeon-files-quotas.module.ts +12 -4
- package/templates/module-presets/i18n/apps/web/src/App.tsx +68 -41
- package/templates/module-presets/logger/packages/logger/src/index.ts +0 -1
- package/templates/base/docs/AI/ARCHITECTURE.md +0 -85
- package/templates/base/docs/AI/MODULE_CHECKS.md +0 -28
- package/templates/base/docs/AI/MODULE_SPEC.md +0 -77
- package/templates/base/docs/AI/PROJECT.md +0 -43
- package/templates/base/docs/AI/ROADMAP.md +0 -171
- package/templates/base/docs/AI/TASKS.md +0 -60
- package/templates/base/docs/AI/VALIDATION.md +0 -31
- package/templates/base/docs/README.md +0 -18
- package/templates/module-presets/logger/packages/logger/src/http-logging.interceptor.ts +0 -94
package/src/modules/files.mjs
CHANGED
|
@@ -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
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
438
|
+
patchHealthController(targetRoot, probeTargets);
|
|
439
|
+
registerWebProbe(targetRoot, probeTargets);
|
|
509
440
|
patchApiDockerfile(targetRoot);
|
|
510
441
|
patchCompose(targetRoot);
|
|
511
442
|
patchReadme(targetRoot);
|
package/src/modules/i18n.mjs
CHANGED
|
@@ -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
|
|
453
|
-
if (!
|
|
433
|
+
function restoreManagedWebProbes(targetRoot, definitions) {
|
|
434
|
+
if (!Array.isArray(definitions) || definitions.length === 0) {
|
|
454
435
|
return;
|
|
455
436
|
}
|
|
456
437
|
|
|
457
|
-
const
|
|
458
|
-
if (!fs.existsSync(
|
|
438
|
+
const probesFilePath = path.join(targetRoot, 'apps', 'web', 'src', 'probes.ts');
|
|
439
|
+
if (!fs.existsSync(probesFilePath)) {
|
|
459
440
|
return;
|
|
460
441
|
}
|
|
461
442
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
});
|