diff --git a/.env.example b/.env.example index 4dba4e8..233d707 100644 --- a/.env.example +++ b/.env.example @@ -27,9 +27,10 @@ SMTP_PASS= SMTP_FROM=shop@example.com # Обновление из админки (/admin/system) -# SHOP_ROOT=/opt/shop +# SHOP_ROOT=/opt/shop/shop10 # ADMIN_UPDATE_ENABLED=1 # ADMIN_UPDATE_USE_SUDO=1 +# SHOP_GIT_USER=root # PostgreSQL 17 (одна строка или отдельные переменные) DATABASE_URL=postgresql://shop:shop@127.0.0.1:5432/shop diff --git a/scripts/admin-web-update.sh b/scripts/admin-web-update.sh index cfc5c14..00001d4 100644 --- a/scripts/admin-web-update.sh +++ b/scripts/admin-web-update.sh @@ -1,37 +1,41 @@ #!/bin/bash # Обновление кода из админки (git pull + npm + перезапуск shop) # Запуск: bash scripts/admin-web-update.sh -# С www-data часто нужен sudoers: NOPASSWD на этот скрипт (ADMIN_UPDATE_USE_SUDO=1) +# С www-data: ADMIN_UPDATE_USE_SUDO=1 + sudoers NOPASSWD на этот скрипт set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=shop-root.sh source "$SCRIPT_DIR/shop-root.sh" -RUN_USER="${SHOP_RUN_USER:-www-data}" PORT="${PORT:-3000}" +REPO_OWNER="${SHOP_GIT_USER:-$(stat -c '%U' "$SHOP_ROOT/.git" 2>/dev/null || stat -c '%U' "$SHOP_ROOT" 2>/dev/null || echo root)}" -run_in_shop() { +ensure_git_safe() { + local user="$1" + if [ -z "$user" ]; then return; fi + if [ "$(id -u)" -eq 0 ]; then + sudo -u "$user" git config --global --add safe.directory "$SHOP_ROOT" 2>/dev/null || true + else + git config --global --add safe.directory "$SHOP_ROOT" 2>/dev/null || true + fi +} + +run_as_owner() { local cmd="$1" - if [ "$(id -u)" -eq 0 ] && [ "$(whoami)" != "$RUN_USER" ]; then - sudo -u "$RUN_USER" env SHOP_ROOT="$SHOP_ROOT" bash -c "cd \"$SHOP_ROOT\" && $cmd" + if [ "$(id -u)" -eq 0 ] && [ "$(whoami)" != "$REPO_OWNER" ]; then + sudo -u "$REPO_OWNER" env SHOP_ROOT="$SHOP_ROOT" bash -c "cd \"$SHOP_ROOT\" && $cmd" else bash -c "cd \"$SHOP_ROOT\" && $cmd" fi } -ensure_git_safe() { - git config --global --add safe.directory "$SHOP_ROOT" 2>/dev/null || true - if [ "$(id -u)" -eq 0 ] && id "$RUN_USER" &>/dev/null; then - sudo -u "$RUN_USER" git config --global --add safe.directory "$SHOP_ROOT" 2>/dev/null || true - fi -} - -ensure_git_safe +ensure_git_safe "$REPO_OWNER" +ensure_git_safe "$(whoami)" echo "=== Обновление Shop (админка) ===" echo "Каталог: $SHOP_ROOT" -echo "Пользователь для git/npm: $(id -un 2>/dev/null || echo ?)" +echo "Git от пользователя: $REPO_OWNER (текущий: $(whoami))" if [ ! -d .git ]; then echo "Ошибка: нет .git в $SHOP_ROOT" @@ -40,19 +44,19 @@ fi echo "" echo "Текущая версия:" -git log -1 --oneline || true +run_as_owner "git log -1 --oneline" echo "" echo "--- git sync ---" -run_in_shop "bash scripts/git-sync.sh" +run_as_owner "bash scripts/git-sync.sh" echo "" echo "--- npm install ---" -run_in_shop "npm install --omit=dev" +run_as_owner "npm install --omit=dev" echo "" echo "Новая версия:" -git log -1 --oneline +run_as_owner "git log -1 --oneline" echo "" echo "--- перезапуск shop ---" @@ -69,7 +73,7 @@ if command -v systemctl >/dev/null 2>&1 && systemctl cat shop.service >/dev/null exit 1 fi else - echo "INFO: служба shop не найдена — перезапустите Node вручную (pm2/npm start)" + echo "INFO: служба shop не найдена — перезапустите Node вручную" fi echo "" diff --git a/src/services/git-deploy.js b/src/services/git-deploy.js index 8b1d5df..f01e883 100644 --- a/src/services/git-deploy.js +++ b/src/services/git-deploy.js @@ -41,38 +41,99 @@ function gitEnv(root) { }; } -/** Git 2.35+: репозиторий с другим владельцем (www-data vs root) */ -async function ensureSafeDirectory(root) { - const resolved = path.resolve(root); - const home = process.env.HOME || '/var/www'; +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 { - await execFileAsync('git', ['config', '--global', '--add', 'safe.directory', resolved], { - timeout: 15000, - env: { ...process.env, HOME: home }, - }); + 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 { - // глобальный config может быть недоступен — используем -c в gitCmd + return null; } } -async function gitCmd(args, cwd) { - const root = path.resolve(cwd); - const { stdout, stderr } = await execFileAsync( - 'git', - ['-c', `safe.directory=${root}`, ...args], - { - cwd: root, - maxBuffer: 1024 * 1024, - timeout: 90000, - env: gitEnv(root), +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 { @@ -86,6 +147,7 @@ async function getGitInfo({ fetchRemote = false } = {}) { const info = { available: true, shopRoot: root, + repoOwner, packageVersion: pkg?.version || null, branch: null, commit: null, @@ -93,13 +155,14 @@ async function getGitInfo({ fetchRemote = false } = {}) { commitSubject: null, commitDate: null, dirty: false, + dirtyHint: null, behind: null, updateEnabled: isUpdateEnabled(), platform: process.platform, }; try { - await ensureSafeDirectory(root); + await ensureSafeDirectory(root, repoOwner); info.branch = await gitCmd(['branch', '--show-current'], root); if (!info.branch) { info.branch = '(detached)'; @@ -109,8 +172,17 @@ async function getGitInfo({ fetchRemote = false } = {}) { 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); - const status = await gitCmd(['status', '--porcelain'], root); - info.dirty = status.length > 0; + 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; @@ -119,14 +191,12 @@ async function getGitInfo({ fetchRemote = false } = {}) { if (fetchRemote) { try { - await gitCmd(['fetch', 'origin'], root); - const behind = await gitCmd( - ['rev-list', '--count', 'HEAD..origin/main'], - root - ); - info.behind = parseInt(behind, 10) || 0; + 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.`; + } } } @@ -179,4 +249,5 @@ module.exports = { isUpdateEnabled, getGitInfo, runDeployUpdate, + getRepoOwner, }; diff --git a/src/views/admin/system.ejs b/src/views/admin/system.ejs index b3fd921..83a8fd3 100644 --- a/src/views/admin/system.ejs +++ b/src/views/admin/system.ejs @@ -28,6 +28,10 @@
v<%= git.packageVersion || '?' %>
Каталог
<%= git.shopRoot %>
+ <% if (git.repoOwner) { %> +
Владелец .git
+
<%= git.repoOwner %> (git pull выполняется от этого пользователя)
+ <% } %>
Ветка
<%= git.branch %>
Коммит
@@ -38,7 +42,10 @@ <% if (git.dirty) { %>
Состояние
-
Есть незакоммиченные изменения
+
+ Есть незакоммиченные изменения + <% if (git.dirtyHint) { %>

<%= git.dirtyHint %>

<% } %> +
<% } %> <% if (git.behind != null) { %>
На origin/main