git subtree split -b отказывается двигать существующую ветку, если новый коммит ей не потомок — после reset в ядре ветка молча оставалась старой, и продукт тянул устаревшую половину. Ветки производные, поэтому branch -f. Поймано doctor'ом: сверка поддерева с ядром показала расхождение серверной половины при совпадающей браузерной. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LeBkZLiQrvdECf5nfgiMz
255 lines
17 KiB
JavaScript
Executable File
255 lines
17 KiB
JavaScript
Executable File
#!/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"`],
|
||
// В панели ядро приезжает через git subtree, а не копированием: хуки
|
||
// predev/prebuild и сам update.sh здесь не нужны и вредны — update.sh
|
||
// сносит каталог целиком (rm -rf) и затёр бы подтянутое поддерево.
|
||
[` "predev": "npm run update",\n`, ''],
|
||
[` "update": "sh scripts/update.sh",\n`, ''],
|
||
[` "prebuild": "npm run update",\n`, ''],
|
||
]);
|
||
// Заголовок вкладки браузера — не то же, что 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));
|
||
}
|
||
|
||
// Доктор берётся из scripts/, а не тот, что мог приехать внутри frontend/:
|
||
// в шаблоне frontend/scripts/doctor.mjs — вендоренная копия, и она бывает
|
||
// старше исходника. Панель должна получить актуальные проверки.
|
||
fs.mkdirSync(path.join(target, 'frontend/scripts'), { recursive: true });
|
||
fs.copyFileSync(path.join(ROOT, 'scripts/doctor.mjs'),
|
||
path.join(target, 'frontend/scripts/doctor.mjs'));
|
||
|
||
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 });
|
||
|
||
// ─── ядро ───────────────────────────────────────────────────────────────────
|
||
// Ядро сюда не копируется: его приносит `git subtree add` — командами ниже.
|
||
// Копия и поддерево не уживаются, поэтому update.sh в панель не едет вовсе.
|
||
fs.rmSync(path.join(target, 'frontend/scripts/update.sh'), { force: true });
|
||
|
||
// ─── что дальше ─────────────────────────────────────────────────────────────
|
||
// Панель — надстройка: она обычно лежит подкаталогом продуктового репозитория,
|
||
// и prefix для subtree считается от его корня. Угадать корень отсюда нельзя,
|
||
// поэтому в подсказке путь даётся относительно каталога панели.
|
||
const prefix = `${path.basename(target)}/`;
|
||
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=...
|
||
|
||
Ядро приносит git subtree — из каталога панели в её репозитории:
|
||
git subtree add --prefix=${prefix}frontend/src/core ${ROOT} dist/browser --squash
|
||
git subtree add --prefix=${prefix}backend/core ${ROOT} dist/server --squash
|
||
|
||
Дальше обновляется тем же git subtree pull. Копировать ядро руками не нужно —
|
||
и не стоит: поддерево умеет merge и покажет конфликт, а копия молча затрёт.
|
||
|
||
Проверить, что панель собрана по стандарту:
|
||
cd ${target}/frontend && npm run doctor`);
|