naider 1.21.1 → 1.24.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/bin/naide.js +50 -4
- package/mcp/server.js +1 -1
- package/package.json +1 -1
- package/src/gen.js +966 -104
package/src/gen.js
CHANGED
|
@@ -93,7 +93,7 @@ const COMPOSITE_WORDS = {
|
|
|
93
93
|
const NOISE = new Set([
|
|
94
94
|
'a', 'an', 'the', 'for', 'with', 'and', 'or', 'on', 'at', 'to', 'of', 'in', 'by',
|
|
95
95
|
'that', 'which', 'this', 'it', 'its', 'my', 'is', 'are', 'has', 'have', 'do', 'does',
|
|
96
|
-
'make', 'create', 'build', 'generate', 'add', 'implement', 'simple', 'basic', 'new',
|
|
96
|
+
'make', 'create', 'build', 'generate', 'add', 'implement', 'simple', 'basic', 'new', 'remove', 'delete', 'drop',
|
|
97
97
|
'please', 'want', 'need', 'like', 'just', 'some', 'using', 'use', 'based',
|
|
98
98
|
'setup', 'set', 'up', 'get', 'should', 'can', 'could', 'would',
|
|
99
99
|
'の', 'を', 'に', 'で', 'は', 'が', 'と', 'も', 'から', 'まで', 'より', 'って',
|
|
@@ -266,6 +266,65 @@ function applyRelationships(entities, relationships) {
|
|
|
266
266
|
}
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
+
// ── Many-to-Many Relationships ────────────────────────────
|
|
270
|
+
|
|
271
|
+
const MANY_TO_MANY = {
|
|
272
|
+
'User-Product': { junction: 'Favorite', fkLeft: 'user_id', fkRight: 'product_id' },
|
|
273
|
+
'User-Team': { junction: 'TeamMember', fkLeft: 'user_id', fkRight: 'team_id' },
|
|
274
|
+
'Post-Category':{ junction: 'PostTag', fkLeft: 'post_id', fkRight: 'category_id' },
|
|
275
|
+
'Product-Category':{ junction: 'ProductCategory', fkLeft: 'product_id', fkRight: 'category_id' },
|
|
276
|
+
'User-Group': { junction: 'GroupMember', fkLeft: 'user_id', fkRight: 'group_id' },
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
function detectManyToMany(entityNames) {
|
|
280
|
+
const rels = [];
|
|
281
|
+
for (let i = 0; i < entityNames.length; i++) {
|
|
282
|
+
for (let j = i + 1; j < entityNames.length; j++) {
|
|
283
|
+
const key1 = entityNames[i] + '-' + entityNames[j];
|
|
284
|
+
const key2 = entityNames[j] + '-' + entityNames[i];
|
|
285
|
+
const match = MANY_TO_MANY[key1] || MANY_TO_MANY[key2];
|
|
286
|
+
if (match) rels.push({ left: entityNames[i], right: entityNames[j], ...match });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return rels;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function junctionSchemaBlock(rel) {
|
|
293
|
+
return `schema ${rel.junction}:\n id auto\n ${rel.fkLeft.padEnd(10)}int required\n ${rel.fkRight.padEnd(10)}int required\n created auto`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function manyToManyRoutes(rel) {
|
|
297
|
+
const leftPlural = pluralize(rel.left.toLowerCase());
|
|
298
|
+
const rightPlural = pluralize(rel.right.toLowerCase());
|
|
299
|
+
const store = rel.junction + 'Store';
|
|
300
|
+
return [
|
|
301
|
+
`get "/api/${leftPlural}/:id/${rightPlural}" (req, res):`,
|
|
302
|
+
` list links = ${store}.findAll("${rel.fkLeft}", req.params.id)`,
|
|
303
|
+
` ret links`,
|
|
304
|
+
``,
|
|
305
|
+
`post "/api/${leftPlural}/:id/${rightPlural}" (req, res):`,
|
|
306
|
+
` any link = ${store}.create({${rel.fkLeft}: req.params.id, ${rel.fkRight}: req.body.${rel.fkRight}})`,
|
|
307
|
+
` ret ok(link, null)`,
|
|
308
|
+
``,
|
|
309
|
+
`del "/api/${leftPlural}/:id/${rightPlural}/:linkId" (req, res):`,
|
|
310
|
+
` ${store}.remove(req.params.linkId)`,
|
|
311
|
+
` ret ok({removed: true}, null)`,
|
|
312
|
+
``,
|
|
313
|
+
`get "/api/${rightPlural}/:id/${leftPlural}" (req, res):`,
|
|
314
|
+
` list links = ${store}.findAll("${rel.fkRight}", req.params.id)`,
|
|
315
|
+
` ret links`,
|
|
316
|
+
];
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ── Enum Fields ───────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
const ENUM_VALUES = {
|
|
322
|
+
status: ['pending', 'active', 'completed', 'cancelled'],
|
|
323
|
+
priority: ['low', 'medium', 'high', 'critical'],
|
|
324
|
+
role: ['user', 'admin', 'moderator'],
|
|
325
|
+
visibility: ['public', 'private', 'unlisted'],
|
|
326
|
+
};
|
|
327
|
+
|
|
269
328
|
// ── Utilities ───────────────────────────────────────────────
|
|
270
329
|
|
|
271
330
|
function singularize(w) {
|
|
@@ -382,6 +441,16 @@ function classify(words, raw) {
|
|
|
382
441
|
return !NOISE.has(wl) && !allKnown.has(wl) && !/^\d+$/.test(wl) && wl.length > 2 && /^[a-z]+s?$/i.test(wl);
|
|
383
442
|
}) || Object.keys(JA_ENTITIES).some(ja => raw.includes(ja));
|
|
384
443
|
|
|
444
|
+
// Entity removal detection (B8)
|
|
445
|
+
if (/(?:remove|delete|drop|取り除|削除)\s+/i.test(raw)) {
|
|
446
|
+
mods.removal = true;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// API versioning detection (B13)
|
|
450
|
+
if (/(?:version|v\d|versioning|バージョン)/i.test(raw)) {
|
|
451
|
+
mods.apiVersion = 'v1';
|
|
452
|
+
}
|
|
453
|
+
|
|
385
454
|
// Complex NL phrase detection
|
|
386
455
|
if (/(?:users?\s+can\s+(?:comment|post|review|write)|コメントできる|投稿できる|レビューできる)/i.test(raw)) {
|
|
387
456
|
intents.add('auth'); intents.add('crud');
|
|
@@ -474,7 +543,6 @@ function extractParams(words, raw, intents, mods) {
|
|
|
474
543
|
if (!wl || NOISE.has(wl) || allKnown.has(wl) || /^\d+$/.test(wl) || wl.length <= 2) continue;
|
|
475
544
|
const name = capitalize(singularize(wl));
|
|
476
545
|
if (!entityNames.includes(name)) entityNames.push(name);
|
|
477
|
-
break;
|
|
478
546
|
}
|
|
479
547
|
}
|
|
480
548
|
}
|
|
@@ -540,6 +608,7 @@ function extractParams(words, raw, intents, mods) {
|
|
|
540
608
|
if (params.entities.length > 1) {
|
|
541
609
|
params.relationships = detectRelationships(params.entities);
|
|
542
610
|
applyRelationships(params.entities, params.relationships);
|
|
611
|
+
params.m2mRelationships = detectManyToMany(params.entities.map(e => e.name));
|
|
543
612
|
}
|
|
544
613
|
|
|
545
614
|
// Extract bot commands
|
|
@@ -649,16 +718,33 @@ function paginatedListBlock(entity) {
|
|
|
649
718
|
const store = entity.name + 'Store';
|
|
650
719
|
return [
|
|
651
720
|
`get "/api/${plural}" (req, res):`,
|
|
652
|
-
` int page = req.query.page
|
|
653
|
-
` int limit = req.query.limit
|
|
654
|
-
` str search = req.query.search
|
|
655
|
-
`
|
|
721
|
+
` int page = req.query.page or 1`,
|
|
722
|
+
` int limit = req.query.limit or 20`,
|
|
723
|
+
` str search = req.query.search or ""`,
|
|
724
|
+
` str sortBy = req.query.sort or "id"`,
|
|
725
|
+
` str order = req.query.order or "desc"`,
|
|
726
|
+
` mut list rows = ${store}.all()`,
|
|
656
727
|
` if search:`,
|
|
657
|
-
`
|
|
658
|
-
`
|
|
659
|
-
`
|
|
660
|
-
`
|
|
661
|
-
`
|
|
728
|
+
` rows = rows.filter((item) => JSON.stringify(item).toLowerCase().includes(search.toLowerCase()))`,
|
|
729
|
+
` each key in Object.keys(req.query):`,
|
|
730
|
+
` if key.endsWith("_gte"):`,
|
|
731
|
+
` str field = key.replace("_gte", "")`,
|
|
732
|
+
` rows = rows.filter((item) => item[field] >= Number(req.query[key]))`,
|
|
733
|
+
` if key.endsWith("_lte"):`,
|
|
734
|
+
` str field = key.replace("_lte", "")`,
|
|
735
|
+
` rows = rows.filter((item) => item[field] <= Number(req.query[key]))`,
|
|
736
|
+
` if key.endsWith("_eq"):`,
|
|
737
|
+
` str field = key.replace("_eq", "")`,
|
|
738
|
+
` rows = rows.filter((item) => String(item[field]) == req.query[key])`,
|
|
739
|
+
` if order == "asc":`,
|
|
740
|
+
` rows = rows.sort((a, b) => String(a[sortBy]).localeCompare(String(b[sortBy])))`,
|
|
741
|
+
` else:`,
|
|
742
|
+
` rows = rows.sort((a, b) => String(b[sortBy]).localeCompare(String(a[sortBy])))`,
|
|
743
|
+
` int total = rows.length`,
|
|
744
|
+
` int offset = page - 1`,
|
|
745
|
+
` int start = offset * limit`,
|
|
746
|
+
` list items = rows.slice(start, start + limit)`,
|
|
747
|
+
` ret {items, total, page, limit, sort: sortBy, order}`,
|
|
662
748
|
];
|
|
663
749
|
}
|
|
664
750
|
|
|
@@ -817,7 +903,9 @@ function openApiSpec(entities, intents, params, relationships) {
|
|
|
817
903
|
{ name: 'page', in: 'query', schema: { type: 'integer', default: 1 } },
|
|
818
904
|
{ name: 'limit', in: 'query', schema: { type: 'integer', default: 20 } },
|
|
819
905
|
{ name: 'search', in: 'query', schema: { type: 'string' } },
|
|
820
|
-
|
|
906
|
+
{ name: 'sort', in: 'query', schema: { type: 'string' }, description: 'Field to sort by' },
|
|
907
|
+
{ name: 'order', in: 'query', schema: { type: 'string', enum: ['asc','desc'], default: 'asc' } },
|
|
908
|
+
], responses: { '200': { description: `List of ${plural} with pagination` } } },
|
|
821
909
|
post: { summary: `Create ${entity.name.toLowerCase()}`, requestBody: { content: { 'application/json': { schema: {
|
|
822
910
|
type: 'object', required: requiredFields.map(f => f.name),
|
|
823
911
|
properties: Object.fromEntries(requiredFields.map(f => [f.name, properties[f.name]])),
|
|
@@ -847,12 +935,24 @@ function openApiSpec(entities, intents, params, relationships) {
|
|
|
847
935
|
};
|
|
848
936
|
}
|
|
849
937
|
if (intents.has('auth')) {
|
|
850
|
-
paths['/api/auth/register'] = { post: { summary: 'Register', responses: { '200': { description: 'Token + user' } } } };
|
|
851
|
-
paths['/api/auth/login'] = { post: { summary: 'Login', responses: { '200': { description: 'Token + user' } } } };
|
|
938
|
+
paths['/api/auth/register'] = { post: { summary: 'Register', requestBody: { content: { 'application/json': { schema: { type: 'object', required: ['email','password'], properties: { email:{type:'string'}, password:{type:'string'}, name:{type:'string'} } } } } }, responses: { '200': { description: 'Token + user' }, '400': { description: 'Validation error' } } } };
|
|
939
|
+
paths['/api/auth/login'] = { post: { summary: 'Login', requestBody: { content: { 'application/json': { schema: { type: 'object', required: ['email','password'], properties: { email:{type:'string'}, password:{type:'string'} } } } } }, responses: { '200': { description: 'Token + user' }, '401': { description: 'Invalid credentials' } } } };
|
|
852
940
|
paths['/api/auth/me'] = { get: { summary: 'Current user', security: [{ bearerAuth: [] }], responses: { '200': { description: 'User' } } } };
|
|
941
|
+
paths['/api/auth/forgot-password'] = { post: { summary: 'Request password reset', requestBody: { content: { 'application/json': { schema: { type: 'object', required: ['email'], properties: { email:{type:'string'} } } } } }, responses: { '200': { description: 'Reset token sent' } } } };
|
|
942
|
+
paths['/api/auth/reset-password'] = { post: { summary: 'Reset password with token', requestBody: { content: { 'application/json': { schema: { type: 'object', required: ['token','password'], properties: { token:{type:'string'}, password:{type:'string'} } } } } }, responses: { '200': { description: 'Password reset' }, '400': { description: 'Invalid token' } } } };
|
|
943
|
+
paths['/api/auth/change-password'] = { post: { summary: 'Change password (authenticated)', security: [{ bearerAuth: [] }], requestBody: { content: { 'application/json': { schema: { type: 'object', required: ['oldPassword','newPassword'], properties: { oldPassword:{type:'string'}, newPassword:{type:'string'} } } } } }, responses: { '200': { description: 'Password changed' }, '401': { description: 'Wrong password' } } } };
|
|
944
|
+
}
|
|
945
|
+
const m2m = detectManyToMany(entities.map(e => e.name));
|
|
946
|
+
for (const rel of m2m) {
|
|
947
|
+
const lp = pluralize(rel.left.toLowerCase());
|
|
948
|
+
const rp = pluralize(rel.right.toLowerCase());
|
|
949
|
+
paths[`/api/${lp}/{id}/${rp}`] = { get: { summary: `${rel.right}s for ${rel.left}`, responses: { '200': { description: `List of ${rp}` } } } };
|
|
950
|
+
paths[`/api/${rp}/{id}/${lp}`] = { get: { summary: `${rel.left}s for ${rel.right}`, responses: { '200': { description: `List of ${lp}` } } } };
|
|
951
|
+
paths[`/api/${rel.junction.toLowerCase()}`] = { post: { summary: `Link ${rel.left}↔${rel.right}`, responses: { '201': { description: 'Linked' } } }, delete: { summary: `Unlink ${rel.left}↔${rel.right}`, responses: { '200': { description: 'Unlinked' } } } };
|
|
853
952
|
}
|
|
854
953
|
paths['/api/health'] = { get: { summary: 'Health check', responses: { '200': { description: 'Status' } } } };
|
|
855
954
|
paths['/api/upload'] = { post: { summary: 'Upload file', responses: { '200': { description: 'File info' } } } };
|
|
955
|
+
paths['/docs'] = { get: { summary: 'Swagger UI documentation', responses: { '200': { description: 'HTML page' } } } };
|
|
856
956
|
|
|
857
957
|
const spec = {
|
|
858
958
|
openapi: '3.0.3',
|
|
@@ -991,6 +1091,446 @@ function apiClientCode(entities, intents, port) {
|
|
|
991
1091
|
return lines.join('\n') + '\n';
|
|
992
1092
|
}
|
|
993
1093
|
|
|
1094
|
+
// ── Input Validation (A4) ──────────────────────────────────
|
|
1095
|
+
|
|
1096
|
+
function validationBlock(entity) {
|
|
1097
|
+
const lines = [];
|
|
1098
|
+
lines.push(`fn validate${entity.name}(any body):`);
|
|
1099
|
+
lines.push(` list errors = []`);
|
|
1100
|
+
for (const f of entity.fields) {
|
|
1101
|
+
if (f.type === 'auto') continue;
|
|
1102
|
+
if (f.modifiers.includes('required')) {
|
|
1103
|
+
lines.push(` if not body.${f.name}:`);
|
|
1104
|
+
lines.push(` errors.push("${f.name} is required")`);
|
|
1105
|
+
}
|
|
1106
|
+
if (f.modifiers.includes('email')) {
|
|
1107
|
+
lines.push(` if body.${f.name} and not body.${f.name}.includes("@"):`);
|
|
1108
|
+
lines.push(` errors.push("${f.name} must be a valid email")`);
|
|
1109
|
+
}
|
|
1110
|
+
const minMod = f.modifiers.find(m => m.startsWith('min('));
|
|
1111
|
+
if (minMod) {
|
|
1112
|
+
const minVal = minMod.match(/\d+/)[0];
|
|
1113
|
+
if (f.type === 'str') {
|
|
1114
|
+
lines.push(` if body.${f.name} and body.${f.name}.length < ${minVal}:`);
|
|
1115
|
+
lines.push(` errors.push("${f.name} must be at least ${minVal} characters")`);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
const maxMod = f.modifiers.find(m => m.startsWith('max('));
|
|
1119
|
+
if (maxMod) {
|
|
1120
|
+
const maxVal = maxMod.match(/\d+/)[0];
|
|
1121
|
+
if (f.type === 'str') {
|
|
1122
|
+
lines.push(` if body.${f.name} and body.${f.name}.length > ${maxVal}:`);
|
|
1123
|
+
lines.push(` errors.push("${f.name} must be at most ${maxVal} characters")`);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
if (ENUM_VALUES[f.name]) {
|
|
1127
|
+
const vals = ENUM_VALUES[f.name].map(v => `"${v}"`).join(', ');
|
|
1128
|
+
lines.push(` if body.${f.name} and not [${vals}].includes(body.${f.name}):`);
|
|
1129
|
+
lines.push(` errors.push("${f.name} must be one of: ${ENUM_VALUES[f.name].join(', ')}")`);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
lines.push(` ret errors`);
|
|
1133
|
+
return lines.join('\n');
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
function validatedCreateRoute(entity) {
|
|
1137
|
+
const lower = entity.name.toLowerCase();
|
|
1138
|
+
const plural = pluralize(lower);
|
|
1139
|
+
const store = entity.name + 'Store';
|
|
1140
|
+
return [
|
|
1141
|
+
`post "/api/${plural}" (req, res):`,
|
|
1142
|
+
` list errors = validate${entity.name}(req.body)`,
|
|
1143
|
+
` if errors.length > 0:`,
|
|
1144
|
+
` ret.status(400) {ok: false, errors}`,
|
|
1145
|
+
` any created = ${store}.create(req.body)`,
|
|
1146
|
+
` ret.status(201) ok(created, null)`,
|
|
1147
|
+
``,
|
|
1148
|
+
`put "/api/${plural}/:id" (req, res):`,
|
|
1149
|
+
` list errors = validate${entity.name}(req.body)`,
|
|
1150
|
+
` if errors.length > 0:`,
|
|
1151
|
+
` ret.status(400) {ok: false, errors}`,
|
|
1152
|
+
` any updated = ${store}.update(req.params.id, req.body)`,
|
|
1153
|
+
` ret ok(updated, null)`,
|
|
1154
|
+
];
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// ── Swagger UI Route (A5) ──────────────────────────────────
|
|
1158
|
+
|
|
1159
|
+
function swaggerUIRoute() {
|
|
1160
|
+
return [
|
|
1161
|
+
`get "/docs" (req, res):`,
|
|
1162
|
+
` str html = "<!DOCTYPE html><html><head><title>API Docs</title><link rel=stylesheet href=https://unpkg.com/swagger-ui-dist/swagger-ui.css></head><body><div id=swagger-ui></div><script src=https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js></script><script>SwaggerUIBundle({url:'/api/openapi.json',dom_id:'#swagger-ui'})</script></body></html>"`,
|
|
1163
|
+
` res.type("html")`,
|
|
1164
|
+
` ret html`,
|
|
1165
|
+
];
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
// ── Password Reset Routes (A6) ─────────────────────────────
|
|
1169
|
+
|
|
1170
|
+
function passwordResetRoutes() {
|
|
1171
|
+
return [
|
|
1172
|
+
`post "/api/auth/forgot-password" (req, res):`,
|
|
1173
|
+
` any user = UserStore.findBy("email", req.body.email)`,
|
|
1174
|
+
` if not user:`,
|
|
1175
|
+
` ret ok({message: "If the email exists, a reset link has been sent"}, null)`,
|
|
1176
|
+
` str resetToken = crypto.randomUUID()`,
|
|
1177
|
+
` UserStore.update(user.id, {resetToken})`,
|
|
1178
|
+
` log("Password reset requested for: " + req.body.email)`,
|
|
1179
|
+
` ret ok({message: "If the email exists, a reset link has been sent"}, null)`,
|
|
1180
|
+
``,
|
|
1181
|
+
`post "/api/auth/reset-password" (req, res):`,
|
|
1182
|
+
` if not req.body.token or not req.body.password:`,
|
|
1183
|
+
` ret.status(400) err("Token and password are required", 400)`,
|
|
1184
|
+
` any user = UserStore.findBy("resetToken", req.body.token)`,
|
|
1185
|
+
` if not user:`,
|
|
1186
|
+
` ret.status(400) err("Invalid or expired token", 400)`,
|
|
1187
|
+
` str hashed = await hash(req.body.password)`,
|
|
1188
|
+
` UserStore.update(user.id, {password: hashed, resetToken: null})`,
|
|
1189
|
+
` ret ok({message: "Password has been reset"}, null)`,
|
|
1190
|
+
``,
|
|
1191
|
+
`post "/api/auth/change-password" (req, res):`,
|
|
1192
|
+
` if not req.body.oldPassword or not req.body.newPassword:`,
|
|
1193
|
+
` ret.status(400) err("Old and new passwords are required", 400)`,
|
|
1194
|
+
` bool valid = await verify(req.body.oldPassword, req.user.password)`,
|
|
1195
|
+
` if not valid:`,
|
|
1196
|
+
` ret.status(401) err("Invalid old password", 401)`,
|
|
1197
|
+
` str hashed = await hash(req.body.newPassword)`,
|
|
1198
|
+
` UserStore.update(req.user.id, {password: hashed})`,
|
|
1199
|
+
` ret ok({message: "Password changed"}, null)`,
|
|
1200
|
+
];
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// ── WebSocket Rooms (B11) ──────────────────────────────────
|
|
1204
|
+
|
|
1205
|
+
function wsRoomsBlock(hasAuth) {
|
|
1206
|
+
const lines = [
|
|
1207
|
+
`ws "/ws":`,
|
|
1208
|
+
` on "connection" (socket, req):`,
|
|
1209
|
+
];
|
|
1210
|
+
if (hasAuth) {
|
|
1211
|
+
lines.push(
|
|
1212
|
+
` # WS auth: verify token from query string`,
|
|
1213
|
+
` str wsToken = ""`,
|
|
1214
|
+
` if req.url and req.url.includes("token="):`,
|
|
1215
|
+
` str qs = req.url.split("token=")[1] or ""`,
|
|
1216
|
+
` int ampIdx = qs.indexOf("&")`,
|
|
1217
|
+
` if ampIdx > 0:`,
|
|
1218
|
+
` wsToken = qs.slice(0, ampIdx)`,
|
|
1219
|
+
` else:`,
|
|
1220
|
+
` wsToken = qs`,
|
|
1221
|
+
` if wsToken:`,
|
|
1222
|
+
` any wsDecoded = jwt.verify(wsToken, JWT_SECRET)`,
|
|
1223
|
+
` if wsDecoded:`,
|
|
1224
|
+
` socket.user = wsDecoded`,
|
|
1225
|
+
` socket.authenticated = true`,
|
|
1226
|
+
` else:`,
|
|
1227
|
+
` socket.authenticated = false`,
|
|
1228
|
+
` socket.send(JSON.stringify({type: "error", message: "Invalid token"}))`,
|
|
1229
|
+
` socket.close()`,
|
|
1230
|
+
` else:`,
|
|
1231
|
+
` socket.authenticated = false`,
|
|
1232
|
+
);
|
|
1233
|
+
}
|
|
1234
|
+
lines.push(
|
|
1235
|
+
` socket.room = "general"`,
|
|
1236
|
+
` log("Client connected to room: general")`,
|
|
1237
|
+
` on "message" (data, socket):`,
|
|
1238
|
+
);
|
|
1239
|
+
if (hasAuth) {
|
|
1240
|
+
lines.push(
|
|
1241
|
+
` if not socket.authenticated:`,
|
|
1242
|
+
` socket.send(JSON.stringify({type: "error", message: "Not authenticated"}))`,
|
|
1243
|
+
` ret`,
|
|
1244
|
+
);
|
|
1245
|
+
}
|
|
1246
|
+
lines.push(
|
|
1247
|
+
` any msg = JSON.parse(data)`,
|
|
1248
|
+
` if msg.type == "join":`,
|
|
1249
|
+
` socket.room = msg.room`,
|
|
1250
|
+
` socket.send(JSON.stringify({type: "joined", room: msg.room}))`,
|
|
1251
|
+
` if msg.type == "leave":`,
|
|
1252
|
+
` socket.room = "general"`,
|
|
1253
|
+
` if msg.type == "message":`,
|
|
1254
|
+
` broadcast({type: "message", room: socket.room, data: msg.data, sender: msg.sender}, socket.room)`,
|
|
1255
|
+
` if msg.type == "broadcast":`,
|
|
1256
|
+
` broadcast(msg.data)`,
|
|
1257
|
+
` on "close" (socket):`,
|
|
1258
|
+
` log("Client disconnected")`,
|
|
1259
|
+
);
|
|
1260
|
+
return lines;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
// ── Rate Limiting (B12) ────────────────────────────────────
|
|
1264
|
+
|
|
1265
|
+
function rateLimitBlock() {
|
|
1266
|
+
return `fn rateLimit(int windowMs, int maxReqs):
|
|
1267
|
+
map hits = {}
|
|
1268
|
+
ret fn(req, res, next):
|
|
1269
|
+
str key = req.ip
|
|
1270
|
+
int now = Date.now()
|
|
1271
|
+
if not hits[key] or now - hits[key].start > windowMs:
|
|
1272
|
+
hits[key] = {count: 1, start: now}
|
|
1273
|
+
else:
|
|
1274
|
+
hits[key].count = hits[key].count + 1
|
|
1275
|
+
if hits[key].count > maxReqs:
|
|
1276
|
+
ret.status(429) {error: "Too many requests"}
|
|
1277
|
+
next()`;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// ── Logging Middleware (C17) ───────────────────────────────
|
|
1281
|
+
|
|
1282
|
+
function loggingBlock() {
|
|
1283
|
+
return `fn requestLogger(req, res, next):
|
|
1284
|
+
int startTime = Date.now()
|
|
1285
|
+
log("[" + req.method + "] " + req.path)
|
|
1286
|
+
next()`;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// ── RBAC Middleware (D1) ──────────────────────────────────
|
|
1290
|
+
|
|
1291
|
+
function rbacBlock() {
|
|
1292
|
+
return `fn requireRole(list allowed):
|
|
1293
|
+
ret fn(req, res, next):
|
|
1294
|
+
if not req.user:
|
|
1295
|
+
ret.status(401) {error: "Not authenticated"}
|
|
1296
|
+
str userRole = req.user.role or "user"
|
|
1297
|
+
if not allowed.includes(userRole):
|
|
1298
|
+
ret.status(403) {error: "Insufficient permissions"}
|
|
1299
|
+
next()`;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
function rbacRouteGuards(entity) {
|
|
1303
|
+
const lower = entity.name.toLowerCase();
|
|
1304
|
+
const plural = pluralize(lower);
|
|
1305
|
+
return [
|
|
1306
|
+
`# Role guards for ${plural}`,
|
|
1307
|
+
`del "/api/${plural}/:id" (req, res):`,
|
|
1308
|
+
` str role = req.user.role or "user"`,
|
|
1309
|
+
` if role != "admin" and role != "moderator":`,
|
|
1310
|
+
` ret.status(403) {error: "Only admin/moderator can delete"}`,
|
|
1311
|
+
` ${entity.name}Store.remove(req.params.id)`,
|
|
1312
|
+
` ret {deleted: true}`,
|
|
1313
|
+
];
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
// ── Caching Headers (C18) ─────────────────────────────────
|
|
1317
|
+
|
|
1318
|
+
function cachingRoute() {
|
|
1319
|
+
return [
|
|
1320
|
+
`get "/api/cache-headers" (req, res):`,
|
|
1321
|
+
` res.set("Cache-Control", "public, max-age=60")`,
|
|
1322
|
+
` ret {cached: true, ttl: 60}`,
|
|
1323
|
+
];
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
// ── Frontend SPA (S2) ─────────────────────────────────────
|
|
1327
|
+
|
|
1328
|
+
function frontendSPA(entities, intents, port) {
|
|
1329
|
+
const L = [];
|
|
1330
|
+
L.push('<!DOCTYPE html>');
|
|
1331
|
+
L.push('<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">');
|
|
1332
|
+
L.push('<title>App</title>');
|
|
1333
|
+
L.push(`<style>
|
|
1334
|
+
:root{--bg:#f8fafc;--card:#fff;--primary:#2563eb;--danger:#dc2626;--text:#1e293b;--muted:#64748b;--border:#e2e8f0;--radius:8px}
|
|
1335
|
+
*{margin:0;padding:0;box-sizing:border-box}body{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);line-height:1.6}
|
|
1336
|
+
nav{background:var(--primary);color:#fff;padding:1rem 2rem;display:flex;gap:1rem;align-items:center;box-shadow:0 2px 4px rgba(0,0,0,.1)}
|
|
1337
|
+
nav a{color:#fff;text-decoration:none;padding:.4rem 1rem;border-radius:var(--radius);opacity:.8;cursor:pointer}nav a:hover,nav a.active{opacity:1;background:rgba(255,255,255,.15)}
|
|
1338
|
+
.container{max-width:1200px;margin:2rem auto;padding:0 1rem}
|
|
1339
|
+
.card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:1.5rem;margin-bottom:1rem;box-shadow:0 1px 3px rgba(0,0,0,.05)}
|
|
1340
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:1rem}
|
|
1341
|
+
.form-group{margin-bottom:1rem}
|
|
1342
|
+
.form-group label{display:block;font-weight:500;margin-bottom:.25rem;font-size:.875rem}
|
|
1343
|
+
.form-group input,.form-group textarea,.form-group select{width:100%;padding:.5rem .75rem;border:1px solid var(--border);border-radius:var(--radius);font-size:.875rem}
|
|
1344
|
+
.form-group textarea{min-height:80px;resize:vertical}
|
|
1345
|
+
.btn{padding:.5rem 1rem;border:none;border-radius:var(--radius);cursor:pointer;font-size:.875rem;font-weight:500;transition:opacity .2s}
|
|
1346
|
+
.btn-primary{background:var(--primary);color:#fff}.btn-danger{background:var(--danger);color:#fff}
|
|
1347
|
+
.btn:hover{opacity:.85}.btn-sm{padding:.25rem .5rem;font-size:.75rem}
|
|
1348
|
+
table{width:100%;border-collapse:collapse}th,td{padding:.75rem;text-align:left;border-bottom:1px solid var(--border)}
|
|
1349
|
+
th{background:var(--bg);font-weight:600;font-size:.875rem;color:var(--muted)}
|
|
1350
|
+
.search{display:flex;gap:.5rem;margin-bottom:1rem}
|
|
1351
|
+
.search input{flex:1;padding:.5rem .75rem;border:1px solid var(--border);border-radius:var(--radius)}
|
|
1352
|
+
.pagination{display:flex;gap:.5rem;justify-content:center;margin-top:1rem;align-items:center}
|
|
1353
|
+
.toast{position:fixed;top:1rem;right:1rem;background:#22c55e;color:#fff;padding:.75rem 1.5rem;border-radius:var(--radius);opacity:0;transition:opacity .3s;z-index:999}
|
|
1354
|
+
.toast.show{opacity:1}.toast.error{background:var(--danger)}.toast.warn{background:#f59e0b}
|
|
1355
|
+
.loading{text-align:center;padding:2rem;color:var(--muted)}.loading::after{content:"";display:inline-block;width:1.5rem;height:1.5rem;border:2px solid var(--border);border-top-color:var(--primary);border-radius:50%;animation:spin .6s linear infinite;margin-left:.5rem}
|
|
1356
|
+
@keyframes spin{to{transform:rotate(360deg)}}
|
|
1357
|
+
.error-banner{background:#fef2f2;border:1px solid #fecaca;color:#991b1b;padding:.75rem 1rem;border-radius:var(--radius);margin-bottom:1rem;font-size:.875rem}
|
|
1358
|
+
.modal-bg{position:fixed;inset:0;background:rgba(0,0,0,.4);display:flex;align-items:center;justify-content:center;z-index:100}
|
|
1359
|
+
.modal{background:var(--card);border-radius:var(--radius);padding:2rem;width:90%;max-width:500px;box-shadow:0 20px 60px rgba(0,0,0,.2)}
|
|
1360
|
+
.modal h3{margin-bottom:1rem}.modal-actions{display:flex;gap:.5rem;justify-content:flex-end;margin-top:1.5rem}
|
|
1361
|
+
.badge{display:inline-block;padding:.15rem .5rem;border-radius:99px;font-size:.75rem;font-weight:500;background:var(--border)}
|
|
1362
|
+
.hidden{display:none}
|
|
1363
|
+
</style></head><body>`);
|
|
1364
|
+
L.push('<div id="toast" class="toast"></div>');
|
|
1365
|
+
const hasAuth = intents.has('auth');
|
|
1366
|
+
const navLinks = entities.map(e => `<a onclick="showSection('${e.name.toLowerCase()}')" id="nav-${e.name.toLowerCase()}">${pluralize(e.name)}</a>`).join('');
|
|
1367
|
+
const authNav = hasAuth ? `<a onclick="showSection('auth')" id="nav-auth">Login</a><span id="user-info" class="hidden" style="margin-left:auto;font-size:.875rem"></span><a id="logout-btn" class="hidden" onclick="doLogout()">Logout</a>` : '';
|
|
1368
|
+
L.push(`<nav><strong style="font-size:1.25rem">App</strong>${navLinks}${authNav}</nav>`);
|
|
1369
|
+
L.push('<div class="container">');
|
|
1370
|
+
if (hasAuth) {
|
|
1371
|
+
L.push(`<div id="section-auth"><div class="card" style="max-width:400px;margin:2rem auto"><h2 id="auth-title">Login</h2>`);
|
|
1372
|
+
L.push(`<div class="form-group"><label>Email</label><input type="email" id="auth-email" placeholder="email@example.com"></div>`);
|
|
1373
|
+
L.push(`<div class="form-group"><label>Password</label><input type="password" id="auth-password" placeholder="password"></div>`);
|
|
1374
|
+
L.push(`<div id="auth-name-group" class="form-group hidden"><label>Name</label><input type="text" id="auth-name" placeholder="Your name"></div>`);
|
|
1375
|
+
L.push(`<button class="btn btn-primary" style="width:100%" onclick="doAuth()">Login</button>`);
|
|
1376
|
+
L.push(`<p style="text-align:center;margin-top:1rem;font-size:.875rem">Don't have an account? <a onclick="toggleAuthMode()" style="color:var(--primary);cursor:pointer">Register</a></p>`);
|
|
1377
|
+
L.push(`</div></div>`);
|
|
1378
|
+
}
|
|
1379
|
+
for (const entity of entities) {
|
|
1380
|
+
const lower = entity.name.toLowerCase();
|
|
1381
|
+
const plural = pluralize(lower);
|
|
1382
|
+
const fields = entity.fields.filter(f => f.type !== 'auto');
|
|
1383
|
+
L.push(`<div id="section-${lower}" class="hidden">`);
|
|
1384
|
+
L.push(`<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem"><h2>${pluralize(entity.name)}</h2><button class="btn btn-primary" onclick="${lower}ShowModal()">+ New ${entity.name}</button></div>`);
|
|
1385
|
+
L.push(`<div class="search"><input id="${lower}-search" placeholder="Search ${plural}..." oninput="${lower}Load()"><select id="${lower}-sort" onchange="${lower}Load()">`);
|
|
1386
|
+
for (const f of entity.fields) L.push(`<option value="${f.name}">${f.name}</option>`);
|
|
1387
|
+
L.push(`</select><select id="${lower}-order" onchange="${lower}Load()"><option value="desc">Newest</option><option value="asc">Oldest</option></select></div>`);
|
|
1388
|
+
L.push(`<div class="card"><table><thead><tr>${entity.fields.map(f => `<th>${f.name}</th>`).join('')}<th>Actions</th></tr></thead><tbody id="${lower}-tbody"></tbody></table>`);
|
|
1389
|
+
L.push(`<div class="pagination" id="${lower}-pager"></div></div>`);
|
|
1390
|
+
L.push(`<div id="${lower}-modal" class="modal-bg hidden" onclick="if(event.target===this)this.classList.add('hidden')"><div class="modal"><h3 id="${lower}-modal-title">New ${entity.name}</h3>`);
|
|
1391
|
+
L.push(`<input type="hidden" id="${lower}-edit-id">`);
|
|
1392
|
+
for (const f of fields) {
|
|
1393
|
+
if (f.type === 'bool') {
|
|
1394
|
+
L.push(`<div class="form-group"><label><input type="checkbox" id="${lower}-f-${f.name}"> ${f.name}</label></div>`);
|
|
1395
|
+
} else {
|
|
1396
|
+
const inputType = f.type === 'int' ? 'number' : f.name === 'password' ? 'password' : f.name === 'email' ? 'email' : 'text';
|
|
1397
|
+
const tag = f.name === 'body' || f.name === 'content' || f.name === 'description' ? 'textarea' : 'input';
|
|
1398
|
+
L.push(`<div class="form-group"><label>${f.name}${f.modifiers.includes('required') ? ' *' : ''}</label><${tag} ${tag === 'input' ? `type="${inputType}" ` : ''}id="${lower}-f-${f.name}" placeholder="${f.name}"></${tag}></div>`);
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
L.push(`<div class="modal-actions"><button class="btn" onclick="document.getElementById('${lower}-modal').classList.add('hidden')">Cancel</button><button class="btn btn-primary" onclick="${lower}Save()">Save</button></div></div></div>`);
|
|
1402
|
+
L.push('</div>');
|
|
1403
|
+
}
|
|
1404
|
+
L.push('</div>');
|
|
1405
|
+
L.push('<script>');
|
|
1406
|
+
L.push(`const API="";let currentPage={};let _token=null;let _isRegister=false;`);
|
|
1407
|
+
L.push(`function authHeaders(){const h={"Content-Type":"application/json"};if(_token)h.Authorization="Bearer "+_token;return h}`);
|
|
1408
|
+
L.push(`function toast(m,type){const t=document.getElementById("toast");t.textContent=m;t.className="toast show"+(type?" "+type:"");setTimeout(()=>{t.classList.remove("show")},2500)}`);
|
|
1409
|
+
L.push(`function showError(msg){toast(msg,"error")}`);
|
|
1410
|
+
L.push(`async function apiFetch(url,opts){try{const r=await fetch(url,opts||{headers:authHeaders()});if(r.status===401){${hasAuth?'showError("Session expired — please log in again");doLogout();':''} return null}if(!r.ok){const e=await r.json().catch(()=>({error:"Request failed"}));showError(e.error||"Error "+r.status);return null}return await r.json()}catch(e){showError("Network error: "+e.message);return null}}`);
|
|
1411
|
+
L.push(`function setLoading(id,on){const el=document.getElementById(id);if(el)el.innerHTML=on?'<div class="loading">Loading...</div>':""}`);
|
|
1412
|
+
L.push(`function showBanner(containerId,msg){const el=document.getElementById(containerId);if(!el)return;let b=el.querySelector(".error-banner");if(!b){b=document.createElement("div");b.className="error-banner";el.prepend(b)}b.textContent=msg;setTimeout(()=>b.remove(),5000)}`);
|
|
1413
|
+
L.push(`function showSection(name){document.querySelectorAll('[id^="section-"]').forEach(s=>s.classList.add("hidden"));const el=document.getElementById("section-"+name);if(el)el.classList.remove("hidden");document.querySelectorAll("nav a").forEach(a=>a.classList.remove("active"));const n=document.getElementById("nav-"+name);if(n)n.classList.add("active");if(window[name+"Load"])window[name+"Load"]()}`);
|
|
1414
|
+
if (hasAuth) {
|
|
1415
|
+
L.push(`function toggleAuthMode(){_isRegister=!_isRegister;document.getElementById("auth-title").textContent=_isRegister?"Register":"Login";document.getElementById("auth-name-group").classList.toggle("hidden",!_isRegister)}`);
|
|
1416
|
+
L.push(`async function doAuth(){const email=document.getElementById("auth-email").value;const password=document.getElementById("auth-password").value;if(!email||!password){showError("Email and password are required");return}const url=_isRegister?"/api/auth/register":"/api/auth/login";const body=_isRegister?{name:document.getElementById("auth-name").value,email,password}:{email,password};try{const r=await fetch(API+url,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});const d=await r.json();if(!r.ok){showError(d.error||"Auth failed ("+r.status+")");return}if(d.token){_token=d.token;localStorage.setItem("_token",d.token);document.getElementById("user-info").textContent="Hi, "+(d.user?.name||email);document.getElementById("user-info").classList.remove("hidden");document.getElementById("logout-btn").classList.remove("hidden");document.getElementById("nav-auth").classList.add("hidden");toast(_isRegister?"Registered!":"Logged in!");showSection("${entities[0].name.toLowerCase()}")}else{showError(d.error||"No token received")}}catch(e){showError("Network error: "+e.message)}}`);
|
|
1417
|
+
L.push(`function doLogout(){_token=null;localStorage.removeItem("_token");document.getElementById("user-info").classList.add("hidden");document.getElementById("logout-btn").classList.add("hidden");document.getElementById("nav-auth").classList.remove("hidden");toast("Logged out");showSection("auth")}`);
|
|
1418
|
+
L.push(`(function(){const t=localStorage.getItem("_token");if(t){_token=t;document.getElementById("user-info").textContent="Logged in";document.getElementById("user-info").classList.remove("hidden");document.getElementById("logout-btn").classList.remove("hidden");document.getElementById("nav-auth").classList.add("hidden")}})()`);
|
|
1419
|
+
}
|
|
1420
|
+
for (const entity of entities) {
|
|
1421
|
+
const lower = entity.name.toLowerCase();
|
|
1422
|
+
const plural = pluralize(lower);
|
|
1423
|
+
const fields = entity.fields.filter(f => f.type !== 'auto');
|
|
1424
|
+
L.push(`async function ${lower}Load(page){page=page||1;currentPage["${lower}"]=page;const s=document.getElementById("${lower}-search").value;const sort=document.getElementById("${lower}-sort").value;const ord=document.getElementById("${lower}-order").value;`);
|
|
1425
|
+
L.push(`setLoading("${lower}-tbody",true);`);
|
|
1426
|
+
L.push(`const d=await apiFetch(API+"/api/${plural}?page="+page+"&limit=10&search="+encodeURIComponent(s)+"&sort="+sort+"&order="+ord);if(!d){setLoading("${lower}-tbody",false);return}const items=d.items||d;`);
|
|
1427
|
+
L.push(`document.getElementById("${lower}-tbody").innerHTML=items.map(i=>"<tr>${entity.fields.map(f => `<td>"+(i.${f.name}!=null?i.${f.name}:"")+"</td>`).join('')}<td><button class='btn btn-sm btn-primary' onclick='${lower}Edit("+i.id+")'>Edit</button> <button class='btn btn-sm btn-danger' onclick='${lower}Del("+i.id+")'>Del</button></td></tr>").join("");`);
|
|
1428
|
+
L.push(`const tp=Math.ceil((d.total||items.length)/10);let pg="";for(let p=1;p<=tp;p++)pg+="<button class='btn btn-sm "+(p===page?"btn-primary":"")+"' onclick='${lower}Load("+p+")'>"+p+"</button>";document.getElementById("${lower}-pager").innerHTML=pg}`);
|
|
1429
|
+
L.push(`function ${lower}ShowModal(id){document.getElementById("${lower}-edit-id").value=id||"";document.getElementById("${lower}-modal-title").textContent=id?"Edit ${entity.name}":"New ${entity.name}";${fields.map(f => f.type === 'bool' ? `document.getElementById("${lower}-f-${f.name}").checked=false` : `document.getElementById("${lower}-f-${f.name}").value=""`).join(';')};document.getElementById("${lower}-modal").classList.remove("hidden")}`);
|
|
1430
|
+
L.push(`async function ${lower}Edit(id){const d=await apiFetch(API+"/api/${plural}/"+id);if(!d)return;const i=d.data||d;${lower}ShowModal(id);${fields.map(f => f.type === 'bool' ? `document.getElementById("${lower}-f-${f.name}").checked=!!i.${f.name}` : `document.getElementById("${lower}-f-${f.name}").value=i.${f.name}||""`).join(';')}}`);
|
|
1431
|
+
L.push(`async function ${lower}Save(){const id=document.getElementById("${lower}-edit-id").value;const body={${fields.map(f => {
|
|
1432
|
+
if (f.type === 'bool') return `${f.name}:document.getElementById("${lower}-f-${f.name}").checked`;
|
|
1433
|
+
if (f.type === 'int') return `${f.name}:+document.getElementById("${lower}-f-${f.name}").value`;
|
|
1434
|
+
return `${f.name}:document.getElementById("${lower}-f-${f.name}").value`;
|
|
1435
|
+
}).join(',')}};`);
|
|
1436
|
+
L.push(`const method=id?"PUT":"POST";const url=API+"/api/${plural}"+(id?"/"+id:"");const d=await apiFetch(url,{method,headers:authHeaders(),body:JSON.stringify(body)});if(!d){showBanner("section-${lower}","Save failed — check required fields");return}document.getElementById("${lower}-modal").classList.add("hidden");toast(id?"Updated!":"Created!");${lower}Load()}`);
|
|
1437
|
+
L.push(`async function ${lower}Del(id){if(!confirm("Delete this ${lower}?"))return;const d=await apiFetch(API+"/api/${plural}/"+id,{method:"DELETE",headers:authHeaders()});if(!d)return;toast("Deleted!");${lower}Load()}`);
|
|
1438
|
+
}
|
|
1439
|
+
const initSection = hasAuth ? 'auth' : entities[0].name.toLowerCase();
|
|
1440
|
+
L.push(`showSection("${initSection}")`);
|
|
1441
|
+
L.push('</script></body></html>');
|
|
1442
|
+
return L.join('\n');
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// ── Docker Compose (B14) ──────────────────────────────────
|
|
1446
|
+
|
|
1447
|
+
function dockerCompose(projectName, port, intents) {
|
|
1448
|
+
const lines = ['version: "3.8"', '', 'services:'];
|
|
1449
|
+
lines.push(` app:`);
|
|
1450
|
+
lines.push(` build: .`);
|
|
1451
|
+
lines.push(` ports:`);
|
|
1452
|
+
lines.push(` - "${port}:${port}"`);
|
|
1453
|
+
lines.push(` env_file: .env`);
|
|
1454
|
+
lines.push(` volumes:`);
|
|
1455
|
+
lines.push(` - app-data:/app/data`);
|
|
1456
|
+
if (intents.has('database')) {
|
|
1457
|
+
lines.push(` depends_on:`);
|
|
1458
|
+
lines.push(` - db`);
|
|
1459
|
+
lines.push('');
|
|
1460
|
+
lines.push(` db:`);
|
|
1461
|
+
lines.push(` image: postgres:16-alpine`);
|
|
1462
|
+
lines.push(` environment:`);
|
|
1463
|
+
lines.push(` POSTGRES_DB: ${projectName.replace(/-/g, '_')}`);
|
|
1464
|
+
lines.push(` POSTGRES_USER: app`);
|
|
1465
|
+
lines.push(` POSTGRES_PASSWORD: changeme`);
|
|
1466
|
+
lines.push(` ports:`);
|
|
1467
|
+
lines.push(` - "5432:5432"`);
|
|
1468
|
+
lines.push(` volumes:`);
|
|
1469
|
+
lines.push(` - db-data:/var/lib/postgresql/data`);
|
|
1470
|
+
}
|
|
1471
|
+
lines.push('');
|
|
1472
|
+
lines.push('volumes:');
|
|
1473
|
+
lines.push(' app-data:');
|
|
1474
|
+
if (intents.has('database')) lines.push(' db-data:');
|
|
1475
|
+
return lines.join('\n') + '\n';
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
// ── GitHub Actions CI (C16) ────────────────────────────────
|
|
1479
|
+
|
|
1480
|
+
function githubActionsCI(projectName) {
|
|
1481
|
+
return `name: CI
|
|
1482
|
+
|
|
1483
|
+
on:
|
|
1484
|
+
push:
|
|
1485
|
+
branches: [main]
|
|
1486
|
+
pull_request:
|
|
1487
|
+
branches: [main]
|
|
1488
|
+
|
|
1489
|
+
jobs:
|
|
1490
|
+
test:
|
|
1491
|
+
runs-on: ubuntu-latest
|
|
1492
|
+
strategy:
|
|
1493
|
+
matrix:
|
|
1494
|
+
node-version: [20, 22]
|
|
1495
|
+
steps:
|
|
1496
|
+
- uses: actions/checkout@v4
|
|
1497
|
+
- uses: actions/setup-node@v4
|
|
1498
|
+
with:
|
|
1499
|
+
node-version: \${{ matrix.node-version }}
|
|
1500
|
+
cache: npm
|
|
1501
|
+
- run: npm ci
|
|
1502
|
+
- run: npm run build
|
|
1503
|
+
- run: npm test
|
|
1504
|
+
|
|
1505
|
+
lint:
|
|
1506
|
+
runs-on: ubuntu-latest
|
|
1507
|
+
steps:
|
|
1508
|
+
- uses: actions/checkout@v4
|
|
1509
|
+
- uses: actions/setup-node@v4
|
|
1510
|
+
with:
|
|
1511
|
+
node-version: 22
|
|
1512
|
+
cache: npm
|
|
1513
|
+
- run: npm ci
|
|
1514
|
+
- run: npx naide app.naide --check
|
|
1515
|
+
`;
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
// ── Environment Files (C15) ───────────────────────────────
|
|
1519
|
+
|
|
1520
|
+
function envDevelopment(port, intents) {
|
|
1521
|
+
const lines = ['NODE_ENV=development', `PORT=${port}`, 'LOG_LEVEL=debug'];
|
|
1522
|
+
if (intents.has('auth')) lines.push('JWT_SECRET=dev-secret-do-not-use-in-prod');
|
|
1523
|
+
if (intents.has('database')) lines.push('DATABASE_URL=postgres://app:changeme@localhost:5432/app_dev');
|
|
1524
|
+
return lines.join('\n') + '\n';
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
function envProduction(port, intents) {
|
|
1528
|
+
const lines = ['NODE_ENV=production', `PORT=${port}`, 'LOG_LEVEL=warn'];
|
|
1529
|
+
if (intents.has('auth')) lines.push('JWT_SECRET=CHANGE_ME_TO_RANDOM_SECRET');
|
|
1530
|
+
if (intents.has('database')) lines.push('DATABASE_URL=postgres://user:pass@db-host:5432/app_prod');
|
|
1531
|
+
return lines.join('\n') + '\n';
|
|
1532
|
+
}
|
|
1533
|
+
|
|
994
1534
|
// ── Auth Route Templates ────────────────────────────────────
|
|
995
1535
|
|
|
996
1536
|
function authRoutesBlock(userEntity) {
|
|
@@ -1034,6 +1574,173 @@ function envLines(intents, params) {
|
|
|
1034
1574
|
return lines;
|
|
1035
1575
|
}
|
|
1036
1576
|
|
|
1577
|
+
// ── Test Runner (D2) ─────────────────────────────────────
|
|
1578
|
+
|
|
1579
|
+
function testRunnerFile(entities, intents, port) {
|
|
1580
|
+
const lines = [];
|
|
1581
|
+
lines.push(`import { describe, it } from 'node:test';`);
|
|
1582
|
+
lines.push(`import assert from 'node:assert/strict';`);
|
|
1583
|
+
lines.push('');
|
|
1584
|
+
lines.push(`const BASE = process.env.TEST_URL || 'http://localhost:${port}';`);
|
|
1585
|
+
lines.push(`const headers = {'Content-Type': 'application/json'};`);
|
|
1586
|
+
lines.push(`let authToken = null;`);
|
|
1587
|
+
lines.push('');
|
|
1588
|
+
lines.push(`async function api(path, opts = {}) {`);
|
|
1589
|
+
lines.push(` const h = {...headers};`);
|
|
1590
|
+
lines.push(` if (authToken) h.Authorization = 'Bearer ' + authToken;`);
|
|
1591
|
+
lines.push(` const r = await fetch(BASE + path, {...opts, headers: {...h, ...opts.headers}});`);
|
|
1592
|
+
lines.push(` const body = await r.json().catch(() => null);`);
|
|
1593
|
+
lines.push(` return {status: r.status, body, ok: r.ok};`);
|
|
1594
|
+
lines.push(`}`);
|
|
1595
|
+
lines.push('');
|
|
1596
|
+
lines.push(`describe('Health', () => {`);
|
|
1597
|
+
lines.push(` it('GET /api/health returns ok', async () => {`);
|
|
1598
|
+
lines.push(` const r = await api('/api/health');`);
|
|
1599
|
+
lines.push(` assert.equal(r.status, 200);`);
|
|
1600
|
+
lines.push(` assert.equal(r.body.status, 'ok');`);
|
|
1601
|
+
lines.push(` });`);
|
|
1602
|
+
lines.push(`});`);
|
|
1603
|
+
lines.push('');
|
|
1604
|
+
if (intents.has('auth')) {
|
|
1605
|
+
lines.push(`describe('Auth', () => {`);
|
|
1606
|
+
lines.push(` const email = 'test' + Date.now() + '@test.com';`);
|
|
1607
|
+
lines.push(` it('POST /api/auth/register creates user', async () => {`);
|
|
1608
|
+
lines.push(` const r = await api('/api/auth/register', {method: 'POST', body: JSON.stringify({name: 'Test', email, password: 'test1234'})});`);
|
|
1609
|
+
lines.push(` assert.equal(r.status, 200);`);
|
|
1610
|
+
lines.push(` assert.ok(r.body.token);`);
|
|
1611
|
+
lines.push(` authToken = r.body.token;`);
|
|
1612
|
+
lines.push(` });`);
|
|
1613
|
+
lines.push(` it('POST /api/auth/login returns token', async () => {`);
|
|
1614
|
+
lines.push(` const r = await api('/api/auth/login', {method: 'POST', body: JSON.stringify({email, password: 'test1234'})});`);
|
|
1615
|
+
lines.push(` assert.equal(r.status, 200);`);
|
|
1616
|
+
lines.push(` assert.ok(r.body.token);`);
|
|
1617
|
+
lines.push(` authToken = r.body.token;`);
|
|
1618
|
+
lines.push(` });`);
|
|
1619
|
+
lines.push(` it('GET /api/auth/me returns current user', async () => {`);
|
|
1620
|
+
lines.push(` const r = await api('/api/auth/me');`);
|
|
1621
|
+
lines.push(` assert.equal(r.status, 200);`);
|
|
1622
|
+
lines.push(` assert.equal(r.body.email || r.body.data?.email, email);`);
|
|
1623
|
+
lines.push(` });`);
|
|
1624
|
+
lines.push(` it('rejects invalid login', async () => {`);
|
|
1625
|
+
lines.push(` const r = await api('/api/auth/login', {method: 'POST', body: JSON.stringify({email: 'no@no.com', password: 'wrong'})});`);
|
|
1626
|
+
lines.push(` assert.equal(r.status, 401);`);
|
|
1627
|
+
lines.push(` });`);
|
|
1628
|
+
lines.push(`});`);
|
|
1629
|
+
lines.push('');
|
|
1630
|
+
}
|
|
1631
|
+
for (const entity of entities) {
|
|
1632
|
+
const lower = entity.name.toLowerCase();
|
|
1633
|
+
const plural = pluralize(lower);
|
|
1634
|
+
const requiredFields = entity.fields.filter(f => f.modifiers.includes('required') && f.type !== 'auto');
|
|
1635
|
+
const sampleData = {};
|
|
1636
|
+
for (const f of requiredFields) {
|
|
1637
|
+
if (f.type === 'int') sampleData[f.name] = 1;
|
|
1638
|
+
else if (f.type === 'bool') sampleData[f.name] = true;
|
|
1639
|
+
else sampleData[f.name] = 'test_' + f.name;
|
|
1640
|
+
}
|
|
1641
|
+
if (Object.keys(sampleData).length === 0) {
|
|
1642
|
+
const first = entity.fields.find(f => f.type !== 'auto' && f.type !== 'bool');
|
|
1643
|
+
if (first) sampleData[first.name] = first.type === 'int' ? 1 : 'test';
|
|
1644
|
+
}
|
|
1645
|
+
lines.push(`describe('${entity.name} CRUD', () => {`);
|
|
1646
|
+
lines.push(` let createdId;`);
|
|
1647
|
+
lines.push(` it('POST /api/${plural} creates', async () => {`);
|
|
1648
|
+
lines.push(` const r = await api('/api/${plural}', {method: 'POST', body: JSON.stringify(${JSON.stringify(sampleData)})});`);
|
|
1649
|
+
lines.push(` assert.ok(r.ok, 'status ' + r.status);`);
|
|
1650
|
+
lines.push(` createdId = r.body.id || r.body.data?.id;`);
|
|
1651
|
+
lines.push(` assert.ok(createdId, 'has id');`);
|
|
1652
|
+
lines.push(` });`);
|
|
1653
|
+
lines.push(` it('GET /api/${plural} lists', async () => {`);
|
|
1654
|
+
lines.push(` const r = await api('/api/${plural}');`);
|
|
1655
|
+
lines.push(` assert.equal(r.status, 200);`);
|
|
1656
|
+
lines.push(` const items = r.body.items || r.body;`);
|
|
1657
|
+
lines.push(` assert.ok(Array.isArray(items));`);
|
|
1658
|
+
lines.push(` });`);
|
|
1659
|
+
lines.push(` it('GET /api/${plural}/:id finds', async () => {`);
|
|
1660
|
+
lines.push(` const r = await api('/api/${plural}/' + createdId);`);
|
|
1661
|
+
lines.push(` assert.equal(r.status, 200);`);
|
|
1662
|
+
lines.push(` });`);
|
|
1663
|
+
lines.push(` it('PUT /api/${plural}/:id updates', async () => {`);
|
|
1664
|
+
lines.push(` const r = await api('/api/${plural}/' + createdId, {method: 'PUT', body: JSON.stringify(${JSON.stringify(sampleData)})});`);
|
|
1665
|
+
lines.push(` assert.ok(r.ok);`);
|
|
1666
|
+
lines.push(` });`);
|
|
1667
|
+
lines.push(` it('DELETE /api/${plural}/:id deletes', async () => {`);
|
|
1668
|
+
lines.push(` const r = await api('/api/${plural}/' + createdId, {method: 'DELETE'});`);
|
|
1669
|
+
lines.push(` assert.ok(r.ok);`);
|
|
1670
|
+
lines.push(` });`);
|
|
1671
|
+
lines.push(`});`);
|
|
1672
|
+
lines.push('');
|
|
1673
|
+
}
|
|
1674
|
+
return lines.join('\n');
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
// ── DB Migration (D3) ───────────────────────────────────────
|
|
1678
|
+
|
|
1679
|
+
function migrationFile(entities, relationships, m2mRels) {
|
|
1680
|
+
const lines = [];
|
|
1681
|
+
const ts = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
|
1682
|
+
lines.push(`# Migration: ${ts}`);
|
|
1683
|
+
lines.push(`# Auto-generated by NAIDE ~~ engine`);
|
|
1684
|
+
lines.push(`# Run: naide migrate.naide`);
|
|
1685
|
+
lines.push('');
|
|
1686
|
+
lines.push('use "node:fs"');
|
|
1687
|
+
lines.push('');
|
|
1688
|
+
lines.push(`fn migrate():`);
|
|
1689
|
+
for (const entity of entities) {
|
|
1690
|
+
const lower = entity.name.toLowerCase();
|
|
1691
|
+
const plural = pluralize(lower);
|
|
1692
|
+
lines.push(` log("Creating table: ${plural}")`);
|
|
1693
|
+
const cols = entity.fields.map(f => {
|
|
1694
|
+
let col = f.name;
|
|
1695
|
+
if (f.type === 'auto') col += ' INTEGER PRIMARY KEY AUTOINCREMENT';
|
|
1696
|
+
else if (f.type === 'int') col += ' INTEGER';
|
|
1697
|
+
else if (f.type === 'bool') col += ' BOOLEAN DEFAULT false';
|
|
1698
|
+
else col += ' TEXT';
|
|
1699
|
+
if (f.modifiers.includes('required') && f.type !== 'auto') col += ' NOT NULL';
|
|
1700
|
+
if (f.modifiers.includes('unique')) col += ' UNIQUE';
|
|
1701
|
+
return col;
|
|
1702
|
+
});
|
|
1703
|
+
lines.push(` str sql_${lower} = "CREATE TABLE IF NOT EXISTS ${plural} (${cols.join(', ')}"`);
|
|
1704
|
+
const fks = relationships.filter(r => r.child === entity.name);
|
|
1705
|
+
if (fks.length > 0) {
|
|
1706
|
+
for (const fk of fks) {
|
|
1707
|
+
const parentPlural = pluralize(fk.parent.toLowerCase());
|
|
1708
|
+
lines.push(` sql_${lower} = sql_${lower} + ", FOREIGN KEY (${fk.fk}) REFERENCES ${parentPlural}(id)"`);
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
lines.push(` sql_${lower} = sql_${lower} + ")"`);
|
|
1712
|
+
lines.push(` log(sql_${lower})`);
|
|
1713
|
+
lines.push('');
|
|
1714
|
+
}
|
|
1715
|
+
for (const rel of m2mRels) {
|
|
1716
|
+
const jLower = rel.junction.toLowerCase();
|
|
1717
|
+
lines.push(` log("Creating junction table: ${jLower}")`);
|
|
1718
|
+
const lp = pluralize(rel.left.toLowerCase());
|
|
1719
|
+
const rp = pluralize(rel.right.toLowerCase());
|
|
1720
|
+
lines.push(` str sql_${jLower} = "CREATE TABLE IF NOT EXISTS ${jLower} (id INTEGER PRIMARY KEY AUTOINCREMENT, ${rel.fkLeft} INTEGER NOT NULL, ${rel.fkRight} INTEGER NOT NULL, created TEXT DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (${rel.fkLeft}) REFERENCES ${lp}(id), FOREIGN KEY (${rel.fkRight}) REFERENCES ${rp}(id), UNIQUE(${rel.fkLeft}, ${rel.fkRight}))"`);
|
|
1721
|
+
lines.push(` log(sql_${jLower})`);
|
|
1722
|
+
lines.push('');
|
|
1723
|
+
}
|
|
1724
|
+
lines.push(' log("Migration complete")');
|
|
1725
|
+
lines.push('');
|
|
1726
|
+
lines.push(`fn rollback():`);
|
|
1727
|
+
for (const entity of [...entities].reverse()) {
|
|
1728
|
+
const plural = pluralize(entity.name.toLowerCase());
|
|
1729
|
+
lines.push(` log("Dropping table: ${plural}")`);
|
|
1730
|
+
lines.push(` str drop_${entity.name.toLowerCase()} = "DROP TABLE IF EXISTS ${plural}"`);
|
|
1731
|
+
lines.push(` log(drop_${entity.name.toLowerCase()})`);
|
|
1732
|
+
}
|
|
1733
|
+
for (const rel of m2mRels) {
|
|
1734
|
+
const jLower = rel.junction.toLowerCase();
|
|
1735
|
+
lines.push(` str drop_${jLower} = "DROP TABLE IF EXISTS ${jLower}"`);
|
|
1736
|
+
lines.push(` log(drop_${jLower})`);
|
|
1737
|
+
}
|
|
1738
|
+
lines.push(' log("Rollback complete")');
|
|
1739
|
+
lines.push('');
|
|
1740
|
+
lines.push('migrate()');
|
|
1741
|
+
return lines.join('\n') + '\n';
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1037
1744
|
// ── Code Composer ───────────────────────────────────────────
|
|
1038
1745
|
|
|
1039
1746
|
function compose(intents, params) {
|
|
@@ -1058,6 +1765,24 @@ function compose(intents, params) {
|
|
|
1058
1765
|
const utils = utilityFunctions(intents);
|
|
1059
1766
|
if (utils.length > 0) sections.push(...utils);
|
|
1060
1767
|
|
|
1768
|
+
// Rate limiting & logging middleware (B12, C17)
|
|
1769
|
+
if (intents.has('server')) {
|
|
1770
|
+
sections.push(rateLimitBlock());
|
|
1771
|
+
sections.push(loggingBlock());
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
// RBAC middleware (D1)
|
|
1775
|
+
if (hasAuth) {
|
|
1776
|
+
sections.push(rbacBlock());
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
// Input validation functions (A4)
|
|
1780
|
+
if (intents.has('schema') && intents.has('crud')) {
|
|
1781
|
+
for (const entity of entities) {
|
|
1782
|
+
sections.push(validationBlock(entity));
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1061
1786
|
// Schema blocks for all entities (add role + deleted fields)
|
|
1062
1787
|
if (intents.has('schema')) {
|
|
1063
1788
|
for (const entity of entities) {
|
|
@@ -1071,6 +1796,12 @@ function compose(intents, params) {
|
|
|
1071
1796
|
sections.push(schemaBlock(entity.name, entity.fields));
|
|
1072
1797
|
}
|
|
1073
1798
|
}
|
|
1799
|
+
// M:N junction table schemas (S3)
|
|
1800
|
+
const m2mRels = detectManyToMany(entities.map(e => e.name));
|
|
1801
|
+
for (const rel of m2mRels) {
|
|
1802
|
+
sections.push(junctionSchemaBlock(rel));
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1074
1805
|
// Audit log schema when auth is present
|
|
1075
1806
|
if (hasAuth) {
|
|
1076
1807
|
sections.push(auditSchema());
|
|
@@ -1099,6 +1830,8 @@ function compose(intents, params) {
|
|
|
1099
1830
|
body.push(' protect "/api/*"');
|
|
1100
1831
|
body.push(' public "/api/auth/*"');
|
|
1101
1832
|
body.push(' public "/api/health"');
|
|
1833
|
+
body.push(' public "/docs"');
|
|
1834
|
+
body.push(' public "/api/openapi.json"');
|
|
1102
1835
|
if (userEntity) {
|
|
1103
1836
|
body.push('');
|
|
1104
1837
|
body.push(...authRoutesBlock(userEntity.name));
|
|
@@ -1114,6 +1847,12 @@ function compose(intents, params) {
|
|
|
1114
1847
|
body.push(' ret {status: "ok"}');
|
|
1115
1848
|
}
|
|
1116
1849
|
}
|
|
1850
|
+
|
|
1851
|
+
// Password reset routes (A6)
|
|
1852
|
+
if (userEntity) {
|
|
1853
|
+
body.push('');
|
|
1854
|
+
body.push(...passwordResetRoutes());
|
|
1855
|
+
}
|
|
1117
1856
|
}
|
|
1118
1857
|
|
|
1119
1858
|
// Paginated list endpoints (override default CRUD list)
|
|
@@ -1125,21 +1864,30 @@ function compose(intents, params) {
|
|
|
1125
1864
|
}
|
|
1126
1865
|
}
|
|
1127
1866
|
|
|
1867
|
+
// Validated create/update routes (A4)
|
|
1868
|
+
if (intents.has('crud') && intents.has('schema')) {
|
|
1869
|
+
for (const entity of entities) {
|
|
1870
|
+
body.push(...validatedCreateRoute(entity));
|
|
1871
|
+
body.push('');
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1128
1875
|
// Nested routes for relationships
|
|
1129
1876
|
for (const rel of relationships) {
|
|
1130
1877
|
body.push(...nestedRouteBlock(rel.parent, rel.child, rel.fk));
|
|
1131
1878
|
body.push('');
|
|
1132
1879
|
}
|
|
1133
1880
|
|
|
1134
|
-
//
|
|
1881
|
+
// M:N routes (S3)
|
|
1882
|
+
const m2mRels = detectManyToMany(entities.map(e => e.name));
|
|
1883
|
+
for (const rel of m2mRels) {
|
|
1884
|
+
body.push(...manyToManyRoutes(rel));
|
|
1885
|
+
body.push('');
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
// WebSocket with rooms + auth (B11, D4)
|
|
1135
1889
|
if (intents.has('websocket')) {
|
|
1136
|
-
body.push(
|
|
1137
|
-
body.push(' on "connection" (socket):');
|
|
1138
|
-
body.push(' log("Client connected")');
|
|
1139
|
-
body.push(' on "message" (data, socket):');
|
|
1140
|
-
body.push(' socket.send(data)');
|
|
1141
|
-
body.push(' on "close" (socket):');
|
|
1142
|
-
body.push(' log("Client disconnected")');
|
|
1890
|
+
body.push(...wsRoomsBlock(hasAuth));
|
|
1143
1891
|
body.push('');
|
|
1144
1892
|
}
|
|
1145
1893
|
|
|
@@ -1163,6 +1911,14 @@ function compose(intents, params) {
|
|
|
1163
1911
|
}
|
|
1164
1912
|
}
|
|
1165
1913
|
|
|
1914
|
+
// RBAC role-guarded delete routes (D1)
|
|
1915
|
+
if (hasAuth && intents.has('crud') && intents.has('schema')) {
|
|
1916
|
+
for (const entity of entities) {
|
|
1917
|
+
body.push(...rbacRouteGuards(entity));
|
|
1918
|
+
body.push('');
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1166
1922
|
// File upload
|
|
1167
1923
|
if (intents.has('crud')) {
|
|
1168
1924
|
body.push(...uploadRoute());
|
|
@@ -1175,6 +1931,14 @@ function compose(intents, params) {
|
|
|
1175
1931
|
body.push('');
|
|
1176
1932
|
}
|
|
1177
1933
|
|
|
1934
|
+
// Swagger UI (A5)
|
|
1935
|
+
body.push(...swaggerUIRoute());
|
|
1936
|
+
body.push('');
|
|
1937
|
+
|
|
1938
|
+
// Cache control (C18)
|
|
1939
|
+
body.push(...cachingRoute());
|
|
1940
|
+
body.push('');
|
|
1941
|
+
|
|
1178
1942
|
// Health check
|
|
1179
1943
|
body.push('get "/api/health" (req, res):');
|
|
1180
1944
|
if (intents.has('schema') && intents.has('crud') && entities.length > 0) {
|
|
@@ -1355,8 +2119,10 @@ export function generateProject(instruction, options = {}) {
|
|
|
1355
2119
|
dev: 'naide app.naide',
|
|
1356
2120
|
build: 'naide app.naide --emit -o dist/app.mjs',
|
|
1357
2121
|
start: 'node dist/app.mjs',
|
|
1358
|
-
test: '
|
|
2122
|
+
test: 'node --test test.mjs',
|
|
2123
|
+
'test:unit': 'naide test app.naide',
|
|
1359
2124
|
seed: 'naide app.naide --run-seed',
|
|
2125
|
+
migrate: 'naide migrate.naide',
|
|
1360
2126
|
},
|
|
1361
2127
|
dependencies: deps,
|
|
1362
2128
|
};
|
|
@@ -1416,40 +2182,33 @@ CMD ["node", "dist/app.mjs"]
|
|
|
1416
2182
|
if (intents.has('server')) {
|
|
1417
2183
|
readmeLines.push(`## API Endpoints\n`);
|
|
1418
2184
|
readmeLines.push(`- \`GET /api/health\` — Health check`);
|
|
2185
|
+
readmeLines.push(`- \`GET /docs\` — Swagger UI`);
|
|
1419
2186
|
for (const e of entities) {
|
|
1420
2187
|
const ep = pluralize(e.toLowerCase());
|
|
1421
|
-
readmeLines.push(`- \`GET /api/${ep}?page=1&limit=20&search
|
|
1422
|
-
readmeLines.push(`- \`POST /api/${ep}\` — Create
|
|
1423
|
-
readmeLines.push(`- \`GET /api/${ep}/:id\` — Get
|
|
1424
|
-
readmeLines.push(`- \`
|
|
1425
|
-
readmeLines.push(`- \`
|
|
1426
|
-
readmeLines.push(`- \`DELETE /api/${ep}/:id/soft\` — Soft delete ${e.toLowerCase()}`);
|
|
1427
|
-
readmeLines.push(`- \`PUT /api/${ep}/:id/restore\` — Restore ${e.toLowerCase()}`);
|
|
1428
|
-
readmeLines.push(`- \`POST /api/${ep}/batch\` — Batch create ${ep}`);
|
|
1429
|
-
readmeLines.push(`- \`DELETE /api/${ep}/batch\` — Batch delete ${ep}`);
|
|
2188
|
+
readmeLines.push(`- \`GET /api/${ep}?page=1&limit=20&search=&sort=id&order=desc\` — List (paginated, sortable, filterable)`);
|
|
2189
|
+
readmeLines.push(`- \`POST /api/${ep}\` — Create (validated)`, `- \`PUT /api/${ep}/:id\` — Update (validated)`);
|
|
2190
|
+
readmeLines.push(`- \`GET /api/${ep}/:id\` — Get`, `- \`DELETE /api/${ep}/:id\` — Delete`);
|
|
2191
|
+
readmeLines.push(`- \`DELETE /api/${ep}/:id/soft\` — Soft delete`, `- \`PUT /api/${ep}/:id/restore\` — Restore`);
|
|
2192
|
+
readmeLines.push(`- \`POST /api/${ep}/batch\` — Batch create`, `- \`DELETE /api/${ep}/batch\` — Batch delete`);
|
|
1430
2193
|
}
|
|
1431
2194
|
for (const r of result.analysis.relationships) {
|
|
1432
|
-
|
|
1433
|
-
const childPlural = pluralize(r.child.toLowerCase());
|
|
1434
|
-
readmeLines.push(`- \`GET /api/${parentPlural}/:id/${childPlural}\` — ${r.child}s by ${r.parent}`);
|
|
2195
|
+
readmeLines.push(`- \`GET /api/${pluralize(r.parent.toLowerCase())}/:id/${pluralize(r.child.toLowerCase())}\` — ${r.child}s by ${r.parent}`);
|
|
1435
2196
|
}
|
|
1436
|
-
readmeLines.push(`- \`POST /api/upload\` — File upload`);
|
|
1437
|
-
readmeLines.push(`- \`POST /api/webhooks\` — Register webhook`);
|
|
1438
|
-
readmeLines.push(`- \`GET /api/webhooks/health\` — Webhook health`);
|
|
2197
|
+
readmeLines.push(`- \`POST /api/upload\` — File upload`, `- \`POST /api/webhooks\` — Webhook`);
|
|
1439
2198
|
if (intents.has('auth')) {
|
|
1440
|
-
readmeLines.push(`- \`POST /api/auth/register\` — Register`);
|
|
1441
|
-
readmeLines.push(`- \`POST /api/auth/
|
|
1442
|
-
readmeLines.push(`- \`
|
|
1443
|
-
readmeLines.push(`- \`GET /api/admin/stats\` — Admin stats (role: admin)`);
|
|
2199
|
+
readmeLines.push(`- \`POST /api/auth/register\` — Register`, `- \`POST /api/auth/login\` — Login`, `- \`GET /api/auth/me\` — Current user`);
|
|
2200
|
+
readmeLines.push(`- \`POST /api/auth/forgot-password\` — Forgot password`, `- \`POST /api/auth/reset-password\` — Reset password`);
|
|
2201
|
+
readmeLines.push(`- \`POST /api/auth/change-password\` — Change password`, `- \`GET /api/admin/stats\` — Admin stats`);
|
|
1444
2202
|
}
|
|
1445
2203
|
}
|
|
1446
2204
|
if (intents.has('server')) {
|
|
1447
|
-
readmeLines.push(`\n## Docker\n`);
|
|
1448
|
-
readmeLines.push('```bash');
|
|
1449
|
-
readmeLines.push(`docker build -t ${projectName} .`);
|
|
1450
|
-
readmeLines.push(`docker run -p ${result.port}:${result.port} --env-file .env ${projectName}`);
|
|
1451
|
-
readmeLines.push('```');
|
|
2205
|
+
readmeLines.push(`\n## Docker\n`, '```bash', `docker-compose up -d`, '```');
|
|
1452
2206
|
}
|
|
2207
|
+
readmeLines.push(`\n## Generated Files\n`);
|
|
2208
|
+
readmeLines.push('| File | Purpose |', '|------|---------|');
|
|
2209
|
+
readmeLines.push('| app.naide | Main application |', '| index.html | Frontend SPA |', '| admin.html | Admin dashboard |');
|
|
2210
|
+
readmeLines.push('| client.mjs | API client library |', '| openapi.json | OpenAPI 3.0 spec |');
|
|
2211
|
+
readmeLines.push('| docker-compose.yml | Docker orchestration |', '| .github/workflows/ci.yml | CI/CD pipeline |');
|
|
1453
2212
|
files.push({ path: 'README.md', content: readmeLines.join('\n') + '\n' });
|
|
1454
2213
|
|
|
1455
2214
|
// Build full entity objects for generators
|
|
@@ -1476,6 +2235,34 @@ CMD ["node", "dist/app.mjs"]
|
|
|
1476
2235
|
files.push({ path: 'client.mjs', content: apiClientCode(entityObjs, intents, result.port) });
|
|
1477
2236
|
}
|
|
1478
2237
|
|
|
2238
|
+
// Frontend SPA (S2)
|
|
2239
|
+
if (intents.has('server') && entityObjs.length > 0) {
|
|
2240
|
+
files.push({ path: 'index.html', content: frontendSPA(entityObjs, intents, result.port) });
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
// Docker Compose (B14)
|
|
2244
|
+
if (intents.has('server')) {
|
|
2245
|
+
files.push({ path: 'docker-compose.yml', content: dockerCompose(projectName, result.port, intents) });
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
// Environment files (C15)
|
|
2249
|
+
files.push({ path: '.env.development', content: envDevelopment(result.port, intents) });
|
|
2250
|
+
files.push({ path: '.env.production', content: envProduction(result.port, intents) });
|
|
2251
|
+
|
|
2252
|
+
// GitHub Actions CI (C16)
|
|
2253
|
+
files.push({ path: '.github/workflows/ci.yml', content: githubActionsCI(projectName) });
|
|
2254
|
+
|
|
2255
|
+
// Test runner (D2)
|
|
2256
|
+
if (intents.has('server') && entityObjs.length > 0) {
|
|
2257
|
+
files.push({ path: 'test.mjs', content: testRunnerFile(entityObjs, intents, result.port) });
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
// DB migration (D3)
|
|
2261
|
+
if (intents.has('database') && entityObjs.length > 0) {
|
|
2262
|
+
const m2mRels = detectManyToMany(entities.map(e => e));
|
|
2263
|
+
files.push({ path: 'migrate.naide', content: migrationFile(entityObjs, result.relationships || [], m2mRels) });
|
|
2264
|
+
}
|
|
2265
|
+
|
|
1479
2266
|
return { ...result, files };
|
|
1480
2267
|
}
|
|
1481
2268
|
|
|
@@ -1487,8 +2274,34 @@ export function updateProject(projectDir, instruction, options = {}) {
|
|
|
1487
2274
|
|
|
1488
2275
|
const existingState = existing ? parseExistingProject(existing) : { entities: [], intents: [], port: 3000 };
|
|
1489
2276
|
const newResult = generate(instruction, options);
|
|
1490
|
-
const newEntities = (newResult.entities || []).filter(e => !existingState.entities.includes(e));
|
|
1491
2277
|
|
|
2278
|
+
// Entity removal detection (B8)
|
|
2279
|
+
const isRemoval = /(?:remove|delete|drop|取り除|削除)\s+/i.test(instruction);
|
|
2280
|
+
let removedEntities = [];
|
|
2281
|
+
if (isRemoval) {
|
|
2282
|
+
const removeTargets = (newResult.entities || []);
|
|
2283
|
+
removedEntities = existingState.entities.filter(e => removeTargets.includes(e));
|
|
2284
|
+
const allEntities = existingState.entities.filter(e => !removeTargets.includes(e));
|
|
2285
|
+
if (allEntities.length === 0) {
|
|
2286
|
+
return { code: '', valid: true, fixed: false, repairs: [], intents: [], entities: [], relationships: [], port: existingState.port,
|
|
2287
|
+
files: [{ path: 'app.naide', content: '' }], analysis: { instruction, entities: [], relationships: [], plan: [`Removed: ${removedEntities.join(', ')}`], added: [] },
|
|
2288
|
+
action: 'removed', removed: removedEntities };
|
|
2289
|
+
}
|
|
2290
|
+
const combinedIntents = existingState.intents.filter(i => i !== 'schema' || allEntities.length > 0);
|
|
2291
|
+
const extras = [];
|
|
2292
|
+
if (combinedIntents.includes('auth')) extras.push('auth');
|
|
2293
|
+
if (combinedIntents.includes('websocket')) extras.push('websocket');
|
|
2294
|
+
if (combinedIntents.includes('database')) extras.push('database');
|
|
2295
|
+
const fullInstruction = 'REST API for ' + allEntities.join(' and ') + (extras.length ? ' with ' + extras.join(' and ') : '');
|
|
2296
|
+
const combined = generate(fullInstruction, options);
|
|
2297
|
+
const result = buildUpdateFiles(projectDir, combined, allEntities, existingState, options);
|
|
2298
|
+
result.action = 'removed';
|
|
2299
|
+
result.removed = removedEntities;
|
|
2300
|
+
result.analysis.plan.unshift(`Removed: ${removedEntities.join(', ')}`);
|
|
2301
|
+
return result;
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
const newEntities = (newResult.entities || []).filter(e => !existingState.entities.includes(e));
|
|
1492
2305
|
const allEntities = [...existingState.entities, ...newEntities];
|
|
1493
2306
|
const combinedIntents = [...new Set([...existingState.intents, ...newResult.intents])];
|
|
1494
2307
|
const extras = [];
|
|
@@ -1498,8 +2311,12 @@ export function updateProject(projectDir, instruction, options = {}) {
|
|
|
1498
2311
|
const fullInstruction = 'REST API for ' + allEntities.join(' and ') + (extras.length ? ' with ' + extras.join(' and ') : '');
|
|
1499
2312
|
|
|
1500
2313
|
const combined = generate(fullInstruction, options);
|
|
1501
|
-
|
|
2314
|
+
return buildUpdateFiles(projectDir, combined, allEntities, existingState, options);
|
|
2315
|
+
}
|
|
1502
2316
|
|
|
2317
|
+
function buildUpdateFiles(projectDir, combined, allEntities, existingState, options) {
|
|
2318
|
+
const existing = _existsFS(projectDir + '/app.naide') ? _readFS(projectDir + '/app.naide', 'utf-8') : '';
|
|
2319
|
+
const finalCode = combined.code;
|
|
1503
2320
|
const files = [];
|
|
1504
2321
|
files.push({ path: 'app.naide', content: finalCode });
|
|
1505
2322
|
|
|
@@ -1514,10 +2331,7 @@ export function updateProject(projectDir, instruction, options = {}) {
|
|
|
1514
2331
|
const deps = {};
|
|
1515
2332
|
if (intents.has('server')) deps.express = '^4.21.0';
|
|
1516
2333
|
if (intents.has('websocket')) deps.ws = '^8.18.0';
|
|
1517
|
-
if (intents.has('auth')) {
|
|
1518
|
-
deps.jsonwebtoken = '^9.0.2';
|
|
1519
|
-
deps.bcryptjs = '^2.4.3';
|
|
1520
|
-
}
|
|
2334
|
+
if (intents.has('auth')) { deps.jsonwebtoken = '^9.0.2'; deps.bcryptjs = '^2.4.3'; }
|
|
1521
2335
|
if (intents.has('mail')) deps.nodemailer = '^6.9.0';
|
|
1522
2336
|
if (entities.length > 0) deps.naider = '^1.21.0';
|
|
1523
2337
|
|
|
@@ -1536,8 +2350,7 @@ export function updateProject(projectDir, instruction, options = {}) {
|
|
|
1536
2350
|
}
|
|
1537
2351
|
files.push({ path: 'package.json', content: JSON.stringify(pkg, null, 2) + '\n' });
|
|
1538
2352
|
|
|
1539
|
-
const envL = [];
|
|
1540
|
-
envL.push(`PORT=${port}`);
|
|
2353
|
+
const envL = [`PORT=${port}`];
|
|
1541
2354
|
if (intents.has('auth')) envL.push('JWT_SECRET=change-me-in-production');
|
|
1542
2355
|
if (intents.has('bot')) envL.push(`${(options.platform || 'DISCORD').toUpperCase()}_TOKEN=your-bot-token`);
|
|
1543
2356
|
if (intents.has('mail')) { envL.push('SMTP_HOST=smtp.example.com'); envL.push('SMTP_USER=user@example.com'); envL.push('SMTP_PASS=password'); }
|
|
@@ -1547,9 +2360,7 @@ export function updateProject(projectDir, instruction, options = {}) {
|
|
|
1547
2360
|
const existingEnv = _readFS(existingEnvPath, 'utf-8');
|
|
1548
2361
|
const existingKeys = new Set(existingEnv.split('\n').map(l => l.split('=')[0]).filter(Boolean));
|
|
1549
2362
|
const newEnvLines = envL.filter(l => !existingKeys.has(l.split('=')[0]));
|
|
1550
|
-
if (newEnvLines.length > 0) {
|
|
1551
|
-
files.push({ path: '.env', content: existingEnv.trimEnd() + '\n' + newEnvLines.join('\n') + '\n' });
|
|
1552
|
-
}
|
|
2363
|
+
if (newEnvLines.length > 0) files.push({ path: '.env', content: existingEnv.trimEnd() + '\n' + newEnvLines.join('\n') + '\n' });
|
|
1553
2364
|
} else {
|
|
1554
2365
|
files.push({ path: '.env', content: envL.join('\n') + '\n' });
|
|
1555
2366
|
}
|
|
@@ -1559,56 +2370,48 @@ export function updateProject(projectDir, instruction, options = {}) {
|
|
|
1559
2370
|
if (intents.has('server')) {
|
|
1560
2371
|
files.push({ path: 'Dockerfile', content: `FROM node:22-slim\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci --omit=dev\nRUN npm install -g naider\nCOPY . .\nRUN naide app.naide --emit -o dist/app.mjs\nEXPOSE ${port}\nCMD ["node", "dist/app.mjs"]\n` });
|
|
1561
2372
|
files.push({ path: '.dockerignore', content: 'node_modules\ndist\ndata\n.env\n.git\n' });
|
|
2373
|
+
files.push({ path: 'docker-compose.yml', content: dockerCompose(projectName, port, intents) });
|
|
1562
2374
|
}
|
|
1563
2375
|
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
2376
|
+
files.push({ path: '.env.development', content: envDevelopment(port, intents) });
|
|
2377
|
+
files.push({ path: '.env.production', content: envProduction(port, intents) });
|
|
2378
|
+
files.push({ path: '.github/workflows/ci.yml', content: githubActionsCI(projectName) });
|
|
2379
|
+
|
|
2380
|
+
const readmeLines = [`# ${projectName}\n`, `Generated by NAIDE Agent Coder (~~)\n`, `## Entities\n`];
|
|
1567
2381
|
for (const e of entities) {
|
|
1568
|
-
const
|
|
1569
|
-
const fc = preset ? preset.length : 3;
|
|
2382
|
+
const fc = SCHEMA_PRESETS[e.toLowerCase()]?.length || 3;
|
|
1570
2383
|
readmeLines.push(`- **${e}** (${fc} fields)`);
|
|
1571
2384
|
}
|
|
1572
2385
|
if (relationships.length > 0) {
|
|
1573
2386
|
readmeLines.push(`\n## Relationships\n`);
|
|
1574
2387
|
for (const r of relationships) readmeLines.push(`- ${r.parent} → ${r.child} (${r.fk})`);
|
|
1575
2388
|
}
|
|
1576
|
-
readmeLines.push(`\n## Quick Start\n
|
|
1577
|
-
readmeLines.push('```bash\nnpm install\nnpm run dev\n```\n');
|
|
2389
|
+
readmeLines.push(`\n## Quick Start\n`, '```bash', 'npm install', 'npm run dev', '```\n');
|
|
1578
2390
|
if (intents.has('server')) {
|
|
1579
|
-
readmeLines.push(`## API Endpoints\n`);
|
|
1580
|
-
readmeLines.push(`- \`GET /api/health\` — Health check`);
|
|
2391
|
+
readmeLines.push(`## API Endpoints\n`, `- \`GET /api/health\` — Health check`, `- \`GET /docs\` — Swagger UI`);
|
|
1581
2392
|
for (const e of entities) {
|
|
1582
2393
|
const ep = pluralize(e.toLowerCase());
|
|
1583
|
-
readmeLines.push(`- \`GET /api/${ep}?page=1&limit=20&search
|
|
1584
|
-
readmeLines.push(`- \`POST /api/${ep}\` — Create
|
|
1585
|
-
readmeLines.push(`- \`GET /api/${ep}/:id\` — Get
|
|
1586
|
-
readmeLines.push(`- \`
|
|
1587
|
-
readmeLines.push(`- \`
|
|
1588
|
-
readmeLines.push(`- \`DELETE /api/${ep}/:id/soft\` — Soft delete`);
|
|
1589
|
-
readmeLines.push(`- \`PUT /api/${ep}/:id/restore\` — Restore`);
|
|
1590
|
-
readmeLines.push(`- \`POST /api/${ep}/batch\` — Batch create`);
|
|
1591
|
-
readmeLines.push(`- \`DELETE /api/${ep}/batch\` — Batch delete`);
|
|
1592
|
-
}
|
|
1593
|
-
for (const r of relationships) {
|
|
1594
|
-
readmeLines.push(`- \`GET /api/${pluralize(r.parent.toLowerCase())}/:id/${pluralize(r.child.toLowerCase())}\` — ${r.child}s by ${r.parent}`);
|
|
2394
|
+
readmeLines.push(`- \`GET /api/${ep}?page=1&limit=20&search=&sort=id&order=desc\` — List (paginated, sortable, filterable)`);
|
|
2395
|
+
readmeLines.push(`- \`POST /api/${ep}\` — Create (validated)`, `- \`PUT /api/${ep}/:id\` — Update (validated)`);
|
|
2396
|
+
readmeLines.push(`- \`GET /api/${ep}/:id\` — Get`, `- \`DELETE /api/${ep}/:id\` — Delete`);
|
|
2397
|
+
readmeLines.push(`- \`DELETE /api/${ep}/:id/soft\` — Soft delete`, `- \`PUT /api/${ep}/:id/restore\` — Restore`);
|
|
2398
|
+
readmeLines.push(`- \`POST /api/${ep}/batch\` — Batch create`, `- \`DELETE /api/${ep}/batch\` — Batch delete`);
|
|
1595
2399
|
}
|
|
1596
|
-
readmeLines.push(`- \`POST /api/upload\` — File upload`);
|
|
1597
|
-
readmeLines.push(`- \`POST /api/webhooks\` — Register webhook`);
|
|
2400
|
+
readmeLines.push(`- \`POST /api/upload\` — File upload`, `- \`POST /api/webhooks\` — Webhook`);
|
|
1598
2401
|
if (intents.has('auth')) {
|
|
1599
|
-
readmeLines.push(`- \`POST /api/auth/register\` — Register`);
|
|
1600
|
-
readmeLines.push(`- \`POST /api/auth/
|
|
1601
|
-
readmeLines.push(`- \`
|
|
1602
|
-
readmeLines.push(`- \`GET /api/admin/stats\` — Admin stats`);
|
|
2402
|
+
readmeLines.push(`- \`POST /api/auth/register\` — Register`, `- \`POST /api/auth/login\` — Login`, `- \`GET /api/auth/me\` — Current user`);
|
|
2403
|
+
readmeLines.push(`- \`POST /api/auth/forgot-password\` — Forgot password`, `- \`POST /api/auth/reset-password\` — Reset password`);
|
|
2404
|
+
readmeLines.push(`- \`POST /api/auth/change-password\` — Change password`, `- \`GET /api/admin/stats\` — Admin stats`);
|
|
1603
2405
|
}
|
|
1604
2406
|
}
|
|
1605
2407
|
if (intents.has('server')) {
|
|
1606
|
-
readmeLines.push(`\n## Docker\n`);
|
|
1607
|
-
readmeLines.push('```bash');
|
|
1608
|
-
readmeLines.push(`docker build -t ${projectName} .`);
|
|
1609
|
-
readmeLines.push(`docker run -p ${port}:${port} --env-file .env ${projectName}`);
|
|
1610
|
-
readmeLines.push('```');
|
|
2408
|
+
readmeLines.push(`\n## Docker\n`, '```bash', `docker-compose up -d`, '```');
|
|
1611
2409
|
}
|
|
2410
|
+
readmeLines.push(`\n## Generated Files\n`);
|
|
2411
|
+
readmeLines.push('| File | Purpose |', '|------|---------|');
|
|
2412
|
+
readmeLines.push('| app.naide | Main application |', '| index.html | Frontend SPA |', '| admin.html | Admin dashboard |');
|
|
2413
|
+
readmeLines.push('| client.mjs | API client library |', '| openapi.json | OpenAPI 3.0 spec |');
|
|
2414
|
+
readmeLines.push('| docker-compose.yml | Docker orchestration |', '| .github/workflows/ci.yml | CI/CD pipeline |');
|
|
1612
2415
|
files.push({ path: 'README.md', content: readmeLines.join('\n') + '\n' });
|
|
1613
2416
|
|
|
1614
2417
|
const entityObjs = entities.map(name => {
|
|
@@ -1621,25 +2424,20 @@ export function updateProject(projectDir, instruction, options = {}) {
|
|
|
1621
2424
|
if (intents.has('server') && entityObjs.length > 0) {
|
|
1622
2425
|
files.push({ path: 'openapi.json', content: JSON.stringify(openApiSpec(entityObjs, intents, { port }, relationships), null, 2) + '\n' });
|
|
1623
2426
|
files.push({ path: 'admin.html', content: adminPage(entityObjs, intents) });
|
|
2427
|
+
files.push({ path: 'index.html', content: frontendSPA(entityObjs, intents, port) });
|
|
1624
2428
|
}
|
|
1625
2429
|
if (intents.has('server')) {
|
|
1626
2430
|
files.push({ path: 'client.mjs', content: apiClientCode(entityObjs, intents, port) });
|
|
1627
2431
|
}
|
|
1628
2432
|
|
|
1629
|
-
const added = existing
|
|
1630
|
-
? entities.filter(e => !existing.includes(`schema ${e}`))
|
|
1631
|
-
: entities;
|
|
2433
|
+
const added = existing ? entities.filter(e => !existing.includes(`schema ${e}`)) : entities;
|
|
1632
2434
|
const analysis = {
|
|
1633
|
-
instruction,
|
|
1634
|
-
|
|
1635
|
-
relationships,
|
|
1636
|
-
plan: [],
|
|
1637
|
-
added,
|
|
2435
|
+
instruction: '', entities: entityObjs.map(e => ({ name: e.name, fieldCount: e.fields.length })),
|
|
2436
|
+
relationships, plan: [], added,
|
|
1638
2437
|
};
|
|
1639
2438
|
if (added.length > 0) analysis.plan.push(`Added: ${added.join(', ')}`);
|
|
1640
2439
|
if (existing) analysis.plan.push('Merged with existing app.naide');
|
|
1641
|
-
analysis.plan.push(`Total entities: ${entities.length}`);
|
|
1642
|
-
analysis.plan.push(`Supporting files regenerated`);
|
|
2440
|
+
analysis.plan.push(`Total entities: ${entities.length}`, `Supporting files regenerated (${files.length} files)`);
|
|
1643
2441
|
|
|
1644
2442
|
return {
|
|
1645
2443
|
code: finalCode, valid: combined.valid, fixed: combined.fixed, repairs: combined.repairs,
|
|
@@ -1793,17 +2591,22 @@ function buildAnalysis(instruction, intents, params) {
|
|
|
1793
2591
|
}
|
|
1794
2592
|
|
|
1795
2593
|
if (intents.has('database')) plan.push(`Database: ${params.dbType || 'file-based'}`);
|
|
1796
|
-
if (intents.has('server')) plan.push(`Server on port ${params.port}`);
|
|
1797
|
-
if (intents.has('crud')) plan.push(`CRUD endpoints for ${params.entities.map(e => pluralize(e.name.toLowerCase())).join(', ')} (paginated)`);
|
|
1798
|
-
if (intents.has('
|
|
2594
|
+
if (intents.has('server')) plan.push(`Server on port ${params.port} (rate-limited, request logging)`);
|
|
2595
|
+
if (intents.has('crud')) plan.push(`CRUD endpoints for ${params.entities.map(e => pluralize(e.name.toLowerCase())).join(', ')} (paginated, sortable, filterable)`);
|
|
2596
|
+
if (intents.has('crud')) plan.push(`Input validation: ${params.entities.map(e => `validate${e.name}()`).join(', ')}`);
|
|
2597
|
+
if (intents.has('auth')) plan.push('Auth: JWT + register/login/me + forgot/reset/change password + role-based admin');
|
|
1799
2598
|
if (params.relationships.length > 0) plan.push(`Nested routes: ${params.relationships.map(r => `/${pluralize(r.parent.toLowerCase())}/:id/${pluralize(r.child.toLowerCase())}`).join(', ')}`);
|
|
1800
|
-
|
|
2599
|
+
const m2m = detectManyToMany(params.entities.map(e => e.name));
|
|
2600
|
+
if (m2m.length > 0) plan.push(`M:N relations: ${m2m.map(r => `${r.left}↔${r.right} via ${r.junction}`).join(', ')}`);
|
|
2601
|
+
if (intents.has('auth')) plan.push('RBAC: requireRole() middleware for admin/moderator/user');
|
|
2602
|
+
if (intents.has('websocket'))plan.push('WebSocket: rooms + broadcast' + (intents.has('auth') ? ' (token auth)' : ''));
|
|
1801
2603
|
if (intents.has('ai')) plan.push('AI endpoint: /api/ask');
|
|
1802
2604
|
if (intents.has('bot')) plan.push(`Bot: ${params.platform || 'discord'}`);
|
|
1803
2605
|
if (intents.has('mail')) plan.push('Mail: SMTP configuration');
|
|
1804
2606
|
if (intents.has('graphql')) plan.push('GraphQL endpoint');
|
|
1805
2607
|
if (intents.has('page')) plan.push('Page: HTML generation');
|
|
1806
2608
|
if (intents.has('cli')) plan.push('CLI app with flags');
|
|
2609
|
+
if (intents.has('server')) plan.push('Swagger UI at /docs');
|
|
1807
2610
|
if (intents.has('database') && intents.has('schema')) plan.push('Seed: sample data function');
|
|
1808
2611
|
plan.push(`Tests: ${params.entities.length} entity CRUD test suites`);
|
|
1809
2612
|
|
|
@@ -1852,3 +2655,62 @@ export function generate(instruction, options = {}) {
|
|
|
1852
2655
|
analysis,
|
|
1853
2656
|
};
|
|
1854
2657
|
}
|
|
2658
|
+
|
|
2659
|
+
// ── Multi-Target Generation (D6) ─────────────────────────────
|
|
2660
|
+
|
|
2661
|
+
const TARGET_EXTENSIONS = {
|
|
2662
|
+
node: '.mjs', python: '.py', bun: '.mjs', typescript: '.ts',
|
|
2663
|
+
go: '.go', java: '.java', rust: '.rs', cpp: '.cpp', c: '.c',
|
|
2664
|
+
csharp: '.cs', kotlin: '.kt', swift: '.swift', dart: '.dart',
|
|
2665
|
+
php: '.php', ruby: '.rb',
|
|
2666
|
+
};
|
|
2667
|
+
|
|
2668
|
+
const ALL_TARGETS = Object.keys(TARGET_EXTENSIONS);
|
|
2669
|
+
|
|
2670
|
+
export async function generateForTarget(instruction, target, options = {}) {
|
|
2671
|
+
const result = generate(instruction, options);
|
|
2672
|
+
if (!result.valid) return { ...result, compiled: null, target };
|
|
2673
|
+
|
|
2674
|
+
let compiled;
|
|
2675
|
+
try {
|
|
2676
|
+
const { compile, compileAsync } = await import('./index.js');
|
|
2677
|
+
if (target === 'node') {
|
|
2678
|
+
const r = compile(result.code);
|
|
2679
|
+
compiled = r.js;
|
|
2680
|
+
} else {
|
|
2681
|
+
const r = await compileAsync(result.code, { target });
|
|
2682
|
+
compiled = r.code;
|
|
2683
|
+
}
|
|
2684
|
+
} catch (e) {
|
|
2685
|
+
compiled = null;
|
|
2686
|
+
result.compileError = e.message;
|
|
2687
|
+
}
|
|
2688
|
+
|
|
2689
|
+
return { ...result, compiled, target, extension: TARGET_EXTENSIONS[target] || '.txt' };
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
export async function generateAllTargets(instruction, options = {}) {
|
|
2693
|
+
const result = generate(instruction, options);
|
|
2694
|
+
if (!result.valid) return { ...result, targets: {} };
|
|
2695
|
+
|
|
2696
|
+
const targets = {};
|
|
2697
|
+
try {
|
|
2698
|
+
const { compile, compileAsync } = await import('./index.js');
|
|
2699
|
+
for (const target of ALL_TARGETS) {
|
|
2700
|
+
try {
|
|
2701
|
+
if (target === 'node') {
|
|
2702
|
+
targets[target] = { code: compile(result.code).js, ext: '.mjs' };
|
|
2703
|
+
} else {
|
|
2704
|
+
const r = await compileAsync(result.code, { target });
|
|
2705
|
+
targets[target] = { code: r.code, ext: TARGET_EXTENSIONS[target] };
|
|
2706
|
+
}
|
|
2707
|
+
} catch (e) {
|
|
2708
|
+
targets[target] = { error: e.message, ext: TARGET_EXTENSIONS[target] };
|
|
2709
|
+
}
|
|
2710
|
+
}
|
|
2711
|
+
} catch (e) {
|
|
2712
|
+
return { ...result, targets: {}, importError: e.message };
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
return { ...result, targets };
|
|
2716
|
+
}
|