/** * BetterDesk Console - Self-Update 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 * Tracking: data/.update_sha (deployed commit SHA) * * Flow: * 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'); const path = require('path'); 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_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 personal-access token (60 req/h without, 5 000 with) const GITHUB_TOKEN = process.env.UPDATE_GITHUB_TOKEN || ''; // ---------- 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 =================================== /** * 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' }; if (GITHUB_TOKEN) headers['Authorization'] = `Bearer ${GITHUB_TOKEN}`; const req = https.get({ hostname: url.hostname, path: url.pathname + url.search, headers }, (res) => { if (res.statusCode === 301 || res.statusCode === 302) { return ghGet(res.headers.location).then(resolve, reject); } const chunks = []; res.on('data', (c) => chunks.push(c)); res.on('end', () => { const body = Buffer.concat(chunks).toString(); if (res.statusCode >= 400) { 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')); } }); }); req.on('error', reject); req.setTimeout(15000, () => { req.destroy(); reject(new Error('GitHub API timeout')); }); }); } /** * 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 headers = { 'User-Agent': USER_AGENT }; if (GITHUB_TOKEN) headers['Authorization'] = `Bearer ${GITHUB_TOKEN}`; 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); }); } // ======================== 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 || '' }; } function getLocalVersion() { const versionFile = path.join(PROJECT_ROOT, 'VERSION'); if (fs.existsSync(versionFile)) { const v = fs.readFileSync(versionFile, 'utf8').trim(); if (v) return v; } 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 by comparing local commit SHA with remote HEAD. */ async function checkForUpdates() { const localVersion = getLocalVersion(); const localSHA = getLocalSHA(); const remote = await getRemoteHeadSHA(); // 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 { compare = await ghGet(`/repos/${GITHUB_OWNER}/${GITHUB_REPO}/compare/${localSHA}...${remote.sha}`); } catch (err) { // 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 }; } componentSummary[comp].fileCount++; } return { localVersion, 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 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(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'); 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); } 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 console files that will be changed. */ 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 }); const localVersion = getLocalVersion(); const localSHA = getLocalSHA(); let backedUp = 0; 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); fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.copyFileSync(src, dest); backedUp++; } } fs.writeFileSync(path.join(backupPath, 'manifest.json'), JSON.stringify({ version: localVersion, sha: localSHA, timestamp: new Date().toISOString(), filesBackedUp: backedUp, files: allFiles.filter(f => f.component === 'console' && f.localPath).map(f => f.localPath) }, null, 2)); return { backupPath, backedUp }; } /** * 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(remoteSHA, changedData, opts = {}) { const { createBackup = true, components: selectedComponents = ['console', 'scripts'] } = opts; let backupInfo = null; if (createBackup) { const allFiles = Object.values(changedData.grouped).flat(); backupInfo = await createPreUpdateBackup(allFiles); } const results = { applied: [], failed: [], removed: [], skipped: [], npmInstalled: false, servicesRestarted: [], servicesFailed: [], backupPath: backupInfo?.backupPath || null, backedUp: backupInfo?.backedUp || 0, 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; } /** * 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 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: 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 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'); const manifestPath = path.join(backupPath, 'manifest.json'); 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); if (fs.existsSync(src)) { fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.copyFileSync(src, dest); restored++; } } // 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 = { checkForUpdates, getChangedFiles, createPreUpdateBackup, applyUpdate, restartService, listBackups, restoreFromBackup, getLocalVersion, getLocalSHA, saveLocalSHA, COMPONENTS };