const { execFile, spawn } = require('child_process'); const fs = require('fs'); const path = require('path'); const { promisify } = require('util'); const execFileAsync = promisify(execFile); function resolveShopRoot() { const candidates = [ process.env.SHOP_ROOT, path.resolve(__dirname, '../..'), '/opt/shop', ].filter(Boolean); for (const dir of candidates) { const resolved = path.resolve(dir); if (fs.existsSync(path.join(resolved, 'package.json'))) { return resolved; } } return null; } function isUpdateEnabled() { if (process.env.ADMIN_UPDATE_ENABLED === '0') return false; const root = resolveShopRoot(); if (!root) return false; if (!fs.existsSync(path.join(root, '.git'))) return false; if (process.platform === 'win32') return false; return fs.existsSync(path.join(root, 'scripts', 'admin-web-update.sh')); } function gitEnv(root) { const resolved = path.resolve(root); return { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'safe.directory', GIT_CONFIG_VALUE_0: resolved, }; } async function getRepoOwner(root) { if (process.env.SHOP_GIT_USER) { return process.env.SHOP_GIT_USER.trim(); } if (process.platform === 'win32') return null; const gitPath = path.join(root, '.git'); try { const target = fs.statSync(gitPath).isDirectory() ? gitPath : root; const { stdout } = await execFileAsync('stat', ['-c', '%U', target], { timeout: 5000 }); const user = stdout.trim(); return user || null; } catch { return null; } } async function ensureSafeDirectory(root, user) { const resolved = path.resolve(root); const targets = [{ home: process.env.HOME || '/var/www' }]; if (user) { try { const { stdout } = await execFileAsync('getent', ['passwd', user], { timeout: 5000 }); const home = stdout.split(':')[5]; if (home) targets.push({ home }); } catch { /* ignore */ } } for (const { home } of targets) { try { await execFileAsync('git', ['config', '--global', '--add', 'safe.directory', resolved], { timeout: 15000, env: { ...process.env, HOME: home }, }); } catch { /* ignore */ } } } async function execGit(args, cwd, runAs) { const root = path.resolve(cwd); const gitArgs = ['-c', `safe.directory=${root}`, ...args]; const opts = { cwd: root, maxBuffer: 1024 * 1024, timeout: 120000, env: gitEnv(root), }; if (runAs && process.env.ADMIN_UPDATE_USE_SUDO === '1') { const { stdout, stderr } = await execFileAsync('sudo', ['-n', '-u', runAs, 'git', ...gitArgs], opts); return `${stdout}${stderr}`.trim(); } const { stdout, stderr } = await execFileAsync('git', gitArgs, opts); return `${stdout}${stderr}`.trim(); } async function gitCmd(args, cwd, { needsWrite = false } = {}) { const root = path.resolve(cwd); const owner = await getRepoOwner(root); try { return await execGit(args, root, null); } catch (err) { const msg = err.message || ''; const denied = /permission denied|EACCES|FETCH_HEAD/i.test(msg); if ((needsWrite || denied) && owner) { await ensureSafeDirectory(root, owner); return execGit(args, root, owner); } throw err; } } /** Сколько коммитов на origin/main без записи в .git (без fetch) */ async function getCommitsBehind(root) { const localHead = (await gitCmd(['rev-parse', 'HEAD'], root)).split('\n')[0].trim(); const remoteOut = await gitCmd(['ls-remote', 'origin', 'refs/heads/main'], root); const remoteSha = remoteOut.split(/\s+/)[0]?.trim(); if (!remoteSha) { throw new Error('Не найден refs/heads/main на origin'); } if (remoteSha === localHead) return 0; const count = await gitCmd(['rev-list', '--count', `${localHead}..${remoteSha}`], root); return parseInt(count, 10) || 0; } async function getGitInfo({ fetchRemote = false } = {}) { const root = resolveShopRoot(); const pkg = root ? JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) : null; const repoOwner = root ? await getRepoOwner(root) : null; if (!root || !fs.existsSync(path.join(root, '.git'))) { return { available: false, packageVersion: pkg?.version || null, shopRoot: root, reason: 'Каталог не является git-репозиторием. Задайте SHOP_ROOT в .env.', }; } const info = { available: true, shopRoot: root, repoOwner, packageVersion: pkg?.version || null, branch: null, commit: null, commitShort: null, commitSubject: null, commitDate: null, dirty: false, dirtyHint: null, behind: null, updateEnabled: isUpdateEnabled(), platform: process.platform, }; try { await ensureSafeDirectory(root, repoOwner); info.branch = await gitCmd(['branch', '--show-current'], root); if (!info.branch) { info.branch = '(detached)'; info.commitShort = await gitCmd(['rev-parse', '--short', 'HEAD'], root); } info.commit = await gitCmd(['rev-parse', 'HEAD'], root); info.commitShort = info.commitShort || (await gitCmd(['rev-parse', '--short', 'HEAD'], root)); info.commitSubject = await gitCmd(['log', '-1', '--pretty=%s'], root); info.commitDate = await gitCmd(['log', '-1', '--pretty=%ci'], root); try { const status = await gitCmd(['status', '--porcelain'], root); info.dirty = status.length > 0; if (info.dirty) { info.dirtyHint = 'На сервере есть локальные изменения. Обновление может их перезаписать; при ошибке выполните git stash или git reset --hard с машины администратора.'; } } catch { info.dirty = null; info.dirtyHint = 'Не удалось прочитать статус (права на .git).'; } } catch (err) { info.available = false; info.reason = err.message; return info; } if (fetchRemote) { try { info.behind = await getCommitsBehind(root); } catch (err) { info.fetchError = err.message; if (repoOwner) { info.fetchError += ` Владелец репозитория: ${repoOwner}. Задайте SHOP_GIT_USER=${repoOwner} и ADMIN_UPDATE_USE_SUDO=1 в .env.`; } } } return info; } function runDeployUpdate() { const root = resolveShopRoot(); const scriptPath = path.join(root, 'scripts', 'admin-web-update.sh'); return new Promise((resolve) => { const useSudo = process.env.ADMIN_UPDATE_USE_SUDO === '1'; const cmd = useSudo ? 'sudo' : 'bash'; const args = useSudo ? ['-n', scriptPath] : [scriptPath]; const child = spawn(cmd, args, { cwd: root, env: { ...gitEnv(root), SHOP_ROOT: root }, timeout: 300000, }); let output = ''; child.stdout.on('data', (chunk) => { output += chunk.toString(); }); child.stderr.on('data', (chunk) => { output += chunk.toString(); }); child.on('error', (err) => { resolve({ ok: false, code: -1, output: `${output}\n${err.message}`.trim(), }); }); child.on('close', (code) => { resolve({ ok: code === 0, code: code ?? 1, output: output.trim() || '(нет вывода)', }); }); }); } module.exports = { resolveShopRoot, isUpdateEnabled, getGitInfo, runDeployUpdate, getRepoOwner, };