Files
ma7engine/scripts/new-panel.mjs
T
maenekoandClaude Opus 5 18bd05bcbb ma7engine: ядро, шаблон панели, скаффолдер и доктор
Первый коммит существующего состояния. Ядро кладётся под git ради того,
чтобы половины разъезжались в продуктовые репозитории через git subtree,
а не копированием: subtree делает настоящий merge, хранит историю и умеет
отдавать правки обратно.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LeBkZLiQrvdECf5nfgiMz
2026-08-26 20:39:37 +03:00

237 lines
16 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 -- <slug> [каталог]\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', [['<title>Demo Panel</title>', `<title>${display}</title>`]]);
edit('backend/package.json', [['"ma7engine-example-backend"', `"${slug}-backend"`]]);
edit('frontend/src/App.tsx', [
[`// Шаблон фронта панели — с этого начинается новый. Копируется вместе с
// ../backend; ядро в свою панель приезжает через \`npm run update\`.
//
// Всё, что здесь есть: конфиг. Логин, тема, drawer/сайдбар, снэкбар, сессия,
// меню по правам и переключение вкладок — в <Panel />.
//
// Парная половина — backend/server.ts: вкладки ниже ходят в его
// маршруты /api/demo/*, так что две половины запускаются вместе и работают.`,
`// Фронт панели ${display}. Всё, что здесь есть: конфиг. Логин, тема,
// drawer/сайдбар, снэкбар, сессия, меню по правам и переключение вкладок —
// в <Panel /> из ядра.
//
// Ядро приезжает копией в 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 : <ServerBar /> },`,
` // slots: { aboveTab: ctx => ctx.tab?.id === 'kit' ? null : <ServerBar /> },`],
]);
// ─── бэкенд: шапка, лог и вырезание демо-маршрутов ──────────────────────────
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`);