feat(setup): профессиональный установщик с 3 режимами

- Разработка: генерация .env, сборка библиотек и контрактов
- Тестовая сеть: фронтенд подключается к testnet API
- Продакшен: генерация конфигурации для mainnet
- Красивый CLI с inquirer, ora spinners, цветной вывод
This commit is contained in:
Cursor Agent
2026-02-25 18:39:18 +00:00
parent abae483dd3
commit a53957271e
+410 -99
View File
@@ -1,131 +1,442 @@
import inquirer, { Answers } from 'inquirer';
import { promises as fs } from 'fs';
import { exec } from 'child_process';
import util from 'util';
import ora from 'ora';
import inquirer from 'inquirer'
import { promises as fs } from 'fs'
import { exec } from 'child_process'
import util from 'util'
import ora from 'ora'
import path from 'path'
const execPromise = util.promisify(exec);
const execPromise = util.promisify(exec)
const ROOT = path.resolve(__dirname, '../..')
const COMPONENTS = path.resolve(__dirname, '..')
console.clear();
console.clear()
console.log(`
888b d888 .d88888b. 888b 888 .d88888b.
8888b d8888 d88P" "Y88b 8888b 888 d88P" "Y88b
88888b.d88888 888 888 88888b 888 888 888
888Y88888P888 888 888 888Y88b 888 888 888
888 Y888P 888 888 888 888 Y88b888 888 888
888 Y8P 888 888 888 888 Y88888 888 888
888 " 888 Y88b. .d88P 888 Y8888 Y88b. .d88P
888 888 "Y88888P" 888 Y888 "Y88888P"
\x1b[36m╔══════════════════════════════════════════════════╗
║ ║
║ ██████╗ ██╗ ██████╗ ██╗████████╗ █████╗ ██╗ ║
║ ██╔══██╗██║██╔════╝ ██║╚══██╔══╝██╔══██╗██║ ║
║ ██║ ██║██║██║ ███╗██║ ██║ ███████║██║ ║
║ ██║ ██║██║██║ ██║██║ ██║ ██╔══██║██║ ║
║ ██████╔╝██║╚██████╔╝██║ ██║ ██║ ██║███╗ ║
║ ╚═════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ║
║ ║
║ ЦИФРОВОЙ КООПЕРАТИВ — УСТАНОВЩИК ║
║ ║
╚══════════════════════════════════════════════════╝\x1b[0m
`)
Cooperative Relationship Management System
`);
// ──────────────────────────────────────────────────
// Конфигурация режимов
// ──────────────────────────────────────────────────
interface MenuAnswers extends Answers {
choice: 'dev' | 'use';
env?: 'frontend-testnet' | 'local-full';
const MAINNET_CONFIG = {
CHAIN_ID: '6a6e3744e98b16d1b25b4e2c6fdc98b63d3e1a1c23f1a6b1b5b5c4e6d8a7b0c1',
API_URL: 'https://api.coopenomics.world',
P2P_PEER: 'p2p.coopenomics.world:9876',
}
const TESTNET_CONFIG = {
CHAIN_ID: 'f0364a3f9fd913081f1c0b05c6f8f50a59b2ba60bb928cb321ba3a9a36316624',
BACKEND_URL: 'https://testnet.coopenomics.world/backend',
CHAIN_URL: 'https://api-testnet.coopenomics.world',
}
// ──────────────────────────────────────────────────
// Главное меню
// ──────────────────────────────────────────────────
type SetupMode = 'dev' | 'testnet' | 'production'
async function mainMenu(): Promise<void> {
const { choice } = await inquirer.prompt<MenuAnswers>([
const { mode } = await inquirer.prompt<{ mode: SetupMode }>([
{
type: 'list',
name: 'choice',
message: 'Выберите режим установки MONO:',
name: 'mode',
message: '🔧 Выберите режим установки:',
choices: [
{ name: 'Для разработки', value: 'dev' },
{ name: 'Для использования', value: 'use' }
]
}
]);
{ name: '🖥️ Для разработки — локальный блокчейн и все сервисы', value: 'dev' },
{ name: '📡 Тестовая сеть — фронтенд подключается к тестнету', value: 'testnet' },
{ name: '🚀 Продакшен — подключение к основной сети', value: 'production' },
],
},
])
if (choice === 'use') {
console.log('🚀 Быстрая установка для использования пока не реализована. Выход...');
process.exit(0);
switch (mode) {
case 'dev':
await setupDevelopment()
break
case 'testnet':
await setupTestnet()
break
case 'production':
await setupProduction()
break
}
await developmentMenu();
}
async function developmentMenu(): Promise<void> {
const { env } = await inquirer.prompt<MenuAnswers>([
// ──────────────────────────────────────────────────
// РЕЖИМ РАЗРАБОТКИ
// ──────────────────────────────────────────────────
async function setupDevelopment(): Promise<void> {
console.log('\n\x1b[33m━━━ 🖥️ РЕЖИМ РАЗРАБОТКИ ━━━\x1b[0m\n')
const { coopname } = await inquirer.prompt<{ coopname: string }>([
{
type: 'input',
name: 'coopname',
message: 'Имя кооператива (латиница, до 12 символов):',
default: 'voskhod',
validate: (v: string) => /^[a-z1-5.]{1,12}$/.test(v) || 'Только a-z, 1-5, точка. Макс. 12 символов.',
},
])
const { contractsMode } = await inquirer.prompt<{ contractsMode: 'test' | 'prod' }>([
{
type: 'list',
name: 'env',
message: 'Выберите среду разработки:',
name: 'contractsMode',
message: 'Режим смарт-контрактов:',
choices: [
{ name: 'Только фронтенд на тестнете', value: 'frontend-testnet' },
{ name: 'Локальный фронтенд и бэкенд', value: 'local-full' }
]
{ name: 'Тестовый (1 член совета)', value: 'test' },
{ name: 'Продуктовый (5 членов совета)', value: 'prod' },
],
},
])
// 1. Генерация .env файлов
step('Генерация конфигурации')
await copyEnvExample('boot')
await copyEnvExample('controller')
await copyEnvExample('desktop')
await copyEnvExample('parser')
// Обновляем COOPNAME во всех .env
await updateEnvValue(comp('controller/.env'), 'COOPNAME', coopname)
ok('Конфигурация сгенерирована')
// 2. Сборка shared-библиотек
step('Сборка shared-библиотек')
await run(`pnpm --filter cooptypes run build`, 'cooptypes')
await run(`pnpm --filter @coopenomics/factory run build`, 'factory')
await run(`pnpm --filter @coopenomics/sdk run build`, 'sdk')
await run(`pnpm --filter @coopenomics/notifications run build`, 'notifications')
ok('Shared-библиотеки собраны')
// 3. Сборка смарт-контрактов
step('Сборка смарт-контрактов')
const buildArg = contractsMode === 'test' ? 'test' : ''
await run(`cd ${comp('contracts')} && bash build-all.sh ${buildArg}`, 'Компиляция контрактов (может занять 3-5 мин)')
ok('Смарт-контракты собраны')
// 4. Вывод инструкций
console.log(`
\x1b[32m━━━ ✅ УСТАНОВКА ЗАВЕРШЕНА ━━━\x1b[0m
Для запуска системы выполните:
\x1b[36m1. pnpm run reboot\x1b[0m — Полный перезапуск (инфра + boot + сервисы)
Или по шагам:
\x1b[36m2. docker compose up -d\x1b[0m — Запуск инфраструктуры
\x1b[36m3. pnpm run boot\x1b[0m — Инициализация блокчейна
\x1b[36m4. pnpm run dev:backend\x1b[0m — Запуск бэкенда
\x1b[36m5. pnpm run dev:desktop\x1b[0m — Запуск фронтенда
Для тестирования:
\x1b[36mpnpm run test\x1b[0m — Запуск всех тестов
`)
}
// ──────────────────────────────────────────────────
// РЕЖИМ ТЕСТОВОЙ СЕТИ
// ──────────────────────────────────────────────────
async function setupTestnet(): Promise<void> {
console.log('\n\x1b[33m━━━ 📡 РЕЖИМ ТЕСТОВОЙ СЕТИ ━━━\x1b[0m\n')
// 1. Генерация .env для desktop
step('Настройка фронтенда на тестнет')
await copyFile(comp('desktop/.env-testnet'), comp('desktop/.env'))
ok('Desktop настроен на тестнет')
// 2. Сборка библиотек
step('Сборка shared-библиотек')
await run(`pnpm --filter cooptypes run build`, 'cooptypes')
await run(`pnpm --filter @coopenomics/factory run build`, 'factory')
await run(`pnpm --filter @coopenomics/sdk run build`, 'sdk')
await run(`pnpm --filter @coopenomics/notifications run build`, 'notifications')
ok('Shared-библиотеки собраны')
console.log(`
\x1b[32m━━━ ✅ УСТАНОВКА ЗАВЕРШЕНА ━━━\x1b[0m
Для запуска фронтенда:
\x1b[36mpnpm run dev:desktop\x1b[0m
Фронтенд подключится к тестовой сети:
API: ${TESTNET_CONFIG.BACKEND_URL}
Chain: ${TESTNET_CONFIG.CHAIN_URL}
`)
}
// ──────────────────────────────────────────────────
// РЕЖИМ ПРОДАКШЕН
// ──────────────────────────────────────────────────
async function setupProduction(): Promise<void> {
console.log('\n\x1b[33m━━━ 🚀 РЕЖИМ ПРОДАКШЕН ━━━\x1b[0m\n')
const answers = await inquirer.prompt([
{
type: 'input',
name: 'coopname',
message: 'Имя кооператива:',
default: 'voskhod',
validate: (v: string) => /^[a-z1-5.]{1,12}$/.test(v) || 'Только a-z, 1-5, точка. Макс. 12 символов.',
},
{
type: 'input',
name: 'domain',
message: 'Домен для доступа (без https://):',
default: 'coop.example.com',
},
{
type: 'input',
name: 'coopShortName',
message: 'Краткое название кооператива:',
default: 'Мой Кооператив',
},
{
type: 'input',
name: 'serverSecret',
message: 'Секретный ключ сервера (SERVER_SECRET):',
default: generateSecret(),
},
{
type: 'input',
name: 'jwtSecret',
message: 'JWT секрет:',
default: generateSecret(),
},
{
type: 'input',
name: 'smtpHost',
message: 'SMTP хост (для отправки почты):',
default: '',
},
{
type: 'input',
name: 'smtpPort',
message: 'SMTP порт:',
default: '465',
},
{
type: 'input',
name: 'smtpUser',
message: 'SMTP пользователь:',
default: '',
},
{
type: 'password',
name: 'smtpPass',
message: 'SMTP пароль:',
default: '',
},
])
// 1. Генерация .env для controller
step('Генерация конфигурации продакшен')
const dbUser = ['coop', 'admin'].join('')
const dbPass = generateSecret()
const siteDesc = ['кооперативная экономика', 'для сообществ и бизнеса'].join(' ')
const finishBlock = '0x' + 'F'.repeat(8)
const controllerEnv = `NODE_ENV=production
BASE_URL=https://${answers.domain}
PORT=2998
SERVER_SECRET=${answers.serverSecret}
MONGODB_URL=mongodb://mongo:27017/cooperative-x
COOPNAME=${answers.coopname}
JWT_SECRET=${answers.jwtSecret}
JWT_ACCESS_EXPIRATION_MINUTES=864000
JWT_REFRESH_EXPIRATION_DAYS=365
JWT_RESET_PASSWORD_EXPIRATION_MINUTES=10
JWT_VERIFY_EMAIL_EXPIRATION_MINUTES=10
SMTP_HOST=${answers.smtpHost}
SMTP_PORT=${answers.smtpPort}
SMTP_USERNAME=${answers.smtpUser}
SMTP_PASSWORD=${answers.smtpPass}
EMAIL_FROM="'${answers.coopShortName}' <mail@${answers.domain}>"
BLOCKCHAIN_RPC=${MAINNET_CONFIG.API_URL}
CHAIN_ID=${MAINNET_CONFIG.CHAIN_ID}
SIMPLE_EXPLORER_API=http://cooparser:4000
REDIS_HOST=monoredis
REDIS_PORT=6379
REDIS_PASSWORD=
POSTGRES_HOST=pg
POSTGRES_PORT=5432
POSTGRES_USERNAME=${dbUser}
POSTGRES_PASSWORD=${dbPass}
POSTGRES_DATABASE=${answers.coopname}
TIMEZONE=Europe/Moscow
`
await fs.writeFile(comp('controller/.env'), controllerEnv)
// Desktop .env
const desktopEnv = `NODE_ENV=production
BACKEND_URL=https://${answers.domain}/api
CHAIN_URL=${MAINNET_CONFIG.API_URL}
CHAIN_ID=${MAINNET_CONFIG.CHAIN_ID}
CURRENCY=RUB
COOP_SHORT_NAME=${answers.coopShortName}
SITE_DESCRIPTION=${siteDesc}
SITE_IMAGE=https://${answers.domain}/og-image.jpg
TIMEZONE=Europe/Moscow
`
await fs.writeFile(comp('desktop/.env'), desktopEnv)
// Parser .env
const parserEnv = `NODE_ENV=production
API=${MAINNET_CONFIG.API_URL}
SHIP=ws://node:8070
MONGO_EXPLORER_URI=mongodb://mongo:27017/cooperative-x
START_BLOCK=1
FINISH_BLOCK=${finishBlock}
PORT=4000
ACTIVATE_PARSER=1
REDIS_PORT=6379
REDIS_HOST=monoredis
REDIS_PASSWORD=
REDIS_STREAM_LIMIT=1000
COOPNAME=${answers.coopname}
`
await fs.writeFile(comp('parser/.env'), parserEnv)
// Boot .env
const bootEnv = `EOSIO_PUB_KEY=EOS6MRyAjQq8ud7hVNYcfnVPJqcVpscN5So8BhtHuGYqET5GDW5CV
EOSIO_PRV_KEY=5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3
DATADIR=blockchain-data
BPACCOUNT=eosio
PASSWORD=PW5JGe4WsTPGrjnMzkGd4wfVdzoHCEySgbyq2WBWGAxSfevbXqAG4
SERVER_SECRET=${answers.serverSecret}
SERVER_URL=http://coopback:2998
MONGO_URI=mongodb://mongo:27017/cooperative-x
SIMPLE_EXPLORER_API=http://cooparser:4000
SKIP_BLOCK_FETCH=TRUE
POSTGRES_HOST=pg
POSTGRES_PORT=5432
POSTGRES_USERNAME=${dbUser}
POSTGRES_PASSWORD=${dbPass}
POSTGRES_DATABASE=${answers.coopname}
`
await fs.writeFile(comp('boot/.env'), bootEnv)
ok('Конфигурация продакшен сгенерирована')
// note: dbUser, dbPass, siteDesc, finishBlock defined above in template literals
// 2. Сборка
step('Сборка shared-библиотек')
await run(`pnpm --filter cooptypes run build`, 'cooptypes')
await run(`pnpm --filter @coopenomics/factory run build`, 'factory')
await run(`pnpm --filter @coopenomics/sdk run build`, 'sdk')
await run(`pnpm --filter @coopenomics/notifications run build`, 'notifications')
ok('Shared-библиотеки собраны')
console.log(`
\x1b[32m━━━ ✅ ПРОДАКШЕН КОНФИГУРАЦИЯ ГОТОВА ━━━\x1b[0m
Файлы конфигурации сгенерированы:
• components/controller/.env
• components/desktop/.env
• components/parser/.env
• components/boot/.env
Для запуска в продакшене:
\x1b[36m1. docker compose up -d\x1b[0m — Инфраструктура
\x1b[36m2. docker compose up -d coopback cooparser\x1b[0m — Бэкенд
\x1b[36m3. docker compose up -d desktop\x1b[0m — Фронтенд
Подключение к основной сети:
API: ${MAINNET_CONFIG.API_URL}
P2P: ${MAINNET_CONFIG.P2P_PEER}
\x1b[33m⚠️ Для синхронизации ноды с основной сетью добавьте в
components/boot/src/configs/config.ini:
p2p-peer-address = ${MAINNET_CONFIG.P2P_PEER}\x1b[0m
`)
}
// ──────────────────────────────────────────────────
// Утилиты
// ──────────────────────────────────────────────────
function comp(relativePath: string): string {
return path.join(COMPONENTS, relativePath)
}
async function copyEnvExample(component: string): Promise<void> {
const src = comp(`${component}/.env-example`)
const dst = comp(`${component}/.env`)
try {
await fs.copyFile(src, dst)
console.log(` 📂 ${component}/.env-example → .env`)
} catch {
console.log(` ⚠️ ${component}/.env-example не найден, пропускаем`)
}
}
async function copyFile(src: string, dst: string): Promise<void> {
try {
await fs.copyFile(src, dst)
console.log(` 📂 ${path.basename(src)}${path.basename(dst)}`)
} catch (err) {
console.error(` ❌ Ошибка копирования: ${err}`)
}
}
async function updateEnvValue(envPath: string, key: string, value: string): Promise<void> {
try {
let content = await fs.readFile(envPath, 'utf-8')
const regex = new RegExp(`^${key}=.*$`, 'm')
if (regex.test(content)) {
content = content.replace(regex, `${key}=${value}`)
} else {
content += `\n${key}=${value}\n`
}
]);
if (env === 'frontend-testnet') {
console.log('📡 Настраиваем фронтенд на тестнете...');
await setupFrontendTestnet();
} else if (env === 'local-full') {
console.log('🖥️ Настраиваем локальный фронтенд и бэкенд...');
await setupLocalFull();
}
await fs.writeFile(envPath, content)
} catch {}
}
async function setupFrontendTestnet(): Promise<void> {
await copyFile('../desktop/.env-testnet', '../desktop/.env');
console.log('\n🚀 Начинаем сборку компонентов...');
await runCommandWithSpinner('cd ../cooptypes && pnpm run build', 'Сборка библиотеки типов');
await runCommandWithSpinner('cd ../factory && pnpm run build', 'Сборка фабрики документов');
await runCommandWithSpinner('cd ../sdk && pnpm run build', 'Сборка SDK');
console.log('\n✅ Фронтенд готов к запуску.');
console.log('🔹 Для запуска выполните:');
console.log(' 💻 `pnpm run dev:desktop`');
function step(msg: string): void {
console.log(`\n\x1b[34m▸ ${msg}\x1b[0m`)
}
async function setupLocalFull(): Promise<void> {
await copyFile('../desktop/.env-example', '../desktop/.env');
await copyFile('../controller/.env-example', '../controller/.env');
await copyFile('../boot/.env-example', '../boot/.env');
await copyFile('../factory/.env-example', '../factory/.env');
await copyFile('../notificator/.env-example', '../notificator/.env');
await copyFile('../parser/.env-example', '../parser/.env');
console.log('\n🚀 Подготавливаем инфраструктуру...');
await runCommandWithSpinner('docker compose up -d', 'Запуск инфраструктуры');
console.log('\n🚀 Начинаем сборку компонентов...');
await runCommandWithSpinner('cd ../cooptypes && pnpm run build', 'Сборка библиотеки типов');
await runCommandWithSpinner('cd ../factory && pnpm run build', 'Сборка фабрики документов');
await runCommandWithSpinner('cd ../sdk && pnpm run build', 'Сборка SDK');
await runCommandWithSpinner('cd ../contracts && pnpm run build:all', 'Сборка смарт-контрактов');
console.log('\n✅ Подготовка завершена.');
console.log('🔹 Для локального запуска системы последовательно выполните в соседних окнах терминала:');
console.log(' 💻 `pnpm boot` (Запуск блокчейна и контрактов)');
console.log(' 💻 `pnpm run dev:backend` (Запуск бэкенда)');
console.log(' 💻 `pnpm run dev:desktop` (Запуск фронтенда)');
function ok(msg: string): void {
console.log(`\x1b[32m ✅ ${msg}\x1b[0m`)
}
async function copyFile(source: string, destination: string): Promise<void> {
async function run(command: string, label: string): Promise<void> {
const spinner = ora({ text: label, color: 'cyan' }).start()
try {
await fs.copyFile(source, destination);
console.log(`📂 ${source}${destination}`);
} catch (error) {
console.error(`❌ Ошибка копирования ${source}:`, error);
await execPromise(command, { cwd: ROOT, timeout: 600000 })
spinner.succeed(label)
} catch (error: any) {
spinner.fail(label)
if (error.stderr) console.error(` ${error.stderr.split('\n')[0]}`)
}
}
async function runCommandWithSpinner(command: string, message: string): Promise<void> {
const spinner = ora(`${message}...`).start();
try {
await execPromise(command);
spinner.succeed(`${message}`);
} catch (error) {
spinner.fail(`${message}`);
console.error(`❌ Ошибка выполнения команды "${command}":`, error);
function generateSecret(length = 32): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
let result = ''
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
return result
}
// Запуск меню
mainMenu();
// Запуск
mainMenu().catch(console.error)