naider 1.21.0 → 1.23.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 CHANGED
@@ -8,7 +8,7 @@ import { spawn } from 'child_process';
8
8
  import { createRequire } from 'module';
9
9
  const _require = createRequire(import.meta.url);
10
10
 
11
- const NAIDE_VERSION = '1.21.0';
11
+ const NAIDE_VERSION = '1.23.0';
12
12
 
13
13
  function crashReport(err, context = {}) {
14
14
  const info = [
@@ -171,7 +171,13 @@ if (files[0] === 'gen' || files[0] === '~~') {
171
171
  Project Mode (--project):
172
172
  naide ~~ "e-commerce app with auth" --project
173
173
  # Creates: app.naide, package.json, .env, .gitignore,
174
- # Dockerfile, .dockerignore, README.md
174
+ # Dockerfile, .dockerignore, README.md, openapi.json,
175
+ # admin.html, client.mjs
176
+
177
+ Update existing project (re-run --project in same dir):
178
+ naide ~~ "add users with auth" --project -o my-app
179
+ naide ~~ "add products" --project -o my-app
180
+ # Merges new entities, regenerates all supporting files
175
181
 
176
182
  File Write Mode:
177
183
  naide ~~ "add auth" app.naide # merge into existing file
@@ -213,7 +219,7 @@ if (files[0] === 'gen' || files[0] === '~~') {
213
219
  process.exit(0);
214
220
  }
215
221
 
216
- const { generate, generateProject, writeToFile } = await import('../src/gen.js');
222
+ const { generate, generateProject, updateProject, writeToFile } = await import('../src/gen.js');
217
223
 
218
224
  // Write mode: inject into existing .naide/.nx file
219
225
  const targetFile = instruction.match(/\S+\.(?:naide|nx)$/)?.[0];
@@ -235,32 +241,67 @@ if (files[0] === 'gen' || files[0] === '~~') {
235
241
  process.exit(0);
236
242
  }
237
243
 
238
- // Project mode: generate full project directory
244
+ // Project mode: generate or update project directory
239
245
  if (flags.project) {
240
- const result = generateProject(instruction);
241
- const primaryName = (result.entities[0] || 'app').toLowerCase().replace(/[^a-z0-9]/g, '');
242
- const dir = flags.output || (primaryName + '-app');
246
+ const isCurrentDir = instruction === '.' || instruction === './';
247
+ let dir, result;
248
+
249
+ if (isCurrentDir) {
250
+ // naide gen . --project → update current directory
251
+ dir = '.';
252
+ const outDir = resolve(dir);
253
+ if (!existsSync(resolve(outDir, 'app.naide'))) {
254
+ process.stderr.write(' Error: no app.naide found in current directory.\n');
255
+ process.stderr.write(' Use naide ~~ "instruction" --project to create a new project.\n');
256
+ process.exit(1);
257
+ }
258
+ process.stderr.write(' Error: use naide ~~ "instruction" --project in an existing project dir to update.\n');
259
+ process.exit(1);
260
+ }
261
+
262
+ const primaryGuess = instruction.split(/\s+/)[0].toLowerCase().replace(/[^a-z0-9]/g, '') || 'app';
263
+ dir = flags.output || (primaryGuess + '-app');
243
264
  const outDir = resolve(dir);
244
- if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
265
+ const isUpdate = existsSync(resolve(outDir, 'app.naide'));
266
+
267
+ if (isUpdate) {
268
+ result = updateProject(outDir, instruction);
269
+ } else {
270
+ result = generateProject(instruction);
271
+ }
272
+
273
+ const primaryName = (result.entities[0] || 'app').toLowerCase().replace(/[^a-z0-9]/g, '');
274
+ if (!flags.output && !isUpdate) dir = primaryName + '-app';
275
+ const finalDir = resolve(dir);
276
+ if (!existsSync(finalDir)) mkdirSync(finalDir, { recursive: true });
245
277
  for (const f of result.files) {
246
- const fp = resolve(outDir, f.path);
278
+ const fp = resolve(finalDir, f.path);
247
279
  writeFileSync(fp, f.content, 'utf-8');
248
280
  }
249
281
  const a = result.analysis;
250
- process.stderr.write('\n ── NAIDE Agent ──────────────────────────────────────────\n');
251
- process.stderr.write(` Input: "${a.instruction}"\n`);
282
+ const mode = result.action === 'removed' ? 'Removed' : isUpdate ? 'Updated' : 'Created';
283
+ process.stderr.write(`\n ── NAIDE Agent (${mode}) ────────────────────────────────\n`);
284
+ process.stderr.write(` Input: "${a.instruction || instruction}"\n`);
252
285
  if (a.entities.length > 0) {
253
286
  process.stderr.write(` Entities: ${a.entities.map(e => `${e.name} (${e.fieldCount} fields)`).join(', ')}\n`);
254
287
  }
255
- if (a.relationships.length > 0) {
288
+ if (a.relationships && a.relationships.length > 0) {
256
289
  for (const r of a.relationships) process.stderr.write(` Relation: ${r.parent} 1→N ${r.child} (${r.fk})\n`);
257
290
  }
291
+ if (result.removed && result.removed.length > 0) {
292
+ process.stderr.write(` Removed: ${result.removed.join(', ')}\n`);
293
+ }
294
+ if (a.added && a.added.length > 0) {
295
+ process.stderr.write(` Added: ${a.added.join(', ')}\n`);
296
+ }
258
297
  process.stderr.write(` Plan:\n`);
259
298
  for (const step of a.plan) process.stderr.write(` + ${step}\n`);
299
+ process.stderr.write(` Files: ${result.files.length} generated\n`);
260
300
  process.stderr.write(' ────────────────────────────────────────────────────────\n\n');
261
- console.log(` Project created: ${dir}/`);
301
+ console.log(` Project ${mode.toLowerCase()}: ${dir}/`);
262
302
  for (const f of result.files) console.log(` ${f.path}`);
263
- console.log(`\n cd ${dir} && npm install && npm run dev\n`);
303
+ if (!isUpdate) console.log(`\n cd ${dir} && npm install && npm run dev\n`);
304
+ else console.log(`\n Files regenerated. Run: npm run dev\n`);
264
305
  process.exit(0);
265
306
  }
266
307
 
package/mcp/server.js CHANGED
@@ -192,7 +192,7 @@ async function handleMessage(msg) {
192
192
  respond(msg.id, {
193
193
  protocolVersion: '2024-11-05',
194
194
  capabilities: { tools: {} },
195
- serverInfo: { name: 'naide-mcp', version: '1.21.0' }
195
+ serverInfo: { name: 'naide-mcp', version: '1.23.0' }
196
196
  });
197
197
  break;
198
198
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "naider",
3
- "version": "1.21.0",
3
+ "version": "1.23.0",
4
4
  "description": "NAIDE - Simpler than Python, compiles to 15 targets. AI-specialized language with 35+ built-in functions, syntax sugar (unless/until/repeat/swap/is/isnt), and 47 features. Targets: Node.js, Python, TypeScript, C, C++, Java, Go, Rust, PHP, Ruby, Kotlin, Swift, Dart, C#, Bun.",
5
5
  "main": "src/index.js",
6
6
  "exports": {
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,373 @@ 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() {
1206
+ return [
1207
+ `ws "/ws":`,
1208
+ ` on "connection" (socket):`,
1209
+ ` socket.room = "general"`,
1210
+ ` log("Client connected to room: general")`,
1211
+ ` on "message" (data, socket):`,
1212
+ ` any msg = JSON.parse(data)`,
1213
+ ` if msg.type == "join":`,
1214
+ ` socket.room = msg.room`,
1215
+ ` socket.send(JSON.stringify({type: "joined", room: msg.room}))`,
1216
+ ` if msg.type == "leave":`,
1217
+ ` socket.room = "general"`,
1218
+ ` if msg.type == "message":`,
1219
+ ` broadcast({type: "message", room: socket.room, data: msg.data, sender: msg.sender}, socket.room)`,
1220
+ ` if msg.type == "broadcast":`,
1221
+ ` broadcast(msg.data)`,
1222
+ ` on "close" (socket):`,
1223
+ ` log("Client disconnected")`,
1224
+ ];
1225
+ }
1226
+
1227
+ // ── Rate Limiting (B12) ────────────────────────────────────
1228
+
1229
+ function rateLimitBlock() {
1230
+ return `fn rateLimit(int windowMs, int maxReqs):
1231
+ map hits = {}
1232
+ ret fn(req, res, next):
1233
+ str key = req.ip
1234
+ int now = Date.now()
1235
+ if not hits[key] or now - hits[key].start > windowMs:
1236
+ hits[key] = {count: 1, start: now}
1237
+ else:
1238
+ hits[key].count = hits[key].count + 1
1239
+ if hits[key].count > maxReqs:
1240
+ ret.status(429) {error: "Too many requests"}
1241
+ next()`;
1242
+ }
1243
+
1244
+ // ── Logging Middleware (C17) ───────────────────────────────
1245
+
1246
+ function loggingBlock() {
1247
+ return `fn requestLogger(req, res, next):
1248
+ int startTime = Date.now()
1249
+ log("[" + req.method + "] " + req.path)
1250
+ next()`;
1251
+ }
1252
+
1253
+ // ── Caching Headers (C18) ─────────────────────────────────
1254
+
1255
+ function cachingRoute() {
1256
+ return [
1257
+ `get "/api/cache-headers" (req, res):`,
1258
+ ` res.set("Cache-Control", "public, max-age=60")`,
1259
+ ` ret {cached: true, ttl: 60}`,
1260
+ ];
1261
+ }
1262
+
1263
+ // ── Frontend SPA (S2) ─────────────────────────────────────
1264
+
1265
+ function frontendSPA(entities, intents, port) {
1266
+ const L = [];
1267
+ L.push('<!DOCTYPE html>');
1268
+ L.push('<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">');
1269
+ L.push('<title>App</title>');
1270
+ L.push(`<style>
1271
+ :root{--bg:#f8fafc;--card:#fff;--primary:#2563eb;--danger:#dc2626;--text:#1e293b;--muted:#64748b;--border:#e2e8f0;--radius:8px}
1272
+ *{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}
1273
+ 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)}
1274
+ 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)}
1275
+ .container{max-width:1200px;margin:2rem auto;padding:0 1rem}
1276
+ .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)}
1277
+ .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:1rem}
1278
+ .form-group{margin-bottom:1rem}
1279
+ .form-group label{display:block;font-weight:500;margin-bottom:.25rem;font-size:.875rem}
1280
+ .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}
1281
+ .form-group textarea{min-height:80px;resize:vertical}
1282
+ .btn{padding:.5rem 1rem;border:none;border-radius:var(--radius);cursor:pointer;font-size:.875rem;font-weight:500;transition:opacity .2s}
1283
+ .btn-primary{background:var(--primary);color:#fff}.btn-danger{background:var(--danger);color:#fff}
1284
+ .btn:hover{opacity:.85}.btn-sm{padding:.25rem .5rem;font-size:.75rem}
1285
+ table{width:100%;border-collapse:collapse}th,td{padding:.75rem;text-align:left;border-bottom:1px solid var(--border)}
1286
+ th{background:var(--bg);font-weight:600;font-size:.875rem;color:var(--muted)}
1287
+ .search{display:flex;gap:.5rem;margin-bottom:1rem}
1288
+ .search input{flex:1;padding:.5rem .75rem;border:1px solid var(--border);border-radius:var(--radius)}
1289
+ .pagination{display:flex;gap:.5rem;justify-content:center;margin-top:1rem;align-items:center}
1290
+ .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}
1291
+ .toast.show{opacity:1}.modal-bg{position:fixed;inset:0;background:rgba(0,0,0,.4);display:flex;align-items:center;justify-content:center;z-index:100}
1292
+ .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)}
1293
+ .modal h3{margin-bottom:1rem}.modal-actions{display:flex;gap:.5rem;justify-content:flex-end;margin-top:1.5rem}
1294
+ .badge{display:inline-block;padding:.15rem .5rem;border-radius:99px;font-size:.75rem;font-weight:500;background:var(--border)}
1295
+ .hidden{display:none}
1296
+ </style></head><body>`);
1297
+ L.push('<div id="toast" class="toast"></div>');
1298
+ const hasAuth = intents.has('auth');
1299
+ const navLinks = entities.map(e => `<a onclick="showSection('${e.name.toLowerCase()}')" id="nav-${e.name.toLowerCase()}">${pluralize(e.name)}</a>`).join('');
1300
+ 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>` : '';
1301
+ L.push(`<nav><strong style="font-size:1.25rem">App</strong>${navLinks}${authNav}</nav>`);
1302
+ L.push('<div class="container">');
1303
+ if (hasAuth) {
1304
+ L.push(`<div id="section-auth"><div class="card" style="max-width:400px;margin:2rem auto"><h2 id="auth-title">Login</h2>`);
1305
+ L.push(`<div class="form-group"><label>Email</label><input type="email" id="auth-email" placeholder="email@example.com"></div>`);
1306
+ L.push(`<div class="form-group"><label>Password</label><input type="password" id="auth-password" placeholder="password"></div>`);
1307
+ L.push(`<div id="auth-name-group" class="form-group hidden"><label>Name</label><input type="text" id="auth-name" placeholder="Your name"></div>`);
1308
+ L.push(`<button class="btn btn-primary" style="width:100%" onclick="doAuth()">Login</button>`);
1309
+ 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>`);
1310
+ L.push(`</div></div>`);
1311
+ }
1312
+ for (const entity of entities) {
1313
+ const lower = entity.name.toLowerCase();
1314
+ const plural = pluralize(lower);
1315
+ const fields = entity.fields.filter(f => f.type !== 'auto');
1316
+ L.push(`<div id="section-${lower}" class="hidden">`);
1317
+ 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>`);
1318
+ L.push(`<div class="search"><input id="${lower}-search" placeholder="Search ${plural}..." oninput="${lower}Load()"><select id="${lower}-sort" onchange="${lower}Load()">`);
1319
+ for (const f of entity.fields) L.push(`<option value="${f.name}">${f.name}</option>`);
1320
+ L.push(`</select><select id="${lower}-order" onchange="${lower}Load()"><option value="desc">Newest</option><option value="asc">Oldest</option></select></div>`);
1321
+ 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>`);
1322
+ L.push(`<div class="pagination" id="${lower}-pager"></div></div>`);
1323
+ 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>`);
1324
+ L.push(`<input type="hidden" id="${lower}-edit-id">`);
1325
+ for (const f of fields) {
1326
+ if (f.type === 'bool') {
1327
+ L.push(`<div class="form-group"><label><input type="checkbox" id="${lower}-f-${f.name}"> ${f.name}</label></div>`);
1328
+ } else {
1329
+ const inputType = f.type === 'int' ? 'number' : f.name === 'password' ? 'password' : f.name === 'email' ? 'email' : 'text';
1330
+ const tag = f.name === 'body' || f.name === 'content' || f.name === 'description' ? 'textarea' : 'input';
1331
+ 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>`);
1332
+ }
1333
+ }
1334
+ 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>`);
1335
+ L.push('</div>');
1336
+ }
1337
+ L.push('</div>');
1338
+ L.push('<script>');
1339
+ L.push(`const API="";let currentPage={};let _token=null;let _isRegister=false;`);
1340
+ L.push(`function authHeaders(){const h={"Content-Type":"application/json"};if(_token)h.Authorization="Bearer "+_token;return h}`);
1341
+ L.push(`function toast(m){const t=document.getElementById("toast");t.textContent=m;t.classList.add("show");setTimeout(()=>t.classList.remove("show"),2500)}`);
1342
+ 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"]()}`);
1343
+ if (hasAuth) {
1344
+ L.push(`function toggleAuthMode(){_isRegister=!_isRegister;document.getElementById("auth-title").textContent=_isRegister?"Register":"Login";document.getElementById("auth-name-group").classList.toggle("hidden",!_isRegister)}`);
1345
+ L.push(`async function doAuth(){const email=document.getElementById("auth-email").value;const password=document.getElementById("auth-password").value;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(d.token){_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{toast(d.error||"Auth failed")}}catch(e){toast("Error: "+e.message)}}`);
1346
+ L.push(`function doLogout(){_token=null;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")}`);
1347
+ }
1348
+ for (const entity of entities) {
1349
+ const lower = entity.name.toLowerCase();
1350
+ const plural = pluralize(lower);
1351
+ const fields = entity.fields.filter(f => f.type !== 'auto');
1352
+ 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;`);
1353
+ L.push(`const r=await fetch(API+"/api/${plural}?page="+page+"&limit=10&search="+encodeURIComponent(s)+"&sort="+sort+"&order="+ord,{headers:authHeaders()});const d=await r.json();const items=d.items||d;`);
1354
+ 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("");`);
1355
+ 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}`);
1356
+ 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")}`);
1357
+ L.push(`async function ${lower}Edit(id){const r=await fetch(API+"/api/${plural}/"+id,{headers:authHeaders()});const d=await r.json();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(';')}}`);
1358
+ L.push(`async function ${lower}Save(){const id=document.getElementById("${lower}-edit-id").value;const body={${fields.map(f => {
1359
+ if (f.type === 'bool') return `${f.name}:document.getElementById("${lower}-f-${f.name}").checked`;
1360
+ if (f.type === 'int') return `${f.name}:+document.getElementById("${lower}-f-${f.name}").value`;
1361
+ return `${f.name}:document.getElementById("${lower}-f-${f.name}").value`;
1362
+ }).join(',')}};`);
1363
+ L.push(`const method=id?"PUT":"POST";const url=API+"/api/${plural}"+(id?"/"+id:"");await fetch(url,{method,headers:authHeaders(),body:JSON.stringify(body)});document.getElementById("${lower}-modal").classList.add("hidden");toast(id?"Updated!":"Created!");${lower}Load()}`);
1364
+ L.push(`async function ${lower}Del(id){if(!confirm("Delete this ${lower}?"))return;await fetch(API+"/api/${plural}/"+id,{method:"DELETE",headers:authHeaders()});toast("Deleted!");${lower}Load()}`);
1365
+ }
1366
+ const initSection = hasAuth ? 'auth' : entities[0].name.toLowerCase();
1367
+ L.push(`showSection("${initSection}")`);
1368
+ L.push('</script></body></html>');
1369
+ return L.join('\n');
1370
+ }
1371
+
1372
+ // ── Docker Compose (B14) ──────────────────────────────────
1373
+
1374
+ function dockerCompose(projectName, port, intents) {
1375
+ const lines = ['version: "3.8"', '', 'services:'];
1376
+ lines.push(` app:`);
1377
+ lines.push(` build: .`);
1378
+ lines.push(` ports:`);
1379
+ lines.push(` - "${port}:${port}"`);
1380
+ lines.push(` env_file: .env`);
1381
+ lines.push(` volumes:`);
1382
+ lines.push(` - app-data:/app/data`);
1383
+ if (intents.has('database')) {
1384
+ lines.push(` depends_on:`);
1385
+ lines.push(` - db`);
1386
+ lines.push('');
1387
+ lines.push(` db:`);
1388
+ lines.push(` image: postgres:16-alpine`);
1389
+ lines.push(` environment:`);
1390
+ lines.push(` POSTGRES_DB: ${projectName.replace(/-/g, '_')}`);
1391
+ lines.push(` POSTGRES_USER: app`);
1392
+ lines.push(` POSTGRES_PASSWORD: changeme`);
1393
+ lines.push(` ports:`);
1394
+ lines.push(` - "5432:5432"`);
1395
+ lines.push(` volumes:`);
1396
+ lines.push(` - db-data:/var/lib/postgresql/data`);
1397
+ }
1398
+ lines.push('');
1399
+ lines.push('volumes:');
1400
+ lines.push(' app-data:');
1401
+ if (intents.has('database')) lines.push(' db-data:');
1402
+ return lines.join('\n') + '\n';
1403
+ }
1404
+
1405
+ // ── GitHub Actions CI (C16) ────────────────────────────────
1406
+
1407
+ function githubActionsCI(projectName) {
1408
+ return `name: CI
1409
+
1410
+ on:
1411
+ push:
1412
+ branches: [main]
1413
+ pull_request:
1414
+ branches: [main]
1415
+
1416
+ jobs:
1417
+ test:
1418
+ runs-on: ubuntu-latest
1419
+ strategy:
1420
+ matrix:
1421
+ node-version: [20, 22]
1422
+ steps:
1423
+ - uses: actions/checkout@v4
1424
+ - uses: actions/setup-node@v4
1425
+ with:
1426
+ node-version: \${{ matrix.node-version }}
1427
+ cache: npm
1428
+ - run: npm ci
1429
+ - run: npm run build
1430
+ - run: npm test
1431
+
1432
+ lint:
1433
+ runs-on: ubuntu-latest
1434
+ steps:
1435
+ - uses: actions/checkout@v4
1436
+ - uses: actions/setup-node@v4
1437
+ with:
1438
+ node-version: 22
1439
+ cache: npm
1440
+ - run: npm ci
1441
+ - run: npx naide app.naide --check
1442
+ `;
1443
+ }
1444
+
1445
+ // ── Environment Files (C15) ───────────────────────────────
1446
+
1447
+ function envDevelopment(port, intents) {
1448
+ const lines = ['NODE_ENV=development', `PORT=${port}`, 'LOG_LEVEL=debug'];
1449
+ if (intents.has('auth')) lines.push('JWT_SECRET=dev-secret-do-not-use-in-prod');
1450
+ if (intents.has('database')) lines.push('DATABASE_URL=postgres://app:changeme@localhost:5432/app_dev');
1451
+ return lines.join('\n') + '\n';
1452
+ }
1453
+
1454
+ function envProduction(port, intents) {
1455
+ const lines = ['NODE_ENV=production', `PORT=${port}`, 'LOG_LEVEL=warn'];
1456
+ if (intents.has('auth')) lines.push('JWT_SECRET=CHANGE_ME_TO_RANDOM_SECRET');
1457
+ if (intents.has('database')) lines.push('DATABASE_URL=postgres://user:pass@db-host:5432/app_prod');
1458
+ return lines.join('\n') + '\n';
1459
+ }
1460
+
994
1461
  // ── Auth Route Templates ────────────────────────────────────
995
1462
 
996
1463
  function authRoutesBlock(userEntity) {
@@ -1058,6 +1525,19 @@ function compose(intents, params) {
1058
1525
  const utils = utilityFunctions(intents);
1059
1526
  if (utils.length > 0) sections.push(...utils);
1060
1527
 
1528
+ // Rate limiting & logging middleware (B12, C17)
1529
+ if (intents.has('server')) {
1530
+ sections.push(rateLimitBlock());
1531
+ sections.push(loggingBlock());
1532
+ }
1533
+
1534
+ // Input validation functions (A4)
1535
+ if (intents.has('schema') && intents.has('crud')) {
1536
+ for (const entity of entities) {
1537
+ sections.push(validationBlock(entity));
1538
+ }
1539
+ }
1540
+
1061
1541
  // Schema blocks for all entities (add role + deleted fields)
1062
1542
  if (intents.has('schema')) {
1063
1543
  for (const entity of entities) {
@@ -1071,6 +1551,12 @@ function compose(intents, params) {
1071
1551
  sections.push(schemaBlock(entity.name, entity.fields));
1072
1552
  }
1073
1553
  }
1554
+ // M:N junction table schemas (S3)
1555
+ const m2mRels = detectManyToMany(entities.map(e => e.name));
1556
+ for (const rel of m2mRels) {
1557
+ sections.push(junctionSchemaBlock(rel));
1558
+ }
1559
+
1074
1560
  // Audit log schema when auth is present
1075
1561
  if (hasAuth) {
1076
1562
  sections.push(auditSchema());
@@ -1099,6 +1585,8 @@ function compose(intents, params) {
1099
1585
  body.push(' protect "/api/*"');
1100
1586
  body.push(' public "/api/auth/*"');
1101
1587
  body.push(' public "/api/health"');
1588
+ body.push(' public "/docs"');
1589
+ body.push(' public "/api/openapi.json"');
1102
1590
  if (userEntity) {
1103
1591
  body.push('');
1104
1592
  body.push(...authRoutesBlock(userEntity.name));
@@ -1114,6 +1602,12 @@ function compose(intents, params) {
1114
1602
  body.push(' ret {status: "ok"}');
1115
1603
  }
1116
1604
  }
1605
+
1606
+ // Password reset routes (A6)
1607
+ if (userEntity) {
1608
+ body.push('');
1609
+ body.push(...passwordResetRoutes());
1610
+ }
1117
1611
  }
1118
1612
 
1119
1613
  // Paginated list endpoints (override default CRUD list)
@@ -1125,21 +1619,30 @@ function compose(intents, params) {
1125
1619
  }
1126
1620
  }
1127
1621
 
1622
+ // Validated create/update routes (A4)
1623
+ if (intents.has('crud') && intents.has('schema')) {
1624
+ for (const entity of entities) {
1625
+ body.push(...validatedCreateRoute(entity));
1626
+ body.push('');
1627
+ }
1628
+ }
1629
+
1128
1630
  // Nested routes for relationships
1129
1631
  for (const rel of relationships) {
1130
1632
  body.push(...nestedRouteBlock(rel.parent, rel.child, rel.fk));
1131
1633
  body.push('');
1132
1634
  }
1133
1635
 
1134
- // WebSocket
1636
+ // M:N routes (S3)
1637
+ const m2mRels = detectManyToMany(entities.map(e => e.name));
1638
+ for (const rel of m2mRels) {
1639
+ body.push(...manyToManyRoutes(rel));
1640
+ body.push('');
1641
+ }
1642
+
1643
+ // WebSocket with rooms (B11)
1135
1644
  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")');
1645
+ body.push(...wsRoomsBlock());
1143
1646
  body.push('');
1144
1647
  }
1145
1648
 
@@ -1175,6 +1678,14 @@ function compose(intents, params) {
1175
1678
  body.push('');
1176
1679
  }
1177
1680
 
1681
+ // Swagger UI (A5)
1682
+ body.push(...swaggerUIRoute());
1683
+ body.push('');
1684
+
1685
+ // Cache control (C18)
1686
+ body.push(...cachingRoute());
1687
+ body.push('');
1688
+
1178
1689
  // Health check
1179
1690
  body.push('get "/api/health" (req, res):');
1180
1691
  if (intents.has('schema') && intents.has('crud') && entities.length > 0) {
@@ -1416,40 +1927,33 @@ CMD ["node", "dist/app.mjs"]
1416
1927
  if (intents.has('server')) {
1417
1928
  readmeLines.push(`## API Endpoints\n`);
1418
1929
  readmeLines.push(`- \`GET /api/health\` — Health check`);
1930
+ readmeLines.push(`- \`GET /docs\` — Swagger UI`);
1419
1931
  for (const e of entities) {
1420
1932
  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}`);
1933
+ readmeLines.push(`- \`GET /api/${ep}?page=1&limit=20&search=&sort=id&order=desc\` — List (paginated, sortable, filterable)`);
1934
+ readmeLines.push(`- \`POST /api/${ep}\` — Create (validated)`, `- \`PUT /api/${ep}/:id\` — Update (validated)`);
1935
+ readmeLines.push(`- \`GET /api/${ep}/:id\` — Get`, `- \`DELETE /api/${ep}/:id\` — Delete`);
1936
+ readmeLines.push(`- \`DELETE /api/${ep}/:id/soft\` — Soft delete`, `- \`PUT /api/${ep}/:id/restore\` — Restore`);
1937
+ readmeLines.push(`- \`POST /api/${ep}/batch\` — Batch create`, `- \`DELETE /api/${ep}/batch\` — Batch delete`);
1430
1938
  }
1431
1939
  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}`);
1940
+ readmeLines.push(`- \`GET /api/${pluralize(r.parent.toLowerCase())}/:id/${pluralize(r.child.toLowerCase())}\` — ${r.child}s by ${r.parent}`);
1435
1941
  }
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`);
1942
+ readmeLines.push(`- \`POST /api/upload\` — File upload`, `- \`POST /api/webhooks\` — Webhook`);
1439
1943
  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)`);
1944
+ readmeLines.push(`- \`POST /api/auth/register\` — Register`, `- \`POST /api/auth/login\` — Login`, `- \`GET /api/auth/me\` — Current user`);
1945
+ readmeLines.push(`- \`POST /api/auth/forgot-password\` — Forgot password`, `- \`POST /api/auth/reset-password\` — Reset password`);
1946
+ readmeLines.push(`- \`POST /api/auth/change-password\` — Change password`, `- \`GET /api/admin/stats\` — Admin stats`);
1444
1947
  }
1445
1948
  }
1446
1949
  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('```');
1950
+ readmeLines.push(`\n## Docker\n`, '```bash', `docker-compose up -d`, '```');
1452
1951
  }
1952
+ readmeLines.push(`\n## Generated Files\n`);
1953
+ readmeLines.push('| File | Purpose |', '|------|---------|');
1954
+ readmeLines.push('| app.naide | Main application |', '| index.html | Frontend SPA |', '| admin.html | Admin dashboard |');
1955
+ readmeLines.push('| client.mjs | API client library |', '| openapi.json | OpenAPI 3.0 spec |');
1956
+ readmeLines.push('| docker-compose.yml | Docker orchestration |', '| .github/workflows/ci.yml | CI/CD pipeline |');
1453
1957
  files.push({ path: 'README.md', content: readmeLines.join('\n') + '\n' });
1454
1958
 
1455
1959
  // Build full entity objects for generators
@@ -1476,9 +1980,231 @@ CMD ["node", "dist/app.mjs"]
1476
1980
  files.push({ path: 'client.mjs', content: apiClientCode(entityObjs, intents, result.port) });
1477
1981
  }
1478
1982
 
1983
+ // Frontend SPA (S2)
1984
+ if (intents.has('server') && entityObjs.length > 0) {
1985
+ files.push({ path: 'index.html', content: frontendSPA(entityObjs, intents, result.port) });
1986
+ }
1987
+
1988
+ // Docker Compose (B14)
1989
+ if (intents.has('server')) {
1990
+ files.push({ path: 'docker-compose.yml', content: dockerCompose(projectName, result.port, intents) });
1991
+ }
1992
+
1993
+ // Environment files (C15)
1994
+ files.push({ path: '.env.development', content: envDevelopment(result.port, intents) });
1995
+ files.push({ path: '.env.production', content: envProduction(result.port, intents) });
1996
+
1997
+ // GitHub Actions CI (C16)
1998
+ files.push({ path: '.github/workflows/ci.yml', content: githubActionsCI(projectName) });
1999
+
1479
2000
  return { ...result, files };
1480
2001
  }
1481
2002
 
2003
+ // ── Project Update Mode ───────────────────────────────────
2004
+
2005
+ export function updateProject(projectDir, instruction, options = {}) {
2006
+ const appPath = projectDir + '/app.naide';
2007
+ const existing = _existsFS(appPath) ? _readFS(appPath, 'utf-8') : '';
2008
+
2009
+ const existingState = existing ? parseExistingProject(existing) : { entities: [], intents: [], port: 3000 };
2010
+ const newResult = generate(instruction, options);
2011
+
2012
+ // Entity removal detection (B8)
2013
+ const isRemoval = /(?:remove|delete|drop|取り除|削除)\s+/i.test(instruction);
2014
+ let removedEntities = [];
2015
+ if (isRemoval) {
2016
+ const removeTargets = (newResult.entities || []);
2017
+ removedEntities = existingState.entities.filter(e => removeTargets.includes(e));
2018
+ const allEntities = existingState.entities.filter(e => !removeTargets.includes(e));
2019
+ if (allEntities.length === 0) {
2020
+ return { code: '', valid: true, fixed: false, repairs: [], intents: [], entities: [], relationships: [], port: existingState.port,
2021
+ files: [{ path: 'app.naide', content: '' }], analysis: { instruction, entities: [], relationships: [], plan: [`Removed: ${removedEntities.join(', ')}`], added: [] },
2022
+ action: 'removed', removed: removedEntities };
2023
+ }
2024
+ const combinedIntents = existingState.intents.filter(i => i !== 'schema' || allEntities.length > 0);
2025
+ const extras = [];
2026
+ if (combinedIntents.includes('auth')) extras.push('auth');
2027
+ if (combinedIntents.includes('websocket')) extras.push('websocket');
2028
+ if (combinedIntents.includes('database')) extras.push('database');
2029
+ const fullInstruction = 'REST API for ' + allEntities.join(' and ') + (extras.length ? ' with ' + extras.join(' and ') : '');
2030
+ const combined = generate(fullInstruction, options);
2031
+ const result = buildUpdateFiles(projectDir, combined, allEntities, existingState, options);
2032
+ result.action = 'removed';
2033
+ result.removed = removedEntities;
2034
+ result.analysis.plan.unshift(`Removed: ${removedEntities.join(', ')}`);
2035
+ return result;
2036
+ }
2037
+
2038
+ const newEntities = (newResult.entities || []).filter(e => !existingState.entities.includes(e));
2039
+ const allEntities = [...existingState.entities, ...newEntities];
2040
+ const combinedIntents = [...new Set([...existingState.intents, ...newResult.intents])];
2041
+ const extras = [];
2042
+ if (combinedIntents.includes('auth')) extras.push('auth');
2043
+ if (combinedIntents.includes('websocket')) extras.push('websocket');
2044
+ if (combinedIntents.includes('database')) extras.push('database');
2045
+ const fullInstruction = 'REST API for ' + allEntities.join(' and ') + (extras.length ? ' with ' + extras.join(' and ') : '');
2046
+
2047
+ const combined = generate(fullInstruction, options);
2048
+ return buildUpdateFiles(projectDir, combined, allEntities, existingState, options);
2049
+ }
2050
+
2051
+ function buildUpdateFiles(projectDir, combined, allEntities, existingState, options) {
2052
+ const existing = _existsFS(projectDir + '/app.naide') ? _readFS(projectDir + '/app.naide', 'utf-8') : '';
2053
+ const finalCode = combined.code;
2054
+ const files = [];
2055
+ files.push({ path: 'app.naide', content: finalCode });
2056
+
2057
+ const intents = new Set(combined.intents);
2058
+ const entities = combined.entities || allEntities;
2059
+ const relationships = combined.relationships || detectRelationships(entities);
2060
+
2061
+ const primaryName = (entities[0] || 'app').toLowerCase();
2062
+ const projectName = safeName(primaryName) + '-app';
2063
+ const port = existingState.port || combined.port || 3000;
2064
+
2065
+ const deps = {};
2066
+ if (intents.has('server')) deps.express = '^4.21.0';
2067
+ if (intents.has('websocket')) deps.ws = '^8.18.0';
2068
+ if (intents.has('auth')) { deps.jsonwebtoken = '^9.0.2'; deps.bcryptjs = '^2.4.3'; }
2069
+ if (intents.has('mail')) deps.nodemailer = '^6.9.0';
2070
+ if (entities.length > 0) deps.naider = '^1.21.0';
2071
+
2072
+ const existingPkgPath = projectDir + '/package.json';
2073
+ let pkg;
2074
+ if (_existsFS(existingPkgPath)) {
2075
+ pkg = JSON.parse(_readFS(existingPkgPath, 'utf-8'));
2076
+ pkg.dependencies = { ...pkg.dependencies, ...deps };
2077
+ } else {
2078
+ pkg = {
2079
+ name: projectName, version: '1.0.0', type: 'module',
2080
+ scripts: { dev: 'naide app.naide', build: 'naide app.naide --emit -o dist/app.mjs',
2081
+ start: 'node dist/app.mjs', test: 'naide test app.naide', seed: 'naide app.naide --run-seed' },
2082
+ dependencies: deps,
2083
+ };
2084
+ }
2085
+ files.push({ path: 'package.json', content: JSON.stringify(pkg, null, 2) + '\n' });
2086
+
2087
+ const envL = [`PORT=${port}`];
2088
+ if (intents.has('auth')) envL.push('JWT_SECRET=change-me-in-production');
2089
+ if (intents.has('bot')) envL.push(`${(options.platform || 'DISCORD').toUpperCase()}_TOKEN=your-bot-token`);
2090
+ if (intents.has('mail')) { envL.push('SMTP_HOST=smtp.example.com'); envL.push('SMTP_USER=user@example.com'); envL.push('SMTP_PASS=password'); }
2091
+ if (intents.has('ai')) envL.push('OPENAI_API_KEY=your-api-key');
2092
+ const existingEnvPath = projectDir + '/.env';
2093
+ if (_existsFS(existingEnvPath)) {
2094
+ const existingEnv = _readFS(existingEnvPath, 'utf-8');
2095
+ const existingKeys = new Set(existingEnv.split('\n').map(l => l.split('=')[0]).filter(Boolean));
2096
+ const newEnvLines = envL.filter(l => !existingKeys.has(l.split('=')[0]));
2097
+ if (newEnvLines.length > 0) files.push({ path: '.env', content: existingEnv.trimEnd() + '\n' + newEnvLines.join('\n') + '\n' });
2098
+ } else {
2099
+ files.push({ path: '.env', content: envL.join('\n') + '\n' });
2100
+ }
2101
+
2102
+ files.push({ path: '.gitignore', content: 'node_modules/\ndist/\ndata/\n.env\n*.mjs\n' });
2103
+
2104
+ if (intents.has('server')) {
2105
+ 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` });
2106
+ files.push({ path: '.dockerignore', content: 'node_modules\ndist\ndata\n.env\n.git\n' });
2107
+ files.push({ path: 'docker-compose.yml', content: dockerCompose(projectName, port, intents) });
2108
+ }
2109
+
2110
+ files.push({ path: '.env.development', content: envDevelopment(port, intents) });
2111
+ files.push({ path: '.env.production', content: envProduction(port, intents) });
2112
+ files.push({ path: '.github/workflows/ci.yml', content: githubActionsCI(projectName) });
2113
+
2114
+ const readmeLines = [`# ${projectName}\n`, `Generated by NAIDE Agent Coder (~~)\n`, `## Entities\n`];
2115
+ for (const e of entities) {
2116
+ const fc = SCHEMA_PRESETS[e.toLowerCase()]?.length || 3;
2117
+ readmeLines.push(`- **${e}** (${fc} fields)`);
2118
+ }
2119
+ if (relationships.length > 0) {
2120
+ readmeLines.push(`\n## Relationships\n`);
2121
+ for (const r of relationships) readmeLines.push(`- ${r.parent} → ${r.child} (${r.fk})`);
2122
+ }
2123
+ readmeLines.push(`\n## Quick Start\n`, '```bash', 'npm install', 'npm run dev', '```\n');
2124
+ if (intents.has('server')) {
2125
+ readmeLines.push(`## API Endpoints\n`, `- \`GET /api/health\` — Health check`, `- \`GET /docs\` — Swagger UI`);
2126
+ for (const e of entities) {
2127
+ const ep = pluralize(e.toLowerCase());
2128
+ readmeLines.push(`- \`GET /api/${ep}?page=1&limit=20&search=&sort=id&order=desc\` — List (paginated, sortable, filterable)`);
2129
+ readmeLines.push(`- \`POST /api/${ep}\` — Create (validated)`, `- \`PUT /api/${ep}/:id\` — Update (validated)`);
2130
+ readmeLines.push(`- \`GET /api/${ep}/:id\` — Get`, `- \`DELETE /api/${ep}/:id\` — Delete`);
2131
+ readmeLines.push(`- \`DELETE /api/${ep}/:id/soft\` — Soft delete`, `- \`PUT /api/${ep}/:id/restore\` — Restore`);
2132
+ readmeLines.push(`- \`POST /api/${ep}/batch\` — Batch create`, `- \`DELETE /api/${ep}/batch\` — Batch delete`);
2133
+ }
2134
+ readmeLines.push(`- \`POST /api/upload\` — File upload`, `- \`POST /api/webhooks\` — Webhook`);
2135
+ if (intents.has('auth')) {
2136
+ readmeLines.push(`- \`POST /api/auth/register\` — Register`, `- \`POST /api/auth/login\` — Login`, `- \`GET /api/auth/me\` — Current user`);
2137
+ readmeLines.push(`- \`POST /api/auth/forgot-password\` — Forgot password`, `- \`POST /api/auth/reset-password\` — Reset password`);
2138
+ readmeLines.push(`- \`POST /api/auth/change-password\` — Change password`, `- \`GET /api/admin/stats\` — Admin stats`);
2139
+ }
2140
+ }
2141
+ if (intents.has('server')) {
2142
+ readmeLines.push(`\n## Docker\n`, '```bash', `docker-compose up -d`, '```');
2143
+ }
2144
+ readmeLines.push(`\n## Generated Files\n`);
2145
+ readmeLines.push('| File | Purpose |', '|------|---------|');
2146
+ readmeLines.push('| app.naide | Main application |', '| index.html | Frontend SPA |', '| admin.html | Admin dashboard |');
2147
+ readmeLines.push('| client.mjs | API client library |', '| openapi.json | OpenAPI 3.0 spec |');
2148
+ readmeLines.push('| docker-compose.yml | Docker orchestration |', '| .github/workflows/ci.yml | CI/CD pipeline |');
2149
+ files.push({ path: 'README.md', content: readmeLines.join('\n') + '\n' });
2150
+
2151
+ const entityObjs = entities.map(name => {
2152
+ const preset = SCHEMA_PRESETS[name.toLowerCase()];
2153
+ const fields = preset ? preset.map(f => ({ name: f[0], type: f[1], modifiers: f.slice(2) })) : defaultFields(name);
2154
+ if (!fields.some(f => f.name === 'deleted')) fields.push({ name: 'deleted', type: 'bool', modifiers: [] });
2155
+ return { name, fields };
2156
+ });
2157
+
2158
+ if (intents.has('server') && entityObjs.length > 0) {
2159
+ files.push({ path: 'openapi.json', content: JSON.stringify(openApiSpec(entityObjs, intents, { port }, relationships), null, 2) + '\n' });
2160
+ files.push({ path: 'admin.html', content: adminPage(entityObjs, intents) });
2161
+ files.push({ path: 'index.html', content: frontendSPA(entityObjs, intents, port) });
2162
+ }
2163
+ if (intents.has('server')) {
2164
+ files.push({ path: 'client.mjs', content: apiClientCode(entityObjs, intents, port) });
2165
+ }
2166
+
2167
+ const added = existing ? entities.filter(e => !existing.includes(`schema ${e}`)) : entities;
2168
+ const analysis = {
2169
+ instruction: '', entities: entityObjs.map(e => ({ name: e.name, fieldCount: e.fields.length })),
2170
+ relationships, plan: [], added,
2171
+ };
2172
+ if (added.length > 0) analysis.plan.push(`Added: ${added.join(', ')}`);
2173
+ if (existing) analysis.plan.push('Merged with existing app.naide');
2174
+ analysis.plan.push(`Total entities: ${entities.length}`, `Supporting files regenerated (${files.length} files)`);
2175
+
2176
+ return {
2177
+ code: finalCode, valid: combined.valid, fixed: combined.fixed, repairs: combined.repairs,
2178
+ intents: [...intents], entities, relationships, port,
2179
+ files, analysis, action: existing ? 'updated' : 'created',
2180
+ };
2181
+ }
2182
+
2183
+ function parseExistingProject(code) {
2184
+ const lines = code.split('\n');
2185
+ const entities = [];
2186
+ const intents = new Set();
2187
+ let port = 3000;
2188
+
2189
+ for (const line of lines) {
2190
+ const sm = line.match(/^schema\s+(\w+)\s*:/);
2191
+ if (sm && sm[1] !== 'AuditLog') {
2192
+ entities.push(sm[1]);
2193
+ intents.add('schema');
2194
+ }
2195
+ if (line.match(/^server\s/)) intents.add('server');
2196
+ if (line.match(/\bcrud\b/)) intents.add('crud');
2197
+ if (line.match(/\bauth\b/)) intents.add('auth');
2198
+ if (line.match(/\bdb\b/)) intents.add('database');
2199
+ if (line.match(/\bws\b/)) intents.add('websocket');
2200
+ if (line.match(/\bbot\b/)) intents.add('bot');
2201
+ const pm = line.match(/port\s+(\d+)/);
2202
+ if (pm) port = parseInt(pm[1], 10);
2203
+ }
2204
+
2205
+ return { entities, intents: [...intents], port };
2206
+ }
2207
+
1482
2208
  // ── File Write Mode ────────────────────────────────────────
1483
2209
 
1484
2210
  export function writeToFile(filePath, instruction, options = {}) {
@@ -1599,17 +2325,21 @@ function buildAnalysis(instruction, intents, params) {
1599
2325
  }
1600
2326
 
1601
2327
  if (intents.has('database')) plan.push(`Database: ${params.dbType || 'file-based'}`);
1602
- if (intents.has('server')) plan.push(`Server on port ${params.port}`);
1603
- if (intents.has('crud')) plan.push(`CRUD endpoints for ${params.entities.map(e => pluralize(e.name.toLowerCase())).join(', ')} (paginated)`);
1604
- if (intents.has('auth')) plan.push('Auth: JWT + register/login/me + role-based admin');
2328
+ if (intents.has('server')) plan.push(`Server on port ${params.port} (rate-limited, request logging)`);
2329
+ if (intents.has('crud')) plan.push(`CRUD endpoints for ${params.entities.map(e => pluralize(e.name.toLowerCase())).join(', ')} (paginated, sortable, filterable)`);
2330
+ if (intents.has('crud')) plan.push(`Input validation: ${params.entities.map(e => `validate${e.name}()`).join(', ')}`);
2331
+ if (intents.has('auth')) plan.push('Auth: JWT + register/login/me + forgot/reset/change password + role-based admin');
1605
2332
  if (params.relationships.length > 0) plan.push(`Nested routes: ${params.relationships.map(r => `/${pluralize(r.parent.toLowerCase())}/:id/${pluralize(r.child.toLowerCase())}`).join(', ')}`);
1606
- if (intents.has('websocket'))plan.push('WebSocket: real-time messaging');
2333
+ const m2m = detectManyToMany(params.entities.map(e => e.name));
2334
+ if (m2m.length > 0) plan.push(`M:N relations: ${m2m.map(r => `${r.left}↔${r.right} via ${r.junction}`).join(', ')}`);
2335
+ if (intents.has('websocket'))plan.push('WebSocket: rooms + broadcast');
1607
2336
  if (intents.has('ai')) plan.push('AI endpoint: /api/ask');
1608
2337
  if (intents.has('bot')) plan.push(`Bot: ${params.platform || 'discord'}`);
1609
2338
  if (intents.has('mail')) plan.push('Mail: SMTP configuration');
1610
2339
  if (intents.has('graphql')) plan.push('GraphQL endpoint');
1611
2340
  if (intents.has('page')) plan.push('Page: HTML generation');
1612
2341
  if (intents.has('cli')) plan.push('CLI app with flags');
2342
+ if (intents.has('server')) plan.push('Swagger UI at /docs');
1613
2343
  if (intents.has('database') && intents.has('schema')) plan.push('Seed: sample data function');
1614
2344
  plan.push(`Tests: ${params.entities.length} entity CRUD test suites`);
1615
2345
 
package/src/index.js CHANGED
@@ -3,7 +3,7 @@ import { Parser } from './parser.js';
3
3
  import { Generator } from './generator.js';
4
4
  import { preprocess } from './preprocess.js';
5
5
  import { TypeChecker } from './typechecker.js';
6
- import { generate, expandDirectives } from './gen.js';
6
+ import { generate, generateProject, updateProject, expandDirectives } from './gen.js';
7
7
 
8
8
  async function getGenerator(target, options) {
9
9
  switch (target) {
@@ -162,4 +162,4 @@ export function transpile(source, opts) {
162
162
  return compile(source, opts).js;
163
163
  }
164
164
 
165
- export { preprocess, generate };
165
+ export { preprocess, generate, generateProject, updateProject };