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/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 || 1`,
653
- ` int limit = req.query.limit || 20`,
654
- ` str search = req.query.search || ""`,
655
- ` list all = ${store}.all()`,
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
- ` all = all.filter((item) => JSON.stringify(item).includes(search))`,
658
- ` int total = all.length`,
659
- ` int start = (page - 1) * limit`,
660
- ` list items = all.slice(start, start + limit)`,
661
- ` ret {items, total, page, limit}`,
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
- ], responses: { '200': { description: `List of ${plural}` } } },
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
- // WebSocket
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('ws "/ws":');
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: 'naide test app.naide',
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=\` — List ${ep} (paginated)`);
1422
- readmeLines.push(`- \`POST /api/${ep}\` — Create ${e.toLowerCase()}`);
1423
- readmeLines.push(`- \`GET /api/${ep}/:id\` — Get ${e.toLowerCase()}`);
1424
- readmeLines.push(`- \`PUT /api/${ep}/:id\` — Update ${e.toLowerCase()}`);
1425
- readmeLines.push(`- \`DELETE /api/${ep}/:id\` — Delete ${e.toLowerCase()}`);
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
- const parentPlural = pluralize(r.parent.toLowerCase());
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/login\` — Login`);
1442
- readmeLines.push(`- \`GET /api/auth/me\` — Current user`);
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
- const finalCode = combined.code;
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
- const readmeLines = [`# ${projectName}\n`];
1565
- readmeLines.push(`Generated by NAIDE Agent Coder (~~)\n`);
1566
- readmeLines.push(`## Entities\n`);
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 preset = SCHEMA_PRESETS[e.toLowerCase()];
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=\` — List ${ep} (paginated)`);
1584
- readmeLines.push(`- \`POST /api/${ep}\` — Create ${e.toLowerCase()}`);
1585
- readmeLines.push(`- \`GET /api/${ep}/:id\` — Get ${e.toLowerCase()}`);
1586
- readmeLines.push(`- \`PUT /api/${ep}/:id\` — Update ${e.toLowerCase()}`);
1587
- readmeLines.push(`- \`DELETE /api/${ep}/:id\` — Delete ${e.toLowerCase()}`);
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/login\` — Login`);
1601
- readmeLines.push(`- \`GET /api/auth/me\` — Current user`);
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
- entities: entityObjs.map(e => ({ name: e.name, fieldCount: e.fields.length })),
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('auth')) plan.push('Auth: JWT + register/login/me + role-based admin');
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
- if (intents.has('websocket'))plan.push('WebSocket: real-time messaging');
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
+ }