@vira-ui/cli 1.0.1 → 1.1.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/README.md +965 -0
- package/dist/index.js +1271 -40
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -91,8 +91,79 @@ const program = new commander_1.Command();
|
|
|
91
91
|
program
|
|
92
92
|
.name("vira")
|
|
93
93
|
.description("ViraJS CLI - Create projects and generate code")
|
|
94
|
-
.version("
|
|
94
|
+
.version("1.1.0");
|
|
95
95
|
const SUPPORTED_TEMPLATES = ["frontend", "fullstack", "kanban"];
|
|
96
|
+
/**
|
|
97
|
+
* Инициализация проекта в текущей директории
|
|
98
|
+
*/
|
|
99
|
+
program
|
|
100
|
+
.command("init")
|
|
101
|
+
.description("Initialize a Vira project in the current directory")
|
|
102
|
+
.option("-t, --template <template>", "Template type (frontend|fullstack|kanban). If not specified, interactive selection will be shown.")
|
|
103
|
+
.action(async (options) => {
|
|
104
|
+
const projectPath = process.cwd();
|
|
105
|
+
const projectName = path.basename(projectPath);
|
|
106
|
+
// Проверяем, не пуста ли директория
|
|
107
|
+
const files = await fs.readdir(projectPath);
|
|
108
|
+
const ignoreFiles = ['.git', '.gitignore', 'node_modules', '.DS_Store'];
|
|
109
|
+
const visibleFiles = files.filter(f => !ignoreFiles.includes(f) && !f.startsWith('.'));
|
|
110
|
+
if (visibleFiles.length > 0) {
|
|
111
|
+
const { proceed } = await inquirer_1.default.prompt([
|
|
112
|
+
{
|
|
113
|
+
type: "confirm",
|
|
114
|
+
name: "proceed",
|
|
115
|
+
message: `Directory is not empty. Continue anyway?`,
|
|
116
|
+
default: false,
|
|
117
|
+
},
|
|
118
|
+
]);
|
|
119
|
+
if (!proceed) {
|
|
120
|
+
console.log(chalk_1.default.yellow("Init cancelled."));
|
|
121
|
+
process.exit(0);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
console.log(chalk_1.default.blue(`\nInitializing Vira project in: ${projectPath}\n`));
|
|
125
|
+
// Интерактивный выбор шаблона, если не указан
|
|
126
|
+
let template;
|
|
127
|
+
if (options.template) {
|
|
128
|
+
template = options.template;
|
|
129
|
+
if (!SUPPORTED_TEMPLATES.includes(template)) {
|
|
130
|
+
console.error(chalk_1.default.red(`Unknown template: ${template}. Use one of: ${SUPPORTED_TEMPLATES.join(", ")}`));
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
const { selectedTemplate } = await inquirer_1.default.prompt([
|
|
136
|
+
{
|
|
137
|
+
type: "list",
|
|
138
|
+
name: "selectedTemplate",
|
|
139
|
+
message: "Select a template:",
|
|
140
|
+
choices: [
|
|
141
|
+
{
|
|
142
|
+
name: "Frontend (React + Vite + Vira UI)",
|
|
143
|
+
value: "frontend",
|
|
144
|
+
short: "frontend",
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
name: "Fullstack (Frontend + Go Backend + Docker)",
|
|
148
|
+
value: "fullstack",
|
|
149
|
+
short: "fullstack",
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
name: "Kanban (Reference app with VRP)",
|
|
153
|
+
value: "kanban",
|
|
154
|
+
short: "kanban",
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
default: "frontend",
|
|
158
|
+
},
|
|
159
|
+
]);
|
|
160
|
+
template = selectedTemplate;
|
|
161
|
+
}
|
|
162
|
+
// Создаём структуру проекта
|
|
163
|
+
await createProjectStructure(projectPath, template);
|
|
164
|
+
console.log(chalk_1.default.green(`\n✓ Project initialized successfully!\n`));
|
|
165
|
+
printNextSteps(projectName, template, true);
|
|
166
|
+
});
|
|
96
167
|
/**
|
|
97
168
|
* Создание проекта
|
|
98
169
|
*/
|
|
@@ -159,18 +230,27 @@ program
|
|
|
159
230
|
.command("generate")
|
|
160
231
|
.alias("g")
|
|
161
232
|
.description("Generate code")
|
|
162
|
-
.argument("<type>", "Type: component, service, page, model, route")
|
|
233
|
+
.argument("<type>", "Type: component, service, page, model, route, test")
|
|
163
234
|
.argument("<name>", "Name")
|
|
164
235
|
.option("-d, --dir <directory>", "Output directory", "src")
|
|
236
|
+
.option("-i, --interactive", "Interactive mode (for components and services)", false)
|
|
237
|
+
.option("--vrp", "Use Vira Reactive Protocol (VRP)", false)
|
|
238
|
+
.option("--no-vrp", "Do not use VRP (explicit)", false)
|
|
165
239
|
.action(async (type, name, options) => {
|
|
166
240
|
console.log(chalk_1.default.blue(`Generating ${type}: ${name}`));
|
|
167
241
|
switch (type) {
|
|
168
242
|
case "component":
|
|
169
243
|
case "comp":
|
|
170
|
-
await generateComponent(name, options.dir
|
|
244
|
+
await generateComponent(name, options.dir, {
|
|
245
|
+
interactive: options.interactive ?? false,
|
|
246
|
+
useVRP: options.vrp ? true : options.noVrp ? false : undefined,
|
|
247
|
+
});
|
|
171
248
|
break;
|
|
172
249
|
case "service":
|
|
173
|
-
await generateService(name, options.dir
|
|
250
|
+
await generateService(name, options.dir, {
|
|
251
|
+
interactive: options.interactive ?? false,
|
|
252
|
+
useVRP: options.vrp ? true : options.noVrp ? false : undefined,
|
|
253
|
+
});
|
|
174
254
|
break;
|
|
175
255
|
case "page":
|
|
176
256
|
await generatePage(name, options.dir);
|
|
@@ -181,6 +261,9 @@ program
|
|
|
181
261
|
case "route":
|
|
182
262
|
await generateRoute(name, options.dir);
|
|
183
263
|
break;
|
|
264
|
+
case "test":
|
|
265
|
+
await generateTest(name, options.dir);
|
|
266
|
+
break;
|
|
184
267
|
default:
|
|
185
268
|
console.error(chalk_1.default.red(`Unknown type: ${type}`));
|
|
186
269
|
process.exit(1);
|
|
@@ -227,22 +310,115 @@ make
|
|
|
227
310
|
await generateEventHandler(name, options.dir);
|
|
228
311
|
console.log(chalk_1.default.green(`✓ event handler ${name} created in ${options.dir}`));
|
|
229
312
|
});
|
|
313
|
+
make
|
|
314
|
+
.command("model")
|
|
315
|
+
.description("Create a Go model struct")
|
|
316
|
+
.argument("<name>", "Model name (e.g. Client)")
|
|
317
|
+
.option("-d, --dir <directory>", "Target directory", path.join("backend", "internal", "models"))
|
|
318
|
+
.option("-f, --fields <fields>", "Comma-separated field definitions (e.g. 'name:string,email:string,phone:string')")
|
|
319
|
+
.action(async (name, options) => {
|
|
320
|
+
await generateGoModel(name, options.dir, options.fields || undefined);
|
|
321
|
+
console.log(chalk_1.default.green(`✓ Go model ${name} created in ${options.dir}`));
|
|
322
|
+
});
|
|
323
|
+
make
|
|
324
|
+
.command("crud")
|
|
325
|
+
.description("Create CRUD handlers for a resource")
|
|
326
|
+
.argument("<name>", "Resource name (e.g. user)")
|
|
327
|
+
.option("-d, --dir <directory>", "Target directory", path.join("backend", "internal", "handlers"))
|
|
328
|
+
.option("-m, --model <model>", "Model name (defaults to capitalized resource name)")
|
|
329
|
+
.action(async (name, options) => {
|
|
330
|
+
await generateCRUDHandler(name, options.dir, options.model);
|
|
331
|
+
console.log(chalk_1.default.green(`✓ CRUD handlers for ${name} created in ${options.dir}`));
|
|
332
|
+
});
|
|
230
333
|
const protoCommand = program
|
|
231
334
|
.command("proto")
|
|
232
|
-
.description("VRP
|
|
335
|
+
.description("VRP (Vira Reactive Protocol) utilities");
|
|
233
336
|
protoCommand
|
|
234
337
|
.command("validate")
|
|
235
|
-
.description("Validate VRP protocol schema")
|
|
236
|
-
.
|
|
237
|
-
|
|
238
|
-
|
|
338
|
+
.description("Validate VRP protocol schema and types")
|
|
339
|
+
.option("--file <path>", "Path to types file", path.join("backend", "internal", "types", "types.go"))
|
|
340
|
+
.action(async (options) => {
|
|
341
|
+
const typesPath = path.resolve(process.cwd(), options.file || path.join("backend", "internal", "types", "types.go"));
|
|
342
|
+
try {
|
|
343
|
+
if (await fs.pathExists(typesPath)) {
|
|
344
|
+
const content = await fs.readFile(typesPath, "utf8");
|
|
345
|
+
const structs = parseGoStructs(content);
|
|
346
|
+
if (structs.length === 0) {
|
|
347
|
+
console.log(chalk_1.default.yellow("⚠ No Go structs found in types file"));
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
console.log(chalk_1.default.green(`✓ VRP v0.1 protocol schema validated`));
|
|
351
|
+
console.log(chalk_1.default.gray(` Found ${structs.length} type(s): ${structs.map(s => s.name).join(", ")}`));
|
|
352
|
+
console.log(chalk_1.default.gray(" See VIRA_PROTOCOL.md for full specification"));
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
console.log(chalk_1.default.yellow(`⚠ Types file not found: ${typesPath}`));
|
|
357
|
+
console.log(chalk_1.default.gray(" VRP protocol schema structure is valid"));
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
catch (error) {
|
|
361
|
+
console.log(chalk_1.default.yellow("⚠ Could not validate types file"));
|
|
362
|
+
console.log(chalk_1.default.gray(" VRP protocol schema structure is valid"));
|
|
363
|
+
}
|
|
239
364
|
});
|
|
240
365
|
protoCommand
|
|
241
366
|
.command("generate")
|
|
242
|
-
.description("Generate protocol documentation
|
|
243
|
-
.
|
|
244
|
-
|
|
245
|
-
|
|
367
|
+
.description("Generate protocol documentation and channel helpers")
|
|
368
|
+
.option("--file <path>", "Path to types file", path.join("backend", "internal", "types", "types.go"))
|
|
369
|
+
.option("--output <path>", "Output directory", "docs")
|
|
370
|
+
.action(async (options) => {
|
|
371
|
+
const typesPath = path.resolve(process.cwd(), options.file || path.join("backend", "internal", "types", "types.go"));
|
|
372
|
+
const outputDir = path.resolve(process.cwd(), options.output || "docs");
|
|
373
|
+
try {
|
|
374
|
+
if (await fs.pathExists(typesPath)) {
|
|
375
|
+
const content = await fs.readFile(typesPath, "utf8");
|
|
376
|
+
const structs = parseGoStructs(content);
|
|
377
|
+
await fs.ensureDir(outputDir);
|
|
378
|
+
// Генерируем документацию по каналам
|
|
379
|
+
const channelDocs = generateChannelDocumentation(structs);
|
|
380
|
+
await fs.writeFile(path.join(outputDir, "VRP_CHANNELS.md"), channelDocs);
|
|
381
|
+
console.log(chalk_1.default.green(`✓ VRP protocol docs generated`));
|
|
382
|
+
console.log(chalk_1.default.gray(` Channels: ${outputDir}/VRP_CHANNELS.md`));
|
|
383
|
+
console.log(chalk_1.default.gray(` Protocol version: v0.1`));
|
|
384
|
+
console.log(chalk_1.default.gray(` Types: ${structs.length}`));
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
console.log(chalk_1.default.yellow(`⚠ Types file not found: ${typesPath}`));
|
|
388
|
+
console.log(chalk_1.default.gray(" Generating basic protocol documentation"));
|
|
389
|
+
await fs.ensureDir(outputDir);
|
|
390
|
+
const basicDocs = `# Vira Reactive Protocol (VRP)
|
|
391
|
+
|
|
392
|
+
Protocol version: v0.1
|
|
393
|
+
|
|
394
|
+
## Overview
|
|
395
|
+
|
|
396
|
+
VRP is a WebSocket-based protocol for real-time state synchronization.
|
|
397
|
+
|
|
398
|
+
## Message Types
|
|
399
|
+
|
|
400
|
+
- \`handshake\` - Initial connection
|
|
401
|
+
- \`ack\` - Acknowledgment
|
|
402
|
+
- \`sub\` - Subscribe to channel
|
|
403
|
+
- \`unsub\` - Unsubscribe from channel
|
|
404
|
+
- \`update\` - Full state update
|
|
405
|
+
- \`event\` - Event notification
|
|
406
|
+
- \`diff\` - Partial state update
|
|
407
|
+
- \`ping\` / \`pong\` - Keep-alive
|
|
408
|
+
- \`error\` - Error message
|
|
409
|
+
|
|
410
|
+
See VIRA_PROTOCOL.md for full specification.
|
|
411
|
+
`;
|
|
412
|
+
await fs.writeFile(path.join(outputDir, "VRP_CHANNELS.md"), basicDocs);
|
|
413
|
+
console.log(chalk_1.default.green(`✓ Basic VRP protocol docs: ${outputDir}/VRP_CHANNELS.md`));
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
catch (error) {
|
|
417
|
+
console.error(chalk_1.default.red("✗ Error generating documentation"));
|
|
418
|
+
if (error instanceof Error) {
|
|
419
|
+
console.error(chalk_1.default.red(` ${error.message}`));
|
|
420
|
+
}
|
|
421
|
+
}
|
|
246
422
|
});
|
|
247
423
|
program
|
|
248
424
|
.command("doc")
|
|
@@ -268,14 +444,27 @@ program
|
|
|
268
444
|
.option("--backend <path>", "Path to Go types file", path.join("backend", "internal", "types", "types.go"))
|
|
269
445
|
.option("--frontend <path>", "Output TS types path (frontend)", path.join("frontend", "src", "vira-types.ts"))
|
|
270
446
|
.option("--ui <path>", "Output TS types path (ui)", path.join("ui", "src", "vira-types.ts"))
|
|
447
|
+
.option("-w, --watch", "Watch mode: automatically sync on file changes", false)
|
|
271
448
|
.action(async (options) => {
|
|
272
449
|
if (options.types) {
|
|
273
|
-
|
|
450
|
+
if (options.watch) {
|
|
451
|
+
console.log(chalk_1.default.yellow("Watch mode is not yet implemented. Running once..."));
|
|
452
|
+
await syncTypes(options);
|
|
453
|
+
}
|
|
454
|
+
else {
|
|
455
|
+
await syncTypes(options);
|
|
456
|
+
}
|
|
274
457
|
}
|
|
275
458
|
else {
|
|
276
459
|
console.log(chalk_1.default.yellow("Nothing to sync. Use --types to sync TypeScript types."));
|
|
277
460
|
}
|
|
278
461
|
});
|
|
462
|
+
program
|
|
463
|
+
.command("validate")
|
|
464
|
+
.description("Validate project structure and configuration")
|
|
465
|
+
.action(async () => {
|
|
466
|
+
await validateProject();
|
|
467
|
+
});
|
|
279
468
|
program.parse(process.argv);
|
|
280
469
|
/**
|
|
281
470
|
* Создание структуры проекта
|
|
@@ -313,6 +502,16 @@ async function createFullstackProject(projectPath) {
|
|
|
313
502
|
await createBackendStub(backendPath);
|
|
314
503
|
await createDeployScaffold(deployPath);
|
|
315
504
|
await createWorkspaceReadme(projectPath);
|
|
505
|
+
// 🎯 6️⃣ Добавляем корневой package.json с start:dev скриптом
|
|
506
|
+
const rootPackageJson = {
|
|
507
|
+
name: path.basename(projectPath),
|
|
508
|
+
version: "0.1.0",
|
|
509
|
+
private: true,
|
|
510
|
+
scripts: {
|
|
511
|
+
"start:dev": "cd deploy && docker compose -f docker-compose.dev.yml up -d && cd ../frontend && npm install && npm run dev",
|
|
512
|
+
},
|
|
513
|
+
};
|
|
514
|
+
await fs.writeJSON(path.join(projectPath, "package.json"), rootPackageJson, { spaces: 2 });
|
|
316
515
|
}
|
|
317
516
|
/**
|
|
318
517
|
* Create Kanban reference app (VRP-only, no direct useState/fetch)
|
|
@@ -361,7 +560,10 @@ async function createFrontendProject(projectPath) {
|
|
|
361
560
|
dependencies: {
|
|
362
561
|
"@vira-ui/core": "^1.0.0",
|
|
363
562
|
"@vira-ui/ui": "^1.0.0",
|
|
563
|
+
"@vira-ui/react": "^1.0.0",
|
|
364
564
|
"lucide-react": "^0.400.0",
|
|
565
|
+
"uuid": "^9.0.1",
|
|
566
|
+
// lodash не нужен - используем debounceServiceMethod из @vira-ui/core!
|
|
365
567
|
},
|
|
366
568
|
devDependencies: {
|
|
367
569
|
"@vira-ui/babel-plugin": "^1.0.0",
|
|
@@ -369,6 +571,7 @@ async function createFrontendProject(projectPath) {
|
|
|
369
571
|
"@types/node": "^20.10.0",
|
|
370
572
|
"@types/react": "^18.2.0",
|
|
371
573
|
"@types/react-dom": "^18.2.0",
|
|
574
|
+
"@types/uuid": "^9.0.7",
|
|
372
575
|
"react": "^18.2.0",
|
|
373
576
|
"react-dom": "^18.2.0",
|
|
374
577
|
"typescript": "^5.3.0",
|
|
@@ -440,10 +643,15 @@ async function createDeployScaffold(deployPath) {
|
|
|
440
643
|
async function createWorkspaceReadme(projectPath) {
|
|
441
644
|
await fs.writeFile(path.join(projectPath, "README.md"), readme_1.readme);
|
|
442
645
|
}
|
|
443
|
-
function printNextSteps(projectName, template) {
|
|
646
|
+
function printNextSteps(projectName, template, isInit = false) {
|
|
444
647
|
console.log(chalk_1.default.yellow(`\nNext steps:`));
|
|
445
648
|
if (template === "fullstack") {
|
|
446
|
-
|
|
649
|
+
if (!isInit) {
|
|
650
|
+
console.log(` cd ${projectName}/frontend`);
|
|
651
|
+
}
|
|
652
|
+
else {
|
|
653
|
+
console.log(` cd frontend`);
|
|
654
|
+
}
|
|
447
655
|
console.log(` npm install`);
|
|
448
656
|
console.log(` npm run dev`);
|
|
449
657
|
console.log(`\nUI package:`);
|
|
@@ -455,41 +663,435 @@ function printNextSteps(projectName, template) {
|
|
|
455
663
|
console.log(` go mod tidy`);
|
|
456
664
|
console.log(` go run ./cmd/api`);
|
|
457
665
|
console.log(`\nDev stack (DB/Redis/Kafka):`);
|
|
458
|
-
|
|
666
|
+
if (!isInit) {
|
|
667
|
+
console.log(` cd ../${projectName}/deploy && docker compose -f docker-compose.dev.yml up`);
|
|
668
|
+
}
|
|
669
|
+
else {
|
|
670
|
+
console.log(` cd ../deploy && docker compose -f docker-compose.dev.yml up`);
|
|
671
|
+
}
|
|
459
672
|
return;
|
|
460
673
|
}
|
|
461
|
-
|
|
674
|
+
if (!isInit) {
|
|
675
|
+
console.log(` cd ${projectName}`);
|
|
676
|
+
}
|
|
462
677
|
console.log(` npm install`);
|
|
463
678
|
console.log(` npm run dev`);
|
|
464
679
|
}
|
|
680
|
+
/**
|
|
681
|
+
* Сбор VRP конфигурации
|
|
682
|
+
*/
|
|
683
|
+
async function collectVRPConfig(name, useVRP, interactive) {
|
|
684
|
+
if (useVRP === false) {
|
|
685
|
+
return null;
|
|
686
|
+
}
|
|
687
|
+
if (!interactive && useVRP !== true) {
|
|
688
|
+
return null;
|
|
689
|
+
}
|
|
690
|
+
// Если явно указано --vrp, используем значения по умолчанию
|
|
691
|
+
if (useVRP === true && !interactive) {
|
|
692
|
+
return {
|
|
693
|
+
channel: name.toLowerCase(),
|
|
694
|
+
stateType: `${name}State`,
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
// Интерактивный режим: спрашиваем
|
|
698
|
+
if (interactive && useVRP === undefined) {
|
|
699
|
+
const answer = await inquirer_1.default.prompt([
|
|
700
|
+
{
|
|
701
|
+
type: "confirm",
|
|
702
|
+
name: "useVRP",
|
|
703
|
+
message: "Use Vira Reactive Protocol (VRP) for state management?",
|
|
704
|
+
default: false,
|
|
705
|
+
},
|
|
706
|
+
]);
|
|
707
|
+
if (!answer.useVRP) {
|
|
708
|
+
return null;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
// Спрашиваем детали VRP
|
|
712
|
+
const config = await inquirer_1.default.prompt([
|
|
713
|
+
{
|
|
714
|
+
type: "input",
|
|
715
|
+
name: "channel",
|
|
716
|
+
message: "VRP channel name (e.g., 'user', 'task:123', 'demo'):",
|
|
717
|
+
default: name.toLowerCase(),
|
|
718
|
+
validate: (input) => input.length > 0 || "Channel name is required",
|
|
719
|
+
},
|
|
720
|
+
{
|
|
721
|
+
type: "input",
|
|
722
|
+
name: "stateType",
|
|
723
|
+
message: "State type name (interface name):",
|
|
724
|
+
default: `${name}State`,
|
|
725
|
+
},
|
|
726
|
+
]);
|
|
727
|
+
return {
|
|
728
|
+
channel: config.channel,
|
|
729
|
+
stateType: config.stateType,
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Сбор props компонента
|
|
734
|
+
*/
|
|
735
|
+
async function collectProps(name, hasVRP, interactive) {
|
|
736
|
+
if (!interactive) {
|
|
737
|
+
return { props: [], hasProps: true };
|
|
738
|
+
}
|
|
739
|
+
const answer = await inquirer_1.default.prompt([
|
|
740
|
+
{
|
|
741
|
+
type: "confirm",
|
|
742
|
+
name: "hasProps",
|
|
743
|
+
message: "Does this component need props?",
|
|
744
|
+
default: !hasVRP,
|
|
745
|
+
},
|
|
746
|
+
]);
|
|
747
|
+
if (!answer.hasProps) {
|
|
748
|
+
return { props: [], hasProps: false };
|
|
749
|
+
}
|
|
750
|
+
const props = [];
|
|
751
|
+
let addMore = true;
|
|
752
|
+
while (addMore) {
|
|
753
|
+
const prop = await inquirer_1.default.prompt([
|
|
754
|
+
{
|
|
755
|
+
type: "input",
|
|
756
|
+
name: "name",
|
|
757
|
+
message: "Prop name:",
|
|
758
|
+
validate: (input) => input.length > 0 || "Prop name is required",
|
|
759
|
+
},
|
|
760
|
+
{
|
|
761
|
+
type: "list",
|
|
762
|
+
name: "type",
|
|
763
|
+
message: "Prop type:",
|
|
764
|
+
choices: [
|
|
765
|
+
"string",
|
|
766
|
+
"number",
|
|
767
|
+
"boolean",
|
|
768
|
+
"ReactNode",
|
|
769
|
+
"() => void",
|
|
770
|
+
"(e: Event) => void",
|
|
771
|
+
"string[]",
|
|
772
|
+
"Custom",
|
|
773
|
+
],
|
|
774
|
+
default: "string",
|
|
775
|
+
},
|
|
776
|
+
{
|
|
777
|
+
type: "input",
|
|
778
|
+
name: "customType",
|
|
779
|
+
message: "Custom type:",
|
|
780
|
+
when: (answers) => answers.type === "Custom",
|
|
781
|
+
},
|
|
782
|
+
{
|
|
783
|
+
type: "confirm",
|
|
784
|
+
name: "required",
|
|
785
|
+
message: "Required prop?",
|
|
786
|
+
default: true,
|
|
787
|
+
},
|
|
788
|
+
]);
|
|
789
|
+
const propType = prop.type === "Custom" ? prop.customType : prop.type;
|
|
790
|
+
props.push({
|
|
791
|
+
name: prop.name,
|
|
792
|
+
type: propType,
|
|
793
|
+
required: prop.required,
|
|
794
|
+
});
|
|
795
|
+
const { continueAdding } = await inquirer_1.default.prompt([
|
|
796
|
+
{
|
|
797
|
+
type: "confirm",
|
|
798
|
+
name: "continueAdding",
|
|
799
|
+
message: "Add another prop?",
|
|
800
|
+
default: true,
|
|
801
|
+
},
|
|
802
|
+
]);
|
|
803
|
+
addMore = continueAdding;
|
|
804
|
+
}
|
|
805
|
+
return { props, hasProps: true };
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* Построение импортов
|
|
809
|
+
*/
|
|
810
|
+
function buildImports(vrpConfig, useViraUI) {
|
|
811
|
+
let imports = `import { createElement } from '@vira-ui/core';
|
|
812
|
+
import type { ViraComponentProps } from '@vira-ui/core';`;
|
|
813
|
+
if (vrpConfig) {
|
|
814
|
+
imports += `\nimport { useViraState } from '@vira-ui/react';`;
|
|
815
|
+
}
|
|
816
|
+
if (useViraUI) {
|
|
817
|
+
imports += `\nimport { Container, Stack } from '@vira-ui/ui';`;
|
|
818
|
+
}
|
|
819
|
+
return imports;
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* Построение интерфейса props
|
|
823
|
+
*/
|
|
824
|
+
function buildPropsInterface(name, props) {
|
|
825
|
+
if (props.length === 0) {
|
|
826
|
+
return `export interface ${name}Props extends ViraComponentProps {
|
|
827
|
+
// Add your props here
|
|
828
|
+
}`;
|
|
829
|
+
}
|
|
830
|
+
return `export interface ${name}Props extends ViraComponentProps {
|
|
831
|
+
${props.map((p) => ` ${p.name}${p.required ? "" : "?"}: ${p.type};`).join("\n")}
|
|
832
|
+
}`;
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* Построение тела компонента
|
|
836
|
+
*/
|
|
837
|
+
function buildComponentBody(name, vrpConfig, props, hasProps, useViraUI) {
|
|
838
|
+
const propsUsage = props.map((p) => ` ${p.name}={props.${p.name}}`).join("\n");
|
|
839
|
+
if (vrpConfig) {
|
|
840
|
+
return ` const { data, sendEvent, sendUpdate, sendDiff } = useViraState<${vrpConfig.stateType}>('${vrpConfig.channel}', null);
|
|
841
|
+
|
|
842
|
+
// Use data from VRP state
|
|
843
|
+
// Example: const value = data?.field ?? defaultValue;
|
|
844
|
+
|
|
845
|
+
// 🎯 4️⃣ Для inline-редактирования с авто-save используйте встроенный watch() с debounce:
|
|
846
|
+
// import { watch, signal } from '@vira-ui/core';
|
|
847
|
+
// const [editValue, setEditValue] = signal('');
|
|
848
|
+
// watch(() => editValue(), (newValue) => {
|
|
849
|
+
// sendDiff({ [field]: newValue, updated_at: new Date().toISOString() });
|
|
850
|
+
// }, { debounce: 500 });
|
|
851
|
+
|
|
852
|
+
return createElement('div', { className: '${name.toLowerCase()}' },
|
|
853
|
+
${propsUsage ? propsUsage + ",\n" : ""} // Add your content here
|
|
854
|
+
);`;
|
|
855
|
+
}
|
|
856
|
+
if (useViraUI) {
|
|
857
|
+
return ` return (
|
|
858
|
+
<Container>
|
|
859
|
+
<Stack>
|
|
860
|
+
${propsUsage ? propsUsage.split('\n').map(p => ` ${p.replace(' ', '')}`).join('\n') + "\n" : ""} {/* Add your content here */}
|
|
861
|
+
</Stack>
|
|
862
|
+
</Container>
|
|
863
|
+
);`;
|
|
864
|
+
}
|
|
865
|
+
return ` return createElement('div', { className: '${name.toLowerCase()}' },
|
|
866
|
+
${propsUsage ? propsUsage + ",\n" : ""} // Add your content here
|
|
867
|
+
);`;
|
|
868
|
+
}
|
|
465
869
|
/**
|
|
466
870
|
* Генерация компонента
|
|
467
871
|
*/
|
|
468
|
-
async function generateComponent(name, dir) {
|
|
872
|
+
async function generateComponent(name, dir, config) {
|
|
469
873
|
const componentPath = path.join(process.cwd(), dir, "components", `${name}.tsx`);
|
|
470
874
|
await fs.ensureDir(path.dirname(componentPath));
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
875
|
+
// Сбор конфигурации
|
|
876
|
+
const vrpConfig = await collectVRPConfig(name, config.useVRP, config.interactive);
|
|
877
|
+
const { props, hasProps } = await collectProps(name, !!vrpConfig, config.interactive);
|
|
878
|
+
// Спрашиваем про Vira UI, если интерактивный режим
|
|
879
|
+
let useViraUI = false;
|
|
880
|
+
if (config.interactive) {
|
|
881
|
+
const uiAnswer = await inquirer_1.default.prompt([
|
|
882
|
+
{
|
|
883
|
+
type: "confirm",
|
|
884
|
+
name: "useViraUI",
|
|
885
|
+
message: "Use Vira UI components (@vira-ui/ui)?",
|
|
886
|
+
default: true,
|
|
887
|
+
},
|
|
888
|
+
]);
|
|
889
|
+
useViraUI = uiAnswer.useViraUI;
|
|
890
|
+
}
|
|
891
|
+
// Построение кода
|
|
892
|
+
const imports = buildImports(vrpConfig, useViraUI);
|
|
893
|
+
const propsInterface = buildPropsInterface(name, props);
|
|
894
|
+
const componentBody = buildComponentBody(name, vrpConfig, props, hasProps, useViraUI);
|
|
895
|
+
// Сборка финального кода
|
|
896
|
+
let componentCode = `${imports}
|
|
897
|
+
`;
|
|
898
|
+
// Интерфейс состояния VRP
|
|
899
|
+
if (vrpConfig) {
|
|
900
|
+
componentCode += `export interface ${vrpConfig.stateType} {
|
|
901
|
+
// Add your state fields here
|
|
902
|
+
id?: string;
|
|
476
903
|
}
|
|
477
904
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
//
|
|
481
|
-
|
|
905
|
+
`;
|
|
906
|
+
}
|
|
907
|
+
// Интерфейс props
|
|
908
|
+
if (hasProps || !vrpConfig) {
|
|
909
|
+
componentCode += `${propsInterface}
|
|
910
|
+
|
|
911
|
+
`;
|
|
912
|
+
}
|
|
913
|
+
// Сигнатура функции
|
|
914
|
+
const functionSignature = hasProps || !vrpConfig
|
|
915
|
+
? `export function ${name}(props: ${name}Props) {`
|
|
916
|
+
: `export function ${name}() {`;
|
|
917
|
+
componentCode += `${functionSignature}
|
|
918
|
+
${componentBody}
|
|
482
919
|
}
|
|
483
920
|
`;
|
|
484
921
|
await fs.writeFile(componentPath, componentCode);
|
|
485
922
|
}
|
|
486
923
|
/**
|
|
487
|
-
*
|
|
924
|
+
* Сбор VRP конфигурации для сервиса
|
|
488
925
|
*/
|
|
489
|
-
async function
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
926
|
+
async function collectServiceVRPConfig(name, useVRP, interactive) {
|
|
927
|
+
if (useVRP === false) {
|
|
928
|
+
return null;
|
|
929
|
+
}
|
|
930
|
+
if (!interactive && useVRP !== true) {
|
|
931
|
+
return null;
|
|
932
|
+
}
|
|
933
|
+
// Если явно указано --vrp, используем значения по умолчанию
|
|
934
|
+
if (useVRP === true && !interactive) {
|
|
935
|
+
return {
|
|
936
|
+
channel: name.toLowerCase(),
|
|
937
|
+
stateType: `${name}State`,
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
// Интерактивный режим: спрашиваем
|
|
941
|
+
if (interactive && useVRP === undefined) {
|
|
942
|
+
const answer = await inquirer_1.default.prompt([
|
|
943
|
+
{
|
|
944
|
+
type: "confirm",
|
|
945
|
+
name: "useVRP",
|
|
946
|
+
message: "Use Vira Reactive Protocol (VRP) for this service?",
|
|
947
|
+
default: true,
|
|
948
|
+
},
|
|
949
|
+
]);
|
|
950
|
+
if (!answer.useVRP) {
|
|
951
|
+
return null;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
// Спрашиваем детали VRP
|
|
955
|
+
const config = await inquirer_1.default.prompt([
|
|
956
|
+
{
|
|
957
|
+
type: "input",
|
|
958
|
+
name: "channel",
|
|
959
|
+
message: "VRP channel name:",
|
|
960
|
+
default: name.toLowerCase(),
|
|
961
|
+
},
|
|
962
|
+
{
|
|
963
|
+
type: "input",
|
|
964
|
+
name: "stateType",
|
|
965
|
+
message: "State type name:",
|
|
966
|
+
default: `${name}State`,
|
|
967
|
+
},
|
|
968
|
+
]);
|
|
969
|
+
return {
|
|
970
|
+
channel: config.channel,
|
|
971
|
+
stateType: config.stateType,
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Построение VRP-based сервиса
|
|
976
|
+
*/
|
|
977
|
+
function buildVRPService(name, vrpConfig) {
|
|
978
|
+
const lowerName = name.toLowerCase();
|
|
979
|
+
return `// ${name} service using Vira Core DI container + VRP
|
|
980
|
+
import { createService, useService } from '@vira-ui/core';
|
|
981
|
+
import { useViraState } from '@vira-ui/react';
|
|
982
|
+
import { v4 as uuid } from 'uuid';
|
|
983
|
+
|
|
984
|
+
export interface ${vrpConfig.stateType} {
|
|
985
|
+
// Add your state fields here
|
|
986
|
+
id?: string;
|
|
987
|
+
created_at?: string;
|
|
988
|
+
updated_at?: string;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// 🎯 3️⃣ Универсальный VRP hook для списков (переиспользуемый для любых сущностей)
|
|
992
|
+
export function useVrpList<T>(channel: string) {
|
|
993
|
+
const { data, sendEvent, sendDiff } = useViraState<T[] | T>(channel, []);
|
|
994
|
+
const list = Array.isArray(data) ? data : Object.values(data || {});
|
|
995
|
+
return { data: list, sendEvent, sendDiff };
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// Create ${lowerName} service (singleton via DI container)
|
|
999
|
+
// Service holds pure business logic helpers
|
|
1000
|
+
createService('${lowerName}', () => ({
|
|
1001
|
+
// Add your business logic methods here
|
|
1002
|
+
processData(data: ${vrpConfig.stateType} | null): any {
|
|
1003
|
+
if (!data) return null;
|
|
1004
|
+
// Add processing logic
|
|
1005
|
+
return data;
|
|
1006
|
+
},
|
|
1007
|
+
}));
|
|
1008
|
+
|
|
1009
|
+
// Hook for ${name} operations (combines service + VRP state)
|
|
1010
|
+
export function use${name}(id?: string) {
|
|
1011
|
+
const channel = id ? \`${vrpConfig.channel}:\${id}\` : '${vrpConfig.channel}';
|
|
1012
|
+
const { data, sendEvent, sendUpdate, sendDiff } = id
|
|
1013
|
+
? useViraState<${vrpConfig.stateType}>(channel, null)
|
|
1014
|
+
: useVrpList<${vrpConfig.stateType}>(channel);
|
|
1015
|
+
const ${lowerName}Service = useService<{ processData: (data: ${vrpConfig.stateType} | null) => any }>('${lowerName}');
|
|
1016
|
+
|
|
1017
|
+
return {
|
|
1018
|
+
data,
|
|
1019
|
+
// 🎯 1️⃣ Создание с авто-генерацией UUID на фронте (VRP сразу знает id, не надо ждать бэка)
|
|
1020
|
+
create(item: Omit<${vrpConfig.stateType}, 'id' | 'created_at' | 'updated_at'>) {
|
|
1021
|
+
const id = uuid();
|
|
1022
|
+
const newItem: ${vrpConfig.stateType} = {
|
|
1023
|
+
...item,
|
|
1024
|
+
id,
|
|
1025
|
+
created_at: new Date().toISOString(),
|
|
1026
|
+
updated_at: new Date().toISOString(),
|
|
1027
|
+
};
|
|
1028
|
+
sendEvent('${lowerName}.created', {
|
|
1029
|
+
...newItem,
|
|
1030
|
+
timestamp: new Date().toISOString()
|
|
1031
|
+
});
|
|
1032
|
+
},
|
|
1033
|
+
// Update operations
|
|
1034
|
+
update(updates: Partial<${vrpConfig.stateType}>) {
|
|
1035
|
+
sendDiff(updates);
|
|
1036
|
+
sendEvent('${lowerName}.updated', {
|
|
1037
|
+
...updates,
|
|
1038
|
+
updated_at: new Date().toISOString(),
|
|
1039
|
+
timestamp: new Date().toISOString()
|
|
1040
|
+
});
|
|
1041
|
+
},
|
|
1042
|
+
// Delete operation
|
|
1043
|
+
delete(itemId: string) {
|
|
1044
|
+
sendEvent('${lowerName}.deleted', {
|
|
1045
|
+
id: itemId,
|
|
1046
|
+
timestamp: new Date().toISOString()
|
|
1047
|
+
});
|
|
1048
|
+
},
|
|
1049
|
+
sendEvent,
|
|
1050
|
+
sendDiff,
|
|
1051
|
+
// Service methods
|
|
1052
|
+
processData() {
|
|
1053
|
+
return ${lowerName}Service.processData(data);
|
|
1054
|
+
},
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// 🎯 2️⃣ Сервис для bulk actions с VRP (переиспользуемый для любых сущностей)
|
|
1059
|
+
// Используем batch() из @vira-ui/core для оптимизации множественных обновлений
|
|
1060
|
+
import { batch } from '@vira-ui/core';
|
|
1061
|
+
|
|
1062
|
+
createService('${lowerName}Bulk', () => ({
|
|
1063
|
+
bulkUpdate(ids: string[], payload: Partial<${vrpConfig.stateType}>, sendEvent: Function) {
|
|
1064
|
+
// batch() группирует все обновления в один цикл - компоненты обновятся только один раз!
|
|
1065
|
+
batch(() => {
|
|
1066
|
+
ids.forEach(id => {
|
|
1067
|
+
sendEvent('${lowerName}.updated', {
|
|
1068
|
+
id,
|
|
1069
|
+
...payload,
|
|
1070
|
+
updated_at: new Date().toISOString(),
|
|
1071
|
+
timestamp: new Date().toISOString()
|
|
1072
|
+
});
|
|
1073
|
+
});
|
|
1074
|
+
});
|
|
1075
|
+
},
|
|
1076
|
+
bulkDelete(ids: string[], sendEvent: Function) {
|
|
1077
|
+
// batch() для оптимизации массового удаления
|
|
1078
|
+
batch(() => {
|
|
1079
|
+
ids.forEach(id => {
|
|
1080
|
+
sendEvent('${lowerName}.deleted', {
|
|
1081
|
+
id,
|
|
1082
|
+
timestamp: new Date().toISOString()
|
|
1083
|
+
});
|
|
1084
|
+
});
|
|
1085
|
+
});
|
|
1086
|
+
},
|
|
1087
|
+
}));
|
|
1088
|
+
`;
|
|
1089
|
+
}
|
|
1090
|
+
/**
|
|
1091
|
+
* Построение стандартного сервиса с signals
|
|
1092
|
+
*/
|
|
1093
|
+
function buildStandardService(name) {
|
|
1094
|
+
return `import { createViraService, signal } from '@vira-ui/core';
|
|
493
1095
|
|
|
494
1096
|
export const ${name}Service = createViraService('${name.toLowerCase()}', () => {
|
|
495
1097
|
const data = signal([]);
|
|
@@ -517,6 +1119,19 @@ export const ${name}Service = createViraService('${name.toLowerCase()}', () => {
|
|
|
517
1119
|
};
|
|
518
1120
|
});
|
|
519
1121
|
`;
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* Генерация сервиса
|
|
1125
|
+
*/
|
|
1126
|
+
async function generateService(name, dir, config) {
|
|
1127
|
+
const servicePath = path.join(process.cwd(), dir, "services", `${name}Service.ts`);
|
|
1128
|
+
await fs.ensureDir(path.dirname(servicePath));
|
|
1129
|
+
// Сбор VRP конфигурации
|
|
1130
|
+
const vrpConfig = await collectServiceVRPConfig(name, config.useVRP, config.interactive);
|
|
1131
|
+
// Построение кода
|
|
1132
|
+
const serviceCode = vrpConfig
|
|
1133
|
+
? buildVRPService(name, vrpConfig)
|
|
1134
|
+
: buildStandardService(name);
|
|
520
1135
|
await fs.writeFile(servicePath, serviceCode);
|
|
521
1136
|
}
|
|
522
1137
|
/**
|
|
@@ -525,9 +1140,49 @@ export const ${name}Service = createViraService('${name.toLowerCase()}', () => {
|
|
|
525
1140
|
async function generatePage(name, dir) {
|
|
526
1141
|
const pagePath = path.join(process.cwd(), dir, "pages", `${name}Page.tsx`);
|
|
527
1142
|
await fs.ensureDir(path.dirname(pagePath));
|
|
528
|
-
const pageCode = `import { createElement } from '@vira-ui/core';
|
|
1143
|
+
const pageCode = `import { createService, useService, createElement } from '@vira-ui/core';
|
|
1144
|
+
import { useViraState } from '@vira-ui/react';
|
|
1145
|
+
|
|
1146
|
+
// 🎯 5️⃣ VRP notifications: очередь с максимумом (предотвращает перегрузку фронта)
|
|
1147
|
+
const MAX_NOTIFICATIONS = 5;
|
|
1148
|
+
|
|
1149
|
+
// Создаём сервис для управления состоянием страницы
|
|
1150
|
+
createService('${name.toLowerCase()}Page', () => ({
|
|
1151
|
+
searchQuery: '',
|
|
1152
|
+
selectedIds: new Set<string>(),
|
|
1153
|
+
|
|
1154
|
+
setSearchQuery(value: string) {
|
|
1155
|
+
this.searchQuery = value;
|
|
1156
|
+
},
|
|
1157
|
+
|
|
1158
|
+
toggleSelect(id: string) {
|
|
1159
|
+
if (this.selectedIds.has(id)) {
|
|
1160
|
+
this.selectedIds.delete(id);
|
|
1161
|
+
} else {
|
|
1162
|
+
this.selectedIds.add(id);
|
|
1163
|
+
}
|
|
1164
|
+
},
|
|
1165
|
+
|
|
1166
|
+
clearSelection() {
|
|
1167
|
+
this.selectedIds.clear();
|
|
1168
|
+
},
|
|
1169
|
+
}));
|
|
529
1170
|
|
|
530
1171
|
export function ${name}Page() {
|
|
1172
|
+
// VRP для списка
|
|
1173
|
+
// const { data, create, sendEvent } = use${name}();
|
|
1174
|
+
|
|
1175
|
+
// VRP для real-time уведомлений с лимитом
|
|
1176
|
+
const notificationsState = useViraState<any[]>('notifications:${name.toLowerCase()}', []);
|
|
1177
|
+
|
|
1178
|
+
// Используем сервис страницы
|
|
1179
|
+
const pageService = useService('${name.toLowerCase()}Page');
|
|
1180
|
+
|
|
1181
|
+
// Обработка уведомлений с лимитом очереди
|
|
1182
|
+
if (notificationsState.data && notificationsState.data.length > MAX_NOTIFICATIONS) {
|
|
1183
|
+
notificationsState.data = notificationsState.data.slice(-MAX_NOTIFICATIONS);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
531
1186
|
return createElement('div', { className: '${name.toLowerCase()}-page' },
|
|
532
1187
|
createElement('h1', null, '${name}'),
|
|
533
1188
|
// Add your content here
|
|
@@ -539,7 +1194,7 @@ export function ${name}Page() {
|
|
|
539
1194
|
/**
|
|
540
1195
|
* Генерация модели
|
|
541
1196
|
*/
|
|
542
|
-
async function generateModel(name, dir) {
|
|
1197
|
+
async function generateModel(name, dir, fields) {
|
|
543
1198
|
const modelPath = path.join(process.cwd(), dir, "models", `${name}.ts`);
|
|
544
1199
|
await fs.ensureDir(path.dirname(modelPath));
|
|
545
1200
|
const modelCode = `import { defineModel } from '@vira-ui/core';
|
|
@@ -570,6 +1225,78 @@ export const ${name}Route = reactiveRoute({
|
|
|
570
1225
|
`;
|
|
571
1226
|
await fs.writeFile(routePath, routeCode);
|
|
572
1227
|
}
|
|
1228
|
+
/**
|
|
1229
|
+
* Генерация теста
|
|
1230
|
+
*/
|
|
1231
|
+
async function generateTest(name, dir) {
|
|
1232
|
+
// Определяем тип файла (component, service, page)
|
|
1233
|
+
const componentPath = path.join(process.cwd(), dir, "components", `${name}.tsx`);
|
|
1234
|
+
const servicePath = path.join(process.cwd(), dir, "services", `${name}Service.ts`);
|
|
1235
|
+
const pagePath = path.join(process.cwd(), dir, "pages", `${name}Page.tsx`);
|
|
1236
|
+
let testPath;
|
|
1237
|
+
let testCode;
|
|
1238
|
+
if (await fs.pathExists(componentPath)) {
|
|
1239
|
+
// Тест для компонента
|
|
1240
|
+
testPath = path.join(process.cwd(), dir, "components", `${name}.test.tsx`);
|
|
1241
|
+
testCode = `import React from 'react';
|
|
1242
|
+
import { render } from '@testing-library/react';
|
|
1243
|
+
import { ${name} } from './${name}';
|
|
1244
|
+
import type { ${name}Props } from './${name}';
|
|
1245
|
+
|
|
1246
|
+
describe('${name}', () => {
|
|
1247
|
+
it('renders correctly', () => {
|
|
1248
|
+
const props: ${name}Props = {
|
|
1249
|
+
// Add test props here
|
|
1250
|
+
};
|
|
1251
|
+
const { container } = render(React.createElement(${name}, props));
|
|
1252
|
+
expect(container).toBeTruthy();
|
|
1253
|
+
});
|
|
1254
|
+
});
|
|
1255
|
+
`;
|
|
1256
|
+
}
|
|
1257
|
+
else if (await fs.pathExists(servicePath)) {
|
|
1258
|
+
// Тест для сервиса
|
|
1259
|
+
testPath = path.join(process.cwd(), dir, "services", `${name}Service.test.ts`);
|
|
1260
|
+
testCode = `import { ${name}Service } from './${name}Service';
|
|
1261
|
+
|
|
1262
|
+
describe('${name}Service', () => {
|
|
1263
|
+
it('should be defined', () => {
|
|
1264
|
+
expect(${name}Service).toBeDefined();
|
|
1265
|
+
});
|
|
1266
|
+
|
|
1267
|
+
// Add more tests here
|
|
1268
|
+
});
|
|
1269
|
+
`;
|
|
1270
|
+
}
|
|
1271
|
+
else if (await fs.pathExists(pagePath)) {
|
|
1272
|
+
// Тест для страницы
|
|
1273
|
+
testPath = path.join(process.cwd(), dir, "pages", `${name}Page.test.tsx`);
|
|
1274
|
+
testCode = `import React from 'react';
|
|
1275
|
+
import { render } from '@testing-library/react';
|
|
1276
|
+
import { ${name}Page } from './${name}Page';
|
|
1277
|
+
|
|
1278
|
+
describe('${name}Page', () => {
|
|
1279
|
+
it('renders correctly', () => {
|
|
1280
|
+
const { container } = render(React.createElement(${name}Page));
|
|
1281
|
+
expect(container).toBeTruthy();
|
|
1282
|
+
});
|
|
1283
|
+
});
|
|
1284
|
+
`;
|
|
1285
|
+
}
|
|
1286
|
+
else {
|
|
1287
|
+
// Общий тест, если файл не найден
|
|
1288
|
+
testPath = path.join(process.cwd(), dir, "__tests__", `${name}.test.ts`);
|
|
1289
|
+
await fs.ensureDir(path.dirname(testPath));
|
|
1290
|
+
testCode = `describe('${name}', () => {
|
|
1291
|
+
it('should work', () => {
|
|
1292
|
+
expect(true).toBe(true);
|
|
1293
|
+
});
|
|
1294
|
+
});
|
|
1295
|
+
`;
|
|
1296
|
+
}
|
|
1297
|
+
await fs.ensureDir(path.dirname(testPath));
|
|
1298
|
+
await fs.writeFile(testPath, testCode);
|
|
1299
|
+
}
|
|
573
1300
|
/**
|
|
574
1301
|
* Sync TypeScript types from Go structs (scaffold-level parser)
|
|
575
1302
|
*/
|
|
@@ -728,6 +1455,50 @@ function toCamel(name) {
|
|
|
728
1455
|
return name;
|
|
729
1456
|
return name.charAt(0).toLowerCase() + name.slice(1);
|
|
730
1457
|
}
|
|
1458
|
+
/**
|
|
1459
|
+
* Генерация документации по VRP каналам
|
|
1460
|
+
*/
|
|
1461
|
+
function generateChannelDocumentation(structs) {
|
|
1462
|
+
const lines = [];
|
|
1463
|
+
lines.push("# Vira Reactive Protocol (VRP) Channels");
|
|
1464
|
+
lines.push("");
|
|
1465
|
+
lines.push("Protocol version: v0.1");
|
|
1466
|
+
lines.push("");
|
|
1467
|
+
lines.push("## Available Channels");
|
|
1468
|
+
lines.push("");
|
|
1469
|
+
lines.push("The following channels are available based on your Go types:");
|
|
1470
|
+
lines.push("");
|
|
1471
|
+
for (const struct of structs) {
|
|
1472
|
+
const channelName = struct.name.toLowerCase();
|
|
1473
|
+
lines.push(`### \`${channelName}\` / \`${channelName}:{id}\``);
|
|
1474
|
+
lines.push("");
|
|
1475
|
+
lines.push(`Type: \`${struct.name}\``);
|
|
1476
|
+
lines.push("");
|
|
1477
|
+
if (struct.fields.length > 0) {
|
|
1478
|
+
lines.push("Fields:");
|
|
1479
|
+
lines.push("");
|
|
1480
|
+
for (const field of struct.fields) {
|
|
1481
|
+
const jsonName = field.json || toCamel(field.name);
|
|
1482
|
+
const tsType = goTypeToTs(field.type);
|
|
1483
|
+
lines.push(`- \`${jsonName}\`: \`${tsType}\``);
|
|
1484
|
+
}
|
|
1485
|
+
lines.push("");
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
lines.push("## Usage");
|
|
1489
|
+
lines.push("");
|
|
1490
|
+
lines.push("```typescript");
|
|
1491
|
+
lines.push("import { useViraState } from '@vira-ui/react';");
|
|
1492
|
+
lines.push("");
|
|
1493
|
+
if (structs.length > 0) {
|
|
1494
|
+
const firstStruct = structs[0];
|
|
1495
|
+
lines.push(`const { data, sendEvent, sendUpdate, sendDiff } = useViraState<${firstStruct.name}>('${firstStruct.name.toLowerCase()}', null);`);
|
|
1496
|
+
}
|
|
1497
|
+
lines.push("```");
|
|
1498
|
+
lines.push("");
|
|
1499
|
+
lines.push("See VIRA_PROTOCOL.md for full specification.");
|
|
1500
|
+
return lines.join("\n");
|
|
1501
|
+
}
|
|
731
1502
|
/**
|
|
732
1503
|
* Go HTTP handler scaffold
|
|
733
1504
|
*/
|
|
@@ -760,21 +1531,42 @@ func ${handlerName}(w http.ResponseWriter, r *http.Request) {
|
|
|
760
1531
|
/**
|
|
761
1532
|
* Go model scaffold
|
|
762
1533
|
*/
|
|
763
|
-
async function generateGoModel(name, dir) {
|
|
1534
|
+
async function generateGoModel(name, dir, fields) {
|
|
764
1535
|
const modelName = capitalize(name);
|
|
765
1536
|
const targetDir = path.join(process.cwd(), dir);
|
|
766
1537
|
await fs.ensureDir(targetDir);
|
|
1538
|
+
// Парсим поля если указаны
|
|
1539
|
+
let fieldsCode = "";
|
|
1540
|
+
if (fields) {
|
|
1541
|
+
const fieldList = fields.split(",").map(f => f.trim());
|
|
1542
|
+
fieldsCode = fieldList.map(field => {
|
|
1543
|
+
const [fieldName, fieldType] = field.split(":").map(s => s.trim());
|
|
1544
|
+
const goType = mapTypeScriptToGo(fieldType || "string");
|
|
1545
|
+
return ` ${capitalize(fieldName)} ${goType} \`db:"${fieldName.toLowerCase()}" json:"${fieldName.toLowerCase()}"\``;
|
|
1546
|
+
}).join("\n");
|
|
1547
|
+
// Добавляем стандартные поля если их нет
|
|
1548
|
+
if (!fieldList.some(f => f.toLowerCase().includes("id"))) {
|
|
1549
|
+
fieldsCode = ` ID string \`db:"id" json:"id"\`
|
|
1550
|
+
${fieldsCode}
|
|
1551
|
+
CreatedAt time.Time \`db:"created_at" json:"created_at"\`
|
|
1552
|
+
UpdatedAt time.Time \`db:"updated_at" json:"updated_at"\``;
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
else {
|
|
1556
|
+
// Дефолтные поля для типичной модели
|
|
1557
|
+
fieldsCode = ` ID string \`db:"id" json:"id"\`
|
|
1558
|
+
CreatedAt time.Time \`db:"created_at" json:"created_at"\`
|
|
1559
|
+
UpdatedAt time.Time \`db:"updated_at" json:"updated_at"\``;
|
|
1560
|
+
}
|
|
767
1561
|
const modelCode = `package models
|
|
768
1562
|
|
|
769
1563
|
import "time"
|
|
770
1564
|
|
|
771
1565
|
type ${modelName} struct {
|
|
772
|
-
|
|
773
|
-
CreatedAt time.Time \`db:"created_at"\`
|
|
774
|
-
UpdatedAt time.Time \`db:"updated_at"\`
|
|
1566
|
+
${fieldsCode}
|
|
775
1567
|
}
|
|
776
1568
|
`;
|
|
777
|
-
await fs.writeFile(path.join(targetDir, `${modelName}.go`), modelCode);
|
|
1569
|
+
await fs.writeFile(path.join(targetDir, `${modelName.toLowerCase()}.go`), modelCode);
|
|
778
1570
|
}
|
|
779
1571
|
/**
|
|
780
1572
|
* SQL migration scaffold (timestamped up/down)
|
|
@@ -822,6 +1614,19 @@ function capitalize(value) {
|
|
|
822
1614
|
return value;
|
|
823
1615
|
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
824
1616
|
}
|
|
1617
|
+
/**
|
|
1618
|
+
* Маппинг TypeScript типов в Go типы
|
|
1619
|
+
*/
|
|
1620
|
+
function mapTypeScriptToGo(tsType) {
|
|
1621
|
+
const mapping = {
|
|
1622
|
+
"string": "string",
|
|
1623
|
+
"number": "int",
|
|
1624
|
+
"boolean": "bool",
|
|
1625
|
+
"Date": "time.Time",
|
|
1626
|
+
"date": "time.Time",
|
|
1627
|
+
};
|
|
1628
|
+
return mapping[tsType.toLowerCase()] || "string";
|
|
1629
|
+
}
|
|
825
1630
|
function toPascal(value) {
|
|
826
1631
|
return value
|
|
827
1632
|
.split(/[^a-zA-Z0-9]+/)
|
|
@@ -829,3 +1634,429 @@ function toPascal(value) {
|
|
|
829
1634
|
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
|
830
1635
|
.join("");
|
|
831
1636
|
}
|
|
1637
|
+
/**
|
|
1638
|
+
* Генерация CRUD handlers для ресурса
|
|
1639
|
+
*/
|
|
1640
|
+
async function generateCRUDHandler(name, dir, modelName) {
|
|
1641
|
+
const safeName = name.toLowerCase();
|
|
1642
|
+
const handlerName = capitalize(name);
|
|
1643
|
+
const model = modelName || capitalize(name);
|
|
1644
|
+
const targetDir = path.join(process.cwd(), dir);
|
|
1645
|
+
await fs.ensureDir(targetDir);
|
|
1646
|
+
const handlerCode = `package handlers
|
|
1647
|
+
|
|
1648
|
+
import (
|
|
1649
|
+
"encoding/json"
|
|
1650
|
+
"net/http"
|
|
1651
|
+
"strconv"
|
|
1652
|
+
"time"
|
|
1653
|
+
"github.com/gorilla/mux"
|
|
1654
|
+
"github.com/go-playground/validator/v10"
|
|
1655
|
+
"github.com/google/uuid"
|
|
1656
|
+
)
|
|
1657
|
+
|
|
1658
|
+
var validate = validator.New()
|
|
1659
|
+
|
|
1660
|
+
// 🎯 Production-ready: Pagination support
|
|
1661
|
+
type PaginationParams struct {
|
|
1662
|
+
Limit int \`json:"limit"\`
|
|
1663
|
+
Offset int \`json:"offset"\`
|
|
1664
|
+
Cursor string \`json:"cursor,omitempty"\`
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
// 🎯 Production-ready: List response with pagination
|
|
1668
|
+
type ${handlerName}ListResponse struct {
|
|
1669
|
+
Items []${model} \`json:"items"\`
|
|
1670
|
+
Total int \`json:"total"\`
|
|
1671
|
+
Limit int \`json:"limit"\`
|
|
1672
|
+
Offset int \`json:"offset"\`
|
|
1673
|
+
HasMore bool \`json:"has_more"\`
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
// 🎯 Production-ready: Event logging structure
|
|
1677
|
+
type ${handlerName}Event struct {
|
|
1678
|
+
ID string \`json:"id"\`
|
|
1679
|
+
Type string \`json:"type"\` // created, updated, deleted
|
|
1680
|
+
EntityID string \`json:"entity_id"\`
|
|
1681
|
+
UserID string \`json:"user_id,omitempty"\`
|
|
1682
|
+
OldValue *${model} \`json:"old_value,omitempty"\`
|
|
1683
|
+
NewValue *${model} \`json:"new_value,omitempty"\`
|
|
1684
|
+
Timestamp time.Time \`json:"timestamp"\`
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
// 🎯 Production-ready: Batch request/response
|
|
1688
|
+
type BatchUpdateRequest struct {
|
|
1689
|
+
IDs []string \`json:"ids" validate:"required,min=1"\`
|
|
1690
|
+
Payload map[string]interface{} \`json:"payload" validate:"required"\`
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
type BatchDeleteRequest struct {
|
|
1694
|
+
IDs []string \`json:"ids" validate:"required,min=1"\`
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
// List${handlerName} handles GET /${safeName} with pagination
|
|
1698
|
+
func List${handlerName}(w http.ResponseWriter, r *http.Request) {
|
|
1699
|
+
w.Header().Set("Content-Type", "application/json")
|
|
1700
|
+
|
|
1701
|
+
// 🎯 Production-ready: Parse pagination params (limit, offset, cursor)
|
|
1702
|
+
limit := 50 // default
|
|
1703
|
+
offset := 0
|
|
1704
|
+
|
|
1705
|
+
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
|
|
1706
|
+
if parsed, err := strconv.Atoi(limitStr); err == nil && parsed > 0 && parsed <= 100 {
|
|
1707
|
+
limit = parsed
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
|
|
1712
|
+
if parsed, err := strconv.Atoi(offsetStr); err == nil && parsed >= 0 {
|
|
1713
|
+
offset = parsed
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
// TODO: Implement actual DB query with limit/offset
|
|
1718
|
+
// items, total, err := db.List${handlerName}(limit, offset)
|
|
1719
|
+
// if err != nil {
|
|
1720
|
+
// http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
1721
|
+
// return
|
|
1722
|
+
// }
|
|
1723
|
+
|
|
1724
|
+
// 🎯 Production-ready: Redis caching (commented for now - implement with your cache layer)
|
|
1725
|
+
// cacheKey := fmt.Sprintf("${safeName}:list:limit=%d:offset=%d", limit, offset)
|
|
1726
|
+
// if cached := redis.Get(cacheKey); cached != nil {
|
|
1727
|
+
// json.NewEncoder(w).Encode(cached)
|
|
1728
|
+
// return
|
|
1729
|
+
// }
|
|
1730
|
+
|
|
1731
|
+
response := ${handlerName}ListResponse{
|
|
1732
|
+
Items: []${model}{},
|
|
1733
|
+
Total: 0,
|
|
1734
|
+
Limit: limit,
|
|
1735
|
+
Offset: offset,
|
|
1736
|
+
HasMore: false,
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
// 🎯 Production-ready: Cache response (TTL 30s for lists)
|
|
1740
|
+
// redis.Set(cacheKey, response, 30*time.Second)
|
|
1741
|
+
|
|
1742
|
+
json.NewEncoder(w).Encode(response)
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
// Get${handlerName} handles GET /${safeName}/{id}
|
|
1746
|
+
func Get${handlerName}(w http.ResponseWriter, r *http.Request) {
|
|
1747
|
+
vars := mux.Vars(r)
|
|
1748
|
+
id := vars["id"]
|
|
1749
|
+
|
|
1750
|
+
w.Header().Set("Content-Type", "application/json")
|
|
1751
|
+
|
|
1752
|
+
// 🎯 Production-ready: Redis caching for detail views
|
|
1753
|
+
// cacheKey := fmt.Sprintf("${safeName}:detail:%s", id)
|
|
1754
|
+
// if cached := redis.Get(cacheKey); cached != nil {
|
|
1755
|
+
// json.NewEncoder(w).Encode(cached)
|
|
1756
|
+
// return
|
|
1757
|
+
// }
|
|
1758
|
+
|
|
1759
|
+
// TODO: Implement actual DB query
|
|
1760
|
+
// item, err := db.Get${handlerName}(id)
|
|
1761
|
+
// if err != nil {
|
|
1762
|
+
// http.Error(w, err.Error(), http.StatusNotFound)
|
|
1763
|
+
// return
|
|
1764
|
+
// }
|
|
1765
|
+
|
|
1766
|
+
item := ${model}{ID: id}
|
|
1767
|
+
|
|
1768
|
+
// 🎯 Production-ready: Cache detail (TTL 5min)
|
|
1769
|
+
// redis.Set(cacheKey, item, 5*time.Minute)
|
|
1770
|
+
|
|
1771
|
+
json.NewEncoder(w).Encode(item)
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
// Create${handlerName} handles POST /${safeName}
|
|
1775
|
+
func Create${handlerName}(w http.ResponseWriter, r *http.Request) {
|
|
1776
|
+
var input ${model}
|
|
1777
|
+
|
|
1778
|
+
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
|
1779
|
+
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
|
1780
|
+
return
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
// 🎯 Production-ready: Validation (email, phone, unique fields)
|
|
1784
|
+
if err := validate.Struct(input); err != nil {
|
|
1785
|
+
w.Header().Set("Content-Type", "application/json")
|
|
1786
|
+
w.WriteHeader(http.StatusBadRequest)
|
|
1787
|
+
json.NewEncoder(w).Encode(map[string]string{
|
|
1788
|
+
"error": "Validation failed",
|
|
1789
|
+
"details": err.Error(),
|
|
1790
|
+
})
|
|
1791
|
+
return
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
// Generate UUID if not provided
|
|
1795
|
+
if input.ID == "" {
|
|
1796
|
+
input.ID = uuid.New().String()
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
input.CreatedAt = time.Now()
|
|
1800
|
+
input.UpdatedAt = time.Now()
|
|
1801
|
+
|
|
1802
|
+
// TODO: Implement actual DB insert
|
|
1803
|
+
// if err := db.Create${handlerName}(&input); err != nil {
|
|
1804
|
+
// http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
1805
|
+
// return
|
|
1806
|
+
// }
|
|
1807
|
+
|
|
1808
|
+
// 🎯 Production-ready: Log event for audit trail
|
|
1809
|
+
event := ${handlerName}Event{
|
|
1810
|
+
ID: uuid.New().String(),
|
|
1811
|
+
Type: "created",
|
|
1812
|
+
EntityID: input.ID,
|
|
1813
|
+
NewValue: &input,
|
|
1814
|
+
Timestamp: time.Now(),
|
|
1815
|
+
}
|
|
1816
|
+
// logEvent(event) // Implement event logging to client_events table
|
|
1817
|
+
|
|
1818
|
+
// 🎯 Production-ready: Invalidate cache
|
|
1819
|
+
// redis.Del("${safeName}:list:*")
|
|
1820
|
+
|
|
1821
|
+
// 🎯 Production-ready: Emit VRP event (batchEmit for large lists)
|
|
1822
|
+
// vrp.BatchEmit("${safeName}", "created", input)
|
|
1823
|
+
|
|
1824
|
+
w.Header().Set("Content-Type", "application/json")
|
|
1825
|
+
w.WriteHeader(http.StatusCreated)
|
|
1826
|
+
json.NewEncoder(w).Encode(input)
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
// Update${handlerName} handles PUT /${safeName}/{id}
|
|
1830
|
+
func Update${handlerName}(w http.ResponseWriter, r *http.Request) {
|
|
1831
|
+
vars := mux.Vars(r)
|
|
1832
|
+
id := vars["id"]
|
|
1833
|
+
|
|
1834
|
+
var input ${model}
|
|
1835
|
+
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
|
1836
|
+
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
|
1837
|
+
return
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
// 🎯 Production-ready: Get old value for event logging
|
|
1841
|
+
// oldValue, _ := db.Get${handlerName}(id)
|
|
1842
|
+
|
|
1843
|
+
// 🎯 Production-ready: Validation
|
|
1844
|
+
if err := validate.Struct(input); err != nil {
|
|
1845
|
+
w.Header().Set("Content-Type", "application/json")
|
|
1846
|
+
w.WriteHeader(http.StatusBadRequest)
|
|
1847
|
+
json.NewEncoder(w).Encode(map[string]string{
|
|
1848
|
+
"error": "Validation failed",
|
|
1849
|
+
"details": err.Error(),
|
|
1850
|
+
})
|
|
1851
|
+
return
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
input.ID = id
|
|
1855
|
+
input.UpdatedAt = time.Now()
|
|
1856
|
+
|
|
1857
|
+
// TODO: Implement actual DB update
|
|
1858
|
+
// if err := db.Update${handlerName}(&input); err != nil {
|
|
1859
|
+
// http.Error(w, err.Error(), http.StatusNotFound)
|
|
1860
|
+
// return
|
|
1861
|
+
// }
|
|
1862
|
+
|
|
1863
|
+
// 🎯 Production-ready: Log event with old/new values
|
|
1864
|
+
// event := ${handlerName}Event{
|
|
1865
|
+
// ID: uuid.New().String(),
|
|
1866
|
+
// Type: "updated",
|
|
1867
|
+
// EntityID: id,
|
|
1868
|
+
// OldValue: oldValue,
|
|
1869
|
+
// NewValue: &input,
|
|
1870
|
+
// Timestamp: time.Now(),
|
|
1871
|
+
// }
|
|
1872
|
+
// logEvent(event)
|
|
1873
|
+
|
|
1874
|
+
// 🎯 Production-ready: Invalidate cache
|
|
1875
|
+
// redis.Del(fmt.Sprintf("${safeName}:detail:%s", id), "${safeName}:list:*")
|
|
1876
|
+
|
|
1877
|
+
// 🎯 Production-ready: Emit VRP diff for real-time updates
|
|
1878
|
+
// vrp.SendDiff("${safeName}:" + id, input)
|
|
1879
|
+
|
|
1880
|
+
w.Header().Set("Content-Type", "application/json")
|
|
1881
|
+
json.NewEncoder(w).Encode(input)
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
// Delete${handlerName} handles DELETE /${safeName}/{id}
|
|
1885
|
+
func Delete${handlerName}(w http.ResponseWriter, r *http.Request) {
|
|
1886
|
+
vars := mux.Vars(r)
|
|
1887
|
+
id := vars["id"]
|
|
1888
|
+
|
|
1889
|
+
// 🎯 Production-ready: Get value for event logging
|
|
1890
|
+
// oldValue, _ := db.Get${handlerName}(id)
|
|
1891
|
+
|
|
1892
|
+
// TODO: Implement actual DB delete
|
|
1893
|
+
// if err := db.Delete${handlerName}(id); err != nil {
|
|
1894
|
+
// http.Error(w, err.Error(), http.StatusNotFound)
|
|
1895
|
+
// return
|
|
1896
|
+
// }
|
|
1897
|
+
|
|
1898
|
+
// 🎯 Production-ready: Log deletion event
|
|
1899
|
+
// event := ${handlerName}Event{
|
|
1900
|
+
// ID: uuid.New().String(),
|
|
1901
|
+
// Type: "deleted",
|
|
1902
|
+
// EntityID: id,
|
|
1903
|
+
// OldValue: oldValue,
|
|
1904
|
+
// Timestamp: time.Now(),
|
|
1905
|
+
// }
|
|
1906
|
+
// logEvent(event)
|
|
1907
|
+
|
|
1908
|
+
// 🎯 Production-ready: Invalidate cache
|
|
1909
|
+
// redis.Del(fmt.Sprintf("${safeName}:detail:%s", id), "${safeName}:list:*")
|
|
1910
|
+
|
|
1911
|
+
// 🎯 Production-ready: Emit VRP event
|
|
1912
|
+
// vrp.SendEvent("${safeName}", "deleted", map[string]string{"id": id})
|
|
1913
|
+
|
|
1914
|
+
w.WriteHeader(http.StatusNoContent)
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
// 🎯 Production-ready: BatchUpdate handles POST /${safeName}/batch/update
|
|
1918
|
+
func BatchUpdate${handlerName}(w http.ResponseWriter, r *http.Request) {
|
|
1919
|
+
var req BatchUpdateRequest
|
|
1920
|
+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
1921
|
+
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
|
1922
|
+
return
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
if err := validate.Struct(req); err != nil {
|
|
1926
|
+
w.Header().Set("Content-Type", "application/json")
|
|
1927
|
+
w.WriteHeader(http.StatusBadRequest)
|
|
1928
|
+
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
|
1929
|
+
return
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
// TODO: Implement batch update in transaction
|
|
1933
|
+
// tx := db.Begin()
|
|
1934
|
+
// for _, id := range req.IDs {
|
|
1935
|
+
// if err := tx.Update${handlerName}(id, req.Payload); err != nil {
|
|
1936
|
+
// tx.Rollback()
|
|
1937
|
+
// http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
1938
|
+
// return
|
|
1939
|
+
// }
|
|
1940
|
+
// }
|
|
1941
|
+
// tx.Commit()
|
|
1942
|
+
|
|
1943
|
+
// 🎯 Production-ready: Batch emit VRP events
|
|
1944
|
+
// vrp.BatchEmit("${safeName}", "updated", req)
|
|
1945
|
+
|
|
1946
|
+
// Invalidate cache
|
|
1947
|
+
// redis.Del("${safeName}:*")
|
|
1948
|
+
|
|
1949
|
+
w.Header().Set("Content-Type", "application/json")
|
|
1950
|
+
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
1951
|
+
"updated": len(req.IDs),
|
|
1952
|
+
"ids": req.IDs,
|
|
1953
|
+
})
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
// 🎯 Production-ready: BatchDelete handles POST /${safeName}/batch/delete
|
|
1957
|
+
func BatchDelete${handlerName}(w http.ResponseWriter, r *http.Request) {
|
|
1958
|
+
var req BatchDeleteRequest
|
|
1959
|
+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
1960
|
+
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
|
1961
|
+
return
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
if err := validate.Struct(req); err != nil {
|
|
1965
|
+
w.Header().Set("Content-Type", "application/json")
|
|
1966
|
+
w.WriteHeader(http.StatusBadRequest)
|
|
1967
|
+
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
|
1968
|
+
return
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
// TODO: Implement batch delete in transaction
|
|
1972
|
+
// tx := db.Begin()
|
|
1973
|
+
// for _, id := range req.IDs {
|
|
1974
|
+
// if err := tx.Delete${handlerName}(id); err != nil {
|
|
1975
|
+
// tx.Rollback()
|
|
1976
|
+
// http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
1977
|
+
// return
|
|
1978
|
+
// }
|
|
1979
|
+
// }
|
|
1980
|
+
// tx.Commit()
|
|
1981
|
+
|
|
1982
|
+
// 🎯 Production-ready: Batch emit VRP events
|
|
1983
|
+
// vrp.BatchEmit("${safeName}", "deleted", req)
|
|
1984
|
+
|
|
1985
|
+
// Invalidate cache
|
|
1986
|
+
// redis.Del("${safeName}:*")
|
|
1987
|
+
|
|
1988
|
+
w.WriteHeader(http.StatusNoContent)
|
|
1989
|
+
}
|
|
1990
|
+
`;
|
|
1991
|
+
await fs.writeFile(path.join(targetDir, `${safeName}_crud.go`), handlerCode);
|
|
1992
|
+
}
|
|
1993
|
+
/**
|
|
1994
|
+
* Валидация проекта
|
|
1995
|
+
*/
|
|
1996
|
+
async function validateProject() {
|
|
1997
|
+
const cwd = process.cwd();
|
|
1998
|
+
const errors = [];
|
|
1999
|
+
const warnings = [];
|
|
2000
|
+
console.log(chalk_1.default.blue("\nValidating Vira project...\n"));
|
|
2001
|
+
// Проверка структуры frontend проекта
|
|
2002
|
+
const frontendPath = path.join(cwd, "frontend");
|
|
2003
|
+
const frontendExists = await fs.pathExists(frontendPath);
|
|
2004
|
+
if (frontendExists) {
|
|
2005
|
+
const requiredFiles = [
|
|
2006
|
+
"package.json",
|
|
2007
|
+
"vite.config.ts",
|
|
2008
|
+
"tsconfig.json",
|
|
2009
|
+
"src/main.tsx",
|
|
2010
|
+
];
|
|
2011
|
+
for (const file of requiredFiles) {
|
|
2012
|
+
const filePath = path.join(frontendPath, file);
|
|
2013
|
+
if (!(await fs.pathExists(filePath))) {
|
|
2014
|
+
errors.push(`Missing frontend file: ${file}`);
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
// Проверка структуры директорий
|
|
2018
|
+
const requiredDirs = ["src/components", "src/services", "src/pages"];
|
|
2019
|
+
for (const dir of requiredDirs) {
|
|
2020
|
+
const dirPath = path.join(frontendPath, dir);
|
|
2021
|
+
if (!(await fs.pathExists(dirPath))) {
|
|
2022
|
+
warnings.push(`Missing frontend directory: ${dir}`);
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
else {
|
|
2027
|
+
// Проверка standalone frontend
|
|
2028
|
+
if (!(await fs.pathExists(path.join(cwd, "package.json")))) {
|
|
2029
|
+
errors.push("Missing package.json");
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
// Проверка структуры backend проекта
|
|
2033
|
+
const backendPath = path.join(cwd, "backend");
|
|
2034
|
+
if (await fs.pathExists(backendPath)) {
|
|
2035
|
+
const requiredFiles = ["go.mod", "cmd/api/main.go"];
|
|
2036
|
+
for (const file of requiredFiles) {
|
|
2037
|
+
const filePath = path.join(backendPath, file);
|
|
2038
|
+
if (!(await fs.pathExists(filePath))) {
|
|
2039
|
+
errors.push(`Missing backend file: ${file}`);
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
// Вывод результатов
|
|
2044
|
+
if (errors.length > 0) {
|
|
2045
|
+
console.log(chalk_1.default.red("✗ Errors found:"));
|
|
2046
|
+
errors.forEach((err) => console.log(chalk_1.default.red(` - ${err}`)));
|
|
2047
|
+
}
|
|
2048
|
+
if (warnings.length > 0) {
|
|
2049
|
+
console.log(chalk_1.default.yellow("\n⚠ Warnings:"));
|
|
2050
|
+
warnings.forEach((warn) => console.log(chalk_1.default.yellow(` - ${warn}`)));
|
|
2051
|
+
}
|
|
2052
|
+
if (errors.length === 0 && warnings.length === 0) {
|
|
2053
|
+
console.log(chalk_1.default.green("✓ Project structure is valid!"));
|
|
2054
|
+
}
|
|
2055
|
+
else if (errors.length === 0) {
|
|
2056
|
+
console.log(chalk_1.default.green("\n✓ Project structure is valid (with warnings)"));
|
|
2057
|
+
}
|
|
2058
|
+
else {
|
|
2059
|
+
console.log(chalk_1.default.red("\n✗ Project validation failed"));
|
|
2060
|
+
process.exit(1);
|
|
2061
|
+
}
|
|
2062
|
+
}
|