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 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
- - Planned modules write docs notes under `docs/AI/MODULES/`.
40
+ - Module notes are written under `modules/<module-id>/README.md`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-forgeon",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "description": "Forgeon project generator CLI",
5
5
  "license": "MIT",
6
6
  "author": "Forgeon",
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
- import { getProxyConfigPath } from '../infrastructure/proxy.mjs';
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
- const aiProjectFragments = ['00_title', '10_what_is', '20_structure_base'];
83
- if (options.dbPrismaEnabled) {
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
+ }
@@ -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
- const projectDoc = readFile(path.join(targetRoot, 'docs', 'AI', 'PROJECT.md'));
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
- const projectDoc = readFile(path.join(targetRoot, 'docs', 'AI', 'PROJECT.md'));
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.match(projectDoc, /`infra` - Docker Compose \(always\) \+ proxy preset \(`caddy`\)/);
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
  }
@@ -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(`- docs: ${docsPath}`);
117
+ console.log(`- readme: ${docsPath}`);
118
118
  }
@@ -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, 'docs', 'AI', 'MODULES', 'README.md');
25
- if (!fs.existsSync(indexPath)) {
26
- fs.mkdirSync(path.dirname(indexPath), { recursive: true });
27
- fs.writeFileSync(
28
- indexPath,
29
- '# MODULES\n\nGenerated notes for module presets 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}.md`;
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, 'docs', 'AI', 'MODULES', `${preset.id}.md`);
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);
@@ -57,7 +57,7 @@ export function addModule({ moduleId, targetRoot, packageRoot, writeDocs = true
57
57
  targetRoot,
58
58
  preset,
59
59
  })
60
- : path.join(targetRoot, 'docs', 'AI', 'MODULES', `${preset.id}.md`);
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
- const note = fs.readFileSync(result.docsPath, 'utf8');
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();
@@ -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) && !fs.existsSync(targetScript)) {
97
+ if (fs.existsSync(sourceScript)) {
98
98
  fs.mkdirSync(path.dirname(targetScript), { recursive: true });
99
99
  fs.copyFileSync(sourceScript, targetScript);
100
100
  }
@@ -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 selects fragments based on chosen flags and writes final docs into project root and `docs/AI`.
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
- - Project docs index: `docs/README.md`
6
- - AI workflow docs: `docs/AI/*`
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`