#!/usr/bin/env node // Заводит новую панель на этом ядре — копирует обе половины шаблона и // подставляет то, что README раньше предписывал править руками: имена в // package.json, брендинг и ключи localStorage. Забытые ключи — не мелочь: две // панели на одном домене затирают друг другу токен и тему. // // npm run new-panel -- admin-hub # → ../admin-hub // npm run new-panel -- admin-hub ~/work/hub # явный путь // // По умолчанию панель кладётся соседом ma7engine: именно там её найдёт перебор // кандидатов в scripts/update.sh, и `npm run update` заработает без $MA7ENGINE. // // Не sh, в отличие от update.sh: здесь многострочная правка исходников, а не // копирование каталогов, и sed по номерам строк ломается от любой правки // шаблона. Node гарантированно есть — обе половины и так npm-проекты. import fs from 'node:fs'; import { execFileSync } from 'node:child_process'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.dirname(HERE); // …/ma7engine const die = (msg) => { console.error(`new-panel: ${msg}`); process.exit(1); }; // ─── аргументы ────────────────────────────────────────────────────────────── const [slug, dirArg] = process.argv.slice(2); if (!slug || slug === '--help' || slug === '-h') { console.log('Использование: npm run new-panel -- [каталог]\n' + ' slug — имя панели строчными буквами через дефис, например admin-hub'); process.exit(slug ? 0 : 1); } if (!/^[a-z][a-z0-9-]*$/.test(slug)) { die(`slug "${slug}" негоден — строчные латинские буквы, цифры и дефис, начинается с буквы`); } const target = path.resolve(dirArg ?? path.join(path.dirname(ROOT), slug)); if (fs.existsSync(target) && fs.readdirSync(target).length > 0) { die(`каталог ${target} существует и не пуст`); } // admin-hub → admin_hub (ключи стораджа) и «Admin Hub» (брендинг) const keyPrefix = slug.replace(/-/g, '_'); const display = slug.split('-').map(w => w[0].toUpperCase() + w.slice(1)).join(' '); // ─── копирование половин ──────────────────────────────────────────────────── // Вендоренная копия ядра не копируется: она приедет своим `npm run update`. // Так новая панель гарантированно получает актуальное ядро, а не слепок чужого. const SKIP = new Set(['node_modules', 'dist', 'core', 'package-lock.json']); function copyTree(from, to) { fs.mkdirSync(to, { recursive: true }); for (const entry of fs.readdirSync(from, { withFileTypes: true })) { if (SKIP.has(entry.name)) continue; const src = path.join(from, entry.name); const dst = path.join(to, entry.name); if (entry.isDirectory()) copyTree(src, dst); else fs.copyFileSync(src, dst); } } copyTree(path.join(ROOT, 'frontend'), path.join(target, 'frontend')); copyTree(path.join(ROOT, 'backend'), path.join(target, 'backend')); // ─── подстановки ──────────────────────────────────────────────────────────── // Каждая замена обязана сработать: молча пропущенная — это ровно тот случай, // когда панель уезжает с чужим ключом стораджа и ловится потом руками. function edit(relPath, pairs) { const file = path.join(target, relPath); let text = fs.readFileSync(file, 'utf8'); for (const [from, to] of pairs) { if (!text.includes(from)) die(`в ${relPath} не найдено для замены:\n---\n${from}\n---`); text = text.replace(from, to); } fs.writeFileSync(file, text); } edit('frontend/package.json', [['"ma7engine-example-frontend"', `"${slug}-frontend"`]]); // Заголовок вкладки браузера — не то же, что branding.name в App.tsx: его видно // до того, как React смонтируется, и в закладках. edit('frontend/index.html', [['Demo Panel', `${display}`]]); edit('backend/package.json', [['"ma7engine-example-backend"', `"${slug}-backend"`]]); edit('frontend/src/App.tsx', [ [`// Шаблон фронта панели — с этого начинается новый. Копируется вместе с // ../backend; ядро в свою панель приезжает через \`npm run update\`. // // Всё, что здесь есть: конфиг. Логин, тема, drawer/сайдбар, снэкбар, сессия, // меню по правам и переключение вкладок — в . // // Парная половина — backend/server.ts: вкладки ниже ходят в его // маршруты /api/demo/*, так что две половины запускаются вместе и работают.`, `// Фронт панели ${display}. Всё, что здесь есть: конфиг. Логин, тема, // drawer/сайдбар, снэкбар, сессия, меню по правам и переключение вкладок — // в из ядра. // // Ядро приезжает копией в src/core через \`npm run update\` (его зовут predev // и prebuild). Парная половина — backend/server.ts.`], [`branding: { name: 'Demo Panel', version: '0.1.0' },`, `branding: { name: '${display}', version: '0.1.0' },`], [`storageKeys: { token: 'demo_token', theme: 'demo_theme' },`, `storageKeys: { token: '${keyPrefix}_token', theme: '${keyPrefix}_theme' },`], [` // slots: { aboveTab: ctx => ctx.tab?.id === 'docs' ? null : },`, ` // slots: { aboveTab: ctx => ctx.tab?.id === 'kit' ? null : },`], ]); // ─── бэкенд: шапка, лог и вырезание демо-маршрутов ────────────────────────── const serverFile = path.join(target, 'backend/server.ts'); let server = fs.readFileSync(serverFile, 'utf8'); const headOld = `// Шаблон бэкенда панели — с этого начинается новый. Копируется вместе с // ../frontend; ядро в свою панель приезжает через \`npm run update\`. // // Всё, что здесь есть: конфиг и свои маршруты. Логин, сессия, права, // рейт-лимит, раздача SPA и порядок их монтирования — в createPanelServer.`; const headNew = `// Бэкенд панели ${display}. Всё, что здесь есть: конфиг и свои маршруты. // Логин, сессия, права, рейт-лимит, раздача SPA и порядок их монтирования — // в createPanelServer. Ядро приезжает копией в ./core через \`npm run update\`, // запущенный во фронте.`; if (!server.includes(headOld)) die('backend/server.ts: не найдена шапка шаблона'); server = server.replace(headOld, headNew); // mountRoutes с демо-маршрутами уезжает целиком: три роута /api/demo/* нужны // только вкладкам hello и secrets, которых в новой панели не будет. Образец // остаётся комментарием — вместе со строкой импорта, которая ему понадобится. const startMark = '\n mountRoutes(app, { auth, forwardError }) {\n'; const endMark = '\n },\n});\n'; const start = server.indexOf(startMark); const end = server.indexOf(endMark, start); if (start === -1 || end === -1) die('backend/server.ts: не найден блок mountRoutes'); server = server.slice(0, start) + ` // Маршруты панели. Монтируются ядром после логина и до статики — порядок // зафиксирован внутри createPanelServer и снаружи не нарушается. // // import type { Request, Response } from 'express'; // // mountRoutes(app, { auth, forwardError }) { // // Открыт любому залогиненному — у вкладки нет permission. // app.get('/api/reports', auth.requireAuth, async (req: Request, res: Response) => { // try { res.json(await db.reports()); } // catch (e) { forwardError(res, e); } // }); // // // Закрыт правом. Гейт на фронте (permission во вкладке) — это // // только UX; настоящая граница здесь. // app.post('/api/reports', auth.requireAuth, // auth.requirePermission('reports.manage'), handler); // }, }); ` + server.slice(end + endMark.length); // Request/Response остались бы висеть неиспользованными — образец выше // показывает, как вернуть импорт вместе с маршрутом. const importLine = "import type { Request, Response } from 'express';\n"; if (server.includes(importLine)) server = server.replace(importLine, ''); if (!server.includes('demo-panel: listening on')) die('backend/server.ts: не найдена строка лога'); server = server.replace('demo-panel: listening on', `${slug}: listening on`); fs.writeFileSync(serverFile, server); // ─── демо-вкладки ─────────────────────────────────────────────────────────── // hello и secrets ходят в /api/demo/*, которых больше нет — уезжают. docs // (справочник по ядру) и kit (живой каталог классов дизайн-системы) // самодостаточны и остаются: новая панель запускается непустой. for (const tab of ['hello', 'secrets']) { fs.rmSync(path.join(target, 'frontend/src/tabs', tab), { recursive: true, force: true }); } // ─── файлы уровня панели ──────────────────────────────────────────────────── // Копия ядра в панели — закоммиченный исходник, а не артефакт сборки: образ // собирается из чистого checkout'а, где ma7engine рядом нет. Обновляется она // осознанно, через `npm run update`, и приходит в ревью диффом; расхождение с // ядром ловит `npm run doctor`. fs.writeFileSync(path.join(target, '.gitignore'), `node_modules/ # Сборка фронта — в образе он собирается заново. frontend/dist # frontend/src/core и backend/core здесь НЕ игнорируются намеренно: копия ядра # едет в репозиторий панели, иначе образ не собрать без ma7engine рядом. `); // Dockerfile и .dockerignore едут из шаблона как есть: контекст сборки — каталог // панели, поэтому ни одного пути в них править не нужно. for (const f of ['Dockerfile', '.dockerignore']) { fs.copyFileSync(path.join(ROOT, f), path.join(target, f)); } fs.mkdirSync(path.join(target, 'scripts'), { recursive: true }); const dev = fs.readFileSync(path.join(ROOT, 'scripts/demo.sh'), 'utf8') .replace('# Поднимает демо-панель целиком: бэкенд в фоне, Vite на переднем плане.', `# Поднимает панель ${display} целиком: бэкенд в фоне, Vite на переднем плане.`) .replace('# npm run demo (из корня ma7engine)\n', '') .replace('# sh scripts/demo.sh (откуда угодно — пути считаются от самого скрипта)', '# sh scripts/dev.sh (откуда угодно — пути считаются от самого скрипта)') .replace('# Учётка по умолчанию — admin / demo. Переопределяется окружением:\n' + '# ADMIN_USER=root ADMIN_PASS=secret npm run demo', '# Учётка по умолчанию — admin / demo, только для дева. Переопределяется\n' + '# окружением: ADMIN_USER=root ADMIN_PASS=secret sh scripts/dev.sh') .replaceAll('demo: ', `${slug}: `); fs.writeFileSync(path.join(target, 'scripts/dev.sh'), dev, { mode: 0o755 }); // ─── ядро ─────────────────────────────────────────────────────────────────── // Копия ядра кладётся сразу: в панели она закоммиченный исходник, и панель // должна собираться с первого коммита, а не после того, как кто-то вспомнит // про `npm run update`. Путь к ядру известен — это мы сами. try { execFileSync('sh', [path.join(target, 'frontend/scripts/update.sh')], { env: { ...process.env, MA7ENGINE: ROOT }, stdio: 'pipe' }); console.log('new-panel: ядро скопировано в frontend/src/core и backend/core'); } catch (e) { console.error('new-panel: не удалось разложить ядро — запусти `npm run update` во фронте\n' + String(e.stderr ?? e.message)); } // ─── что дальше ───────────────────────────────────────────────────────────── console.log(` new-panel: панель ${display} создана в ${target} Дальше: cd ${target}/frontend && npm i cd ${target}/backend && npm i cd ${target} && sh scripts/dev.sh # обе половины, фронт на :5173 Пароль и секрет обязательны — без них ядро не даст процессу подняться: ADMIN_PASS=... ADMIN_JWT_SECRET=... Проверить, что панель собрана по стандарту: cd ${target}/frontend && npm run doctor`);