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 +55 -14
- package/mcp/server.js +1 -1
- package/package.json +1 -1
- package/src/gen.js +780 -50
- package/src/index.js +2 -2
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.
|
|
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
|
|
244
|
+
// Project mode: generate or update project directory
|
|
239
245
|
if (flags.project) {
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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(
|
|
278
|
+
const fp = resolve(finalDir, f.path);
|
|
247
279
|
writeFileSync(fp, f.content, 'utf-8');
|
|
248
280
|
}
|
|
249
281
|
const a = result.analysis;
|
|
250
|
-
|
|
251
|
-
process.stderr.write(
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
653
|
-
` int limit = req.query.limit
|
|
654
|
-
` str search = req.query.search
|
|
655
|
-
`
|
|
721
|
+
` int page = req.query.page or 1`,
|
|
722
|
+
` int limit = req.query.limit or 20`,
|
|
723
|
+
` str search = req.query.search or ""`,
|
|
724
|
+
` str sortBy = req.query.sort or "id"`,
|
|
725
|
+
` str order = req.query.order or "desc"`,
|
|
726
|
+
` mut list rows = ${store}.all()`,
|
|
656
727
|
` if search:`,
|
|
657
|
-
`
|
|
658
|
-
`
|
|
659
|
-
`
|
|
660
|
-
`
|
|
661
|
-
`
|
|
728
|
+
` rows = rows.filter((item) => JSON.stringify(item).toLowerCase().includes(search.toLowerCase()))`,
|
|
729
|
+
` each key in Object.keys(req.query):`,
|
|
730
|
+
` if key.endsWith("_gte"):`,
|
|
731
|
+
` str field = key.replace("_gte", "")`,
|
|
732
|
+
` rows = rows.filter((item) => item[field] >= Number(req.query[key]))`,
|
|
733
|
+
` if key.endsWith("_lte"):`,
|
|
734
|
+
` str field = key.replace("_lte", "")`,
|
|
735
|
+
` rows = rows.filter((item) => item[field] <= Number(req.query[key]))`,
|
|
736
|
+
` if key.endsWith("_eq"):`,
|
|
737
|
+
` str field = key.replace("_eq", "")`,
|
|
738
|
+
` rows = rows.filter((item) => String(item[field]) == req.query[key])`,
|
|
739
|
+
` if order == "asc":`,
|
|
740
|
+
` rows = rows.sort((a, b) => String(a[sortBy]).localeCompare(String(b[sortBy])))`,
|
|
741
|
+
` else:`,
|
|
742
|
+
` rows = rows.sort((a, b) => String(b[sortBy]).localeCompare(String(a[sortBy])))`,
|
|
743
|
+
` int total = rows.length`,
|
|
744
|
+
` int offset = page - 1`,
|
|
745
|
+
` int start = offset * limit`,
|
|
746
|
+
` list items = rows.slice(start, start + limit)`,
|
|
747
|
+
` ret {items, total, page, limit, sort: sortBy, order}`,
|
|
662
748
|
];
|
|
663
749
|
}
|
|
664
750
|
|
|
@@ -817,7 +903,9 @@ function openApiSpec(entities, intents, params, relationships) {
|
|
|
817
903
|
{ name: 'page', in: 'query', schema: { type: 'integer', default: 1 } },
|
|
818
904
|
{ name: 'limit', in: 'query', schema: { type: 'integer', default: 20 } },
|
|
819
905
|
{ name: 'search', in: 'query', schema: { type: 'string' } },
|
|
820
|
-
|
|
906
|
+
{ name: 'sort', in: 'query', schema: { type: 'string' }, description: 'Field to sort by' },
|
|
907
|
+
{ name: 'order', in: 'query', schema: { type: 'string', enum: ['asc','desc'], default: 'asc' } },
|
|
908
|
+
], responses: { '200': { description: `List of ${plural} with pagination` } } },
|
|
821
909
|
post: { summary: `Create ${entity.name.toLowerCase()}`, requestBody: { content: { 'application/json': { schema: {
|
|
822
910
|
type: 'object', required: requiredFields.map(f => f.name),
|
|
823
911
|
properties: Object.fromEntries(requiredFields.map(f => [f.name, properties[f.name]])),
|
|
@@ -847,12 +935,24 @@ function openApiSpec(entities, intents, params, relationships) {
|
|
|
847
935
|
};
|
|
848
936
|
}
|
|
849
937
|
if (intents.has('auth')) {
|
|
850
|
-
paths['/api/auth/register'] = { post: { summary: 'Register', responses: { '200': { description: 'Token + user' } } } };
|
|
851
|
-
paths['/api/auth/login'] = { post: { summary: 'Login', responses: { '200': { description: 'Token + user' } } } };
|
|
938
|
+
paths['/api/auth/register'] = { post: { summary: 'Register', requestBody: { content: { 'application/json': { schema: { type: 'object', required: ['email','password'], properties: { email:{type:'string'}, password:{type:'string'}, name:{type:'string'} } } } } }, responses: { '200': { description: 'Token + user' }, '400': { description: 'Validation error' } } } };
|
|
939
|
+
paths['/api/auth/login'] = { post: { summary: 'Login', requestBody: { content: { 'application/json': { schema: { type: 'object', required: ['email','password'], properties: { email:{type:'string'}, password:{type:'string'} } } } } }, responses: { '200': { description: 'Token + user' }, '401': { description: 'Invalid credentials' } } } };
|
|
852
940
|
paths['/api/auth/me'] = { get: { summary: 'Current user', security: [{ bearerAuth: [] }], responses: { '200': { description: 'User' } } } };
|
|
941
|
+
paths['/api/auth/forgot-password'] = { post: { summary: 'Request password reset', requestBody: { content: { 'application/json': { schema: { type: 'object', required: ['email'], properties: { email:{type:'string'} } } } } }, responses: { '200': { description: 'Reset token sent' } } } };
|
|
942
|
+
paths['/api/auth/reset-password'] = { post: { summary: 'Reset password with token', requestBody: { content: { 'application/json': { schema: { type: 'object', required: ['token','password'], properties: { token:{type:'string'}, password:{type:'string'} } } } } }, responses: { '200': { description: 'Password reset' }, '400': { description: 'Invalid token' } } } };
|
|
943
|
+
paths['/api/auth/change-password'] = { post: { summary: 'Change password (authenticated)', security: [{ bearerAuth: [] }], requestBody: { content: { 'application/json': { schema: { type: 'object', required: ['oldPassword','newPassword'], properties: { oldPassword:{type:'string'}, newPassword:{type:'string'} } } } } }, responses: { '200': { description: 'Password changed' }, '401': { description: 'Wrong password' } } } };
|
|
944
|
+
}
|
|
945
|
+
const m2m = detectManyToMany(entities.map(e => e.name));
|
|
946
|
+
for (const rel of m2m) {
|
|
947
|
+
const lp = pluralize(rel.left.toLowerCase());
|
|
948
|
+
const rp = pluralize(rel.right.toLowerCase());
|
|
949
|
+
paths[`/api/${lp}/{id}/${rp}`] = { get: { summary: `${rel.right}s for ${rel.left}`, responses: { '200': { description: `List of ${rp}` } } } };
|
|
950
|
+
paths[`/api/${rp}/{id}/${lp}`] = { get: { summary: `${rel.left}s for ${rel.right}`, responses: { '200': { description: `List of ${lp}` } } } };
|
|
951
|
+
paths[`/api/${rel.junction.toLowerCase()}`] = { post: { summary: `Link ${rel.left}↔${rel.right}`, responses: { '201': { description: 'Linked' } } }, delete: { summary: `Unlink ${rel.left}↔${rel.right}`, responses: { '200': { description: 'Unlinked' } } } };
|
|
853
952
|
}
|
|
854
953
|
paths['/api/health'] = { get: { summary: 'Health check', responses: { '200': { description: 'Status' } } } };
|
|
855
954
|
paths['/api/upload'] = { post: { summary: 'Upload file', responses: { '200': { description: 'File info' } } } };
|
|
955
|
+
paths['/docs'] = { get: { summary: 'Swagger UI documentation', responses: { '200': { description: 'HTML page' } } } };
|
|
856
956
|
|
|
857
957
|
const spec = {
|
|
858
958
|
openapi: '3.0.3',
|
|
@@ -991,6 +1091,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
|
-
//
|
|
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(
|
|
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
|
|
1422
|
-
readmeLines.push(`- \`POST /api/${ep}\` — Create
|
|
1423
|
-
readmeLines.push(`- \`GET /api/${ep}/:id\` — Get
|
|
1424
|
-
readmeLines.push(`- \`
|
|
1425
|
-
readmeLines.push(`- \`
|
|
1426
|
-
readmeLines.push(`- \`DELETE /api/${ep}/:id/soft\` — Soft delete ${e.toLowerCase()}`);
|
|
1427
|
-
readmeLines.push(`- \`PUT /api/${ep}/:id/restore\` — Restore ${e.toLowerCase()}`);
|
|
1428
|
-
readmeLines.push(`- \`POST /api/${ep}/batch\` — Batch create ${ep}`);
|
|
1429
|
-
readmeLines.push(`- \`DELETE /api/${ep}/batch\` — Batch delete ${ep}`);
|
|
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
|
-
|
|
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/
|
|
1442
|
-
readmeLines.push(`- \`
|
|
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('
|
|
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
|
-
|
|
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 };
|