db6ab9a701
Co-authored-by: Cursor <cursoragent@cursor.com>
301 lines
8.6 KiB
JavaScript
301 lines
8.6 KiB
JavaScript
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 через ls-remote + merge-base (без fetch, без записи в .git).
|
||
*/
|
||
async function getRemoteSyncStatus(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');
|
||
}
|
||
|
||
const remoteShort = (
|
||
await gitCmd(['rev-parse', '--short', remoteSha], root)
|
||
).split('\n')[0].trim();
|
||
|
||
if (remoteSha === localHead) {
|
||
return { behind: 0, ahead: 0, diverged: false, remoteShort, remoteSha };
|
||
}
|
||
|
||
let mergeBase;
|
||
try {
|
||
mergeBase = (await gitCmd(['merge-base', localHead, remoteSha], root)).split('\n')[0].trim();
|
||
} catch {
|
||
throw new Error(
|
||
'Не удалось сравнить с origin/main (нет общего предка). Выполните на сервере: git fetch && git reset --hard origin/main'
|
||
);
|
||
}
|
||
|
||
if (!mergeBase) {
|
||
throw new Error('Нет общего предка с origin/main');
|
||
}
|
||
|
||
let behind = 0;
|
||
let ahead = 0;
|
||
|
||
if (mergeBase !== remoteSha) {
|
||
const behindStr = await gitCmd(['rev-list', '--count', `${mergeBase}..${remoteSha}`], root);
|
||
behind = parseInt(behindStr, 10) || 0;
|
||
}
|
||
if (mergeBase !== localHead) {
|
||
const aheadStr = await gitCmd(['rev-list', '--count', `${mergeBase}..${localHead}`], root);
|
||
ahead = parseInt(aheadStr, 10) || 0;
|
||
}
|
||
|
||
return {
|
||
behind,
|
||
ahead,
|
||
diverged: behind > 0 && ahead > 0,
|
||
remoteShort,
|
||
remoteSha,
|
||
};
|
||
}
|
||
|
||
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,
|
||
ahead: null,
|
||
diverged: false,
|
||
remoteShort: 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 {
|
||
const sync = await getRemoteSyncStatus(root);
|
||
info.behind = sync.behind;
|
||
info.ahead = sync.ahead;
|
||
info.diverged = sync.diverged;
|
||
info.remoteShort = sync.remoteShort;
|
||
} 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,
|
||
};
|