fix: git в админке — ls-remote и pull от владельца репозитория
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+2
-1
@@ -27,9 +27,10 @@ SMTP_PASS=
|
|||||||
SMTP_FROM=shop@example.com
|
SMTP_FROM=shop@example.com
|
||||||
|
|
||||||
# Обновление из админки (/admin/system)
|
# Обновление из админки (/admin/system)
|
||||||
# SHOP_ROOT=/opt/shop
|
# SHOP_ROOT=/opt/shop/shop10
|
||||||
# ADMIN_UPDATE_ENABLED=1
|
# ADMIN_UPDATE_ENABLED=1
|
||||||
# ADMIN_UPDATE_USE_SUDO=1
|
# ADMIN_UPDATE_USE_SUDO=1
|
||||||
|
# SHOP_GIT_USER=root
|
||||||
|
|
||||||
# PostgreSQL 17 (одна строка или отдельные переменные)
|
# PostgreSQL 17 (одна строка или отдельные переменные)
|
||||||
DATABASE_URL=postgresql://shop:shop@127.0.0.1:5432/shop
|
DATABASE_URL=postgresql://shop:shop@127.0.0.1:5432/shop
|
||||||
|
|||||||
+23
-19
@@ -1,37 +1,41 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Обновление кода из админки (git pull + npm + перезапуск shop)
|
# Обновление кода из админки (git pull + npm + перезапуск shop)
|
||||||
# Запуск: bash scripts/admin-web-update.sh
|
# Запуск: 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
|
set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
# shellcheck source=shop-root.sh
|
# shellcheck source=shop-root.sh
|
||||||
source "$SCRIPT_DIR/shop-root.sh"
|
source "$SCRIPT_DIR/shop-root.sh"
|
||||||
|
|
||||||
RUN_USER="${SHOP_RUN_USER:-www-data}"
|
|
||||||
PORT="${PORT:-3000}"
|
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"
|
local cmd="$1"
|
||||||
if [ "$(id -u)" -eq 0 ] && [ "$(whoami)" != "$RUN_USER" ]; then
|
if [ "$(id -u)" -eq 0 ] && [ "$(whoami)" != "$REPO_OWNER" ]; then
|
||||||
sudo -u "$RUN_USER" env SHOP_ROOT="$SHOP_ROOT" bash -c "cd \"$SHOP_ROOT\" && $cmd"
|
sudo -u "$REPO_OWNER" env SHOP_ROOT="$SHOP_ROOT" bash -c "cd \"$SHOP_ROOT\" && $cmd"
|
||||||
else
|
else
|
||||||
bash -c "cd \"$SHOP_ROOT\" && $cmd"
|
bash -c "cd \"$SHOP_ROOT\" && $cmd"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
ensure_git_safe() {
|
ensure_git_safe "$REPO_OWNER"
|
||||||
git config --global --add safe.directory "$SHOP_ROOT" 2>/dev/null || true
|
ensure_git_safe "$(whoami)"
|
||||||
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
|
|
||||||
|
|
||||||
echo "=== Обновление Shop (админка) ==="
|
echo "=== Обновление Shop (админка) ==="
|
||||||
echo "Каталог: $SHOP_ROOT"
|
echo "Каталог: $SHOP_ROOT"
|
||||||
echo "Пользователь для git/npm: $(id -un 2>/dev/null || echo ?)"
|
echo "Git от пользователя: $REPO_OWNER (текущий: $(whoami))"
|
||||||
|
|
||||||
if [ ! -d .git ]; then
|
if [ ! -d .git ]; then
|
||||||
echo "Ошибка: нет .git в $SHOP_ROOT"
|
echo "Ошибка: нет .git в $SHOP_ROOT"
|
||||||
@@ -40,19 +44,19 @@ fi
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Текущая версия:"
|
echo "Текущая версия:"
|
||||||
git log -1 --oneline || true
|
run_as_owner "git log -1 --oneline"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "--- git sync ---"
|
echo "--- git sync ---"
|
||||||
run_in_shop "bash scripts/git-sync.sh"
|
run_as_owner "bash scripts/git-sync.sh"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "--- npm install ---"
|
echo "--- npm install ---"
|
||||||
run_in_shop "npm install --omit=dev"
|
run_as_owner "npm install --omit=dev"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Новая версия:"
|
echo "Новая версия:"
|
||||||
git log -1 --oneline
|
run_as_owner "git log -1 --oneline"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "--- перезапуск shop ---"
|
echo "--- перезапуск shop ---"
|
||||||
@@ -69,7 +73,7 @@ if command -v systemctl >/dev/null 2>&1 && systemctl cat shop.service >/dev/null
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo "INFO: служба shop не найдена — перезапустите Node вручную (pm2/npm start)"
|
echo "INFO: служба shop не найдена — перезапустите Node вручную"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
+100
-29
@@ -41,38 +41,99 @@ function gitEnv(root) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Git 2.35+: репозиторий с другим владельцем (www-data vs root) */
|
async function getRepoOwner(root) {
|
||||||
async function ensureSafeDirectory(root) {
|
if (process.env.SHOP_GIT_USER) {
|
||||||
const resolved = path.resolve(root);
|
return process.env.SHOP_GIT_USER.trim();
|
||||||
const home = process.env.HOME || '/var/www';
|
}
|
||||||
|
if (process.platform === 'win32') return null;
|
||||||
|
const gitPath = path.join(root, '.git');
|
||||||
try {
|
try {
|
||||||
await execFileAsync('git', ['config', '--global', '--add', 'safe.directory', resolved], {
|
const target = fs.statSync(gitPath).isDirectory() ? gitPath : root;
|
||||||
timeout: 15000,
|
const { stdout } = await execFileAsync('stat', ['-c', '%U', target], { timeout: 5000 });
|
||||||
env: { ...process.env, HOME: home },
|
const user = stdout.trim();
|
||||||
});
|
return user || null;
|
||||||
} catch {
|
} catch {
|
||||||
// глобальный config может быть недоступен — используем -c в gitCmd
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function gitCmd(args, cwd) {
|
async function ensureSafeDirectory(root, user) {
|
||||||
const root = path.resolve(cwd);
|
const resolved = path.resolve(root);
|
||||||
const { stdout, stderr } = await execFileAsync(
|
const targets = [{ home: process.env.HOME || '/var/www' }];
|
||||||
'git',
|
if (user) {
|
||||||
['-c', `safe.directory=${root}`, ...args],
|
try {
|
||||||
{
|
const { stdout } = await execFileAsync('getent', ['passwd', user], { timeout: 5000 });
|
||||||
cwd: root,
|
const home = stdout.split(':')[5];
|
||||||
maxBuffer: 1024 * 1024,
|
if (home) targets.push({ home });
|
||||||
timeout: 90000,
|
} catch {
|
||||||
env: gitEnv(root),
|
/* 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();
|
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 } = {}) {
|
async function getGitInfo({ fetchRemote = false } = {}) {
|
||||||
const root = resolveShopRoot();
|
const root = resolveShopRoot();
|
||||||
const pkg = root ? JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) : null;
|
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'))) {
|
if (!root || !fs.existsSync(path.join(root, '.git'))) {
|
||||||
return {
|
return {
|
||||||
@@ -86,6 +147,7 @@ async function getGitInfo({ fetchRemote = false } = {}) {
|
|||||||
const info = {
|
const info = {
|
||||||
available: true,
|
available: true,
|
||||||
shopRoot: root,
|
shopRoot: root,
|
||||||
|
repoOwner,
|
||||||
packageVersion: pkg?.version || null,
|
packageVersion: pkg?.version || null,
|
||||||
branch: null,
|
branch: null,
|
||||||
commit: null,
|
commit: null,
|
||||||
@@ -93,13 +155,14 @@ async function getGitInfo({ fetchRemote = false } = {}) {
|
|||||||
commitSubject: null,
|
commitSubject: null,
|
||||||
commitDate: null,
|
commitDate: null,
|
||||||
dirty: false,
|
dirty: false,
|
||||||
|
dirtyHint: null,
|
||||||
behind: null,
|
behind: null,
|
||||||
updateEnabled: isUpdateEnabled(),
|
updateEnabled: isUpdateEnabled(),
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ensureSafeDirectory(root);
|
await ensureSafeDirectory(root, repoOwner);
|
||||||
info.branch = await gitCmd(['branch', '--show-current'], root);
|
info.branch = await gitCmd(['branch', '--show-current'], root);
|
||||||
if (!info.branch) {
|
if (!info.branch) {
|
||||||
info.branch = '(detached)';
|
info.branch = '(detached)';
|
||||||
@@ -109,8 +172,17 @@ async function getGitInfo({ fetchRemote = false } = {}) {
|
|||||||
info.commitShort = info.commitShort || (await gitCmd(['rev-parse', '--short', 'HEAD'], root));
|
info.commitShort = info.commitShort || (await gitCmd(['rev-parse', '--short', 'HEAD'], root));
|
||||||
info.commitSubject = await gitCmd(['log', '-1', '--pretty=%s'], root);
|
info.commitSubject = await gitCmd(['log', '-1', '--pretty=%s'], root);
|
||||||
info.commitDate = await gitCmd(['log', '-1', '--pretty=%ci'], root);
|
info.commitDate = await gitCmd(['log', '-1', '--pretty=%ci'], root);
|
||||||
const status = await gitCmd(['status', '--porcelain'], root);
|
try {
|
||||||
info.dirty = status.length > 0;
|
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) {
|
} catch (err) {
|
||||||
info.available = false;
|
info.available = false;
|
||||||
info.reason = err.message;
|
info.reason = err.message;
|
||||||
@@ -119,14 +191,12 @@ async function getGitInfo({ fetchRemote = false } = {}) {
|
|||||||
|
|
||||||
if (fetchRemote) {
|
if (fetchRemote) {
|
||||||
try {
|
try {
|
||||||
await gitCmd(['fetch', 'origin'], root);
|
info.behind = await getCommitsBehind(root);
|
||||||
const behind = await gitCmd(
|
|
||||||
['rev-list', '--count', 'HEAD..origin/main'],
|
|
||||||
root
|
|
||||||
);
|
|
||||||
info.behind = parseInt(behind, 10) || 0;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
info.fetchError = err.message;
|
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,
|
isUpdateEnabled,
|
||||||
getGitInfo,
|
getGitInfo,
|
||||||
runDeployUpdate,
|
runDeployUpdate,
|
||||||
|
getRepoOwner,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,6 +28,10 @@
|
|||||||
<dd><strong>v<%= git.packageVersion || '?' %></strong></dd>
|
<dd><strong>v<%= git.packageVersion || '?' %></strong></dd>
|
||||||
<dt>Каталог</dt>
|
<dt>Каталог</dt>
|
||||||
<dd><code class="admin-system__path"><%= git.shopRoot %></code></dd>
|
<dd><code class="admin-system__path"><%= git.shopRoot %></code></dd>
|
||||||
|
<% if (git.repoOwner) { %>
|
||||||
|
<dt>Владелец .git</dt>
|
||||||
|
<dd><code><%= git.repoOwner %></code> <span class="muted">(git pull выполняется от этого пользователя)</span></dd>
|
||||||
|
<% } %>
|
||||||
<dt>Ветка</dt>
|
<dt>Ветка</dt>
|
||||||
<dd><%= git.branch %></dd>
|
<dd><%= git.branch %></dd>
|
||||||
<dt>Коммит</dt>
|
<dt>Коммит</dt>
|
||||||
@@ -38,7 +42,10 @@
|
|||||||
</dd>
|
</dd>
|
||||||
<% if (git.dirty) { %>
|
<% if (git.dirty) { %>
|
||||||
<dt>Состояние</dt>
|
<dt>Состояние</dt>
|
||||||
<dd><span class="badge badge--warn">Есть незакоммиченные изменения</span></dd>
|
<dd>
|
||||||
|
<span class="badge badge--warn">Есть незакоммиченные изменения</span>
|
||||||
|
<% if (git.dirtyHint) { %><p class="muted" style="margin:0.35rem 0 0;font-size:0.85rem"><%= git.dirtyHint %></p><% } %>
|
||||||
|
</dd>
|
||||||
<% } %>
|
<% } %>
|
||||||
<% if (git.behind != null) { %>
|
<% if (git.behind != null) { %>
|
||||||
<dt>На origin/main</dt>
|
<dt>На origin/main</dt>
|
||||||
|
|||||||
Reference in New Issue
Block a user