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.
- package/LICENSE +21 -0
- package/README.md +169 -0
- package/bin/cli.js +306 -0
- package/branding.json +8 -0
- package/package.json +72 -0
- package/src/__tests__/cli-smoke.test.js +46 -0
- package/src/blueprint/__tests__/blueprint.test.js +116 -0
- package/src/blueprint/blueprint.js +181 -0
- package/src/blueprint/default.blueprint.json +78 -0
- package/src/blueprint/index.js +10 -0
- package/src/blueprint/loader.js +101 -0
- package/src/blueprint/schema-kit.js +161 -0
- package/src/blueprint/schema.js +78 -0
- package/src/branding/__tests__/branding.test.js +49 -0
- package/src/branding/index.js +48 -0
- package/src/commands/__tests__/commands.test.js +83 -0
- package/src/commands/check.js +71 -0
- package/src/commands/cleanup.js +347 -0
- package/src/commands/customize.js +263 -0
- package/src/commands/doctor.js +84 -0
- package/src/commands/env.js +75 -0
- package/src/commands/finalize.js +68 -0
- package/src/commands/generate/ci-cd.js +378 -0
- package/src/commands/generate/deploy-advanced.js +253 -0
- package/src/commands/generate/deploy.js +99 -0
- package/src/commands/generate/env.template.js +221 -0
- package/src/commands/generate/index.js +7 -0
- package/src/commands/generate/module.js +836 -0
- package/src/commands/generate/page.js +1415 -0
- package/src/commands/generate/test-scaffold.js +279 -0
- package/src/commands/generate/theme.js +67 -0
- package/src/commands/generate-resource.js +133 -0
- package/src/commands/index.js +9 -0
- package/src/commands/init.js +350 -0
- package/src/commands/make/resource.js +298 -0
- package/src/commands/preset.js +57 -0
- package/src/commands/remove.js +170 -0
- package/src/commands/rename.js +54 -0
- package/src/commands/rollback.js +90 -0
- package/src/commands/wizard.js +303 -0
- package/src/core/__tests__/generator.test.js +67 -0
- package/src/core/__tests__/marker-strategy.test.js +57 -0
- package/src/core/__tests__/resource-definition.test.js +32 -0
- package/src/core/generator.js +542 -0
- package/src/core/marker-strategy.js +138 -0
- package/src/core/resource-definition.js +346 -0
- package/src/core/state-tracker.js +67 -0
- package/src/core/template-loader.js +163 -0
- package/src/engine/__tests__/engine.test.js +306 -0
- package/src/engine/index.js +21 -0
- package/src/engine/injector.js +198 -0
- package/src/engine/pipeline.js +138 -0
- package/src/engine/transaction.js +105 -0
- package/src/engine/validator.js +190 -0
- package/src/index.js +4 -0
- package/src/recipes/__tests__/recipe.test.js +128 -0
- package/src/recipes/builtin/module.json +22 -0
- package/src/recipes/builtin/page.json +21 -0
- package/src/recipes/builtin/resource.json +35 -0
- package/src/recipes/condition.js +147 -0
- package/src/recipes/index.js +11 -0
- package/src/recipes/loader.js +95 -0
- package/src/recipes/recipe.js +89 -0
- package/src/recipes/schema.js +47 -0
- package/src/schemas/__tests__/schemas.test.js +67 -0
- package/src/schemas/index.js +18 -0
- package/src/schemas/options.js +38 -0
- package/src/schemas/resource.js +112 -0
- package/src/services/__tests__/reporter.test.js +98 -0
- package/src/services/clock.js +31 -0
- package/src/services/index.js +43 -0
- package/src/services/reporter.js +136 -0
- package/src/templates/resource/api.js.ejs +39 -0
- package/src/templates/resource/components/form.jsx.ejs +81 -0
- package/src/templates/resource/components/table.jsx.ejs +68 -0
- package/src/templates/resource/controller.js.ejs +154 -0
- package/src/templates/resource/hooks.js.ejs +46 -0
- package/src/templates/resource/model.js.ejs +64 -0
- package/src/templates/resource/page-detail.jsx.ejs +55 -0
- package/src/templates/resource/page-form.jsx.ejs +30 -0
- package/src/templates/resource/page-inline.jsx.ejs +74 -0
- package/src/templates/resource/page-modal.jsx.ejs +98 -0
- package/src/templates/resource/page-page.jsx.ejs +99 -0
- package/src/templates/resource/page-sidepanel.jsx.ejs +100 -0
- package/src/templates/resource/routes.js.ejs +35 -0
- package/src/templates/resource/service.js.ejs +132 -0
- package/src/templates/resource/test.ejs +71 -0
- package/src/templates/resource/types.ts.ejs +17 -0
- package/src/templates/resource/validator.js.ejs +26 -0
- package/src/templates/snippets/lazy-import.ejs +1 -0
- package/src/templates/snippets/nav-entry.ejs +1 -0
- package/src/templates/snippets/route-entry.ejs +5 -0
- package/src/templates/snippets/route-mount.ejs +1 -0
- package/src/utils/fieldValidators.js +371 -0
- package/src/utils/logging/logger.js +47 -0
- package/src/utils/namingUtils.js +38 -0
- 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
|
+
}
|