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,57 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { MarkerStrategy } from "../marker-strategy.js";
3
+
4
+ describe("MarkerStrategy", () => {
5
+ const sampleContent = `//══════════════════════════════════════════════════════════════════════════════
6
+ // AUTO-GENERATED — DO NOT EDIT MANUALLY
7
+ // Resource: Test
8
+ // Generated at: 2026-05-11T16:50:49.052Z
9
+ //══════════════════════════════════════════════════════════════════════════════
10
+
11
+ const a = 1;
12
+
13
+ //══════════════════════════════════════════════════════════════════════════════
14
+ // END AUTO-GENERATED
15
+ //══════════════════════════════════════════════════════════════════════════════
16
+ const b = 2;
17
+ `;
18
+
19
+ it("should detect markers", () => {
20
+ const parsed = MarkerStrategy.parse(sampleContent);
21
+ expect(parsed.hasMarkers).toBe(true);
22
+ expect(parsed.autoBlock).toContain("const a = 1;");
23
+ expect(parsed.epilogue).toContain("const b = 2;");
24
+ });
25
+
26
+ it("should extract auto block from new content", () => {
27
+ const newContent = `//══════════════════════════════════════════════════════════════════════════════
28
+ // AUTO-GENERATED — DO NOT EDIT MANUALLY
29
+ //══════════════════════════════════════════════════════════════════════════════
30
+
31
+ const a = 2;
32
+
33
+ //══════════════════════════════════════════════════════════════════════════════
34
+ // END AUTO-GENERATED
35
+ //══════════════════════════════════════════════════════════════════════════════
36
+ `;
37
+ const auto = MarkerStrategy.extractAutoBlock(newContent);
38
+ expect(auto).toBe("const a = 2;");
39
+ });
40
+
41
+ it("should compose new content preserving custom code", () => {
42
+ const parsed = MarkerStrategy.parse(sampleContent);
43
+ const newAuto = "const a = 3;";
44
+ const composed = MarkerStrategy.compose(parsed, newAuto);
45
+ expect(composed).toContain("const a = 3;");
46
+ expect(composed).toContain("const b = 2;");
47
+ });
48
+
49
+ it("should support stealth mode (no timestamps)", () => {
50
+ const content = "hello";
51
+ const withMarkers = MarkerStrategy.wrapWithMarkers(content, "Test", {
52
+ stealth: true,
53
+ });
54
+ expect(withMarkers).not.toContain("Generated at:");
55
+ expect(withMarkers).not.toContain("Resource:");
56
+ });
57
+ });
@@ -0,0 +1,32 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ResourceDefinition, parseFieldSpec } from '../resource-definition.js';
3
+
4
+ describe('ResourceDefinition', () => {
5
+ it('should parse compact field spec', () => {
6
+ const spec = 'email:email[required]|unique';
7
+ const field = parseFieldSpec(spec);
8
+ expect(field.name).toBe('email');
9
+ expect(field.type).toBe('email');
10
+ expect(field.validation.required).toBe(true);
11
+ expect(field.validation.unique).toBe(true);
12
+ });
13
+
14
+ it('should parse select options', () => {
15
+ const spec = 'role:select[admin,user,guest]';
16
+ const field = parseFieldSpec(spec);
17
+ expect(field.special.options).toEqual(['admin', 'user', 'guest']);
18
+ });
19
+
20
+ it('should create ResourceDefinition instance', () => {
21
+ const def = new ResourceDefinition({
22
+ name: 'Product',
23
+ fields: [
24
+ { name: 'name', type: 'string', validation: { required: true } },
25
+ { name: 'price', type: 'number', validation: { min: 0 } }
26
+ ]
27
+ });
28
+ expect(def.pascalName).toBe('Product');
29
+ expect(def.kebabName).toBe('product');
30
+ expect(def.fields.length).toBe(2);
31
+ });
32
+ });
@@ -0,0 +1,542 @@
1
+ import fs from "fs-extra";
2
+ import path from "path";
3
+ import { TemplateLoader } from "./template-loader.js";
4
+ import { MarkerStrategy } from "./marker-strategy.js";
5
+ import { ResourceDefinition } from "./resource-definition.js";
6
+ import { StateTracker } from "./state-tracker.js";
7
+ import { blueprintLoader } from "../blueprint/index.js";
8
+ import chalk from "chalk";
9
+
10
+ export class Generator {
11
+ /**
12
+ * @param {Object} options
13
+ */
14
+ constructor(options = {}) {
15
+ this.projectRoot = options.projectRoot || process.cwd();
16
+ this.arch = options.architecture || "moderate";
17
+ this.dryRun = options.dryRun || false;
18
+ this.verbose = options.verbose || false;
19
+ this.force = options.force || false;
20
+ this.withFrontend = options.withFrontend !== false;
21
+ this.withTests = options.withTests || false;
22
+
23
+ // Architecture contract — resolved lazily, never hardcoded. See blueprint/.
24
+ this.blueprint = options.blueprint || null;
25
+
26
+ this.templates = new TemplateLoader();
27
+ this.templates.projectRoot = this.projectRoot;
28
+
29
+ this.tracker = new StateTracker(this.projectRoot);
30
+
31
+ this.resource = null;
32
+ this.generatedFiles = [];
33
+ this.issues = [];
34
+ }
35
+
36
+ /**
37
+ * Main entry: generate resource from definition object or file
38
+ */
39
+ async generateFromDefinition(resourceDef) {
40
+ this.resource =
41
+ resourceDef instanceof ResourceDefinition
42
+ ? resourceDef
43
+ : new ResourceDefinition(resourceDef);
44
+
45
+ await this.validateProject();
46
+ const context = await this.buildContext();
47
+ await this.generateBackend(context);
48
+ await this.ensureBackendDeps(context);
49
+
50
+ if (this.withFrontend) {
51
+ await this.generateFrontend(context);
52
+ }
53
+
54
+ await this.updateProjectFiles(context);
55
+
56
+ // Record for rollback
57
+ if (!this.dryRun && this.resource) {
58
+ await this.tracker.recordEvent({
59
+ action: "generate",
60
+ resource: this.resource.name,
61
+ files: this.generatedFiles
62
+ .filter((f) => f.action !== "SKIP")
63
+ .map((f) => ({ path: f.output, action: f.action })),
64
+ });
65
+ }
66
+
67
+ return {
68
+ files: this.generatedFiles,
69
+ issues: this.issues,
70
+ resource: this.resource,
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Load + cache the architecture blueprint for this project.
76
+ * Falls back to the CLI's built-in MERN blueprint when a project has none.
77
+ */
78
+ async getBlueprint() {
79
+ if (!this.blueprint) {
80
+ this.blueprint = await blueprintLoader.load(this.projectRoot);
81
+ }
82
+ return this.blueprint;
83
+ }
84
+
85
+ /**
86
+ * Validate we're in a project the blueprint recognizes.
87
+ */
88
+ async validateProject() {
89
+ const blueprint = await this.getBlueprint();
90
+ const modulesDir = blueprint.resolvePath("backend.modules", this.projectRoot);
91
+ if (!(await fs.pathExists(modulesDir))) {
92
+ throw new Error(
93
+ "Not a MERN Starter Kit backend. Run from project root where backend/ exists.",
94
+ );
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Build context object for templates
100
+ */
101
+ async buildContext() {
102
+ const blueprint = await this.getBlueprint();
103
+ const backendDir = blueprint.resolveRoot("backend", this.projectRoot);
104
+ const frontendDir = blueprint.resolveRoot("frontend", this.projectRoot);
105
+ const usesTS = blueprint.usesTypeScript(this.projectRoot);
106
+
107
+ return {
108
+ resource: this.resource,
109
+ blueprint,
110
+ options: {
111
+ architecture: this.arch,
112
+ force: this.force,
113
+ withTests: this.withTests,
114
+ withFrontend: this.withFrontend,
115
+ timestamp: new Date().toISOString(),
116
+ },
117
+ project: {
118
+ root: this.projectRoot,
119
+ backendDir,
120
+ frontendDir,
121
+ usesTypeScript: usesTS,
122
+ },
123
+ utils: {
124
+ pascal: (s) => s.charAt(0).toUpperCase() + s.slice(1),
125
+ camel: (s) => s.charAt(0).toLowerCase() + s.slice(1),
126
+ snake: (s) => s.replace(/[A-Z]/g, (m) => "_" + m.toLowerCase()),
127
+ kebab: (s) => s.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase()),
128
+ quote: (str) => JSON.stringify(str),
129
+ indent: (str, n) =>
130
+ str
131
+ .split("\n")
132
+ .map((l) => " ".repeat(n) + l)
133
+ .join("\n"),
134
+ },
135
+ };
136
+ }
137
+
138
+ // Directory + language detection now lives in the Blueprint (blueprint/),
139
+ // resolved via getBlueprint() — no architecture assumptions hardcoded here.
140
+
141
+ // ═══════════════════════════════════════════════════════════════════════════
142
+ // BACKEND GENERATION
143
+ // ═══════════════════════════════════════════════════════════════════════════
144
+
145
+ async generateBackend(context) {
146
+ if (!this.resource) return;
147
+ const { name, kebabName } = this.resource;
148
+
149
+ const templates = [
150
+ {
151
+ tpl: "resource/model.js.ejs",
152
+ out: `backend/src/modules/${kebabName}/models/${name}.js`,
153
+ },
154
+ {
155
+ tpl: "resource/service.js.ejs",
156
+ out: `backend/src/modules/${kebabName}/services/${name}.service.js`,
157
+ },
158
+ {
159
+ tpl: "resource/controller.js.ejs",
160
+ out: `backend/src/modules/${kebabName}/controllers/${name}.controller.js`,
161
+ },
162
+ {
163
+ tpl: "resource/routes.js.ejs",
164
+ out: `backend/src/modules/${kebabName}/routes/${name}.routes.js`,
165
+ },
166
+ {
167
+ tpl: "resource/validator.js.ejs",
168
+ out: `backend/src/utils/validators/${name}.validator.js`,
169
+ },
170
+ ];
171
+
172
+ if (this.arch === "advanced" || this.withTests) {
173
+ templates.push({
174
+ tpl: "resource/test.ejs",
175
+ out: `backend/src/modules/${kebabName}/tests/${name}.test.js`,
176
+ });
177
+ }
178
+
179
+ for (const job of templates) {
180
+ await this.generateFile(job.tpl, job.out, context);
181
+ }
182
+
183
+ await this.injectIntoRoutesIndex(context);
184
+ }
185
+
186
+ // ═══════════════════════════════════════════════════════════════════════════
187
+ // FRONTEND GENERATION
188
+ // ═══════════════════════════════════════════════════════════════════════════
189
+
190
+ async generateFrontend(context) {
191
+ if (!this.resource) return;
192
+ const { name, kebabName } = this.resource;
193
+
194
+ // Legacy path: the modal shell is the closest match to the old ListPage.
195
+ // New work should prefer the engine-backed `loom generate resource`.
196
+ const templates = [
197
+ {
198
+ tpl: "resource/page-modal.jsx.ejs",
199
+ out: `frontend/src/pages/admin/${kebabName}/ListPage.jsx`,
200
+ },
201
+ {
202
+ tpl: "resource/page-detail.jsx.ejs",
203
+ out: `frontend/src/pages/admin/${kebabName}/DetailPage.jsx`,
204
+ },
205
+ {
206
+ tpl: "resource/page-form.jsx.ejs",
207
+ out: `frontend/src/pages/admin/${kebabName}/FormPage.jsx`,
208
+ },
209
+ {
210
+ tpl: "resource/components/table.jsx.ejs",
211
+ out: `frontend/src/components/tables/${name}Table.jsx`,
212
+ },
213
+ {
214
+ tpl: "resource/components/form.jsx.ejs",
215
+ out: `frontend/src/components/forms/${name}Form.jsx`,
216
+ },
217
+ {
218
+ tpl: "resource/api.js.ejs",
219
+ out: `frontend/src/api/${kebabName}.api.js`,
220
+ },
221
+ { tpl: "resource/hooks.js.ejs", out: `frontend/src/hooks/use${name}.js` },
222
+ ];
223
+
224
+ if (context.project.usesTypeScript) {
225
+ templates.push({
226
+ tpl: "resource/types.ts.ejs",
227
+ out: `frontend/src/types/${kebabName}.types.ts`,
228
+ });
229
+ }
230
+
231
+ for (const job of templates) {
232
+ await this.generateFile(job.tpl, job.out, context);
233
+ }
234
+
235
+ await this.injectIntoFrontendRouter(context);
236
+ await this.injectIntoNavigation(context);
237
+ }
238
+
239
+ // ═══════════════════════════════════════════════════════════════════════════
240
+ // FILE GENERATION (with dry-run, force, markers)
241
+ // ═══════════════════════════════════════════════════════════════════════════
242
+
243
+ async generateFile(templatePath, outputPath, context) {
244
+ const fullOut = path.join(this.projectRoot, outputPath);
245
+ const exists = await fs.pathExists(fullOut);
246
+
247
+ if (exists && !this.force) {
248
+ this.log(`[SKIP] ${outputPath} exists (use --force to overwrite)`);
249
+ this.generatedFiles.push({
250
+ template: templatePath,
251
+ output: outputPath,
252
+ action: "SKIP",
253
+ reason: "exists",
254
+ });
255
+ return;
256
+ }
257
+
258
+ if (this.dryRun) {
259
+ this.generatedFiles.push({
260
+ template: templatePath,
261
+ output: outputPath,
262
+ action: exists ? "UPDATE" : "CREATE",
263
+ reason: "dry-run",
264
+ });
265
+ return;
266
+ }
267
+
268
+ await fs.ensureDir(path.dirname(fullOut));
269
+
270
+ let content;
271
+ try {
272
+ content = await this.templates.render(
273
+ templatePath,
274
+ context,
275
+ this.projectRoot,
276
+ );
277
+ } catch (err) {
278
+ this.issues.push({
279
+ type: "error",
280
+ file: outputPath,
281
+ message: err instanceof Error ? err.message : String(err),
282
+ });
283
+ throw err;
284
+ }
285
+
286
+ if (exists) {
287
+ try {
288
+ const existing = await fs.readFile(fullOut, "utf-8");
289
+ const parsed = MarkerStrategy.parse(existing);
290
+ if (parsed.hasMarkers) {
291
+ const newAuto = MarkerStrategy.extractAutoBlock(content);
292
+ content = MarkerStrategy.compose(parsed, newAuto);
293
+ } else {
294
+ content = MarkerStrategy.ensureMarkers(
295
+ content,
296
+ context.resource?.name || "",
297
+ );
298
+ }
299
+ } catch (err) {
300
+ this.issues.push({
301
+ type: "warn",
302
+ file: outputPath,
303
+ message: `Marker strategy failed: ${err instanceof Error ? err.message : String(err)}`,
304
+ });
305
+ }
306
+ } else {
307
+ content = MarkerStrategy.ensureMarkers(
308
+ content,
309
+ context.resource?.name || "",
310
+ );
311
+ }
312
+
313
+ try {
314
+ await fs.writeFile(fullOut, content, "utf-8");
315
+ this.log(`[${exists ? "UPDATE" : "CREATE"}] ${outputPath}`);
316
+ this.generatedFiles.push({
317
+ template: templatePath,
318
+ output: outputPath,
319
+ action: exists ? "UPDATE" : "CREATE",
320
+ });
321
+ } catch (err) {
322
+ this.issues.push({
323
+ type: "error",
324
+ file: outputPath,
325
+ message: err instanceof Error ? err.message : String(err),
326
+ });
327
+ throw err;
328
+ }
329
+ }
330
+
331
+ // ═══════════════════════════════════════════════════════════════════════════
332
+ // PROJECT FILE UPDATES (routes, navigation, etc)
333
+ // ═══════════════════════════════════════════════════════════════════════════
334
+
335
+ async injectIntoRoutesIndex(context) {
336
+ const indexPath = path.join(
337
+ this.projectRoot,
338
+ "backend",
339
+ "src",
340
+ "routes",
341
+ "index.js",
342
+ );
343
+ if (!(await fs.pathExists(indexPath))) {
344
+ this.log("[SKIP] backend/src/routes/index.js not found");
345
+ return;
346
+ }
347
+
348
+ let code = await fs.readFile(indexPath, "utf-8");
349
+ const mountLine = `router.use("/${context.resource.kebabName}", require("../modules/${context.resource.kebabName}/routes/${context.resource.name}.routes"));`;
350
+
351
+ if (code.includes(mountLine)) {
352
+ this.log("[SKIP] Route already mounted in index.js");
353
+ return;
354
+ }
355
+
356
+ if (code.includes("module.exports = router")) {
357
+ code = code.replace(
358
+ "module.exports = router;",
359
+ `${mountLine}\nmodule.exports = router;`,
360
+ );
361
+ await fs.writeFile(indexPath, code, "utf-8");
362
+ this.generatedFiles.push({
363
+ output: "backend/src/routes/index.js",
364
+ action: "UPDATE",
365
+ reason: "mount-route",
366
+ });
367
+ this.log(`[UPDATE] backend/src/routes/index.js`);
368
+ } else {
369
+ this.issues.push({
370
+ type: "warn",
371
+ file: "routes/index.js",
372
+ message: "Could not find module.exports line to mount route",
373
+ });
374
+ }
375
+ }
376
+
377
+ async injectIntoFrontendRouter(context) {
378
+ if (!this.withFrontend) return;
379
+
380
+ const routerPath = path.join(
381
+ this.projectRoot,
382
+ "frontend",
383
+ "src",
384
+ "routes",
385
+ "AppRouter.jsx",
386
+ );
387
+ if (!(await fs.pathExists(routerPath))) {
388
+ this.log("[SKIP] frontend/src/routes/AppRouter.jsx not found");
389
+ return;
390
+ }
391
+
392
+ let code = await fs.readFile(routerPath, "utf-8");
393
+ const pageName = context.resource.pascalName;
394
+ const kebabName = context.resource.kebabName;
395
+
396
+ const importLine = `const ${pageName}List = lazy(() => import("@/pages/admin/${kebabName}/ListPage"));`;
397
+ let changed = false;
398
+
399
+ if (!code.includes(importLine)) {
400
+ const importRegex = /^const \w+Page = lazy\(.*?\);/gm;
401
+ const imports = code.match(importRegex);
402
+
403
+ if (imports && imports.length > 0) {
404
+ const lastImport = imports[imports.length - 1];
405
+ code = code.replace(lastImport, `${lastImport}\n${importLine}`);
406
+ } else {
407
+ code = code.replace(
408
+ "export function AppRouter()",
409
+ `${importLine}\nexport function AppRouter()`,
410
+ );
411
+ }
412
+ changed = true;
413
+ }
414
+
415
+ const routePath = `/admin/${kebabName}`;
416
+ const routeBlock = `${pageName}List`;
417
+ const routeInsert = `\n {/* ${pageName} */}
418
+ <Route
419
+ path="${routePath}"
420
+ element={<AppShell secure><${routeBlock} /></AppShell>}
421
+ />`;
422
+
423
+ if (!code.includes(`path="${routePath}"`)) {
424
+ const wildcardRegex = /^(\s*)<Route\s+path="\*"\s+element=.*?\/>/m;
425
+ const match = code.match(wildcardRegex);
426
+
427
+ if (match) {
428
+ const indent = match[1];
429
+ const indentedInsert = routeInsert.replace(/\n/g, "\n" + indent);
430
+ code = code.replace(wildcardRegex, indentedInsert + "\n" + match[0]);
431
+ } else {
432
+ code = code.replace("</Routes>", `${routeInsert}\n </Routes>`);
433
+ }
434
+ changed = true;
435
+ }
436
+
437
+ if (changed) {
438
+ await fs.writeFile(routerPath, code, "utf-8");
439
+ this.generatedFiles.push({
440
+ output: "frontend/src/routes/AppRouter.jsx",
441
+ action: "UPDATE",
442
+ reason: "add-route",
443
+ });
444
+ this.log(`[UPDATE] frontend/src/routes/AppRouter.jsx`);
445
+ }
446
+ }
447
+
448
+ async injectIntoNavigation(context) {
449
+ if (!this.withFrontend) return;
450
+
451
+ const presetPath = path.join(
452
+ this.projectRoot,
453
+ "frontend",
454
+ "src",
455
+ "config",
456
+ "app-preset.js",
457
+ );
458
+ if (!(await fs.pathExists(presetPath))) {
459
+ this.log("[SKIP] frontend/src/config/app-preset.js not found");
460
+ return;
461
+ }
462
+
463
+ let presetCode = await fs.readFile(presetPath, "utf-8");
464
+ const routePath = `/admin/${context.resource.kebabName}`;
465
+ const navLabel = context.resource.name.replace(/([A-Z])/g, " $1").trim();
466
+ const navEntry = `{ label: "${navLabel}", href: "${routePath}", icon: "layout" },`;
467
+
468
+ if (presetCode.includes(`href: "${routePath}"`)) {
469
+ this.log("[SKIP] Navigation entry already exists");
470
+ return;
471
+ }
472
+
473
+ const navMatch = presetCode.match(/navigation\s*:\s*\[([^\]]*)\]/s);
474
+ if (!navMatch) {
475
+ this.issues.push({
476
+ type: "warn",
477
+ file: "app-preset.js",
478
+ message: "Could not find navigation array",
479
+ });
480
+ return;
481
+ }
482
+
483
+ let existingItems = navMatch[1].trim();
484
+ existingItems = existingItems.replace(/,\s*$/, "");
485
+
486
+ const newItems = existingItems
487
+ ? `${existingItems},\n ${navEntry}`
488
+ : navEntry;
489
+ const replacement = `navigation: [\n ${newItems}\n ]`;
490
+
491
+ presetCode = presetCode.replace(
492
+ /navigation\s*:\s*\[[^\]]*\]/s,
493
+ replacement,
494
+ );
495
+ await fs.writeFile(presetPath, presetCode, "utf-8");
496
+ this.generatedFiles.push({
497
+ output: "frontend/src/config/app-preset.js",
498
+ action: "UPDATE",
499
+ reason: "add-nav",
500
+ });
501
+ this.log(`[UPDATE] frontend/src/config/app-preset.js (navigation)`);
502
+ }
503
+
504
+ async ensureBackendDeps(context) {
505
+ const backendDir = context.project.backendDir;
506
+ const pkgPath = path.join(this.projectRoot, backendDir, "package.json");
507
+ if (!fs.existsSync(pkgPath)) return;
508
+
509
+ const pkg = await fs.readJSON(pkgPath);
510
+ const required = {};
511
+
512
+ // slugify needed if resource has slug, code, or sku field
513
+ if (this.resource.fields.some(f => ["slug", "code", "sku"].includes(f.name))) {
514
+ required.slugify = "^1.6.6";
515
+ }
516
+
517
+ // No express-validator needed for resource generator (uses Joi)
518
+
519
+ if (Object.keys(required).length === 0) return;
520
+
521
+ let changed = false;
522
+ const deps = pkg.dependencies || (pkg.dependencies = {});
523
+ for (const [name, version] of Object.entries(required)) {
524
+ if (!deps[name]) {
525
+ deps[name] = version;
526
+ changed = true;
527
+ }
528
+ }
529
+ if (changed) {
530
+ await fs.writeJSON(pkgPath, pkg, { spaces: 2 });
531
+ this.log(chalk.green("✓ Added backend dependencies: " + Object.keys(required).join(", ")));
532
+ }
533
+ }
534
+
535
+ async updateProjectFiles(context) {
536
+ // future logic
537
+ }
538
+
539
+ log(msg) {
540
+ if (this.verbose) console.log(msg);
541
+ }
542
+ }