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,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MarkerStrategy — preserves custom code during regeneration
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const MARKERS = {
|
|
6
|
+
// Matches the entire auto-generated block including header/footer.
|
|
7
|
+
// Note: the END line is `// END AUTO-GENERATED` (a plain comment) — it is NOT
|
|
8
|
+
// prefixed with the ═ rule, so the pattern must not expect ═ chars there.
|
|
9
|
+
block:
|
|
10
|
+
/(\/\/═+\s*[\r\n]+\/\/ AUTO-GENERATED[\s\S]*?\/\/ END AUTO-GENERATED\s*[\r\n]+\/\/═+)/m,
|
|
11
|
+
|
|
12
|
+
header: (resourceName, timestamp, stealth = false) => {
|
|
13
|
+
if (stealth)
|
|
14
|
+
return `//══════════════════════════════════════════════════════════════════════════════
|
|
15
|
+
// AUTO-GENERATED — DO NOT EDIT MANUALLY
|
|
16
|
+
//══════════════════════════════════════════════════════════════════════════════`;
|
|
17
|
+
|
|
18
|
+
return `//══════════════════════════════════════════════════════════════════════════════
|
|
19
|
+
// AUTO-GENERATED — DO NOT EDIT MANUALLY
|
|
20
|
+
// Resource: ${resourceName || "Unknown"}
|
|
21
|
+
// Generated at: ${timestamp || new Date().toISOString()}
|
|
22
|
+
//══════════════════════════════════════════════════════════════════════════════`;
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
footer: `//══════════════════════════════════════════════════════════════════════════════
|
|
26
|
+
// END AUTO-GENERATED
|
|
27
|
+
//══════════════════════════════════════════════════════════════════════════════`,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export class MarkerStrategy {
|
|
31
|
+
static parse(content) {
|
|
32
|
+
// Very flexible regex for parsing. The END line is a plain `// END
|
|
33
|
+
// AUTO-GENERATED` comment — not prefixed with the ═/- rule — so the middle
|
|
34
|
+
// segment must match `// END`, not `//═══ END`.
|
|
35
|
+
const flexibleBlock =
|
|
36
|
+
/(\/\/[═-]+\s*[\r\n]+\/\/ AUTO-GENERATED[\s\S]*?\/\/ END AUTO-GENERATED\s*[\r\n]+\/\/[═-]+)/m;
|
|
37
|
+
const blockMatch = content.match(flexibleBlock);
|
|
38
|
+
|
|
39
|
+
if (!blockMatch) {
|
|
40
|
+
return { hasMarkers: false, full: content };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const fullBlock = blockMatch[0];
|
|
44
|
+
const blockStart = blockMatch.index;
|
|
45
|
+
const blockEnd = blockStart + fullBlock.length;
|
|
46
|
+
|
|
47
|
+
const lines = fullBlock.split(/\r?\n/);
|
|
48
|
+
|
|
49
|
+
// Find auto content (between header and footer)
|
|
50
|
+
let headerEndIdx = -1;
|
|
51
|
+
let separatorCount = 0;
|
|
52
|
+
for (let i = 0; i < lines.length; i++) {
|
|
53
|
+
if (lines[i].includes("════════") || lines[i].includes("--------")) {
|
|
54
|
+
separatorCount++;
|
|
55
|
+
if (separatorCount === 2) {
|
|
56
|
+
headerEndIdx = i + 1;
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let footerStartIdx = -1;
|
|
63
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
64
|
+
if (lines[i].includes("END AUTO-GENERATED")) {
|
|
65
|
+
footerStartIdx = i - 1;
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let autoBlock = "";
|
|
71
|
+
if (headerEndIdx > 0 && footerStartIdx > headerEndIdx) {
|
|
72
|
+
autoBlock = lines.slice(headerEndIdx, footerStartIdx).join("\n").trim();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Look for custom zone
|
|
76
|
+
const customZoneMarker = "// ✎ CUSTOM CODE ZONE";
|
|
77
|
+
let customBlock = "";
|
|
78
|
+
const customIdx = content.indexOf(customZoneMarker);
|
|
79
|
+
if (customIdx !== -1) {
|
|
80
|
+
customBlock = content.slice(customIdx);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
hasMarkers: true,
|
|
85
|
+
prelude: content.slice(0, blockStart),
|
|
86
|
+
autoBlock,
|
|
87
|
+
customBlock,
|
|
88
|
+
epilogue: content.slice(blockEnd),
|
|
89
|
+
fullBlock,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
static compose(parsed, newAutoBlock, options = {}) {
|
|
94
|
+
if (!parsed.hasMarkers) {
|
|
95
|
+
return MarkerStrategy.wrapWithMarkers(newAutoBlock, "", options);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const timestamp = new Date().toISOString();
|
|
99
|
+
const header = MARKERS.header(
|
|
100
|
+
parsed.resourceName,
|
|
101
|
+
timestamp,
|
|
102
|
+
options.stealth,
|
|
103
|
+
);
|
|
104
|
+
const footer = MARKERS.footer;
|
|
105
|
+
|
|
106
|
+
const autoSection = `${header}\n\n${newAutoBlock.trim()}\n\n${footer}`;
|
|
107
|
+
|
|
108
|
+
// Preserve existing custom code if it exists
|
|
109
|
+
const customSection = parsed.customBlock
|
|
110
|
+
? `\n\n${parsed.customBlock}`
|
|
111
|
+
: "\n\n// ✎ CUSTOM CODE ZONE — YOUR CODE HERE\n// Add custom logic below. This section is preserved during regeneration.\n// ────────────────────────────────────────────────────────────────────────────\n";
|
|
112
|
+
|
|
113
|
+
return parsed.prelude + autoSection + customSection + parsed.epilogue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
static wrapWithMarkers(autoContent, resourceName = "", options = {}) {
|
|
117
|
+
const timestamp = new Date().toISOString();
|
|
118
|
+
const header = MARKERS.header(resourceName, timestamp, options.stealth);
|
|
119
|
+
const footer = MARKERS.footer;
|
|
120
|
+
|
|
121
|
+
const customZone =
|
|
122
|
+
"\n\n// ✎ CUSTOM CODE ZONE — YOUR CODE HERE\n// Add custom logic below. This section is preserved during regeneration.\n// ────────────────────────────────────────────────────────────────────────────\n";
|
|
123
|
+
|
|
124
|
+
return `${header}\n\n${autoContent.trim()}\n\n${footer}${customZone}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
static ensureMarkers(content, resourceName, options = {}) {
|
|
128
|
+
if (MarkerStrategy.parse(content).hasMarkers) {
|
|
129
|
+
return content;
|
|
130
|
+
}
|
|
131
|
+
return MarkerStrategy.wrapWithMarkers(content, resourceName, options);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
static extractAutoBlock(content) {
|
|
135
|
+
const parsed = MarkerStrategy.parse(content);
|
|
136
|
+
return parsed.hasMarkers ? parsed.autoBlock : content.trim();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ResourceDefinition — unified schema for all generators
|
|
3
|
+
* Central type that describes a domain resource end-to-end
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class ResourceDefinition {
|
|
7
|
+
constructor({
|
|
8
|
+
name,
|
|
9
|
+
collection,
|
|
10
|
+
fields = [],
|
|
11
|
+
relations = {},
|
|
12
|
+
ui = {},
|
|
13
|
+
features = {},
|
|
14
|
+
hooks = {},
|
|
15
|
+
permissions = {},
|
|
16
|
+
options = {},
|
|
17
|
+
}) {
|
|
18
|
+
this.name = name; // 'User' | 'Product' | 'Order'
|
|
19
|
+
this.collection = collection || this.toKebabCase(name) + "s";
|
|
20
|
+
this.fields = (fields || []).map((f) => new FieldDefinition(f));
|
|
21
|
+
this.relations = relations || { belongsTo: [], hasMany: [] };
|
|
22
|
+
this.ui = ui || {};
|
|
23
|
+
this.features = features || {};
|
|
24
|
+
this.hooks = hooks || {};
|
|
25
|
+
this.permissions = permissions || {};
|
|
26
|
+
this.options = options || {};
|
|
27
|
+
|
|
28
|
+
this._mongooseType = "";
|
|
29
|
+
this._mongooseConstraints = [];
|
|
30
|
+
this._mongooseDef = "";
|
|
31
|
+
|
|
32
|
+
this.validate();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
validate() {
|
|
36
|
+
if (!this.name) throw new Error("Resource name is required");
|
|
37
|
+
if (!/^[A-Z][a-zA-Z0-9]*$/.test(this.name)) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`Resource name "${this.name}" must be PascalCase (start uppercase, alphanumeric)`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Validate field names unique
|
|
44
|
+
const fieldNames = this.fields.map((f) => f.name);
|
|
45
|
+
const duplicates = fieldNames.filter(
|
|
46
|
+
(name, i) => fieldNames.indexOf(name) !== i,
|
|
47
|
+
);
|
|
48
|
+
if (duplicates.length)
|
|
49
|
+
throw new Error(`Duplicate fields: ${duplicates.join(", ")}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Naming helpers ────────────────────────────────────────────────────────
|
|
53
|
+
get pascalName() {
|
|
54
|
+
return this.name;
|
|
55
|
+
}
|
|
56
|
+
get camelName() {
|
|
57
|
+
return this.name[0].toLowerCase() + this.name.slice(1);
|
|
58
|
+
}
|
|
59
|
+
get snakeName() {
|
|
60
|
+
return this.camelName.replace(/[A-Z]/g, (m) => "_" + m.toLowerCase());
|
|
61
|
+
}
|
|
62
|
+
get kebabName() {
|
|
63
|
+
return this.camelName.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
|
|
64
|
+
}
|
|
65
|
+
get pluralKebab() {
|
|
66
|
+
return this.kebabName + "s";
|
|
67
|
+
}
|
|
68
|
+
get pluralPascal() {
|
|
69
|
+
return this.pascalName + "s";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Computed flags ────────────────────────────────────────────────────────
|
|
73
|
+
get hasTimestamps() {
|
|
74
|
+
return true;
|
|
75
|
+
} // always include createdAt, updatedAt
|
|
76
|
+
get hasSoftDelete() {
|
|
77
|
+
return this.features && this.features.softDelete === true;
|
|
78
|
+
}
|
|
79
|
+
get hasAuditLog() {
|
|
80
|
+
return this.features && this.features.auditLog === true;
|
|
81
|
+
}
|
|
82
|
+
get hasAuth() {
|
|
83
|
+
return this.features && this.features.auth !== false;
|
|
84
|
+
} // default true
|
|
85
|
+
|
|
86
|
+
// ── Indexes ───────────────────────────────────────────────────────────────
|
|
87
|
+
get indexes() {
|
|
88
|
+
const idxs = [];
|
|
89
|
+
this.fields.forEach((f) => {
|
|
90
|
+
if (f.validation && f.validation.unique)
|
|
91
|
+
idxs.push({ [f.name]: 1 }, { unique: true });
|
|
92
|
+
if (f.type === "text" || f.type === "richtext")
|
|
93
|
+
idxs.push({ [f.name]: "text" });
|
|
94
|
+
});
|
|
95
|
+
return idxs;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
toKebabCase(str) {
|
|
99
|
+
if (!str) return "";
|
|
100
|
+
return str
|
|
101
|
+
.replace(/([a-z])([A-Z])/g, "$1-$2")
|
|
102
|
+
.replace(/[\s_]+/g, "-")
|
|
103
|
+
.toLowerCase();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export class FieldDefinition {
|
|
108
|
+
constructor(data) {
|
|
109
|
+
this.name = data.name;
|
|
110
|
+
this.type = data.type || "string";
|
|
111
|
+
this.validation = data.validation || {};
|
|
112
|
+
this.special = data.special || {};
|
|
113
|
+
this.ui = data.ui || {};
|
|
114
|
+
|
|
115
|
+
// Derived properties
|
|
116
|
+
this.formInputType = fieldTypeToFormInput(this.type);
|
|
117
|
+
this.mongooseType = fieldTypeToMongoose(this.type);
|
|
118
|
+
this._mongooseConstraints = this.buildMongooseConstraints();
|
|
119
|
+
this.joiRule = this.buildJoiRule();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
get mongooseDef() {
|
|
123
|
+
return this._mongooseConstraints.join(', ');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
buildJoiRule() {
|
|
127
|
+
let rule = "Joi.";
|
|
128
|
+
const v = this.validation;
|
|
129
|
+
|
|
130
|
+
switch (this.type) {
|
|
131
|
+
case "number":
|
|
132
|
+
case "range":
|
|
133
|
+
rule += "number()";
|
|
134
|
+
if (v.min !== undefined) rule += `.min(${v.min})`;
|
|
135
|
+
if (v.max !== undefined) rule += `.max(${v.max})`;
|
|
136
|
+
break;
|
|
137
|
+
case "boolean":
|
|
138
|
+
rule += "boolean()";
|
|
139
|
+
break;
|
|
140
|
+
case "date":
|
|
141
|
+
case "datetime":
|
|
142
|
+
rule += "date()";
|
|
143
|
+
break;
|
|
144
|
+
case "email":
|
|
145
|
+
rule += "string().email()";
|
|
146
|
+
break;
|
|
147
|
+
case "password":
|
|
148
|
+
rule += "string().min(8)";
|
|
149
|
+
break;
|
|
150
|
+
default:
|
|
151
|
+
rule += "string()";
|
|
152
|
+
if (v.minLength !== undefined) rule += `.min(${v.minLength})`;
|
|
153
|
+
if (v.maxLength !== undefined) rule += `.max(${v.maxLength})`;
|
|
154
|
+
if (v.pattern) rule += `.pattern(${v.pattern})`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (v.required) rule += ".required()";
|
|
158
|
+
else rule += ".optional()";
|
|
159
|
+
|
|
160
|
+
if (v.default !== undefined) {
|
|
161
|
+
const val = typeof v.default === "string" ? `'${v.default}'` : v.default;
|
|
162
|
+
rule += `.default(${val})`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return rule;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
buildMongooseConstraints() {
|
|
169
|
+
const c = [];
|
|
170
|
+
const v = this.validation;
|
|
171
|
+
|
|
172
|
+
// Always include type
|
|
173
|
+
c.push(`type: ${this.mongooseType}`);
|
|
174
|
+
|
|
175
|
+
if (v.required) c.push("required: true");
|
|
176
|
+
if (v.unique) c.push("unique: true");
|
|
177
|
+
if (v.default !== undefined) {
|
|
178
|
+
const val = typeof v.default === "string" ? `'${v.default}'` : v.default;
|
|
179
|
+
c.push(`default: ${val}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (this.type === "number" || this.type === "range") {
|
|
183
|
+
if (v.min !== undefined) c.push(`min: ${v.min}`);
|
|
184
|
+
if (v.max !== undefined) c.push(`max: ${v.max}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (this.type === "string" || this.type === "text") {
|
|
188
|
+
if (v.minLength !== undefined) c.push(`minLength: ${v.minLength}`);
|
|
189
|
+
if (v.maxLength !== undefined) c.push(`maxLength: ${v.maxLength}`);
|
|
190
|
+
if (v.pattern) c.push(`match: ${v.pattern}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (this.type === "ref" || this.type === "reference") {
|
|
194
|
+
if (this.special && this.special.model)
|
|
195
|
+
c.push(`ref: '${this.special.model}'`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return c;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Map our unified types to Mongoose types
|
|
204
|
+
*/
|
|
205
|
+
export function fieldTypeToMongoose(type) {
|
|
206
|
+
const map = {
|
|
207
|
+
string: "String",
|
|
208
|
+
text: "String",
|
|
209
|
+
number: "Number",
|
|
210
|
+
boolean: "Boolean",
|
|
211
|
+
date: "Date",
|
|
212
|
+
datetime: "Date",
|
|
213
|
+
email: "String",
|
|
214
|
+
password: "String",
|
|
215
|
+
ref: "mongoose.Schema.Types.ObjectId",
|
|
216
|
+
reference: "mongoose.Schema.Types.ObjectId",
|
|
217
|
+
array: "Array",
|
|
218
|
+
object: "Object",
|
|
219
|
+
image: "String",
|
|
220
|
+
file: "String",
|
|
221
|
+
};
|
|
222
|
+
return map[type] || "String";
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* parseFieldSpec — parses a compact string representation of a field
|
|
227
|
+
*/
|
|
228
|
+
export function parseFieldSpec(spec) {
|
|
229
|
+
if (!spec || typeof spec !== "string") return null;
|
|
230
|
+
|
|
231
|
+
const match = spec.match(
|
|
232
|
+
/^([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:\s*([a-zA-Z]+)(?:\[(.*?)\])?(?:[:|](.*))?$/,
|
|
233
|
+
);
|
|
234
|
+
if (!match) {
|
|
235
|
+
console.warn(
|
|
236
|
+
`[WARN] Could not parse field spec: "${spec}". Expected format: name:type[options]`,
|
|
237
|
+
);
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const [, name, typeRaw, optionsRaw, rulesRaw] = match;
|
|
242
|
+
|
|
243
|
+
const field = {
|
|
244
|
+
name,
|
|
245
|
+
type: typeRaw.toLowerCase(),
|
|
246
|
+
validation: {},
|
|
247
|
+
special: {},
|
|
248
|
+
ui: {},
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// Aliases
|
|
252
|
+
if (field.type === "str") field.type = "string";
|
|
253
|
+
if (field.type === "num") field.type = "number";
|
|
254
|
+
if (field.type === "bool") field.type = "boolean";
|
|
255
|
+
|
|
256
|
+
// Parse options in brackets [...]
|
|
257
|
+
if (optionsRaw) {
|
|
258
|
+
if (field.type === "ref" || field.type === "reference") {
|
|
259
|
+
field.special.model = optionsRaw.trim();
|
|
260
|
+
} else if (field.type === "select" || field.type === "multiselect") {
|
|
261
|
+
field.special.options = optionsRaw.split(",").map((o) => o.trim());
|
|
262
|
+
} else if (field.type === "image" || field.type === "file") {
|
|
263
|
+
const opts = optionsRaw.split(";");
|
|
264
|
+
field.special.upload = opts[0]?.trim() || field.type + "s";
|
|
265
|
+
if (opts[1]) {
|
|
266
|
+
const [k, v] = opts[1].split("=");
|
|
267
|
+
if (k === "max") field.special.maxSize = v;
|
|
268
|
+
}
|
|
269
|
+
} else {
|
|
270
|
+
const parts = optionsRaw.split(",").map((p) => p.trim());
|
|
271
|
+
parts.forEach((part) => {
|
|
272
|
+
if (part.includes("=")) {
|
|
273
|
+
const [k, v] = part.split("=");
|
|
274
|
+
if (k === "min") field.validation.min = parseFloat(v);
|
|
275
|
+
else if (k === "max") field.validation.max = parseFloat(v);
|
|
276
|
+
else if (k === "minLength") field.validation.minLength = parseInt(v);
|
|
277
|
+
else if (k === "maxLength") field.validation.maxLength = parseInt(v);
|
|
278
|
+
else if (k === "pattern") field.validation.pattern = v;
|
|
279
|
+
else if (k === "default") field.validation.default = v;
|
|
280
|
+
} else {
|
|
281
|
+
if (part === "required") field.validation.required = true;
|
|
282
|
+
else if (part === "unique") field.validation.unique = true;
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (rulesRaw) {
|
|
289
|
+
const rules = rulesRaw.split("|").map((r) => r.trim());
|
|
290
|
+
rules.forEach((rule) => {
|
|
291
|
+
if (rule === "required") field.validation.required = true;
|
|
292
|
+
else if (rule === "unique") field.validation.unique = true;
|
|
293
|
+
else if (rule.startsWith("min="))
|
|
294
|
+
field.validation.min = parseFloat(rule.split("=")[1]);
|
|
295
|
+
else if (rule.startsWith("max="))
|
|
296
|
+
field.validation.max = parseFloat(rule.split("=")[1]);
|
|
297
|
+
else if (rule.startsWith("minLength="))
|
|
298
|
+
field.validation.minLength = parseInt(rule.split("=")[1]);
|
|
299
|
+
else if (rule.startsWith("maxLength="))
|
|
300
|
+
field.validation.maxLength = parseInt(rule.split("=")[1]);
|
|
301
|
+
else if (rule.startsWith("default="))
|
|
302
|
+
field.validation.default = rule.split("=").slice(1).join("=");
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return field;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function parseKeyValue(str) {
|
|
310
|
+
const result = {};
|
|
311
|
+
if (!str) return result;
|
|
312
|
+
|
|
313
|
+
const pairs = str.split(",");
|
|
314
|
+
pairs.forEach((pair) => {
|
|
315
|
+
const [key, ...valParts] = pair.split("=");
|
|
316
|
+
if (key && valParts.length > 0) {
|
|
317
|
+
result[key.trim()] = valParts.join("=").trim();
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
return result;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function fieldTypeToFormInput(type) {
|
|
324
|
+
const map = {
|
|
325
|
+
string: "text",
|
|
326
|
+
text: "textarea",
|
|
327
|
+
number: "number",
|
|
328
|
+
boolean: "checkbox",
|
|
329
|
+
date: "date",
|
|
330
|
+
email: "email",
|
|
331
|
+
phone: "tel",
|
|
332
|
+
url: "url",
|
|
333
|
+
datetime: "datetime-local",
|
|
334
|
+
time: "time",
|
|
335
|
+
color: "color",
|
|
336
|
+
file: "file",
|
|
337
|
+
password: "password",
|
|
338
|
+
range: "range",
|
|
339
|
+
select: "select",
|
|
340
|
+
multiselect: "multiselect",
|
|
341
|
+
reference: "select",
|
|
342
|
+
ref: "select",
|
|
343
|
+
image: "image-upload",
|
|
344
|
+
};
|
|
345
|
+
return map[type] || "text";
|
|
346
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* StateTracker — tracks generated files for rollbacks
|
|
6
|
+
*/
|
|
7
|
+
export class StateTracker {
|
|
8
|
+
constructor(projectRoot) {
|
|
9
|
+
this.projectRoot = projectRoot;
|
|
10
|
+
this.stateDir = path.join(projectRoot, '.loom');
|
|
11
|
+
this.stateFile = path.join(this.stateDir, 'state.json');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async ensureStateDir() {
|
|
15
|
+
await fs.ensureDir(this.stateDir);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async loadState() {
|
|
19
|
+
if (!(await fs.pathExists(this.stateFile))) {
|
|
20
|
+
return { history: [] };
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
return await fs.readJSON(this.stateFile);
|
|
24
|
+
} catch {
|
|
25
|
+
return { history: [] };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async saveState(state) {
|
|
30
|
+
await this.ensureStateDir();
|
|
31
|
+
await fs.writeJSON(this.stateFile, state, { spaces: 2 });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Record a new generation event
|
|
36
|
+
*/
|
|
37
|
+
async recordEvent(event) {
|
|
38
|
+
const state = await this.loadState();
|
|
39
|
+
const newEvent = {
|
|
40
|
+
id: Date.now().toString(),
|
|
41
|
+
timestamp: new Date().toISOString(),
|
|
42
|
+
...event, // { action: 'generate', resource: 'User', files: [...] }
|
|
43
|
+
};
|
|
44
|
+
state.history.unshift(newEvent);
|
|
45
|
+
// Keep only last 10 events
|
|
46
|
+
if (state.history.length > 10) state.history.pop();
|
|
47
|
+
await this.saveState(state);
|
|
48
|
+
return newEvent.id;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Get last event
|
|
53
|
+
*/
|
|
54
|
+
async getLastEvent() {
|
|
55
|
+
const state = await this.loadState();
|
|
56
|
+
return state.history[0] || null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Remove event by ID
|
|
61
|
+
*/
|
|
62
|
+
async removeEvent(id) {
|
|
63
|
+
const state = await this.loadState();
|
|
64
|
+
state.history = state.history.filter(e => e.id !== id);
|
|
65
|
+
await this.saveState(state);
|
|
66
|
+
}
|
|
67
|
+
}
|