create-forgeon 0.1.34 → 0.1.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +1 -0
  2. package/package.json +1 -1
  3. package/src/modules/db-prisma.mjs +401 -0
  4. package/src/modules/executor.mjs +4 -0
  5. package/src/modules/executor.test.mjs +585 -13
  6. package/src/modules/i18n.mjs +244 -22
  7. package/src/modules/jwt-auth.mjs +612 -0
  8. package/src/modules/logger.mjs +76 -27
  9. package/src/modules/registry.mjs +15 -7
  10. package/src/modules/swagger.mjs +12 -2
  11. package/templates/module-fragments/db-prisma/00_title.md +6 -0
  12. package/templates/module-fragments/db-prisma/10_overview.md +10 -0
  13. package/templates/module-fragments/db-prisma/20_scope.md +14 -0
  14. package/templates/module-fragments/db-prisma/90_status_implemented.md +4 -0
  15. package/templates/module-fragments/jwt-auth/20_scope.md +17 -7
  16. package/templates/module-fragments/jwt-auth/90_status_implemented.md +7 -0
  17. package/templates/module-presets/jwt-auth/apps/api/prisma/migrations/0002_auth_refresh_token_hash/migration.sql +3 -0
  18. package/templates/module-presets/jwt-auth/apps/api/src/auth/prisma-auth-refresh-token.store.ts +36 -0
  19. package/templates/module-presets/jwt-auth/packages/auth-api/package.json +28 -0
  20. package/templates/module-presets/jwt-auth/packages/auth-api/src/auth-config.loader.ts +27 -0
  21. package/templates/module-presets/jwt-auth/packages/auth-api/src/auth-config.module.ts +8 -0
  22. package/templates/module-presets/jwt-auth/packages/auth-api/src/auth-config.service.ts +36 -0
  23. package/templates/module-presets/jwt-auth/packages/auth-api/src/auth-env.schema.ts +19 -0
  24. package/templates/module-presets/jwt-auth/packages/auth-api/src/auth-refresh-token.store.ts +23 -0
  25. package/templates/module-presets/jwt-auth/packages/auth-api/src/auth.controller.ts +71 -0
  26. package/templates/module-presets/jwt-auth/packages/auth-api/src/auth.service.ts +155 -0
  27. package/templates/module-presets/jwt-auth/packages/auth-api/src/auth.types.ts +6 -0
  28. package/templates/module-presets/jwt-auth/packages/auth-api/src/dto/index.ts +2 -0
  29. package/templates/module-presets/jwt-auth/packages/auth-api/src/dto/login.dto.ts +11 -0
  30. package/templates/module-presets/jwt-auth/packages/auth-api/src/dto/refresh.dto.ts +8 -0
  31. package/templates/module-presets/jwt-auth/packages/auth-api/src/forgeon-auth.module.ts +47 -0
  32. package/templates/module-presets/jwt-auth/packages/auth-api/src/index.ts +12 -0
  33. package/templates/module-presets/jwt-auth/packages/auth-api/src/jwt-auth.guard.ts +5 -0
  34. package/templates/module-presets/jwt-auth/packages/auth-api/src/jwt.strategy.ts +20 -0
  35. package/templates/module-presets/jwt-auth/packages/auth-api/tsconfig.json +9 -0
  36. package/templates/module-presets/jwt-auth/packages/auth-contracts/package.json +21 -0
  37. package/templates/module-presets/jwt-auth/packages/auth-contracts/src/index.ts +47 -0
  38. package/templates/module-presets/jwt-auth/packages/auth-contracts/tsconfig.json +9 -0
@@ -0,0 +1,612 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { copyRecursive, writeJson } from '../utils/fs.mjs';
4
+
5
+ const JWT_README_START = '<!-- forgeon:jwt-auth:start -->';
6
+ const JWT_README_END = '<!-- forgeon:jwt-auth:end -->';
7
+
8
+ function copyFromPreset(packageRoot, targetRoot, relativePath) {
9
+ const source = path.join(packageRoot, 'templates', 'module-presets', 'jwt-auth', relativePath);
10
+ if (!fs.existsSync(source)) {
11
+ throw new Error(`Missing jwt-auth preset template: ${source}`);
12
+ }
13
+ const destination = path.join(targetRoot, relativePath);
14
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
15
+ copyRecursive(source, destination);
16
+ }
17
+
18
+ function ensureDependency(packageJson, name, version) {
19
+ if (!packageJson.dependencies) {
20
+ packageJson.dependencies = {};
21
+ }
22
+ packageJson.dependencies[name] = version;
23
+ }
24
+
25
+ function ensureBuildSteps(packageJson, scriptName, requiredCommands) {
26
+ if (!packageJson.scripts) {
27
+ packageJson.scripts = {};
28
+ }
29
+
30
+ const current = packageJson.scripts[scriptName];
31
+ const steps =
32
+ typeof current === 'string' && current.trim().length > 0
33
+ ? current
34
+ .split('&&')
35
+ .map((item) => item.trim())
36
+ .filter(Boolean)
37
+ : [];
38
+
39
+ for (const command of requiredCommands) {
40
+ if (!steps.includes(command)) {
41
+ steps.push(command);
42
+ }
43
+ }
44
+
45
+ if (steps.length > 0) {
46
+ packageJson.scripts[scriptName] = steps.join(' && ');
47
+ }
48
+ }
49
+
50
+ function ensureLineAfter(content, anchorLine, lineToInsert) {
51
+ if (content.includes(lineToInsert)) {
52
+ return content;
53
+ }
54
+
55
+ const index = content.indexOf(anchorLine);
56
+ if (index < 0) {
57
+ return `${content.trimEnd()}\n${lineToInsert}\n`;
58
+ }
59
+
60
+ const insertAt = index + anchorLine.length;
61
+ return `${content.slice(0, insertAt)}\n${lineToInsert}${content.slice(insertAt)}`;
62
+ }
63
+
64
+ function ensureLineBefore(content, anchorLine, lineToInsert) {
65
+ if (content.includes(lineToInsert)) {
66
+ return content;
67
+ }
68
+
69
+ const index = content.indexOf(anchorLine);
70
+ if (index < 0) {
71
+ return `${content.trimEnd()}\n${lineToInsert}\n`;
72
+ }
73
+
74
+ return `${content.slice(0, index)}${lineToInsert}\n${content.slice(index)}`;
75
+ }
76
+
77
+ function ensureLoadItem(content, itemName) {
78
+ const pattern = /load:\s*\[([^\]]*)\]/m;
79
+ const match = content.match(pattern);
80
+ if (!match) {
81
+ return content;
82
+ }
83
+
84
+ const rawList = match[1];
85
+ const items = rawList
86
+ .split(',')
87
+ .map((item) => item.trim())
88
+ .filter(Boolean);
89
+
90
+ if (!items.includes(itemName)) {
91
+ items.push(itemName);
92
+ }
93
+
94
+ const next = `load: [${items.join(', ')}]`;
95
+ return content.replace(pattern, next);
96
+ }
97
+
98
+ function ensureValidatorSchema(content, schemaName) {
99
+ const pattern = /validate:\s*createEnvValidator\(\[([^\]]*)\]\)/m;
100
+ const match = content.match(pattern);
101
+ if (!match) {
102
+ return content;
103
+ }
104
+
105
+ const rawList = match[1];
106
+ const items = rawList
107
+ .split(',')
108
+ .map((item) => item.trim())
109
+ .filter(Boolean);
110
+
111
+ if (!items.includes(schemaName)) {
112
+ items.push(schemaName);
113
+ }
114
+
115
+ const next = `validate: createEnvValidator([${items.join(', ')}])`;
116
+ return content.replace(pattern, next);
117
+ }
118
+
119
+ function upsertEnvLines(filePath, lines) {
120
+ let content = '';
121
+ if (fs.existsSync(filePath)) {
122
+ content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
123
+ }
124
+
125
+ const keys = new Set(
126
+ content
127
+ .split('\n')
128
+ .filter(Boolean)
129
+ .map((line) => line.split('=')[0]),
130
+ );
131
+
132
+ const append = [];
133
+ for (const line of lines) {
134
+ const key = line.split('=')[0];
135
+ if (!keys.has(key)) {
136
+ append.push(line);
137
+ }
138
+ }
139
+
140
+ const next =
141
+ append.length > 0 ? `${content.trimEnd()}\n${append.join('\n')}\n` : `${content.trimEnd()}\n`;
142
+ fs.writeFileSync(filePath, next.replace(/^\n/, ''), 'utf8');
143
+ }
144
+
145
+ function detectDbAdapter(targetRoot) {
146
+ const apiPackagePath = path.join(targetRoot, 'apps', 'api', 'package.json');
147
+ let deps = {};
148
+ if (fs.existsSync(apiPackagePath)) {
149
+ const packageJson = JSON.parse(fs.readFileSync(apiPackagePath, 'utf8'));
150
+ deps = {
151
+ ...(packageJson.dependencies ?? {}),
152
+ ...(packageJson.devDependencies ?? {}),
153
+ };
154
+ }
155
+
156
+ if (
157
+ deps['@forgeon/db-prisma'] ||
158
+ fs.existsSync(path.join(targetRoot, 'packages', 'db-prisma', 'package.json'))
159
+ ) {
160
+ return { id: 'db-prisma', supported: true, tokenStore: 'prisma' };
161
+ }
162
+
163
+ const dbDeps = Object.keys(deps).filter((name) => name.startsWith('@forgeon/db-'));
164
+ if (dbDeps.length > 0) {
165
+ return { id: dbDeps[0], supported: false, tokenStore: 'none' };
166
+ }
167
+
168
+ const packagesPath = path.join(targetRoot, 'packages');
169
+ if (fs.existsSync(packagesPath)) {
170
+ const localDbPackages = fs
171
+ .readdirSync(packagesPath, { withFileTypes: true })
172
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith('db-'))
173
+ .map((entry) => entry.name);
174
+ if (localDbPackages.includes('db-prisma')) {
175
+ return { id: 'db-prisma', supported: true, tokenStore: 'prisma' };
176
+ }
177
+ if (localDbPackages.length > 0) {
178
+ return { id: `@forgeon/${localDbPackages[0]}`, supported: false, tokenStore: 'none' };
179
+ }
180
+ }
181
+
182
+ return null;
183
+ }
184
+
185
+ function printDbWarning(message) {
186
+ console.error(`\x1b[31m[create-forgeon add jwt-auth] ${message}\x1b[0m`);
187
+ }
188
+
189
+ function patchApiPackage(targetRoot) {
190
+ const packagePath = path.join(targetRoot, 'apps', 'api', 'package.json');
191
+ if (!fs.existsSync(packagePath)) {
192
+ return;
193
+ }
194
+
195
+ const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
196
+ ensureDependency(packageJson, '@forgeon/auth-api', 'workspace:*');
197
+ ensureDependency(packageJson, '@forgeon/auth-contracts', 'workspace:*');
198
+
199
+ ensureBuildSteps(packageJson, 'predev', [
200
+ 'pnpm --filter @forgeon/auth-contracts build',
201
+ 'pnpm --filter @forgeon/auth-api build',
202
+ ]);
203
+
204
+ writeJson(packagePath, packageJson);
205
+ }
206
+
207
+ function patchAppModule(targetRoot, dbAdapter) {
208
+ const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'app.module.ts');
209
+ if (!fs.existsSync(filePath)) {
210
+ return;
211
+ }
212
+
213
+ const withPrismaStore = dbAdapter?.supported === true && dbAdapter?.id === 'db-prisma';
214
+
215
+ let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
216
+ if (!content.includes("from '@forgeon/auth-api';")) {
217
+ if (content.includes("import { ForgeonI18nModule, i18nConfig, i18nEnvSchema } from '@forgeon/i18n';")) {
218
+ content = ensureLineAfter(
219
+ content,
220
+ "import { ForgeonI18nModule, i18nConfig, i18nEnvSchema } from '@forgeon/i18n';",
221
+ "import { AUTH_REFRESH_TOKEN_STORE, authConfig, authEnvSchema, ForgeonAuthModule } from '@forgeon/auth-api';",
222
+ );
223
+ } else if (
224
+ content.includes("import { ForgeonLoggerModule, loggerConfig, loggerEnvSchema } from '@forgeon/logger';")
225
+ ) {
226
+ content = ensureLineAfter(
227
+ content,
228
+ "import { ForgeonLoggerModule, loggerConfig, loggerEnvSchema } from '@forgeon/logger';",
229
+ "import { AUTH_REFRESH_TOKEN_STORE, authConfig, authEnvSchema, ForgeonAuthModule } from '@forgeon/auth-api';",
230
+ );
231
+ } else if (
232
+ content.includes("import { ForgeonSwaggerModule, swaggerConfig, swaggerEnvSchema } from '@forgeon/swagger';")
233
+ ) {
234
+ content = ensureLineAfter(
235
+ content,
236
+ "import { ForgeonSwaggerModule, swaggerConfig, swaggerEnvSchema } from '@forgeon/swagger';",
237
+ "import { AUTH_REFRESH_TOKEN_STORE, authConfig, authEnvSchema, ForgeonAuthModule } from '@forgeon/auth-api';",
238
+ );
239
+ } else if (
240
+ content.includes("import { dbPrismaConfig, dbPrismaEnvSchema, DbPrismaModule } from '@forgeon/db-prisma';")
241
+ ) {
242
+ content = ensureLineAfter(
243
+ content,
244
+ "import { dbPrismaConfig, dbPrismaEnvSchema, DbPrismaModule } from '@forgeon/db-prisma';",
245
+ "import { AUTH_REFRESH_TOKEN_STORE, authConfig, authEnvSchema, ForgeonAuthModule } from '@forgeon/auth-api';",
246
+ );
247
+ } else {
248
+ content = ensureLineAfter(
249
+ content,
250
+ "import { ConfigModule } from '@nestjs/config';",
251
+ "import { AUTH_REFRESH_TOKEN_STORE, authConfig, authEnvSchema, ForgeonAuthModule } from '@forgeon/auth-api';",
252
+ );
253
+ }
254
+ }
255
+
256
+ if (withPrismaStore && !content.includes("./auth/prisma-auth-refresh-token.store")) {
257
+ content = ensureLineBefore(
258
+ content,
259
+ "import { HealthController } from './health/health.controller';",
260
+ "import { PrismaAuthRefreshTokenStore } from './auth/prisma-auth-refresh-token.store';",
261
+ );
262
+ }
263
+
264
+ content = ensureLoadItem(content, 'authConfig');
265
+ content = ensureValidatorSchema(content, 'authEnvSchema');
266
+
267
+ if (!content.includes('ForgeonAuthModule.register(')) {
268
+ const moduleBlock = withPrismaStore
269
+ ? ` ForgeonAuthModule.register({
270
+ imports: [DbPrismaModule],
271
+ refreshTokenStoreProvider: {
272
+ provide: AUTH_REFRESH_TOKEN_STORE,
273
+ useClass: PrismaAuthRefreshTokenStore,
274
+ },
275
+ }),`
276
+ : ` ForgeonAuthModule.register(),`;
277
+
278
+ if (content.includes(' ForgeonI18nModule.register({')) {
279
+ content = ensureLineBefore(content, ' ForgeonI18nModule.register({', moduleBlock);
280
+ } else if (content.includes(' DbPrismaModule,')) {
281
+ content = ensureLineAfter(content, ' DbPrismaModule,', moduleBlock);
282
+ } else if (content.includes(' ForgeonLoggerModule,')) {
283
+ content = ensureLineAfter(content, ' ForgeonLoggerModule,', moduleBlock);
284
+ } else if (content.includes(' ForgeonSwaggerModule,')) {
285
+ content = ensureLineAfter(content, ' ForgeonSwaggerModule,', moduleBlock);
286
+ } else {
287
+ content = ensureLineAfter(content, ' CoreErrorsModule,', moduleBlock);
288
+ }
289
+ }
290
+
291
+ fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
292
+ }
293
+
294
+ function patchHealthController(targetRoot) {
295
+ const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts');
296
+ if (!fs.existsSync(filePath)) {
297
+ return;
298
+ }
299
+
300
+ let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
301
+
302
+ if (!content.includes("from '@forgeon/auth-api';")) {
303
+ if (content.includes("import { PrismaService } from '@forgeon/db-prisma';")) {
304
+ content = ensureLineAfter(
305
+ content,
306
+ "import { PrismaService } from '@forgeon/db-prisma';",
307
+ "import { AuthService } from '@forgeon/auth-api';",
308
+ );
309
+ } else {
310
+ content = ensureLineAfter(
311
+ content,
312
+ "import { BadRequestException, ConflictException, Controller, Get, Post, Query } from '@nestjs/common';",
313
+ "import { AuthService } from '@forgeon/auth-api';",
314
+ );
315
+ }
316
+ }
317
+
318
+ if (!content.includes('private readonly authService: AuthService')) {
319
+ const constructorMatch = content.match(/constructor\(([\s\S]*?)\)\s*\{/m);
320
+ if (constructorMatch) {
321
+ const original = constructorMatch[0];
322
+ const inner = constructorMatch[1].trimEnd();
323
+ const normalizedInner = inner.replace(/,\s*$/, '');
324
+ const separator = normalizedInner.length > 0 ? ',' : '';
325
+ const next = `constructor(${normalizedInner}${separator}
326
+ private readonly authService: AuthService,
327
+ ) {`;
328
+ content = content.replace(original, next);
329
+ }
330
+ }
331
+
332
+ if (!content.includes("@Get('auth')")) {
333
+ const method = `
334
+ @Get('auth')
335
+ getAuthProbe() {
336
+ return this.authService.getProbeStatus();
337
+ }
338
+ `;
339
+ if (content.includes("@Post('db')")) {
340
+ content = content.replace("@Post('db')", `${method}\n @Post('db')`);
341
+ } else if (content.includes('private translate(')) {
342
+ const index = content.indexOf('private translate(');
343
+ content = `${content.slice(0, index).trimEnd()}\n\n${method}\n${content.slice(index)}`;
344
+ } else {
345
+ content = `${content.trimEnd()}\n${method}\n`;
346
+ }
347
+ }
348
+
349
+ fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
350
+ }
351
+
352
+ function patchWebApp(targetRoot) {
353
+ const filePath = path.join(targetRoot, 'apps', 'web', 'src', 'App.tsx');
354
+ if (!fs.existsSync(filePath)) {
355
+ return;
356
+ }
357
+
358
+ let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
359
+ if (!content.includes('authProbeResult')) {
360
+ content = content.replace(
361
+ ' const [dbProbeResult, setDbProbeResult] = useState<ProbeResult | null>(null);',
362
+ ` const [dbProbeResult, setDbProbeResult] = useState<ProbeResult | null>(null);
363
+ const [authProbeResult, setAuthProbeResult] = useState<ProbeResult | null>(null);`,
364
+ );
365
+ }
366
+
367
+ if (!content.includes('Check JWT auth probe')) {
368
+ const path = content.includes("runProbe(setHealthResult, '/health')") ? '/health/auth' : '/api/health/auth';
369
+ content = content.replace(
370
+ /<button onClick=\{\(\) => runProbe\(setErrorProbeResult,[\s\S]*?<\/button>/m,
371
+ (match) =>
372
+ `${match}
373
+ <button onClick={() => runProbe(setAuthProbeResult, '${path}')}>Check JWT auth probe</button>`,
374
+ );
375
+ }
376
+
377
+ if (!content.includes("renderResult('Auth probe response', authProbeResult)")) {
378
+ content = content.replace(
379
+ "{renderResult('DB probe response', dbProbeResult)}",
380
+ `{renderResult('DB probe response', dbProbeResult)}
381
+ {renderResult('Auth probe response', authProbeResult)}`,
382
+ );
383
+ }
384
+
385
+ fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
386
+ }
387
+
388
+ function patchApiDockerfile(targetRoot) {
389
+ const dockerfilePath = path.join(targetRoot, 'apps', 'api', 'Dockerfile');
390
+ if (!fs.existsSync(dockerfilePath)) {
391
+ return;
392
+ }
393
+
394
+ let content = fs.readFileSync(dockerfilePath, 'utf8').replace(/\r\n/g, '\n');
395
+ const packageAnchors = [
396
+ 'COPY packages/swagger/package.json packages/swagger/package.json',
397
+ 'COPY packages/logger/package.json packages/logger/package.json',
398
+ 'COPY packages/i18n/package.json packages/i18n/package.json',
399
+ 'COPY packages/db-prisma/package.json packages/db-prisma/package.json',
400
+ 'COPY packages/core/package.json packages/core/package.json',
401
+ ];
402
+ const packageAnchor = packageAnchors.find((line) => content.includes(line)) ?? packageAnchors.at(-1);
403
+ content = ensureLineAfter(
404
+ content,
405
+ packageAnchor,
406
+ 'COPY packages/auth-contracts/package.json packages/auth-contracts/package.json',
407
+ );
408
+ content = ensureLineAfter(
409
+ content,
410
+ 'COPY packages/auth-contracts/package.json packages/auth-contracts/package.json',
411
+ 'COPY packages/auth-api/package.json packages/auth-api/package.json',
412
+ );
413
+
414
+ const sourceAnchors = [
415
+ 'COPY packages/swagger packages/swagger',
416
+ 'COPY packages/logger packages/logger',
417
+ 'COPY packages/i18n packages/i18n',
418
+ 'COPY packages/db-prisma packages/db-prisma',
419
+ 'COPY packages/core packages/core',
420
+ ];
421
+ const sourceAnchor = sourceAnchors.find((line) => content.includes(line)) ?? sourceAnchors.at(-1);
422
+ content = ensureLineAfter(content, sourceAnchor, 'COPY packages/auth-contracts packages/auth-contracts');
423
+ content = ensureLineAfter(
424
+ content,
425
+ 'COPY packages/auth-contracts packages/auth-contracts',
426
+ 'COPY packages/auth-api packages/auth-api',
427
+ );
428
+
429
+ content = content
430
+ .replace(/^RUN pnpm --filter @forgeon\/auth-contracts build\r?\n?/gm, '')
431
+ .replace(/^RUN pnpm --filter @forgeon\/auth-api build\r?\n?/gm, '');
432
+
433
+ const buildAnchor = content.includes('RUN pnpm --filter @forgeon/api prisma:generate')
434
+ ? 'RUN pnpm --filter @forgeon/api prisma:generate'
435
+ : 'RUN pnpm --filter @forgeon/api build';
436
+ content = ensureLineBefore(content, buildAnchor, 'RUN pnpm --filter @forgeon/auth-contracts build');
437
+ content = ensureLineBefore(content, buildAnchor, 'RUN pnpm --filter @forgeon/auth-api build');
438
+
439
+ fs.writeFileSync(dockerfilePath, `${content.trimEnd()}\n`, 'utf8');
440
+ }
441
+
442
+ function patchCompose(targetRoot) {
443
+ const composePath = path.join(targetRoot, 'infra', 'docker', 'compose.yml');
444
+ if (!fs.existsSync(composePath)) {
445
+ return;
446
+ }
447
+
448
+ let content = fs.readFileSync(composePath, 'utf8').replace(/\r\n/g, '\n');
449
+ if (!content.includes('JWT_ACCESS_SECRET: ${JWT_ACCESS_SECRET}')) {
450
+ content = content.replace(
451
+ /^(\s+API_PREFIX:.*)$/m,
452
+ `$1
453
+ JWT_ACCESS_SECRET: \${JWT_ACCESS_SECRET}
454
+ JWT_ACCESS_EXPIRES_IN: \${JWT_ACCESS_EXPIRES_IN}
455
+ JWT_REFRESH_SECRET: \${JWT_REFRESH_SECRET}
456
+ JWT_REFRESH_EXPIRES_IN: \${JWT_REFRESH_EXPIRES_IN}
457
+ AUTH_BCRYPT_ROUNDS: \${AUTH_BCRYPT_ROUNDS}
458
+ AUTH_DEMO_EMAIL: \${AUTH_DEMO_EMAIL}
459
+ AUTH_DEMO_PASSWORD: \${AUTH_DEMO_PASSWORD}`,
460
+ );
461
+ }
462
+
463
+ fs.writeFileSync(composePath, `${content.trimEnd()}\n`, 'utf8');
464
+ }
465
+
466
+ function patchReadme(targetRoot, dbAdapter) {
467
+ const readmePath = path.join(targetRoot, 'README.md');
468
+ if (!fs.existsSync(readmePath)) {
469
+ return;
470
+ }
471
+
472
+ const persistenceSummary =
473
+ dbAdapter?.supported && dbAdapter.id === 'db-prisma'
474
+ ? '- refresh token persistence: enabled (`db-prisma` adapter)'
475
+ : '- refresh token persistence: disabled (no supported DB adapter found)';
476
+ const dbFollowUp =
477
+ dbAdapter?.supported && dbAdapter.id === 'db-prisma'
478
+ ? '- migration: `apps/api/prisma/migrations/0002_auth_refresh_token_hash`'
479
+ : `- to enable persistence later:
480
+ 1. install a DB module first (for now: \`create-forgeon add db-prisma --project .\`);
481
+ 2. run \`create-forgeon add jwt-auth --project .\` again to auto-wire the adapter.`;
482
+
483
+ const section = `${JWT_README_START}
484
+ ## JWT Auth Module
485
+
486
+ The jwt-auth add-module provides:
487
+ - \`@forgeon/auth-contracts\` shared auth routes/types/error codes
488
+ - \`@forgeon/auth-api\` Nest auth module (\`login\`, \`refresh\`, \`logout\`, \`me\`)
489
+ - JWT guard + passport strategy
490
+ - auth probe endpoint: \`GET /api/health/auth\`
491
+
492
+ Current mode:
493
+ ${persistenceSummary}
494
+ ${dbFollowUp}
495
+
496
+ Default demo credentials:
497
+ - \`AUTH_DEMO_EMAIL=demo@forgeon.local\`
498
+ - \`AUTH_DEMO_PASSWORD=forgeon-demo-password\`
499
+
500
+ Default routes:
501
+ - \`POST /api/auth/login\`
502
+ - \`POST /api/auth/refresh\`
503
+ - \`POST /api/auth/logout\`
504
+ - \`GET /api/auth/me\`
505
+ ${JWT_README_END}`;
506
+
507
+ let content = fs.readFileSync(readmePath, 'utf8').replace(/\r\n/g, '\n');
508
+ const sectionPattern = new RegExp(`${JWT_README_START}[\\s\\S]*?${JWT_README_END}`, 'm');
509
+ if (sectionPattern.test(content)) {
510
+ content = content.replace(sectionPattern, section);
511
+ } else if (content.includes('## Prisma In Docker Start')) {
512
+ content = content.replace('## Prisma In Docker Start', `${section}\n\n## Prisma In Docker Start`);
513
+ } else {
514
+ content = `${content.trimEnd()}\n\n${section}\n`;
515
+ }
516
+
517
+ fs.writeFileSync(readmePath, `${content.trimEnd()}\n`, 'utf8');
518
+ }
519
+
520
+ function patchPrismaSchema(targetRoot) {
521
+ const schemaPath = path.join(targetRoot, 'apps', 'api', 'prisma', 'schema.prisma');
522
+ if (!fs.existsSync(schemaPath)) {
523
+ return;
524
+ }
525
+
526
+ let content = fs.readFileSync(schemaPath, 'utf8').replace(/\r\n/g, '\n');
527
+ if (!content.includes('refreshTokenHash')) {
528
+ content = content.replace(
529
+ /email\s+String\s+@unique/g,
530
+ 'email String @unique\n refreshTokenHash String?',
531
+ );
532
+ fs.writeFileSync(schemaPath, `${content.trimEnd()}\n`, 'utf8');
533
+ }
534
+ }
535
+
536
+ function patchPrismaMigration(packageRoot, targetRoot) {
537
+ const migrationSource = path.join(
538
+ packageRoot,
539
+ 'templates',
540
+ 'module-presets',
541
+ 'jwt-auth',
542
+ 'apps',
543
+ 'api',
544
+ 'prisma',
545
+ 'migrations',
546
+ '0002_auth_refresh_token_hash',
547
+ );
548
+ const migrationTarget = path.join(
549
+ targetRoot,
550
+ 'apps',
551
+ 'api',
552
+ 'prisma',
553
+ 'migrations',
554
+ '0002_auth_refresh_token_hash',
555
+ );
556
+
557
+ if (!fs.existsSync(migrationTarget) && fs.existsSync(migrationSource)) {
558
+ copyRecursive(migrationSource, migrationTarget);
559
+ }
560
+ }
561
+
562
+ export function applyJwtAuthModule({ packageRoot, targetRoot }) {
563
+ const dbAdapter = detectDbAdapter(targetRoot);
564
+ const supportsPrismaStore = dbAdapter?.supported === true && dbAdapter?.id === 'db-prisma';
565
+
566
+ copyFromPreset(packageRoot, targetRoot, path.join('packages', 'auth-contracts'));
567
+ copyFromPreset(packageRoot, targetRoot, path.join('packages', 'auth-api'));
568
+
569
+ if (supportsPrismaStore) {
570
+ copyFromPreset(
571
+ packageRoot,
572
+ targetRoot,
573
+ path.join('apps', 'api', 'src', 'auth', 'prisma-auth-refresh-token.store.ts'),
574
+ );
575
+ patchPrismaSchema(targetRoot);
576
+ patchPrismaMigration(packageRoot, targetRoot);
577
+ } else {
578
+ const detected = dbAdapter?.id ? `detected: ${dbAdapter.id}` : 'no DB adapter detected';
579
+ printDbWarning(
580
+ `jwt-auth installed without persistent refresh token store (${detected}). ` +
581
+ 'Login/refresh works in stateless mode. Re-run add after supported DB module is installed.',
582
+ );
583
+ }
584
+
585
+ patchApiPackage(targetRoot);
586
+ patchAppModule(targetRoot, dbAdapter);
587
+ patchHealthController(targetRoot);
588
+ patchWebApp(targetRoot);
589
+ patchApiDockerfile(targetRoot);
590
+ patchCompose(targetRoot);
591
+ patchReadme(targetRoot, dbAdapter);
592
+
593
+ upsertEnvLines(path.join(targetRoot, 'apps', 'api', '.env.example'), [
594
+ 'JWT_ACCESS_SECRET=forgeon-access-secret-change-me',
595
+ 'JWT_ACCESS_EXPIRES_IN=15m',
596
+ 'JWT_REFRESH_SECRET=forgeon-refresh-secret-change-me',
597
+ 'JWT_REFRESH_EXPIRES_IN=7d',
598
+ 'AUTH_BCRYPT_ROUNDS=10',
599
+ 'AUTH_DEMO_EMAIL=demo@forgeon.local',
600
+ 'AUTH_DEMO_PASSWORD=forgeon-demo-password',
601
+ ]);
602
+
603
+ upsertEnvLines(path.join(targetRoot, 'infra', 'docker', '.env.example'), [
604
+ 'JWT_ACCESS_SECRET=forgeon-access-secret-change-me',
605
+ 'JWT_ACCESS_EXPIRES_IN=15m',
606
+ 'JWT_REFRESH_SECRET=forgeon-refresh-secret-change-me',
607
+ 'JWT_REFRESH_EXPIRES_IN=7d',
608
+ 'AUTH_BCRYPT_ROUNDS=10',
609
+ 'AUTH_DEMO_EMAIL=demo@forgeon.local',
610
+ 'AUTH_DEMO_PASSWORD=forgeon-demo-password',
611
+ ]);
612
+ }