mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 17:45:42 +00:00
d37b6c2c88
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.
910 lines
33 KiB
JavaScript
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
|
|
};
|