feat(cli): generate plugins with dual DB mode (self/provide)

- manifest.json includes "database": "self" field
- main.ts reads manifest and conditionally calls CoreService.setupDatabase()
- TypeORM with PLUGIN_DB_* -> POSTGRES_* fallback for both modes
- Example Item entity, controller, service with CRUD
- @nestjs/typeorm, typeorm, pg added to generated package.json
- Dynamic import for AppModule to support provide mode
This commit is contained in:
Kyryll O 2026-05-26 23:44:55 +03:00
parent 107af7b0f1
commit 788acab14e
1 changed files with 165 additions and 13 deletions

View File

@ -43,6 +43,7 @@ function buildTemplates(name: string, port: number): FileTemplate[] {
remoteEntry: `http://localhost:${port}/remoteEntry.js`, remoteEntry: `http://localhost:${port}/remoteEntry.js`,
exposedModule: './App', exposedModule: './App',
roles: ['admin'], roles: ['admin'],
database: 'self',
}, null, 2) + '\n', }, null, 2) + '\n',
}, },
{ {
@ -105,12 +106,15 @@ spec:
'@nestjs/core': '^10.0.0', '@nestjs/core': '^10.0.0',
'@nestjs/microservices': '^10.0.0', '@nestjs/microservices': '^10.0.0',
'@nestjs/platform-express': '^10.0.0', '@nestjs/platform-express': '^10.0.0',
'@nestjs/typeorm': '^10.0.2',
'@nestjs/serve-static': '^4.0.2', '@nestjs/serve-static': '^4.0.2',
'amqp-connection-manager': '^4.1.14', 'amqp-connection-manager': '^4.1.14',
amqplib: '^0.10.3', amqplib: '^0.10.3',
'erp-plugin-sdk': 'git+https://gitea.ss.3w.com.ua/erp/erp-plugin-sdk.git', 'erp-plugin-sdk': 'git+https://gitea.ss.3w.com.ua/erp/erp-plugin-sdk.git',
pg: '^8.12.0',
'reflect-metadata': '^0.1.13', 'reflect-metadata': '^0.1.13',
rxjs: '^7.8.1', rxjs: '^7.8.1',
typeorm: '^0.3.20',
uuid: '^9.0.1', uuid: '^9.0.1',
}, },
devDependencies: { devDependencies: {
@ -181,14 +185,36 @@ CMD ["npm", "run", "start:prod"]
}, },
{ {
relativePath: 'backend/src/main.ts', relativePath: 'backend/src/main.ts',
content: `import { getRabbitMQOptions, CoreService, RequestContextInterceptor } from 'erp-plugin-sdk'; content: `import { CoreService, RequestContextInterceptor } from 'erp-plugin-sdk';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import * as path from 'path'; import * as path from 'path';
import * as fs from 'fs';
async function bootstrap() { async function bootstrap() {
const logger = new Logger('PluginBootstrap'); const logger = new Logger('PluginBootstrap');
// Read manifest to determine database mode
const manifestPath = path.join(process.cwd(), '..', 'manifest.json');
if (fs.existsSync(manifestPath)) {
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
if (manifest.database === 'provide') {
logger.log('Core-provisioned database mode detected. Setting up...');
for (let i = 0; i < 10; i++) {
try {
await CoreService.setupDatabase({ pluginId: '${name}' });
logger.log('Database provisioned successfully');
break;
} catch (err: any) {
logger.warn(\`Database setup attempt \${i + 1} failed: \${err.message}\`);
if (i < 9) await new Promise(r => setTimeout(r, 3000));
else throw err;
}
}
}
}
const { AppModule } = await import('./app.module');
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
app.useGlobalInterceptors(new RequestContextInterceptor()); app.useGlobalInterceptors(new RequestContextInterceptor());
@ -214,6 +240,7 @@ async function bootstrap() {
next(); next();
}); });
const { getRabbitMQOptions } = await import('erp-plugin-sdk');
app.connectMicroservice(getRabbitMQOptions('plugin_${snake}_queue')); app.connectMicroservice(getRabbitMQOptions('plugin_${snake}_queue'));
app.enableCors(); app.enableCors();
@ -227,7 +254,11 @@ async function bootstrap() {
logger.log(\`Plugin Service is running on: http://localhost:\${port}\`); logger.log(\`Plugin Service is running on: http://localhost:\${port}\`);
} }
bootstrap();
bootstrap().catch((err) => {
console.error('Failed to bootstrap plugin:', err);
process.exit(1);
});
`, `,
}, },
{ {
@ -235,11 +266,26 @@ bootstrap();
content: `import { CoreService, getRabbitMQClientOptions } from 'erp-plugin-sdk'; content: `import { CoreService, getRabbitMQClientOptions } from 'erp-plugin-sdk';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ClientsModule } from '@nestjs/microservices'; import { ClientsModule } from '@nestjs/microservices';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { PingController } from './ping.controller'; import { PingController } from './ping.controller';
import { ItemController } from './item.controller';
import { ItemService } from './item.service';
import { Item } from './entities/item.entity';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forRoot({
type: 'postgres',
host: process.env.PLUGIN_DB_HOST || process.env.POSTGRES_HOST || 'localhost',
port: parseInt(process.env.PLUGIN_DB_PORT || process.env.POSTGRES_PORT || '5432', 10),
username: process.env.PLUGIN_DB_USER || process.env.POSTGRES_USER || 'postgres',
password: process.env.PLUGIN_DB_PASSWORD || process.env.POSTGRES_PASSWORD || 'postgres',
database: process.env.PLUGIN_DB_NAME || process.env.POSTGRES_DB || 'crm',
autoLoadEntities: true,
synchronize: true,
}),
TypeOrmModule.forFeature([Item]),
ClientsModule.register([ ClientsModule.register([
{ {
name: 'RABBITMQ_CLIENT', name: 'RABBITMQ_CLIENT',
@ -247,8 +293,8 @@ import { PingController } from './ping.controller';
}, },
]), ]),
], ],
controllers: [AppController, PingController], controllers: [AppController, PingController, ItemController],
providers: [CoreService], providers: [CoreService, ItemService],
exports: [CoreService], exports: [CoreService],
}) })
export class AppModule {} export class AppModule {}
@ -290,6 +336,107 @@ export class PingController {
return { status: 'ok', timestamp: Date.now() }; return { status: 'ok', timestamp: Date.now() };
} }
} }
`,
},
{
relativePath: 'backend/src/item.controller.ts',
content: `import { Controller, Get, Post, Put, Delete, Body, Param } from '@nestjs/common';
import { ItemService } from './item.service';
import { Item } from './entities/item.entity';
@Controller('items')
export class ItemController {
constructor(private readonly service: ItemService) {}
@Get()
async findAll(): Promise<Item[]> {
return this.service.findAll();
}
@Get(':id')
async findOne(@Param('id') id: string): Promise<Item> {
return this.service.findOne(id);
}
@Post()
async create(@Body() data: Partial<Item>): Promise<Item> {
return this.service.create(data);
}
@Put(':id')
async update(@Param('id') id: string, @Body() data: Partial<Item>): Promise<Item> {
return this.service.update(id, data);
}
@Delete(':id')
async remove(@Param('id') id: string): Promise<void> {
return this.service.remove(id);
}
}
`,
},
{
relativePath: 'backend/src/item.service.ts',
content: `import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Item } from './entities/item.entity';
@Injectable()
export class ItemService {
constructor(
@InjectRepository(Item)
private readonly repo: Repository<Item>,
) {}
async findAll(): Promise<Item[]> {
return this.repo.find();
}
async findOne(id: string): Promise<Item> {
const item = await this.repo.findOne({ where: { id } });
if (!item) throw new NotFoundException(\`Item \${id} not found\`);
return item;
}
async create(data: Partial<Item>): Promise<Item> {
const item = this.repo.create(data);
return this.repo.save(item);
}
async update(id: string, data: Partial<Item>): Promise<Item> {
await this.repo.update(id, data);
return this.findOne(id);
}
async remove(id: string): Promise<void> {
const result = await this.repo.delete(id);
if (!result.affected) throw new NotFoundException(\`Item \${id} not found\`);
}
}
`,
},
{
relativePath: 'backend/src/entities/item.entity.ts',
content: `import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
@Entity()
export class Item {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
title: string;
@Column({ type: 'text', nullable: true })
description: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
`, `,
}, },
{ {
@ -348,13 +495,10 @@ export class PingController {
{ {
relativePath: 'frontend/tsconfig.node.json', relativePath: 'frontend/tsconfig.node.json',
content: JSON.stringify({ content: JSON.stringify({
compilerOptions: {
composite: true, composite: true,
module: 'ESNext', module: 'ESNext',
moduleResolution: 'Node', moduleResolution: 'Node',
allowSyntheticDefaultImports: true, allowSyntheticDefaultImports: true,
},
include: ['vite.config.ts'],
}, null, 2) + '\n', }, null, 2) + '\n',
}, },
{ {
@ -459,6 +603,10 @@ Arguments:
plugin-name Plugin name in kebab-case (e.g., "my-custom-plugin") plugin-name Plugin name in kebab-case (e.g., "my-custom-plugin")
--port, -p Port number for the plugin server (default: 3002) --port, -p Port number for the plugin server (default: 3002)
Database modes (set in manifest.json):
"self" (default) Plugin connects using shared POSTGRES_* env vars
"provide" Core provisions a dedicated database; change manifest
Example: Example:
create-erp-plugin my-plugin --port 3007 create-erp-plugin my-plugin --port 3007
`); `);
@ -494,12 +642,16 @@ Example:
fs.writeFileSync(filePath, tpl.content, 'utf-8'); fs.writeFileSync(filePath, tpl.content, 'utf-8');
} }
console.log(` Plugin "${name}" created successfully in ./${name}/`); console.log(`\u2705 Plugin "${name}" created successfully in ./${name}/`);
console.log(); console.log();
console.log('Next steps:'); console.log('Next steps:');
console.log(` cd ${name}/backend && npm install`); console.log(` cd ${name}/backend && npm install`);
console.log(` cd ${name}/frontend && npm install`); console.log(` cd ${name}/frontend && npm install`);
console.log(` cd ${name}/backend && npm run start:dev`); console.log(` cd ${name}/backend && npm run start:dev`);
console.log();
console.log('Database:');
console.log(' Current mode: "self" (shared DB via POSTGRES_* env vars)');
console.log(' To switch to a dedicated DB, set manifest.json "database": "provide"');
} }
main().catch((err) => { main().catch((err) => {