create-forgeon 0.2.3 → 0.2.5

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.3",
3
+ "version": "0.2.5",
4
4
  "description": "Forgeon project generator CLI",
5
5
  "license": "MIT",
6
6
  "author": "Forgeon",
@@ -3,8 +3,10 @@ import path from 'node:path';
3
3
  import { copyRecursive, writeJson } from '../utils/fs.mjs';
4
4
  import {
5
5
  ensureBuildSteps,
6
+ ensureClassMember,
6
7
  ensureDependency,
7
8
  ensureDevDependency,
9
+ ensureImportLine,
8
10
  ensureLineAfter,
9
11
  ensureLineBefore,
10
12
  ensureLoadItem,
@@ -118,11 +120,7 @@ function patchHealthController(targetRoot) {
118
120
  content = ensureNestCommonImport(content, 'Post');
119
121
 
120
122
  if (!content.includes("from '@forgeon/db-prisma';")) {
121
- const nestCommonImport = content.match(/import\s*\{[^}]*\}\s*from '@nestjs\/common';/m)?.[0];
122
- const anchor = content.includes("import { I18nService } from 'nestjs-i18n';")
123
- ? "import { I18nService } from 'nestjs-i18n';"
124
- : nestCommonImport;
125
- content = ensureLineAfter(content, anchor, "import { PrismaService } from '@forgeon/db-prisma';");
123
+ content = ensureImportLine(content, "import { PrismaService } from '@forgeon/db-prisma';");
126
124
  }
127
125
 
128
126
  if (!content.includes('private readonly prisma: PrismaService')) {
@@ -167,17 +165,7 @@ function patchHealthController(targetRoot) {
167
165
  };
168
166
  }
169
167
  `;
170
- const translateIndex = content.indexOf('private translate(');
171
- if (translateIndex > -1) {
172
- content = `${content.slice(0, translateIndex).trimEnd()}\n\n${dbMethod}\n${content.slice(translateIndex)}`;
173
- } else {
174
- const classEnd = content.lastIndexOf('\n}');
175
- if (classEnd >= 0) {
176
- content = `${content.slice(0, classEnd).trimEnd()}\n\n${dbMethod}\n${content.slice(classEnd)}`;
177
- } else {
178
- content = `${content.trimEnd()}\n${dbMethod}\n`;
179
- }
180
- }
168
+ content = ensureClassMember(content, 'HealthController', dbMethod, { beforeNeedle: 'private translate(' });
181
169
  }
182
170
 
183
171
  fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
@@ -1053,6 +1053,59 @@ describe('addModule', () => {
1053
1053
  }
1054
1054
  });
1055
1055
 
1056
+ it('keeps health controller valid for add sequence jwt-auth -> logger -> swagger -> i18n -> db-prisma on db/i18n-disabled scaffold', () => {
1057
+ const targetRoot = mkTmp('forgeon-module-seq-health-valid-');
1058
+ const projectRoot = path.join(targetRoot, 'demo-seq-health-valid');
1059
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
1060
+
1061
+ try {
1062
+ scaffoldProject({
1063
+ templateRoot,
1064
+ packageRoot,
1065
+ targetRoot: projectRoot,
1066
+ projectName: 'demo-seq-health-valid',
1067
+ frontend: 'react',
1068
+ db: 'prisma',
1069
+ dbPrismaEnabled: false,
1070
+ i18nEnabled: false,
1071
+ proxy: 'caddy',
1072
+ });
1073
+
1074
+ for (const moduleId of ['jwt-auth', 'logger', 'swagger', 'i18n', 'db-prisma']) {
1075
+ addModule({ moduleId, targetRoot: projectRoot, packageRoot });
1076
+ }
1077
+
1078
+ const healthController = fs.readFileSync(
1079
+ path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
1080
+ 'utf8',
1081
+ );
1082
+
1083
+ const classStart = healthController.indexOf('export class HealthController {');
1084
+ const classEnd = healthController.lastIndexOf('\n}');
1085
+ assert.equal(classStart > -1, true);
1086
+ assert.equal(classEnd > classStart, true);
1087
+
1088
+ const imports = [...healthController.matchAll(/^import\s.+;$/gm)];
1089
+ assert.equal(imports.length > 0, true);
1090
+ for (const importLine of imports) {
1091
+ assert.equal(importLine.index < classStart, true);
1092
+ }
1093
+
1094
+ const authProbe = healthController.indexOf("@Get('auth')");
1095
+ const dbProbe = healthController.indexOf("@Post('db')");
1096
+ const translateMethod = healthController.indexOf('private translate(');
1097
+ assert.equal(authProbe > classStart && authProbe < classEnd, true);
1098
+ assert.equal(dbProbe > classStart && dbProbe < classEnd, true);
1099
+ assert.equal(translateMethod > classStart && translateMethod < classEnd, true);
1100
+
1101
+ assert.match(healthController, /private readonly authService: AuthService/);
1102
+ assert.match(healthController, /private readonly i18n: I18nService/);
1103
+ assert.match(healthController, /private readonly prisma: PrismaService/);
1104
+ } finally {
1105
+ fs.rmSync(targetRoot, { recursive: true, force: true });
1106
+ }
1107
+ });
1108
+
1056
1109
  it('applies swagger then jwt-auth without forcing swagger dependency in auth-api', () => {
1057
1110
  const targetRoot = mkTmp('forgeon-module-jwt-swagger-');
1058
1111
  const projectRoot = path.join(targetRoot, 'demo-jwt-swagger');
@@ -3,7 +3,9 @@ import path from 'node:path';
3
3
  import { copyRecursive, writeJson } from '../utils/fs.mjs';
4
4
  import {
5
5
  ensureBuildSteps,
6
+ ensureClassMember,
6
7
  ensureDependency,
8
+ ensureImportLine,
7
9
  ensureLineAfter,
8
10
  ensureLineBefore,
9
11
  ensureLoadItem,
@@ -288,19 +290,7 @@ function patchHealthController(targetRoot) {
288
290
 
289
291
  let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
290
292
  if (!content.includes("from 'nestjs-i18n';")) {
291
- if (content.includes("import { PrismaService } from '@forgeon/db-prisma';")) {
292
- content = ensureLineAfter(
293
- content,
294
- "import { PrismaService } from '@forgeon/db-prisma';",
295
- "import { I18nService } from 'nestjs-i18n';",
296
- );
297
- } else {
298
- content = ensureLineAfter(
299
- content,
300
- "import { BadRequestException, ConflictException, Controller, Get, Post, Query } from '@nestjs/common';",
301
- "import { I18nService } from 'nestjs-i18n';",
302
- );
303
- }
293
+ content = ensureImportLine(content, "import { I18nService } from 'nestjs-i18n';");
304
294
  }
305
295
 
306
296
  if (!content.includes('private readonly i18n: I18nService')) {
@@ -308,11 +298,22 @@ function patchHealthController(targetRoot) {
308
298
  if (constructorMatch) {
309
299
  const original = constructorMatch[0];
310
300
  const inner = constructorMatch[1].trimEnd();
311
- const separator = inner.length > 0 ? ',' : '';
312
- const next = `constructor(${inner}${separator}
301
+ const normalizedInner = inner.replace(/,\s*$/, '');
302
+ const separator = normalizedInner.length > 0 ? ',' : '';
303
+ const next = `constructor(${normalizedInner}${separator}
313
304
  private readonly i18n: I18nService,
314
305
  ) {`;
315
306
  content = content.replace(original, next);
307
+ } else {
308
+ const classAnchor = 'export class HealthController {';
309
+ if (content.includes(classAnchor)) {
310
+ content = content.replace(
311
+ classAnchor,
312
+ `${classAnchor}
313
+ constructor(private readonly i18n: I18nService) {}
314
+ `,
315
+ );
316
+ }
316
317
  }
317
318
  }
318
319
 
@@ -323,9 +324,10 @@ function patchHealthController(targetRoot) {
323
324
  return typeof value === 'string' ? value : key;
324
325
  }
325
326
  `;
326
- content = `${content.trimEnd()}\n${translateMethod}\n`;
327
+ content = ensureClassMember(content, 'HealthController', translateMethod);
327
328
  }
328
329
 
330
+ content = content.replace(/getHealth\(\)/g, "getHealth(@Query('lang') lang?: string)");
329
331
  content = content.replace(
330
332
  /getHealth\(@Query\('lang'\)\s*_lang\?:\s*string\)/g,
331
333
  "getHealth(@Query('lang') lang?: string)",
@@ -339,6 +341,7 @@ function patchHealthController(targetRoot) {
339
341
  "getValidationProbe(@Query('value') value?: string, @Query('lang') lang?: string)",
340
342
  );
341
343
  content = content.replace(/message:\s*'OK',/g, "message: this.translate('common.actions.ok', lang),");
344
+ content = content.replace(/i18n:\s*'disabled',/g, "i18n: 'en',");
342
345
  content = content.replace(/i18n:\s*'English',/g, "i18n: 'en',");
343
346
 
344
347
  content = content.replace(
@@ -3,7 +3,9 @@ import path from 'node:path';
3
3
  import { copyRecursive, writeJson } from '../utils/fs.mjs';
4
4
  import {
5
5
  ensureBuildSteps,
6
+ ensureClassMember,
6
7
  ensureDependency,
8
+ ensureImportLine,
7
9
  ensureLineAfter,
8
10
  ensureLineBefore,
9
11
  ensureLoadItem,
@@ -117,20 +119,7 @@ function patchHealthController(targetRoot) {
117
119
  let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
118
120
 
119
121
  if (!content.includes("from '@forgeon/auth-api';")) {
120
- const nestCommonImport = content.match(/import\s*\{[^}]*\}\s*from '@nestjs\/common';/m)?.[0];
121
- if (content.includes("import { PrismaService } from '@forgeon/db-prisma';")) {
122
- content = ensureLineAfter(
123
- content,
124
- "import { PrismaService } from '@forgeon/db-prisma';",
125
- "import { AuthService } from '@forgeon/auth-api';",
126
- );
127
- } else {
128
- content = ensureLineAfter(
129
- content,
130
- nestCommonImport ?? "import { Controller, Get } from '@nestjs/common';",
131
- "import { AuthService } from '@forgeon/auth-api';",
132
- );
133
- }
122
+ content = ensureImportLine(content, "import { AuthService } from '@forgeon/auth-api';");
134
123
  }
135
124
 
136
125
  if (!content.includes('private readonly authService: AuthService')) {
@@ -164,19 +153,8 @@ function patchHealthController(targetRoot) {
164
153
  return this.authService.getProbeStatus();
165
154
  }
166
155
  `;
167
- if (content.includes("@Post('db')")) {
168
- content = content.replace("@Post('db')", `${method}\n @Post('db')`);
169
- } else if (content.includes('private translate(')) {
170
- const index = content.indexOf('private translate(');
171
- content = `${content.slice(0, index).trimEnd()}\n\n${method}\n${content.slice(index)}`;
172
- } else {
173
- const classEnd = content.lastIndexOf('\n}');
174
- if (classEnd >= 0) {
175
- content = `${content.slice(0, classEnd).trimEnd()}\n\n${method}\n${content.slice(classEnd)}`;
176
- } else {
177
- content = `${content.trimEnd()}\n${method}\n`;
178
- }
179
- }
156
+ const beforeNeedle = content.includes("@Post('db')") ? "@Post('db')" : 'private translate(';
157
+ content = ensureClassMember(content, 'HealthController', method, { beforeNeedle });
180
158
  }
181
159
 
182
160
  fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
@@ -73,6 +73,81 @@ export function ensureLineBefore(content, anchorLine, lineToInsert) {
73
73
  return `${content.slice(0, index)}${lineToInsert}\n${content.slice(index)}`;
74
74
  }
75
75
 
76
+ export function ensureImportLine(content, importLine) {
77
+ if (content.includes(importLine)) {
78
+ return content;
79
+ }
80
+
81
+ const importMatches = [...content.matchAll(/^import\s.+;$/gm)];
82
+ if (importMatches.length === 0) {
83
+ return `${importLine}\n${content}`;
84
+ }
85
+
86
+ const lastImport = importMatches.at(-1);
87
+ const insertAt = lastImport.index + lastImport[0].length;
88
+ return `${content.slice(0, insertAt)}\n${importLine}${content.slice(insertAt)}`;
89
+ }
90
+
91
+ function findClassRange(content, className) {
92
+ const classPattern = new RegExp(`export\\s+class\\s+${className}\\b`);
93
+ const classMatch = classPattern.exec(content);
94
+ if (!classMatch) {
95
+ return null;
96
+ }
97
+
98
+ const openBrace = content.indexOf('{', classMatch.index);
99
+ if (openBrace < 0) {
100
+ return null;
101
+ }
102
+
103
+ let depth = 0;
104
+ for (let index = openBrace; index < content.length; index += 1) {
105
+ const char = content[index];
106
+ if (char === '{') {
107
+ depth += 1;
108
+ } else if (char === '}') {
109
+ depth -= 1;
110
+ if (depth === 0) {
111
+ return {
112
+ classStart: classMatch.index,
113
+ bodyStart: openBrace + 1,
114
+ classEnd: index,
115
+ };
116
+ }
117
+ }
118
+ }
119
+
120
+ return null;
121
+ }
122
+
123
+ export function ensureClassMember(content, className, memberCode, options = {}) {
124
+ const member = memberCode.trim();
125
+ if (member.length === 0) {
126
+ return content;
127
+ }
128
+
129
+ const range = findClassRange(content, className);
130
+ if (!range) {
131
+ return `${content.trimEnd()}\n${member}\n`;
132
+ }
133
+
134
+ const classBody = content.slice(range.bodyStart, range.classEnd);
135
+ if (classBody.includes(member)) {
136
+ return content;
137
+ }
138
+
139
+ let insertAt = range.classEnd;
140
+ const beforeNeedle = options.beforeNeedle;
141
+ if (typeof beforeNeedle === 'string' && beforeNeedle.length > 0) {
142
+ const needleIndex = classBody.indexOf(beforeNeedle);
143
+ if (needleIndex >= 0) {
144
+ insertAt = range.bodyStart + needleIndex;
145
+ }
146
+ }
147
+
148
+ return `${content.slice(0, insertAt)}\n\n${member}\n${content.slice(insertAt)}`;
149
+ }
150
+
76
151
  export function upsertEnvLines(filePath, lines) {
77
152
  let content = '';
78
153
  if (fs.existsSync(filePath)) {
@@ -72,12 +72,12 @@ const INTEGRATION_GROUPS = [
72
72
  {
73
73
  id: 'auth-persistence',
74
74
  title: 'Auth Persistence Integration',
75
- modules: ['jwt-auth', 'db-prisma', 'core-config'],
75
+ modules: ['jwt-auth', 'db-prisma'],
76
76
  description: [
77
- 'Register Prisma refresh-token store in AuthModule',
78
- 'Wire AUTH_REFRESH_TOKEN_STORE provider to Prisma store',
79
- 'Extend Prisma User model with refreshTokenHash',
80
- 'Add auth persistence migration and update README note',
77
+ 'Patch AppModule to wire AUTH_REFRESH_TOKEN_STORE with PrismaAuthRefreshTokenStore',
78
+ 'Add apps/api/src/auth/prisma-auth-refresh-token.store.ts',
79
+ 'Extend Prisma User model with refreshTokenHash and add migration 0002_auth_refresh_token_hash',
80
+ 'Update JWT auth README note about refresh-token persistence',
81
81
  ],
82
82
  isAvailable: (detected) => detected.jwtAuth && detected.dbPrisma,
83
83
  isPending: (rootDir) => isAuthPersistencePending(rootDir),