stackloom-cli 1.0.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.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +169 -0
  3. package/bin/cli.js +306 -0
  4. package/branding.json +8 -0
  5. package/package.json +72 -0
  6. package/src/__tests__/cli-smoke.test.js +46 -0
  7. package/src/blueprint/__tests__/blueprint.test.js +116 -0
  8. package/src/blueprint/blueprint.js +181 -0
  9. package/src/blueprint/default.blueprint.json +78 -0
  10. package/src/blueprint/index.js +10 -0
  11. package/src/blueprint/loader.js +101 -0
  12. package/src/blueprint/schema-kit.js +161 -0
  13. package/src/blueprint/schema.js +78 -0
  14. package/src/branding/__tests__/branding.test.js +49 -0
  15. package/src/branding/index.js +48 -0
  16. package/src/commands/__tests__/commands.test.js +83 -0
  17. package/src/commands/check.js +71 -0
  18. package/src/commands/cleanup.js +347 -0
  19. package/src/commands/customize.js +263 -0
  20. package/src/commands/doctor.js +84 -0
  21. package/src/commands/env.js +75 -0
  22. package/src/commands/finalize.js +68 -0
  23. package/src/commands/generate/ci-cd.js +378 -0
  24. package/src/commands/generate/deploy-advanced.js +253 -0
  25. package/src/commands/generate/deploy.js +99 -0
  26. package/src/commands/generate/env.template.js +221 -0
  27. package/src/commands/generate/index.js +7 -0
  28. package/src/commands/generate/module.js +836 -0
  29. package/src/commands/generate/page.js +1415 -0
  30. package/src/commands/generate/test-scaffold.js +279 -0
  31. package/src/commands/generate/theme.js +67 -0
  32. package/src/commands/generate-resource.js +133 -0
  33. package/src/commands/index.js +9 -0
  34. package/src/commands/init.js +350 -0
  35. package/src/commands/make/resource.js +298 -0
  36. package/src/commands/preset.js +57 -0
  37. package/src/commands/remove.js +170 -0
  38. package/src/commands/rename.js +54 -0
  39. package/src/commands/rollback.js +90 -0
  40. package/src/commands/wizard.js +303 -0
  41. package/src/core/__tests__/generator.test.js +67 -0
  42. package/src/core/__tests__/marker-strategy.test.js +57 -0
  43. package/src/core/__tests__/resource-definition.test.js +32 -0
  44. package/src/core/generator.js +542 -0
  45. package/src/core/marker-strategy.js +138 -0
  46. package/src/core/resource-definition.js +346 -0
  47. package/src/core/state-tracker.js +67 -0
  48. package/src/core/template-loader.js +163 -0
  49. package/src/engine/__tests__/engine.test.js +306 -0
  50. package/src/engine/index.js +21 -0
  51. package/src/engine/injector.js +198 -0
  52. package/src/engine/pipeline.js +138 -0
  53. package/src/engine/transaction.js +105 -0
  54. package/src/engine/validator.js +190 -0
  55. package/src/index.js +4 -0
  56. package/src/recipes/__tests__/recipe.test.js +128 -0
  57. package/src/recipes/builtin/module.json +22 -0
  58. package/src/recipes/builtin/page.json +21 -0
  59. package/src/recipes/builtin/resource.json +35 -0
  60. package/src/recipes/condition.js +147 -0
  61. package/src/recipes/index.js +11 -0
  62. package/src/recipes/loader.js +95 -0
  63. package/src/recipes/recipe.js +89 -0
  64. package/src/recipes/schema.js +47 -0
  65. package/src/schemas/__tests__/schemas.test.js +67 -0
  66. package/src/schemas/index.js +18 -0
  67. package/src/schemas/options.js +38 -0
  68. package/src/schemas/resource.js +112 -0
  69. package/src/services/__tests__/reporter.test.js +98 -0
  70. package/src/services/clock.js +31 -0
  71. package/src/services/index.js +43 -0
  72. package/src/services/reporter.js +136 -0
  73. package/src/templates/resource/api.js.ejs +39 -0
  74. package/src/templates/resource/components/form.jsx.ejs +81 -0
  75. package/src/templates/resource/components/table.jsx.ejs +68 -0
  76. package/src/templates/resource/controller.js.ejs +154 -0
  77. package/src/templates/resource/hooks.js.ejs +46 -0
  78. package/src/templates/resource/model.js.ejs +64 -0
  79. package/src/templates/resource/page-detail.jsx.ejs +55 -0
  80. package/src/templates/resource/page-form.jsx.ejs +30 -0
  81. package/src/templates/resource/page-inline.jsx.ejs +74 -0
  82. package/src/templates/resource/page-modal.jsx.ejs +98 -0
  83. package/src/templates/resource/page-page.jsx.ejs +99 -0
  84. package/src/templates/resource/page-sidepanel.jsx.ejs +100 -0
  85. package/src/templates/resource/routes.js.ejs +35 -0
  86. package/src/templates/resource/service.js.ejs +132 -0
  87. package/src/templates/resource/test.ejs +71 -0
  88. package/src/templates/resource/types.ts.ejs +17 -0
  89. package/src/templates/resource/validator.js.ejs +26 -0
  90. package/src/templates/snippets/lazy-import.ejs +1 -0
  91. package/src/templates/snippets/nav-entry.ejs +1 -0
  92. package/src/templates/snippets/route-entry.ejs +5 -0
  93. package/src/templates/snippets/route-mount.ejs +1 -0
  94. package/src/utils/fieldValidators.js +371 -0
  95. package/src/utils/logging/logger.js +47 -0
  96. package/src/utils/namingUtils.js +38 -0
  97. package/src/utils/sanitize.js +200 -0
@@ -0,0 +1,836 @@
1
+ #!/usr/bin/env node
2
+
3
+ import inquirer from "inquirer";
4
+ import path from "path";
5
+ import fs from "fs-extra";
6
+ import chalk from "chalk";
7
+ import ora from "ora";
8
+ import { getConfigPaths } from "./page.js";
9
+ import { parseFieldSpec } from "../../utils/fieldValidators.js";
10
+
11
+ const DEFAULT_FRONTEND_DIR = "frontend";
12
+ const DEFAULT_BACKEND_DIR = "backend";
13
+
14
+ async function ensureBackendDependencies(projectRoot, backendDir, fields, archLevel) {
15
+ // Only standard/advanced modules need these dependencies
16
+ if (archLevel === "lightweight") return;
17
+
18
+ const pkgPath = path.join(projectRoot, backendDir, "package.json");
19
+ if (!fs.existsSync(pkgPath)) return;
20
+
21
+ const pkg = await fs.readJSON(pkgPath);
22
+ const required = {
23
+ "express-validator": "^7.2.1",
24
+ };
25
+
26
+ // Add slugify if slug/code/sku field exists
27
+ if (fields.some(f => ["slug", "code", "sku"].includes(f.name))) {
28
+ required.slugify = "^1.6.6";
29
+ }
30
+
31
+ let changed = false;
32
+ const deps = pkg.dependencies || (pkg.dependencies = {});
33
+ for (const [name, version] of Object.entries(required)) {
34
+ if (!deps[name]) {
35
+ deps[name] = version;
36
+ changed = true;
37
+ }
38
+ }
39
+ if (changed) {
40
+ await fs.writeJSON(pkgPath, pkg, { spaces: 2 });
41
+ console.log(chalk.green("✓ Added backend dependencies: " + Object.keys(required).join(", ")));
42
+ }
43
+ }
44
+
45
+ export default async function generateModuleCmd(name, options) {
46
+ console.warn(
47
+ chalk.yellow(
48
+ "⚠ 'generate module' is superseded by 'loom generate resource --recipe module'\n" +
49
+ " (engine-backed: recipe-driven, transactional, validated). This command still works.",
50
+ ),
51
+ );
52
+ const spinner = ora();
53
+ const projectRoot = process.cwd();
54
+ const { frontendDir, backendDir } = await getConfigPaths(projectRoot);
55
+
56
+ const backendCheckPath = path.join(projectRoot, backendDir, "src/modules/auth");
57
+ if (!fs.existsSync(backendCheckPath)) {
58
+ console.log(chalk.red("✖ Not a MERN Starter Kit backend."));
59
+ process.exit(1);
60
+ }
61
+
62
+ const moduleName = name.toLowerCase();
63
+ const modDir = path.join(projectRoot, backendDir, "src/modules", moduleName);
64
+
65
+ if (fs.existsSync(modDir)) {
66
+ if (options.force) {
67
+ spinner.warn(`${modDir} exists — will overwrite (--force)`);
68
+ } else {
69
+ console.log(chalk.yellow(`⚠ Module ${moduleName} already exists. Use --force to overwrite.`));
70
+ process.exit(1);
71
+ }
72
+ }
73
+
74
+ let archLevel = options.architecture || "moderate";
75
+ if (options.interactive) {
76
+ const answers = await inquirer.prompt([
77
+ {
78
+ type: "list",
79
+ name: "arch",
80
+ message: "Architecture level for this module:",
81
+ choices: [
82
+ { name: "Lightweight — inline controller, minimal files", value: "lightweight" },
83
+ { name: "Moderate — full layer separation", value: "moderate" },
84
+ { name: "Advanced — with tests, types, domain logic", value: "advanced" },
85
+ ],
86
+ default: "moderate",
87
+ },
88
+ ]);
89
+ archLevel = answers.arch;
90
+ }
91
+
92
+ const customFields = await resolveModuleFields(moduleName, options);
93
+ await generateModuleFiles(projectRoot, moduleName, archLevel, customFields, { frontendDir, backendDir });
94
+ spinner.succeed(`Generated module: ${moduleName} (${archLevel})`);
95
+
96
+ if (options.withPage || (options.interactive && (await inquirer.prompt([{ type: "confirm", name: "addPage", message: `Create corresponding frontend page for ${moduleName}?`, default: true }])).addPage)) {
97
+ const { default: generatePage } = await import("./page.js");
98
+ const pageOptions = {
99
+ ...options,
100
+ withForm: true,
101
+ formMode: options.formMode || "page",
102
+ force: options.force,
103
+ noNav: options.noNav,
104
+ route: `/${moduleName}`,
105
+ formFields: customFields.map(f => {
106
+ const rules = [];
107
+ if (f.validation?.required) rules.push("required");
108
+ if (f.validation?.unique) rules.push("unique");
109
+ if (f.validation?.minLength !== undefined) rules.push(`minLength=${f.validation.minLength}`);
110
+ if (f.validation?.maxLength !== undefined) rules.push(`maxLength=${f.validation.maxLength}`);
111
+ if (f.validation?.min !== undefined) rules.push(`min=${f.validation.min}`);
112
+ if (f.validation?.max !== undefined) rules.push(`max=${f.validation.max}`);
113
+ if (f.validation?.step !== undefined) rules.push(`step=${f.validation.step}`);
114
+ if (f.validation?.pattern) rules.push(`pattern=${f.validation.pattern}`);
115
+ if (f.validation?.default !== undefined) rules.push(`default=${f.validation.default}`);
116
+ if (f.validation?.accept) rules.push(`accept=${f.validation.accept}`);
117
+ if (f.validation?.multiple) rules.push("multiple");
118
+
119
+ const baseSpec = `${f.name}:${mapFieldTypeToForm(f.type)}`;
120
+ return rules.length > 0 ? `${baseSpec}:${rules.join("|")}` : baseSpec;
121
+ }).join(";"),
122
+ };
123
+ await generatePage(moduleName, pageOptions);
124
+ }
125
+ }
126
+
127
+ function mapFieldTypeToForm(type) {
128
+ const map = {
129
+ string: "text",
130
+ text: "textarea",
131
+ number: "number",
132
+ boolean: "boolean",
133
+ date: "date",
134
+ email: "email",
135
+ phone: "tel",
136
+ url: "url",
137
+ datetime: "datetime-local",
138
+ time: "time",
139
+ color: "color",
140
+ file: "file",
141
+ password: "password",
142
+ range: "range",
143
+ };
144
+ return map[type] || "text";
145
+ }
146
+
147
+ function getJoiTypeForModule(type, validation = {}) {
148
+ let base;
149
+ switch (type) {
150
+ case "number":
151
+ base = "Joi.number().integer()";
152
+ if (validation.min !== undefined) base += `.min(${validation.min})`;
153
+ if (validation.max !== undefined) base += `.max(${validation.max})`;
154
+ break;
155
+ case "boolean":
156
+ base = "Joi.boolean()";
157
+ break;
158
+ case "date":
159
+ case "datetime":
160
+ base = "Joi.date().iso()";
161
+ break;
162
+ case "email":
163
+ base = "Joi.string().email()";
164
+ break;
165
+ case "url":
166
+ base = "Joi.string().uri()";
167
+ break;
168
+ case "phone":
169
+ base = "Joi.string().pattern(/^[+]?[1-9]\\d{1,14}$/, 'Invalid phone')";
170
+ break;
171
+ case "password":
172
+ base = "Joi.string().min(8).pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)/, 'Must contain uppercase, lowercase, digit')";
173
+ break;
174
+ case "text":
175
+ base = "Joi.string()";
176
+ if (validation.minLength) base += `.min(${validation.minLength})`;
177
+ if (validation.maxLength) base += `.max(${validation.maxLength})`;
178
+ break;
179
+ default: base = "Joi.string().trim()";
180
+ }
181
+ if (validation.required) base += ".required()";
182
+ return base;
183
+ }
184
+
185
+ function getMongooseSchemaField(type, validation = {}) {
186
+ let def = "{ type: ";
187
+
188
+ switch (type) {
189
+ case "string": def += "String"; break;
190
+ case "text": def += "String"; break;
191
+ case "email": def += "String"; break;
192
+ case "phone": def += "String"; break;
193
+ case "url": def += "String"; break;
194
+ case "password": def += "String"; break;
195
+ case "number": def += "Number"; break;
196
+ case "boolean": def += "Boolean"; break;
197
+ case "date": def += "Date"; break;
198
+ case "datetime": def += "Date"; break;
199
+ case "color": def += "String"; break;
200
+ case "range": def += "Number"; break;
201
+ case "file": def += "String"; break;
202
+ default: def += "Mixed";
203
+ }
204
+
205
+ const constraints = [];
206
+
207
+ if ((type === "string" || type === "text" || type === "email" || type === "url" || type === "phone" || type === "password")) {
208
+ constraints.push("trim: true");
209
+ }
210
+
211
+ if (type === "password") constraints.push("minlength: 8");
212
+ if (validation.minLength) constraints.push(`minlength: ${validation.minLength}`);
213
+ if (validation.maxLength) constraints.push(`maxlength: ${validation.maxLength}`);
214
+ if (validation.min !== undefined && type === "number") constraints.push(`min: ${validation.min}`);
215
+ if (validation.max !== undefined && type === "number") constraints.push(`max: ${validation.max}`);
216
+ if (validation.default !== undefined) constraints.push(`default: ${JSON.stringify(validation.default)}`);
217
+
218
+ // Only add required if explicitly set
219
+ if (validation.required) constraints.push("required: true");
220
+ // unique should be in separate index
221
+ if (validation.unique) constraints.push("unique: true");
222
+
223
+ if (constraints.length > 0) {
224
+ def += `, ${constraints.join(", ")}`;
225
+ }
226
+
227
+ return def + " }";
228
+ }
229
+
230
+ async function resolveModuleFields(moduleName, options) {
231
+ if (options.fields) {
232
+ return options.fields.split(";").map((pair) => {
233
+ const field = parseFieldSpec(pair);
234
+ if (!field) return null;
235
+
236
+ // Auto-add required to first field if no rules
237
+ if (!field.validation || Object.keys(field.validation).length === 0) {
238
+ field.validation = { required: true };
239
+ }
240
+ return field;
241
+ }).filter(Boolean);
242
+ }
243
+
244
+ if (options.interactive) {
245
+ const { addCustom } = await inquirer.prompt([
246
+ { type: "confirm", name: "addCustom", message: "Add custom fields beyond default 'name'?", default: false },
247
+ ]);
248
+
249
+ if (!addCustom) return [{
250
+ name: "name",
251
+ type: "string",
252
+ validation: {
253
+ required: true,
254
+ minLength: 3,
255
+ maxLength: 100
256
+ }
257
+ }];
258
+
259
+ const fields = [{
260
+ name: "name",
261
+ type: "string",
262
+ validation: {
263
+ required: true,
264
+ minLength: 3,
265
+ maxLength: 100
266
+ }
267
+ }];
268
+
269
+ let more = true;
270
+ while (more) {
271
+ const answers = await inquirer.prompt([
272
+ {
273
+ type: "input",
274
+ name: "name",
275
+ message: "Field name:",
276
+ validate: (v) => /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(v) || "Valid JS identifier"
277
+ },
278
+ {
279
+ type: "list",
280
+ name: "type",
281
+ message: "Data type:",
282
+ choices: [
283
+ { name: "String (text)", value: "string" },
284
+ { name: "Text (long)", value: "text" },
285
+ { name: "Email", value: "email" },
286
+ { name: "Phone", value: "phone" },
287
+ { name: "URL", value: "url" },
288
+ { name: "Password", value: "password" },
289
+ { name: "Number", value: "number" },
290
+ { name: "Boolean", value: "boolean" },
291
+ { name: "Date", value: "date" },
292
+ { name: "DateTime", value: "datetime" },
293
+ { name: "Color", value: "color" },
294
+ { name: "File", value: "file" },
295
+ { name: "Range", value: "range" },
296
+ ],
297
+ default: "string"
298
+ },
299
+ {
300
+ type: "checkbox",
301
+ name: "validation",
302
+ message: "Validation:",
303
+ choices: [
304
+ { name: "Required", value: "required", checked: true },
305
+ { name: "Must be unique", value: "unique" },
306
+ { name: "Set min value/length", value: "min" },
307
+ { name: "Set max value/length", value: "max" },
308
+ ]
309
+ },
310
+ {
311
+ type: "input",
312
+ name: "minVal",
313
+ message: "Min (number/date/length):",
314
+ when: (a) => a.validation.includes("min"),
315
+ validate: (v) => !v || !isNaN(v) || "Enter a number/date"
316
+ },
317
+ {
318
+ type: "input",
319
+ name: "maxVal",
320
+ message: "Max (number/date/length):",
321
+ when: (a) => a.validation.includes("max"),
322
+ validate: (v) => !v || !isNaN(v) || "Enter a number/date"
323
+ },
324
+ {
325
+ type: "input",
326
+ name: "placeholder",
327
+ message: "Placeholder text (optional):"
328
+ },
329
+ ]);
330
+
331
+ const field = {
332
+ name: answers.name,
333
+ type: answers.type,
334
+ validation: {
335
+ required: answers.validation.includes("required"),
336
+ unique: answers.validation.includes("unique"),
337
+ }
338
+ };
339
+
340
+ if (answers.minVal) {
341
+ const val = parseFloat(answers.minVal);
342
+ if (!isNaN(val)) {
343
+ field.validation.min = val;
344
+ if (answers.type === "string" || answers.type === "text") {
345
+ field.validation.minLength = val;
346
+ }
347
+ }
348
+ }
349
+ if (answers.maxVal) {
350
+ const val = parseFloat(answers.maxVal);
351
+ if (!isNaN(val)) {
352
+ field.validation.max = val;
353
+ if (answers.type === "string" || answers.type === "text") {
354
+ field.validation.maxLength = val;
355
+ }
356
+ }
357
+ }
358
+
359
+ fields.push(field);
360
+ const { again } = await inquirer.prompt([{ type: "confirm", name: "again", message: "Add another field?", default: false }]);
361
+ more = again;
362
+ }
363
+ return fields;
364
+ }
365
+
366
+ return [{
367
+ name: "name",
368
+ type: "string",
369
+ validation: { required: true, minLength: 3, maxLength: 100 }
370
+ }];
371
+ }
372
+
373
+ async function generateModuleFiles(projectRoot, moduleName, archLevel, fields, { frontendDir, backendDir }) {
374
+ const modDir = path.join(projectRoot, backendDir, "src/modules", moduleName);
375
+ await fs.ensureDir(modDir);
376
+
377
+ const pascalName = moduleName.charAt(0).toUpperCase() + moduleName.slice(1);
378
+
379
+ if (archLevel === "lightweight") {
380
+ const { model, controller, middleware, routes } = generateLightweight(moduleName, pascalName, fields);
381
+ await fs.writeFile(path.join(modDir, `${moduleName}.model.js`), model);
382
+ await fs.writeFile(path.join(modDir, `${moduleName}.controller.js`), controller);
383
+ await fs.writeFile(path.join(modDir, `${moduleName}.middleware.js`), middleware);
384
+ await fs.writeFile(path.join(modDir, `${moduleName}.routes.js`), routes);
385
+ } else {
386
+ const { model, service, controller, routes, validator } = generateStandardModule(moduleName, pascalName, archLevel === "advanced", fields);
387
+ await fs.writeFile(path.join(modDir, `${moduleName}.model.js`), model);
388
+ await fs.writeFile(path.join(modDir, `${moduleName}.service.js`), service);
389
+ await fs.writeFile(path.join(modDir, `${moduleName}.controller.js`), controller);
390
+ await fs.writeFile(path.join(modDir, `${moduleName}.routes.js`), routes);
391
+ await fs.ensureDir(path.join(projectRoot, backendDir, "src/utils/validators"));
392
+ await fs.writeFile(path.join(projectRoot, backendDir, "src/utils/validators", `${moduleName}.validator.js`), validator);
393
+
394
+ if (archLevel === "advanced") {
395
+ await fs.ensureDir(path.join(modDir, "tests"));
396
+ await fs.writeFile(path.join(modDir, "tests", `${moduleName}.test.js`),
397
+ `const { ${moduleName}Model } = require("../${moduleName}.model");\n` +
398
+ `const request = require("supertest");\n` +
399
+ `const app = require("../../app");\n\n` +
400
+ `describe("${moduleName} module", () => {\n` +
401
+ ` beforeAll(async () => {\n // Connect to test database\n });\n\n` +
402
+ ` afterAll(async () => {\n // Clean up\n });\n\n` +
403
+ ` test("should create ${moduleName}", async () => {\n const res = await request(app)\n .post("/api/${moduleName}")\n .send({ name: "test" })\n .expect(201);\n expect(res.body).toHaveProperty("data");\n });\n});\n`);
404
+ }
405
+ }
406
+
407
+ const routesIndexPath = path.join(projectRoot, backendDir, "src/routes/index.js");
408
+ if (fs.existsSync(routesIndexPath)) {
409
+ const routesCode = await fs.readFile(routesIndexPath, "utf-8");
410
+ const mountLine = `router.use("/${moduleName}", require("../modules/${moduleName}/${moduleName}.routes"));`;
411
+ if (!routesCode.includes(mountLine)) {
412
+ await fs.writeFile(routesIndexPath, routesCode.replace("module.exports = router;", `${mountLine}\nmodule.exports = router;`));
413
+ }
414
+ }
415
+
416
+ // Ensure required dependencies are present in backend package.json
417
+ await ensureBackendDependencies(projectRoot, backendDir, fields, archLevel);
418
+ }
419
+
420
+ function generateLightweight(moduleName, pascalName, fields) {
421
+ const schemaFields = fields.map(f => {
422
+ let def = `${f.name}: { type: `;
423
+
424
+ if (f.type === "number") def += "Number";
425
+ else if (f.type === "boolean") def += "Boolean";
426
+ else if (f.type === "date") def += "Date";
427
+ else def += "String";
428
+
429
+ const constraints = [];
430
+ if (f.validation?.required) constraints.push("required: true");
431
+ if (f.type === "string" || f.type === "text" || f.type === "email" || f.type === "phone") constraints.push("trim: true");
432
+ if (f.validation?.minLength) constraints.push(`minlength: ${f.validation.minLength}`);
433
+ if (f.validation?.maxLength) constraints.push(`maxlength: ${f.validation.maxLength}`);
434
+
435
+ if (constraints.length > 0) def += `, ${constraints.join(", ")}`;
436
+ return def + " }";
437
+ }).join(",\n ");
438
+
439
+ const model = `const mongoose = require("mongoose");
440
+
441
+ const ${moduleName}Schema = new mongoose.Schema(
442
+ {
443
+ ${schemaFields}
444
+ },
445
+ { timestamps: true }
446
+ );
447
+
448
+ module.exports = { ${moduleName}Model: mongoose.model("${pascalName}", ${moduleName}Schema) };
449
+ `;
450
+
451
+ const middleware = `// ${pascalName} Middleware
452
+
453
+ const ${pascalName}Middleware = {
454
+ validateId: async (req, res, next) => {
455
+ const { id } = req.params;
456
+ const Model = require("./${moduleName}.model").${pascalName}Model;
457
+ const doc = await Model.findById(id);
458
+ if (!doc) return res.status(404).json({ success: false, message: "${pascalName} not found" });
459
+ req.${moduleName}Doc = doc;
460
+ next();
461
+ },
462
+ sanitizeInput: (req, res, next) => {
463
+ ${fields.filter(f => ["email","tel","url","text","string"].includes(f.type)).map(f => `if (req.body && req.body.${f.name}) req.body.${f.name} = req.body.${f.name}${f.type === "email" ? ".toLowerCase().trim()" : ".trim()"};`).join('\n ')}
464
+ next();
465
+ }
466
+ };
467
+ module.exports = ${pascalName}Middleware;
468
+ `;
469
+
470
+ const sanitizationCode = fields.filter(f => ["email","tel","url","text","string"].includes(f.type))
471
+ .map(f => `if (req.body.${f.name}) req.body.${f.name} = req.body.${f.name}${f.type === "email" ? ".toLowerCase().trim()" : ".trim()"};`).join('\n ');
472
+
473
+ const controller = `// ${pascalName} Controller
474
+ const ${pascalName}Model = require("./${moduleName}.model").${moduleName}Model;
475
+ const ApiResponse = require("../../utils/ApiResponse");
476
+ const ApiError = require("../../utils/ApiError");
477
+
478
+ const create${pascalName} = async (req, res, next) => {
479
+ try {
480
+ ${sanitizationCode}
481
+ const doc = await ${pascalName}Model.create(req.body);
482
+ return res.status(201).json(new ApiResponse(201, "${pascalName} created", { data: doc }).body);
483
+ } catch (err) { return next(err); }
484
+ };
485
+
486
+ const getAll${pascalName}s = async (req, res, next) => {
487
+ try {
488
+ const { page = 1, limit = 50 } = req.query;
489
+ const docs = await ${pascalName}Model.find({}).skip((page - 1) * limit).limit(parseInt(limit));
490
+ const total = await ${pascalName}Model.countDocuments();
491
+ return res.status(200).json(new ApiResponse(200, "Fetched", { data: docs, page: parseInt(page), total }).body);
492
+ } catch (err) { return next(err); }
493
+ };
494
+
495
+ const get${pascalName}ById = async (req, res, next) => {
496
+ try {
497
+ const doc = await ${pascalName}Model.findById(req.params.id);
498
+ if (!doc) throw new ApiError(404, "${pascalName} not found");
499
+ return res.status(200).json(new ApiResponse(200, "Fetched", { data: doc }).body);
500
+ } catch (err) { return next(err); }
501
+ };
502
+
503
+ const update${pascalName} = async (req, res, next) => {
504
+ try {
505
+ ${sanitizationCode}
506
+ const doc = await ${pascalName}Model.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true });
507
+ if (!doc) throw new ApiError(404, "${pascalName} not found");
508
+ return res.status(200).json(new ApiResponse(200, "Updated", { data: doc }).body);
509
+ } catch (err) { return next(err); }
510
+ };
511
+
512
+ const delete${pascalName} = async (req, res, next) => {
513
+ try {
514
+ const doc = await ${pascalName}Model.findByIdAndDelete(req.params.id);
515
+ if (!doc) throw new ApiError(404, "${pascalName} not found");
516
+ return res.status(200).json(new ApiResponse(200, "Deleted").body);
517
+ } catch (err) { return next(err); }
518
+ };
519
+
520
+ module.exports = { create${pascalName}, getAll${pascalName}s, get${pascalName}ById, update${pascalName}, delete${pascalName} };
521
+ `;
522
+
523
+ const routes = `// ${pascalName} Routes
524
+ const express = require("express");
525
+ const router = express.Router();
526
+ const authenticate = require("../../middlewares/auth.middleware").authenticate;
527
+ const requireRole = require("../../middlewares/auth.middleware").requireRole;
528
+ const controller = require("./${moduleName}.controller");
529
+ const middleware = require("./${moduleName}.middleware");
530
+
531
+ router.use(middleware.sanitizeInput);
532
+ router.post("/", authenticate, controller.create${pascalName});
533
+ router.get("/", authenticate, controller.getAll${pascalName}s);
534
+ router.get("/:id", authenticate, middleware.validateId, controller.get${pascalName}ById);
535
+ router.put("/:id", authenticate, middleware.validateId, controller.update${pascalName});
536
+ router.delete("/:id", authenticate, requireRole("admin"), middleware.validateId, controller.delete${pascalName});
537
+ module.exports = router;
538
+ `;
539
+
540
+ return { model, controller, middleware, routes };
541
+ }
542
+
543
+ function generateStandardModule(moduleName, pascalName, advanced, fields) {
544
+ // Model
545
+ const schemaFields = fields.map(f => {
546
+ let def = ` ${f.name}: ${getMongooseSchemaField(f.type, f.validation)}`;
547
+ return def;
548
+ }).join(",\n");
549
+
550
+ const model = `const mongoose = require("mongoose");
551
+ ${advanced ? `// Domain Model: ${pascalName}\n// This model represents core business entity ${moduleName}\n` : ""}
552
+
553
+ const ${moduleName}Schema = new mongoose.Schema(
554
+ {
555
+ ${schemaFields}
556
+ },
557
+ {
558
+ timestamps: true,
559
+ toJSON: { virtuals: true, transform: (doc, ret) => { delete ret._id; delete ret.__v; } },
560
+ toObject: { virtuals: true }
561
+ }
562
+ );
563
+
564
+ // Virtuals for computed fields (if needed)
565
+ // ${moduleName}Schema.virtual('fullName').get(function() { return this.firstName + ' ' + this.lastName; });
566
+
567
+ module.exports = mongoose.model("${pascalName}", ${moduleName}Schema);
568
+ `;
569
+
570
+ // Service
571
+ const service = `const ${moduleName}Model = require("./${moduleName}.model");
572
+ const ApiError = require("../../utils/ApiError");
573
+ ${advanced ? `\n/**
574
+ * ${pascalName} Service
575
+ * Handles business logic for ${moduleName} domain
576
+ */\nclass ${pascalName}Service {\n` : ""}
577
+
578
+ const create${pascalName} = async (payload) => {
579
+ // Sanitize input
580
+ ${getSanitizationCode(fields, 'payload')}
581
+
582
+ // Generate code/slug if needed
583
+ ${generateCodeGenerationLogic(fields, 'payload')}
584
+
585
+ return await ${moduleName}Model.create(payload);
586
+ };
587
+
588
+ const getAll${pascalName}s = async (filters = {}) => {
589
+ const query = ${moduleName}Model.find({});
590
+ ${getFilteringCode(fields, 'filters')}
591
+ return await query.exec();
592
+ };
593
+
594
+ const get${pascalName}ById = async (id) => {
595
+ const doc = await ${moduleName}Model.findById(id);
596
+ if (!doc) throw new ApiError(404, "${moduleName} not found");
597
+ return doc;
598
+ };
599
+
600
+ const get${pascalName}ByUnique = async (field, value) => {
601
+ return await ${moduleName}Model.findOne({ [field]: value });
602
+ };
603
+
604
+ const update${pascalName} = async (id, updates) => {
605
+ ${getSanitizationCode(fields, 'updates')}
606
+ const doc = await ${moduleName}Model.findByIdAndUpdate(id, updates, { new: true, runValidators: true });
607
+ if (!doc) throw new ApiError(404, "${moduleName} not found");
608
+ return doc;
609
+ };
610
+
611
+ const delete${pascalName} = async (id) => {
612
+ // Soft delete pattern: set deleted=true, deletedAt timestamp
613
+ // For now hard delete:
614
+ const doc = await ${moduleName}Model.findByIdAndDelete(id);
615
+ if (!doc) throw new ApiError(404, "${moduleName} not found");
616
+ return doc;
617
+ };
618
+
619
+ // Advanced: Batch operations
620
+ ${advanced ? `const bulkCreate${pascalName}s = async (items) => {
621
+ if (!Array.isArray(items)) throw new ApiError(400, "Items must be an array");
622
+ return await ${moduleName}Model.insertMany(items);
623
+ };
624
+
625
+ const bulkUpdate${pascalName}s = async (updates) => {
626
+ // updates: [{ id, changes }, ...]
627
+ const session = await mongoose.startSession();
628
+ session.startTransaction();
629
+ try {
630
+ for (const update of updates) {
631
+ await ${moduleName}Model.findByIdAndUpdate(update.id, update.changes, { session });
632
+ }
633
+ await session.commitTransaction();
634
+ session.endSession();
635
+ return updates.length;
636
+ } catch (err) {
637
+ await session.abortTransaction();
638
+ session.endSession();
639
+ throw err;
640
+ }
641
+ };` : ''}
642
+
643
+ ${advanced ? `}\nmodule.exports = new ${pascalName}Service();` : `module.exports = {
644
+ create${pascalName},
645
+ getAll${pascalName}s,
646
+ get${pascalName}ById,
647
+ get${pascalName}ByUnique,
648
+ update${pascalName},
649
+ delete${pascalName},
650
+ ${advanced ? 'bulkCreate' + pascalName + 's,\n bulkUpdate' + pascalName + 's,' : ''}
651
+ };`}
652
+ `;
653
+
654
+ // Controller
655
+ const controller = `const service = require("./${moduleName}.service");
656
+ const ApiResponse = require("../../utils/ApiResponse");
657
+
658
+ const create = async (req, res, next) => {
659
+ try {
660
+ const result = await service.create${pascalName}(req.body);
661
+ return res.status(201).json(new ApiResponse(201, "${pascalName} created", { data: result }).body);
662
+ } catch (err) {
663
+ return next(err);
664
+ }
665
+ };
666
+
667
+ const list = async (req, res, next) => {
668
+ try {
669
+ const { page = 1, limit = 50, sort = "createdAt", order = "desc", ...filters } = req.query;
670
+ const result = await service.getAll${pascalName}s({
671
+ page: parseInt(page),
672
+ limit: parseInt(limit),
673
+ sort,
674
+ order,
675
+ filters
676
+ });
677
+ return res.status(200).json(new ApiResponse(200, "Fetched", { data: result }).body);
678
+ } catch (err) {
679
+ return next(err);
680
+ }
681
+ };
682
+
683
+ const getOne = async (req, res, next) => {
684
+ try {
685
+ const result = await service.get${pascalName}ById(req.params.id);
686
+ return res.status(200).json(new ApiResponse(200, "Fetched", { data: result }).body);
687
+ } catch (err) {
688
+ return next(err);
689
+ }
690
+ };
691
+
692
+ const update = async (req, res, next) => {
693
+ try {
694
+ const result = await service.update${pascalName}(req.params.id, req.body);
695
+ return res.status(200).json(new ApiResponse(200, "Updated", { data: result }).body);
696
+ } catch (err) {
697
+ return next(err);
698
+ }
699
+ };
700
+
701
+ const remove = async (req, res, next) => {
702
+ try {
703
+ await service.delete${pascalName}(req.params.id);
704
+ return res.status(200).json(new ApiResponse(200, "Deleted").body);
705
+ } catch (err) {
706
+ return next(err);
707
+ }
708
+ };
709
+
710
+ module.exports = { create, list, getOne, update, remove };
711
+ `;
712
+
713
+ // Validator
714
+ const validatorFields = fields.map(f => {
715
+ const joiRule = getJoiTypeForModule(f.type, f.validation);
716
+ return ` ${f.name}: ${joiRule}`;
717
+ }).join(",\n");
718
+
719
+ const optionalFields = fields.filter(f => !f.validation.required).map(f => `"${f.name}"`).join(", ");
720
+
721
+ const validator = `const Joi = require("joi");
722
+
723
+ /**
724
+ * ${pascalName} Validation Schemas
725
+ * ================================
726
+ * Keep these in sync with ${moduleName}.model.js schema
727
+ */
728
+
729
+ const create${pascalName}Schema = Joi.object({
730
+ ${validatorFields}
731
+ });
732
+
733
+ const update${pascalName}Schema = create${pascalName}Schema.fork(
734
+ [${optionalFields}],
735
+ (schema) => schema.optional()
736
+ );
737
+
738
+ module.exports = {
739
+ create${pascalName}Schema,
740
+ update${pascalName}Schema,
741
+ };
742
+ `;
743
+
744
+ // Routes
745
+ const routes = `const express = require("express");
746
+ const controller = require("./${moduleName}.controller");
747
+ const validate = require("../../middlewares/validate");
748
+ const authenticate = require("../../middlewares/auth.middleware").authenticate;
749
+ const requireRole = require("../../middlewares/auth.middleware").requireRole;
750
+ const { query } = require("express-validator");
751
+
752
+ const router = express.Router();
753
+
754
+ // Create — requires auth
755
+ router.post("/", authenticate,
756
+ validate(require("../../utils/validators/${moduleName}.validator").create${pascalName}Schema),
757
+ controller.create
758
+ );
759
+
760
+ // List — with pagination & filtering
761
+ router.get("/", authenticate,
762
+ query("page").optional().isInt({ min: 1 }).toInt(),
763
+ query("limit").optional().isInt({ min: 1, max: 100 }).toInt(),
764
+ query("sort").optional().isIn(${fields.map(f => `"${f.name}"`).join(", ")}),
765
+ query("order").optional().isIn(["asc", "desc"]),
766
+ ${fields.map(f => `query("${f.name}").optional().isString(),`).join("\n ")}
767
+ validate,
768
+ controller.list
769
+ );
770
+
771
+ // Single record
772
+ router.get("/:id", authenticate, controller.getOne);
773
+
774
+ // Update
775
+ router.put("/:id", authenticate,
776
+ validate(require("../../utils/validators/${moduleName}.validator").update${pascalName}Schema),
777
+ controller.update
778
+ );
779
+
780
+ // Delete — admin only
781
+ router.delete("/:id", authenticate, requireRole("admin"), controller.remove);
782
+
783
+ module.exports = router;
784
+ `;
785
+
786
+ return { model, service, controller, routes, validator };
787
+ }
788
+
789
+ function getSanitizationCode(fields, objName) {
790
+ return fields.filter(f => ["email", "tel", "url", "text", "string"].includes(f.type))
791
+ .map(f => {
792
+ switch (f.type) {
793
+ case "email":
794
+ return ` if (${objName}.${f.name}) ${objName}.${f.name} = ${objName}.${f.name}.toLowerCase().trim();`;
795
+ case "tel":
796
+ return ` if (${objName}.${f.name}) ${objName}.${f.name} = ${objName}.${f.name}.replace(/[^+0-9]/g, '');`;
797
+ case "url":
798
+ return ` if (${objName}.${f.name}) ${objName}.${f.name} = ${objName}.${f.name}.trim();`;
799
+ default:
800
+ return ` if (${objName}.${f.name} && typeof ${objName}.${f.name} === "string") ${objName}.${f.name} = ${objName}.${f.name}.trim();`;
801
+ }
802
+ }).join('\n');
803
+ }
804
+
805
+ function getFilteringCode(fields, filtersObj) {
806
+ const filters = [];
807
+
808
+ fields.forEach(f => {
809
+ filters.push(` if (${filtersObj}.filters.${f.name}) query = query.where('${f.name}', ${filtersObj}.filters.${f.name});`);
810
+ });
811
+
812
+ return filters.join('\n');
813
+ }
814
+
815
+ function generateCodeGenerationLogic(fields, objName) {
816
+ // If there's a 'slug' or 'code' field, generate it
817
+ const codeField = fields.find(f => f.name === "slug" || f.name === "code" || f.name === "sku");
818
+ if (!codeField) return "// No auto-generated field";
819
+
820
+ if (codeField.name === "slug" || codeField.name === "code") {
821
+ return ` // Auto-generate ${codeField.name} from name if not provided
822
+ if (!${objName}.${codeField.name} && ${objName}.name) {
823
+ const slugify = require('slugify');
824
+ ${objName}.${codeField.name} = slugify(${objName}.name, { lower: true, strict: true });
825
+ }`;
826
+ }
827
+
828
+ if (codeField.name === "sku") {
829
+ return ` // Generate SKU if not provided: PROD-XXXXX
830
+ if (!${objName}.sku) {
831
+ ${objName}.sku = "PROD-" + Math.random().toString(36).substr(2, 6).toUpperCase();
832
+ }`;
833
+ }
834
+
835
+ return "";
836
+ }