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,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,5 @@
1
+ {/* <%= resource.pascalName %> */}
2
+ <Route
3
+ path="/admin/<%= resource.kebabName %>"
4
+ element={<AppShell secure><<%= resource.pascalName %>List /></AppShell>}
5
+ />
@@ -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;