DC-005: Fix all 138 broken test paths after src/ refactor
After the DC-005 module reorganization (41 files moved into src/ subdirs),
138 test suites failed because the refactor script's path-rewrite logic
missed three categories:
1. Files inside src/ doing 'require("./src/...")' — should be 'require("../...")'
2. Files in src/X/Y/ doing 'require("../../../src/...")' — should be 'require("../../...")'
3. Test files in __tests__/ with leftover 'require("../../../src/...")' paths
Root cause: the original refactor script ran before all files were moved,
so it computed relative paths against stale filesystem state.
Result:
- 30/30 test suites pass
- 879/879 tests pass (was: 18/30 suites, 614/687 tests)
Also fixed:
- routes/apps/restore.js: wrong responses import path
- routes/*/*.js: '../../src/utilities/X' → '../src/utilities/X' (depth 2 routes)
This commit is contained in:
@@ -0,0 +1,779 @@
|
||||
/**
|
||||
* DashCaddy Self-Updater
|
||||
* Polls for new versions, downloads and stages updates,
|
||||
* triggers host-side updater for API container rebuilds.
|
||||
*
|
||||
* Frontend files are updated directly (zero-downtime).
|
||||
* API files require a container rebuild via the host-side systemd service.
|
||||
*/
|
||||
|
||||
const EventEmitter = require('events');
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs').promises;
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const os = require('os');
|
||||
const { execSync } = require('child_process');
|
||||
const platformPaths = require('./platform-paths');
|
||||
const isWindows = platformPaths.isWindows;
|
||||
|
||||
const DEFAULTS = {
|
||||
CHECK_INTERVAL: 30 * 60 * 1000, // 30 minutes
|
||||
UPDATE_URL: process.env.DASHCADDY_UPDATE_URL || 'https://get.dashcaddy.net/release',
|
||||
MIRROR_URL: process.env.DASHCADDY_MIRROR_URL || 'https://get2.dashcaddy.net/release',
|
||||
UPDATES_DIR: platformPaths.containerUpdatesDir,
|
||||
// API_SOURCE_DIR is the HOST path — written to trigger.json for the host-side updater
|
||||
API_SOURCE_DIR: path.join(platformPaths.caddySites, 'dashcaddy-api'),
|
||||
// FRONTEND_DIR is the container path — dashboard is volume-mounted at /app/dashboard
|
||||
FRONTEND_DIR: platformPaths.containerFrontendDir,
|
||||
MAX_BACKUPS: 3,
|
||||
HEALTH_TIMEOUT: 60000,
|
||||
DOWNLOAD_TIMEOUT: 120000,
|
||||
CHANNEL: process.env.DASHCADDY_UPDATE_CHANNEL || 'stable',
|
||||
INSTANCE_ID_FILE: platformPaths.isWindows
|
||||
? path.join(platformPaths.caddyBase, 'instance-id')
|
||||
: '/etc/dashcaddy/instance-id',
|
||||
};
|
||||
|
||||
class SelfUpdater extends EventEmitter {
|
||||
constructor(options = {}) {
|
||||
super();
|
||||
this.config = {
|
||||
enabled: options.enabled !== false,
|
||||
checkInterval: parseInt(options.checkInterval || DEFAULTS.CHECK_INTERVAL, 10),
|
||||
updateUrl: options.updateUrl || DEFAULTS.UPDATE_URL,
|
||||
mirrorUrl: options.mirrorUrl || DEFAULTS.MIRROR_URL,
|
||||
updatesDir: options.updatesDir || DEFAULTS.UPDATES_DIR,
|
||||
// hostUpdatesDir is the HOST path that maps to updatesDir inside the container.
|
||||
// Used when writing trigger.json so the host-side script can find staging files.
|
||||
hostUpdatesDir: options.hostUpdatesDir || (platformPaths.isWindows ? options.updatesDir || DEFAULTS.UPDATES_DIR : '/opt/dashcaddy/updates'),
|
||||
apiSourceDir: options.apiSourceDir || DEFAULTS.API_SOURCE_DIR,
|
||||
frontendDir: options.frontendDir || DEFAULTS.FRONTEND_DIR,
|
||||
// hostFrontendDir is the path on the HOST where Caddy serves the dashboard
|
||||
// from. The in-container `frontendDir` is often a path that isn't mounted
|
||||
// (e.g. /app/dashboard with no bind mount), so writing there is silently
|
||||
// useless. When this is set, we pass it to the host-side updater script
|
||||
// and skip the in-container copy entirely.
|
||||
hostFrontendDir: options.hostFrontendDir
|
||||
|| process.env.DASHCADDY_HOST_FRONTEND_DIR
|
||||
|| (platformPaths.isWindows ? null : '/var/www/dashcaddy-status'),
|
||||
maxBackups: parseInt(options.maxBackups || DEFAULTS.MAX_BACKUPS, 10),
|
||||
channel: options.channel || process.env.DASHCADDY_UPDATE_CHANNEL || DEFAULTS.CHANNEL,
|
||||
instanceIdFile: options.instanceIdFile || process.env.DASHCADDY_INSTANCE_ID_FILE || DEFAULTS.INSTANCE_ID_FILE,
|
||||
};
|
||||
|
||||
this.status = 'idle'; // idle | checking | downloading | applying | waiting
|
||||
this.checkTimer = null;
|
||||
this.lastCheckTime = null;
|
||||
this.lastCheckResult = null;
|
||||
this.instanceId = this._loadOrCreateInstanceId();
|
||||
|
||||
// Ensure directories exist
|
||||
this._ensureDirs();
|
||||
|
||||
// Notify-secret lives next to instance-id (alongside updates dir on Linux,
|
||||
// <caddyBase>/notify-secret on Windows). Auto-generated on first start.
|
||||
this.notifySecretFile = options.notifySecretFile
|
||||
|| process.env.DASHCADDY_NOTIFY_SECRET_FILE
|
||||
|| path.join(this.config.updatesDir, 'notify-secret');
|
||||
this.notifySecret = this._loadOrCreateNotifySecret();
|
||||
}
|
||||
|
||||
// ── Lifecycle ──
|
||||
|
||||
start() {
|
||||
if (!this.config.enabled || this.checkTimer) return;
|
||||
|
||||
console.log('[SelfUpdater] Starting auto-update checks every %ds', this.config.checkInterval / 1000);
|
||||
|
||||
// First check after a short delay (let server finish startup)
|
||||
setTimeout(() => {
|
||||
this._autoCheckAndApply();
|
||||
this.checkTimer = setInterval(() => this._autoCheckAndApply(), this.config.checkInterval);
|
||||
}, 15000);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.checkTimer) {
|
||||
clearInterval(this.checkTimer);
|
||||
this.checkTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Version / Identity Info ──
|
||||
|
||||
getLocalVersion() {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
|
||||
let commit = null;
|
||||
try {
|
||||
commit = fs.readFileSync(path.join(__dirname, 'VERSION'), 'utf8').trim();
|
||||
} catch { /* ignore */ }
|
||||
return { version: pkg.version, commit };
|
||||
} catch (e) {
|
||||
return { version: '0.0.0', commit: null };
|
||||
}
|
||||
}
|
||||
|
||||
getInstanceInfo() {
|
||||
return {
|
||||
instanceId: this.instanceId,
|
||||
channel: this.config.channel,
|
||||
hostname: os.hostname(),
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
isWindows,
|
||||
version: this.getLocalVersion(),
|
||||
};
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
getNotifySecret() {
|
||||
return this.notifySecret;
|
||||
}
|
||||
|
||||
// Public wrapper for the auto-check+apply loop, used by the notify endpoint
|
||||
// so the publisher can wake an instance up immediately instead of waiting
|
||||
// for the next 30-min poll. Returns immediately; work runs async.
|
||||
notifyAndApply(triggeredBy = 'notify') {
|
||||
if (this.status !== 'idle' && this.status !== 'checking') {
|
||||
return { accepted: false, reason: `busy (status: ${this.status})`, status: this.status };
|
||||
}
|
||||
// Fire-and-forget; the response shouldn't block on the container rebuild.
|
||||
setImmediate(() => {
|
||||
this._autoCheckAndApply().catch(err =>
|
||||
console.error('[SelfUpdater] %s-triggered update error: %s', triggeredBy, err.message)
|
||||
);
|
||||
});
|
||||
return { accepted: true, triggeredBy };
|
||||
}
|
||||
|
||||
// ── Check for Updates ──
|
||||
|
||||
async checkForUpdate() {
|
||||
this.status = 'checking';
|
||||
try {
|
||||
let remote;
|
||||
let sourceUrl = this.config.updateUrl;
|
||||
try {
|
||||
remote = await this._fetchJson(`${this.config.updateUrl}/version.json`);
|
||||
} catch (primaryErr) {
|
||||
console.warn('[SelfUpdater] Primary server failed:', primaryErr.message, '— trying mirror');
|
||||
try {
|
||||
remote = await this._fetchJson(`${this.config.mirrorUrl}/version.json`);
|
||||
sourceUrl = this.config.mirrorUrl;
|
||||
} catch (mirrorErr) {
|
||||
this.status = 'idle';
|
||||
this.lastCheckTime = Date.now();
|
||||
this.lastCheckResult = { available: false, error: 'Update servers unreachable' };
|
||||
return this.lastCheckResult;
|
||||
}
|
||||
}
|
||||
|
||||
const local = this.getLocalVersion();
|
||||
const policy = this._evaluateReleasePolicy(local, remote);
|
||||
const available = policy.eligible && policy.newer;
|
||||
|
||||
this.lastCheckTime = Date.now();
|
||||
this.lastCheckResult = {
|
||||
available,
|
||||
local,
|
||||
remote,
|
||||
sourceUrl,
|
||||
policy,
|
||||
instance: this.getInstanceInfo(),
|
||||
};
|
||||
this.status = 'idle';
|
||||
|
||||
if (available) {
|
||||
this.emit('update-available', remote);
|
||||
}
|
||||
|
||||
return this.lastCheckResult;
|
||||
} catch (e) {
|
||||
this.status = 'idle';
|
||||
this.lastCheckTime = Date.now();
|
||||
this.lastCheckResult = { available: false, error: e.message };
|
||||
return this.lastCheckResult;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Apply Update ──
|
||||
|
||||
async applyUpdate(remoteInfo) {
|
||||
if (this.status !== 'idle' && this.status !== 'checking') {
|
||||
throw new Error(`Update already in progress (status: ${this.status})`);
|
||||
}
|
||||
|
||||
const local = this.getLocalVersion();
|
||||
const policy = this._evaluateReleasePolicy(local, remoteInfo);
|
||||
if (!policy.eligible) {
|
||||
throw new Error(`Release not eligible for this instance: ${policy.reason}`);
|
||||
}
|
||||
|
||||
const stagingDir = path.join(this.config.updatesDir, 'staging');
|
||||
|
||||
try {
|
||||
// 1. Download (try primary, fallback to mirror)
|
||||
this.status = 'downloading';
|
||||
this.emit('update-progress', { step: 'downloading', version: remoteInfo.version, policy });
|
||||
|
||||
const tarballPath = path.join(this.config.updatesDir, remoteInfo.tarball);
|
||||
const primaryUrl = `${this.config.updateUrl}/${remoteInfo.tarball}`;
|
||||
const mirrorUrl = `${this.config.mirrorUrl}/${remoteInfo.tarball}`;
|
||||
try {
|
||||
await this._downloadFile(primaryUrl, tarballPath);
|
||||
} catch (dlErr) {
|
||||
console.warn('[SelfUpdater] Primary download failed:', dlErr.message, '— trying mirror');
|
||||
// Ensure file is fully cleaned up before mirror attempt
|
||||
try { fs.unlinkSync(tarballPath); } catch { /* ignore */ }
|
||||
await this._downloadFile(mirrorUrl, tarballPath);
|
||||
}
|
||||
|
||||
// 2. Verify SHA-256
|
||||
const hash = await this._computeSha256(tarballPath);
|
||||
if (hash !== remoteInfo.sha256) {
|
||||
await fsp.unlink(tarballPath).catch(() => {});
|
||||
throw new Error(`SHA-256 mismatch: expected ${remoteInfo.sha256}, got ${hash}`);
|
||||
}
|
||||
|
||||
// 3. Extract
|
||||
this.status = 'applying';
|
||||
this.emit('update-progress', { step: 'extracting', version: remoteInfo.version });
|
||||
|
||||
await this._cleanDir(stagingDir);
|
||||
await this._extractTarball(tarballPath, stagingDir);
|
||||
|
||||
// 4. Locate frontend source. The actual deploy is done either here (if no
|
||||
// hostFrontendDir is configured, e.g. Windows or unusual setups) or by
|
||||
// the host-side updater script via trigger.json (preferred path on Linux,
|
||||
// where Caddy serves from /var/www/... outside the container's filesystem).
|
||||
const frontendSrc = this._findDir(stagingDir, 'status');
|
||||
let hostFrontendStagingPath = null;
|
||||
if (frontendSrc && this.config.hostFrontendDir) {
|
||||
// Defer the copy to the host-side script. Just compute the host path
|
||||
// for staging so it can find the files.
|
||||
hostFrontendStagingPath = frontendSrc.replace(this.config.updatesDir, this.config.hostUpdatesDir);
|
||||
} else if (frontendSrc) {
|
||||
// No host path configured — copy in-container (legacy / Windows path).
|
||||
await this._copyDir(frontendSrc, this.config.frontendDir, [
|
||||
'dist', 'css', 'assets', 'vendor', 'js', 'index.html', 'sw.js'
|
||||
]);
|
||||
this.emit('update-progress', { step: 'frontend-updated', version: remoteInfo.version });
|
||||
}
|
||||
|
||||
// 5. Trigger API rebuild (Linux only — host-side systemd service)
|
||||
const apiSrc = this._findDir(stagingDir, 'dashcaddy-api');
|
||||
if (apiSrc && !isWindows) {
|
||||
this.status = 'waiting';
|
||||
this.emit('update-progress', { step: 'triggering-rebuild', version: remoteInfo.version });
|
||||
|
||||
// Convert container path to host path for trigger.json
|
||||
const hostApiSrc = apiSrc.replace(this.config.updatesDir, this.config.hostUpdatesDir);
|
||||
const trigger = {
|
||||
action: 'update',
|
||||
version: remoteInfo.version,
|
||||
commit: remoteInfo.commit,
|
||||
fromVersion: local.version,
|
||||
stagingDir: hostApiSrc,
|
||||
apiSourceDir: this.config.apiSourceDir,
|
||||
frontendStagingDir: hostFrontendStagingPath,
|
||||
frontendTargetDir: this.config.hostFrontendDir || null,
|
||||
timestamp: new Date().toISOString(),
|
||||
channel: this.config.channel,
|
||||
instanceId: this.instanceId,
|
||||
};
|
||||
await fsp.writeFile(
|
||||
path.join(this.config.updatesDir, 'trigger.json'),
|
||||
JSON.stringify(trigger, null, 2)
|
||||
);
|
||||
|
||||
// The host-side systemd service will handle the rest.
|
||||
// After container restart, checkPostUpdateResult() reads the result.
|
||||
this._addToHistory({
|
||||
version: remoteInfo.version,
|
||||
fromVersion: local.version,
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'pending',
|
||||
frontendUpdated: !!frontendSrc,
|
||||
apiUpdated: true,
|
||||
channel: this.config.channel,
|
||||
instanceId: this.instanceId,
|
||||
});
|
||||
} else if (isWindows) {
|
||||
// Windows: frontend updated, API needs manual restart
|
||||
this._addToHistory({
|
||||
version: remoteInfo.version,
|
||||
fromVersion: local.version,
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'partial',
|
||||
frontendUpdated: !!frontendSrc,
|
||||
apiUpdated: false,
|
||||
note: 'API update requires manual container restart on Windows',
|
||||
channel: this.config.channel,
|
||||
instanceId: this.instanceId,
|
||||
});
|
||||
this.status = 'idle';
|
||||
}
|
||||
|
||||
// Clean up tarball
|
||||
await fsp.unlink(tarballPath).catch(() => {});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
fromVersion: local.version,
|
||||
toVersion: remoteInfo.version,
|
||||
frontendUpdated: !!frontendSrc,
|
||||
apiUpdated: !isWindows && !!apiSrc,
|
||||
policy,
|
||||
};
|
||||
} catch (e) {
|
||||
this.status = 'idle';
|
||||
this._addToHistory({
|
||||
version: remoteInfo.version,
|
||||
fromVersion: local.version,
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'failed',
|
||||
error: e.message,
|
||||
channel: this.config.channel,
|
||||
instanceId: this.instanceId,
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Post-Update Result ──
|
||||
|
||||
async checkPostUpdateResult() {
|
||||
const resultPath = path.join(this.config.updatesDir, 'result.json');
|
||||
try {
|
||||
const data = await fsp.readFile(resultPath, 'utf8');
|
||||
const result = JSON.parse(data);
|
||||
// Delete the result file so we don't process it again
|
||||
await fsp.unlink(resultPath).catch(() => {});
|
||||
|
||||
// Update the matching history entry, preferring the newest pending item
|
||||
// for the same target version. Fall back to the newest pending item if
|
||||
// older result files lack enough metadata to match more precisely.
|
||||
const history = this.getUpdateHistory();
|
||||
const pendingIndex = history.findIndex(
|
||||
h => h.status === 'pending' && (!result.version || h.version === result.version)
|
||||
);
|
||||
const fallbackIndex = pendingIndex === -1
|
||||
? history.findIndex(h => h.status === 'pending')
|
||||
: -1;
|
||||
const historyIndex = pendingIndex !== -1 ? pendingIndex : fallbackIndex;
|
||||
|
||||
if (historyIndex !== -1) {
|
||||
const pending = history[historyIndex];
|
||||
pending.status = result.success ? 'success' : 'rolled-back';
|
||||
pending.duration = result.duration;
|
||||
if (result.error) pending.error = result.error;
|
||||
if (result.version) pending.version = result.version;
|
||||
if (result.timestamp) pending.completedAt = result.timestamp;
|
||||
this._saveHistory(history);
|
||||
}
|
||||
|
||||
this.status = 'idle';
|
||||
return result;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rollback ──
|
||||
|
||||
async rollbackToVersion(version) {
|
||||
if (isWindows) throw new Error('Auto-rollback not supported on Windows');
|
||||
|
||||
const backupDir = path.join(this.config.updatesDir, 'backups', version);
|
||||
try {
|
||||
await fsp.access(backupDir);
|
||||
} catch (_) {
|
||||
throw new Error(`No backup found for version ${version}`);
|
||||
}
|
||||
|
||||
const local = this.getLocalVersion();
|
||||
const hostBackupDir = backupDir.replace(this.config.updatesDir, this.config.hostUpdatesDir);
|
||||
const trigger = {
|
||||
action: 'rollback',
|
||||
version: version,
|
||||
fromVersion: local.version,
|
||||
stagingDir: hostBackupDir,
|
||||
apiSourceDir: this.config.apiSourceDir,
|
||||
timestamp: new Date().toISOString(),
|
||||
channel: this.config.channel,
|
||||
instanceId: this.instanceId,
|
||||
};
|
||||
|
||||
this.status = 'waiting';
|
||||
await fsp.writeFile(
|
||||
path.join(this.config.updatesDir, 'trigger.json'),
|
||||
JSON.stringify(trigger, null, 2)
|
||||
);
|
||||
|
||||
this._addToHistory({
|
||||
version: version,
|
||||
fromVersion: local.version,
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'pending',
|
||||
rollback: true,
|
||||
channel: this.config.channel,
|
||||
instanceId: this.instanceId,
|
||||
});
|
||||
}
|
||||
|
||||
getAvailableRollbacks() {
|
||||
const backupsDir = path.join(this.config.updatesDir, 'backups');
|
||||
try {
|
||||
return fs.readdirSync(backupsDir)
|
||||
.filter(d => fs.statSync(path.join(backupsDir, d)).isDirectory())
|
||||
.sort()
|
||||
.reverse();
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── History ──
|
||||
|
||||
getUpdateHistory() {
|
||||
const historyPath = path.join(this.config.updatesDir, 'self-update-history.json');
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(historyPath, 'utf8'));
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private Methods ──
|
||||
|
||||
async _autoCheckAndApply() {
|
||||
try {
|
||||
const result = await this.checkForUpdate();
|
||||
if (result.available && result.remote) {
|
||||
console.log('[SelfUpdater] Update available: %s → %s', result.local.version, result.remote.version);
|
||||
await this.applyUpdate(result.remote);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[SelfUpdater] Auto-update error:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
_evaluateReleasePolicy(local, remote) {
|
||||
const releaseChannel = remote?.channel || remote?.releaseChannel || 'stable';
|
||||
const allowedChannels = Array.isArray(remote?.channels)
|
||||
? remote.channels
|
||||
: Array.isArray(remote?.eligibleChannels)
|
||||
? remote.eligibleChannels
|
||||
: [releaseChannel];
|
||||
|
||||
if (!allowedChannels.includes(this.config.channel)) {
|
||||
return {
|
||||
eligible: false,
|
||||
newer: this._isNewer(local, remote),
|
||||
reason: `channel mismatch (${this.config.channel} not in ${allowedChannels.join(', ')})`,
|
||||
releaseChannel,
|
||||
allowedChannels,
|
||||
};
|
||||
}
|
||||
|
||||
if (remote?.revoked === true) {
|
||||
return {
|
||||
eligible: false,
|
||||
newer: this._isNewer(local, remote),
|
||||
reason: 'release revoked',
|
||||
releaseChannel,
|
||||
allowedChannels,
|
||||
};
|
||||
}
|
||||
|
||||
const minUpdaterVersion = remote?.minUpdaterVersion;
|
||||
if (minUpdaterVersion && this._compareVersions(local.version, minUpdaterVersion) < 0) {
|
||||
return {
|
||||
eligible: false,
|
||||
newer: this._isNewer(local, remote),
|
||||
reason: `requires updater >= ${minUpdaterVersion}`,
|
||||
releaseChannel,
|
||||
allowedChannels,
|
||||
};
|
||||
}
|
||||
|
||||
const rollout = this._normalizeRollout(remote?.rollout);
|
||||
if (rollout < 100) {
|
||||
const bucket = this._getRolloutBucket(this.instanceId);
|
||||
if (bucket >= rollout) {
|
||||
return {
|
||||
eligible: false,
|
||||
newer: this._isNewer(local, remote),
|
||||
reason: `outside rollout (${bucket} >= ${rollout})`,
|
||||
releaseChannel,
|
||||
allowedChannels,
|
||||
rollout,
|
||||
rolloutBucket: bucket,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const targets = remote?.targets;
|
||||
if (targets && typeof targets === 'object') {
|
||||
const platformKey = `${process.platform}-${process.arch}`;
|
||||
const matchedTarget = targets[platformKey] || targets[process.platform] || targets.default;
|
||||
if (!matchedTarget) {
|
||||
return {
|
||||
eligible: false,
|
||||
newer: this._isNewer(local, remote),
|
||||
reason: `no target for ${platformKey}`,
|
||||
releaseChannel,
|
||||
allowedChannels,
|
||||
rollout,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
eligible: true,
|
||||
newer: this._isNewer(local, remote),
|
||||
reason: 'eligible',
|
||||
releaseChannel,
|
||||
allowedChannels,
|
||||
rollout,
|
||||
rolloutBucket: this._getRolloutBucket(this.instanceId),
|
||||
};
|
||||
}
|
||||
|
||||
_isNewer(local, remote) {
|
||||
if (!remote || !remote.version) return false;
|
||||
const versionCompare = this._compareVersions(local.version || '0.0.0', remote.version);
|
||||
if (versionCompare < 0) return true;
|
||||
if (versionCompare > 0) return false;
|
||||
// Same version — check commit hash
|
||||
if (remote.commit && local.commit && remote.commit !== local.commit) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
_compareVersions(a, b) {
|
||||
const av = String(a || '0.0.0').split('.').map(part => parseInt(part, 10) || 0);
|
||||
const bv = String(b || '0.0.0').split('.').map(part => parseInt(part, 10) || 0);
|
||||
const len = Math.max(av.length, bv.length, 3);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const left = av[i] || 0;
|
||||
const right = bv[i] || 0;
|
||||
if (left > right) return 1;
|
||||
if (left < right) return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
_normalizeRollout(value) {
|
||||
if (value == null) return 100;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return 100;
|
||||
return Math.max(0, Math.min(100, Math.floor(parsed)));
|
||||
}
|
||||
|
||||
_getRolloutBucket(instanceId) {
|
||||
const digest = crypto.createHash('sha256').update(String(instanceId || 'unknown')).digest();
|
||||
return digest[0] % 100;
|
||||
}
|
||||
|
||||
_loadOrCreateNotifySecret() {
|
||||
try {
|
||||
if (fs.existsSync(this.notifySecretFile)) {
|
||||
const existing = fs.readFileSync(this.notifySecretFile, 'utf8').trim();
|
||||
if (existing) return existing;
|
||||
}
|
||||
} catch (_) { /* regenerate */ }
|
||||
|
||||
const secret = crypto.randomBytes(24).toString('base64url');
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(this.notifySecretFile), { recursive: true });
|
||||
fs.writeFileSync(this.notifySecretFile, `${secret}\n`, { mode: 0o600 });
|
||||
} catch (error) {
|
||||
console.warn('[SelfUpdater] Failed to persist notify secret:', error.message);
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
_loadOrCreateInstanceId() {
|
||||
try {
|
||||
if (fs.existsSync(this.config.instanceIdFile)) {
|
||||
const existing = fs.readFileSync(this.config.instanceIdFile, 'utf8').trim();
|
||||
if (existing) return existing;
|
||||
}
|
||||
} catch (_) {
|
||||
// Fall through and regenerate
|
||||
}
|
||||
|
||||
const instanceId = crypto.randomUUID();
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(this.config.instanceIdFile), { recursive: true });
|
||||
fs.writeFileSync(this.config.instanceIdFile, `${instanceId}\n`, 'utf8');
|
||||
} catch (error) {
|
||||
console.warn('[SelfUpdater] Failed to persist instance ID:', error.message);
|
||||
}
|
||||
return instanceId;
|
||||
}
|
||||
|
||||
_addToHistory(entry) {
|
||||
const history = this.getUpdateHistory();
|
||||
history.unshift(entry);
|
||||
// Keep last 50 entries
|
||||
if (history.length > 50) history.length = 50;
|
||||
this._saveHistory(history);
|
||||
}
|
||||
|
||||
_saveHistory(history) {
|
||||
const historyPath = path.join(this.config.updatesDir, 'self-update-history.json');
|
||||
try {
|
||||
fs.writeFileSync(historyPath, JSON.stringify(history, null, 2));
|
||||
} catch (e) {
|
||||
console.error('[SelfUpdater] Failed to save history:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async _ensureDirs() {
|
||||
for (const dir of [this.config.updatesDir, path.join(this.config.updatesDir, 'staging'), path.join(this.config.updatesDir, 'backups')]) {
|
||||
await fsp.mkdir(dir, { recursive: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async _fetchJson(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const mod = url.startsWith('https') ? https : http;
|
||||
const req = mod.get(url, { timeout: 15000 }, (res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
res.resume();
|
||||
return reject(new Error(`HTTP ${res.statusCode} from ${url}`));
|
||||
}
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(data));
|
||||
} catch (e) {
|
||||
reject(new Error('Invalid JSON from ' + url));
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => { req.destroy(); reject(new Error('Timeout fetching ' + url)); });
|
||||
});
|
||||
}
|
||||
|
||||
async _downloadFile(url, dest) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const mod = url.startsWith('https') ? https : http;
|
||||
const file = fs.createWriteStream(dest);
|
||||
const req = mod.get(url, { timeout: DEFAULTS.DOWNLOAD_TIMEOUT }, (res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
file.close();
|
||||
fs.unlinkSync(dest);
|
||||
return reject(new Error(`HTTP ${res.statusCode} downloading ${url}`));
|
||||
}
|
||||
res.pipe(file);
|
||||
file.on('finish', () => { file.close(resolve); });
|
||||
});
|
||||
req.on('error', (e) => {
|
||||
file.close();
|
||||
fs.unlink(dest, () => {});
|
||||
reject(e);
|
||||
});
|
||||
req.on('timeout', () => { req.destroy(); reject(new Error('Download timeout')); });
|
||||
});
|
||||
}
|
||||
|
||||
async _computeSha256(filePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = crypto.createHash('sha256');
|
||||
const stream = fs.createReadStream(filePath);
|
||||
stream.on('data', chunk => hash.update(chunk));
|
||||
stream.on('end', () => resolve(hash.digest('hex')));
|
||||
stream.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async _extractTarball(tarballPath, destDir) {
|
||||
await fsp.mkdir(destDir, { recursive: true });
|
||||
// Use tar command (available on Linux, and Git Bash on Windows)
|
||||
try {
|
||||
execSync(`tar xzf "${tarballPath}" -C "${destDir}" --strip-components=1`, { stdio: 'pipe' });
|
||||
} catch (e) {
|
||||
throw new Error('Failed to extract tarball: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
_findDir(baseDir, name) {
|
||||
const direct = path.join(baseDir, name);
|
||||
if (fs.existsSync(direct)) return direct;
|
||||
// Also check one level deeper (e.g., dashcaddy/dashcaddy-api)
|
||||
try {
|
||||
for (const entry of fs.readdirSync(baseDir)) {
|
||||
const sub = path.join(baseDir, entry, name);
|
||||
if (fs.existsSync(sub)) return sub;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
async _copyDir(src, dest, items) {
|
||||
await fsp.mkdir(dest, { recursive: true });
|
||||
for (const item of items) {
|
||||
const srcPath = path.join(src, item);
|
||||
const destPath = path.join(dest, item);
|
||||
try {
|
||||
const stat = await fsp.stat(srcPath);
|
||||
if (stat.isDirectory()) {
|
||||
await this._copyDirRecursive(srcPath, destPath);
|
||||
} else {
|
||||
await fsp.copyFile(srcPath, destPath);
|
||||
}
|
||||
} catch (_) {
|
||||
// Item may not exist in the update — skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _copyDirRecursive(src, dest) {
|
||||
await fsp.mkdir(dest, { recursive: true });
|
||||
const entries = await fsp.readdir(src, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const srcPath = path.join(src, entry.name);
|
||||
const destPath = path.join(dest, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await this._copyDirRecursive(srcPath, destPath);
|
||||
} else {
|
||||
await fsp.copyFile(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _cleanDir(dir) {
|
||||
try {
|
||||
await fsp.rm(dir, { recursive: true, force: true });
|
||||
} catch { /* ignore */ }
|
||||
await fsp.mkdir(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton
|
||||
const selfUpdater = new SelfUpdater({
|
||||
enabled: process.env.DASHCADDY_UPDATE_ENABLED !== 'false',
|
||||
checkInterval: process.env.DASHCADDY_UPDATE_INTERVAL,
|
||||
updateUrl: process.env.DASHCADDY_UPDATE_URL,
|
||||
mirrorUrl: process.env.DASHCADDY_MIRROR_URL,
|
||||
updatesDir: process.env.DASHCADDY_UPDATES_DIR,
|
||||
hostUpdatesDir: process.env.DASHCADDY_HOST_UPDATES_DIR,
|
||||
apiSourceDir: process.env.DASHCADDY_API_SOURCE_DIR,
|
||||
frontendDir: process.env.DASHCADDY_FRONTEND_DIR,
|
||||
channel: process.env.DASHCADDY_UPDATE_CHANNEL,
|
||||
instanceIdFile: process.env.DASHCADDY_INSTANCE_ID_FILE,
|
||||
});
|
||||
|
||||
module.exports = selfUpdater;
|
||||
module.exports.SelfUpdater = SelfUpdater;
|
||||
Reference in New Issue
Block a user