From 611ae655b17ab8fda34e1412cdb340fca0bee730 Mon Sep 17 00:00:00 2001
From: UNITRONIX <36471318+UNITRONIX@users.noreply.github.com>
Date: Tue, 7 Apr 2026 00:37:03 +0200
Subject: [PATCH] =?UTF-8?q?feat(update):=20rewrite=20self-update=20system?=
=?UTF-8?q?=20=E2=80=94=20commit-SHA=20based=20detection?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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
---
web-nodejs/lang/en.json | 21 +-
web-nodejs/lang/pl.json | 21 +-
web-nodejs/lang/zh.json | 21 +-
web-nodejs/public/css/pages.css | 99 +++++
web-nodejs/public/js/settings.js | 151 ++++---
web-nodejs/routes/settings.routes.js | 64 ++-
web-nodejs/services/updateService.js | 628 +++++++++++++++++----------
web-nodejs/views/settings.ejs | 2 +-
8 files changed, 691 insertions(+), 316 deletions(-)
diff --git a/web-nodejs/lang/en.json b/web-nodejs/lang/en.json
index 0b979964..368d7713 100644
--- a/web-nodejs/lang/en.json
+++ b/web-nodejs/lang/en.json
@@ -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."
}
}
diff --git a/web-nodejs/lang/pl.json b/web-nodejs/lang/pl.json
index 0584ff73..33b0e689 100644
--- a/web-nodejs/lang/pl.json
+++ b/web-nodejs/lang/pl.json
@@ -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."
}
}
diff --git a/web-nodejs/lang/zh.json b/web-nodejs/lang/zh.json
index ef19672f..978df2e7 100644
--- a/web-nodejs/lang/zh.json
+++ b/web-nodejs/lang/zh.json
@@ -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脚本或手动编译。"
}
}
diff --git a/web-nodejs/public/css/pages.css b/web-nodejs/public/css/pages.css
index bf245af5..efff5745 100644
--- a/web-nodejs/public/css/pages.css
+++ b/web-nodejs/public/css/pages.css
@@ -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;
+}
diff --git a/web-nodejs/public/js/settings.js b/web-nodejs/public/js/settings.js
index 5b6d9bb1..d35cc98a 100644
--- a/web-nodejs/public/js/settings.js
+++ b/web-nodejs/public/js/settings.js
@@ -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 = `${_('updates.update_available')} `;
+ if (data.baselineEstablished) {
+ if (statusBadge) statusBadge.innerHTML = `${_('updates.baseline_set')} `;
+ 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 = `${_('updates.update_available')}${behind} `;
+
+ _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, ' ');
- } else {
- changelogEl.innerHTML = `
${_('updates.no_changelog')}
`;
- }
- }
-
- 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 = `folder ` +
- `${_('updates.files_changed')}: ${_updateState.changedFiles.length} ` +
- `(+${added} / ~${modified} / -${removed})
`;
- }
-
+ 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 = `${_('updates.changes_unavailable')}
`;
- }
+ } catch (_e) {
+ const cl = document.getElementById('update-changelog');
+ if (cl) cl.innerHTML = `${_('updates.changes_unavailable')}
`;
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 = '';
+ for (const c of commits.slice(0, 20)) {
+ const d = c.date ? new Date(c.date).toLocaleDateString() : '';
+ html += `
+ ${Utils.escapeHtml(c.sha || '')}
+ ${Utils.escapeHtml(c.message || '')}
+ ${Utils.escapeHtml(c.author || '')} · ${d}
+
`;
+ }
+ html += '
';
+ changelogEl.innerHTML = html;
+ } else {
+ changelogEl.innerHTML = `${_('updates.no_changelog')}
`;
+ }
+ }
+
+ // ---- 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 = '';
+ for (const [comp, files] of Object.entries(grouped)) {
+ if (!files || files.length === 0) continue;
+ const m = meta[comp] || meta.other;
+ const badge = m.auto
+ ? `
${_('updates.auto')} `
+ : `
${_('updates.manual')} `;
+ html += `
+ ${m.icon}
+ ${m.label}
+ ${files.length} ${_('updates.files')}
+ ${badge}
+
`;
+ }
+ html += '
';
+ html += `${_('updates.total_files')}: ${changesData.totalFiles || 0}
`;
+
+ if (grouped.server?.length > 0) {
+ html += `warning ${_('updates.server_rebuild_warning')}
`;
+ }
+
+ 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 = `${_('updates.no_backups')}
`;
@@ -1133,11 +1185,12 @@
let html = '';
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 += `
${Utils.escapeHtml(b.name)}
- ${date} · ${b.fileCount} ${_('updates.files')}
+ ${date}${sha} · ${b.fileCount || b.filesBackedUp || 0} ${_('updates.files')}
restore ${_('updates.restore')}
diff --git a/web-nodejs/routes/settings.routes.js b/web-nodejs/routes/settings.routes.js
index d1ff246f..45ae324f 100644
--- a/web-nodejs/routes/settings.routes.js
+++ b/web-nodejs/routes/settings.routes.js
@@ -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);
}
diff --git a/web-nodejs/services/updateService.js b/web-nodejs/services/updateService.js
index aeb0f9c9..194d6ee4 100644
--- a/web-nodejs/services/updateService.js
+++ b/web-nodejs/services/updateService.js
@@ -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
};
diff --git a/web-nodejs/views/settings.ejs b/web-nodejs/views/settings.ejs
index 3e6aea98..0b51105e 100644
--- a/web-nodejs/views/settings.ejs
+++ b/web-nodejs/views/settings.ejs
@@ -571,7 +571,7 @@
${_('updates.latest_version')}
- —
+ —