Первый коммит существующего состояния. Ядро кладётся под git ради того, чтобы половины разъезжались в продуктовые репозитории через git subtree, а не копированием: subtree делает настоящий merge, хранит историю и умеет отдавать правки обратно. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LeBkZLiQrvdECf5nfgiMz
348 lines
21 KiB
JavaScript
Executable File
348 lines
21 KiB
JavaScript
Executable File
#!/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: панель соответствует стандарту');
|