Files
BetterDesk/web-nodejs/services/updateService.js
T
UNITRONIX d37b6c2c88 Add Go server build support to updater
Enable optional compilation and deployment of the Go server as part of the self-update flow. UI: add checkbox/status/info area in settings.js, check /api/settings/updates/server-info, include server component when selected, adjust progress/messages and longer timeouts. API/Server: add server-info endpoint and extend install request timeouts. Service: updateService now sets server localRoot and implements functions to detect Go, fetch server source (git or GitHub API), build the binary, and deploy it. i18n: add related translation keys across many locale files. Misc: improve input validation and error logging in desktop.routes, and return graceful defaults for missing policy routes in policies.routes.
2026-04-16 01:07:13 +02:00

910 lines
33 KiB
JavaScript

/**
* 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: path.join(PROJECT_ROOT, 'betterdesk-server'),
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));
}
// ======================== Server Build Support ===========================
let _updateInProgress = false;
/**
* Run a shell command as a promise (non-blocking unlike execSync).
*/
function execPromise(cmd, opts = {}) {
return new Promise((resolve, reject) => {
const { exec } = require('child_process');
exec(cmd, { maxBuffer: 5 * 1024 * 1024, ...opts }, (err, stdout, stderr) => {
if (err) {
err.stderr = stderr;
err.stdout = stdout;
return reject(err);
}
resolve({ stdout, stderr });
});
});
}
/**
* Copy directory recursively.
*/
function copyDirRecursive(src, dest) {
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
copyDirRecursive(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
/**
* Check if Go toolchain is available.
* @returns {{ available: boolean, version: string|null }}
*/
function checkGoAvailable() {
try {
const version = execSync('go version', { timeout: 10000, stdio: 'pipe' }).toString().trim();
return { available: true, version };
} catch (_e) {
return { available: false, version: null };
}
}
/**
* Detect the installed Go server binary path from the system service.
* @returns {string|null}
*/
function detectServerBinaryPath() {
// 1. Explicit environment variable
if (process.env.BETTERDESK_SERVER_BINARY) {
const p = process.env.BETTERDESK_SERVER_BINARY;
if (fs.existsSync(p)) return p;
}
// 2. Read from systemd / NSSM service definition
try {
if (IS_WINDOWS) {
const out = execSync('nssm get BetterDeskServer Application 2>nul', {
timeout: 5000, stdio: 'pipe'
}).toString().trim();
if (out && fs.existsSync(out)) return out;
} else {
const raw = execSync(
'systemctl show betterdesk-server --property=ExecStart --value 2>/dev/null || true',
{ timeout: 5000, stdio: 'pipe' }
).toString().trim();
// ExecStart value may look like: /opt/rustdesk/betterdesk-server --flag ...
const binPath = raw.replace(/^\{[^}]*path=/, '').replace(/\s*;.*$/, '').split(/\s+/)[0];
if (binPath && fs.existsSync(binPath)) return binPath;
}
} catch (_e) { /* service may not be installed */ }
// 3. Well-known installation paths
const candidates = IS_WINDOWS
? [
'C:\\betterdesk\\betterdesk-server.exe',
'C:\\Program Files\\BetterDesk\\betterdesk-server.exe',
path.join(PROJECT_ROOT, 'betterdesk-server', 'betterdesk-server.exe')
]
: [
'/opt/rustdesk/betterdesk-server',
'/opt/betterdesk/betterdesk-server',
'/usr/local/bin/betterdesk-server',
path.join(PROJECT_ROOT, 'betterdesk-server', 'betterdesk-server')
];
for (const p of candidates) {
if (fs.existsSync(p)) return p;
}
return null;
}
/**
* Ensure full Go server source code is present locally.
* If go.mod already exists, assumes source is present (changed files applied separately).
* Otherwise downloads the full source tree from GitHub.
*
* @param {string} remoteSHA
* @returns {Promise<{ strategy: string, filesDownloaded: number }>}
*/
async function ensureServerSource(remoteSHA) {
const serverDir = COMPONENTS.server.localRoot;
const goModPath = path.join(serverDir, 'go.mod');
if (fs.existsSync(goModPath)) {
return { strategy: 'incremental', filesDownloaded: 0 };
}
fs.mkdirSync(serverDir, { recursive: true });
// --- Try git clone --depth=1 (fastest) ---
try {
const tmpDir = path.join(config.dataDir, '_tmp_server_clone');
if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true });
const repoUrl = GITHUB_TOKEN
? `https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_OWNER}/${GITHUB_REPO}.git`
: `https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}.git`;
execSync(
`git clone --depth=1 --single-branch --branch "${GITHUB_BRANCH}" "${repoUrl}" "${tmpDir}"`,
{ timeout: 120000, stdio: 'pipe' }
);
const srcDir = path.join(tmpDir, 'betterdesk-server');
if (fs.existsSync(srcDir)) {
copyDirRecursive(srcDir, serverDir);
}
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_e) { /* ok */ }
return { strategy: 'git-clone', filesDownloaded: -1 };
} catch (_e) {
/* git not available or clone failed — fall through to API */
}
// --- Fallback: GitHub tree API + raw file downloads ---
const tree = await ghGet(`/repos/${GITHUB_OWNER}/${GITHUB_REPO}/git/trees/${remoteSHA}?recursive=1`);
const serverFiles = (tree.tree || []).filter(t =>
t.path.startsWith('betterdesk-server/') &&
t.type === 'blob' &&
!EXCLUDE_PATTERNS.some(rx => rx.test(t.path))
);
let downloaded = 0;
for (const file of serverFiles) {
try {
const content = await ghDownloadFile(GITHUB_OWNER, GITHUB_REPO, remoteSHA, file.path);
const localPath = file.path.slice(COMPONENTS.server.prefix.length);
const dest = path.join(serverDir, localPath);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, content);
downloaded++;
} catch (err) {
console.error(`[UPDATE] Failed to download ${file.path}: ${err.message}`);
}
}
return { strategy: 'api-download', filesDownloaded: downloaded };
}
/**
* Build the Go server binary from local source.
* Uses async exec to avoid blocking the Node.js event loop.
*
* @returns {Promise<{ success: boolean, binaryPath: string|null, error?: string, duration?: number }>}
*/
async function buildGoServer() {
const serverDir = COMPONENTS.server.localRoot;
if (!fs.existsSync(path.join(serverDir, 'go.mod'))) {
return { success: false, binaryPath: null, error: 'go.mod not found — server source incomplete' };
}
const goCheck = checkGoAvailable();
if (!goCheck.available) {
return { success: false, binaryPath: null, error: 'Go toolchain not installed. Install Go from https://go.dev/dl/' };
}
const binaryName = IS_WINDOWS ? 'betterdesk-server.exe' : 'betterdesk-server';
const outputPath = path.join(serverDir, binaryName);
const start = Date.now();
const buildEnv = { ...process.env, CGO_ENABLED: '0' };
try {
await execPromise('go mod download', {
cwd: serverDir,
timeout: 120000,
env: buildEnv
});
await execPromise(
`go build -trimpath -ldflags="-s -w" -o "${binaryName}" .`,
{ cwd: serverDir, timeout: 300000, env: buildEnv }
);
if (!fs.existsSync(outputPath)) {
return { success: false, binaryPath: null, error: 'Build completed but binary not found' };
}
return { success: true, binaryPath: outputPath, duration: Date.now() - start };
} catch (err) {
const stderr = (err.stderr || '').toString().slice(0, 500);
return { success: false, binaryPath: null, error: `Build failed: ${stderr || err.message}`.trim() };
}
}
/**
* Deploy the compiled binary to the service installation path.
* Creates a timestamped backup of the existing binary first.
*
* @param {string} builtBinaryPath Path to the newly compiled binary
* @param {string} targetPath Service binary path
* @returns {{ success: boolean, backupPath?: string, error?: string }}
*/
function deployServerBinary(builtBinaryPath, targetPath) {
if (!builtBinaryPath || !fs.existsSync(builtBinaryPath)) {
return { success: false, error: 'Compiled binary not found' };
}
if (!targetPath) {
return { success: false, error: 'Target binary path not detected — set BETTERDESK_SERVER_BINARY env var' };
}
// Backup existing binary
let backupPath = null;
if (fs.existsSync(targetPath)) {
backupPath = targetPath + '.bak.' + Date.now();
try {
fs.copyFileSync(targetPath, backupPath);
} catch (err) {
return { success: false, error: `Backup failed: ${err.message}` };
}
}
// Copy new binary to service path
try {
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.copyFileSync(builtBinaryPath, targetPath);
if (!IS_WINDOWS) {
try { fs.chmodSync(targetPath, 0o755); } catch (_e) { /* ok */ }
}
return { success: true, backupPath };
} catch (err) {
// Attempt to restore backup on failure
if (backupPath && fs.existsSync(backupPath)) {
try { fs.copyFileSync(backupPath, targetPath); } catch (_e) { /* critical */ }
}
return { success: false, error: `Deploy failed: ${err.message}` };
}
}
/**
* Get server update readiness info for the UI.
*/
function getServerUpdateInfo() {
const goInfo = checkGoAvailable();
const binaryPath = detectServerBinaryPath();
const sourcePresent = fs.existsSync(path.join(COMPONENTS.server.localRoot || '', 'go.mod'));
return {
goAvailable: goInfo.available,
goVersion: goInfo.version,
binaryPath,
sourcePresent,
canAutoUpdate: goInfo.available
};
}
// ======================== 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 = {}) {
if (_updateInProgress) throw new Error('Another update is already in progress');
_updateInProgress = true;
try {
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 source files + compile + deploy ----
if (changedData.grouped.server?.length && selectedComponents.includes('server')) {
// 1. Ensure full source is present (downloads if missing)
try {
const sourceResult = await ensureServerSource(remoteSHA);
console.log(`[UPDATE] Server source: strategy=${sourceResult.strategy}, files=${sourceResult.filesDownloaded}`);
} catch (err) {
results.failed.push({ file: 'server-source', error: `Source download failed: ${err.message}` });
}
// 2. Download changed server source files (incremental)
const serverDir = COMPONENTS.server.localRoot;
for (const file of changedData.grouped.server) {
try {
if (file.status === 'removed') {
const localPath = file.path.slice(COMPONENTS.server.prefix.length);
const localFile = path.join(serverDir, localPath);
if (fs.existsSync(localFile)) { fs.unlinkSync(localFile); results.removed.push(file.path); }
continue;
}
const content = await ghDownloadFile(GITHUB_OWNER, GITHUB_REPO, remoteSHA, file.path);
const localPath = file.path.slice(COMPONENTS.server.prefix.length);
const dest = path.join(serverDir, 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 });
}
}
// 3. Build binary from source
const buildResult = await buildGoServer();
results.serverBuild = {
success: buildResult.success,
duration: buildResult.duration || 0,
error: buildResult.error || null
};
if (buildResult.success) {
// 4. Deploy to service path
const targetPath = detectServerBinaryPath();
const deployResult = deployServerBinary(buildResult.binaryPath, targetPath);
results.serverDeploy = {
success: deployResult.success,
backupPath: deployResult.backupPath || null,
error: deployResult.error || null
};
if (deployResult.success) {
results.needsServerRestart = true;
}
}
} else if (changedData.grouped.server?.length) {
for (const f of changedData.grouped.server) {
results.skipped.push(f.path + ' (server — not selected)');
}
}
if (changedData.grouped.agent?.length) {
for (const f of changedData.grouped.agent) {
results.skipped.push(f.path + ' (agent — 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;
} finally {
_updateInProgress = false;
}
}
/**
* 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,
getServerUpdateInfo,
COMPONENTS
};