erp-plugin-sdk/src/cli/index.ts

665 lines
18 KiB
JavaScript

#!/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<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;
}
`,
},
{
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: `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${label}</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/App.tsx"></script>
</body>
</html>
`,
},
{
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<AppProps> = ({ language = 'en' }) => {
return (
<Card className="plugin-card" title={language === 'uk' ? '${label}' : '${label}'}>
<div style={{ textAlign: 'center', padding: '40px 0' }}>
<Puzzle size={48} color="var(--accent)" style={{ marginBottom: '20px' }} />
<Typography.H2>${label}</Typography.H2>
<Typography.P>
{language === 'uk'
? 'Плагін успішно створено та підключено до CRM.'
: 'Plugin successfully created and connected to CRM.'}
</Typography.P>
</div>
</Card>
);
};
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 <plugin-name> [--port <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);
});