diff --git a/package.json b/package.json index aa5119e..03886f1 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "version": "1.0.0", "main": "dist/index.js", "types": "dist/index.d.ts", + "bin": { + "create-erp-plugin": "./dist/cli/index.js" + }, "scripts": { "build": "tsc", "prepare": "npm run build", diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 0000000..ed50428 --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1,515 @@ +#!/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'], + }, 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/serve-static': '^4.0.2', + 'amqp-connection-manager': '^4.1.14', + amqplib: '^0.10.3', + 'erp-plugin-sdk': 'git+https://gitea.ss.3w.com.ua/erp/erp-plugin-sdk.git', + 'reflect-metadata': '^0.1.13', + rxjs: '^7.8.1', + 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/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 { getRabbitMQOptions, CoreService, RequestContextInterceptor } from 'erp-plugin-sdk'; +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; +import { Logger } from '@nestjs/common'; +import * as path from 'path'; + +async function bootstrap() { + const logger = new Logger('PluginBootstrap'); + 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(); + }); + + 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(); +`, + }, + { + relativePath: 'backend/src/app.module.ts', + content: `import { CoreService } from 'erp-plugin-sdk'; +import { Module } from '@nestjs/common'; +import { ClientsModule, Transport } from '@nestjs/microservices'; +import { AppController } from './app.controller'; +import { PingController } from './ping.controller'; + +@Module({ + imports: [ + ClientsModule.register([ + { + name: 'RABBITMQ_CLIENT', + transport: Transport.RMQ, + options: { + urls: [process.env.RABBITMQ_URL || 'amqp://localhost:5672'], + queue: 'core_queue', + queueOptions: { + durable: true, + }, + }, + }, + ]), + ], + controllers: [AppController, PingController], + providers: [CoreService], + 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: '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({ + compilerOptions: { + composite: true, + module: 'ESNext', + moduleResolution: 'Node', + allowSyntheticDefaultImports: true, + }, + include: ['vite.config.ts'], + }, 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) + +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(`✅ 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`); +} + +main().catch((err) => { + console.error('Error:', err.message); + process.exit(1); +});