#!/usr/bin/env node import * as fs from 'fs'; import * as path from 'path'; function toSnakeCase(name: string): string { return name.replace(/[-]/g, '_'); } function toPascalCase(name: string): string { return name .split(/[-_]/) .map(s => s.charAt(0).toUpperCase() + s.slice(1)) .join(' '); } function humanLabel(name: string): string { return name .split(/[-_]/) .map(s => s.charAt(0).toUpperCase() + s.slice(1)) .join(' '); } interface FileTemplate { relativePath: string; content: string; } function buildTemplates(name: string, port: number): FileTemplate[] { const snake = toSnakeCase(name); const label = humanLabel(name); const routePath = `/${name}`; return [ { relativePath: 'manifest.json', content: JSON.stringify({ id: name, name: snake, label: { en: label, uk: label }, icon: 'Puzzle', routePath, remoteEntry: `http://localhost:${port}/remoteEntry.js`, exposedModule: './App', roles: ['admin'], database: 'self', }, null, 2) + '\n', }, { relativePath: 'k8s-plugin.yaml', content: `apiVersion: apps/v1 kind: Deployment metadata: name: ${name} namespace: crm spec: replicas: 1 selector: matchLabels: app: ${name} template: metadata: labels: app: ${name} spec: containers: - name: backend image: crm-plugins/${name}:latest ports: - containerPort: ${port} env: - name: CORE_BACKEND_URL value: "http://core-backend.crm.svc.cluster.local:3001" - name: PUBLIC_URL value: "https://crm.yourdomain.com/modules/${name}" --- apiVersion: v1 kind: Service metadata: name: ${name} namespace: crm spec: selector: app: ${name} ports: - port: 80 targetPort: ${port} `, }, { relativePath: 'backend/package.json', content: JSON.stringify({ name: `${name}-backend`, version: '0.0.1', description: `CRM Plugin Backend - ${label}`, private: true, scripts: { build: 'nest build', start: 'nest start', 'start:dev': 'nest start --watch', 'start:prod': 'node dist/main', }, dependencies: { '@nestjs/common': '^10.0.0', '@nestjs/core': '^10.0.0', '@nestjs/microservices': '^10.0.0', '@nestjs/platform-express': '^10.0.0', '@nestjs/typeorm': '^10.0.2', '@nestjs/serve-static': '^4.0.2', 'amqp-connection-manager': '^4.1.14', amqplib: '^0.10.3', '@erp/plugin-sdk': '^1.1.0', pg: '^8.12.0', 'reflect-metadata': '^0.1.13', rxjs: '^7.8.1', typeorm: '^0.3.20', uuid: '^9.0.1', }, devDependencies: { '@nestjs/cli': '^10.0.0', '@nestjs/schematics': '^10.0.0', '@types/node': '^20.3.1', typescript: '^5.1.3', }, }, null, 2) + '\n', }, { relativePath: 'backend/.npmrc', content: '@erp:registry=https://gitea.ss.3w.com.ua/api/packages/erp/npm/\n', }, { relativePath: 'backend/tsconfig.json', content: JSON.stringify({ compilerOptions: { module: 'commonjs', declaration: true, removeComments: true, emitDecoratorMetadata: true, experimentalDecorators: true, allowSyntheticDefaultImports: true, target: 'ES2021', sourceMap: true, outDir: './dist', baseUrl: './', incremental: true, skipLibCheck: true, strictNullChecks: false, noImplicitAny: false, strictBindCallApply: false, forceConsistentCasingInFileNames: false, noFallthroughCasesInSwitch: false, }, }, null, 2) + '\n', }, { relativePath: 'backend/nest-cli.json', content: JSON.stringify({ collection: '@nestjs/schematics', sourceRoot: 'src', compilerOptions: { deleteOutDir: true, }, }, null, 2) + '\n', }, { relativePath: 'backend/Dockerfile', content: `FROM node:18-alpine WORKDIR /app COPY backend/package*.json ./backend/ COPY frontend/package*.json ./frontend/ RUN apk add --no-cache git RUN cd backend && npm install RUN cd frontend && npm install COPY . . RUN cd backend && npm run build RUN cd frontend && npm run build EXPOSE ${port} WORKDIR /app/backend CMD ["npm", "run", "start:prod"] `, }, { relativePath: 'backend/src/main.ts', content: `import { CoreService, RequestContextInterceptor } from '@erp/plugin-sdk'; import { NestFactory } from '@nestjs/core'; import { Logger } from '@nestjs/common'; import * as path from 'path'; import * as fs from 'fs'; async function bootstrap() { 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); app.useGlobalInterceptors(new RequestContextInterceptor()); const express = require('express'); const distPath = path.join(process.cwd(), '..', 'frontend', 'dist'); app.use('/', express.static(distPath, { setHeaders: (res: any) => { res.set('Access-Control-Allow-Origin', '*'); } })); app.use((req: any, res: any, next: any) => { logger.log(\`Incoming request: \${req.method} \${req.url}\`); res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS'); res.header('Access-Control-Allow-Headers', 'Content-Type, Accept, Authorization'); if (req.method === 'OPTIONS') { return res.sendStatus(200); } next(); }); const { getRabbitMQOptions } = await import('@erp/plugin-sdk'); app.connectMicroservice(getRabbitMQOptions('plugin_${snake}_queue')); app.enableCors(); await app.startAllMicroservices(); const port = process.env.PORT || ${port}; await app.listen(port); const coreService = app.get(CoreService); coreService.register(); logger.log(\`Plugin Service is running on: http://localhost:\${port}\`); } bootstrap().catch((err) => { console.error('Failed to bootstrap plugin:', err); process.exit(1); }); `, }, { relativePath: 'backend/src/app.module.ts', content: `import { CoreService, getRabbitMQClientOptions } from '@erp/plugin-sdk'; import { Module } from '@nestjs/common'; import { ClientsModule } from '@nestjs/microservices'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AppController } from './app.controller'; import { PingController } from './ping.controller'; import { ItemController } from './item.controller'; import { ItemService } from './item.service'; import { Item } from './entities/item.entity'; @Module({ 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([ { name: 'RABBITMQ_CLIENT', ...getRabbitMQClientOptions(), }, ]), ], controllers: [AppController, PingController, ItemController], providers: [CoreService, ItemService], exports: [CoreService], }) export class AppModule {} `, }, { relativePath: 'backend/src/app.controller.ts', content: `import { Controller, Get } from '@nestjs/common'; import * as fs from 'fs'; import * as path from 'path'; @Controller('debug') export class AppController { @Get('files') listFiles() { const distPath = path.join(process.cwd(), '..', 'frontend', 'dist'); try { const files = fs.readdirSync(distPath); return { path: distPath, exists: fs.existsSync(distPath), files }; } catch (e: any) { return { error: e.message, path: distPath }; } } } `, }, { relativePath: 'backend/src/ping.controller.ts', content: `import { Controller, Get } from '@nestjs/common'; @Controller('ping') export class PingController { @Get() ping() { 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 { return this.service.findAll(); } @Get(':id') async findOne(@Param('id') id: string): Promise { return this.service.findOne(id); } @Post() async create(@Body() data: Partial): Promise { return this.service.create(data); } @Put(':id') async update(@Param('id') id: string, @Body() data: Partial): Promise { return this.service.update(id, data); } @Delete(':id') async remove(@Param('id') id: string): Promise { 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, ) {} async findAll(): Promise { return this.repo.find(); } async findOne(id: string): Promise { const item = await this.repo.findOne({ where: { id } }); if (!item) throw new NotFoundException(\`Item \${id} not found\`); return item; } async create(data: Partial): Promise { const item = this.repo.create(data); return this.repo.save(item); } async update(id: string, data: Partial): Promise { await this.repo.update(id, data); return this.findOne(id); } async remove(id: string): Promise { 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; } `, }, { relativePath: 'frontend/package.json', content: JSON.stringify({ name: `${name}-ui`, private: true, version: '0.1.0', type: 'module', scripts: { dev: 'vite', build: 'tsc && vite build', preview: 'vite preview', }, dependencies: { react: '^18.3.1', 'react-dom': '^18.3.1', 'react-router-dom': '^6.23.1', 'lucide-react': '^0.378.0', 'framer-motion': '^11.2.10', }, devDependencies: { '@originjs/vite-plugin-federation': '^1.3.5', '@types/react': '^18.3.3', '@types/react-dom': '^18.3.0', '@vitejs/plugin-react': '^4.3.1', typescript: '^5.2.2', vite: '^5.3.1', }, }, null, 2) + '\n', }, { relativePath: 'frontend/tsconfig.json', content: JSON.stringify({ compilerOptions: { target: 'ESNext', useDefineForClassFields: true, lib: ['DOM', 'DOM.Iterable', 'ESNext'], allowJs: false, skipLibCheck: true, esModuleInterop: false, allowSyntheticDefaultImports: true, strict: true, forceConsistentCasingInFileNames: true, module: 'ESNext', moduleResolution: 'Node', resolveJsonModule: true, isolatedModules: true, noEmit: true, jsx: 'react-jsx', }, include: ['src'], references: [{ path: './tsconfig.node.json' }], }, null, 2) + '\n', }, { relativePath: 'frontend/tsconfig.node.json', content: JSON.stringify({ composite: true, module: 'ESNext', moduleResolution: 'Node', allowSyntheticDefaultImports: true, }, null, 2) + '\n', }, { relativePath: 'frontend/vite.config.ts', content: `import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import federation from '@originjs/vite-plugin-federation'; export default defineConfig({ plugins: [ react(), federation({ name: '${snake}', filename: 'remoteEntry.js', remotes: { host: 'http://localhost:3000/assets/remoteEntry.js' }, exposes: { './App': './src/App.tsx', }, shared: { react: { singleton: true }, 'react-dom': { singleton: true }, 'react-router-dom': { singleton: true }, 'lucide-react': { singleton: true }, 'framer-motion': { singleton: true } } }) ], build: { modulePreload: false, target: 'esnext', minify: false, cssCodeSplit: false, assetsDir: '' }, server: { port: ${port}, cors: true } }); `, }, { relativePath: 'frontend/index.html', content: ` ${label}
`, }, { relativePath: 'frontend/src/App.tsx', content: `import React from 'react'; import { Puzzle } from 'lucide-react'; // @ts-ignore import { Card, Typography } from 'host/UIKit'; interface AppProps { language?: string; } const App: React.FC = ({ language = 'en' }) => { return (
${label} {language === 'uk' ? 'Плагін успішно створено та підключено до CRM.' : 'Plugin successfully created and connected to CRM.'}
); }; export default App; `, }, ]; } async function main() { const args = process.argv.slice(2); if (args.length === 0 || args[0] === '--help' || args[0] === '-h') { console.log(`Usage: create-erp-plugin [--port ] Create a new ERP plugin scaffold in the current directory. Arguments: plugin-name Plugin name in kebab-case (e.g., "my-custom-plugin") --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: create-erp-plugin my-plugin --port 3007 `); process.exit(0); } const name = args[0]; const portIndex = args.indexOf('--port') !== -1 ? args.indexOf('--port') : args.indexOf('-p'); const port = portIndex !== -1 ? parseInt(args[portIndex + 1], 10) : 3002; if (port < 1024 || port > 65535) { console.error('Error: Port must be between 1024 and 65535'); process.exit(1); } if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) { console.error('Error: Plugin name must be in kebab-case (e.g., "my-plugin")'); process.exit(1); } const targetDir = path.join(process.cwd(), name); if (fs.existsSync(targetDir)) { console.error(`Error: Directory "${name}" already exists`); process.exit(1); } const templates = buildTemplates(name, port); for (const tpl of templates) { const filePath = path.join(targetDir, tpl.relativePath); fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, tpl.content, 'utf-8'); } console.log(`\u2705 Plugin "${name}" created successfully in ./${name}/`); console.log(); console.log('Next steps:'); console.log(` cd ${name}/backend && npm install`); console.log(` cd ${name}/frontend && npm install`); 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) => { console.error('Error:', err.message); process.exit(1); });