Files
ma7engine/scripts/doctor.mjs
T
maenekoandClaude Opus 5 7977ea6738 release.sh: переставлять split-ветки принудительно
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
2026-08-26 20:45:08 +03:00

380 lines
23 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
// Проверяет, что панель собрана и осталась по стандарту 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 { execFileSync } from 'node:child_process';
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'));
// Ядро попадает в панель одним из двух способов, и проверки у них разные:
//
// subtree — панель лежит в git-репозитории продукта, ядро подтянуто
// `git subtree add` и потому отслеживается git. Обновляется
// `git subtree pull`; update.sh здесь не нужен и вреден — он
// сносит каталог целиком и затёр бы поддерево.
//
// copy — ядро раскладывает update.sh из соседнего ma7engine. Так устроен
// сам шаблон, и так живёт панель вне git.
function coreIsTracked(dir) {
try {
const out = execFileSync('git', ['ls-files', '--', dir],
{ cwd: PANEL, stdio: ['ignore', 'pipe', 'ignore'] });
return out.toString().trim() !== '';
} catch { return false; }
}
const MODE = !IS_TEMPLATE && coreIsTracked(path.join(FRONT, 'src/core')) ? 'subtree' : 'copy';
// ─── накопитель результатов ─────────────────────────────────────────────────
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 (MODE === 'subtree') {
if (fs.existsSync(updateSh)) {
err('в subtree-панели остался frontend/scripts/update.sh',
'он сносит каталог ядра целиком (rm -rf) и затрёт подтянутое поддерево — ' +
'обновление здесь делается git subtree pull');
} else ok('ядро приходит через git subtree (update.sh не нужен)');
} else 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 (MODE === 'subtree') {
const stale = ['update', 'predev', 'prebuild'].filter(h => /update/.test(s[h] ?? ''));
if (stale.length) err(`frontend/package.json: остались хуки копирования (${stale.join(', ')})`,
'ядро приходит поддеревом — эти скрипты его затрут');
else ok('в package.json нет хуков копирования ядра');
} else {
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)' : ` (ядро: ${MODE})`}\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: панель соответствует стандарту');