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,132 @@
|
|
|
1
|
+
const <%= resource.pascalName %>Model = require('../models/<%= resource.name %>');
|
|
2
|
+
const ApiError = require('../../../utils/ApiError');
|
|
3
|
+
<% if (options.architecture === 'advanced') { -%>
|
|
4
|
+
const mongoose = require('mongoose');
|
|
5
|
+
<% } -%>
|
|
6
|
+
|
|
7
|
+
// ── Create ───────────────────────────────────────────────────────────────────
|
|
8
|
+
const create<%= resource.pascalName %> = async (payload) => {
|
|
9
|
+
<% resource.fields.forEach(function(f) { -%>
|
|
10
|
+
<% if (['email', 'phone', 'url', 'string', 'text'].includes(f.type)) { -%>
|
|
11
|
+
if (payload.<%= f.name %>) {
|
|
12
|
+
<% if (f.type === 'email') { -%>
|
|
13
|
+
payload.<%= f.name %> = payload.<%= f.name %>.toLowerCase().trim();
|
|
14
|
+
<% } else if (f.type === 'phone') { -%>
|
|
15
|
+
payload.<%= f.name %> = payload.<%= f.name %>.replace(/[^+0-9]/g, '');
|
|
16
|
+
<% } else { -%>
|
|
17
|
+
payload.<%= f.name %> = payload.<%= f.name %>.trim();
|
|
18
|
+
<% } -%>
|
|
19
|
+
}
|
|
20
|
+
<% } -%>
|
|
21
|
+
<% }); -%>
|
|
22
|
+
<% var slugField = resource.fields.find(function(f) { return ['slug', 'code', 'sku'].includes(f.name); }); -%>
|
|
23
|
+
<% if (slugField) { -%>
|
|
24
|
+
if (!payload.<%= slugField.name %> && payload.name) {
|
|
25
|
+
const slugify = require('slugify');
|
|
26
|
+
payload.<%= slugField.name %> = slugify(payload.name, { lower: true, strict: true });
|
|
27
|
+
}
|
|
28
|
+
<% } -%>
|
|
29
|
+
return <%= resource.pascalName %>Model.create(payload);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// ── List (filters, pagination, sorting) ──────────────────────────────────────
|
|
33
|
+
const getAll<%= resource.pascalName %>s = async (filters = {}) => {
|
|
34
|
+
let query = <%= resource.pascalName %>Model.find({});
|
|
35
|
+
<% resource.fields.forEach(function(f) { -%>
|
|
36
|
+
<% if (['number', 'date', 'datetime', 'range'].includes(f.type)) { -%>
|
|
37
|
+
if (filters.<%= f.name %>Min) query = query.where('<%= f.name %>').gte(filters.<%= f.name %>Min);
|
|
38
|
+
if (filters.<%= f.name %>Max) query = query.where('<%= f.name %>').lte(filters.<%= f.name %>Max);
|
|
39
|
+
<% } else { -%>
|
|
40
|
+
if (filters.<%= f.name %> !== undefined) query = query.where('<%= f.name %>').equals(filters.<%= f.name %>);
|
|
41
|
+
<% } -%>
|
|
42
|
+
<% }); -%>
|
|
43
|
+
const page = parseInt(filters.page, 10) || 1;
|
|
44
|
+
const limit = parseInt(filters.limit, 10) || 20;
|
|
45
|
+
if (filters.sort) {
|
|
46
|
+
query = query.sort({ [filters.sort]: filters.order === 'asc' ? 1 : -1 });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Count before pagination so callers can build pagination UI.
|
|
50
|
+
const total = await <%= resource.pascalName %>Model.countDocuments(query.getFilter());
|
|
51
|
+
// .lean() — plain objects, not Mongoose documents, for fast list reads.
|
|
52
|
+
const data = await query.skip((page - 1) * limit).limit(limit).lean().exec();
|
|
53
|
+
return { data, total, page, limit, pages: Math.ceil(total / limit) };
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// ── Get One ──────────────────────────────────────────────────────────────────
|
|
57
|
+
const get<%= resource.pascalName %>ById = async (id) => {
|
|
58
|
+
const doc = await <%= resource.pascalName %>Model.findById(id);
|
|
59
|
+
if (!doc) throw new ApiError(404, '<%= resource.name %> not found');
|
|
60
|
+
return doc;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// ── Update ───────────────────────────────────────────────────────────────────
|
|
64
|
+
const update<%= resource.pascalName %> = async (id, updates) => {
|
|
65
|
+
<% resource.fields.forEach(function(f) { -%>
|
|
66
|
+
<% if (['email', 'phone', 'url', 'string', 'text'].includes(f.type)) { -%>
|
|
67
|
+
if (updates.<%= f.name %>) {
|
|
68
|
+
<% if (f.type === 'email') { -%>
|
|
69
|
+
updates.<%= f.name %> = updates.<%= f.name %>.toLowerCase().trim();
|
|
70
|
+
<% } else if (f.type === 'phone') { -%>
|
|
71
|
+
updates.<%= f.name %> = updates.<%= f.name %>.replace(/[^+0-9]/g, '');
|
|
72
|
+
<% } else { -%>
|
|
73
|
+
updates.<%= f.name %> = updates.<%= f.name %>.trim();
|
|
74
|
+
<% } -%>
|
|
75
|
+
}
|
|
76
|
+
<% } -%>
|
|
77
|
+
<% }); -%>
|
|
78
|
+
const doc = await <%= resource.pascalName %>Model.findByIdAndUpdate(id, updates, {
|
|
79
|
+
new: true,
|
|
80
|
+
runValidators: true,
|
|
81
|
+
});
|
|
82
|
+
if (!doc) throw new ApiError(404, '<%= resource.name %> not found');
|
|
83
|
+
return doc;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// ── Delete ───────────────────────────────────────────────────────────────────
|
|
87
|
+
const delete<%= resource.pascalName %> = async (id) => {
|
|
88
|
+
<% if (resource.features && resource.features.softDelete) { -%>
|
|
89
|
+
const doc = await <%= resource.pascalName %>Model.findByIdAndUpdate(id, { deletedAt: new Date() }, { new: true });
|
|
90
|
+
<% } else { -%>
|
|
91
|
+
const doc = await <%= resource.pascalName %>Model.findByIdAndDelete(id);
|
|
92
|
+
<% } -%>
|
|
93
|
+
if (!doc) throw new ApiError(404, '<%= resource.name %> not found');
|
|
94
|
+
return doc;
|
|
95
|
+
};
|
|
96
|
+
<% if (options.architecture === 'advanced') { -%>
|
|
97
|
+
|
|
98
|
+
// ── Advanced: batch operations ───────────────────────────────────────────────
|
|
99
|
+
const bulkCreate<%= resource.pascalName %>s = async (items) => {
|
|
100
|
+
if (!Array.isArray(items)) throw new ApiError(400, 'Items must be an array');
|
|
101
|
+
return <%= resource.pascalName %>Model.insertMany(items);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const bulkUpdate<%= resource.pascalName %>s = async (updates) => {
|
|
105
|
+
const session = await mongoose.startSession();
|
|
106
|
+
session.startTransaction();
|
|
107
|
+
try {
|
|
108
|
+
for (const update of updates) {
|
|
109
|
+
await <%= resource.pascalName %>Model.findByIdAndUpdate(update.id, update.changes, { session });
|
|
110
|
+
}
|
|
111
|
+
await session.commitTransaction();
|
|
112
|
+
return updates.length;
|
|
113
|
+
} catch (err) {
|
|
114
|
+
await session.abortTransaction();
|
|
115
|
+
throw err;
|
|
116
|
+
} finally {
|
|
117
|
+
session.endSession();
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
<% } -%>
|
|
121
|
+
|
|
122
|
+
module.exports = {
|
|
123
|
+
create<%= resource.pascalName %>,
|
|
124
|
+
getAll<%= resource.pascalName %>s,
|
|
125
|
+
get<%= resource.pascalName %>ById,
|
|
126
|
+
update<%= resource.pascalName %>,
|
|
127
|
+
delete<%= resource.pascalName %>,
|
|
128
|
+
<% if (options.architecture === 'advanced') { -%>
|
|
129
|
+
bulkCreate<%= resource.pascalName %>s,
|
|
130
|
+
bulkUpdate<%= resource.pascalName %>s,
|
|
131
|
+
<% } -%>
|
|
132
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const request = require('supertest');
|
|
2
|
+
const app = require('../../../app'); // Correct path: up to tests, kebabDir, modules, src/app
|
|
3
|
+
const mongoose = require('mongoose');
|
|
4
|
+
const <%= resource.pascalName %>Model = require('../models/<%= resource.name %>');
|
|
5
|
+
|
|
6
|
+
describe('<%= resource.pascalName %> API', () => {
|
|
7
|
+
let testId;
|
|
8
|
+
|
|
9
|
+
beforeAll(async () => {
|
|
10
|
+
// Ensure DB connection if needed
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterAll(async () => {
|
|
14
|
+
await <%= resource.pascalName %>Model.deleteMany({});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('POST /api/<%= resource.pluralKebab %> - should create <%= resource.name.toLowerCase() %>', async () => {
|
|
18
|
+
const payload = {
|
|
19
|
+
<% resource.fields.filter(f => !['createdAt', 'updatedAt', '_id'].includes(f.name)).forEach(f => { %>
|
|
20
|
+
<%= f.name %>: <% if (f.type === 'number') { %>100<% } else if (f.type === 'boolean') { %>true<% } else if (f.type === 'date') { %>new Date()<% } else { %>'test-<%= f.name %>'<% } %>,
|
|
21
|
+
<% }); %>
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const res = await request(app)
|
|
25
|
+
.post('/api/<%= resource.pluralKebab %>')
|
|
26
|
+
.send(payload)
|
|
27
|
+
.expect(201);
|
|
28
|
+
|
|
29
|
+
expect(res.body.data).toHaveProperty('_id');
|
|
30
|
+
testId = res.body.data._id;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('GET /api/<%= resource.pluralKebab %> - should list <%= resource.pluralKebab %>', async () => {
|
|
34
|
+
const res = await request(app)
|
|
35
|
+
.get('/api/<%= resource.pluralKebab %>')
|
|
36
|
+
.expect(200);
|
|
37
|
+
|
|
38
|
+
expect(Array.isArray(res.body.data)).toBe(true);
|
|
39
|
+
expect(res.body.data.length).toBeGreaterThan(0);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('GET /api/<%= resource.pluralKebab %>/:id - should get one <%= resource.name.toLowerCase() %>', async () => {
|
|
43
|
+
const res = await request(app)
|
|
44
|
+
.get(`/api/<%= resource.pluralKebab %>/${testId}`)
|
|
45
|
+
.expect(200);
|
|
46
|
+
|
|
47
|
+
expect(res.body.data).toHaveProperty('_id', testId);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('PUT /api/<%= resource.pluralKebab %>/:id - should update <%= resource.name.toLowerCase() %>', async () => {
|
|
51
|
+
const res = await request(app)
|
|
52
|
+
.put(`/api/<%= resource.pluralKebab %>/${testId}`)
|
|
53
|
+
.send({
|
|
54
|
+
<% if (resource.fields[0]) { %>
|
|
55
|
+
<%= resource.fields[0].name %>: 'updated-value'
|
|
56
|
+
<% } %>
|
|
57
|
+
})
|
|
58
|
+
.expect(200);
|
|
59
|
+
|
|
60
|
+
expect(res.body.status).toBe('success');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('DELETE /api/<%= resource.pluralKebab %>/:id - should delete <%= resource.name.toLowerCase() %>', async () => {
|
|
64
|
+
await request(app)
|
|
65
|
+
.delete(`/api/<%= resource.pluralKebab %>/${testId}`)
|
|
66
|
+
.expect(200);
|
|
67
|
+
|
|
68
|
+
const check = await <%= resource.pascalName %>Model.findById(testId);
|
|
69
|
+
expect(check).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <%= resource.pascalName %> Types
|
|
3
|
+
* Auto-generated by Stackloom CLI
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface <%= resource.pascalName %> {
|
|
7
|
+
_id?: string;
|
|
8
|
+
<% resource.fields.forEach(function(field) { %>
|
|
9
|
+
<%- field.name %>: <%- field.type === 'number' ? 'number' : field.type === 'boolean' ? 'boolean' : 'string' %>;
|
|
10
|
+
<% }); %>
|
|
11
|
+
createdAt?: string;
|
|
12
|
+
updatedAt?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface Create<%= resource.pascalName %>Input extends Omit<<%= resource.pascalName %>, '_id' | 'createdAt' | 'updatedAt'> {}
|
|
16
|
+
|
|
17
|
+
export interface Update<%= resource.pascalName %>Input extends Partial<Create<%= resource.pascalName %>Input> {}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const Joi = require('joi');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* <%= resource.pascalName %> Validation Schemas
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// ── Create Schema ────────────────────────────────────────────────────────────
|
|
8
|
+
const create<%= resource.pascalName %>Schema = Joi.object({
|
|
9
|
+
<% resource.fields.forEach(function(field) { %>
|
|
10
|
+
<%- field.name %>: <%- field.joiRule %>,
|
|
11
|
+
<% }); %>
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
// ── Update Schema ────────────────────────────────────────────────────────────
|
|
15
|
+
// Update = Create schema, but fields that aren't required become optional>
|
|
16
|
+
<% const optionalFields = resource.fields.filter(f => !(f.validation && f.validation.required)); %>
|
|
17
|
+
const update<%= resource.pascalName %>Schema = create<%= resource.pascalName %>Schema<% if (optionalFields.length > 0) { %>.fork(
|
|
18
|
+
[<%- optionalFields.map(f => `'${f.name}'`).join(', ') %>],
|
|
19
|
+
(schema) => schema.optional()
|
|
20
|
+
)<% } else { %>; // no optional fields: update schema is same as create<% } %>
|
|
21
|
+
|
|
22
|
+
module.exports = {
|
|
23
|
+
create<%= resource.pascalName %>Schema,
|
|
24
|
+
update<%= resource.pascalName %>Schema,
|
|
25
|
+
};
|
|
26
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const <%= resource.pascalName %>List = lazy(() => import("@/pages/admin/<%= resource.kebabName %>/ListPage"));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ label: "<%= resource.name.replace(/([A-Z])/g, ' $1').trim() %>", href: "/admin/<%= resource.kebabName %>", icon: "layout" },
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
router.use("/<%= resource.kebabName %>", require("../modules/<%= resource.kebabName %>/routes/<%= resource.name %>.routes"));
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Input Validation Utility for Form/Module Field Definitions
|
|
4
|
+
* Provides comprehensive validation and sanitization helpers for CLI field specs
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const AVAILABLE_TYPES = [
|
|
8
|
+
// Frontend form input types
|
|
9
|
+
"text", "textarea", "email", "password", "number", "tel", "url",
|
|
10
|
+
"date", "datetime-local", "time", "color", "file", "range", "select", "hidden", "boolean",
|
|
11
|
+
// Backend schema types (alias/extra)
|
|
12
|
+
"string", "datetime"
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
export const VALIDATION_RULES = {
|
|
16
|
+
required: { type: "flag", description: "Field must be filled" },
|
|
17
|
+
unique: { type: "flag", description: "Value must be unique in database" },
|
|
18
|
+
min: { type: "number", description: "Minimum numeric/date value" },
|
|
19
|
+
max: { type: "number", description: "Maximum numeric/date value" },
|
|
20
|
+
minLength: { type: "integer", description: "Minimum string length" },
|
|
21
|
+
maxLength: { type: "integer", description: "Maximum string length" },
|
|
22
|
+
step: { type: "number", description: "Step size for number/range inputs" },
|
|
23
|
+
pattern: { type: "regex", description: "Regex pattern (format: /^[A-Z]+$/)" },
|
|
24
|
+
default: { type: "any", description: "Default value" },
|
|
25
|
+
accept: { type: "string", description: "File accept types (comma-separated MIME types)" },
|
|
26
|
+
multiple: { type: "flag", description: "Allow multiple file selection" },
|
|
27
|
+
options: { type: "array", description: "Dropdown options for select fields" },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const TYPE_VALIDATORS = {
|
|
31
|
+
text: (value) => typeof value === "string",
|
|
32
|
+
textarea: (value) => typeof value === "string",
|
|
33
|
+
email: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
|
|
34
|
+
password: (value) => typeof value === "string" && value.length >= 8,
|
|
35
|
+
number: (value) => !isNaN(parseFloat(value)),
|
|
36
|
+
tel: (value) => /^[+]?[1-9]\d{1,14}$/.test(value.replace(/[^+0-9]/g, "")),
|
|
37
|
+
url: (value) => /^https?:\/\/.+\..+/.test(value),
|
|
38
|
+
date: (value) => !isNaN(Date.parse(value)),
|
|
39
|
+
"datetime-local": (value) => !isNaN(Date.parse(value)),
|
|
40
|
+
time: (value) => /^([01]\d|2[0-3]):[0-5]\d$/.test(value),
|
|
41
|
+
color: (value) => /^#([0-9A-F]{3}){1,2}$/i.test(value),
|
|
42
|
+
range: (value) => !isNaN(parseFloat(value)),
|
|
43
|
+
boolean: (value) => typeof value === "boolean" || value === "true" || value === "false",
|
|
44
|
+
file: (value) => value instanceof File || typeof value === "string",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const TYPE_SANITIZERS = {
|
|
48
|
+
text: (value) => String(value).trim(),
|
|
49
|
+
textarea: (value) => String(value).trim(),
|
|
50
|
+
email: (value) => String(value).toLowerCase().trim(),
|
|
51
|
+
password: (value) => String(value),
|
|
52
|
+
number: (value) => parseFloat(value) || 0,
|
|
53
|
+
tel: (value) => String(value).replace(/[^+0-9]/g, ""),
|
|
54
|
+
url: (value) => String(value).trim(),
|
|
55
|
+
date: (value) => String(value).split("T")[0],
|
|
56
|
+
"datetime-local": (value) => String(value),
|
|
57
|
+
time: (value) => String(value),
|
|
58
|
+
color: (value) => String(value).toUpperCase(),
|
|
59
|
+
range: (value) => parseFloat(value) || 0,
|
|
60
|
+
boolean: (value) => value === true || value === "true",
|
|
61
|
+
file: (value) => value,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Parse field specification string into structured object
|
|
66
|
+
* Format: "name:type:rule1,rule2,rule3" or object input
|
|
67
|
+
*
|
|
68
|
+
* @param {string|object} input - Field specification
|
|
69
|
+
* @returns {object|null} Parsed field object or null if invalid
|
|
70
|
+
*/
|
|
71
|
+
export function parseFieldSpec(input) {
|
|
72
|
+
if (typeof input === "object" && input.name && input.type) {
|
|
73
|
+
return validateFieldObject(input);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (typeof input !== "string") return null;
|
|
77
|
+
|
|
78
|
+
const parts = input.split(":");
|
|
79
|
+
if (parts.length < 2) return null;
|
|
80
|
+
|
|
81
|
+
const name = parts[0].trim();
|
|
82
|
+
const type = parts[1].trim();
|
|
83
|
+
|
|
84
|
+
if (!/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name)) {
|
|
85
|
+
console.error(`✖ Invalid field name "${name}". Must be valid JS identifier.`);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!AVAILABLE_TYPES.includes(type)) {
|
|
90
|
+
console.error(`✖ Invalid type "${type}". Available: ${AVAILABLE_TYPES.join(", ")}`);
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const field = { name, type, validation: { required: false } };
|
|
95
|
+
|
|
96
|
+
// Parse rules (pipe-separated: rule1|rule2|key=value)
|
|
97
|
+
if (parts[2]) {
|
|
98
|
+
const rules = parts[2].split("|");
|
|
99
|
+
for (const rule of rules) {
|
|
100
|
+
const trimmed = rule.trim();
|
|
101
|
+
if (trimmed === "required") {
|
|
102
|
+
field.validation.required = true;
|
|
103
|
+
} else if (trimmed === "unique") {
|
|
104
|
+
field.validation.unique = true;
|
|
105
|
+
} else if (trimmed.startsWith("min=")) {
|
|
106
|
+
const val = parseFloat(trimmed.split("=")[1]);
|
|
107
|
+
if (!isNaN(val)) field.validation.min = val;
|
|
108
|
+
} else if (trimmed.startsWith("max=")) {
|
|
109
|
+
const val = parseFloat(trimmed.split("=")[1]);
|
|
110
|
+
if (!isNaN(val)) field.validation.max = val;
|
|
111
|
+
} else if (trimmed.startsWith("minLength=")) {
|
|
112
|
+
const val = parseInt(trimmed.split("=")[1], 10);
|
|
113
|
+
if (!isNaN(val)) field.validation.minLength = val;
|
|
114
|
+
} else if (trimmed.startsWith("maxLength=")) {
|
|
115
|
+
const val = parseInt(trimmed.split("=")[1], 10);
|
|
116
|
+
if (!isNaN(val)) field.validation.maxLength = val;
|
|
117
|
+
} else if (trimmed.startsWith("step=")) {
|
|
118
|
+
const val = parseFloat(trimmed.split("=")[1]);
|
|
119
|
+
if (!isNaN(val)) field.validation.step = val;
|
|
120
|
+
} else if (trimmed.startsWith("pattern=")) {
|
|
121
|
+
const pattern = trimmed.split("=")[1];
|
|
122
|
+
try {
|
|
123
|
+
new RegExp(pattern);
|
|
124
|
+
field.validation.pattern = pattern;
|
|
125
|
+
} catch {
|
|
126
|
+
console.warn(`⚠ Invalid regex pattern for ${name}: ${pattern}`);
|
|
127
|
+
}
|
|
128
|
+
} else if (trimmed.startsWith("default=")) {
|
|
129
|
+
field.validation.default = trimmed.split("=")[1];
|
|
130
|
+
} else if (trimmed.startsWith("accept=")) {
|
|
131
|
+
field.validation.accept = trimmed.split("=")[1].split(",");
|
|
132
|
+
} else if (trimmed === "multiple") {
|
|
133
|
+
field.validation.multiple = true;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Cross-validate constraints
|
|
139
|
+
if (field.validation.min !== undefined && field.validation.max !== undefined) {
|
|
140
|
+
if (field.validation.min > field.validation.max) {
|
|
141
|
+
console.error(`✖ min (${field.validation.min}) > max (${field.validation.max}) for field "${name}"`);
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (field.validation.minLength !== undefined && field.validation.maxLength !== undefined) {
|
|
146
|
+
if (field.validation.minLength > field.validation.maxLength) {
|
|
147
|
+
console.error(`✖ minLength > maxLength for field "${name}"`);
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return validateFieldObject(field);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Validate field object structure and constraints
|
|
157
|
+
*/
|
|
158
|
+
function validateFieldObject(field) {
|
|
159
|
+
if (!field.name || !field.type) return null;
|
|
160
|
+
|
|
161
|
+
if (!AVAILABLE_TYPES.includes(field.type)) {
|
|
162
|
+
console.error(`✖ Type "${field.type}" not supported`);
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Type-specific validation checks
|
|
167
|
+
const validator = TYPE_VALIDATORS[field.type];
|
|
168
|
+
if (field.validation.default !== undefined && !validator(field.validation.default)) {
|
|
169
|
+
console.warn(`⚠ Default value for ${field.name} may not match type ${field.type}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (field.validation.min !== undefined && !validator(field.validation.min)) {
|
|
173
|
+
console.warn(`⚠ min value for ${field.name} is invalid`);
|
|
174
|
+
delete field.validation.min;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (field.validation.max !== undefined && !validator(field.validation.max)) {
|
|
178
|
+
console.warn(`⚠ max value for ${field.name} is invalid`);
|
|
179
|
+
delete field.validation.max;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Cross-validation: min <= max
|
|
183
|
+
if (field.validation.min !== undefined && field.validation.max !== undefined) {
|
|
184
|
+
if (field.validation.min > field.validation.max) {
|
|
185
|
+
console.error(`✖ min (${field.validation.min}) > max (${field.validation.max}) for ${field.name}`);
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return field;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Generate sanitized input handler code
|
|
195
|
+
*/
|
|
196
|
+
export function generateSanitizationCode(fields) {
|
|
197
|
+
const sanitizers = [];
|
|
198
|
+
|
|
199
|
+
fields.forEach(f => {
|
|
200
|
+
const sanitizer = TYPE_SANITIZERS[f.type];
|
|
201
|
+
if (sanitizer && f.type !== "file") {
|
|
202
|
+
sanitizers.push(` sanitized.${f.name} = sanitize${f.type.charAt(0).toUpperCase() + f.type.slice(1)}(values.${f.name});`);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
return sanitizers.join('\n');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Generate validation code for frontend
|
|
211
|
+
*/
|
|
212
|
+
export function generateValidationCode(fields, useZod = false) {
|
|
213
|
+
if (useZod) {
|
|
214
|
+
return fields.map(f => {
|
|
215
|
+
let zodType = "z.string()";
|
|
216
|
+
if (f.type === "number") zodType = "z.number()";
|
|
217
|
+
if (f.type === "boolean") zodType = "z.boolean()";
|
|
218
|
+
if (f.type === "date") zodType = "z.string().datetime()";
|
|
219
|
+
|
|
220
|
+
let refinements = [];
|
|
221
|
+
if (f.validation.required) refinements.push(".min(1, 'Required')");
|
|
222
|
+
else zodType += ".optional()";
|
|
223
|
+
if (f.minLength) refinements.push(`.min(${f.minLength}, "Min ${f.minLength} chars")`);
|
|
224
|
+
if (f.maxLength) refinements.push(`.max(${f.maxLength}, "Max ${f.maxLength} chars")`);
|
|
225
|
+
if (f.pattern) refinements.push(`.regex(${f.pattern})`);
|
|
226
|
+
|
|
227
|
+
return ` ${f.name}: ${zodType}${refinements.join('')}`;
|
|
228
|
+
}).join(',\n');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Inline validation function
|
|
232
|
+
const checks = [];
|
|
233
|
+
fields.forEach(f => {
|
|
234
|
+
if (f.validation.required) {
|
|
235
|
+
checks.push(` if (!values.${f.name}) errors.push("${f.label || f.name} is required");`);
|
|
236
|
+
}
|
|
237
|
+
if (f.validation.min !== undefined) {
|
|
238
|
+
checks.push(` if (values.${f.name} < ${f.validation.min}) errors.push("Minimum ${f.validation.min}");`);
|
|
239
|
+
}
|
|
240
|
+
if (f.validation.max !== undefined) {
|
|
241
|
+
checks.push(` if (values.${f.name} > ${f.validation.max}) errors.push("Maximum ${f.validation.max}");`);
|
|
242
|
+
}
|
|
243
|
+
if (f.minLength) {
|
|
244
|
+
checks.push(` if (values.${f.name}.length < ${f.minLength}) errors.push("Min ${f.minLength} characters");`);
|
|
245
|
+
}
|
|
246
|
+
if (f.maxLength) {
|
|
247
|
+
checks.push(` if (values.${f.name}.length > ${f.maxLength}) errors.push("Max ${f.maxLength} characters");`);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
return checks.join('\n');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Check if any field requires file upload handling
|
|
256
|
+
*/
|
|
257
|
+
export function hasFileUpload(fields) {
|
|
258
|
+
return fields.some(f => f.type === "file");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Check if any field requires rich text (WYSIWYG)
|
|
263
|
+
*/
|
|
264
|
+
export function hasRichText(fields) {
|
|
265
|
+
return fields.some(f => f.type === "textarea" && (f.validation.maxLength > 500 || f.richText));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Generate backend validator (Joi)
|
|
270
|
+
*/
|
|
271
|
+
export function generateJoiValidator(fields) {
|
|
272
|
+
const rules = fields.map(f => {
|
|
273
|
+
let rule = ` ${f.name}: `;
|
|
274
|
+
|
|
275
|
+
switch (f.type) {
|
|
276
|
+
case "string":
|
|
277
|
+
case "text":
|
|
278
|
+
case "email":
|
|
279
|
+
case "url":
|
|
280
|
+
case "tel":
|
|
281
|
+
case "password":
|
|
282
|
+
rule += "Joi.string().trim()";
|
|
283
|
+
if (f.minLength) rule += `.min(${f.minLength})`;
|
|
284
|
+
if (f.maxLength) rule += `.max(${f.maxLength})`;
|
|
285
|
+
if (f.type === "email") rule += ".email()";
|
|
286
|
+
if (f.type === "url") rule += ".uri()";
|
|
287
|
+
if (f.type === "tel") rule += ".pattern(/^[+]?[1-9]\\d{1,14}$/)";
|
|
288
|
+
if (f.type === "password") rule += ".min(8).pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)/)";
|
|
289
|
+
break;
|
|
290
|
+
case "number":
|
|
291
|
+
case "range":
|
|
292
|
+
rule += "Joi.number().integer()";
|
|
293
|
+
if (f.validation.min !== undefined) rule += `.min(${f.validation.min})`;
|
|
294
|
+
if (f.validation.max !== undefined) rule += `.max(${f.validation.max})`;
|
|
295
|
+
break;
|
|
296
|
+
case "boolean":
|
|
297
|
+
rule += "Joi.boolean()";
|
|
298
|
+
break;
|
|
299
|
+
case "date":
|
|
300
|
+
case "datetime":
|
|
301
|
+
rule += "Joi.date().iso()";
|
|
302
|
+
break;
|
|
303
|
+
case "color":
|
|
304
|
+
rule += "Joi.string().hexColor()";
|
|
305
|
+
break;
|
|
306
|
+
case "file":
|
|
307
|
+
rule += "Joi.any()";
|
|
308
|
+
break;
|
|
309
|
+
default:
|
|
310
|
+
rule += "Joi.any()";
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (f.validation.required) {
|
|
314
|
+
rule += ".required()";
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (f.validation.unique) {
|
|
318
|
+
// Comment for server-side unique check
|
|
319
|
+
rule += ` // TODO: Add unique validation in controller`;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return rule;
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
return `const Joi = require("joi");
|
|
326
|
+
|
|
327
|
+
const schema = Joi.object({
|
|
328
|
+
${rules.join(",\n")}
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
module.exports = schema;`;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Sanitize utilities (exported for use in generated code)
|
|
336
|
+
*/
|
|
337
|
+
export const sanitizers = {
|
|
338
|
+
text: (value) => {
|
|
339
|
+
if (typeof value !== "string") return value;
|
|
340
|
+
// Remove HTML tags and trim
|
|
341
|
+
return value.replace(/<[^>]*>?/gm, '').trim();
|
|
342
|
+
},
|
|
343
|
+
|
|
344
|
+
textarea: (value) => {
|
|
345
|
+
if (typeof value !== "string") return value;
|
|
346
|
+
// Preserve line breaks but remove dangerous tags
|
|
347
|
+
return value.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '').trim();
|
|
348
|
+
},
|
|
349
|
+
|
|
350
|
+
email: (value) => {
|
|
351
|
+
if (typeof value !== "string") return value;
|
|
352
|
+
return value.toLowerCase().trim();
|
|
353
|
+
},
|
|
354
|
+
|
|
355
|
+
url: (value) => {
|
|
356
|
+
if (typeof value !== "string") return value;
|
|
357
|
+
return value.trim();
|
|
358
|
+
},
|
|
359
|
+
|
|
360
|
+
phone: (value) => {
|
|
361
|
+
if (typeof value !== "string") return value;
|
|
362
|
+
// E.164 format: +[country code][number]
|
|
363
|
+
return value.replace(/[^+0-9]/g, '');
|
|
364
|
+
},
|
|
365
|
+
|
|
366
|
+
number: (value) => {
|
|
367
|
+
return parseFloat(value) || 0;
|
|
368
|
+
},
|
|
369
|
+
|
|
370
|
+
default: (value) => value,
|
|
371
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Logger utility for consistent logging across CLI commands
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
|
|
4
|
+
export class Logger {
|
|
5
|
+
static info(message) {
|
|
6
|
+
console.log(chalk.blue(`ℹ ${message}`));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
static success(message) {
|
|
10
|
+
console.log(chalk.green(`✓ ${message}`));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
static warning(message) {
|
|
14
|
+
console.log(chalk.yellow(`⚠ ${message}`));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
static error(message) {
|
|
18
|
+
console.error(chalk.red(`✗ ${message}`));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
static debug(message) {
|
|
22
|
+
if (process.env.LOOM_DEBUG === 'true') {
|
|
23
|
+
console.log(chalk.gray(`[DEBUG] ${message}`));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
static gray(message) {
|
|
28
|
+
console.log(chalk.gray(message));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
static cyan(message) {
|
|
32
|
+
console.log(chalk.cyan(message));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
static progress(message) {
|
|
36
|
+
// For use with ora or similar spinners
|
|
37
|
+
process.stdout.write(`\r${chalk.cyan(`⟳ ${message}`)}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
static clearProgress() {
|
|
41
|
+
// Clear progress line
|
|
42
|
+
process.stdout.clearLine();
|
|
43
|
+
process.stdout.cursorTo(0);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default Logger;
|