speedrun-cli 2.7.10 → 2.8.0
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/CHANGELOG.md +47 -0
- package/README.md +136 -491
- package/bin/cli.js +15 -0
- package/package.json +1 -1
- package/src/fieldManager.js +338 -191
- package/src/index.js +2 -0
- package/src/moduleConfigurator.js +336 -0
- package/src/moduleGenerator.js +84 -88
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module Configurator for speedrun-cli
|
|
3
|
+
* Allows dynamic customization of Auth/Roles Guards and active CRUD endpoints on existing controllers.
|
|
4
|
+
* @module moduleConfigurator
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const inquirer = require('inquirer');
|
|
8
|
+
const fs = require('fs-extra');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const chalk = require('chalk');
|
|
11
|
+
const {
|
|
12
|
+
toKebabCase,
|
|
13
|
+
toPascalCase,
|
|
14
|
+
toSingularPascal,
|
|
15
|
+
detectModulePrimaryKey,
|
|
16
|
+
} = require('./moduleGenerator');
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Detects whether JwtAuthGuard or AuthGuard is used in the project
|
|
20
|
+
*/
|
|
21
|
+
async function getGuardImportDetails(targetDir) {
|
|
22
|
+
const commonGuardsDir = path.join(targetDir, 'src', 'common', 'guards');
|
|
23
|
+
if (await fs.pathExists(path.join(commonGuardsDir, 'jwt-auth.guard.ts'))) {
|
|
24
|
+
return {
|
|
25
|
+
guardName: 'JwtAuthGuard',
|
|
26
|
+
guardImportPath: '../../common/guards/jwt-auth.guard',
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
if (await fs.pathExists(path.join(commonGuardsDir, 'auth.guard.ts'))) {
|
|
30
|
+
return {
|
|
31
|
+
guardName: 'AuthGuard',
|
|
32
|
+
guardImportPath: '../../common/guards/auth.guard',
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
guardName: 'JwtAuthGuard',
|
|
37
|
+
guardImportPath: '../../common/guards/jwt-auth.guard',
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Parses existing controller file to extract active CRUD operations and roles configuration
|
|
43
|
+
*/
|
|
44
|
+
function parseExistingController(controllerContent) {
|
|
45
|
+
const ops = {
|
|
46
|
+
create: /@Post\s*\(/m.test(controllerContent) && /override\s+async\s+create\s*\(/m.test(controllerContent),
|
|
47
|
+
findAll: /@Get\s*\(\s*\)/m.test(controllerContent) && /override\s+async\s+findAll\s*\(/m.test(controllerContent),
|
|
48
|
+
findOne: /@Get\s*\(\s*':/m.test(controllerContent) && /override\s+async\s+findOne\s*\(/m.test(controllerContent),
|
|
49
|
+
update: /@Put\s*\(/m.test(controllerContent) && /override\s+async\s+update\s*\(/m.test(controllerContent),
|
|
50
|
+
remove: /@Delete\s*\(/m.test(controllerContent) && /override\s+async\s+remove\s*\(/m.test(controllerContent),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const hasGuards = /@UseGuards\s*\(/m.test(controllerContent);
|
|
54
|
+
|
|
55
|
+
let roles = [];
|
|
56
|
+
const rolesMatch = controllerContent.match(/@Roles\s*\(\s*([^)]+)\s*\)/m) || controllerContent.match(/\/\/\s*Roles:\s*(.+)/m);
|
|
57
|
+
if (rolesMatch && rolesMatch[1]) {
|
|
58
|
+
roles = rolesMatch[1]
|
|
59
|
+
.split(',')
|
|
60
|
+
.map((r) => r.replace(/['"\s]/g, '').trim())
|
|
61
|
+
.filter(Boolean);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { ops, hasGuards, roles };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Generates pristine controller TypeScript file content
|
|
69
|
+
*/
|
|
70
|
+
function renderControllerContent({
|
|
71
|
+
pascalName,
|
|
72
|
+
kebabName,
|
|
73
|
+
primaryKey = 'id',
|
|
74
|
+
createDtoName,
|
|
75
|
+
updateDtoName,
|
|
76
|
+
responseDtoName,
|
|
77
|
+
ops = { create: true, findAll: true, findOne: true, update: true, remove: true },
|
|
78
|
+
protectWriteOps = false,
|
|
79
|
+
roles = ['ADMIN'],
|
|
80
|
+
guardName = 'JwtAuthGuard',
|
|
81
|
+
guardImportPath = '../../common/guards/jwt-auth.guard',
|
|
82
|
+
}) {
|
|
83
|
+
const formattedRoles = roles.map((r) => `'${r}'`).join(', ');
|
|
84
|
+
const guardDecorator = protectWriteOps && roles.length > 0
|
|
85
|
+
? `\n @UseGuards(${guardName}, RolesGuard)\n @Roles(${formattedRoles})`
|
|
86
|
+
: protectWriteOps
|
|
87
|
+
? `\n @UseGuards(${guardName})`
|
|
88
|
+
: '';
|
|
89
|
+
|
|
90
|
+
const extraGuardsImports = protectWriteOps
|
|
91
|
+
? `import { ${guardName} } from '${guardImportPath}';\nimport { RolesGuard } from '../../common/guards/roles.guard';\nimport { Roles } from '../../common/decorators/roles.decorator';\n`
|
|
92
|
+
: '';
|
|
93
|
+
|
|
94
|
+
const nestCommonImports = ['Controller'];
|
|
95
|
+
if (ops.findAll) nestCommonImports.push('Query');
|
|
96
|
+
if (ops.findOne || ops.update || ops.remove) nestCommonImports.push('Param', 'ParseUUIDPipe', 'HttpStatus');
|
|
97
|
+
if (ops.create) nestCommonImports.push('Post', 'Body');
|
|
98
|
+
if (ops.findAll || ops.findOne) nestCommonImports.push('Get');
|
|
99
|
+
if (ops.update) nestCommonImports.push('Put');
|
|
100
|
+
if (ops.remove) nestCommonImports.push('Delete');
|
|
101
|
+
nestCommonImports.push('Type');
|
|
102
|
+
if (protectWriteOps) nestCommonImports.push('UseGuards');
|
|
103
|
+
|
|
104
|
+
return `import { ${Array.from(new Set(nestCommonImports)).join(', ')} } from '@nestjs/common';
|
|
105
|
+
import { ApiTags, ApiBearerAuth, ApiExtraModels, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger';
|
|
106
|
+
import { BaseController, ApiResponseDto, ApiResponseSchema, PaginatedResponseDto, PaginatedResponseSchema, PaginationQueryDto } from '../../common/base';
|
|
107
|
+
${extraGuardsImports}import { ${pascalName}Service } from './${kebabName}.service';
|
|
108
|
+
${ops.create || ops.update ? `import { ${createDtoName} } from './dto/create-${kebabName}.dto';\nimport { ${updateDtoName} } from './dto/update-${kebabName}.dto';` : `type ${createDtoName} = any;\ntype ${updateDtoName} = any;`}
|
|
109
|
+
import { ${responseDtoName} } from './dto/${kebabName}.dto';
|
|
110
|
+
|
|
111
|
+
type ${pascalName}Entity = any;
|
|
112
|
+
|
|
113
|
+
@ApiTags('${pascalName}')
|
|
114
|
+
@ApiBearerAuth('bearer')
|
|
115
|
+
@ApiExtraModels(ApiResponseDto, PaginatedResponseDto, ${responseDtoName})
|
|
116
|
+
@Controller('${kebabName}')
|
|
117
|
+
export class ${pascalName}Controller extends BaseController<${pascalName}Entity, ${createDtoName}, ${updateDtoName}> {
|
|
118
|
+
constructor(protected readonly service: ${pascalName}Service) {
|
|
119
|
+
super(service);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
protected getDtoClass(): Type<${pascalName}Entity> {
|
|
123
|
+
return ${responseDtoName} as unknown as Type<${pascalName}Entity>;
|
|
124
|
+
}
|
|
125
|
+
${ops.create ? `
|
|
126
|
+
@Post()${guardDecorator}
|
|
127
|
+
@ApiOperation({ summary: 'Create a new ${kebabName}' })
|
|
128
|
+
@ApiResponse({ status: HttpStatus.CREATED, schema: ApiResponseSchema(${responseDtoName}) })
|
|
129
|
+
override async create(@Body() dto: ${createDtoName}): Promise<ApiResponseDto<${pascalName}Entity>> {
|
|
130
|
+
return super.create(dto);
|
|
131
|
+
}
|
|
132
|
+
` : ''}${ops.findAll ? `
|
|
133
|
+
@Get()
|
|
134
|
+
@ApiOperation({ summary: 'Get all ${kebabName} (paginated)' })
|
|
135
|
+
@ApiResponse({ status: HttpStatus.OK, schema: PaginatedResponseSchema(${responseDtoName}) })
|
|
136
|
+
override async findAll(@Query() pagination: PaginationQueryDto): Promise<PaginatedResponseDto<${pascalName}Entity>> {
|
|
137
|
+
return super.findAll(pagination);
|
|
138
|
+
}
|
|
139
|
+
` : ''}${ops.findOne ? `
|
|
140
|
+
@Get(':${primaryKey}')
|
|
141
|
+
@ApiOperation({ summary: 'Get ${kebabName} by ID' })
|
|
142
|
+
@ApiParam({ name: '${primaryKey}', format: 'uuid' })
|
|
143
|
+
@ApiResponse({ status: HttpStatus.OK, schema: ApiResponseSchema(${responseDtoName}) })
|
|
144
|
+
@ApiResponse({ status: HttpStatus.NOT_FOUND, description: '${pascalName} not found' })
|
|
145
|
+
override async findOne(@Param('${primaryKey}', new ParseUUIDPipe({ version: '4', errorHttpStatusCode: HttpStatus.BAD_REQUEST })) ${primaryKey}: string): Promise<ApiResponseDto<${pascalName}Entity>> {
|
|
146
|
+
return super.findOne(${primaryKey});
|
|
147
|
+
}
|
|
148
|
+
` : ''}${ops.update ? `
|
|
149
|
+
@Put(':${primaryKey}')${guardDecorator}
|
|
150
|
+
@ApiOperation({ summary: 'Update ${kebabName} by ID' })
|
|
151
|
+
@ApiParam({ name: '${primaryKey}', format: 'uuid' })
|
|
152
|
+
@ApiResponse({ status: HttpStatus.OK, schema: ApiResponseSchema(${responseDtoName}) })
|
|
153
|
+
@ApiResponse({ status: HttpStatus.NOT_FOUND, description: '${pascalName} not found' })
|
|
154
|
+
override async update(
|
|
155
|
+
@Param('${primaryKey}', new ParseUUIDPipe({ version: '4', errorHttpStatusCode: HttpStatus.BAD_REQUEST })) ${primaryKey}: string,
|
|
156
|
+
@Body() dto: ${updateDtoName}
|
|
157
|
+
): Promise<ApiResponseDto<${pascalName}Entity>> {
|
|
158
|
+
return super.update(${primaryKey}, dto);
|
|
159
|
+
}
|
|
160
|
+
` : ''}${ops.remove ? `
|
|
161
|
+
@Delete(':${primaryKey}')${guardDecorator}
|
|
162
|
+
@ApiOperation({ summary: 'Delete ${kebabName} by ID' })
|
|
163
|
+
@ApiParam({ name: '${primaryKey}', format: 'uuid' })
|
|
164
|
+
@ApiResponse({ status: HttpStatus.OK, schema: ApiResponseSchema(${responseDtoName}) })
|
|
165
|
+
@ApiResponse({ status: HttpStatus.NOT_FOUND, description: '${pascalName} not found' })
|
|
166
|
+
override async remove(@Param('${primaryKey}', new ParseUUIDPipe({ version: '4', errorHttpStatusCode: HttpStatus.BAD_REQUEST })) ${primaryKey}: string): Promise<ApiResponseDto<${pascalName}Entity>> {
|
|
167
|
+
return super.remove(${primaryKey});
|
|
168
|
+
}
|
|
169
|
+
` : ''}
|
|
170
|
+
}
|
|
171
|
+
`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Main Interactive Module Configurator Entry Point
|
|
176
|
+
*/
|
|
177
|
+
async function configureModule(providedModuleName, targetDir = process.cwd()) {
|
|
178
|
+
try {
|
|
179
|
+
let moduleName = providedModuleName;
|
|
180
|
+
|
|
181
|
+
if (!moduleName) {
|
|
182
|
+
const nameAnswer = await inquirer.prompt([{
|
|
183
|
+
type: 'input',
|
|
184
|
+
name: 'moduleName',
|
|
185
|
+
message: 'Which module do you want to configure? (e.g., orders, products)',
|
|
186
|
+
validate: (input) => (input && input.trim() ? true : 'Module name is required'),
|
|
187
|
+
}]);
|
|
188
|
+
moduleName = nameAnswer.moduleName.trim();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const kebabName = toKebabCase(moduleName);
|
|
192
|
+
const pascalName = toPascalCase(moduleName);
|
|
193
|
+
const singularPascal = toSingularPascal(pascalName);
|
|
194
|
+
|
|
195
|
+
const srcDir = path.join(targetDir, 'src');
|
|
196
|
+
const moduleDir = (await fs.pathExists(srcDir))
|
|
197
|
+
? path.join(srcDir, 'modules', kebabName)
|
|
198
|
+
: path.join(targetDir, 'modules', kebabName);
|
|
199
|
+
const controllerPath = path.join(moduleDir, `${kebabName}.controller.ts`);
|
|
200
|
+
|
|
201
|
+
if (!(await fs.pathExists(controllerPath))) {
|
|
202
|
+
console.error(chalk.red(`\n❌ Controller for module "${kebabName}" not found at ${controllerPath}`));
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const controllerContent = await fs.readFile(controllerPath, 'utf8');
|
|
207
|
+
const { ops: currentOps, hasGuards, roles: currentRoles } = parseExistingController(controllerContent);
|
|
208
|
+
const primaryKey = await detectModulePrimaryKey(moduleDir, kebabName);
|
|
209
|
+
const { guardName, guardImportPath } = await getGuardImportDetails(targetDir);
|
|
210
|
+
|
|
211
|
+
const createDtoName = `Create${singularPascal}Dto`;
|
|
212
|
+
const updateDtoName = `Update${singularPascal}Dto`;
|
|
213
|
+
const responseDtoName = `${pascalName}Dto`;
|
|
214
|
+
|
|
215
|
+
console.log(chalk.cyan(`\n⚙️ Configuring module: ${chalk.bold(kebabName)}`));
|
|
216
|
+
|
|
217
|
+
const { configChoice } = await inquirer.prompt([{
|
|
218
|
+
type: 'list',
|
|
219
|
+
name: 'configChoice',
|
|
220
|
+
message: 'Select configuration action:',
|
|
221
|
+
choices: [
|
|
222
|
+
{ name: '🔐 Manage Auth & Roles Guards (POST, PUT, DELETE protection)', value: 'guards' },
|
|
223
|
+
{ name: '🛠️ Toggle Active CRUD Operations (Enable/Disable endpoints)', value: 'operations' },
|
|
224
|
+
{ name: '❌ Cancel', value: 'cancel' },
|
|
225
|
+
],
|
|
226
|
+
}]);
|
|
227
|
+
|
|
228
|
+
if (configChoice === 'cancel') {
|
|
229
|
+
console.log(chalk.gray('Cancelled configuration. No files modified.'));
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
let protectWriteOps = hasGuards;
|
|
234
|
+
let roles = currentRoles.length > 0 ? currentRoles : ['ADMIN'];
|
|
235
|
+
let updatedOps = { ...currentOps };
|
|
236
|
+
|
|
237
|
+
if (configChoice === 'guards') {
|
|
238
|
+
const guardAnswer = await inquirer.prompt([{
|
|
239
|
+
type: 'confirm',
|
|
240
|
+
name: 'protectWriteOps',
|
|
241
|
+
message: 'Protect write operations (POST, PUT, DELETE) with Auth/Roles Guard?',
|
|
242
|
+
default: hasGuards,
|
|
243
|
+
}]);
|
|
244
|
+
|
|
245
|
+
protectWriteOps = guardAnswer.protectWriteOps;
|
|
246
|
+
|
|
247
|
+
if (protectWriteOps) {
|
|
248
|
+
const roleAnswer = await inquirer.prompt([{
|
|
249
|
+
type: 'checkbox',
|
|
250
|
+
name: 'selectedRoles',
|
|
251
|
+
message: 'Select allowed roles for write operations:',
|
|
252
|
+
choices: [
|
|
253
|
+
{ name: 'ADMIN', value: 'ADMIN', checked: roles.includes('ADMIN') || roles.length === 0 },
|
|
254
|
+
{ name: 'USER', value: 'USER', checked: roles.includes('USER') },
|
|
255
|
+
{ name: 'SUPERADMIN', value: 'SUPERADMIN', checked: roles.includes('SUPERADMIN') },
|
|
256
|
+
{ name: 'MANAGER', value: 'MANAGER', checked: roles.includes('MANAGER') },
|
|
257
|
+
{ name: 'Custom Role...', value: '__custom__' },
|
|
258
|
+
],
|
|
259
|
+
}]);
|
|
260
|
+
|
|
261
|
+
let finalRoles = roleAnswer.selectedRoles.filter((r) => r !== '__custom__');
|
|
262
|
+
|
|
263
|
+
if (roleAnswer.selectedRoles.includes('__custom__')) {
|
|
264
|
+
const customRoleAnswer = await inquirer.prompt([{
|
|
265
|
+
type: 'input',
|
|
266
|
+
name: 'customRoles',
|
|
267
|
+
message: 'Enter custom role name(s) (comma-separated, e.g., AUDITOR, EDITOR):',
|
|
268
|
+
validate: (input) => (input && input.trim() ? true : 'Custom role is required'),
|
|
269
|
+
}]);
|
|
270
|
+
|
|
271
|
+
const parsedCustom = customRoleAnswer.customRoles
|
|
272
|
+
.split(',')
|
|
273
|
+
.map((r) => r.trim().toUpperCase())
|
|
274
|
+
.filter(Boolean);
|
|
275
|
+
|
|
276
|
+
finalRoles = Array.from(new Set([...finalRoles, ...parsedCustom]));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
roles = finalRoles.length > 0 ? finalRoles : ['ADMIN'];
|
|
280
|
+
}
|
|
281
|
+
} else if (configChoice === 'operations') {
|
|
282
|
+
const opsAnswer = await inquirer.prompt([{
|
|
283
|
+
type: 'checkbox',
|
|
284
|
+
name: 'activeOps',
|
|
285
|
+
message: 'Select active CRUD operations:',
|
|
286
|
+
choices: [
|
|
287
|
+
{ name: 'Create (POST)', value: 'create', checked: currentOps.create },
|
|
288
|
+
{ name: 'Read All / findAll (GET)', value: 'findAll', checked: currentOps.findAll },
|
|
289
|
+
{ name: 'Read One / findOne (GET /:id)', value: 'findOne', checked: currentOps.findOne },
|
|
290
|
+
{ name: 'Update (PUT /:id)', value: 'update', checked: currentOps.update },
|
|
291
|
+
{ name: 'Delete / remove (DELETE /:id)', value: 'remove', checked: currentOps.remove },
|
|
292
|
+
],
|
|
293
|
+
}]);
|
|
294
|
+
|
|
295
|
+
updatedOps = {
|
|
296
|
+
create: opsAnswer.activeOps.includes('create'),
|
|
297
|
+
findAll: opsAnswer.activeOps.includes('findAll'),
|
|
298
|
+
findOne: opsAnswer.activeOps.includes('findOne'),
|
|
299
|
+
update: opsAnswer.activeOps.includes('update'),
|
|
300
|
+
remove: opsAnswer.activeOps.includes('remove'),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const updatedController = renderControllerContent({
|
|
305
|
+
pascalName,
|
|
306
|
+
kebabName,
|
|
307
|
+
primaryKey,
|
|
308
|
+
createDtoName,
|
|
309
|
+
updateDtoName,
|
|
310
|
+
responseDtoName,
|
|
311
|
+
ops: updatedOps,
|
|
312
|
+
protectWriteOps,
|
|
313
|
+
roles,
|
|
314
|
+
guardName,
|
|
315
|
+
guardImportPath,
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
await fs.writeFile(controllerPath, updatedController, 'utf8');
|
|
319
|
+
console.log(chalk.green(`\n✅ Successfully updated module configuration for "${kebabName}"!`));
|
|
320
|
+
console.log(chalk.gray(` Controller: ${path.relative(process.cwd(), controllerPath)}`));
|
|
321
|
+
if (protectWriteOps) {
|
|
322
|
+
console.log(chalk.gray(` Protected Write Ops: ${roles.join(', ')}`));
|
|
323
|
+
} else {
|
|
324
|
+
console.log(chalk.gray(` Protected Write Ops: Disabled`));
|
|
325
|
+
}
|
|
326
|
+
const activeOpsList = Object.keys(updatedOps).filter((op) => updatedOps[op]);
|
|
327
|
+
console.log(chalk.gray(` Active Operations: ${activeOpsList.join(', ')}`));
|
|
328
|
+
|
|
329
|
+
return true;
|
|
330
|
+
} catch (error) {
|
|
331
|
+
console.error(chalk.red('\n❌ Module configuration failed:'), error.message);
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
module.exports = { configureModule, renderControllerContent };
|
package/src/moduleGenerator.js
CHANGED
|
@@ -7,7 +7,7 @@ const inquirer = require('inquirer');
|
|
|
7
7
|
const fs = require('fs-extra');
|
|
8
8
|
const path = require('path');
|
|
9
9
|
const chalk = require('chalk');
|
|
10
|
-
require('./utils');
|
|
10
|
+
const { detectPackageManager, getRunPrefix } = require('./utils');
|
|
11
11
|
|
|
12
12
|
function toPascalCase(str) {
|
|
13
13
|
return str
|
|
@@ -503,15 +503,19 @@ function getFieldExampleValue(field, pascalName) {
|
|
|
503
503
|
|
|
504
504
|
/**
|
|
505
505
|
* Dynamic ORM Schema Synchronization: Prisma
|
|
506
|
+
* @param {boolean} forceUpdate - When true, replace existing model block instead of skipping
|
|
506
507
|
*/
|
|
507
|
-
async function syncPrismaSchema(targetDir, singularPascal, kebabName, primaryKey, fields, relations, includeStatus = true) {
|
|
508
|
+
async function syncPrismaSchema(targetDir, singularPascal, kebabName, primaryKey, fields, relations, includeStatus = true, forceUpdate = false) {
|
|
508
509
|
try {
|
|
509
510
|
const schemaPath = path.join(targetDir, 'prisma', 'schema.prisma');
|
|
510
511
|
if (!(await fs.pathExists(schemaPath))) return;
|
|
511
512
|
|
|
512
513
|
let content = await fs.readFile(schemaPath, 'utf8');
|
|
513
514
|
|
|
514
|
-
|
|
515
|
+
const modelRegex = new RegExp(`(\\bmodel\\s+${singularPascal}\\s*\\{[^}]*\\})`, 's');
|
|
516
|
+
const modelExists = modelRegex.test(content);
|
|
517
|
+
|
|
518
|
+
if (modelExists && !forceUpdate) {
|
|
515
519
|
return;
|
|
516
520
|
}
|
|
517
521
|
|
|
@@ -547,9 +551,15 @@ ${relLines.length > 0 ? relLines.join('\n') + '\n' : ''}${statusLine} createdAt
|
|
|
547
551
|
}
|
|
548
552
|
`;
|
|
549
553
|
|
|
550
|
-
|
|
554
|
+
if (modelExists && forceUpdate) {
|
|
555
|
+
content = content.replace(modelRegex, modelDefinition.trim());
|
|
556
|
+
console.log(chalk.green(` ✓ Updated existing Prisma model ${singularPascal} in schema.prisma`));
|
|
557
|
+
} else {
|
|
558
|
+
content += modelDefinition;
|
|
559
|
+
console.log(chalk.green(` ✓ Added Prisma model ${singularPascal} to schema.prisma`));
|
|
560
|
+
}
|
|
561
|
+
|
|
551
562
|
await fs.writeFile(schemaPath, content, 'utf8');
|
|
552
|
-
console.log(chalk.green(` ✓ Updated prisma/schema.prisma with model ${singularPascal}`));
|
|
553
563
|
} catch (error) {
|
|
554
564
|
console.warn(chalk.yellow(` ⚠️ Could not sync prisma/schema.prisma: ${error.message}`));
|
|
555
565
|
}
|
|
@@ -835,7 +845,7 @@ class TypeOrm${pascalName}Repository implements IBaseRepository<${pascalName}Ent
|
|
|
835
845
|
}` : `async findOne(${primaryKey}: string): Promise<${pascalName}Entity | null> { throw new Error('Not implemented'); }`}
|
|
836
846
|
|
|
837
847
|
${ops.update ? `async update(${primaryKey}: string, dto: ${updateDtoName}): Promise<${pascalName}Entity> {
|
|
838
|
-
await this.repo.update(${primaryKey}, dto as any);
|
|
848
|
+
await this.repo.update({ ${primaryKey} } as any, dto as any);
|
|
839
849
|
return this.repo.findOneOrFail({ where: { ${primaryKey} } as any });
|
|
840
850
|
}` : `async update(${primaryKey}: string, dto: ${updateDtoName}): Promise<${pascalName}Entity> { throw new Error('Not implemented'); }`}
|
|
841
851
|
|
|
@@ -1206,17 +1216,27 @@ async function generateModule(providedModuleName, targetDir = process.cwd(), spe
|
|
|
1206
1216
|
return ` ${swaggerDecorator}\n ${valDecorators.join('\n ')}\n ${f.name}${f.isOptional ? '?' : ''}: ${details.tsType};`;
|
|
1207
1217
|
}).join('\n\n');
|
|
1208
1218
|
|
|
1219
|
+
// Build FK fields for Create DTO from relations (e.g., userId, categoryId)
|
|
1220
|
+
const relationFkFields = (options.relations || []).map((r) => {
|
|
1221
|
+
return ` @ApiPropertyOptional({ description: 'Foreign key linking to ${r.targetModule}', example: '123e4567-e89b-12d3-a456-426614174000', format: 'uuid' })\n @IsOptional()\n @IsUUID()\n ${r.fkField}?: string;`;
|
|
1222
|
+
}).join('\n\n');
|
|
1223
|
+
|
|
1209
1224
|
const hasDateFields = options.fields.some((f) => ['DateTime', 'timestamp', 'Date'].includes(f.type));
|
|
1225
|
+
const hasRelations = (options.relations || []).length > 0;
|
|
1210
1226
|
|
|
1211
|
-
// Validator import gathering
|
|
1227
|
+
// Validator import gathering — only collect 'Is*' decorators (class-validator)
|
|
1212
1228
|
const allValDecorators = new Set(['IsOptional', 'IsNotEmpty']);
|
|
1213
1229
|
options.fields.forEach((f) => {
|
|
1214
1230
|
const details = getFieldDetails(f.type);
|
|
1215
1231
|
details.valDecorators.forEach((dec) => {
|
|
1216
1232
|
const name = dec.replace('@', '').replace(/\(.*\)/, '');
|
|
1217
|
-
|
|
1233
|
+
// Only include class-validator decorators (Is* prefix), not Type from class-transformer
|
|
1234
|
+
if (name && name.startsWith('Is')) allValDecorators.add(name);
|
|
1218
1235
|
});
|
|
1219
1236
|
});
|
|
1237
|
+
if (hasRelations) allValDecorators.add('IsUUID');
|
|
1238
|
+
|
|
1239
|
+
const createDtoBody = [createFieldsText, relationFkFields].filter(Boolean).join('\n\n');
|
|
1220
1240
|
|
|
1221
1241
|
if (ops.create || ops.update) {
|
|
1222
1242
|
await fs.writeFile(
|
|
@@ -1225,7 +1245,7 @@ async function generateModule(providedModuleName, targetDir = process.cwd(), spe
|
|
|
1225
1245
|
import { ${Array.from(allValDecorators).join(', ')} } from 'class-validator';
|
|
1226
1246
|
${hasDateFields ? `import { Type } from 'class-transformer';\n` : ''}
|
|
1227
1247
|
export class ${createDtoName} {
|
|
1228
|
-
${
|
|
1248
|
+
${createDtoBody}
|
|
1229
1249
|
}
|
|
1230
1250
|
`
|
|
1231
1251
|
);
|
|
@@ -1252,6 +1272,11 @@ export class ${updateDtoName} extends PartialType(${createDtoName}) {}
|
|
|
1252
1272
|
return ` ${swaggerDecorator}\n ${f.name}${f.isOptional ? '?' : ''}: ${details.tsType};`;
|
|
1253
1273
|
}).join('\n\n');
|
|
1254
1274
|
|
|
1275
|
+
// FK relation fields in response DTO (always optional — may be null if not populated)
|
|
1276
|
+
const responseFkFields = (options.relations || []).map((r) => {
|
|
1277
|
+
return ` @ApiPropertyOptional({ example: '123e4567-e89b-12d3-a456-426614174000', format: 'uuid' })\n ${r.fkField}?: string;`;
|
|
1278
|
+
}).join('\n\n');
|
|
1279
|
+
|
|
1255
1280
|
const statusDtoField = options.includeStatus !== false
|
|
1256
1281
|
? ` @ApiProperty({ example: 'ACTIVE' })\n status: string;\n\n`
|
|
1257
1282
|
: '';
|
|
@@ -1264,7 +1289,9 @@ export class ${responseDtoName} {
|
|
|
1264
1289
|
@ApiProperty({ example: '123e4567-e89b-12d3-a456-426614174000', format: 'uuid' })
|
|
1265
1290
|
${primaryKey}: string;
|
|
1266
1291
|
|
|
1267
|
-
${responseFieldsText}
|
|
1292
|
+
${responseFieldsText}${
|
|
1293
|
+
responseFkFields ? `\n\n${responseFkFields}` : ''
|
|
1294
|
+
}
|
|
1268
1295
|
|
|
1269
1296
|
${statusDtoField} @ApiProperty({ example: '2025-01-01T00:00:00.000Z' })
|
|
1270
1297
|
createdAt: Date;
|
|
@@ -1293,79 +1320,18 @@ ${statusDtoField} @ApiProperty({ example: '2025-01-01T00:00:00.000Z' })
|
|
|
1293
1320
|
await fs.writeFile(path.join(moduleDir, `${kebabName}.service.ts`), serviceContent);
|
|
1294
1321
|
|
|
1295
1322
|
// 4. Generate Controller
|
|
1296
|
-
const
|
|
1297
|
-
const
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
type ${pascalName}Entity = any;
|
|
1309
|
-
|
|
1310
|
-
@ApiTags('${pascalName}')
|
|
1311
|
-
@ApiBearerAuth('bearer')
|
|
1312
|
-
@ApiExtraModels(ApiResponseDto, PaginatedResponseDto, ${responseDtoName})
|
|
1313
|
-
@Controller('${kebabName}')
|
|
1314
|
-
export class ${pascalName}Controller extends BaseController<${pascalName}Entity, ${createDtoName}, ${updateDtoName}> {
|
|
1315
|
-
constructor(protected readonly service: ${pascalName}Service) {
|
|
1316
|
-
super(service);
|
|
1317
|
-
}
|
|
1318
|
-
|
|
1319
|
-
protected getDtoClass(): Type<${pascalName}Entity> {
|
|
1320
|
-
return ${responseDtoName} as unknown as Type<${pascalName}Entity>;
|
|
1321
|
-
}
|
|
1322
|
-
${ops.create ? `
|
|
1323
|
-
@Post()${guardDecorator}
|
|
1324
|
-
@ApiOperation({ summary: 'Create a new ${kebabName}' })
|
|
1325
|
-
@ApiResponse({ status: HttpStatus.CREATED, schema: ApiResponseSchema(${responseDtoName}) })
|
|
1326
|
-
override async create(@Body() dto: ${createDtoName}): Promise<ApiResponseDto<${pascalName}Entity>> {
|
|
1327
|
-
return super.create(dto);
|
|
1328
|
-
}
|
|
1329
|
-
` : ''}${ops.findAll ? `
|
|
1330
|
-
@Get()
|
|
1331
|
-
@ApiOperation({ summary: 'Get all ${kebabName} (paginated)' })
|
|
1332
|
-
@ApiResponse({ status: HttpStatus.OK, schema: PaginatedResponseSchema(${responseDtoName}) })
|
|
1333
|
-
override async findAll(@Query() pagination: PaginationQueryDto): Promise<PaginatedResponseDto<${pascalName}Entity>> {
|
|
1334
|
-
return super.findAll(pagination);
|
|
1335
|
-
}
|
|
1336
|
-
` : ''}${ops.findOne ? `
|
|
1337
|
-
@Get(':${primaryKey}')
|
|
1338
|
-
@ApiOperation({ summary: 'Get ${kebabName} by ID' })
|
|
1339
|
-
@ApiParam({ name: '${primaryKey}', format: 'uuid' })
|
|
1340
|
-
@ApiResponse({ status: HttpStatus.OK, schema: ApiResponseSchema(${responseDtoName}) })
|
|
1341
|
-
@ApiResponse({ status: HttpStatus.NOT_FOUND, description: '${pascalName} not found' })
|
|
1342
|
-
override async findOne(@Param('${primaryKey}', new ParseUUIDPipe({ version: '4', errorHttpStatusCode: HttpStatus.BAD_REQUEST })) ${primaryKey}: string): Promise<ApiResponseDto<${pascalName}Entity>> {
|
|
1343
|
-
return super.findOne(${primaryKey});
|
|
1344
|
-
}
|
|
1345
|
-
` : ''}${ops.update ? `
|
|
1346
|
-
@Put(':${primaryKey}')${guardDecorator}
|
|
1347
|
-
@ApiOperation({ summary: 'Update ${kebabName} by ID' })
|
|
1348
|
-
@ApiParam({ name: '${primaryKey}', format: 'uuid' })
|
|
1349
|
-
@ApiResponse({ status: HttpStatus.OK, schema: ApiResponseSchema(${responseDtoName}) })
|
|
1350
|
-
@ApiResponse({ status: HttpStatus.NOT_FOUND, description: '${pascalName} not found' })
|
|
1351
|
-
override async update(
|
|
1352
|
-
@Param('${primaryKey}', new ParseUUIDPipe({ version: '4', errorHttpStatusCode: HttpStatus.BAD_REQUEST })) ${primaryKey}: string,
|
|
1353
|
-
@Body() dto: ${updateDtoName}
|
|
1354
|
-
): Promise<ApiResponseDto<${pascalName}Entity>> {
|
|
1355
|
-
return super.update(${primaryKey}, dto);
|
|
1356
|
-
}
|
|
1357
|
-
` : ''}${ops.remove ? `
|
|
1358
|
-
@Delete(':${primaryKey}')${guardDecorator}
|
|
1359
|
-
@ApiOperation({ summary: 'Delete ${kebabName} by ID' })
|
|
1360
|
-
@ApiParam({ name: '${primaryKey}', format: 'uuid' })
|
|
1361
|
-
@ApiResponse({ status: HttpStatus.OK, schema: ApiResponseSchema(${responseDtoName}) })
|
|
1362
|
-
@ApiResponse({ status: HttpStatus.NOT_FOUND, description: '${pascalName} not found' })
|
|
1363
|
-
override async remove(@Param('${primaryKey}', new ParseUUIDPipe({ version: '4', errorHttpStatusCode: HttpStatus.BAD_REQUEST })) ${primaryKey}: string): Promise<ApiResponseDto<${pascalName}Entity>> {
|
|
1364
|
-
return super.remove(${primaryKey});
|
|
1365
|
-
}
|
|
1366
|
-
` : ''}
|
|
1367
|
-
}
|
|
1368
|
-
`;
|
|
1323
|
+
const { renderControllerContent } = require('./moduleConfigurator');
|
|
1324
|
+
const controllerContent = renderControllerContent({
|
|
1325
|
+
pascalName,
|
|
1326
|
+
kebabName,
|
|
1327
|
+
primaryKey,
|
|
1328
|
+
createDtoName,
|
|
1329
|
+
updateDtoName,
|
|
1330
|
+
responseDtoName,
|
|
1331
|
+
ops,
|
|
1332
|
+
protectWriteOps: options.protectWriteOps,
|
|
1333
|
+
roles: options.roles || ['ADMIN'],
|
|
1334
|
+
});
|
|
1369
1335
|
await fs.writeFile(path.join(moduleDir, `${kebabName}.controller.ts`), controllerContent);
|
|
1370
1336
|
|
|
1371
1337
|
// 5. Generate Module
|
|
@@ -1403,6 +1369,30 @@ export class AppModule {}
|
|
|
1403
1369
|
}
|
|
1404
1370
|
}
|
|
1405
1371
|
|
|
1372
|
+
/**
|
|
1373
|
+
* Detect the primary key used by a module by scanning its response DTO file.
|
|
1374
|
+
* Falls back to 'id' if the file is missing or no PK can be found.
|
|
1375
|
+
*/
|
|
1376
|
+
async function detectModulePrimaryKey(moduleDir, kebabName) {
|
|
1377
|
+
try {
|
|
1378
|
+
const dtoPath = path.join(moduleDir, 'dto', `${kebabName}.dto.ts`);
|
|
1379
|
+
if (!(await fs.pathExists(dtoPath))) return 'id';
|
|
1380
|
+
|
|
1381
|
+
const content = await fs.readFile(dtoPath, 'utf8');
|
|
1382
|
+
// The PK is the first property after 'export class Xxx {'
|
|
1383
|
+
// It is decorated with @ApiProperty and has format: 'uuid'
|
|
1384
|
+
const pkMatch = content.match(/@ApiProperty\([^)]*format:\s*['"](uuid)['"][^)]*\)[\s\S]*?\n\s*(\w+)\s*:/m);
|
|
1385
|
+
if (pkMatch && pkMatch[2]) return pkMatch[2];
|
|
1386
|
+
|
|
1387
|
+
// Fallback: look for *_id or *Id as the first bare property
|
|
1388
|
+
const firstPropMatch = content.match(/^\s*([a-zA-Z][a-zA-Z0-9_]*)\s*:/m);
|
|
1389
|
+
if (firstPropMatch && firstPropMatch[1] !== 'status') return firstPropMatch[1];
|
|
1390
|
+
} catch {
|
|
1391
|
+
// swallow
|
|
1392
|
+
}
|
|
1393
|
+
return 'id';
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1406
1396
|
/**
|
|
1407
1397
|
* Regenerate module DTOs and ORM schemas when fields are updated via fieldManager
|
|
1408
1398
|
*/
|
|
@@ -1411,7 +1401,6 @@ async function regenerateModuleComponents(moduleName, fields, targetDir = proces
|
|
|
1411
1401
|
const kebabName = toKebabCase(moduleName);
|
|
1412
1402
|
const pascalName = toPascalCase(moduleName);
|
|
1413
1403
|
const singularPascal = toSingularPascal(pascalName);
|
|
1414
|
-
const primaryKey = 'id';
|
|
1415
1404
|
|
|
1416
1405
|
const srcDir = path.join(targetDir, 'src');
|
|
1417
1406
|
const moduleDir = (await fs.pathExists(srcDir))
|
|
@@ -1422,9 +1411,12 @@ async function regenerateModuleComponents(moduleName, fields, targetDir = proces
|
|
|
1422
1411
|
await fs.ensureDir(moduleDir);
|
|
1423
1412
|
await fs.ensureDir(dtoDir);
|
|
1424
1413
|
|
|
1425
|
-
//
|
|
1414
|
+
// Detect the existing primary key from the module's response DTO
|
|
1415
|
+
const primaryKey = await detectModulePrimaryKey(moduleDir, kebabName);
|
|
1416
|
+
|
|
1417
|
+
// 1. Sync ORM Schema (forceUpdate=true so existing definitions are replaced)
|
|
1426
1418
|
if (orm === 'prisma') {
|
|
1427
|
-
await syncPrismaSchema(targetDir, singularPascal, kebabName, primaryKey, fields, [], true);
|
|
1419
|
+
await syncPrismaSchema(targetDir, singularPascal, kebabName, primaryKey, fields, [], true, true);
|
|
1428
1420
|
} else if (orm === 'typeorm') {
|
|
1429
1421
|
await syncTypeOrmSchema(moduleDir, singularPascal, kebabName, primaryKey, fields, [], true);
|
|
1430
1422
|
} else if (orm === 'mongoose') {
|
|
@@ -1464,7 +1456,8 @@ async function regenerateModuleComponents(moduleName, fields, targetDir = proces
|
|
|
1464
1456
|
const details = getFieldDetails(f.type);
|
|
1465
1457
|
details.valDecorators.forEach((dec) => {
|
|
1466
1458
|
const name = dec.replace('@', '').replace(/\(.*\)/, '');
|
|
1467
|
-
|
|
1459
|
+
// Only class-validator Is* decorators — Type comes from class-transformer, not here
|
|
1460
|
+
if (name && name.startsWith('Is')) allValDecorators.add(name);
|
|
1468
1461
|
});
|
|
1469
1462
|
});
|
|
1470
1463
|
|
|
@@ -1506,7 +1499,7 @@ export class ${updateDtoName} extends PartialType(${createDtoName}) {}
|
|
|
1506
1499
|
|
|
1507
1500
|
export class ${responseDtoName} {
|
|
1508
1501
|
@ApiProperty({ example: '123e4567-e89b-12d3-a456-426614174000', format: 'uuid' })
|
|
1509
|
-
|
|
1502
|
+
${primaryKey}: string;
|
|
1510
1503
|
|
|
1511
1504
|
${responseFieldsText}
|
|
1512
1505
|
|
|
@@ -1530,6 +1523,9 @@ module.exports = {
|
|
|
1530
1523
|
generateModule,
|
|
1531
1524
|
promptForModuleOptions,
|
|
1532
1525
|
detectOrm,
|
|
1526
|
+
detectModulePrimaryKey,
|
|
1527
|
+
getOrmFieldChoices,
|
|
1528
|
+
toKebabCase,
|
|
1533
1529
|
registerInAppModule,
|
|
1534
1530
|
ensureBaseArchitecture,
|
|
1535
1531
|
regenerateModuleComponents,
|