#!/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 (/