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:
UNITRONIX
2026-04-07 00:37:03 +02:00
parent 428fe24924
commit 611ae655b1
8 changed files with 691 additions and 316 deletions
+18 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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脚本或手动编译。"
}
}
+99
View File
@@ -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;
}
+102 -49
View File
@@ -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} &middot; ${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')}
+29 -35
View File
@@ -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);
}
+406 -222
View File
@@ -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
};
+1 -1
View File
@@ -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">&mdash;</span>
</span>
</div>
<div class="update-version-row" id="update-status-row" style="display:none;">