create-forgeon 0.2.7 → 0.2.9
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/README.md +1 -1
- package/package.json +1 -1
- package/src/core/docs.mjs +14 -79
- package/src/core/docs.test.mjs +11 -36
- package/src/core/scaffold.mjs +5 -1
- package/src/integrations/flow.mjs +1 -1
- package/src/modules/docs.mjs +23 -23
- package/src/modules/executor.mjs +1 -1
- package/src/modules/executor.test.mjs +84 -6
- package/src/modules/sync-integrations.mjs +143 -0
- package/src/run-add-module.mjs +1 -1
- package/templates/base/README.md +1 -0
- package/templates/base/scripts/forgeon-sync-integrations.mjs +118 -0
- package/templates/docs-fragments/AI_ARCHITECTURE/40_docs_generation.md +11 -9
- package/templates/docs-fragments/README/90_next_steps.md +6 -7
package/README.md
CHANGED
|
@@ -37,4 +37,4 @@ pnpm forgeon:sync-integrations
|
|
|
37
37
|
- `add i18n` is implemented and applies backend/frontend i18n wiring.
|
|
38
38
|
- `add jwt-auth` is implemented and auto-detects DB adapter support for refresh-token persistence.
|
|
39
39
|
- Integration sync is bundled by default and runs after `add` commands (best-effort).
|
|
40
|
-
-
|
|
40
|
+
- Module notes are written under `modules/<module-id>/README.md`.
|
package/package.json
CHANGED
package/src/core/docs.mjs
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { getDatabaseLabel } from '../databases/index.mjs';
|
|
4
|
-
import { getFrontendLabel } from '../frameworks/index.mjs';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
function renderTemplate(content, variables) {
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { getDatabaseLabel } from '../databases/index.mjs';
|
|
4
|
+
import { getFrontendLabel } from '../frameworks/index.mjs';
|
|
5
|
+
|
|
6
|
+
function renderTemplate(content, variables) {
|
|
8
7
|
return content.replace(/\{\{([A-Z0-9_]+)\}\}/g, (_, key) => String(variables[key] ?? ''));
|
|
9
8
|
}
|
|
10
9
|
|
|
@@ -50,7 +49,6 @@ export function generateDocs(targetRoot, options, packageRoot) {
|
|
|
50
49
|
DB_PRISMA_STATUS: options.dbPrismaEnabled ? 'enabled' : 'disabled',
|
|
51
50
|
DOCKER_STATUS: 'enabled',
|
|
52
51
|
PROXY_LABEL: options.proxy,
|
|
53
|
-
PROXY_CONFIG_PATH: getProxyConfigPath(options.proxy),
|
|
54
52
|
};
|
|
55
53
|
|
|
56
54
|
const readmeFragments = ['00_title', '10_stack', '20_quick_start_dev_intro'];
|
|
@@ -78,76 +76,13 @@ export function generateDocs(targetRoot, options, packageRoot) {
|
|
|
78
76
|
}
|
|
79
77
|
readmeFragments.push('41_error_handling');
|
|
80
78
|
readmeFragments.push('90_next_steps');
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
aiProjectFragments.push('20b_structure_db_prisma');
|
|
85
|
-
} else {
|
|
86
|
-
aiProjectFragments.push('20b_structure_db_none');
|
|
87
|
-
}
|
|
88
|
-
if (options.i18nEnabled) {
|
|
89
|
-
aiProjectFragments.push('21_structure_i18n');
|
|
90
|
-
}
|
|
91
|
-
aiProjectFragments.push('22_structure_docker', '23_structure_docs', '30_run_dev', '31_run_docker');
|
|
92
|
-
if (options.proxy === 'none') {
|
|
93
|
-
aiProjectFragments.push('32_proxy_notes_none');
|
|
94
|
-
} else {
|
|
95
|
-
aiProjectFragments.push('32_proxy_notes');
|
|
96
|
-
}
|
|
97
|
-
if (options.i18nEnabled) {
|
|
98
|
-
aiProjectFragments.push('33_i18n_notes');
|
|
99
|
-
}
|
|
100
|
-
aiProjectFragments.push('34_error_handling');
|
|
101
|
-
aiProjectFragments.push('40_change_boundaries_base');
|
|
102
|
-
if (options.proxy !== 'none') {
|
|
103
|
-
aiProjectFragments.push('41_change_boundaries_docker');
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const aiArchitectureFragments = ['00_title', '10_layout_base', '11_layout_infra'];
|
|
107
|
-
if (options.i18nEnabled) {
|
|
108
|
-
aiArchitectureFragments.push('12_layout_i18n_resources');
|
|
109
|
-
}
|
|
110
|
-
aiArchitectureFragments.push('20_env_base');
|
|
111
|
-
if (options.dbPrismaEnabled) {
|
|
112
|
-
aiArchitectureFragments.push('20b_env_db_prisma');
|
|
113
|
-
}
|
|
114
|
-
if (options.i18nEnabled) {
|
|
115
|
-
aiArchitectureFragments.push('21_env_i18n');
|
|
116
|
-
}
|
|
117
|
-
aiArchitectureFragments.push('22_ts_module_policy');
|
|
118
|
-
aiArchitectureFragments.push('23_error_handling');
|
|
119
|
-
if (options.dbPrismaEnabled) {
|
|
120
|
-
aiArchitectureFragments.push('30_default_db');
|
|
121
|
-
} else {
|
|
122
|
-
aiArchitectureFragments.push('30_default_db_none');
|
|
123
|
-
}
|
|
124
|
-
aiArchitectureFragments.push('31_docker_runtime', '32_scope_freeze');
|
|
125
|
-
aiArchitectureFragments.push('40_docs_generation', '50_extension_points');
|
|
126
|
-
|
|
127
|
-
writeDocFromFragments({
|
|
128
|
-
targetRoot,
|
|
79
|
+
|
|
80
|
+
writeDocFromFragments({
|
|
81
|
+
targetRoot,
|
|
129
82
|
outputPath: 'README.md',
|
|
130
83
|
fragmentsRoot,
|
|
131
|
-
docKey: 'README',
|
|
132
|
-
fragmentNames: readmeFragments,
|
|
133
|
-
variables,
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
writeDocFromFragments({
|
|
137
|
-
targetRoot,
|
|
138
|
-
outputPath: path.join('docs', 'AI', 'PROJECT.md'),
|
|
139
|
-
fragmentsRoot,
|
|
140
|
-
docKey: 'AI_PROJECT',
|
|
141
|
-
fragmentNames: aiProjectFragments,
|
|
142
|
-
variables,
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
writeDocFromFragments({
|
|
146
|
-
targetRoot,
|
|
147
|
-
outputPath: path.join('docs', 'AI', 'ARCHITECTURE.md'),
|
|
148
|
-
fragmentsRoot,
|
|
149
|
-
docKey: 'AI_ARCHITECTURE',
|
|
150
|
-
fragmentNames: aiArchitectureFragments,
|
|
151
|
-
variables,
|
|
152
|
-
});
|
|
153
|
-
}
|
|
84
|
+
docKey: 'README',
|
|
85
|
+
fragmentNames: readmeFragments,
|
|
86
|
+
variables,
|
|
87
|
+
});
|
|
88
|
+
}
|
package/src/core/docs.test.mjs
CHANGED
|
@@ -18,7 +18,7 @@ describe('generateDocs', () => {
|
|
|
18
18
|
const thisDir = path.dirname(fileURLToPath(import.meta.url));
|
|
19
19
|
const packageRoot = path.resolve(thisDir, '..', '..');
|
|
20
20
|
|
|
21
|
-
it('generates docs for proxy=none without i18n section', () => {
|
|
21
|
+
it('generates docs for proxy=none without i18n section', () => {
|
|
22
22
|
const targetRoot = makeTempDir('forgeon-docs-off-');
|
|
23
23
|
|
|
24
24
|
try {
|
|
@@ -34,31 +34,18 @@ describe('generateDocs', () => {
|
|
|
34
34
|
},
|
|
35
35
|
packageRoot,
|
|
36
36
|
);
|
|
37
|
-
|
|
38
|
-
const readme = readFile(path.join(targetRoot, 'README.md'));
|
|
39
|
-
|
|
40
|
-
const architectureDoc = readFile(path.join(targetRoot, 'docs', 'AI', 'ARCHITECTURE.md'));
|
|
41
|
-
|
|
37
|
+
|
|
38
|
+
const readme = readFile(path.join(targetRoot, 'README.md'));
|
|
39
|
+
|
|
42
40
|
assert.match(readme, /db-prisma`: `disabled`/);
|
|
43
41
|
assert.match(readme, /No DB module is enabled by default/);
|
|
44
42
|
assert.match(readme, /Quick Start \(Docker\)/);
|
|
45
43
|
assert.match(readme, /Proxy Preset: none/);
|
|
46
44
|
assert.match(readme, /Error Handling \(`core-errors`\)/);
|
|
45
|
+
assert.match(readme, /Module notes index: `modules\/README\.md`/);
|
|
47
46
|
assert.doesNotMatch(readme, /i18n Configuration/);
|
|
48
47
|
assert.doesNotMatch(readme, /Prisma In Container Start/);
|
|
49
|
-
|
|
50
|
-
assert.match(projectDoc, /### Docker mode/);
|
|
51
|
-
assert.match(projectDoc, /Active proxy preset: `none`/);
|
|
52
|
-
assert.match(projectDoc, /CoreErrorsModule/);
|
|
53
|
-
assert.doesNotMatch(projectDoc, /packages\/i18n/);
|
|
54
|
-
|
|
55
|
-
assert.match(architectureDoc, /generated without `db-prisma`/);
|
|
56
|
-
assert.doesNotMatch(architectureDoc, /I18N_ENABLED/);
|
|
57
|
-
assert.match(architectureDoc, /API_PREFIX/);
|
|
58
|
-
assert.match(architectureDoc, /Config Strategy/);
|
|
59
|
-
assert.match(architectureDoc, /TypeScript Module Policy/);
|
|
60
|
-
assert.match(architectureDoc, /tsconfig\.base\.esm\.json/);
|
|
61
|
-
assert.doesNotMatch(architectureDoc, /DbPrismaModule/);
|
|
48
|
+
assert.equal(fs.existsSync(path.join(targetRoot, 'docs')), false);
|
|
62
49
|
} finally {
|
|
63
50
|
fs.rmSync(targetRoot, { recursive: true, force: true });
|
|
64
51
|
}
|
|
@@ -80,29 +67,17 @@ describe('generateDocs', () => {
|
|
|
80
67
|
},
|
|
81
68
|
packageRoot,
|
|
82
69
|
);
|
|
83
|
-
|
|
84
|
-
const readme = readFile(path.join(targetRoot, 'README.md'));
|
|
85
|
-
|
|
86
|
-
const architectureDoc = readFile(path.join(targetRoot, 'docs', 'AI', 'ARCHITECTURE.md'));
|
|
87
|
-
|
|
70
|
+
|
|
71
|
+
const readme = readFile(path.join(targetRoot, 'README.md'));
|
|
72
|
+
|
|
88
73
|
assert.match(readme, /Quick Start \(Docker\)/);
|
|
89
74
|
assert.match(readme, /Proxy Preset: Caddy/);
|
|
90
75
|
assert.match(readme, /i18n Configuration/);
|
|
91
76
|
assert.match(readme, /db-prisma`: `enabled`/);
|
|
92
77
|
assert.match(readme, /Prisma In Container Start/);
|
|
93
78
|
assert.match(readme, /Error Handling \(`core-errors`\)/);
|
|
94
|
-
|
|
95
|
-
assert.
|
|
96
|
-
assert.match(projectDoc, /Main proxy config: `infra\/caddy\/Caddyfile`/);
|
|
97
|
-
assert.match(projectDoc, /CoreExceptionFilter/);
|
|
98
|
-
|
|
99
|
-
assert.match(architectureDoc, /infra\/\*/);
|
|
100
|
-
assert.match(architectureDoc, /I18N_DEFAULT_LANG/);
|
|
101
|
-
assert.doesNotMatch(architectureDoc, /I18N_ENABLED/);
|
|
102
|
-
assert.match(architectureDoc, /Active reverse proxy preset: `caddy`/);
|
|
103
|
-
assert.match(architectureDoc, /TypeScript Module Policy/);
|
|
104
|
-
assert.match(architectureDoc, /tsconfig\.base\.node\.json/);
|
|
105
|
-
assert.match(architectureDoc, /DbPrismaModule/);
|
|
79
|
+
assert.match(readme, /Module-specific notes: `modules\/<module-id>\/README\.md`/);
|
|
80
|
+
assert.equal(fs.existsSync(path.join(targetRoot, 'docs')), false);
|
|
106
81
|
} finally {
|
|
107
82
|
fs.rmSync(targetRoot, { recursive: true, force: true });
|
|
108
83
|
}
|
package/src/core/scaffold.mjs
CHANGED
|
@@ -38,7 +38,7 @@ function patchRootPackageJson(targetRoot, projectName) {
|
|
|
38
38
|
writeJson(rootPackageJsonPath, rootPackageJson);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
export function scaffoldProject({
|
|
41
|
+
export function scaffoldProject({
|
|
42
42
|
templateRoot,
|
|
43
43
|
packageRoot,
|
|
44
44
|
targetRoot,
|
|
@@ -50,6 +50,10 @@ export function scaffoldProject({
|
|
|
50
50
|
proxy,
|
|
51
51
|
}) {
|
|
52
52
|
copyRecursive(templateRoot, targetRoot);
|
|
53
|
+
const generatedDocsPath = path.join(targetRoot, 'docs');
|
|
54
|
+
if (fs.existsSync(generatedDocsPath)) {
|
|
55
|
+
fs.rmSync(generatedDocsPath, { recursive: true, force: true });
|
|
56
|
+
}
|
|
53
57
|
patchRootPackageJson(targetRoot, projectName);
|
|
54
58
|
applyProxyPreset(targetRoot, proxy);
|
|
55
59
|
|
|
@@ -114,5 +114,5 @@ export async function runIntegrationFlow({
|
|
|
114
114
|
|
|
115
115
|
export function printModuleAdded(moduleId, docsPath) {
|
|
116
116
|
console.log(colorize('green', `✔ Module added: ${moduleId}`));
|
|
117
|
-
console.log(`-
|
|
117
|
+
console.log(`- readme: ${docsPath}`);
|
|
118
118
|
}
|
package/src/modules/docs.mjs
CHANGED
|
@@ -20,25 +20,25 @@ function readModuleFragment(packageRoot, moduleId, fragmentName, variables) {
|
|
|
20
20
|
return renderTemplate(raw, variables).trim();
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
function ensureModuleIndex(targetRoot) {
|
|
24
|
-
const indexPath = path.join(targetRoot, '
|
|
25
|
-
if (!fs.existsSync(indexPath)) {
|
|
26
|
-
fs.mkdirSync(path.dirname(indexPath), { recursive: true });
|
|
27
|
-
fs.writeFileSync(
|
|
28
|
-
indexPath,
|
|
29
|
-
'#
|
|
30
|
-
'utf8',
|
|
31
|
-
);
|
|
32
|
-
}
|
|
33
|
-
return indexPath;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function updateModuleIndex(indexPath, preset) {
|
|
37
|
-
const relativePath =
|
|
38
|
-
const nextLine = `- \`${preset.id}\` - ${preset.label} (${preset.implemented ? 'implemented' : 'planned'})`;
|
|
39
|
-
const current = fs.readFileSync(indexPath, 'utf8').replace(/\r\n/g, '\n');
|
|
40
|
-
|
|
41
|
-
if (current.includes(`\`${preset.id}\``)) {
|
|
23
|
+
function ensureModuleIndex(targetRoot) {
|
|
24
|
+
const indexPath = path.join(targetRoot, 'modules', 'README.md');
|
|
25
|
+
if (!fs.existsSync(indexPath)) {
|
|
26
|
+
fs.mkdirSync(path.dirname(indexPath), { recursive: true });
|
|
27
|
+
fs.writeFileSync(
|
|
28
|
+
indexPath,
|
|
29
|
+
'# Modules\n\nUser-facing notes for modules added via `create-forgeon add`.\n',
|
|
30
|
+
'utf8',
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return indexPath;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function updateModuleIndex(indexPath, preset) {
|
|
37
|
+
const relativePath = `./${preset.id}/README.md`;
|
|
38
|
+
const nextLine = `- [\`${preset.id}\`](${relativePath}) - ${preset.label} (${preset.implemented ? 'implemented' : 'planned'})`;
|
|
39
|
+
const current = fs.readFileSync(indexPath, 'utf8').replace(/\r\n/g, '\n');
|
|
40
|
+
|
|
41
|
+
if (current.includes(`\`${preset.id}\``)) {
|
|
42
42
|
return;
|
|
43
43
|
}
|
|
44
44
|
|
|
@@ -46,7 +46,7 @@ function updateModuleIndex(indexPath, preset) {
|
|
|
46
46
|
fs.writeFileSync(indexPath, content, 'utf8');
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
export function writeModuleDocs({ packageRoot, targetRoot, preset }) {
|
|
49
|
+
export function writeModuleDocs({ packageRoot, targetRoot, preset }) {
|
|
50
50
|
const variables = {
|
|
51
51
|
MODULE_ID: preset.id,
|
|
52
52
|
MODULE_LABEL: preset.label,
|
|
@@ -59,9 +59,9 @@ export function writeModuleDocs({ packageRoot, targetRoot, preset }) {
|
|
|
59
59
|
.map((fragmentName) => readModuleFragment(packageRoot, preset.id, fragmentName, variables))
|
|
60
60
|
.filter(Boolean);
|
|
61
61
|
|
|
62
|
-
const outputPath = path.join(targetRoot, '
|
|
63
|
-
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
64
|
-
fs.writeFileSync(outputPath, `${sections.join('\n\n').trimEnd()}\n`, 'utf8');
|
|
62
|
+
const outputPath = path.join(targetRoot, 'modules', preset.id, 'README.md');
|
|
63
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
64
|
+
fs.writeFileSync(outputPath, `${sections.join('\n\n').trimEnd()}\n`, 'utf8');
|
|
65
65
|
|
|
66
66
|
const indexPath = ensureModuleIndex(targetRoot);
|
|
67
67
|
updateModuleIndex(indexPath, preset);
|
package/src/modules/executor.mjs
CHANGED
|
@@ -57,7 +57,7 @@ export function addModule({ moduleId, targetRoot, packageRoot, writeDocs = true
|
|
|
57
57
|
targetRoot,
|
|
58
58
|
preset,
|
|
59
59
|
})
|
|
60
|
-
: path.join(targetRoot, '
|
|
60
|
+
: path.join(targetRoot, 'modules', preset.id, 'README.md');
|
|
61
61
|
|
|
62
62
|
return {
|
|
63
63
|
preset,
|
|
@@ -5,7 +5,7 @@ import os from 'node:os';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { addModule } from './executor.mjs';
|
|
8
|
-
import { syncIntegrations } from './sync-integrations.mjs';
|
|
8
|
+
import { scanIntegrations, syncIntegrations } from './sync-integrations.mjs';
|
|
9
9
|
import { scaffoldProject } from '../core/scaffold.mjs';
|
|
10
10
|
|
|
11
11
|
function mkTmp(prefix) {
|
|
@@ -289,11 +289,13 @@ describe('addModule', () => {
|
|
|
289
289
|
packageRoot,
|
|
290
290
|
});
|
|
291
291
|
|
|
292
|
-
assert.equal(result.applied, false);
|
|
293
|
-
assert.match(result.message, /planned/);
|
|
294
|
-
assert.equal(fs.existsSync(result.docsPath), true);
|
|
295
|
-
|
|
296
|
-
|
|
292
|
+
assert.equal(result.applied, false);
|
|
293
|
+
assert.match(result.message, /planned/);
|
|
294
|
+
assert.equal(fs.existsSync(result.docsPath), true);
|
|
295
|
+
assert.match(result.docsPath, /modules[\\/].+[\\/]README\.md$/);
|
|
296
|
+
assert.equal(fs.existsSync(path.join(targetRoot, 'modules', 'README.md')), true);
|
|
297
|
+
|
|
298
|
+
const note = fs.readFileSync(result.docsPath, 'utf8');
|
|
297
299
|
assert.match(note, /Queue Worker/);
|
|
298
300
|
assert.match(note, /Status: planned/);
|
|
299
301
|
} finally {
|
|
@@ -337,6 +339,8 @@ describe('addModule', () => {
|
|
|
337
339
|
proxy: 'caddy',
|
|
338
340
|
});
|
|
339
341
|
|
|
342
|
+
assert.equal(fs.existsSync(path.join(projectRoot, 'docs')), false);
|
|
343
|
+
|
|
340
344
|
const result = addModule({
|
|
341
345
|
moduleId: 'i18n',
|
|
342
346
|
targetRoot: projectRoot,
|
|
@@ -1145,6 +1149,80 @@ describe('addModule', () => {
|
|
|
1145
1149
|
}
|
|
1146
1150
|
});
|
|
1147
1151
|
|
|
1152
|
+
it('detects and applies jwt-auth + rbac claims integration explicitly', () => {
|
|
1153
|
+
const targetRoot = mkTmp('forgeon-module-jwt-rbac-');
|
|
1154
|
+
const projectRoot = path.join(targetRoot, 'demo-jwt-rbac');
|
|
1155
|
+
const templateRoot = path.join(packageRoot, 'templates', 'base');
|
|
1156
|
+
|
|
1157
|
+
try {
|
|
1158
|
+
scaffoldProject({
|
|
1159
|
+
templateRoot,
|
|
1160
|
+
packageRoot,
|
|
1161
|
+
targetRoot: projectRoot,
|
|
1162
|
+
projectName: 'demo-jwt-rbac',
|
|
1163
|
+
frontend: 'react',
|
|
1164
|
+
db: 'prisma',
|
|
1165
|
+
dbPrismaEnabled: false,
|
|
1166
|
+
i18nEnabled: false,
|
|
1167
|
+
proxy: 'caddy',
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
addModule({
|
|
1171
|
+
moduleId: 'rbac',
|
|
1172
|
+
targetRoot: projectRoot,
|
|
1173
|
+
packageRoot,
|
|
1174
|
+
});
|
|
1175
|
+
addModule({
|
|
1176
|
+
moduleId: 'jwt-auth',
|
|
1177
|
+
targetRoot: projectRoot,
|
|
1178
|
+
packageRoot,
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
const scan = scanIntegrations({
|
|
1182
|
+
targetRoot: projectRoot,
|
|
1183
|
+
relatedModuleId: 'jwt-auth',
|
|
1184
|
+
});
|
|
1185
|
+
assert.equal(scan.groups.some((group) => group.id === 'auth-rbac-claims'), true);
|
|
1186
|
+
|
|
1187
|
+
const syncResult = syncIntegrations({
|
|
1188
|
+
targetRoot: projectRoot,
|
|
1189
|
+
packageRoot,
|
|
1190
|
+
groupIds: ['auth-rbac-claims'],
|
|
1191
|
+
});
|
|
1192
|
+
const claimsPair = syncResult.summary.find((item) => item.id === 'auth-rbac-claims');
|
|
1193
|
+
assert.ok(claimsPair);
|
|
1194
|
+
assert.equal(claimsPair.result.applied, true);
|
|
1195
|
+
|
|
1196
|
+
const authContracts = fs.readFileSync(
|
|
1197
|
+
path.join(projectRoot, 'packages', 'auth-contracts', 'src', 'index.ts'),
|
|
1198
|
+
'utf8',
|
|
1199
|
+
);
|
|
1200
|
+
assert.match(authContracts, /permissions\?: string\[\];/);
|
|
1201
|
+
|
|
1202
|
+
const authService = fs.readFileSync(
|
|
1203
|
+
path.join(projectRoot, 'packages', 'auth-api', 'src', 'auth.service.ts'),
|
|
1204
|
+
'utf8',
|
|
1205
|
+
);
|
|
1206
|
+
assert.match(authService, /permissions: \['health\.rbac'\]/);
|
|
1207
|
+
assert.match(authService, /permissions: user\.permissions,/);
|
|
1208
|
+
assert.match(
|
|
1209
|
+
authService,
|
|
1210
|
+
/permissions: Array\.isArray\(payload\.permissions\) \? payload\.permissions : \[\],/,
|
|
1211
|
+
);
|
|
1212
|
+
|
|
1213
|
+
const authController = fs.readFileSync(
|
|
1214
|
+
path.join(projectRoot, 'packages', 'auth-api', 'src', 'auth.controller.ts'),
|
|
1215
|
+
'utf8',
|
|
1216
|
+
);
|
|
1217
|
+
assert.match(
|
|
1218
|
+
authController,
|
|
1219
|
+
/permissions: Array\.isArray\(payload\.permissions\) \? payload\.permissions : \[\],/,
|
|
1220
|
+
);
|
|
1221
|
+
} finally {
|
|
1222
|
+
fs.rmSync(targetRoot, { recursive: true, force: true });
|
|
1223
|
+
}
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1148
1226
|
it('applies logger then jwt-auth on db/i18n-disabled scaffold without breaking health controller syntax', () => {
|
|
1149
1227
|
const targetRoot = mkTmp('forgeon-module-jwt-nodb-noi18n-');
|
|
1150
1228
|
const projectRoot = path.join(targetRoot, 'demo-jwt-nodb-noi18n');
|
|
@@ -68,6 +68,29 @@ function isAuthPersistencePending(rootDir) {
|
|
|
68
68
|
return !(hasModuleWiring && hasSchema && hasStoreFile && hasMigration);
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
function isAuthRbacPending(rootDir) {
|
|
72
|
+
const authContractsPath = path.join(rootDir, 'packages', 'auth-contracts', 'src', 'index.ts');
|
|
73
|
+
const authServicePath = path.join(rootDir, 'packages', 'auth-api', 'src', 'auth.service.ts');
|
|
74
|
+
const authControllerPath = path.join(rootDir, 'packages', 'auth-api', 'src', 'auth.controller.ts');
|
|
75
|
+
|
|
76
|
+
if (!fs.existsSync(authContractsPath) || !fs.existsSync(authServicePath) || !fs.existsSync(authControllerPath)) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const authContracts = fs.readFileSync(authContractsPath, 'utf8');
|
|
81
|
+
const authService = fs.readFileSync(authServicePath, 'utf8');
|
|
82
|
+
const authController = fs.readFileSync(authControllerPath, 'utf8');
|
|
83
|
+
|
|
84
|
+
const hasContracts = authContracts.includes('permissions?: string[];');
|
|
85
|
+
const hasDemoClaims = authService.includes("permissions: ['health.rbac']");
|
|
86
|
+
const hasPayloadClaims = authService.includes('permissions: user.permissions,');
|
|
87
|
+
const hasRefreshClaims = authService.includes('permissions: Array.isArray(payload.permissions) ? payload.permissions : [],');
|
|
88
|
+
const hasControllerClaims =
|
|
89
|
+
authController.includes('permissions: Array.isArray(payload.permissions) ? payload.permissions : [],');
|
|
90
|
+
|
|
91
|
+
return !(hasContracts && hasDemoClaims && hasPayloadClaims && hasRefreshClaims && hasControllerClaims);
|
|
92
|
+
}
|
|
93
|
+
|
|
71
94
|
const INTEGRATION_GROUPS = [
|
|
72
95
|
{
|
|
73
96
|
id: 'auth-persistence',
|
|
@@ -83,6 +106,20 @@ const INTEGRATION_GROUPS = [
|
|
|
83
106
|
isPending: (rootDir) => isAuthPersistencePending(rootDir),
|
|
84
107
|
apply: syncJwtDbPrisma,
|
|
85
108
|
},
|
|
109
|
+
{
|
|
110
|
+
id: 'auth-rbac-claims',
|
|
111
|
+
title: 'Auth Claims Integration',
|
|
112
|
+
modules: ['jwt-auth', 'rbac'],
|
|
113
|
+
description: [
|
|
114
|
+
'Extend AuthUser with optional permissions in @forgeon/auth-contracts',
|
|
115
|
+
'Add demo RBAC claims to jwt-auth login and token payloads',
|
|
116
|
+
'Expose permissions in auth refresh and /me responses',
|
|
117
|
+
'Update JWT auth README note about RBAC demo claims',
|
|
118
|
+
],
|
|
119
|
+
isAvailable: (detected) => detected.jwtAuth && detected.rbac,
|
|
120
|
+
isPending: (rootDir) => isAuthRbacPending(rootDir),
|
|
121
|
+
apply: syncJwtRbacClaims,
|
|
122
|
+
},
|
|
86
123
|
];
|
|
87
124
|
|
|
88
125
|
function detectModules(rootDir) {
|
|
@@ -93,6 +130,9 @@ function detectModules(rootDir) {
|
|
|
93
130
|
jwtAuth:
|
|
94
131
|
fs.existsSync(path.join(rootDir, 'packages', 'auth-api', 'package.json')) ||
|
|
95
132
|
appModuleText.includes("from '@forgeon/auth-api'"),
|
|
133
|
+
rbac:
|
|
134
|
+
fs.existsSync(path.join(rootDir, 'packages', 'rbac', 'package.json')) ||
|
|
135
|
+
appModuleText.includes("from '@forgeon/rbac'"),
|
|
96
136
|
dbPrisma:
|
|
97
137
|
fs.existsSync(path.join(rootDir, 'packages', 'db-prisma', 'package.json')) ||
|
|
98
138
|
appModuleText.includes("from '@forgeon/db-prisma'"),
|
|
@@ -216,6 +256,109 @@ function syncJwtDbPrisma({ rootDir, packageRoot, changedFiles }) {
|
|
|
216
256
|
return { applied: true };
|
|
217
257
|
}
|
|
218
258
|
|
|
259
|
+
function syncJwtRbacClaims({ rootDir, changedFiles }) {
|
|
260
|
+
const authContractsPath = path.join(rootDir, 'packages', 'auth-contracts', 'src', 'index.ts');
|
|
261
|
+
const authServicePath = path.join(rootDir, 'packages', 'auth-api', 'src', 'auth.service.ts');
|
|
262
|
+
const authControllerPath = path.join(rootDir, 'packages', 'auth-api', 'src', 'auth.controller.ts');
|
|
263
|
+
const readmePath = path.join(rootDir, 'README.md');
|
|
264
|
+
|
|
265
|
+
if (!fs.existsSync(authContractsPath) || !fs.existsSync(authServicePath) || !fs.existsSync(authControllerPath)) {
|
|
266
|
+
return { applied: false, reason: 'auth package files are missing' };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
let touched = false;
|
|
270
|
+
|
|
271
|
+
let authContracts = fs.readFileSync(authContractsPath, 'utf8').replace(/\r\n/g, '\n');
|
|
272
|
+
const originalAuthContracts = authContracts;
|
|
273
|
+
if (!authContracts.includes('permissions?: string[];')) {
|
|
274
|
+
authContracts = authContracts.replace(
|
|
275
|
+
' roles: string[];',
|
|
276
|
+
` roles: string[];
|
|
277
|
+
permissions?: string[];`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
if (authContracts !== originalAuthContracts) {
|
|
281
|
+
fs.writeFileSync(authContractsPath, `${authContracts.trimEnd()}\n`, 'utf8');
|
|
282
|
+
changedFiles.add(authContractsPath);
|
|
283
|
+
touched = true;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
let authService = fs.readFileSync(authServicePath, 'utf8').replace(/\r\n/g, '\n');
|
|
287
|
+
const originalAuthService = authService;
|
|
288
|
+
authService = authService.replace(
|
|
289
|
+
/roles: \['user'\],/g,
|
|
290
|
+
`roles: ['admin'],
|
|
291
|
+
permissions: ['health.rbac'],`,
|
|
292
|
+
);
|
|
293
|
+
if (!authService.includes('permissions: user.permissions,')) {
|
|
294
|
+
authService = authService.replace(
|
|
295
|
+
' roles: user.roles,',
|
|
296
|
+
` roles: user.roles,
|
|
297
|
+
permissions: user.permissions,`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
if (!authService.includes('permissions: Array.isArray(payload.permissions) ? payload.permissions : [],')) {
|
|
301
|
+
authService = authService.replace(
|
|
302
|
+
" roles: Array.isArray(payload.roles) ? payload.roles : ['user'],",
|
|
303
|
+
` roles: Array.isArray(payload.roles) ? payload.roles : ['user'],
|
|
304
|
+
permissions: Array.isArray(payload.permissions) ? payload.permissions : [],`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
if (!authService.includes('demoPermissions: [')) {
|
|
308
|
+
authService = authService.replace(
|
|
309
|
+
" demoEmail: this.configService.demoEmail,",
|
|
310
|
+
` demoEmail: this.configService.demoEmail,
|
|
311
|
+
demoPermissions: ['health.rbac'],`,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
if (authService !== originalAuthService) {
|
|
315
|
+
fs.writeFileSync(authServicePath, `${authService.trimEnd()}\n`, 'utf8');
|
|
316
|
+
changedFiles.add(authServicePath);
|
|
317
|
+
touched = true;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
let authController = fs.readFileSync(authControllerPath, 'utf8').replace(/\r\n/g, '\n');
|
|
321
|
+
const originalAuthController = authController;
|
|
322
|
+
if (!authController.includes('permissions: Array.isArray(payload.permissions) ? payload.permissions : [],')) {
|
|
323
|
+
authController = authController.replace(
|
|
324
|
+
" roles: Array.isArray(payload.roles) ? payload.roles : ['user'],",
|
|
325
|
+
` roles: Array.isArray(payload.roles) ? payload.roles : ['user'],
|
|
326
|
+
permissions: Array.isArray(payload.permissions) ? payload.permissions : [],`,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
if (authController !== originalAuthController) {
|
|
330
|
+
fs.writeFileSync(authControllerPath, `${authController.trimEnd()}\n`, 'utf8');
|
|
331
|
+
changedFiles.add(authControllerPath);
|
|
332
|
+
touched = true;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (fs.existsSync(readmePath)) {
|
|
336
|
+
let readme = fs.readFileSync(readmePath, 'utf8').replace(/\r\n/g, '\n');
|
|
337
|
+
const originalReadme = readme;
|
|
338
|
+
if (!readme.includes('- RBAC integration: demo auth tokens include `health.rbac` permission')) {
|
|
339
|
+
const marker = 'Default demo credentials:';
|
|
340
|
+
if (readme.includes(marker)) {
|
|
341
|
+
readme = readme.replace(
|
|
342
|
+
marker,
|
|
343
|
+
`- RBAC integration: demo auth tokens include \`health.rbac\` permission
|
|
344
|
+
|
|
345
|
+
Default demo credentials:`,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if (readme !== originalReadme) {
|
|
350
|
+
fs.writeFileSync(readmePath, `${readme.trimEnd()}\n`, 'utf8');
|
|
351
|
+
changedFiles.add(readmePath);
|
|
352
|
+
touched = true;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (!touched) {
|
|
357
|
+
return { applied: false, reason: 'already synced' };
|
|
358
|
+
}
|
|
359
|
+
return { applied: true };
|
|
360
|
+
}
|
|
361
|
+
|
|
219
362
|
export function syncIntegrations({ targetRoot, packageRoot, groupIds = null }) {
|
|
220
363
|
const rootDir = path.resolve(targetRoot);
|
|
221
364
|
const changedFiles = new Set();
|
package/src/run-add-module.mjs
CHANGED
|
@@ -94,7 +94,7 @@ function ensureSyncTooling({ packageRoot, targetRoot }) {
|
|
|
94
94
|
);
|
|
95
95
|
const targetScript = path.join(targetRoot, 'scripts', 'forgeon-sync-integrations.mjs');
|
|
96
96
|
|
|
97
|
-
if (fs.existsSync(sourceScript)
|
|
97
|
+
if (fs.existsSync(sourceScript)) {
|
|
98
98
|
fs.mkdirSync(path.dirname(targetScript), { recursive: true });
|
|
99
99
|
fs.copyFileSync(sourceScript, targetScript);
|
|
100
100
|
}
|
package/templates/base/README.md
CHANGED
|
@@ -37,6 +37,7 @@ pnpm forgeon:sync-integrations
|
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
Current sync coverage:
|
|
40
|
+
- `jwt-auth + rbac`: extends demo auth tokens with the `health.rbac` permission.
|
|
40
41
|
- `jwt-auth + db-prisma`: wires persistent refresh-token storage for auth.
|
|
41
42
|
|
|
42
43
|
`create-forgeon add <module>` scans for relevant integration groups and can apply them immediately.
|
|
@@ -53,6 +53,9 @@ function detectModules(rootDir) {
|
|
|
53
53
|
jwtAuth:
|
|
54
54
|
fs.existsSync(path.join(rootDir, 'packages', 'auth-api', 'package.json')) ||
|
|
55
55
|
appModuleText.includes("from '@forgeon/auth-api'"),
|
|
56
|
+
rbac:
|
|
57
|
+
fs.existsSync(path.join(rootDir, 'packages', 'rbac', 'package.json')) ||
|
|
58
|
+
appModuleText.includes("from '@forgeon/rbac'"),
|
|
56
59
|
dbPrisma:
|
|
57
60
|
fs.existsSync(path.join(rootDir, 'packages', 'db-prisma', 'package.json')) ||
|
|
58
61
|
appModuleText.includes("from '@forgeon/db-prisma'"),
|
|
@@ -179,6 +182,109 @@ function syncJwtDbPrisma({ rootDir, changedFiles }) {
|
|
|
179
182
|
return { applied: true };
|
|
180
183
|
}
|
|
181
184
|
|
|
185
|
+
function syncJwtRbacClaims({ rootDir, changedFiles }) {
|
|
186
|
+
const authContractsPath = path.join(rootDir, 'packages', 'auth-contracts', 'src', 'index.ts');
|
|
187
|
+
const authServicePath = path.join(rootDir, 'packages', 'auth-api', 'src', 'auth.service.ts');
|
|
188
|
+
const authControllerPath = path.join(rootDir, 'packages', 'auth-api', 'src', 'auth.controller.ts');
|
|
189
|
+
const readmePath = path.join(rootDir, 'README.md');
|
|
190
|
+
|
|
191
|
+
if (!fs.existsSync(authContractsPath) || !fs.existsSync(authServicePath) || !fs.existsSync(authControllerPath)) {
|
|
192
|
+
return { applied: false, reason: 'auth package files are missing' };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let touched = false;
|
|
196
|
+
|
|
197
|
+
let authContracts = fs.readFileSync(authContractsPath, 'utf8').replace(/\r\n/g, '\n');
|
|
198
|
+
const originalAuthContracts = authContracts;
|
|
199
|
+
if (!authContracts.includes('permissions?: string[];')) {
|
|
200
|
+
authContracts = authContracts.replace(
|
|
201
|
+
' roles: string[];',
|
|
202
|
+
` roles: string[];
|
|
203
|
+
permissions?: string[];`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
if (authContracts !== originalAuthContracts) {
|
|
207
|
+
fs.writeFileSync(authContractsPath, `${authContracts.trimEnd()}\n`, 'utf8');
|
|
208
|
+
changedFiles.add(authContractsPath);
|
|
209
|
+
touched = true;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
let authService = fs.readFileSync(authServicePath, 'utf8').replace(/\r\n/g, '\n');
|
|
213
|
+
const originalAuthService = authService;
|
|
214
|
+
authService = authService.replace(
|
|
215
|
+
/roles: \['user'\],/g,
|
|
216
|
+
`roles: ['admin'],
|
|
217
|
+
permissions: ['health.rbac'],`,
|
|
218
|
+
);
|
|
219
|
+
if (!authService.includes('permissions: user.permissions,')) {
|
|
220
|
+
authService = authService.replace(
|
|
221
|
+
' roles: user.roles,',
|
|
222
|
+
` roles: user.roles,
|
|
223
|
+
permissions: user.permissions,`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
if (!authService.includes('permissions: Array.isArray(payload.permissions) ? payload.permissions : [],')) {
|
|
227
|
+
authService = authService.replace(
|
|
228
|
+
" roles: Array.isArray(payload.roles) ? payload.roles : ['user'],",
|
|
229
|
+
` roles: Array.isArray(payload.roles) ? payload.roles : ['user'],
|
|
230
|
+
permissions: Array.isArray(payload.permissions) ? payload.permissions : [],`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
if (!authService.includes('demoPermissions: [')) {
|
|
234
|
+
authService = authService.replace(
|
|
235
|
+
" demoEmail: this.configService.demoEmail,",
|
|
236
|
+
` demoEmail: this.configService.demoEmail,
|
|
237
|
+
demoPermissions: ['health.rbac'],`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
if (authService !== originalAuthService) {
|
|
241
|
+
fs.writeFileSync(authServicePath, `${authService.trimEnd()}\n`, 'utf8');
|
|
242
|
+
changedFiles.add(authServicePath);
|
|
243
|
+
touched = true;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
let authController = fs.readFileSync(authControllerPath, 'utf8').replace(/\r\n/g, '\n');
|
|
247
|
+
const originalAuthController = authController;
|
|
248
|
+
if (!authController.includes('permissions: Array.isArray(payload.permissions) ? payload.permissions : [],')) {
|
|
249
|
+
authController = authController.replace(
|
|
250
|
+
" roles: Array.isArray(payload.roles) ? payload.roles : ['user'],",
|
|
251
|
+
` roles: Array.isArray(payload.roles) ? payload.roles : ['user'],
|
|
252
|
+
permissions: Array.isArray(payload.permissions) ? payload.permissions : [],`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
if (authController !== originalAuthController) {
|
|
256
|
+
fs.writeFileSync(authControllerPath, `${authController.trimEnd()}\n`, 'utf8');
|
|
257
|
+
changedFiles.add(authControllerPath);
|
|
258
|
+
touched = true;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (fs.existsSync(readmePath)) {
|
|
262
|
+
let readme = fs.readFileSync(readmePath, 'utf8').replace(/\r\n/g, '\n');
|
|
263
|
+
const originalReadme = readme;
|
|
264
|
+
if (!readme.includes('- RBAC integration: demo auth tokens include `health.rbac` permission')) {
|
|
265
|
+
const marker = 'Default demo credentials:';
|
|
266
|
+
if (readme.includes(marker)) {
|
|
267
|
+
readme = readme.replace(
|
|
268
|
+
marker,
|
|
269
|
+
`- RBAC integration: demo auth tokens include \`health.rbac\` permission
|
|
270
|
+
|
|
271
|
+
Default demo credentials:`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (readme !== originalReadme) {
|
|
276
|
+
fs.writeFileSync(readmePath, `${readme.trimEnd()}\n`, 'utf8');
|
|
277
|
+
changedFiles.add(readmePath);
|
|
278
|
+
touched = true;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (!touched) {
|
|
283
|
+
return { applied: false, reason: 'already synced' };
|
|
284
|
+
}
|
|
285
|
+
return { applied: true };
|
|
286
|
+
}
|
|
287
|
+
|
|
182
288
|
function run() {
|
|
183
289
|
const rootDir = process.cwd();
|
|
184
290
|
const changedFiles = new Set();
|
|
@@ -197,6 +303,18 @@ function run() {
|
|
|
197
303
|
});
|
|
198
304
|
}
|
|
199
305
|
|
|
306
|
+
if (detected.jwtAuth && detected.rbac) {
|
|
307
|
+
summary.push({
|
|
308
|
+
feature: 'jwt-auth + rbac',
|
|
309
|
+
result: syncJwtRbacClaims({ rootDir, changedFiles }),
|
|
310
|
+
});
|
|
311
|
+
} else {
|
|
312
|
+
summary.push({
|
|
313
|
+
feature: 'jwt-auth + rbac',
|
|
314
|
+
result: { applied: false, reason: 'required modules are not both installed' },
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
200
318
|
console.log('[forgeon:sync-integrations] done');
|
|
201
319
|
for (const item of summary) {
|
|
202
320
|
if (item.result.applied) {
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
## Docs Generation Pipeline
|
|
2
|
-
|
|
3
|
-
Project docs are assembled from markdown fragments in:
|
|
4
|
-
|
|
5
|
-
- `packages/create-forgeon/templates/docs-fragments/README`
|
|
6
|
-
- `packages/create-forgeon/templates/docs-fragments/AI_PROJECT`
|
|
7
|
-
- `packages/create-forgeon/templates/docs-fragments/AI_ARCHITECTURE`
|
|
8
|
-
|
|
9
|
-
During scaffold generation, the CLI
|
|
1
|
+
## Docs Generation Pipeline
|
|
2
|
+
|
|
3
|
+
Project docs are assembled from markdown fragments in:
|
|
4
|
+
|
|
5
|
+
- `packages/create-forgeon/templates/docs-fragments/README`
|
|
6
|
+
- `packages/create-forgeon/templates/docs-fragments/AI_PROJECT`
|
|
7
|
+
- `packages/create-forgeon/templates/docs-fragments/AI_ARCHITECTURE`
|
|
8
|
+
|
|
9
|
+
During scaffold generation, the CLI currently writes the generated project `README.md` from these fragments.
|
|
10
|
+
|
|
11
|
+
Internal architecture fragments remain in the Forgeon repository as generator source material and are not emitted into generated projects by default.
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
## Next Steps
|
|
2
|
-
|
|
3
|
-
- Backend entrypoint: `apps/api/src/main.ts`
|
|
4
|
-
- Frontend entrypoint: `apps/web/src/main.tsx`
|
|
5
|
-
-
|
|
6
|
-
-
|
|
7
|
-
- Module contract spec: `docs/AI/MODULE_SPEC.md`
|
|
1
|
+
## Next Steps
|
|
2
|
+
|
|
3
|
+
- Backend entrypoint: `apps/api/src/main.ts`
|
|
4
|
+
- Frontend entrypoint: `apps/web/src/main.tsx`
|
|
5
|
+
- Module notes index: `modules/README.md`
|
|
6
|
+
- Module-specific notes: `modules/<module-id>/README.md`
|