ma7engine: ядро, шаблон панели, скаффолдер и доктор
Первый коммит существующего состояния. Ядро кладётся под git ради того, чтобы половины разъезжались в продуктовые репозитории через git subtree, а не копированием: subtree делает настоящий merge, хранит историю и умеет отдавать правки обратно. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LeBkZLiQrvdECf5nfgiMz
This commit is contained in:
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/bin/sh
|
||||
# Поднимает демо-панель целиком: бэкенд в фоне, Vite на переднем плане.
|
||||
# Ctrl+C гасит обе половины.
|
||||
#
|
||||
# npm run demo (из корня ma7engine)
|
||||
# sh scripts/demo.sh (откуда угодно — пути считаются от самого скрипта)
|
||||
#
|
||||
# Учётка по умолчанию — admin / demo. Переопределяется окружением:
|
||||
# ADMIN_USER=root ADMIN_PASS=secret npm run demo
|
||||
set -eu
|
||||
|
||||
here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
root=$(dirname "$here")
|
||||
|
||||
# Пароль и секрет обязательны для ядра (fail-closed), поэтому у демо есть
|
||||
# заведомо непригодные для прода значения по умолчанию.
|
||||
ADMIN_PASS=${ADMIN_PASS:-demo}
|
||||
ADMIN_JWT_SECRET=${ADMIN_JWT_SECRET:-dev-secret}
|
||||
export ADMIN_PASS ADMIN_JWT_SECRET
|
||||
|
||||
for half in backend frontend; do
|
||||
if [ ! -d "$root/$half/node_modules" ]; then
|
||||
echo "demo: ставлю зависимости в $half…"
|
||||
(cd "$root/$half" && npm i)
|
||||
fi
|
||||
done
|
||||
|
||||
(cd "$root/backend" && npm start) &
|
||||
backend=$!
|
||||
|
||||
# Гасим бэкенд вместе с собой — иначе он останется висеть на :8080. Идём по
|
||||
# всему дереву: между нами и слушающим порт node стоят подоболочка, npm и sh,
|
||||
# и сигнал в верхний процесс до нижнего сам не доходит — тот просто осиротеет.
|
||||
kill_tree() {
|
||||
for child in $(pgrep -P "$1" 2>/dev/null); do kill_tree "$child"; done
|
||||
kill "$1" 2>/dev/null || true
|
||||
}
|
||||
cleanup() { kill_tree "$backend"; }
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
echo "demo: бэкенд на :8080, фронт поднимается на :5173 — открывать его"
|
||||
echo "demo: логин admin / ${ADMIN_PASS}"
|
||||
|
||||
cd "$root/frontend" && npm run dev
|
||||
Executable
+347
@@ -0,0 +1,347 @@
|
||||
#!/usr/bin/env node
|
||||
// Проверяет, что панель собрана и осталась по стандарту ma7engine. Отвечает на
|
||||
// вопрос, на который раньше ответа не было: «эта панель ещё соответствует ядру,
|
||||
// или уже уехала?» — именно расхождение трёх форков и породило само ядро.
|
||||
//
|
||||
// npm run doctor (из <панель>/frontend)
|
||||
// node scripts/doctor.mjs ../admin-hub (из ma7engine, по чужому пути)
|
||||
//
|
||||
// Ошибки (выход 1) — то, из-за чего панель соберётся неправильно или не
|
||||
// соберётся вовсе. Предупреждения (выход 0) — следы шаблона, оставшиеся после
|
||||
// копирования: они не ломают сборку, поэтому не должны ломать и CI.
|
||||
//
|
||||
// Скрипт распространяется в панели вместе с ядром — его кладёт update.sh рядом
|
||||
// с собой, так что проверки в панели всегда те же, что в ядре.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// ─── где панель ─────────────────────────────────────────────────────────────
|
||||
// Аргументом — путь к панели; без аргумента корнем считается ближайший предок,
|
||||
// в котором лежит frontend/package.json. Скрипт живёт либо в
|
||||
// <панель>/frontend/scripts (куда его кладёт update.sh), либо в
|
||||
// ma7engine/scripts — оба случая этот подъём разбирает одинаково, и он не
|
||||
// зависит от того, какие ещё каталоги оказались рядом.
|
||||
function resolvePanel() {
|
||||
const arg = process.argv[2];
|
||||
if (arg) return path.resolve(arg);
|
||||
for (let dir = HERE; ; dir = path.dirname(dir)) {
|
||||
if (fs.existsSync(path.join(dir, 'frontend/package.json'))) return dir;
|
||||
if (dir === path.dirname(dir)) break;
|
||||
}
|
||||
return path.dirname(HERE);
|
||||
}
|
||||
|
||||
const PANEL = resolvePanel();
|
||||
const FRONT = path.join(PANEL, 'frontend');
|
||||
|
||||
// Панель из одной папки (сервер рядом с фронтом, как было в awg-ui) держит
|
||||
// серверную половину ядра в <панель>/core, а не в <панель>/backend/core.
|
||||
const BACK = fs.existsSync(path.join(PANEL, 'backend')) ? path.join(PANEL, 'backend') : PANEL;
|
||||
const SERVER_CORE = path.join(BACK, 'core');
|
||||
|
||||
// Сам шаблон ma7engine — панель по всем структурным признакам, но demo_token и
|
||||
// «Demo Panel» в нём норма, а не недоделка. Узнаётся по каталогу core/ рядом.
|
||||
const IS_TEMPLATE = fs.existsSync(path.join(PANEL, 'core', 'browser'));
|
||||
|
||||
// ─── накопитель результатов ─────────────────────────────────────────────────
|
||||
const errors = [], warns = [], skips = [], oks = [];
|
||||
|
||||
const ok = (m) => oks.push(m);
|
||||
const err = (m, w) => errors.push(w ? `${m}\n ${w}` : m);
|
||||
const warn = (m, w) => warns.push(w ? `${m}\n ${w}` : m);
|
||||
const skip = (m) => skips.push(m);
|
||||
|
||||
const read = (p) => { try { return fs.readFileSync(p, 'utf8'); } catch { return null; } };
|
||||
|
||||
// Проверка «файл есть и в нём встречается X»; возвращает текст либо null.
|
||||
function need(file, label) {
|
||||
const text = read(file);
|
||||
if (text === null) err(`${label}: нет файла ${path.relative(PANEL, file)}`);
|
||||
return text;
|
||||
}
|
||||
|
||||
// ─── 1. раскладка и сборка ──────────────────────────────────────────────────
|
||||
if (!fs.existsSync(FRONT)) {
|
||||
console.error(`doctor: в ${PANEL} нет каталога frontend — это не панель ma7engine`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const updateSh = path.join(FRONT, 'scripts/update.sh');
|
||||
if (fs.existsSync(updateSh)) ok('update.sh на месте');
|
||||
else err('нет frontend/scripts/update.sh', 'без него ядро в панели не обновляется');
|
||||
|
||||
const frontPkgText = need(path.join(FRONT, 'package.json'), 'фронт');
|
||||
const frontPkg = frontPkgText ? JSON.parse(frontPkgText) : null;
|
||||
if (frontPkg) {
|
||||
const s = frontPkg.scripts ?? {};
|
||||
if (/update\.sh/.test(s.update ?? '')) ok('скрипт update ведёт на scripts/update.sh');
|
||||
else err('frontend/package.json: скрипт "update" не зовёт scripts/update.sh');
|
||||
|
||||
for (const hook of ['predev', 'prebuild']) {
|
||||
if (/update/.test(s[hook] ?? '')) ok(`${hook} обновляет ядро`);
|
||||
else err(`frontend/package.json: нет "${hook}": "npm run update"`,
|
||||
'без него dev и build идут на устаревшей копии ядра');
|
||||
}
|
||||
}
|
||||
|
||||
// tsconfig.json шаблона содержит //-комментарий — JSON.parse на нём падает,
|
||||
// поэтому конфиги проверяются регулярками, а не разбором.
|
||||
const tsconf = need(path.join(FRONT, 'tsconfig.json'), 'фронт');
|
||||
if (tsconf) {
|
||||
const pairs = [[/"@core\/\*"\s*:\s*\[\s*"\.\/src\/core\/\*"\s*\]/, '@core/* → ./src/core/*'],
|
||||
[/"@\/\*"\s*:\s*\[\s*"\.\/src\/\*"\s*\]/, '@/* → ./src/*']];
|
||||
for (const [re, label] of pairs) {
|
||||
if (re.test(tsconf)) ok(`tsconfig: ${label}`);
|
||||
else err(`tsconfig.json: нет пути ${label}`, 'вкладки перестанут резолвить импорты ядра');
|
||||
}
|
||||
}
|
||||
|
||||
const viteConf = need(path.join(FRONT, 'vite.config.ts'), 'фронт');
|
||||
if (viteConf) {
|
||||
for (const [re, label] of [[/'@core'\s*:/, '@core'], [/'@'\s*:/, '@']]) {
|
||||
if (re.test(viteConf)) ok(`vite: алиас ${label}`);
|
||||
else err(`vite.config.ts: нет алиаса ${label}`, 'сборка не найдёт то, что находит tsc');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 2. вендоренная копия ядра ──────────────────────────────────────────────
|
||||
const BROWSER_CORE = path.join(FRONT, 'src/core');
|
||||
|
||||
if (!fs.existsSync(BROWSER_CORE)) {
|
||||
err('нет frontend/src/core', 'ядро ни разу не приезжало — запусти npm run update');
|
||||
}
|
||||
|
||||
// Хэш дерева: имена файлов и содержимое, в устойчивом порядке.
|
||||
function treeHash(dir) {
|
||||
if (!fs.existsSync(dir)) return null;
|
||||
const h = crypto.createHash('sha256');
|
||||
const walk = (d, prefix) => {
|
||||
for (const e of fs.readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : 1)) {
|
||||
if (e.name === '.DS_Store') continue; // Finder кладёт его в любую папку
|
||||
const p = path.join(d, e.name);
|
||||
if (e.isDirectory()) walk(p, `${prefix}${e.name}/`);
|
||||
else { h.update(`${prefix}${e.name}\0`); h.update(fs.readFileSync(p)); }
|
||||
}
|
||||
};
|
||||
walk(dir, '');
|
||||
return h.digest('hex');
|
||||
}
|
||||
|
||||
// Ядро ищется тем же перебором, что в update.sh — порядок от ближнего к дальнему.
|
||||
function findEngine() {
|
||||
if (process.env.MA7ENGINE) return process.env.MA7ENGINE;
|
||||
if (IS_TEMPLATE) return PANEL;
|
||||
for (const c of [path.join(FRONT, '../ma7engine'),
|
||||
path.join(FRONT, '../../ma7engine'),
|
||||
path.join(FRONT, '../../../ma7engine'),
|
||||
path.join(FRONT, '../../../admins/ma7engine')]) {
|
||||
if (fs.existsSync(path.join(c, 'core/browser'))) return path.resolve(c);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const engine = findEngine();
|
||||
if (!engine) {
|
||||
skip('сверка копии ядра — ma7engine рядом не найден (укажи MA7ENGINE=/путь)');
|
||||
} else {
|
||||
for (const [copy, src, label] of [[BROWSER_CORE, path.join(engine, 'core/browser'), 'браузерная'],
|
||||
[SERVER_CORE, path.join(engine, 'core/server'), 'серверная']]) {
|
||||
if (!fs.existsSync(copy)) { skip(`сверка: ${label} половина ядра в панель не приезжала`); continue; }
|
||||
if (treeHash(copy) === treeHash(src)) ok(`копия ядра совпадает с ядром (${label})`);
|
||||
else err(`копия ядра разошлась с ma7engine (${label}: ${path.relative(PANEL, copy)})`,
|
||||
'ядро правили руками либо копия устарела — npm run update перезальёт её');
|
||||
}
|
||||
}
|
||||
|
||||
// Ядро обязано ходить относительными путями: с @core внутри себя вендоренная
|
||||
// копия потянула бы за собой требование к настройке потребителя.
|
||||
for (const [dir, label] of [[BROWSER_CORE, 'browser'], [SERVER_CORE, 'server']]) {
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
const bad = [];
|
||||
const walk = (d) => {
|
||||
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
||||
const p = path.join(d, e.name);
|
||||
if (e.isDirectory()) walk(p);
|
||||
else if (/\.tsx?$/.test(e.name) && /from\s+'@(core)?\//.test(read(p) ?? '')) bad.push(p);
|
||||
}
|
||||
};
|
||||
walk(dir);
|
||||
if (bad.length) err(`внутри копии ядра есть алиасные импорты (${label}): ` +
|
||||
bad.map(p => path.relative(PANEL, p)).join(', '),
|
||||
'ядро должно импортировать себя относительными путями');
|
||||
else ok(`копия ядра ходит относительными путями (${label})`);
|
||||
}
|
||||
|
||||
// ─── 3. игнор-файлы ─────────────────────────────────────────────────────────
|
||||
// Копия ядра обязана попадать и в репозиторий панели, и в образ: контекст
|
||||
// сборки — каталог панели, ma7engine рядом при сборке нет.
|
||||
//
|
||||
// В самом ma7engine наоборот: там копия — артефакт `npm run update`, источник
|
||||
// лежит в соседнем core/, и коммитить её значило бы держать ядро в одном
|
||||
// репозитории трижды.
|
||||
const gitignore = read(path.join(PANEL, '.gitignore')) ?? read(path.join(FRONT, '.gitignore'));
|
||||
if (gitignore === null) {
|
||||
skip('.gitignore — файла нет, проверять нечего');
|
||||
} else {
|
||||
const ignoresCore = /(^|\/)(frontend\/)?src\/core\s*$/m.test(gitignore)
|
||||
|| /(^|\/)backend\/core\s*$/m.test(gitignore)
|
||||
|| /^core\/?\s*$/m.test(gitignore);
|
||||
if (IS_TEMPLATE) {
|
||||
if (ignoresCore) ok('.gitignore закрывает копию ядра (шаблон — источник рядом в core/)');
|
||||
else warn('шаблон: .gitignore не закрывает копию ядра',
|
||||
'в ma7engine она артефакт npm run update, а источник — соседний core/');
|
||||
} else if (ignoresCore) {
|
||||
err('.gitignore исключает копию ядра из репозитория панели',
|
||||
'образ собирается из чистого checkout\'а, где ma7engine рядом нет — ' +
|
||||
'без закоммиченной копии сборка встанет на импорте @core');
|
||||
} else {
|
||||
ok('копия ядра коммитится вместе с панелью');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 3b. образ ──────────────────────────────────────────────────────────────
|
||||
const dockerfile = read(path.join(PANEL, 'Dockerfile'));
|
||||
if (dockerfile === null) {
|
||||
warn('нет Dockerfile', 'панель — один процесс и один образ; шаблон везёт готовый');
|
||||
} else {
|
||||
if (/COPY --from=frontend .*dist/.test(dockerfile)) ok('Dockerfile кладёт собранный фронт в образ');
|
||||
else warn('Dockerfile не копирует frontend/dist из стадии сборки',
|
||||
'сервер отдаёт фронт сам — без dist панель поднимется пустой');
|
||||
}
|
||||
|
||||
const dockerignore = read(path.join(PANEL, '.dockerignore'));
|
||||
if (dockerignore === null) {
|
||||
skip('.dockerignore — файла нет, проверять нечего');
|
||||
} else if (/(^|\/|\*)core\/?\s*$/m.test(dockerignore)) {
|
||||
err('.dockerignore исключает core', 'в образ копия ядра должна приехать с исходниками — ' +
|
||||
'иначе сборка встанет на импорте @core');
|
||||
} else {
|
||||
ok('.dockerignore не исключает копию ядра');
|
||||
}
|
||||
|
||||
// ─── 4. вкладки ─────────────────────────────────────────────────────────────
|
||||
// Те же инварианты, что buildTabs проверяет в рантайме, но до сборки.
|
||||
const tabsDir = path.join(FRONT, 'src/tabs');
|
||||
if (!fs.existsSync(tabsDir)) {
|
||||
warn('нет src/tabs', 'панель без вкладок покажет заглушку');
|
||||
} else {
|
||||
// Имена иконок берутся только из тел карт ICONS и coreIcons — разбирать
|
||||
// файл целиком значило бы ловить в известные и случайные `что-то: Ico…`,
|
||||
// а ложная ошибка здесь хуже пропущенной: buildTabs всё равно поймает
|
||||
// незарегистрированную иконку в рантайме.
|
||||
const mapBody = (text, name) => {
|
||||
const start = text.indexOf(`export const ${name}`);
|
||||
if (start === -1) return null;
|
||||
const open = text.indexOf('{', start), close = text.indexOf('\n};', open);
|
||||
return open === -1 || close === -1 ? null : text.slice(open, close);
|
||||
};
|
||||
const bodies = [mapBody(read(path.join(FRONT, 'src/components/icons.tsx')) ?? '', 'ICONS'),
|
||||
mapBody(read(path.join(BROWSER_CORE, 'icons/index.tsx')) ?? '', 'coreIcons')];
|
||||
const known = bodies.some(b => b === null)
|
||||
? null // карту не нашли — проверку пропускаем
|
||||
: new Set([...bodies.join('\n').matchAll(/(\w+)\s*:\s*Ico\w+/g)].map(m => m[1]));
|
||||
if (known === null) skip('сверка иконок вкладок — карта ICONS не разобрана');
|
||||
|
||||
const ids = new Map();
|
||||
let clean = true;
|
||||
|
||||
for (const e of fs.readdirSync(tabsDir, { withFileTypes: true })) {
|
||||
if (!e.isDirectory()) continue;
|
||||
const dir = path.join(tabsDir, e.name);
|
||||
const metaPath = path.join(dir, 'metadata.json');
|
||||
if (!fs.existsSync(path.join(dir, 'index.tsx'))) { err(`tabs/${e.name}: нет index.tsx`); clean = false; continue; }
|
||||
if (!fs.existsSync(metaPath)) { err(`tabs/${e.name}: нет metadata.json`); clean = false; continue; }
|
||||
let meta;
|
||||
try { meta = JSON.parse(read(metaPath)); }
|
||||
catch { err(`tabs/${e.name}/metadata.json: не разбирается как JSON`); clean = false; continue; }
|
||||
for (const key of ['id', 'label', 'icon']) {
|
||||
if (typeof meta[key] !== 'string' || !meta[key].trim()) {
|
||||
err(`tabs/${e.name}/metadata.json: поле "${key}" должно быть непустой строкой`); clean = false;
|
||||
}
|
||||
}
|
||||
if (meta.id) {
|
||||
if (ids.has(meta.id)) { err(`дублирующийся id вкладки "${meta.id}": ${ids.get(meta.id)} и ${e.name}`); clean = false; }
|
||||
else ids.set(meta.id, e.name);
|
||||
}
|
||||
if (meta.icon && known && !known.has(meta.icon)) {
|
||||
err(`tabs/${e.name}: иконка "${meta.icon}" не найдена в карте ICONS`); clean = false;
|
||||
}
|
||||
}
|
||||
if (clean && ids.size) ok(`вкладки в порядке (${ids.size} шт.)`);
|
||||
}
|
||||
|
||||
// ─── 5. бэкенд ──────────────────────────────────────────────────────────────
|
||||
const serverTs = read(path.join(BACK, 'server.ts'));
|
||||
if (serverTs === null) {
|
||||
skip('server.ts не найден — панель, видимо, без своего бэкенда');
|
||||
} else {
|
||||
if (/from\s+'\.\/core'/.test(serverTs)) ok("server.ts импортирует './core'");
|
||||
else warn("server.ts не импортирует './core'", 'панель собирает сервер сама — это допустимо для панели-прокси');
|
||||
|
||||
const backPkgText = read(path.join(BACK, 'package.json'));
|
||||
if (backPkgText) {
|
||||
const deps = JSON.parse(backPkgText).dependencies ?? {};
|
||||
for (const dep of ['express', 'jsonwebtoken']) {
|
||||
if (deps[dep]) ok(`бэкенд: ${dep} в зависимостях`);
|
||||
else err(`backend/package.json: нет зависимости ${dep}`,
|
||||
'серверная половина ядра объявляет её peer-зависимостью');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 6. следы шаблона (предупреждения) ──────────────────────────────────────
|
||||
if (!IS_TEMPLATE) {
|
||||
const app = read(path.join(FRONT, 'src/App.tsx')) ?? '';
|
||||
if (/demo_token|demo_theme/.test(app)) {
|
||||
warn('storageKeys остались демонстрационными (demo_token/demo_theme)',
|
||||
'две панели на одном домене затрут друг другу токен и тему');
|
||||
}
|
||||
if (/name:\s*'Demo Panel'/.test(app)) warn("branding.name остался 'Demo Panel'");
|
||||
|
||||
// Заголовок вкладки браузера живёт отдельно от branding.name и виден до
|
||||
// того, как смонтируется React, — забывается чаще всего.
|
||||
const html = read(path.join(FRONT, 'index.html')) ?? '';
|
||||
if (/<title>Demo Panel<\/title>/.test(html)) warn("index.html: <title> остался 'Demo Panel'");
|
||||
|
||||
for (const [pkgPath, label] of [[path.join(FRONT, 'package.json'), 'frontend'],
|
||||
[path.join(BACK, 'package.json'), 'backend']]) {
|
||||
const t = read(pkgPath);
|
||||
if (t && /"ma7engine-example-/.test(t)) warn(`${label}/package.json: имя осталось от шаблона`);
|
||||
}
|
||||
}
|
||||
|
||||
// Половины ставят TypeScript и @types/node врозь — расхождение мажоров значит,
|
||||
// что tsc во фронте и в бэкенде проверяют код по разным правилам.
|
||||
{
|
||||
const major = (r) => (r ?? '').replace(/^[^\d]*/, '').split('.')[0];
|
||||
const f = frontPkg?.devDependencies ?? {};
|
||||
const bText = read(path.join(BACK, 'package.json'));
|
||||
const b = bText ? (JSON.parse(bText).devDependencies ?? {}) : {};
|
||||
for (const dep of ['typescript', '@types/node']) {
|
||||
if (f[dep] && b[dep] && major(f[dep]) !== major(b[dep])) {
|
||||
warn(`${dep} разъехался между половинами: ${f[dep]} и ${b[dep]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── вывод ──────────────────────────────────────────────────────────────────
|
||||
const rel = path.relative(process.cwd(), PANEL) || '.';
|
||||
console.log(`doctor: ${rel}${IS_TEMPLATE ? ' (шаблон ma7engine)' : ''}\n`);
|
||||
|
||||
for (const m of oks) console.log(` ok ${m}`);
|
||||
for (const m of skips) console.log(` — ${m}`);
|
||||
for (const m of warns) console.log(` warn ${m}`);
|
||||
for (const m of errors) console.log(` ОШИБКА ${m}`);
|
||||
|
||||
console.log();
|
||||
if (errors.length) {
|
||||
console.log(`doctor: ошибок ${errors.length}, предупреждений ${warns.length}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(warns.length
|
||||
? `doctor: ошибок нет, предупреждений ${warns.length}`
|
||||
: 'doctor: панель соответствует стандарту');
|
||||
Executable
+236
@@ -0,0 +1,236 @@
|
||||
#!/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`);
|
||||
Executable
+116
@@ -0,0 +1,116 @@
|
||||
#!/bin/sh
|
||||
# Обновляет вендоренную копию ядра (ma7engine) в панели:
|
||||
# ma7engine/core/browser → <панель>/frontend/src/core (браузерная половина)
|
||||
# ma7engine/core/server → <панель>/backend/core (серверная половина)
|
||||
#
|
||||
# Панель из одной папки, где сервер лежит рядом с фронтом (server.ts в корне,
|
||||
# как в awg-ui), получает серверную часть в <панель>/core.
|
||||
#
|
||||
# Зачем копия, а не алиас прямо на соседнюю папку: контекст сборки образа
|
||||
# панели — её собственный каталог (COPY frontend/ ./ в Dockerfile), и путь,
|
||||
# уходящий выше него, внутри контейнера не разрешится. При копии пути
|
||||
# ./src/core и ./core одинаковы в деве, в tsc --noEmit и в Docker, и Dockerfile
|
||||
# трогать не нужно вообще.
|
||||
#
|
||||
# Файл кладётся в репозиторий потребителя (frontend/scripts/), потому что
|
||||
# вызывать его нужно и тогда, когда ma7engine рядом нет.
|
||||
#
|
||||
# Использование: npm run update (или sh scripts/update.sh)
|
||||
# Где искать ядро: $MA7ENGINE, иначе перебор кандидатов ниже — первый
|
||||
# существующий выигрывает, выбранный путь печатается.
|
||||
set -eu
|
||||
|
||||
abspath() { (CDPATH= cd -- "$1" 2>/dev/null && pwd); }
|
||||
|
||||
here=$(abspath "$(dirname -- "$0")")
|
||||
app=$(dirname "$here") # .../<панель>/frontend или корень панели из одной папки
|
||||
panel=$(dirname "$app") # .../<панель> или каталог, где лежат панели
|
||||
|
||||
# Ядро может лежать где угодно относительно панели: рядом с продуктовым
|
||||
# репозиторием, рядом с остальными панелями или внутри самого репозитория.
|
||||
# Перебираем, вместо того чтобы зашивать одну раскладку — переезд ядра иначе
|
||||
# молча уводит синхронизацию на старую копию.
|
||||
#
|
||||
# Порядок — от ближнего к дальнему: панель, у которой ядро лежит рядом, не
|
||||
# должна утащить копию из каталога уровнем выше (такая копия обычно старая —
|
||||
# именно от неё и уезжает синхронизация молча).
|
||||
CANDIDATES="
|
||||
$app/../ma7engine
|
||||
$app/../../ma7engine
|
||||
$app/../../../ma7engine
|
||||
$app/../../../admins/ma7engine
|
||||
"
|
||||
|
||||
find_engine() {
|
||||
if [ -n "${MA7ENGINE:-}" ]; then
|
||||
if [ ! -d "$MA7ENGINE/core/browser" ]; then
|
||||
echo "update: в MA7ENGINE=$MA7ENGINE нет каталога core/browser" >&2
|
||||
exit 1
|
||||
fi
|
||||
abspath "$MA7ENGINE"
|
||||
return
|
||||
fi
|
||||
for candidate in $CANDIDATES; do
|
||||
if [ -d "$candidate/core/browser" ]; then
|
||||
abspath "$candidate"
|
||||
return
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Куда класть серверную половину. Панели без своего сервера не получают её вовсе.
|
||||
server_target() {
|
||||
if [ -d "$panel/backend" ]; then echo "$panel/backend/core"
|
||||
elif [ -f "$app/server.ts" ]; then echo "$app/core"
|
||||
fi
|
||||
}
|
||||
|
||||
# Скрипт обязан запускаться из фронта панели: $app определяется как каталог
|
||||
# уровнем выше scripts/, и запущенный не оттуда он молча создаёт постороннюю
|
||||
# src/core рядом с чем угодно. index.html — маркер vite-фронта.
|
||||
if [ ! -f "$app/index.html" ]; then
|
||||
echo "update: $app не похож на фронт панели (нет index.html)." >&2
|
||||
echo " Скрипт запускается из <панель>/frontend: npm run update" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
front_to=$app/src/core
|
||||
server_to=$(server_target)
|
||||
|
||||
engine=$(find_engine)
|
||||
|
||||
if [ -z "$engine" ]; then
|
||||
# В контейнере ядра нет — оно уже приехало внутри src/core (и backend/core)
|
||||
# вместе с остальными исходниками. Это норма, а не ошибка сборки.
|
||||
if [ -d "$front_to" ] || { [ -n "$server_to" ] && [ -d "$server_to" ]; }; then
|
||||
echo "update: ma7engine рядом не найден, использую уже синхронизированные копии"
|
||||
exit 0
|
||||
fi
|
||||
echo "update: ma7engine не найден ни в одном из мест:" >&2
|
||||
for candidate in $CANDIDATES; do echo " $candidate" >&2; done
|
||||
echo " Укажи путь через MA7ENGINE=/путь/к/ma7engine" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "update: ядро — $engine"
|
||||
|
||||
copy() { # copy <откуда> <куда> <подпись>
|
||||
[ -d "$1" ] || return 0
|
||||
rm -rf "$2"
|
||||
mkdir -p "$(dirname "$2")"
|
||||
cp -R "$1" "$2"
|
||||
echo " $3: $1 → $2"
|
||||
}
|
||||
|
||||
copy "$engine/core/browser" "$front_to" "фронт"
|
||||
if [ -n "$server_to" ]; then
|
||||
copy "$engine/core/server" "$server_to" "сервер"
|
||||
fi
|
||||
|
||||
# Доктор едет вместе с ядром: проверки в панели должны быть те же, что в ядре,
|
||||
# иначе панель отчитывается о соответствии по устаревшему списку. Кладётся
|
||||
# рядом с этим скриптом — оттуда его зовёт `npm run doctor`.
|
||||
if [ -f "$engine/scripts/doctor.mjs" ]; then
|
||||
cp "$engine/scripts/doctor.mjs" "$here/doctor.mjs"
|
||||
echo " доктор: $engine/scripts/doctor.mjs → $here/doctor.mjs"
|
||||
fi
|
||||
Reference in New Issue
Block a user