mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 01:27:11 +00:00
feat(update): rewrite self-update system — commit-SHA based detection
- Replace release-based check (always 404) with commit-SHA tracking - Track deployed state via data/.update_sha file - Detect changes across all components (console, server, agent, scripts) - Show recent commits, component breakdown, and file counts - Auto-update console + script files, manual badge for Go server - Pre-update backup with SHA-tracked restore - Support npm install when package.json changes - Service restart for console (auto) and Go server (on demand) - 15 new i18n keys in EN/PL/ZH for update UI - CSS for commit list, component rows, rebuild warning
This commit is contained in:
+18
-3
@@ -2614,13 +2614,13 @@
|
||||
"title": "Software Updates",
|
||||
"desc": "Check for new versions and update the BetterDesk console from GitHub",
|
||||
"current_version": "Installed version",
|
||||
"latest_version": "Latest version",
|
||||
"latest_version": "Latest commit",
|
||||
"status": "Status",
|
||||
"check_now": "Check for updates",
|
||||
"checking": "Checking…",
|
||||
"up_to_date": "Up to date",
|
||||
"update_available": "Update available",
|
||||
"changelog": "Release Notes",
|
||||
"changelog": "Recent Commits",
|
||||
"no_changelog": "No release notes available",
|
||||
"files_changed": "Files changed",
|
||||
"files": "files",
|
||||
@@ -2642,6 +2642,21 @@
|
||||
"no_backups": "No update backups found",
|
||||
"restore": "Restore",
|
||||
"restore_confirm": "Are you sure you want to restore from this backup? Changed files will be overwritten.",
|
||||
"restore_success": "Backup restored successfully. Console is restarting…"
|
||||
"restore_success": "Backup restored successfully. Console is restarting…",
|
||||
"baseline_set": "Baseline set",
|
||||
"commits_behind": "commits behind",
|
||||
"total_files": "Total files",
|
||||
"applied": "Applied",
|
||||
"failed": "Failed",
|
||||
"skipped": "Skipped",
|
||||
"removed": "Removed",
|
||||
"auto": "Auto",
|
||||
"manual": "Manual",
|
||||
"component_console": "Web Console",
|
||||
"component_server": "Go Server",
|
||||
"component_agent": "Agent",
|
||||
"component_scripts": "Scripts & Docker",
|
||||
"component_other": "Other",
|
||||
"server_rebuild_warning": "Go Server source changed — binary rebuild required. Use the ALL-IN-ONE script or compile manually."
|
||||
}
|
||||
}
|
||||
|
||||
+18
-3
@@ -2614,13 +2614,13 @@
|
||||
"title": "Aktualizacje oprogramowania",
|
||||
"desc": "Sprawdź nowe wersje i zaktualizuj konsolę BetterDesk z repozytorium GitHub",
|
||||
"current_version": "Zainstalowana wersja",
|
||||
"latest_version": "Najnowsza wersja",
|
||||
"latest_version": "Najnowszy commit",
|
||||
"status": "Status",
|
||||
"check_now": "Sprawdź aktualizacje",
|
||||
"checking": "Sprawdzanie…",
|
||||
"up_to_date": "Aktualna wersja",
|
||||
"update_available": "Dostępna aktualizacja",
|
||||
"changelog": "Informacje o wydaniu",
|
||||
"changelog": "Ostatnie commity",
|
||||
"no_changelog": "Brak informacji o wydaniu",
|
||||
"files_changed": "Zmienione pliki",
|
||||
"files": "plików",
|
||||
@@ -2642,6 +2642,21 @@
|
||||
"no_backups": "Brak kopii zapasowych aktualizacji",
|
||||
"restore": "Przywróć",
|
||||
"restore_confirm": "Czy na pewno chcesz przywrócić z tej kopii zapasowej? Zmienione pliki zostaną nadpisane.",
|
||||
"restore_success": "Kopia zapasowa przywrócona pomyślnie. Konsola jest restartowana…"
|
||||
"restore_success": "Kopia zapasowa przywrócona pomyślnie. Konsola jest restartowana…",
|
||||
"baseline_set": "Punkt odniesienia ustawiony",
|
||||
"commits_behind": "commitów do tyłu",
|
||||
"total_files": "Łączna liczba plików",
|
||||
"applied": "Zastosowane",
|
||||
"failed": "Nieudane",
|
||||
"skipped": "Pominięte",
|
||||
"removed": "Usunięte",
|
||||
"auto": "Auto",
|
||||
"manual": "Ręcznie",
|
||||
"component_console": "Konsola Web",
|
||||
"component_server": "Serwer Go",
|
||||
"component_agent": "Agent",
|
||||
"component_scripts": "Skrypty i Docker",
|
||||
"component_other": "Inne",
|
||||
"server_rebuild_warning": "Źródła serwera Go uległy zmianie — wymagana rekompilacja. Użyj skryptu ALL-IN-ONE lub skompiluj ręcznie."
|
||||
}
|
||||
}
|
||||
|
||||
+18
-3
@@ -2621,13 +2621,13 @@
|
||||
"title": "Software Updates",
|
||||
"desc": "Check for new versions and update the BetterDesk console from GitHub",
|
||||
"current_version": "Installed version",
|
||||
"latest_version": "Latest version",
|
||||
"latest_version": "最新提交",
|
||||
"status": "Status",
|
||||
"check_now": "Check for updates",
|
||||
"checking": "Checking…",
|
||||
"up_to_date": "Up to date",
|
||||
"update_available": "Update available",
|
||||
"changelog": "Release Notes",
|
||||
"changelog": "最近提交",
|
||||
"no_changelog": "No release notes available",
|
||||
"files_changed": "Files changed",
|
||||
"files": "files",
|
||||
@@ -2649,6 +2649,21 @@
|
||||
"no_backups": "No update backups found",
|
||||
"restore": "Restore",
|
||||
"restore_confirm": "Are you sure you want to restore from this backup? Changed files will be overwritten.",
|
||||
"restore_success": "Backup restored successfully. Console is restarting…"
|
||||
"restore_success": "Backup restored successfully. Console is restarting…",
|
||||
"baseline_set": "基线已设置",
|
||||
"commits_behind": "个提交落后",
|
||||
"total_files": "总文件数",
|
||||
"applied": "已应用",
|
||||
"failed": "失败",
|
||||
"skipped": "跳过",
|
||||
"removed": "已删除",
|
||||
"auto": "自动",
|
||||
"manual": "手动",
|
||||
"component_console": "Web控制台",
|
||||
"component_server": "Go服务器",
|
||||
"component_agent": "代理程序",
|
||||
"component_scripts": "脚本和Docker",
|
||||
"component_other": "其他",
|
||||
"server_rebuild_warning": "Go服务器源代码已更改——需要重新编译。使用ALL-IN-ONE脚本或手动编译。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1348,3 +1348,102 @@
|
||||
.update-backup-info .text-muted {
|
||||
font-size: var(--font-size-xs, 0.75rem);
|
||||
}
|
||||
|
||||
/* commit list */
|
||||
.update-commits {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.update-commit-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-sm);
|
||||
padding: 4px 0;
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.update-commit-sha {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
background: var(--bg-tertiary, rgba(255,255,255,0.06));
|
||||
border-radius: 4px;
|
||||
color: var(--primary);
|
||||
font-family: var(--font-mono, 'Fira Code', monospace);
|
||||
}
|
||||
|
||||
.update-commit-msg {
|
||||
flex: 1;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.update-commit-meta {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted, var(--text-secondary));
|
||||
}
|
||||
|
||||
/* component breakdown */
|
||||
.update-components {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.update-component-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: 6px var(--space-sm);
|
||||
background: var(--bg-tertiary, rgba(255,255,255,0.03));
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
}
|
||||
|
||||
.update-component-row .material-icons {
|
||||
font-size: 18px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.update-component-label {
|
||||
flex: 1;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.update-component-count {
|
||||
font-size: var(--font-size-xs, 0.75rem);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.badge-sm {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
|
||||
/* server rebuild warning */
|
||||
.update-warning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: rgba(255, 171, 0, 0.08);
|
||||
border: 1px solid rgba(255, 171, 0, 0.25);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
color: #ffab00;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.update-warning .material-icons {
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -955,7 +955,7 @@
|
||||
|
||||
// ==================== Self-Update ====================
|
||||
|
||||
let _updateState = { remoteVersion: null, changedFiles: [] };
|
||||
let _updateState = { remoteSHA: null, changedData: null };
|
||||
|
||||
function initUpdateSection() {
|
||||
const checkBtn = document.getElementById('update-check-btn');
|
||||
@@ -984,47 +984,33 @@
|
||||
try {
|
||||
const data = await Utils.api('/api/settings/updates/check');
|
||||
|
||||
if (remoteEl) remoteEl.textContent = 'v' + (data.remoteVersion || '?');
|
||||
// Show commit SHA + message
|
||||
if (remoteEl) {
|
||||
const sha = data.remoteSHA ? data.remoteSHA.slice(0, 7) : '—';
|
||||
remoteEl.textContent = sha;
|
||||
if (data.latestMessage) remoteEl.title = data.latestMessage;
|
||||
}
|
||||
if (statusRow) statusRow.style.display = '';
|
||||
|
||||
if (data.updateAvailable) {
|
||||
if (statusBadge) statusBadge.innerHTML = `<span class="badge badge-warning">${_('updates.update_available')}</span>`;
|
||||
if (data.baselineEstablished) {
|
||||
if (statusBadge) statusBadge.innerHTML = `<span class="badge badge-info">${_('updates.baseline_set')}</span>`;
|
||||
if (detailsSection) detailsSection.style.display = 'none';
|
||||
if (installBtn) installBtn.disabled = true;
|
||||
} else if (data.updateAvailable) {
|
||||
const behind = data.commitsBehind > 0 ? ` (${data.commitsBehind} ${_('updates.commits_behind')})` : '';
|
||||
if (statusBadge) statusBadge.innerHTML = `<span class="badge badge-warning">${_('updates.update_available')}${behind}</span>`;
|
||||
|
||||
_updateState.remoteSHA = data.remoteSHA;
|
||||
|
||||
// Fetch changed files
|
||||
const localVer = data.localVersion;
|
||||
const remoteVer = data.remoteVersion;
|
||||
_updateState.remoteVersion = remoteVer;
|
||||
|
||||
try {
|
||||
const changes = await Utils.api(`/api/settings/updates/changes?local=v${localVer}&remote=v${remoteVer}`);
|
||||
_updateState.changedFiles = changes.files || [];
|
||||
|
||||
const changelogEl = document.getElementById('update-changelog');
|
||||
const summaryEl = document.getElementById('update-files-summary');
|
||||
|
||||
if (changelogEl) {
|
||||
if (data.releaseNotes) {
|
||||
changelogEl.innerHTML = Utils.escapeHtml(data.releaseNotes).replace(/\n/g, '<br>');
|
||||
} else {
|
||||
changelogEl.innerHTML = `<p class="text-muted">${_('updates.no_changelog')}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
if (summaryEl) {
|
||||
const added = _updateState.changedFiles.filter(f => f.status === 'added').length;
|
||||
const modified = _updateState.changedFiles.filter(f => f.status === 'modified').length;
|
||||
const removed = _updateState.changedFiles.filter(f => f.status === 'removed' || f.status === 'renamed').length;
|
||||
summaryEl.innerHTML = `<p><span class="material-icons" style="font-size:16px;vertical-align:middle;">folder</span> ` +
|
||||
`${_('updates.files_changed')}: <strong>${_updateState.changedFiles.length}</strong> ` +
|
||||
`<span class="text-muted">(+${added} / ~${modified} / -${removed})</span></p>`;
|
||||
}
|
||||
|
||||
const changes = await Utils.api(`/api/settings/updates/changes?sha=${data.remoteSHA}`);
|
||||
_updateState.changedData = changes;
|
||||
renderUpdateDetails(data, changes);
|
||||
if (installBtn) installBtn.disabled = false;
|
||||
} catch (e) {
|
||||
if (detailsSection) {
|
||||
const changelogEl = document.getElementById('update-changelog');
|
||||
if (changelogEl) changelogEl.innerHTML = `<p class="text-muted">${_('updates.changes_unavailable')}</p>`;
|
||||
}
|
||||
} catch (_e) {
|
||||
const cl = document.getElementById('update-changelog');
|
||||
if (cl) cl.innerHTML = `<p class="text-muted">${_('updates.changes_unavailable')}</p>`;
|
||||
if (installBtn) installBtn.disabled = false;
|
||||
}
|
||||
|
||||
@@ -1042,13 +1028,73 @@
|
||||
}
|
||||
}
|
||||
|
||||
function renderUpdateDetails(checkData, changesData) {
|
||||
const changelogEl = document.getElementById('update-changelog');
|
||||
const summaryEl = document.getElementById('update-files-summary');
|
||||
|
||||
// ---- Recent commits ----
|
||||
if (changelogEl) {
|
||||
const commits = changesData.commits || [];
|
||||
if (commits.length > 0) {
|
||||
let html = '<div class="update-commits">';
|
||||
for (const c of commits.slice(0, 20)) {
|
||||
const d = c.date ? new Date(c.date).toLocaleDateString() : '';
|
||||
html += `<div class="update-commit-item">
|
||||
<code class="update-commit-sha">${Utils.escapeHtml(c.sha || '')}</code>
|
||||
<span class="update-commit-msg">${Utils.escapeHtml(c.message || '')}</span>
|
||||
<span class="update-commit-meta">${Utils.escapeHtml(c.author || '')} · ${d}</span>
|
||||
</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
changelogEl.innerHTML = html;
|
||||
} else {
|
||||
changelogEl.innerHTML = `<p class="text-muted">${_('updates.no_changelog')}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Component breakdown ----
|
||||
if (summaryEl) {
|
||||
const grouped = changesData.grouped || {};
|
||||
const meta = {
|
||||
console: { icon: 'web', label: _('updates.component_console'), auto: true },
|
||||
server: { icon: 'dns', label: _('updates.component_server'), auto: false },
|
||||
agent: { icon: 'smart_toy', label: _('updates.component_agent'), auto: false },
|
||||
scripts: { icon: 'terminal', label: _('updates.component_scripts'), auto: true },
|
||||
other: { icon: 'folder', label: _('updates.component_other'), auto: false }
|
||||
};
|
||||
|
||||
let html = '<div class="update-components">';
|
||||
for (const [comp, files] of Object.entries(grouped)) {
|
||||
if (!files || files.length === 0) continue;
|
||||
const m = meta[comp] || meta.other;
|
||||
const badge = m.auto
|
||||
? `<span class="badge badge-success badge-sm">${_('updates.auto')}</span>`
|
||||
: `<span class="badge badge-warning badge-sm">${_('updates.manual')}</span>`;
|
||||
html += `<div class="update-component-row">
|
||||
<span class="material-icons">${m.icon}</span>
|
||||
<span class="update-component-label">${m.label}</span>
|
||||
<span class="update-component-count">${files.length} ${_('updates.files')}</span>
|
||||
${badge}
|
||||
</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
html += `<p class="text-muted" style="margin-top:8px;font-size:12px;">${_('updates.total_files')}: <strong>${changesData.totalFiles || 0}</strong></p>`;
|
||||
|
||||
if (grouped.server?.length > 0) {
|
||||
html += `<div class="update-warning"><span class="material-icons">warning</span> ${_('updates.server_rebuild_warning')}</div>`;
|
||||
}
|
||||
|
||||
summaryEl.innerHTML = html;
|
||||
}
|
||||
}
|
||||
|
||||
async function installUpdate() {
|
||||
const installBtn = document.getElementById('update-install-btn');
|
||||
const progressEl = document.getElementById('update-progress');
|
||||
const progressFill = document.getElementById('update-progress-fill');
|
||||
const progressText = document.getElementById('update-progress-text');
|
||||
|
||||
if (!_updateState.remoteVersion) {
|
||||
if (!_updateState.remoteSHA) {
|
||||
Notifications.error(_('updates.no_version'));
|
||||
return;
|
||||
}
|
||||
@@ -1069,21 +1115,27 @@
|
||||
const result = await Utils.api('/api/settings/updates/install', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
remoteVersion: _updateState.remoteVersion,
|
||||
createBackup: createBackup
|
||||
remoteSHA: _updateState.remoteSHA,
|
||||
createBackup,
|
||||
components: ['console', 'scripts']
|
||||
}
|
||||
});
|
||||
|
||||
if (progressFill) progressFill.style.width = '100%';
|
||||
if (progressText) progressText.textContent = _('updates.restarting');
|
||||
|
||||
Notifications.success(_('updates.install_success'));
|
||||
|
||||
// Server will restart — poll until it comes back
|
||||
setTimeout(() => {
|
||||
pollServerRestart();
|
||||
}, 3000);
|
||||
let msg = `${_('updates.applied')}: ${result.applied?.length || 0}`;
|
||||
if (result.failed?.length) msg += `, ${_('updates.failed')}: ${result.failed.length}`;
|
||||
if (result.removed?.length) msg += `, ${_('updates.removed')}: ${result.removed.length}`;
|
||||
|
||||
if (result.needsConsoleRestart) {
|
||||
if (progressText) progressText.textContent = _('updates.restarting');
|
||||
Notifications.success(msg);
|
||||
setTimeout(() => pollServerRestart(), 3000);
|
||||
} else {
|
||||
Notifications.success(_('updates.install_success'));
|
||||
if (progressEl) setTimeout(() => { progressEl.style.display = 'none'; }, 5000);
|
||||
if (installBtn) installBtn.disabled = false;
|
||||
}
|
||||
} catch (error) {
|
||||
Notifications.error(error.message || _('updates.install_failed'));
|
||||
if (progressEl) progressEl.style.display = 'none';
|
||||
@@ -1124,7 +1176,7 @@
|
||||
|
||||
try {
|
||||
const data = await Utils.api('/api/settings/updates/backups');
|
||||
const backups = data.backups || [];
|
||||
const backups = Array.isArray(data) ? data : (data.backups || []);
|
||||
|
||||
if (!backups.length) {
|
||||
listEl.innerHTML = `<p class="text-muted">${_('updates.no_backups')}</p>`;
|
||||
@@ -1133,11 +1185,12 @@
|
||||
|
||||
let html = '<div class="update-backups">';
|
||||
for (const b of backups) {
|
||||
const date = new Date(b.timestamp).toLocaleString();
|
||||
const date = b.timestamp ? new Date(b.timestamp).toLocaleString() : '';
|
||||
const sha = b.sha ? ` · ${Utils.escapeHtml(b.sha)}` : '';
|
||||
html += `<div class="update-backup-item">
|
||||
<div class="update-backup-info">
|
||||
<strong>${Utils.escapeHtml(b.name)}</strong>
|
||||
<span class="text-muted">${date} · ${b.fileCount} ${_('updates.files')}</span>
|
||||
<span class="text-muted">${date}${sha} · ${b.fileCount || b.filesBackedUp || 0} ${_('updates.files')}</span>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline" data-backup="${Utils.escapeHtml(b.name)}">
|
||||
<span class="material-icons">restore</span> ${_('updates.restore')}
|
||||
|
||||
@@ -414,16 +414,16 @@ router.get('/api/settings/updates/check', requireAuth, requireAdmin, async (req,
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/settings/updates/changes - Get list of changed files between versions
|
||||
* GET /api/settings/updates/changes - Get list of changed files between local SHA and remote
|
||||
*/
|
||||
router.get('/api/settings/updates/changes', requireAuth, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { local, remote } = req.query;
|
||||
if (!local || !remote) {
|
||||
return res.status(400).json({ success: false, error: 'local and remote version params required' });
|
||||
const { sha } = req.query;
|
||||
if (!sha || !/^[0-9a-f]{7,40}$/i.test(sha)) {
|
||||
return res.status(400).json({ success: false, error: 'Valid SHA parameter required' });
|
||||
}
|
||||
const files = await updateService.getChangedFiles(local, remote);
|
||||
res.json({ success: true, data: { files, total: files.length } });
|
||||
const result = await updateService.getChangedFiles(sha);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (err) {
|
||||
console.error('Get changes error:', err);
|
||||
res.status(500).json({ success: false, error: 'Failed to get changed files: ' + err.message });
|
||||
@@ -432,55 +432,49 @@ router.get('/api/settings/updates/changes', requireAuth, requireAdmin, async (re
|
||||
|
||||
/**
|
||||
* POST /api/settings/updates/install - Apply the update
|
||||
* Body: { remoteVersion, createBackup: true/false }
|
||||
* Body: { remoteSHA, createBackup: true/false, components: ['console','scripts'] }
|
||||
*/
|
||||
router.post('/api/settings/updates/install', requireAuth, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { remoteVersion, createBackup } = req.body;
|
||||
if (!remoteVersion || typeof remoteVersion !== 'string') {
|
||||
return res.status(400).json({ success: false, error: 'remoteVersion is required' });
|
||||
const { remoteSHA, createBackup, components } = req.body;
|
||||
if (!remoteSHA || !/^[0-9a-f]{7,40}$/i.test(remoteSHA)) {
|
||||
return res.status(400).json({ success: false, error: 'Valid remoteSHA is required' });
|
||||
}
|
||||
// Validate version format (semver-like)
|
||||
if (!/^\d+\.\d+\.\d+/.test(remoteVersion.replace(/^v/i, ''))) {
|
||||
return res.status(400).json({ success: false, error: 'Invalid version format' });
|
||||
}
|
||||
|
||||
const localVersion = updateService.getLocalVersion();
|
||||
|
||||
// Get changed files
|
||||
const changedFiles = await updateService.getChangedFiles(localVersion, remoteVersion);
|
||||
if (changedFiles.length === 0) {
|
||||
const changedData = await updateService.getChangedFiles(remoteSHA);
|
||||
if (changedData.totalFiles === 0) {
|
||||
return res.json({ success: true, data: { applied: [], message: 'No files to update' } });
|
||||
}
|
||||
|
||||
// Apply update
|
||||
const doBackup = createBackup !== false;
|
||||
const result = await updateService.applyUpdate(remoteVersion, changedFiles, doBackup);
|
||||
const result = await updateService.applyUpdate(remoteSHA, changedData, {
|
||||
createBackup: createBackup !== false,
|
||||
components: components || ['console', 'scripts']
|
||||
});
|
||||
|
||||
await db.logAction(
|
||||
req.session?.userId,
|
||||
'system_update',
|
||||
`Updated ${localVersion} → ${remoteVersion} (${result.applied.length} files, ${result.failed.length} failed)`,
|
||||
`Updated to ${remoteSHA.slice(0, 7)} (${result.applied.length} applied, ${result.failed.length} failed)`,
|
||||
req.ip
|
||||
);
|
||||
|
||||
// If update applied successfully and has no critical failures, schedule restart
|
||||
const needsRestart = result.applied.length > 0 && result.failed.length === 0;
|
||||
// Restart Go server if changes detected and component selected
|
||||
if (result.needsServerRestart && (components || []).includes('server')) {
|
||||
const svc = updateService.restartService(
|
||||
process.platform === 'win32' ? 'BetterDeskServer' : 'betterdesk-server'
|
||||
);
|
||||
if (svc.success) result.servicesRestarted.push('server');
|
||||
else result.servicesFailed.push({ service: 'server', error: svc.error });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
...result,
|
||||
localVersion,
|
||||
remoteVersion,
|
||||
needsRestart
|
||||
}
|
||||
});
|
||||
res.json({ success: true, data: result });
|
||||
|
||||
// Restart after response is sent (systemd/PM2 will restart automatically)
|
||||
if (needsRestart) {
|
||||
// Restart console after response is sent (systemd/NSSM restarts automatically)
|
||||
if (result.needsConsoleRestart) {
|
||||
setTimeout(() => {
|
||||
console.log(`[UPDATE] Restarting after update to v${remoteVersion}...`);
|
||||
console.log(`[UPDATE] Restarting console after update to ${remoteSHA.slice(0, 7)}...`);
|
||||
process.exit(0);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
/**
|
||||
* BetterDesk Console - Self-Update Service
|
||||
*
|
||||
* Checks GitHub for new releases, compares with local version, downloads
|
||||
* only changed files, creates a backup before applying, and restarts the
|
||||
* console service.
|
||||
* Commit-based update system. Compares locally tracked commit SHA with
|
||||
* the HEAD of the configured GitHub branch. Downloads changed files,
|
||||
* categorises them by component (console / server / agent / scripts),
|
||||
* applies updates, and restarts affected services.
|
||||
*
|
||||
* GitHub repo: UNITRONIX/BetterDesk
|
||||
* Version file: VERSION (root of repo)
|
||||
* Tracking: data/.update_sha (deployed commit SHA)
|
||||
*
|
||||
* Flow:
|
||||
* 1. GET /repos/{owner}/{repo}/releases/latest → remote version
|
||||
* 2. Compare with local VERSION / package.json
|
||||
* 3. GET /repos/{owner}/{repo}/compare/{localTag}...{remoteTag} → changed files
|
||||
* 4. Download each changed file (only web-nodejs/ subtree)
|
||||
* 5. Backup current files → data/backups/pre-update-{ts}/
|
||||
* 6. Overwrite local files
|
||||
* 7. Restart service via process exit (systemd restarts automatically)
|
||||
* 1. GET /repos/{owner}/{repo}/commits/{branch} → remote HEAD SHA
|
||||
* 2. Compare with local .update_sha
|
||||
* 3. GET /repos/{owner}/{repo}/compare/{local}...{remote} → changed files
|
||||
* 4. Categorise: console / server / scripts / agent / other
|
||||
* 5. Backup current console files → data/backups/pre-update-{ts}/
|
||||
* 6. Download & overwrite changed files per selected component
|
||||
* 7. npm install if package.json changed
|
||||
* 8. Restart affected services (systemd / NSSM)
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
@@ -24,29 +26,82 @@ const https = require('https');
|
||||
const { execSync } = require('child_process');
|
||||
const config = require('../config/config');
|
||||
|
||||
const GITHUB_OWNER = process.env.UPDATE_GITHUB_OWNER || 'UNITRONIX';
|
||||
const GITHUB_REPO = process.env.UPDATE_GITHUB_REPO || 'BetterDesk';
|
||||
const GITHUB_API = 'https://api.github.com';
|
||||
const CONSOLE_PREFIX = 'web-nodejs/';
|
||||
const USER_AGENT = `BetterDesk-Console/${config.appVersion}`;
|
||||
const BACKUP_DIR = path.join(config.dataDir, 'backups');
|
||||
const ROOT_DIR = path.join(__dirname, '..');
|
||||
const GITHUB_OWNER = process.env.UPDATE_GITHUB_OWNER || 'UNITRONIX';
|
||||
const GITHUB_REPO = process.env.UPDATE_GITHUB_REPO || 'BetterDesk';
|
||||
const GITHUB_BRANCH = process.env.UPDATE_GITHUB_BRANCH || 'main';
|
||||
const GITHUB_API = 'https://api.github.com';
|
||||
const USER_AGENT = `BetterDesk-Console/${config.appVersion}`;
|
||||
const BACKUP_DIR = path.join(config.dataDir, 'backups');
|
||||
const SHA_FILE = path.join(config.dataDir, '.update_sha');
|
||||
const ROOT_DIR = path.join(__dirname, '..'); // web-nodejs/
|
||||
const PROJECT_ROOT = path.join(ROOT_DIR, '..'); // repo root
|
||||
const IS_WINDOWS = process.platform === 'win32';
|
||||
|
||||
// Optional GitHub token for higher rate limits (60/h unauthenticated, 5000/h with token)
|
||||
// Optional GitHub personal-access token (60 req/h without, 5 000 with)
|
||||
const GITHUB_TOKEN = process.env.UPDATE_GITHUB_TOKEN || '';
|
||||
|
||||
// ======================== Helpers ========================================
|
||||
// ---------- component definitions ----------
|
||||
const COMPONENTS = {
|
||||
console: {
|
||||
prefix: 'web-nodejs/',
|
||||
label: 'Web Console',
|
||||
localRoot: ROOT_DIR,
|
||||
service: IS_WINDOWS ? 'BetterDeskConsole' : 'betterdesk-console',
|
||||
autoUpdate: true
|
||||
},
|
||||
server: {
|
||||
prefix: 'betterdesk-server/',
|
||||
label: 'Go Server',
|
||||
localRoot: null,
|
||||
service: IS_WINDOWS ? 'BetterDeskServer' : 'betterdesk-server',
|
||||
autoUpdate: false
|
||||
},
|
||||
agent: {
|
||||
prefix: 'betterdesk-agent/',
|
||||
label: 'Agent',
|
||||
localRoot: null,
|
||||
service: IS_WINDOWS ? 'BetterDeskAgent' : 'betterdesk-agent',
|
||||
autoUpdate: false
|
||||
},
|
||||
scripts: {
|
||||
// matched by exact file names, not prefix
|
||||
files: [
|
||||
'betterdesk.sh', 'betterdesk.ps1', 'betterdesk-docker.sh',
|
||||
'docker-compose.yml', 'docker-compose.single.yml', 'docker-compose.quick.yml',
|
||||
'Dockerfile', 'Dockerfile.server', 'Dockerfile.console'
|
||||
],
|
||||
label: 'Scripts & Docker',
|
||||
localRoot: PROJECT_ROOT,
|
||||
service: null,
|
||||
autoUpdate: true
|
||||
}
|
||||
};
|
||||
|
||||
// paths that are never downloaded during an update
|
||||
const EXCLUDE_PATTERNS = [
|
||||
/^\.github\//,
|
||||
/^archive\//,
|
||||
/^docs\//,
|
||||
/^screenshots\//,
|
||||
/^dev_modules\//,
|
||||
/^tasks\//,
|
||||
/^sdks\//,
|
||||
/^bridges\//,
|
||||
/node_modules\//,
|
||||
/\.sqlite3$/,
|
||||
/\.exe$/,
|
||||
/^betterdesk-server\/betterdesk-server/ // compiled binaries
|
||||
];
|
||||
|
||||
// ======================== HTTP Helpers ===================================
|
||||
|
||||
/**
|
||||
* Make an HTTPS GET request and return parsed JSON.
|
||||
* HTTPS GET → parsed JSON. Follows one redirect.
|
||||
*/
|
||||
function ghGet(urlPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = urlPath.startsWith('https://') ? new URL(urlPath) : new URL(urlPath, GITHUB_API);
|
||||
const headers = {
|
||||
'User-Agent': USER_AGENT,
|
||||
'Accept': 'application/vnd.github+json'
|
||||
};
|
||||
const headers = { 'User-Agent': USER_AGENT, 'Accept': 'application/vnd.github+json' };
|
||||
if (GITHUB_TOKEN) headers['Authorization'] = `Bearer ${GITHUB_TOKEN}`;
|
||||
|
||||
const req = https.get({ hostname: url.hostname, path: url.pathname + url.search, headers }, (res) => {
|
||||
@@ -61,7 +116,7 @@ function ghGet(urlPath) {
|
||||
return reject(new Error(`GitHub API ${res.statusCode}: ${body.slice(0, 200)}`));
|
||||
}
|
||||
try { resolve(JSON.parse(body)); }
|
||||
catch (e) { reject(new Error('Invalid JSON from GitHub API')); }
|
||||
catch (_e) { reject(new Error('Invalid JSON from GitHub API')); }
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
@@ -70,45 +125,67 @@ function ghGet(urlPath) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Download raw file content from GitHub.
|
||||
* Download raw file content from GitHub (binary-safe).
|
||||
*/
|
||||
function ghDownloadFile(owner, repo, ref, filePath) {
|
||||
const url = `https://raw.githubusercontent.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encodeURIComponent(ref)}/${filePath}`;
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${filePath}`;
|
||||
const headers = { 'User-Agent': USER_AGENT };
|
||||
if (GITHUB_TOKEN) headers['Authorization'] = `Bearer ${GITHUB_TOKEN}`;
|
||||
|
||||
const req = https.get(url, { headers }, (res) => {
|
||||
if (res.statusCode === 301 || res.statusCode === 302) {
|
||||
return ghDownloadFile(owner, repo, ref, filePath).then(resolve, reject);
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
return reject(new Error(`Download failed (${res.statusCode}): ${filePath}`));
|
||||
}
|
||||
const chunks = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.setTimeout(30000, () => { req.destroy(); reject(new Error(`Download timeout: ${filePath}`)); });
|
||||
const follow = (target) => {
|
||||
const req = https.get(target, { headers }, (res) => {
|
||||
if ((res.statusCode === 301 || res.statusCode === 302) && res.headers.location) {
|
||||
return follow(res.headers.location);
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
return reject(new Error(`Download failed (${res.statusCode}): ${filePath}`));
|
||||
}
|
||||
const chunks = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.setTimeout(30000, () => { req.destroy(); reject(new Error(`Download timeout: ${filePath}`)); });
|
||||
};
|
||||
follow(url);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse semver-like version string → comparable integer.
|
||||
* "3.0.0" → 3000000, "2.4.1" → 2004001
|
||||
*/
|
||||
function versionToInt(v) {
|
||||
const parts = String(v).replace(/^v/i, '').split('.').map(Number);
|
||||
return (parts[0] || 0) * 1000000 + (parts[1] || 0) * 1000 + (parts[2] || 0);
|
||||
// ======================== SHA Tracking ===================================
|
||||
|
||||
function getLocalSHA() {
|
||||
if (fs.existsSync(SHA_FILE)) {
|
||||
const sha = fs.readFileSync(SHA_FILE, 'utf8').trim();
|
||||
if (/^[0-9a-f]{7,40}$/i.test(sha)) return sha;
|
||||
}
|
||||
// Fall back to git if available
|
||||
try {
|
||||
const sha = execSync('git rev-parse HEAD', { cwd: PROJECT_ROOT, timeout: 5000, stdio: 'pipe' })
|
||||
.toString().trim();
|
||||
if (/^[0-9a-f]{40}$/i.test(sha)) { saveLocalSHA(sha); return sha; }
|
||||
} catch (_e) { /* no git */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
function saveLocalSHA(sha) {
|
||||
if (!/^[0-9a-f]{7,40}$/i.test(sha)) return;
|
||||
fs.mkdirSync(path.dirname(SHA_FILE), { recursive: true });
|
||||
fs.writeFileSync(SHA_FILE, sha.trim() + '\n');
|
||||
}
|
||||
|
||||
async function getRemoteHeadSHA() {
|
||||
const data = await ghGet(`/repos/${GITHUB_OWNER}/${GITHUB_REPO}/commits/${GITHUB_BRANCH}`);
|
||||
return {
|
||||
sha: data.sha,
|
||||
message: (data.commit?.message || '').split('\n')[0],
|
||||
date: data.commit?.committer?.date || data.commit?.author?.date || '',
|
||||
author: data.commit?.author?.name || ''
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get local version from VERSION file or package.json.
|
||||
*/
|
||||
function getLocalVersion() {
|
||||
// Try VERSION file at console root
|
||||
const versionFile = path.join(ROOT_DIR, '..', 'VERSION');
|
||||
const versionFile = path.join(PROJECT_ROOT, 'VERSION');
|
||||
if (fs.existsSync(versionFile)) {
|
||||
const v = fs.readFileSync(versionFile, 'utf8').trim();
|
||||
if (v) return v;
|
||||
@@ -116,112 +193,171 @@ function getLocalVersion() {
|
||||
return config.appVersion;
|
||||
}
|
||||
|
||||
// ======================== Classify ======================================
|
||||
|
||||
function classifyFile(filepath) {
|
||||
if (COMPONENTS.scripts.files.includes(filepath)) return 'scripts';
|
||||
for (const [name, comp] of Object.entries(COMPONENTS)) {
|
||||
if (comp.prefix && filepath.startsWith(comp.prefix)) return name;
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
|
||||
function isExcluded(filepath) {
|
||||
return EXCLUDE_PATTERNS.some(rx => rx.test(filepath));
|
||||
}
|
||||
|
||||
// ======================== Public API ====================================
|
||||
|
||||
/**
|
||||
* Check for updates — returns version info without downloading anything.
|
||||
* Check for updates by comparing local commit SHA with remote HEAD.
|
||||
*/
|
||||
async function checkForUpdates() {
|
||||
const localVersion = getLocalVersion();
|
||||
const localSHA = getLocalSHA();
|
||||
const remote = await getRemoteHeadSHA();
|
||||
|
||||
// Fetch latest release from GitHub
|
||||
let release;
|
||||
// No baseline yet → establish one
|
||||
if (!localSHA) {
|
||||
saveLocalSHA(remote.sha);
|
||||
return {
|
||||
localVersion,
|
||||
localSHA: remote.sha,
|
||||
remoteSHA: remote.sha,
|
||||
updateAvailable: false,
|
||||
baselineEstablished: true,
|
||||
commitsBehind: 0,
|
||||
latestMessage: remote.message,
|
||||
latestDate: remote.date,
|
||||
latestAuthor: remote.author,
|
||||
components: {}
|
||||
};
|
||||
}
|
||||
|
||||
// Already at HEAD
|
||||
if (localSHA.startsWith(remote.sha.slice(0, 7)) || remote.sha.startsWith(localSHA.slice(0, 7)) || localSHA === remote.sha) {
|
||||
return {
|
||||
localVersion,
|
||||
localSHA,
|
||||
remoteSHA: remote.sha,
|
||||
updateAvailable: false,
|
||||
commitsBehind: 0,
|
||||
latestMessage: remote.message,
|
||||
latestDate: remote.date,
|
||||
latestAuthor: remote.author,
|
||||
components: {}
|
||||
};
|
||||
}
|
||||
|
||||
// Compare
|
||||
let compare;
|
||||
try {
|
||||
release = await ghGet(`/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases/latest`);
|
||||
compare = await ghGet(`/repos/${GITHUB_OWNER}/${GITHUB_REPO}/compare/${localSHA}...${remote.sha}`);
|
||||
} catch (err) {
|
||||
// 404 means no releases published yet — treat as "up to date"
|
||||
if (err.message && err.message.includes('404')) {
|
||||
return {
|
||||
localVersion,
|
||||
remoteVersion: localVersion,
|
||||
isNewer: false,
|
||||
updateAvailable: false,
|
||||
releaseName: `v${localVersion}`,
|
||||
releaseUrl: `https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}`,
|
||||
publishedAt: '',
|
||||
changelog: '',
|
||||
releaseNotes: '',
|
||||
prerelease: false,
|
||||
noReleases: true
|
||||
// SHA may have been force-pushed away
|
||||
return {
|
||||
localVersion,
|
||||
localSHA,
|
||||
remoteSHA: remote.sha,
|
||||
updateAvailable: true,
|
||||
commitsBehind: -1,
|
||||
latestMessage: remote.message,
|
||||
latestDate: remote.date,
|
||||
latestAuthor: remote.author,
|
||||
components: {},
|
||||
compareError: err.message
|
||||
};
|
||||
}
|
||||
|
||||
const files = (compare.files || []).filter(f => !isExcluded(f.filename));
|
||||
const componentSummary = {};
|
||||
for (const file of files) {
|
||||
const comp = classifyFile(file.filename);
|
||||
if (!componentSummary[comp]) {
|
||||
componentSummary[comp] = {
|
||||
changed: true,
|
||||
fileCount: 0,
|
||||
label: COMPONENTS[comp]?.label || 'Other',
|
||||
autoUpdate: COMPONENTS[comp]?.autoUpdate ?? false
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
componentSummary[comp].fileCount++;
|
||||
}
|
||||
const remoteVersion = (release.tag_name || '').replace(/^v/i, '');
|
||||
|
||||
const isNewer = versionToInt(remoteVersion) > versionToInt(localVersion);
|
||||
|
||||
return {
|
||||
localVersion,
|
||||
remoteVersion,
|
||||
isNewer,
|
||||
updateAvailable: isNewer,
|
||||
releaseName: release.name || `v${remoteVersion}`,
|
||||
releaseUrl: release.html_url || '',
|
||||
publishedAt: release.published_at || '',
|
||||
changelog: release.body || '',
|
||||
releaseNotes: release.body || '',
|
||||
prerelease: release.prerelease || false
|
||||
localSHA,
|
||||
remoteSHA: remote.sha,
|
||||
updateAvailable: files.length > 0,
|
||||
commitsBehind: compare.total_commits || (compare.commits || []).length,
|
||||
latestMessage: remote.message,
|
||||
latestDate: remote.date,
|
||||
latestAuthor: remote.author,
|
||||
components: componentSummary
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of changed console files between local and remote versions.
|
||||
* Uses GitHub Compare API to get a diff of only web-nodejs/ files.
|
||||
* Get detailed list of changed files between local SHA and the given remote SHA.
|
||||
* Returns files grouped by component plus a flat list and recent commits.
|
||||
*/
|
||||
async function getChangedFiles(localVersion, remoteVersion) {
|
||||
const localTag = `v${localVersion.replace(/^v/i, '')}`;
|
||||
const remoteTag = `v${remoteVersion.replace(/^v/i, '')}`;
|
||||
async function getChangedFiles(remoteSHA) {
|
||||
const localSHA = getLocalSHA();
|
||||
if (!localSHA) throw new Error('No local baseline SHA — run update check first');
|
||||
if (!/^[0-9a-f]{7,40}$/i.test(remoteSHA)) throw new Error('Invalid remote SHA');
|
||||
|
||||
let files;
|
||||
try {
|
||||
const compare = await ghGet(`/repos/${GITHUB_OWNER}/${GITHUB_REPO}/compare/${localTag}...${remoteTag}`);
|
||||
files = (compare.files || []);
|
||||
} catch (_err) {
|
||||
// If tags don't exist, fall back to getting the tree for remote tag
|
||||
// and comparing with local files manually
|
||||
const tree = await ghGet(`/repos/${GITHUB_OWNER}/${GITHUB_REPO}/git/trees/${remoteTag}?recursive=1`);
|
||||
files = (tree.tree || [])
|
||||
.filter(f => f.type === 'blob' && f.path.startsWith(CONSOLE_PREFIX))
|
||||
.map(f => ({ filename: f.path, status: 'modified', sha: f.sha }));
|
||||
const compare = await ghGet(`/repos/${GITHUB_OWNER}/${GITHUB_REPO}/compare/${localSHA}...${remoteSHA}`);
|
||||
const files = (compare.files || []).filter(f => !isExcluded(f.filename));
|
||||
|
||||
const grouped = { console: [], server: [], agent: [], scripts: [], other: [] };
|
||||
|
||||
for (const f of files) {
|
||||
const comp = classifyFile(f.filename);
|
||||
const entry = {
|
||||
path: f.filename,
|
||||
status: f.status || 'modified',
|
||||
sha: f.sha || '',
|
||||
component: comp
|
||||
};
|
||||
if (comp === 'console') {
|
||||
entry.localPath = f.filename.slice(COMPONENTS.console.prefix.length);
|
||||
} else if (comp === 'scripts') {
|
||||
entry.localPath = f.filename;
|
||||
}
|
||||
(grouped[comp] || grouped.other).push(entry);
|
||||
}
|
||||
|
||||
// Filter to only web-nodejs/ files and exclude tests/dev files
|
||||
const consoleFiles = files.filter(f => {
|
||||
if (!f.filename.startsWith(CONSOLE_PREFIX)) return false;
|
||||
// Skip test files, node_modules references, etc.
|
||||
const rel = f.filename.slice(CONSOLE_PREFIX.length);
|
||||
if (rel.startsWith('node_modules/')) return false;
|
||||
if (rel.startsWith('test/') || rel.startsWith('tests/')) return false;
|
||||
if (rel === 'package-lock.json') return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return consoleFiles.map(f => ({
|
||||
path: f.filename, // full path in repo (web-nodejs/...)
|
||||
localPath: f.filename.slice(CONSOLE_PREFIX.length), // relative to console root
|
||||
status: f.status || 'modified', // added | modified | removed | renamed
|
||||
sha: f.sha || ''
|
||||
}));
|
||||
return {
|
||||
files: files.map(f => ({
|
||||
path: f.filename,
|
||||
status: f.status || 'modified',
|
||||
component: classifyFile(f.filename)
|
||||
})),
|
||||
grouped,
|
||||
totalFiles: files.length,
|
||||
commits: (compare.commits || []).slice(-30).reverse().map(c => ({
|
||||
sha: c.sha?.slice(0, 7),
|
||||
message: (c.commit?.message || '').split('\n')[0],
|
||||
date: c.commit?.committer?.date || '',
|
||||
author: c.commit?.author?.name || ''
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a pre-update backup of files that will be changed.
|
||||
* Returns the backup directory path.
|
||||
* Create a pre-update backup of console files that will be changed.
|
||||
*/
|
||||
async function createPreUpdateBackup(changedFiles) {
|
||||
async function createPreUpdateBackup(allFiles) {
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
const backupPath = path.join(BACKUP_DIR, `pre-update-${ts}`);
|
||||
|
||||
fs.mkdirSync(backupPath, { recursive: true });
|
||||
|
||||
// Save VERSION
|
||||
const localVersion = getLocalVersion();
|
||||
fs.writeFileSync(path.join(backupPath, 'VERSION.txt'), localVersion);
|
||||
|
||||
// Backup each file that will be changed
|
||||
const localSHA = getLocalSHA();
|
||||
let backedUp = 0;
|
||||
for (const file of changedFiles) {
|
||||
|
||||
for (const file of allFiles) {
|
||||
if (file.component !== 'console' || !file.localPath) continue;
|
||||
const src = path.join(ROOT_DIR, file.localPath);
|
||||
if (fs.existsSync(src)) {
|
||||
const dest = path.join(backupPath, file.localPath);
|
||||
@@ -231,147 +367,188 @@ async function createPreUpdateBackup(changedFiles) {
|
||||
}
|
||||
}
|
||||
|
||||
// Save manifest
|
||||
fs.writeFileSync(path.join(backupPath, 'manifest.json'), JSON.stringify({
|
||||
version: localVersion,
|
||||
sha: localSHA,
|
||||
timestamp: new Date().toISOString(),
|
||||
filesBackedUp: backedUp,
|
||||
files: changedFiles.map(f => f.localPath)
|
||||
files: allFiles.filter(f => f.component === 'console' && f.localPath).map(f => f.localPath)
|
||||
}, null, 2));
|
||||
|
||||
return { backupPath, backedUp };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply update — download changed files from GitHub and overwrite local copies.
|
||||
* Returns summary of applied changes.
|
||||
* Apply update — download changed files, run npm install if needed,
|
||||
* update SHA tracking file.
|
||||
*
|
||||
* @param {string} remoteSHA
|
||||
* @param {object} changedData Output of getChangedFiles()
|
||||
* @param {object} opts
|
||||
* @param {boolean} opts.createBackup default true
|
||||
* @param {string[]} opts.components default ['console','scripts']
|
||||
*/
|
||||
async function applyUpdate(remoteVersion, changedFiles, createBackup = true) {
|
||||
const ref = `v${remoteVersion.replace(/^v/i, '')}`;
|
||||
async function applyUpdate(remoteSHA, changedData, opts = {}) {
|
||||
const { createBackup = true, components: selectedComponents = ['console', 'scripts'] } = opts;
|
||||
|
||||
// 1. Backup
|
||||
let backupInfo = null;
|
||||
if (createBackup) {
|
||||
backupInfo = await createPreUpdateBackup(changedFiles);
|
||||
const allFiles = Object.values(changedData.grouped).flat();
|
||||
backupInfo = await createPreUpdateBackup(allFiles);
|
||||
}
|
||||
|
||||
// 2. Download and apply each file
|
||||
const applied = [];
|
||||
const failed = [];
|
||||
const removed = [];
|
||||
|
||||
for (const file of changedFiles) {
|
||||
try {
|
||||
if (file.status === 'removed') {
|
||||
// Delete local file
|
||||
const localFile = path.join(ROOT_DIR, file.localPath);
|
||||
if (fs.existsSync(localFile)) {
|
||||
fs.unlinkSync(localFile);
|
||||
removed.push(file.localPath);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Download from GitHub
|
||||
const content = await ghDownloadFile(GITHUB_OWNER, GITHUB_REPO, ref, file.path);
|
||||
const dest = path.join(ROOT_DIR, file.localPath);
|
||||
|
||||
// Ensure directory exists
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
|
||||
// Write file
|
||||
fs.writeFileSync(dest, content);
|
||||
applied.push(file.localPath);
|
||||
} catch (err) {
|
||||
failed.push({ file: file.localPath, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Update package.json version if needed
|
||||
try {
|
||||
const pkgPath = path.join(ROOT_DIR, 'package.json');
|
||||
if (fs.existsSync(pkgPath)) {
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
||||
if (pkg.version !== remoteVersion.replace(/^v/i, '')) {
|
||||
pkg.version = remoteVersion.replace(/^v/i, '');
|
||||
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
||||
}
|
||||
}
|
||||
} catch (_e) { /* non-critical */ }
|
||||
|
||||
// 4. Check if package.json dependencies changed → run npm install
|
||||
let npmInstalled = false;
|
||||
if (changedFiles.some(f => f.localPath === 'package.json')) {
|
||||
try {
|
||||
execSync('npm install --omit=dev --no-audit --no-fund', {
|
||||
cwd: ROOT_DIR,
|
||||
timeout: 120000,
|
||||
stdio: 'pipe'
|
||||
});
|
||||
npmInstalled = true;
|
||||
} catch (_e) {
|
||||
failed.push({ file: 'npm install', error: 'npm install failed — restart may fix this' });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
applied,
|
||||
failed,
|
||||
removed,
|
||||
npmInstalled,
|
||||
const results = {
|
||||
applied: [],
|
||||
failed: [],
|
||||
removed: [],
|
||||
skipped: [],
|
||||
npmInstalled: false,
|
||||
servicesRestarted: [],
|
||||
servicesFailed: [],
|
||||
backupPath: backupInfo?.backupPath || null,
|
||||
backedUp: backupInfo?.backedUp || 0,
|
||||
totalChanged: changedFiles.length
|
||||
needsConsoleRestart: false,
|
||||
needsServerRestart: false,
|
||||
needsAgentRestart: false
|
||||
};
|
||||
|
||||
// ---- Console files ----
|
||||
if (selectedComponents.includes('console') && changedData.grouped.console?.length) {
|
||||
for (const file of changedData.grouped.console) {
|
||||
try {
|
||||
if (file.status === 'removed') {
|
||||
const localFile = path.join(ROOT_DIR, file.localPath);
|
||||
if (fs.existsSync(localFile)) { fs.unlinkSync(localFile); results.removed.push(file.path); }
|
||||
continue;
|
||||
}
|
||||
if (/^(node_modules|test|tests)\//.test(file.localPath) || file.localPath === 'package-lock.json') {
|
||||
results.skipped.push(file.path);
|
||||
continue;
|
||||
}
|
||||
const content = await ghDownloadFile(GITHUB_OWNER, GITHUB_REPO, remoteSHA, file.path);
|
||||
const dest = path.join(ROOT_DIR, file.localPath);
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.writeFileSync(dest, content);
|
||||
results.applied.push(file.path);
|
||||
} catch (err) {
|
||||
results.failed.push({ file: file.path, error: err.message });
|
||||
}
|
||||
}
|
||||
// npm install when package.json changed
|
||||
if (changedData.grouped.console.some(f => f.localPath === 'package.json')) {
|
||||
try {
|
||||
execSync('npm install --omit=dev --no-audit --no-fund', { cwd: ROOT_DIR, timeout: 120000, stdio: 'pipe' });
|
||||
results.npmInstalled = true;
|
||||
} catch (_e) {
|
||||
results.failed.push({ file: 'npm install', error: 'npm install failed' });
|
||||
}
|
||||
}
|
||||
results.needsConsoleRestart = true;
|
||||
}
|
||||
|
||||
// ---- Script / Docker files ----
|
||||
if (selectedComponents.includes('scripts') && changedData.grouped.scripts?.length) {
|
||||
for (const file of changedData.grouped.scripts) {
|
||||
try {
|
||||
if (file.status === 'removed') continue;
|
||||
const content = await ghDownloadFile(GITHUB_OWNER, GITHUB_REPO, remoteSHA, file.path);
|
||||
const dest = path.join(PROJECT_ROOT, file.localPath);
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.writeFileSync(dest, content);
|
||||
if (!IS_WINDOWS && file.localPath.endsWith('.sh')) {
|
||||
try { fs.chmodSync(dest, 0o755); } catch (_e) { /* ok */ }
|
||||
}
|
||||
results.applied.push(file.path);
|
||||
} catch (err) {
|
||||
results.failed.push({ file: file.path, error: err.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Server / Agent — info only for non-auto components ----
|
||||
if (changedData.grouped.server?.length) {
|
||||
if (selectedComponents.includes('server')) {
|
||||
results.needsServerRestart = true;
|
||||
}
|
||||
for (const f of changedData.grouped.server) {
|
||||
results.skipped.push(f.path + ' (server source — rebuild required)');
|
||||
}
|
||||
}
|
||||
if (changedData.grouped.agent?.length) {
|
||||
for (const f of changedData.grouped.agent) {
|
||||
results.skipped.push(f.path + ' (agent source — rebuild required)');
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Update SHA tracking ----
|
||||
saveLocalSHA(remoteSHA);
|
||||
|
||||
// ---- Pull remote VERSION file ----
|
||||
try {
|
||||
const versionContent = await ghDownloadFile(GITHUB_OWNER, GITHUB_REPO, remoteSHA, 'VERSION');
|
||||
fs.writeFileSync(path.join(PROJECT_ROOT, 'VERSION'), versionContent);
|
||||
} catch (_e) { /* non-critical */ }
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* List available pre-update backups.
|
||||
* Restart a system service.
|
||||
* Returns { success, service, error? }.
|
||||
*/
|
||||
function restartService(serviceName) {
|
||||
try {
|
||||
if (IS_WINDOWS) {
|
||||
execSync(`nssm restart "${serviceName}"`, { timeout: 30000, stdio: 'pipe' });
|
||||
} else {
|
||||
execSync(`sudo systemctl restart "${serviceName}"`, { timeout: 30000, stdio: 'pipe' });
|
||||
}
|
||||
return { success: true, service: serviceName };
|
||||
} catch (err) {
|
||||
return { success: false, service: serviceName, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List pre-update backups (newest first).
|
||||
*/
|
||||
function listBackups() {
|
||||
if (!fs.existsSync(BACKUP_DIR)) return [];
|
||||
|
||||
return fs.readdirSync(BACKUP_DIR)
|
||||
.filter(d => d.startsWith('pre-update-'))
|
||||
.map(d => {
|
||||
const dir = path.join(BACKUP_DIR, d);
|
||||
const manifestPath = path.join(dir, 'manifest.json');
|
||||
let manifest = {};
|
||||
if (fs.existsSync(manifestPath)) {
|
||||
try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); } catch (_e) { /* skip */ }
|
||||
const mPath = path.join(dir, 'manifest.json');
|
||||
let m = {};
|
||||
if (fs.existsSync(mPath)) {
|
||||
try { m = JSON.parse(fs.readFileSync(mPath, 'utf8')); } catch (_e) { /* skip */ }
|
||||
}
|
||||
return {
|
||||
name: d,
|
||||
path: dir,
|
||||
version: manifest.version || 'unknown',
|
||||
timestamp: manifest.timestamp || '',
|
||||
filesBackedUp: manifest.filesBackedUp || 0
|
||||
version: m.version || 'unknown',
|
||||
sha: (m.sha || '').slice(0, 7),
|
||||
timestamp: m.timestamp || '',
|
||||
filesBackedUp: m.filesBackedUp || 0,
|
||||
fileCount: m.filesBackedUp || 0
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore from a pre-update backup.
|
||||
* Restore console files from a pre-update backup and revert the SHA.
|
||||
*/
|
||||
function restoreFromBackup(backupName) {
|
||||
if (!/^pre-update-[\d\-T]+$/.test(backupName)) throw new Error('Invalid backup name');
|
||||
const backupPath = path.join(BACKUP_DIR, backupName);
|
||||
if (!fs.existsSync(backupPath)) {
|
||||
throw new Error('Backup not found');
|
||||
}
|
||||
// Validate backup name to prevent directory traversal
|
||||
if (!/^pre-update-[\d\-T]+$/.test(backupName)) {
|
||||
throw new Error('Invalid backup name');
|
||||
}
|
||||
if (!fs.existsSync(backupPath)) throw new Error('Backup not found');
|
||||
|
||||
const manifestPath = path.join(backupPath, 'manifest.json');
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
throw new Error('Invalid backup — missing manifest');
|
||||
}
|
||||
if (!fs.existsSync(manifestPath)) throw new Error('Invalid backup — missing manifest');
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
let restored = 0;
|
||||
|
||||
for (const filePath of (manifest.files || [])) {
|
||||
const src = path.join(backupPath, filePath);
|
||||
const dest = path.join(ROOT_DIR, filePath);
|
||||
@@ -382,7 +559,10 @@ function restoreFromBackup(backupName) {
|
||||
}
|
||||
}
|
||||
|
||||
return { restored, version: manifest.version, totalFiles: (manifest.files || []).length };
|
||||
// Revert SHA to the pre-update value
|
||||
if (manifest.sha) saveLocalSHA(manifest.sha);
|
||||
|
||||
return { restored, version: manifest.version, sha: manifest.sha, totalFiles: (manifest.files || []).length };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -390,7 +570,11 @@ module.exports = {
|
||||
getChangedFiles,
|
||||
createPreUpdateBackup,
|
||||
applyUpdate,
|
||||
restartService,
|
||||
listBackups,
|
||||
restoreFromBackup,
|
||||
getLocalVersion
|
||||
getLocalVersion,
|
||||
getLocalSHA,
|
||||
saveLocalSHA,
|
||||
COMPONENTS
|
||||
};
|
||||
|
||||
@@ -571,7 +571,7 @@
|
||||
<div class="update-version-row">
|
||||
<span class="update-label">${_('updates.latest_version')}</span>
|
||||
<span class="update-value" id="update-remote-version">
|
||||
<span class="text-muted">—</span>
|
||||
<span class="text-muted">—</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="update-version-row" id="update-status-row" style="display:none;">
|
||||
|
||||
Reference in New Issue
Block a user