create-forgeon 0.2.7 → 0.2.8

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-forgeon",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "Forgeon project generator CLI",
5
5
  "license": "MIT",
6
6
  "author": "Forgeon",
@@ -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) {
@@ -1145,6 +1145,80 @@ describe('addModule', () => {
1145
1145
  }
1146
1146
  });
1147
1147
 
1148
+ it('detects and applies jwt-auth + rbac claims integration explicitly', () => {
1149
+ const targetRoot = mkTmp('forgeon-module-jwt-rbac-');
1150
+ const projectRoot = path.join(targetRoot, 'demo-jwt-rbac');
1151
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
1152
+
1153
+ try {
1154
+ scaffoldProject({
1155
+ templateRoot,
1156
+ packageRoot,
1157
+ targetRoot: projectRoot,
1158
+ projectName: 'demo-jwt-rbac',
1159
+ frontend: 'react',
1160
+ db: 'prisma',
1161
+ dbPrismaEnabled: false,
1162
+ i18nEnabled: false,
1163
+ proxy: 'caddy',
1164
+ });
1165
+
1166
+ addModule({
1167
+ moduleId: 'rbac',
1168
+ targetRoot: projectRoot,
1169
+ packageRoot,
1170
+ });
1171
+ addModule({
1172
+ moduleId: 'jwt-auth',
1173
+ targetRoot: projectRoot,
1174
+ packageRoot,
1175
+ });
1176
+
1177
+ const scan = scanIntegrations({
1178
+ targetRoot: projectRoot,
1179
+ relatedModuleId: 'jwt-auth',
1180
+ });
1181
+ assert.equal(scan.groups.some((group) => group.id === 'auth-rbac-claims'), true);
1182
+
1183
+ const syncResult = syncIntegrations({
1184
+ targetRoot: projectRoot,
1185
+ packageRoot,
1186
+ groupIds: ['auth-rbac-claims'],
1187
+ });
1188
+ const claimsPair = syncResult.summary.find((item) => item.id === 'auth-rbac-claims');
1189
+ assert.ok(claimsPair);
1190
+ assert.equal(claimsPair.result.applied, true);
1191
+
1192
+ const authContracts = fs.readFileSync(
1193
+ path.join(projectRoot, 'packages', 'auth-contracts', 'src', 'index.ts'),
1194
+ 'utf8',
1195
+ );
1196
+ assert.match(authContracts, /permissions\?: string\[\];/);
1197
+
1198
+ const authService = fs.readFileSync(
1199
+ path.join(projectRoot, 'packages', 'auth-api', 'src', 'auth.service.ts'),
1200
+ 'utf8',
1201
+ );
1202
+ assert.match(authService, /permissions: \['health\.rbac'\]/);
1203
+ assert.match(authService, /permissions: user\.permissions,/);
1204
+ assert.match(
1205
+ authService,
1206
+ /permissions: Array\.isArray\(payload\.permissions\) \? payload\.permissions : \[\],/,
1207
+ );
1208
+
1209
+ const authController = fs.readFileSync(
1210
+ path.join(projectRoot, 'packages', 'auth-api', 'src', 'auth.controller.ts'),
1211
+ 'utf8',
1212
+ );
1213
+ assert.match(
1214
+ authController,
1215
+ /permissions: Array\.isArray\(payload\.permissions\) \? payload\.permissions : \[\],/,
1216
+ );
1217
+ } finally {
1218
+ fs.rmSync(targetRoot, { recursive: true, force: true });
1219
+ }
1220
+ });
1221
+
1148
1222
  it('applies logger then jwt-auth on db/i18n-disabled scaffold without breaking health controller syntax', () => {
1149
1223
  const targetRoot = mkTmp('forgeon-module-jwt-nodb-noi18n-');
1150
1224
  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) {