naider 1.20.0 → 1.21.1
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/README.md +73 -1
- package/bin/naide.js +148 -18
- package/mcp/server.js +1 -1
- package/package.json +1 -1
- package/src/gen.js +1173 -50
- package/src/index.js +2 -2
package/src/gen.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Lexer } from './lexer.js';
|
|
2
2
|
import { Parser } from './parser.js';
|
|
3
|
+
import { readFileSync as _readFS, writeFileSync as _writeFS, existsSync as _existsFS } from 'node:fs';
|
|
3
4
|
|
|
4
5
|
// ── Vocabulary ──────────────────────────────────────────────
|
|
5
6
|
|
|
@@ -81,8 +82,12 @@ const COMPOSITE_WORDS = {
|
|
|
81
82
|
todo: ['todo', 'todos', 'to-do', 'タスク管理', 'todoアプリ', 'やることリスト', 'todo管理'],
|
|
82
83
|
blog: ['blog', 'ブログ', '記事管理', '投稿管理', 'ブログサイト', 'ブログアプリ'],
|
|
83
84
|
chat: ['chat', 'チャットアプリ', 'メッセンジャー', 'チャット機能', 'チャットルーム'],
|
|
84
|
-
shop: ['shop', 'ecommerce', 'e-commerce', 'ショップ', 'ストア', '通販', 'ECサイト', 'EC', 'オンラインショップ'],
|
|
85
|
+
shop: ['shop', 'ecommerce', 'e-commerce', 'ショップ', 'ストア', '通販', 'ECサイト', 'EC', 'オンラインショップ', '通販サイト'],
|
|
85
86
|
board: ['board', '掲示板', 'フォーラム', 'BBS', 'bbs'],
|
|
87
|
+
crm: ['crm', 'CRM', '顧客管理', '顧客管理システム', 'カスタマー管理'],
|
|
88
|
+
inventory: ['inventory', '在庫管理', '倉庫管理', '在庫システム'],
|
|
89
|
+
booking: ['booking', 'reservation', '予約システム', '予約管理', '予約アプリ', '予約サイト'],
|
|
90
|
+
sns: ['sns', 'SNS', 'ソーシャル', 'social', 'social-media', 'ソーシャルメディア'],
|
|
86
91
|
};
|
|
87
92
|
|
|
88
93
|
const NOISE = new Set([
|
|
@@ -220,6 +225,47 @@ function defaultFields(schemaName) {
|
|
|
220
225
|
];
|
|
221
226
|
}
|
|
222
227
|
|
|
228
|
+
// ── Relationship Detection ─────────────────────────────────
|
|
229
|
+
|
|
230
|
+
const PARENT_CHILD = {
|
|
231
|
+
User: ['Post', 'Comment', 'Order', 'Message', 'Review', 'Task', 'Notification', 'File',
|
|
232
|
+
'Payment', 'Ticket', 'Note', 'Reservation', 'Activity', 'Log', 'Thread', 'Recipe'],
|
|
233
|
+
Category: ['Product', 'Post', 'Item'],
|
|
234
|
+
Product: ['Order', 'Review', 'Inventory'],
|
|
235
|
+
Post: ['Comment', 'Review'],
|
|
236
|
+
Project: ['Task', 'Ticket'],
|
|
237
|
+
Team: ['Employee', 'Staff', 'Project'],
|
|
238
|
+
Order: ['Payment'],
|
|
239
|
+
Event: ['Reservation'],
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
function detectRelationships(entities) {
|
|
243
|
+
const rels = [];
|
|
244
|
+
const names = entities.map(e => e.name);
|
|
245
|
+
for (let i = 0; i < names.length; i++) {
|
|
246
|
+
for (let j = 0; j < names.length; j++) {
|
|
247
|
+
if (i === j) continue;
|
|
248
|
+
const children = PARENT_CHILD[names[i]];
|
|
249
|
+
if (children && children.includes(names[j])) {
|
|
250
|
+
rels.push({ parent: names[i], child: names[j], fk: names[i].toLowerCase() + '_id' });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return rels;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function applyRelationships(entities, relationships) {
|
|
258
|
+
for (const rel of relationships) {
|
|
259
|
+
const child = entities.find(e => e.name === rel.child);
|
|
260
|
+
if (child && !child.fields.some(f => f.name === rel.fk)) {
|
|
261
|
+
const insertIdx = child.fields.findIndex(f => f.type !== 'auto' || f.name !== 'id');
|
|
262
|
+
const fkField = { name: rel.fk, type: 'int', modifiers: ['required'] };
|
|
263
|
+
if (insertIdx > 0) child.fields.splice(insertIdx, 0, fkField);
|
|
264
|
+
else child.fields.push(fkField);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
223
269
|
// ── Utilities ───────────────────────────────────────────────
|
|
224
270
|
|
|
225
271
|
function singularize(w) {
|
|
@@ -302,16 +348,20 @@ function classify(words, raw) {
|
|
|
302
348
|
}
|
|
303
349
|
}
|
|
304
350
|
|
|
305
|
-
// Composite detection
|
|
351
|
+
// Composite detection (match both lowercase and original for mixed JP/EN keywords)
|
|
306
352
|
for (const [comp, kws] of Object.entries(COMPOSITE_WORDS)) {
|
|
307
|
-
if (kws.some(k => lower.includes(k))) {
|
|
353
|
+
if (kws.some(k => lower.includes(k.toLowerCase()) || raw.includes(k))) {
|
|
308
354
|
matchStrength += 2;
|
|
309
355
|
if (comp === 'fullstack') { intents.add('server'); intents.add('schema'); intents.add('crud'); intents.add('auth'); intents.add('database'); }
|
|
310
356
|
if (comp === 'todo') { intents.add('server'); intents.add('schema'); intents.add('crud'); intents.add('database'); mods.preset = 'todo'; }
|
|
311
357
|
if (comp === 'blog') { intents.add('server'); intents.add('schema'); intents.add('crud'); intents.add('database'); mods.preset = 'post'; }
|
|
312
358
|
if (comp === 'chat') { intents.add('server'); intents.add('schema'); intents.add('websocket'); intents.add('database'); mods.preset = 'message'; }
|
|
313
|
-
if (comp === 'shop') { intents.add('server'); intents.add('schema'); intents.add('crud'); intents.add('auth'); intents.add('database'); mods.preset = 'product'; }
|
|
359
|
+
if (comp === 'shop') { intents.add('server'); intents.add('schema'); intents.add('crud'); intents.add('auth'); intents.add('database'); mods.preset = 'product'; mods.multiEntity = ['User', 'Product', 'Order']; }
|
|
314
360
|
if (comp === 'board') { intents.add('server'); intents.add('schema'); intents.add('crud'); intents.add('database'); mods.preset = 'post'; }
|
|
361
|
+
if (comp === 'crm') { intents.add('server'); intents.add('schema'); intents.add('crud'); intents.add('auth'); intents.add('database'); mods.multiEntity = ['Customer', 'Contact', 'Note']; }
|
|
362
|
+
if (comp === 'inventory') { intents.add('server'); intents.add('schema'); intents.add('crud'); intents.add('database'); mods.multiEntity = ['Product', 'Inventory']; }
|
|
363
|
+
if (comp === 'booking') { intents.add('server'); intents.add('schema'); intents.add('crud'); intents.add('auth'); intents.add('database'); mods.multiEntity = ['User', 'Reservation']; }
|
|
364
|
+
if (comp === 'sns') { intents.add('server'); intents.add('schema'); intents.add('crud'); intents.add('auth'); intents.add('websocket'); intents.add('database'); mods.multiEntity = ['User', 'Post', 'Comment']; }
|
|
315
365
|
}
|
|
316
366
|
}
|
|
317
367
|
|
|
@@ -332,6 +382,33 @@ function classify(words, raw) {
|
|
|
332
382
|
return !NOISE.has(wl) && !allKnown.has(wl) && !/^\d+$/.test(wl) && wl.length > 2 && /^[a-z]+s?$/i.test(wl);
|
|
333
383
|
}) || Object.keys(JA_ENTITIES).some(ja => raw.includes(ja));
|
|
334
384
|
|
|
385
|
+
// Complex NL phrase detection
|
|
386
|
+
if (/(?:users?\s+can\s+(?:comment|post|review|write)|コメントできる|投稿できる|レビューできる)/i.test(raw)) {
|
|
387
|
+
intents.add('auth'); intents.add('crud');
|
|
388
|
+
}
|
|
389
|
+
if (/(?:admin.*(?:delete|manage|moderate|ban)|管理者.*(?:削除|管理|モデレート))/i.test(raw)) {
|
|
390
|
+
intents.add('auth');
|
|
391
|
+
mods.needsAdmin = true;
|
|
392
|
+
}
|
|
393
|
+
if (/(?:upload|file\s+upload|画像.*アップ|ファイル.*アップ)/i.test(raw)) {
|
|
394
|
+
intents.add('crud');
|
|
395
|
+
}
|
|
396
|
+
if (/(?:search|検索|フィルタ|filter|sort|ソート)/i.test(raw)) {
|
|
397
|
+
intents.add('crud');
|
|
398
|
+
}
|
|
399
|
+
if (/(?:notification|通知|push|プッシュ|alert|アラート)/i.test(raw) && !intents.has('mail')) {
|
|
400
|
+
intents.add('websocket');
|
|
401
|
+
}
|
|
402
|
+
if (/(?:deploy|デプロイ|docker|コンテナ|container)/i.test(raw)) {
|
|
403
|
+
mods.needsDocker = true;
|
|
404
|
+
}
|
|
405
|
+
if (/(?:pagina|ページネーション|ページ送り|一覧表示)/i.test(raw)) {
|
|
406
|
+
intents.add('crud');
|
|
407
|
+
}
|
|
408
|
+
if (/(?:seed|初期データ|サンプルデータ|テストデータ|ダミーデータ)/i.test(raw)) {
|
|
409
|
+
mods.needsSeed = true;
|
|
410
|
+
}
|
|
411
|
+
|
|
335
412
|
// "for X" pattern implies schema + crud
|
|
336
413
|
if (hasEntity && intents.has('server')) {
|
|
337
414
|
intents.add('schema'); intents.add('crud');
|
|
@@ -358,7 +435,8 @@ function classify(words, raw) {
|
|
|
358
435
|
}
|
|
359
436
|
|
|
360
437
|
function extractParams(words, raw, intents, mods) {
|
|
361
|
-
const params = { port: 3000, schemaName: null, fields: [],
|
|
438
|
+
const params = { port: 3000, schemaName: null, fields: [], entities: [], relationships: [],
|
|
439
|
+
features: intents, platform: mods.platform || null, dbType: mods.dbType || null, commands: [] };
|
|
362
440
|
|
|
363
441
|
// Extract port number
|
|
364
442
|
for (let i = 0; i < words.length; i++) {
|
|
@@ -370,55 +448,98 @@ function extractParams(words, raw, intents, mods) {
|
|
|
370
448
|
}
|
|
371
449
|
}
|
|
372
450
|
|
|
373
|
-
//
|
|
451
|
+
// ── Multi-entity extraction ──
|
|
452
|
+
const entityNames = [];
|
|
453
|
+
|
|
454
|
+
// Japanese entities (longest match first, collect ALL matches)
|
|
374
455
|
const jaEntries = Object.entries(JA_ENTITIES).sort((a, b) => b[0].length - a[0].length);
|
|
456
|
+
const jaUsed = new Set();
|
|
375
457
|
for (const [ja, en] of jaEntries) {
|
|
376
|
-
if (raw.includes(ja)
|
|
458
|
+
if (raw.includes(ja) && !jaUsed.has(en)) {
|
|
459
|
+
entityNames.push(en);
|
|
460
|
+
jaUsed.add(en);
|
|
461
|
+
}
|
|
377
462
|
}
|
|
378
463
|
|
|
379
|
-
//
|
|
380
|
-
if (
|
|
464
|
+
// English entities: extract nouns from the full instruction, splitting on "and" / ","
|
|
465
|
+
if (entityNames.length === 0) {
|
|
381
466
|
const allKnown = new Set([...Object.values(INTENT_WORDS).flat(), ...Object.values(PLATFORM_WORDS).flat(),
|
|
382
467
|
...Object.values(COMPOSITE_WORDS).flat()]);
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
468
|
+
const lower = raw.toLowerCase();
|
|
469
|
+
const segments = lower.split(/\s+(?:and|&)\s+|,\s*/);
|
|
470
|
+
for (const seg of segments) {
|
|
471
|
+
const segWords = seg.trim().split(/\s+/).filter(w => w !== 'with');
|
|
472
|
+
for (const w of segWords) {
|
|
473
|
+
const wl = w.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
474
|
+
if (!wl || NOISE.has(wl) || allKnown.has(wl) || /^\d+$/.test(wl) || wl.length <= 2) continue;
|
|
475
|
+
const name = capitalize(singularize(wl));
|
|
476
|
+
if (!entityNames.includes(name)) entityNames.push(name);
|
|
477
|
+
break;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Composite multi-entity presets (shop, crm, booking, sns, etc.)
|
|
483
|
+
if (mods.multiEntity && entityNames.length <= 1) {
|
|
484
|
+
for (const e of mods.multiEntity) {
|
|
485
|
+
if (!entityNames.includes(e)) entityNames.push(e);
|
|
388
486
|
}
|
|
389
487
|
}
|
|
390
488
|
|
|
391
489
|
// Preset override
|
|
392
|
-
if (mods.preset &&
|
|
393
|
-
|
|
490
|
+
if (mods.preset && entityNames.length === 0) {
|
|
491
|
+
entityNames.push(capitalize(mods.preset));
|
|
394
492
|
}
|
|
395
493
|
|
|
396
|
-
// Default
|
|
397
|
-
if (
|
|
494
|
+
// Default
|
|
495
|
+
if (entityNames.length === 0 && intents.has('schema')) entityNames.push('Item');
|
|
398
496
|
|
|
399
|
-
//
|
|
497
|
+
// Primary entity (backward compat)
|
|
498
|
+
params.schemaName = entityNames[0] || null;
|
|
499
|
+
|
|
500
|
+
// Extract fields from "with" clause (applies to primary entity)
|
|
501
|
+
// Only treat words after "with" as fields if they look like field names (not entity names or intent keywords)
|
|
400
502
|
const lower = raw.toLowerCase();
|
|
503
|
+
let primaryFields = [];
|
|
401
504
|
const withIdx = lower.indexOf(' with ');
|
|
402
505
|
if (withIdx !== -1) {
|
|
403
506
|
const afterWith = lower.slice(withIdx + 6);
|
|
404
|
-
const
|
|
405
|
-
|
|
507
|
+
const primaryIntents = new Set(['server', 'api', 'rest', 'bot', 'cli', 'page', 'test', 'database',
|
|
508
|
+
'auth', 'crud', 'websocket', 'mail', 'graphql', 'sqlite', 'postgres']);
|
|
509
|
+
const entityLike = new Set(entityNames.map(e => e.toLowerCase()));
|
|
510
|
+
const entityLikePlural = new Set(entityNames.map(e => pluralize(e.toLowerCase())));
|
|
511
|
+
const candidateWords = afterWith
|
|
406
512
|
.split(/[\s,、]+/)
|
|
407
513
|
.map(w => w.replace(/[^a-zA-Z0-9_\-]/g, ''))
|
|
408
|
-
.filter(w => w.length > 1 && !NOISE.has(w) && !
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
514
|
+
.filter(w => w.length > 1 && !NOISE.has(w) && !primaryIntents.has(w) && !/^\d+$/.test(w)
|
|
515
|
+
&& !entityLike.has(w) && !entityLikePlural.has(w) && w !== 'and');
|
|
516
|
+
// Only use as fields if they look like actual field names (not entity names)
|
|
517
|
+
const knownFieldish = new Set([...Object.keys(FIELD_SYNONYMS), ...Object.keys(FIELD_TYPES).flatMap(k => FIELD_TYPES[k])]);
|
|
518
|
+
const hasFieldNames = candidateWords.some(w => knownFieldish.has(normalizeField(w)));
|
|
519
|
+
if (candidateWords.length > 0 && hasFieldNames) {
|
|
520
|
+
primaryFields = [{ name: 'id', type: 'auto', modifiers: [] }];
|
|
521
|
+
for (const rawName of candidateWords) {
|
|
412
522
|
const name = normalizeField(rawName);
|
|
413
523
|
const type = inferType(name);
|
|
414
|
-
|
|
524
|
+
primaryFields.push({ name, type, modifiers: inferModifiers(name, type) });
|
|
415
525
|
}
|
|
416
526
|
}
|
|
417
527
|
}
|
|
418
528
|
|
|
419
|
-
//
|
|
420
|
-
|
|
421
|
-
|
|
529
|
+
// Build entities array
|
|
530
|
+
for (let i = 0; i < entityNames.length; i++) {
|
|
531
|
+
const name = entityNames[i];
|
|
532
|
+
const fields = (i === 0 && primaryFields.length > 0) ? primaryFields : defaultFields(name);
|
|
533
|
+
params.entities.push({ name, fields });
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Backward compat: primary entity fields
|
|
537
|
+
params.fields = params.entities.length > 0 ? params.entities[0].fields : [];
|
|
538
|
+
|
|
539
|
+
// Detect relationships between entities
|
|
540
|
+
if (params.entities.length > 1) {
|
|
541
|
+
params.relationships = detectRelationships(params.entities);
|
|
542
|
+
applyRelationships(params.entities, params.relationships);
|
|
422
543
|
}
|
|
423
544
|
|
|
424
545
|
// Extract bot commands
|
|
@@ -483,6 +604,87 @@ function testBlock(name) {
|
|
|
483
604
|
assert [1, 2, 3].length == 3`;
|
|
484
605
|
}
|
|
485
606
|
|
|
607
|
+
function entityTestBlock(entity) {
|
|
608
|
+
const name = entity.name;
|
|
609
|
+
const lower = name.toLowerCase();
|
|
610
|
+
const plural = pluralize(lower);
|
|
611
|
+
const store = name + 'Store';
|
|
612
|
+
const requiredFields = entity.fields.filter(f => f.modifiers.includes('required') && f.type !== 'auto');
|
|
613
|
+
const sampleObj = requiredFields.map(f => {
|
|
614
|
+
if (f.type === 'int') return `${f.name}: 1`;
|
|
615
|
+
if (f.type === 'bool') return `${f.name}: true`;
|
|
616
|
+
return `${f.name}: "test"`;
|
|
617
|
+
}).join(', ');
|
|
618
|
+
|
|
619
|
+
return `test "${lower} CRUD":
|
|
620
|
+
any created = ${store}.create({${sampleObj}})
|
|
621
|
+
assert created.id
|
|
622
|
+
|
|
623
|
+
any found = ${store}.find(created.id)
|
|
624
|
+
assert found.id == created.id
|
|
625
|
+
|
|
626
|
+
list all = ${store}.all()
|
|
627
|
+
assert all.length > 0
|
|
628
|
+
|
|
629
|
+
${store}.remove(created.id)
|
|
630
|
+
any deleted = ${store}.find(created.id)
|
|
631
|
+
assert not deleted`;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function nestedRouteBlock(parent, child, fk) {
|
|
635
|
+
const parentLower = parent.toLowerCase();
|
|
636
|
+
const childLower = child.toLowerCase();
|
|
637
|
+
const childPlural = pluralize(childLower);
|
|
638
|
+
const childStore = child + 'Store';
|
|
639
|
+
return [
|
|
640
|
+
`get "/api/${pluralize(parentLower)}/:${fk.replace('_id', 'Id')}/${childPlural}" (req, res):`,
|
|
641
|
+
` list items = ${childStore}.findAll("${fk}", req.params.${fk.replace('_id', 'Id')})`,
|
|
642
|
+
` ret items`,
|
|
643
|
+
];
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function paginatedListBlock(entity) {
|
|
647
|
+
const lower = entity.name.toLowerCase();
|
|
648
|
+
const plural = pluralize(lower);
|
|
649
|
+
const store = entity.name + 'Store';
|
|
650
|
+
return [
|
|
651
|
+
`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()`,
|
|
656
|
+
` 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}`,
|
|
662
|
+
];
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function seedBlock(entities) {
|
|
666
|
+
const lines = ['fn seed():'];
|
|
667
|
+
for (const entity of entities) {
|
|
668
|
+
const store = entity.name + 'Store';
|
|
669
|
+
const seen = new Set();
|
|
670
|
+
const sampleFields = [];
|
|
671
|
+
for (const f of entity.fields) {
|
|
672
|
+
if (f.type === 'auto' || seen.has(f.name)) continue;
|
|
673
|
+
seen.add(f.name);
|
|
674
|
+
if (f.name === 'password') { sampleFields.push('password: "hashed_demo"'); continue; }
|
|
675
|
+
if (!f.modifiers.includes('required') && !f.name.endsWith('_id')) continue;
|
|
676
|
+
if (f.name.endsWith('_id')) sampleFields.push(`${f.name}: 1`);
|
|
677
|
+
else if (f.type === 'int') sampleFields.push(`${f.name}: ${f.name === 'price' ? 1000 : f.name === 'total' ? 2500 : 1}`);
|
|
678
|
+
else if (f.type === 'bool') sampleFields.push(`${f.name}: false`);
|
|
679
|
+
else if (f.name === 'email') sampleFields.push(`${f.name}: "demo@example.com"`);
|
|
680
|
+
else sampleFields.push(`${f.name}: "Sample ${entity.name}"`);
|
|
681
|
+
}
|
|
682
|
+
lines.push(` ${store}.create({${sampleFields.join(', ')}})`);
|
|
683
|
+
}
|
|
684
|
+
lines.push(' log("Seed data inserted")');
|
|
685
|
+
return lines.join('\n');
|
|
686
|
+
}
|
|
687
|
+
|
|
486
688
|
function graphqlBlock() {
|
|
487
689
|
return `graphql "/graphql"`;
|
|
488
690
|
}
|
|
@@ -499,16 +701,380 @@ function mailBlock() {
|
|
|
499
701
|
pass SMTP_PASS`;
|
|
500
702
|
}
|
|
501
703
|
|
|
704
|
+
// ── Middleware & Utility Templates ──────────────────────────
|
|
705
|
+
|
|
706
|
+
function utilityFunctions(intents) {
|
|
707
|
+
const fns = [];
|
|
708
|
+
if (intents.has('server')) {
|
|
709
|
+
fns.push(`fn ok(any data, any meta):
|
|
710
|
+
ret {ok: true, data, error: null, meta}`);
|
|
711
|
+
fns.push(`fn err(str message, int code):
|
|
712
|
+
ret {ok: false, data: null, error: message, code}`);
|
|
713
|
+
fns.push(`fn sanitize(str input):
|
|
714
|
+
ret input.replace("<", "<").replace(">", ">").replace("'", "'")`);
|
|
715
|
+
}
|
|
716
|
+
return fns;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function softDeleteRoutes(entity) {
|
|
720
|
+
const lower = entity.name.toLowerCase();
|
|
721
|
+
const plural = pluralize(lower);
|
|
722
|
+
const store = entity.name + 'Store';
|
|
723
|
+
return [
|
|
724
|
+
`del "/api/${plural}/:id/soft" (req, res):`,
|
|
725
|
+
` ${store}.update(req.params.id, {deleted: true})`,
|
|
726
|
+
` ret ok({archived: true}, null)`,
|
|
727
|
+
``,
|
|
728
|
+
`put "/api/${plural}/:id/restore" (req, res):`,
|
|
729
|
+
` ${store}.update(req.params.id, {deleted: false})`,
|
|
730
|
+
` ret ok({restored: true}, null)`,
|
|
731
|
+
];
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function batchRoutes(entity) {
|
|
735
|
+
const lower = entity.name.toLowerCase();
|
|
736
|
+
const plural = pluralize(lower);
|
|
737
|
+
const store = entity.name + 'Store';
|
|
738
|
+
return [
|
|
739
|
+
`post "/api/${plural}/batch" (req, res):`,
|
|
740
|
+
` list results = []`,
|
|
741
|
+
` each item in req.body.items:`,
|
|
742
|
+
` any created = ${store}.create(item)`,
|
|
743
|
+
` results.push(created)`,
|
|
744
|
+
` ret ok({created: results.length, items: results}, null)`,
|
|
745
|
+
``,
|
|
746
|
+
`del "/api/${plural}/batch" (req, res):`,
|
|
747
|
+
` each id in req.body.ids:`,
|
|
748
|
+
` ${store}.remove(id)`,
|
|
749
|
+
` ret ok({deleted: req.body.ids.length}, null)`,
|
|
750
|
+
];
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function uploadRoute() {
|
|
754
|
+
return [
|
|
755
|
+
`post "/api/upload" (req, res):`,
|
|
756
|
+
` any file = req.files.file`,
|
|
757
|
+
` if not file:`,
|
|
758
|
+
` ret.status(400) err("No file provided", 400)`,
|
|
759
|
+
` str path = "uploads/" + file.name`,
|
|
760
|
+
` file.mv(path)`,
|
|
761
|
+
` ret ok({path, name: file.name, size: file.size}, null)`,
|
|
762
|
+
];
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function auditSchema() {
|
|
766
|
+
return `schema AuditLog:
|
|
767
|
+
id auto
|
|
768
|
+
action str required
|
|
769
|
+
entity str required
|
|
770
|
+
entity_id int
|
|
771
|
+
user_id int
|
|
772
|
+
detail str
|
|
773
|
+
created auto`;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function auditFn() {
|
|
777
|
+
return `fn auditLog(str action, str entity, int entityId, int userId):
|
|
778
|
+
AuditLogStore.create({action, entity, entity_id: entityId, user_id: userId})`;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function webhookRoute() {
|
|
782
|
+
return [
|
|
783
|
+
`post "/api/webhooks" (req, res):`,
|
|
784
|
+
` str event = req.body.event`,
|
|
785
|
+
` any payload = req.body.payload`,
|
|
786
|
+
` log("Webhook received: " + event)`,
|
|
787
|
+
` ret ok({received: true, event}, null)`,
|
|
788
|
+
``,
|
|
789
|
+
`get "/api/webhooks/health" (req, res):`,
|
|
790
|
+
` ret ok({status: "listening"}, null)`,
|
|
791
|
+
];
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function serverMiddlewareHints(intents, params) {
|
|
795
|
+
const hints = [];
|
|
796
|
+
hints.push('# ── Middleware (install if needed) ──');
|
|
797
|
+
hints.push('# npm install express-rate-limit → rateLimit({windowMs: 900000, max: 100})');
|
|
798
|
+
hints.push('# npm install helmet → helmet() for security headers');
|
|
799
|
+
hints.push('# npm install compression → compression() for gzip');
|
|
800
|
+
if (intents.has('auth')) {
|
|
801
|
+
hints.push('# npm install cors → cors({origin: "https://yourdomain.com"})');
|
|
802
|
+
}
|
|
803
|
+
return hints.join('\n');
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function openApiSpec(entities, intents, params, relationships) {
|
|
807
|
+
const paths = {};
|
|
808
|
+
for (const entity of entities) {
|
|
809
|
+
const plural = pluralize(entity.name.toLowerCase());
|
|
810
|
+
const requiredFields = entity.fields.filter(f => f.modifiers.includes('required') && f.type !== 'auto');
|
|
811
|
+
const properties = {};
|
|
812
|
+
for (const f of entity.fields) {
|
|
813
|
+
properties[f.name] = { type: f.type === 'auto' ? 'integer' : f.type === 'int' ? 'integer' : f.type === 'bool' ? 'boolean' : 'string' };
|
|
814
|
+
}
|
|
815
|
+
paths[`/api/${plural}`] = {
|
|
816
|
+
get: { summary: `List ${plural} (paginated)`, parameters: [
|
|
817
|
+
{ name: 'page', in: 'query', schema: { type: 'integer', default: 1 } },
|
|
818
|
+
{ name: 'limit', in: 'query', schema: { type: 'integer', default: 20 } },
|
|
819
|
+
{ name: 'search', in: 'query', schema: { type: 'string' } },
|
|
820
|
+
], responses: { '200': { description: `List of ${plural}` } } },
|
|
821
|
+
post: { summary: `Create ${entity.name.toLowerCase()}`, requestBody: { content: { 'application/json': { schema: {
|
|
822
|
+
type: 'object', required: requiredFields.map(f => f.name),
|
|
823
|
+
properties: Object.fromEntries(requiredFields.map(f => [f.name, properties[f.name]])),
|
|
824
|
+
} } } }, responses: { '201': { description: 'Created' } } },
|
|
825
|
+
};
|
|
826
|
+
paths[`/api/${plural}/{id}`] = {
|
|
827
|
+
get: { summary: `Get ${entity.name.toLowerCase()} by ID`, responses: { '200': { description: entity.name } } },
|
|
828
|
+
put: { summary: `Update ${entity.name.toLowerCase()}`, responses: { '200': { description: 'Updated' } } },
|
|
829
|
+
delete: { summary: `Delete ${entity.name.toLowerCase()}`, responses: { '200': { description: 'Deleted' } } },
|
|
830
|
+
};
|
|
831
|
+
paths[`/api/${plural}/batch`] = {
|
|
832
|
+
post: { summary: `Batch create ${plural}`, responses: { '200': { description: 'Batch created' } } },
|
|
833
|
+
delete: { summary: `Batch delete ${plural}`, responses: { '200': { description: 'Batch deleted' } } },
|
|
834
|
+
};
|
|
835
|
+
paths[`/api/${plural}/{id}/soft`] = {
|
|
836
|
+
delete: { summary: `Soft delete ${entity.name.toLowerCase()}`, responses: { '200': { description: 'Archived' } } },
|
|
837
|
+
};
|
|
838
|
+
paths[`/api/${plural}/{id}/restore`] = {
|
|
839
|
+
put: { summary: `Restore ${entity.name.toLowerCase()}`, responses: { '200': { description: 'Restored' } } },
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
for (const rel of relationships) {
|
|
843
|
+
const parentPlural = pluralize(rel.parent.toLowerCase());
|
|
844
|
+
const childPlural = pluralize(rel.child.toLowerCase());
|
|
845
|
+
paths[`/api/${parentPlural}/{${rel.fk.replace('_id', 'Id')}}/${childPlural}`] = {
|
|
846
|
+
get: { summary: `${rel.child}s by ${rel.parent}`, responses: { '200': { description: `List of ${childPlural}` } } },
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
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' } } } };
|
|
852
|
+
paths['/api/auth/me'] = { get: { summary: 'Current user', security: [{ bearerAuth: [] }], responses: { '200': { description: 'User' } } } };
|
|
853
|
+
}
|
|
854
|
+
paths['/api/health'] = { get: { summary: 'Health check', responses: { '200': { description: 'Status' } } } };
|
|
855
|
+
paths['/api/upload'] = { post: { summary: 'Upload file', responses: { '200': { description: 'File info' } } } };
|
|
856
|
+
|
|
857
|
+
const spec = {
|
|
858
|
+
openapi: '3.0.3',
|
|
859
|
+
info: { title: (entities[0]?.name || 'App') + ' API', version: '1.0.0', description: 'Generated by NAIDE Agent Coder' },
|
|
860
|
+
servers: [{ url: `http://localhost:${params.port}` }],
|
|
861
|
+
paths,
|
|
862
|
+
};
|
|
863
|
+
if (intents.has('auth')) {
|
|
864
|
+
spec.components = { securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' } } };
|
|
865
|
+
}
|
|
866
|
+
return spec;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function adminPage(entities, intents) {
|
|
870
|
+
const lines = [];
|
|
871
|
+
lines.push('<!DOCTYPE html>');
|
|
872
|
+
lines.push('<html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">');
|
|
873
|
+
lines.push('<title>Admin Dashboard</title>');
|
|
874
|
+
lines.push('<style>*{margin:0;padding:0;box-sizing:border-box}body{font-family:system-ui;background:#f5f5f5;padding:2rem}');
|
|
875
|
+
lines.push('h1{margin-bottom:1rem}.card{background:#fff;border-radius:8px;padding:1.5rem;margin-bottom:1rem;box-shadow:0 1px 3px rgba(0,0,0,.1)}');
|
|
876
|
+
lines.push('table{width:100%;border-collapse:collapse}th,td{padding:.5rem;text-align:left;border-bottom:1px solid #eee}');
|
|
877
|
+
lines.push('th{background:#f9f9f9;font-weight:600}button{padding:.4rem 1rem;border:none;border-radius:4px;cursor:pointer;background:#2563eb;color:#fff}');
|
|
878
|
+
lines.push('button:hover{background:#1d4ed8}button.danger{background:#dc2626}button.danger:hover{background:#b91c1c}');
|
|
879
|
+
lines.push('input,select{padding:.4rem;border:1px solid #ddd;border-radius:4px;margin-right:.5rem}');
|
|
880
|
+
lines.push('.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1rem;margin-bottom:1.5rem}');
|
|
881
|
+
lines.push('.stat{background:#fff;border-radius:8px;padding:1.5rem;text-align:center;box-shadow:0 1px 3px rgba(0,0,0,.1)}');
|
|
882
|
+
lines.push('.stat h3{font-size:2rem;color:#2563eb}.stat p{color:#666;margin-top:.5rem}');
|
|
883
|
+
lines.push('#toast{position:fixed;top:1rem;right:1rem;background:#22c55e;color:#fff;padding:.8rem 1.5rem;border-radius:8px;display:none}');
|
|
884
|
+
lines.push('</style></head><body>');
|
|
885
|
+
lines.push('<h1>Admin Dashboard</h1>');
|
|
886
|
+
lines.push('<div id="toast"></div>');
|
|
887
|
+
lines.push('<div class="stats" id="stats"></div>');
|
|
888
|
+
for (const entity of entities) {
|
|
889
|
+
const lower = entity.name.toLowerCase();
|
|
890
|
+
const plural = pluralize(lower);
|
|
891
|
+
lines.push(`<div class="card"><h2>${entity.name}s</h2>`);
|
|
892
|
+
lines.push(`<div style="margin:1rem 0"><input id="${lower}-search" placeholder="Search..." oninput="${lower}Load()">`);
|
|
893
|
+
lines.push(`<button onclick="${lower}Load()">Refresh</button> <button onclick="${lower}ShowForm()">+ New</button></div>`);
|
|
894
|
+
lines.push(`<div id="${lower}-form" style="display:none;margin:1rem 0;padding:1rem;background:#f9f9f9;border-radius:4px">`);
|
|
895
|
+
for (const f of entity.fields) {
|
|
896
|
+
if (f.type === 'auto') continue;
|
|
897
|
+
const inputType = f.type === 'int' ? 'number' : f.type === 'bool' ? 'checkbox' : f.name === 'password' ? 'password' : f.name === 'email' ? 'email' : 'text';
|
|
898
|
+
if (f.type === 'bool') {
|
|
899
|
+
lines.push(`<label><input type="checkbox" id="${lower}-${f.name}"> ${f.name}</label> `);
|
|
900
|
+
} else {
|
|
901
|
+
lines.push(`<input type="${inputType}" id="${lower}-${f.name}" placeholder="${f.name}${f.modifiers.includes('required') ? ' *' : ''}">`);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
lines.push(`<button onclick="${lower}Create()">Save</button> <button onclick="document.getElementById('${lower}-form').style.display='none'" class="danger">Cancel</button></div>`);
|
|
905
|
+
lines.push(`<table><thead><tr>${entity.fields.map(f => `<th>${f.name}</th>`).join('')}<th>Actions</th></tr></thead>`);
|
|
906
|
+
lines.push(`<tbody id="${lower}-table"></tbody></table></div>`);
|
|
907
|
+
}
|
|
908
|
+
lines.push('<script>');
|
|
909
|
+
lines.push('const API="";function toast(m){const t=document.getElementById("toast");t.textContent=m;t.style.display="block";setTimeout(()=>t.style.display="none",2000)}');
|
|
910
|
+
lines.push('async function loadStats(){try{const r=await fetch(API+"/api/health");const d=await r.json();const s=document.getElementById("stats");');
|
|
911
|
+
lines.push('s.innerHTML=Object.entries(d).filter(([k])=>k!=="status").map(([k,v])=>`<div class="stat"><h3>${v}</h3><p>${k}</p></div>`).join("")}catch(e){}}');
|
|
912
|
+
for (const entity of entities) {
|
|
913
|
+
const lower = entity.name.toLowerCase();
|
|
914
|
+
const plural = pluralize(lower);
|
|
915
|
+
const fields = entity.fields.filter(f => f.type !== 'auto');
|
|
916
|
+
lines.push(`async function ${lower}Load(){const s=document.getElementById("${lower}-search").value;`);
|
|
917
|
+
lines.push(`const r=await fetch(API+"/api/${plural}?search="+s);const d=await r.json();const items=d.items||d;`);
|
|
918
|
+
lines.push(`document.getElementById("${lower}-table").innerHTML=items.map(i=>"<tr>${entity.fields.map(f => `<td>"+i.${f.name}+"`).join('')}<td>"+`);
|
|
919
|
+
lines.push(`"<button onclick=\\"${lower}Del("+i.id+")\\">Del</button></td></tr>").join("")}`);
|
|
920
|
+
lines.push(`function ${lower}ShowForm(){document.getElementById("${lower}-form").style.display="block"}`);
|
|
921
|
+
lines.push(`async function ${lower}Create(){const body={${fields.map(f => {
|
|
922
|
+
if (f.type === 'bool') return `${f.name}:document.getElementById("${lower}-${f.name}").checked`;
|
|
923
|
+
if (f.type === 'int') return `${f.name}:+document.getElementById("${lower}-${f.name}").value`;
|
|
924
|
+
return `${f.name}:document.getElementById("${lower}-${f.name}").value`;
|
|
925
|
+
}).join(',')}};`);
|
|
926
|
+
lines.push(`await fetch(API+"/api/${plural}",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});`);
|
|
927
|
+
lines.push(`document.getElementById("${lower}-form").style.display="none";toast("Created!");${lower}Load();loadStats()}`);
|
|
928
|
+
lines.push(`async function ${lower}Del(id){if(!confirm("Delete?"))return;await fetch(API+"/api/${plural}/"+id,{method:"DELETE"});toast("Deleted!");${lower}Load();loadStats()}`);
|
|
929
|
+
}
|
|
930
|
+
lines.push(`loadStats();${entities.map(e => pluralize(e.name.toLowerCase()).replace(/^./, c => c) && e.name.toLowerCase() + 'Load()').join(';')}`);
|
|
931
|
+
lines.push('</script></body></html>');
|
|
932
|
+
return lines.join('\n');
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function apiClientCode(entities, intents, port) {
|
|
936
|
+
const lines = [];
|
|
937
|
+
lines.push('// Auto-generated API client — NAIDE Agent Coder');
|
|
938
|
+
lines.push(`const BASE = typeof window !== 'undefined' ? '' : 'http://localhost:${port}';`);
|
|
939
|
+
lines.push('let _token = null;');
|
|
940
|
+
lines.push('');
|
|
941
|
+
lines.push('function headers() {');
|
|
942
|
+
lines.push(" const h = { 'Content-Type': 'application/json' };");
|
|
943
|
+
lines.push(" if (_token) h['Authorization'] = 'Bearer ' + _token;");
|
|
944
|
+
lines.push(' return h;');
|
|
945
|
+
lines.push('}');
|
|
946
|
+
lines.push('');
|
|
947
|
+
lines.push('async function api(method, path, body) {');
|
|
948
|
+
lines.push(' const opts = { method, headers: headers() };');
|
|
949
|
+
lines.push(' if (body) opts.body = JSON.stringify(body);');
|
|
950
|
+
lines.push(' const res = await fetch(BASE + path, opts);');
|
|
951
|
+
lines.push(' return res.json();');
|
|
952
|
+
lines.push('}');
|
|
953
|
+
lines.push('');
|
|
954
|
+
if (intents.has('auth')) {
|
|
955
|
+
lines.push('export async function register(name, email, password) {');
|
|
956
|
+
lines.push(" const r = await api('POST', '/api/auth/register', { name, email, password });");
|
|
957
|
+
lines.push(' if (r.token) _token = r.token;');
|
|
958
|
+
lines.push(' return r;');
|
|
959
|
+
lines.push('}');
|
|
960
|
+
lines.push('');
|
|
961
|
+
lines.push('export async function login(email, password) {');
|
|
962
|
+
lines.push(" const r = await api('POST', '/api/auth/login', { email, password });");
|
|
963
|
+
lines.push(' if (r.token) _token = r.token;');
|
|
964
|
+
lines.push(' return r;');
|
|
965
|
+
lines.push('}');
|
|
966
|
+
lines.push('');
|
|
967
|
+
lines.push("export async function me() { return api('GET', '/api/auth/me'); }");
|
|
968
|
+
lines.push('export function setToken(t) { _token = t; }');
|
|
969
|
+
lines.push('');
|
|
970
|
+
}
|
|
971
|
+
for (const entity of entities) {
|
|
972
|
+
const lower = entity.name.toLowerCase();
|
|
973
|
+
const plural = pluralize(lower);
|
|
974
|
+
const Name = entity.name;
|
|
975
|
+
lines.push(`// ${Name}`);
|
|
976
|
+
lines.push(`export async function list${Name}s(page = 1, limit = 20, search = '') {`);
|
|
977
|
+
lines.push(` return api('GET', \`/api/${plural}?page=\${page}&limit=\${limit}&search=\${search}\`);`);
|
|
978
|
+
lines.push('}');
|
|
979
|
+
lines.push(`export async function get${Name}(id) { return api('GET', \`/api/${plural}/\${id}\`); }`);
|
|
980
|
+
lines.push(`export async function create${Name}(data) { return api('POST', '/api/${plural}', data); }`);
|
|
981
|
+
lines.push(`export async function update${Name}(id, data) { return api('PUT', \`/api/${plural}/\${id}\`, data); }`);
|
|
982
|
+
lines.push(`export async function remove${Name}(id) { return api('DELETE', \`/api/${plural}/\${id}\`); }`);
|
|
983
|
+
lines.push(`export async function archive${Name}(id) { return api('DELETE', \`/api/${plural}/\${id}/soft\`); }`);
|
|
984
|
+
lines.push(`export async function restore${Name}(id) { return api('PUT', \`/api/${plural}/\${id}/restore\`); }`);
|
|
985
|
+
lines.push(`export async function batchCreate${Name}s(items) { return api('POST', '/api/${plural}/batch', { items }); }`);
|
|
986
|
+
lines.push(`export async function batchRemove${Name}s(ids) { return api('DELETE', '/api/${plural}/batch', { ids }); }`);
|
|
987
|
+
lines.push('');
|
|
988
|
+
}
|
|
989
|
+
lines.push("export async function upload(file) { const fd = new FormData(); fd.append('file', file); const r = await fetch(BASE + '/api/upload', { method: 'POST', headers: _token ? { Authorization: 'Bearer ' + _token } : {}, body: fd }); return r.json(); }");
|
|
990
|
+
lines.push("export async function health() { return api('GET', '/api/health'); }");
|
|
991
|
+
return lines.join('\n') + '\n';
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// ── Auth Route Templates ────────────────────────────────────
|
|
995
|
+
|
|
996
|
+
function authRoutesBlock(userEntity) {
|
|
997
|
+
const store = userEntity + 'Store';
|
|
998
|
+
return [
|
|
999
|
+
`post "/api/auth/register" (req, res):`,
|
|
1000
|
+
` str hashed = await hash(req.body.password)`,
|
|
1001
|
+
` any user = ${store}.create({name: req.body.name, email: req.body.email, password: hashed})`,
|
|
1002
|
+
` str token = jwt.sign({id: user.id}, JWT_SECRET)`,
|
|
1003
|
+
` ret {token, user: {id: user.id, name: user.name, email: user.email}}`,
|
|
1004
|
+
``,
|
|
1005
|
+
`post "/api/auth/login" (req, res):`,
|
|
1006
|
+
` any user = ${store}.findBy("email", req.body.email)`,
|
|
1007
|
+
` if not user:`,
|
|
1008
|
+
` ret.status(401) {error: "Invalid credentials"}`,
|
|
1009
|
+
` bool valid = await verify(req.body.password, user.password)`,
|
|
1010
|
+
` if not valid:`,
|
|
1011
|
+
` ret.status(401) {error: "Invalid credentials"}`,
|
|
1012
|
+
` str token = jwt.sign({id: user.id}, JWT_SECRET)`,
|
|
1013
|
+
` ret {token, user: {id: user.id, name: user.name, email: user.email}}`,
|
|
1014
|
+
``,
|
|
1015
|
+
`get "/api/auth/me" (req, res):`,
|
|
1016
|
+
` ret {user: req.user}`,
|
|
1017
|
+
];
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function envLines(intents, params) {
|
|
1021
|
+
const lines = [];
|
|
1022
|
+
if (intents.has('auth')) lines.push('# env JWT_SECRET "change-me-in-production"');
|
|
1023
|
+
if (params.dbType === 'postgres') lines.push('# env DATABASE_URL "postgres://localhost:5432/app"');
|
|
1024
|
+
if (intents.has('mail')) {
|
|
1025
|
+
lines.push('# env SMTP_HOST "smtp.example.com"');
|
|
1026
|
+
lines.push('# env SMTP_USER "user@example.com"');
|
|
1027
|
+
lines.push('# env SMTP_PASS "password"');
|
|
1028
|
+
}
|
|
1029
|
+
if (intents.has('bot')) {
|
|
1030
|
+
const token = (params.platform || 'discord').toUpperCase() + '_TOKEN';
|
|
1031
|
+
lines.push(`# env ${token} "your-bot-token"`);
|
|
1032
|
+
}
|
|
1033
|
+
if (intents.has('ai')) lines.push('# env OPENAI_API_KEY "your-api-key"');
|
|
1034
|
+
return lines;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
502
1037
|
// ── Code Composer ───────────────────────────────────────────
|
|
503
1038
|
|
|
504
1039
|
function compose(intents, params) {
|
|
505
1040
|
const sections = [];
|
|
506
|
-
const
|
|
507
|
-
const
|
|
508
|
-
const
|
|
1041
|
+
const entities = params.entities.length > 0 ? params.entities : [{ name: params.schemaName || 'Item', fields: params.fields }];
|
|
1042
|
+
const primaryName = entities[0].name;
|
|
1043
|
+
const primaryLower = primaryName.toLowerCase();
|
|
1044
|
+
const relationships = params.relationships || [];
|
|
1045
|
+
const hasAuth = intents.has('auth');
|
|
1046
|
+
const userEntity = entities.find(e => ['User', 'Customer', 'Employee', 'Staff'].includes(e.name));
|
|
1047
|
+
|
|
1048
|
+
// Middleware hints
|
|
1049
|
+
if (intents.has('server')) {
|
|
1050
|
+
sections.push(serverMiddlewareHints(intents, params));
|
|
1051
|
+
}
|
|
509
1052
|
|
|
510
|
-
|
|
511
|
-
|
|
1053
|
+
// Env hints
|
|
1054
|
+
const env = envLines(intents, params);
|
|
1055
|
+
if (env.length > 0) sections.push(env.join('\n'));
|
|
1056
|
+
|
|
1057
|
+
// Utility functions (envelope, sanitize)
|
|
1058
|
+
const utils = utilityFunctions(intents);
|
|
1059
|
+
if (utils.length > 0) sections.push(...utils);
|
|
1060
|
+
|
|
1061
|
+
// Schema blocks for all entities (add role + deleted fields)
|
|
1062
|
+
if (intents.has('schema')) {
|
|
1063
|
+
for (const entity of entities) {
|
|
1064
|
+
if (entity.fields.length > 0) {
|
|
1065
|
+
if (hasAuth && userEntity && entity.name === userEntity.name && !entity.fields.some(f => f.name === 'role')) {
|
|
1066
|
+
entity.fields.push({ name: 'role', type: 'str', modifiers: [] });
|
|
1067
|
+
}
|
|
1068
|
+
if (intents.has('crud') && !entity.fields.some(f => f.name === 'deleted')) {
|
|
1069
|
+
entity.fields.push({ name: 'deleted', type: 'bool', modifiers: [] });
|
|
1070
|
+
}
|
|
1071
|
+
sections.push(schemaBlock(entity.name, entity.fields));
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
// Audit log schema when auth is present
|
|
1075
|
+
if (hasAuth) {
|
|
1076
|
+
sections.push(auditSchema());
|
|
1077
|
+
}
|
|
512
1078
|
}
|
|
513
1079
|
|
|
514
1080
|
if (intents.has('database')) {
|
|
@@ -519,39 +1085,117 @@ function compose(intents, params) {
|
|
|
519
1085
|
const body = [];
|
|
520
1086
|
body.push('cors "*"');
|
|
521
1087
|
|
|
1088
|
+
// CRUD for each entity
|
|
522
1089
|
if (intents.has('crud') && intents.has('schema')) {
|
|
523
|
-
|
|
1090
|
+
for (const entity of entities) {
|
|
1091
|
+
const ep = pluralize(entity.name.toLowerCase());
|
|
1092
|
+
body.push(`crud "/api/${ep}" ${entity.name}`);
|
|
1093
|
+
}
|
|
524
1094
|
}
|
|
525
1095
|
|
|
526
|
-
|
|
1096
|
+
// Auth: full routes + role-based access
|
|
1097
|
+
if (hasAuth) {
|
|
527
1098
|
body.push('auth JWT_SECRET:');
|
|
528
1099
|
body.push(' protect "/api/*"');
|
|
529
1100
|
body.push(' public "/api/auth/*"');
|
|
1101
|
+
body.push(' public "/api/health"');
|
|
1102
|
+
if (userEntity) {
|
|
1103
|
+
body.push('');
|
|
1104
|
+
body.push(...authRoutesBlock(userEntity.name));
|
|
1105
|
+
// Role-based admin check route
|
|
1106
|
+
body.push('');
|
|
1107
|
+
body.push('get "/api/admin/stats" (req, res):');
|
|
1108
|
+
body.push(' if req.user.role != "admin":');
|
|
1109
|
+
body.push(' ret.status(403) {error: "Admin access required"}');
|
|
1110
|
+
if (entities.length > 0) {
|
|
1111
|
+
const counts = entities.map(e => `${pluralize(e.name.toLowerCase())}: ${e.name}Store.count()`).join(', ');
|
|
1112
|
+
body.push(` ret {${counts}}`);
|
|
1113
|
+
} else {
|
|
1114
|
+
body.push(' ret {status: "ok"}');
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
530
1117
|
}
|
|
531
1118
|
|
|
532
|
-
|
|
1119
|
+
// Paginated list endpoints (override default CRUD list)
|
|
1120
|
+
if (intents.has('crud') && intents.has('schema')) {
|
|
533
1121
|
body.push('');
|
|
1122
|
+
for (const entity of entities) {
|
|
1123
|
+
body.push(...paginatedListBlock(entity));
|
|
1124
|
+
body.push('');
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// Nested routes for relationships
|
|
1129
|
+
for (const rel of relationships) {
|
|
1130
|
+
body.push(...nestedRouteBlock(rel.parent, rel.child, rel.fk));
|
|
1131
|
+
body.push('');
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// WebSocket
|
|
1135
|
+
if (intents.has('websocket')) {
|
|
534
1136
|
body.push('ws "/ws":');
|
|
535
|
-
body.push(' on "
|
|
1137
|
+
body.push(' on "connection" (socket):');
|
|
1138
|
+
body.push(' log("Client connected")');
|
|
1139
|
+
body.push(' on "message" (data, socket):');
|
|
536
1140
|
body.push(' socket.send(data)');
|
|
1141
|
+
body.push(' on "close" (socket):');
|
|
1142
|
+
body.push(' log("Client disconnected")');
|
|
1143
|
+
body.push('');
|
|
537
1144
|
}
|
|
538
1145
|
|
|
1146
|
+
// AI endpoint
|
|
539
1147
|
if (intents.has('ai')) {
|
|
540
|
-
body.push('');
|
|
541
1148
|
body.push('post "/api/ask" (req, res):');
|
|
1149
|
+
body.push(' if not req.body.prompt:');
|
|
1150
|
+
body.push(' ret.status(400) {error: "prompt is required"}');
|
|
542
1151
|
body.push(' str answer = await ai.ask(req.body.prompt)');
|
|
543
1152
|
body.push(' ret {answer}');
|
|
1153
|
+
body.push('');
|
|
544
1154
|
}
|
|
545
1155
|
|
|
546
|
-
|
|
1156
|
+
// Soft delete + batch for each entity
|
|
1157
|
+
if (intents.has('crud') && intents.has('schema')) {
|
|
1158
|
+
for (const entity of entities) {
|
|
1159
|
+
body.push(...softDeleteRoutes(entity));
|
|
1160
|
+
body.push('');
|
|
1161
|
+
body.push(...batchRoutes(entity));
|
|
1162
|
+
body.push('');
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// File upload
|
|
1167
|
+
if (intents.has('crud')) {
|
|
1168
|
+
body.push(...uploadRoute());
|
|
1169
|
+
body.push('');
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
// Webhook
|
|
1173
|
+
if (intents.has('server')) {
|
|
1174
|
+
body.push(...webhookRoute());
|
|
1175
|
+
body.push('');
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// Health check
|
|
547
1179
|
body.push('get "/api/health" (req, res):');
|
|
548
|
-
if (intents.has('schema') && intents.has('crud')) {
|
|
549
|
-
|
|
1180
|
+
if (intents.has('schema') && intents.has('crud') && entities.length > 0) {
|
|
1181
|
+
const counts = entities.map(e => `${pluralize(e.name.toLowerCase())}: ${e.name}Store.count()`).join(', ');
|
|
1182
|
+
body.push(` ret {status: "ok", ${counts}}`);
|
|
550
1183
|
} else {
|
|
551
1184
|
body.push(' ret {status: "ok"}');
|
|
552
1185
|
}
|
|
553
1186
|
|
|
554
|
-
|
|
1187
|
+
const serverName = safeName(primaryLower === 'item' ? 'app' : primaryLower);
|
|
1188
|
+
sections.push(serverBlock(serverName, params.port, body));
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// Seed function
|
|
1192
|
+
if (intents.has('database') && intents.has('schema') && entities.length > 0) {
|
|
1193
|
+
sections.push(seedBlock(entities));
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// Audit log function
|
|
1197
|
+
if (hasAuth) {
|
|
1198
|
+
sections.push(auditFn());
|
|
555
1199
|
}
|
|
556
1200
|
|
|
557
1201
|
if (intents.has('bot')) {
|
|
@@ -559,7 +1203,7 @@ function compose(intents, params) {
|
|
|
559
1203
|
}
|
|
560
1204
|
|
|
561
1205
|
if (intents.has('cli')) {
|
|
562
|
-
sections.push(cliBlock(
|
|
1206
|
+
sections.push(cliBlock(primaryLower));
|
|
563
1207
|
}
|
|
564
1208
|
|
|
565
1209
|
if (intents.has('graphql')) {
|
|
@@ -567,15 +1211,18 @@ function compose(intents, params) {
|
|
|
567
1211
|
}
|
|
568
1212
|
|
|
569
1213
|
if (intents.has('page')) {
|
|
570
|
-
sections.push(pageBlock(
|
|
1214
|
+
sections.push(pageBlock(primaryName));
|
|
571
1215
|
}
|
|
572
1216
|
|
|
573
1217
|
if (intents.has('mail')) {
|
|
574
1218
|
sections.push(mailBlock());
|
|
575
1219
|
}
|
|
576
1220
|
|
|
577
|
-
|
|
578
|
-
|
|
1221
|
+
// Auto-generate entity tests when test intent or when entities exist
|
|
1222
|
+
if (intents.has('test') || (intents.has('schema') && intents.has('crud') && entities.length > 0)) {
|
|
1223
|
+
for (const entity of entities) {
|
|
1224
|
+
sections.push(entityTestBlock(entity));
|
|
1225
|
+
}
|
|
579
1226
|
}
|
|
580
1227
|
|
|
581
1228
|
return sections.join('\n\n') + '\n';
|
|
@@ -661,7 +1308,7 @@ function heal(code, err) {
|
|
|
661
1308
|
|
|
662
1309
|
const KEYWORD_CHEATSHEET = {
|
|
663
1310
|
'Intent': 'server, api, rest, bot, cli, page, test, database, ai, crud, auth, websocket, mail, graphql',
|
|
664
|
-
'Composite': 'todo, blog, chat, shop, fullstack, board',
|
|
1311
|
+
'Composite': 'todo, blog, chat, shop, fullstack, board, crm, inventory, booking, sns',
|
|
665
1312
|
'Platform': 'discord, slack, telegram, line',
|
|
666
1313
|
'DB': 'sqlite, postgres',
|
|
667
1314
|
'Japanese': 'サーバー, 認証, ログイン, 会員, CRUD, 管理, データベース, ボット, テスト, ページ',
|
|
@@ -677,6 +1324,431 @@ function buildSuggestion(mods) {
|
|
|
677
1324
|
return lines.join('\n');
|
|
678
1325
|
}
|
|
679
1326
|
|
|
1327
|
+
// ── Project Generator ──────────────────────────────────────
|
|
1328
|
+
|
|
1329
|
+
export function generateProject(instruction, options = {}) {
|
|
1330
|
+
const result = generate(instruction, options);
|
|
1331
|
+
const files = [];
|
|
1332
|
+
const intents = new Set(result.intents);
|
|
1333
|
+
const entities = result.entities || [];
|
|
1334
|
+
const primaryName = (entities[0] || 'app').toLowerCase();
|
|
1335
|
+
const projectName = safeName(primaryName) + '-app';
|
|
1336
|
+
|
|
1337
|
+
// Main app file
|
|
1338
|
+
files.push({ path: 'app.naide', content: result.code });
|
|
1339
|
+
|
|
1340
|
+
// Package.json
|
|
1341
|
+
const deps = {};
|
|
1342
|
+
if (intents.has('server')) deps.express = '^4.21.0';
|
|
1343
|
+
if (intents.has('websocket')) deps.ws = '^8.18.0';
|
|
1344
|
+
if (intents.has('auth')) {
|
|
1345
|
+
deps.jsonwebtoken = '^9.0.2';
|
|
1346
|
+
deps.bcryptjs = '^2.4.3';
|
|
1347
|
+
}
|
|
1348
|
+
if (intents.has('mail')) deps.nodemailer = '^6.9.0';
|
|
1349
|
+
if (result.analysis.entities.some(() => true)) deps.naider = '^1.21.0';
|
|
1350
|
+
const pkg = {
|
|
1351
|
+
name: projectName,
|
|
1352
|
+
version: '1.0.0',
|
|
1353
|
+
type: 'module',
|
|
1354
|
+
scripts: {
|
|
1355
|
+
dev: 'naide app.naide',
|
|
1356
|
+
build: 'naide app.naide --emit -o dist/app.mjs',
|
|
1357
|
+
start: 'node dist/app.mjs',
|
|
1358
|
+
test: 'naide test app.naide',
|
|
1359
|
+
seed: 'naide app.naide --run-seed',
|
|
1360
|
+
},
|
|
1361
|
+
dependencies: deps,
|
|
1362
|
+
};
|
|
1363
|
+
files.push({ path: 'package.json', content: JSON.stringify(pkg, null, 2) + '\n' });
|
|
1364
|
+
|
|
1365
|
+
// .env file
|
|
1366
|
+
const envL = [];
|
|
1367
|
+
envL.push(`PORT=${result.port}`);
|
|
1368
|
+
if (intents.has('auth')) envL.push('JWT_SECRET=change-me-in-production');
|
|
1369
|
+
if (intents.has('bot')) envL.push(`${(options.platform || 'DISCORD').toUpperCase()}_TOKEN=your-bot-token`);
|
|
1370
|
+
if (intents.has('mail')) {
|
|
1371
|
+
envL.push('SMTP_HOST=smtp.example.com');
|
|
1372
|
+
envL.push('SMTP_USER=user@example.com');
|
|
1373
|
+
envL.push('SMTP_PASS=password');
|
|
1374
|
+
}
|
|
1375
|
+
if (intents.has('ai')) envL.push('OPENAI_API_KEY=your-api-key');
|
|
1376
|
+
files.push({ path: '.env', content: envL.join('\n') + '\n' });
|
|
1377
|
+
|
|
1378
|
+
// .gitignore
|
|
1379
|
+
files.push({ path: '.gitignore', content: 'node_modules/\ndist/\ndata/\n.env\n*.mjs\n' });
|
|
1380
|
+
|
|
1381
|
+
// Dockerfile
|
|
1382
|
+
if (intents.has('server')) {
|
|
1383
|
+
const dockerfile = `FROM node:22-slim
|
|
1384
|
+
WORKDIR /app
|
|
1385
|
+
COPY package*.json ./
|
|
1386
|
+
RUN npm ci --omit=dev
|
|
1387
|
+
RUN npm install -g naider
|
|
1388
|
+
COPY . .
|
|
1389
|
+
RUN naide app.naide --emit -o dist/app.mjs
|
|
1390
|
+
EXPOSE ${result.port}
|
|
1391
|
+
CMD ["node", "dist/app.mjs"]
|
|
1392
|
+
`;
|
|
1393
|
+
files.push({ path: 'Dockerfile', content: dockerfile });
|
|
1394
|
+
|
|
1395
|
+
files.push({ path: '.dockerignore', content: 'node_modules\ndist\ndata\n.env\n.git\n' });
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
// README
|
|
1399
|
+
const readmeLines = [`# ${projectName}\n`];
|
|
1400
|
+
readmeLines.push(`Generated by NAIDE Agent Coder (~~)\n`);
|
|
1401
|
+
readmeLines.push(`## Features\n`);
|
|
1402
|
+
for (const step of result.analysis.plan) readmeLines.push(`- ${step}`);
|
|
1403
|
+
if (entities.length > 0) {
|
|
1404
|
+
readmeLines.push(`\n## Entities\n`);
|
|
1405
|
+
for (const e of result.analysis.entities) readmeLines.push(`- **${e.name}** (${e.fieldCount} fields)`);
|
|
1406
|
+
}
|
|
1407
|
+
if (result.analysis.relationships.length > 0) {
|
|
1408
|
+
readmeLines.push(`\n## Relationships\n`);
|
|
1409
|
+
for (const r of result.analysis.relationships) readmeLines.push(`- ${r.parent} → ${r.child} (${r.fk})`);
|
|
1410
|
+
}
|
|
1411
|
+
readmeLines.push(`\n## Quick Start\n`);
|
|
1412
|
+
readmeLines.push('```bash');
|
|
1413
|
+
readmeLines.push('npm install');
|
|
1414
|
+
readmeLines.push('npm run dev');
|
|
1415
|
+
readmeLines.push('```\n');
|
|
1416
|
+
if (intents.has('server')) {
|
|
1417
|
+
readmeLines.push(`## API Endpoints\n`);
|
|
1418
|
+
readmeLines.push(`- \`GET /api/health\` — Health check`);
|
|
1419
|
+
for (const e of entities) {
|
|
1420
|
+
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}`);
|
|
1430
|
+
}
|
|
1431
|
+
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}`);
|
|
1435
|
+
}
|
|
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`);
|
|
1439
|
+
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)`);
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
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('```');
|
|
1452
|
+
}
|
|
1453
|
+
files.push({ path: 'README.md', content: readmeLines.join('\n') + '\n' });
|
|
1454
|
+
|
|
1455
|
+
// Build full entity objects for generators
|
|
1456
|
+
const entityObjs = entities.map(name => {
|
|
1457
|
+
const preset = SCHEMA_PRESETS[name.toLowerCase()];
|
|
1458
|
+
const fields = preset ? preset.map(f => ({ name: f[0], type: f[1], modifiers: f.slice(2) })) : defaultFields(name);
|
|
1459
|
+
if (!fields.some(f => f.name === 'deleted')) fields.push({ name: 'deleted', type: 'bool', modifiers: [] });
|
|
1460
|
+
return { name, fields };
|
|
1461
|
+
});
|
|
1462
|
+
|
|
1463
|
+
// OpenAPI spec
|
|
1464
|
+
if (intents.has('server') && entityObjs.length > 0) {
|
|
1465
|
+
const spec = openApiSpec(entityObjs, intents, { port: result.port }, result.relationships || []);
|
|
1466
|
+
files.push({ path: 'openapi.json', content: JSON.stringify(spec, null, 2) + '\n' });
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
// Admin dashboard
|
|
1470
|
+
if (intents.has('server') && entityObjs.length > 0) {
|
|
1471
|
+
files.push({ path: 'admin.html', content: adminPage(entityObjs, intents) });
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
// API client
|
|
1475
|
+
if (intents.has('server')) {
|
|
1476
|
+
files.push({ path: 'client.mjs', content: apiClientCode(entityObjs, intents, result.port) });
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
return { ...result, files };
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
// ── Project Update Mode ───────────────────────────────────
|
|
1483
|
+
|
|
1484
|
+
export function updateProject(projectDir, instruction, options = {}) {
|
|
1485
|
+
const appPath = projectDir + '/app.naide';
|
|
1486
|
+
const existing = _existsFS(appPath) ? _readFS(appPath, 'utf-8') : '';
|
|
1487
|
+
|
|
1488
|
+
const existingState = existing ? parseExistingProject(existing) : { entities: [], intents: [], port: 3000 };
|
|
1489
|
+
const newResult = generate(instruction, options);
|
|
1490
|
+
const newEntities = (newResult.entities || []).filter(e => !existingState.entities.includes(e));
|
|
1491
|
+
|
|
1492
|
+
const allEntities = [...existingState.entities, ...newEntities];
|
|
1493
|
+
const combinedIntents = [...new Set([...existingState.intents, ...newResult.intents])];
|
|
1494
|
+
const extras = [];
|
|
1495
|
+
if (combinedIntents.includes('auth')) extras.push('auth');
|
|
1496
|
+
if (combinedIntents.includes('websocket')) extras.push('websocket');
|
|
1497
|
+
if (combinedIntents.includes('database')) extras.push('database');
|
|
1498
|
+
const fullInstruction = 'REST API for ' + allEntities.join(' and ') + (extras.length ? ' with ' + extras.join(' and ') : '');
|
|
1499
|
+
|
|
1500
|
+
const combined = generate(fullInstruction, options);
|
|
1501
|
+
const finalCode = combined.code;
|
|
1502
|
+
|
|
1503
|
+
const files = [];
|
|
1504
|
+
files.push({ path: 'app.naide', content: finalCode });
|
|
1505
|
+
|
|
1506
|
+
const intents = new Set(combined.intents);
|
|
1507
|
+
const entities = combined.entities || allEntities;
|
|
1508
|
+
const relationships = combined.relationships || detectRelationships(entities);
|
|
1509
|
+
|
|
1510
|
+
const primaryName = (entities[0] || 'app').toLowerCase();
|
|
1511
|
+
const projectName = safeName(primaryName) + '-app';
|
|
1512
|
+
const port = existingState.port || combined.port || 3000;
|
|
1513
|
+
|
|
1514
|
+
const deps = {};
|
|
1515
|
+
if (intents.has('server')) deps.express = '^4.21.0';
|
|
1516
|
+
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
|
+
}
|
|
1521
|
+
if (intents.has('mail')) deps.nodemailer = '^6.9.0';
|
|
1522
|
+
if (entities.length > 0) deps.naider = '^1.21.0';
|
|
1523
|
+
|
|
1524
|
+
const existingPkgPath = projectDir + '/package.json';
|
|
1525
|
+
let pkg;
|
|
1526
|
+
if (_existsFS(existingPkgPath)) {
|
|
1527
|
+
pkg = JSON.parse(_readFS(existingPkgPath, 'utf-8'));
|
|
1528
|
+
pkg.dependencies = { ...pkg.dependencies, ...deps };
|
|
1529
|
+
} else {
|
|
1530
|
+
pkg = {
|
|
1531
|
+
name: projectName, version: '1.0.0', type: 'module',
|
|
1532
|
+
scripts: { dev: 'naide app.naide', build: 'naide app.naide --emit -o dist/app.mjs',
|
|
1533
|
+
start: 'node dist/app.mjs', test: 'naide test app.naide', seed: 'naide app.naide --run-seed' },
|
|
1534
|
+
dependencies: deps,
|
|
1535
|
+
};
|
|
1536
|
+
}
|
|
1537
|
+
files.push({ path: 'package.json', content: JSON.stringify(pkg, null, 2) + '\n' });
|
|
1538
|
+
|
|
1539
|
+
const envL = [];
|
|
1540
|
+
envL.push(`PORT=${port}`);
|
|
1541
|
+
if (intents.has('auth')) envL.push('JWT_SECRET=change-me-in-production');
|
|
1542
|
+
if (intents.has('bot')) envL.push(`${(options.platform || 'DISCORD').toUpperCase()}_TOKEN=your-bot-token`);
|
|
1543
|
+
if (intents.has('mail')) { envL.push('SMTP_HOST=smtp.example.com'); envL.push('SMTP_USER=user@example.com'); envL.push('SMTP_PASS=password'); }
|
|
1544
|
+
if (intents.has('ai')) envL.push('OPENAI_API_KEY=your-api-key');
|
|
1545
|
+
const existingEnvPath = projectDir + '/.env';
|
|
1546
|
+
if (_existsFS(existingEnvPath)) {
|
|
1547
|
+
const existingEnv = _readFS(existingEnvPath, 'utf-8');
|
|
1548
|
+
const existingKeys = new Set(existingEnv.split('\n').map(l => l.split('=')[0]).filter(Boolean));
|
|
1549
|
+
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
|
+
}
|
|
1553
|
+
} else {
|
|
1554
|
+
files.push({ path: '.env', content: envL.join('\n') + '\n' });
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
files.push({ path: '.gitignore', content: 'node_modules/\ndist/\ndata/\n.env\n*.mjs\n' });
|
|
1558
|
+
|
|
1559
|
+
if (intents.has('server')) {
|
|
1560
|
+
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
|
+
files.push({ path: '.dockerignore', content: 'node_modules\ndist\ndata\n.env\n.git\n' });
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
const readmeLines = [`# ${projectName}\n`];
|
|
1565
|
+
readmeLines.push(`Generated by NAIDE Agent Coder (~~)\n`);
|
|
1566
|
+
readmeLines.push(`## Entities\n`);
|
|
1567
|
+
for (const e of entities) {
|
|
1568
|
+
const preset = SCHEMA_PRESETS[e.toLowerCase()];
|
|
1569
|
+
const fc = preset ? preset.length : 3;
|
|
1570
|
+
readmeLines.push(`- **${e}** (${fc} fields)`);
|
|
1571
|
+
}
|
|
1572
|
+
if (relationships.length > 0) {
|
|
1573
|
+
readmeLines.push(`\n## Relationships\n`);
|
|
1574
|
+
for (const r of relationships) readmeLines.push(`- ${r.parent} → ${r.child} (${r.fk})`);
|
|
1575
|
+
}
|
|
1576
|
+
readmeLines.push(`\n## Quick Start\n`);
|
|
1577
|
+
readmeLines.push('```bash\nnpm install\nnpm run dev\n```\n');
|
|
1578
|
+
if (intents.has('server')) {
|
|
1579
|
+
readmeLines.push(`## API Endpoints\n`);
|
|
1580
|
+
readmeLines.push(`- \`GET /api/health\` — Health check`);
|
|
1581
|
+
for (const e of entities) {
|
|
1582
|
+
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}`);
|
|
1595
|
+
}
|
|
1596
|
+
readmeLines.push(`- \`POST /api/upload\` — File upload`);
|
|
1597
|
+
readmeLines.push(`- \`POST /api/webhooks\` — Register webhook`);
|
|
1598
|
+
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`);
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
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('```');
|
|
1611
|
+
}
|
|
1612
|
+
files.push({ path: 'README.md', content: readmeLines.join('\n') + '\n' });
|
|
1613
|
+
|
|
1614
|
+
const entityObjs = entities.map(name => {
|
|
1615
|
+
const preset = SCHEMA_PRESETS[name.toLowerCase()];
|
|
1616
|
+
const fields = preset ? preset.map(f => ({ name: f[0], type: f[1], modifiers: f.slice(2) })) : defaultFields(name);
|
|
1617
|
+
if (!fields.some(f => f.name === 'deleted')) fields.push({ name: 'deleted', type: 'bool', modifiers: [] });
|
|
1618
|
+
return { name, fields };
|
|
1619
|
+
});
|
|
1620
|
+
|
|
1621
|
+
if (intents.has('server') && entityObjs.length > 0) {
|
|
1622
|
+
files.push({ path: 'openapi.json', content: JSON.stringify(openApiSpec(entityObjs, intents, { port }, relationships), null, 2) + '\n' });
|
|
1623
|
+
files.push({ path: 'admin.html', content: adminPage(entityObjs, intents) });
|
|
1624
|
+
}
|
|
1625
|
+
if (intents.has('server')) {
|
|
1626
|
+
files.push({ path: 'client.mjs', content: apiClientCode(entityObjs, intents, port) });
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
const added = existing
|
|
1630
|
+
? entities.filter(e => !existing.includes(`schema ${e}`))
|
|
1631
|
+
: entities;
|
|
1632
|
+
const analysis = {
|
|
1633
|
+
instruction,
|
|
1634
|
+
entities: entityObjs.map(e => ({ name: e.name, fieldCount: e.fields.length })),
|
|
1635
|
+
relationships,
|
|
1636
|
+
plan: [],
|
|
1637
|
+
added,
|
|
1638
|
+
};
|
|
1639
|
+
if (added.length > 0) analysis.plan.push(`Added: ${added.join(', ')}`);
|
|
1640
|
+
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`);
|
|
1643
|
+
|
|
1644
|
+
return {
|
|
1645
|
+
code: finalCode, valid: combined.valid, fixed: combined.fixed, repairs: combined.repairs,
|
|
1646
|
+
intents: [...intents], entities, relationships, port,
|
|
1647
|
+
files, analysis, action: existing ? 'updated' : 'created',
|
|
1648
|
+
};
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
function parseExistingProject(code) {
|
|
1652
|
+
const lines = code.split('\n');
|
|
1653
|
+
const entities = [];
|
|
1654
|
+
const intents = new Set();
|
|
1655
|
+
let port = 3000;
|
|
1656
|
+
|
|
1657
|
+
for (const line of lines) {
|
|
1658
|
+
const sm = line.match(/^schema\s+(\w+)\s*:/);
|
|
1659
|
+
if (sm && sm[1] !== 'AuditLog') {
|
|
1660
|
+
entities.push(sm[1]);
|
|
1661
|
+
intents.add('schema');
|
|
1662
|
+
}
|
|
1663
|
+
if (line.match(/^server\s/)) intents.add('server');
|
|
1664
|
+
if (line.match(/\bcrud\b/)) intents.add('crud');
|
|
1665
|
+
if (line.match(/\bauth\b/)) intents.add('auth');
|
|
1666
|
+
if (line.match(/\bdb\b/)) intents.add('database');
|
|
1667
|
+
if (line.match(/\bws\b/)) intents.add('websocket');
|
|
1668
|
+
if (line.match(/\bbot\b/)) intents.add('bot');
|
|
1669
|
+
const pm = line.match(/port\s+(\d+)/);
|
|
1670
|
+
if (pm) port = parseInt(pm[1], 10);
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
return { entities, intents: [...intents], port };
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
// ── File Write Mode ────────────────────────────────────────
|
|
1677
|
+
|
|
1678
|
+
export function writeToFile(filePath, instruction, options = {}) {
|
|
1679
|
+
const result = generate(instruction, options);
|
|
1680
|
+
const code = result.code;
|
|
1681
|
+
|
|
1682
|
+
if (!_existsFS(filePath)) {
|
|
1683
|
+
_writeFS(filePath, code, 'utf-8');
|
|
1684
|
+
return { ...result, action: 'created', path: filePath };
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
const existing = _readFS(filePath, 'utf-8');
|
|
1688
|
+
const mode = options.mode || 'append';
|
|
1689
|
+
|
|
1690
|
+
if (mode === 'replace') {
|
|
1691
|
+
_writeFS(filePath, code, 'utf-8');
|
|
1692
|
+
return { ...result, action: 'replaced', path: filePath };
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
// Append mode: merge intelligently
|
|
1696
|
+
const merged = mergeCode(existing, code);
|
|
1697
|
+
const validated = validate(merged);
|
|
1698
|
+
_writeFS(filePath, validated.code, 'utf-8');
|
|
1699
|
+
return { ...result, code: validated.code, valid: validated.valid, fixed: validated.fixed,
|
|
1700
|
+
repairs: validated.repairs, action: 'merged', path: filePath };
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
function mergeCode(existing, generated) {
|
|
1704
|
+
const existingLines = existing.split('\n');
|
|
1705
|
+
const generatedLines = generated.split('\n');
|
|
1706
|
+
|
|
1707
|
+
const existingSchemas = new Set();
|
|
1708
|
+
const existingServers = new Set();
|
|
1709
|
+
for (const line of existingLines) {
|
|
1710
|
+
const schemaMatch = line.match(/^schema\s+(\w+)\s*:/);
|
|
1711
|
+
if (schemaMatch) existingSchemas.add(schemaMatch[1]);
|
|
1712
|
+
const serverMatch = line.match(/^server\s+(\w+)\s/);
|
|
1713
|
+
if (serverMatch) existingServers.add(serverMatch[1]);
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
// Filter out duplicate top-level blocks from generated
|
|
1717
|
+
const filteredLines = [];
|
|
1718
|
+
let skipBlock = false;
|
|
1719
|
+
let blockIndent = -1;
|
|
1720
|
+
for (let i = 0; i < generatedLines.length; i++) {
|
|
1721
|
+
const line = generatedLines[i];
|
|
1722
|
+
const schemaMatch = line.match(/^schema\s+(\w+)\s*:/);
|
|
1723
|
+
const serverMatch = line.match(/^server\s+(\w+)\s/);
|
|
1724
|
+
|
|
1725
|
+
if (schemaMatch && existingSchemas.has(schemaMatch[1])) {
|
|
1726
|
+
skipBlock = true;
|
|
1727
|
+
blockIndent = 0;
|
|
1728
|
+
continue;
|
|
1729
|
+
}
|
|
1730
|
+
if (serverMatch && existingServers.has(serverMatch[1])) {
|
|
1731
|
+
// For server blocks, extract only new routes
|
|
1732
|
+
skipBlock = true;
|
|
1733
|
+
blockIndent = 0;
|
|
1734
|
+
continue;
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
if (skipBlock) {
|
|
1738
|
+
const indent = line.match(/^(\s*)/)[1].length;
|
|
1739
|
+
if (line.trim() === '' || indent > blockIndent) continue;
|
|
1740
|
+
skipBlock = false;
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
filteredLines.push(line);
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
const newCode = filteredLines.join('\n').trim();
|
|
1747
|
+
if (!newCode) return existing;
|
|
1748
|
+
|
|
1749
|
+
return existing.trimEnd() + '\n\n' + newCode + '\n';
|
|
1750
|
+
}
|
|
1751
|
+
|
|
680
1752
|
// ── Pre-Processor (for ~~ in source files) ──────────────────
|
|
681
1753
|
|
|
682
1754
|
export function expandDirectives(source) {
|
|
@@ -703,6 +1775,47 @@ export function expandDirectives(source) {
|
|
|
703
1775
|
return { source: result.join('\n'), expanded };
|
|
704
1776
|
}
|
|
705
1777
|
|
|
1778
|
+
// ── Analysis Builder ───────────────────────────────────────
|
|
1779
|
+
|
|
1780
|
+
function buildAnalysis(instruction, intents, params) {
|
|
1781
|
+
const plan = [];
|
|
1782
|
+
|
|
1783
|
+
if (params.entities.length > 0) {
|
|
1784
|
+
for (const e of params.entities) {
|
|
1785
|
+
plan.push(`Schema "${e.name}" (${e.fields.map(f => f.name).join(', ')})`);
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
if (params.relationships.length > 0) {
|
|
1790
|
+
for (const r of params.relationships) {
|
|
1791
|
+
plan.push(`Relation: ${r.parent} → ${r.child} (${r.fk})`);
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
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');
|
|
1799
|
+
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');
|
|
1801
|
+
if (intents.has('ai')) plan.push('AI endpoint: /api/ask');
|
|
1802
|
+
if (intents.has('bot')) plan.push(`Bot: ${params.platform || 'discord'}`);
|
|
1803
|
+
if (intents.has('mail')) plan.push('Mail: SMTP configuration');
|
|
1804
|
+
if (intents.has('graphql')) plan.push('GraphQL endpoint');
|
|
1805
|
+
if (intents.has('page')) plan.push('Page: HTML generation');
|
|
1806
|
+
if (intents.has('cli')) plan.push('CLI app with flags');
|
|
1807
|
+
if (intents.has('database') && intents.has('schema')) plan.push('Seed: sample data function');
|
|
1808
|
+
plan.push(`Tests: ${params.entities.length} entity CRUD test suites`);
|
|
1809
|
+
|
|
1810
|
+
return {
|
|
1811
|
+
instruction,
|
|
1812
|
+
entities: params.entities.map(e => ({ name: e.name, fieldCount: e.fields.length })),
|
|
1813
|
+
relationships: params.relationships,
|
|
1814
|
+
features: [...intents],
|
|
1815
|
+
plan,
|
|
1816
|
+
};
|
|
1817
|
+
}
|
|
1818
|
+
|
|
706
1819
|
// ── Public API ──────────────────────────────────────────────
|
|
707
1820
|
|
|
708
1821
|
export function generate(instruction, options = {}) {
|
|
@@ -711,11 +1824,18 @@ export function generate(instruction, options = {}) {
|
|
|
711
1824
|
const params = extractParams(words, instruction, intents, mods);
|
|
712
1825
|
|
|
713
1826
|
if (options.port) params.port = options.port;
|
|
714
|
-
if (options.schemaName)
|
|
715
|
-
|
|
1827
|
+
if (options.schemaName) {
|
|
1828
|
+
params.schemaName = options.schemaName;
|
|
1829
|
+
if (params.entities.length > 0) params.entities[0].name = options.schemaName;
|
|
1830
|
+
}
|
|
1831
|
+
if (options.fields) {
|
|
1832
|
+
params.fields = options.fields;
|
|
1833
|
+
if (params.entities.length > 0) params.entities[0].fields = options.fields;
|
|
1834
|
+
}
|
|
716
1835
|
|
|
717
1836
|
let code = compose(intents, params);
|
|
718
1837
|
const result = validate(code);
|
|
1838
|
+
const analysis = buildAnalysis(instruction, intents, params);
|
|
719
1839
|
|
|
720
1840
|
return {
|
|
721
1841
|
code: result.code,
|
|
@@ -724,8 +1844,11 @@ export function generate(instruction, options = {}) {
|
|
|
724
1844
|
repairs: result.repairs,
|
|
725
1845
|
intents: [...intents],
|
|
726
1846
|
schema: params.schemaName,
|
|
1847
|
+
entities: params.entities.map(e => e.name),
|
|
1848
|
+
relationships: params.relationships,
|
|
727
1849
|
port: params.port,
|
|
728
1850
|
suggestion: buildSuggestion(mods),
|
|
729
1851
|
fallback: !!mods.fallback,
|
|
1852
|
+
analysis,
|
|
730
1853
|
};
|
|
731
1854
|
}
|