feat(daemon): add automatic update mechanism (Updater), switch to system journal logs, and expose update controls in the UI

This commit is contained in:
2026-01-09 16:55:43 +00:00
parent 1e86acff55
commit 6a3be55cee
8 changed files with 449 additions and 33 deletions

View File

@@ -0,0 +1,270 @@
/**
* Updater
*
* Handles checking for updates, downloading new versions, and performing upgrades
*/
import { VERSION } from '../version.ts';
import { runCommand } from '../utils/command.ts';
export interface Release {
version: string;
tagName: string;
publishedAt: Date;
downloadUrl: string;
isCurrent: boolean;
isNewer: boolean;
ageHours: number;
}
export interface AutoUpgradeStatus {
enabled: boolean;
targetVersion: string | null;
scheduledIn: string | null;
waitingForStability: boolean;
}
export interface UpdateInfo {
currentVersion: string;
releases: Release[];
autoUpgrade: AutoUpgradeStatus;
lastCheck: string | null;
}
interface GiteaRelease {
id: number;
tag_name: string;
name: string;
body: string;
published_at: string;
assets: GiteaAsset[];
}
interface GiteaAsset {
id: number;
name: string;
browser_download_url: string;
size: number;
}
export class Updater {
private repoApiUrl = 'https://code.foss.global/api/v1/repos/ecobridge/eco-os/releases';
private binaryPath = '/opt/eco/bin/eco-daemon';
private releases: Release[] = [];
private lastCheck: Date | null = null;
private logFn: (msg: string) => void;
constructor(logFn: (msg: string) => void) {
this.logFn = logFn;
}
private log(message: string): void {
this.logFn(`[Updater] ${message}`);
}
/**
* Compare semantic versions
* Returns: -1 if a < b, 0 if a == b, 1 if a > b
*/
private compareVersions(a: string, b: string): number {
const partsA = a.replace(/^v/, '').split('.').map(Number);
const partsB = b.replace(/^v/, '').split('.').map(Number);
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
const numA = partsA[i] || 0;
const numB = partsB[i] || 0;
if (numA < numB) return -1;
if (numA > numB) return 1;
}
return 0;
}
/**
* Fetch available releases from Gitea
*/
async checkForUpdates(): Promise<Release[]> {
this.log('Checking for updates...');
try {
const response = await fetch(this.repoApiUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const giteaReleases: GiteaRelease[] = await response.json();
const currentVersion = VERSION;
const now = new Date();
this.releases = giteaReleases
.filter((r) => r.tag_name.startsWith('v'))
.map((r) => {
const version = r.tag_name.replace(/^v/, '');
const publishedAt = new Date(r.published_at);
const ageMs = now.getTime() - publishedAt.getTime();
const ageHours = ageMs / (1000 * 60 * 60);
// Find the daemon binary asset
const daemonAsset = r.assets.find((a) =>
a.name.includes('eco-daemon')
);
return {
version,
tagName: r.tag_name,
publishedAt,
downloadUrl: daemonAsset?.browser_download_url || '',
isCurrent: version === currentVersion,
isNewer: this.compareVersions(version, currentVersion) > 0,
ageHours: Math.round(ageHours * 10) / 10,
};
})
.filter((r) => r.downloadUrl) // Only include releases with daemon binary
.sort((a, b) => this.compareVersions(b.version, a.version)); // Newest first
this.lastCheck = now;
this.log(`Found ${this.releases.length} releases, ${this.releases.filter((r) => r.isNewer).length} newer than current`);
return this.releases;
} catch (error) {
this.log(`Failed to check for updates: ${error}`);
return this.releases;
}
}
/**
* Get cached releases (call checkForUpdates first)
*/
getReleases(): Release[] {
return this.releases;
}
/**
* Determine if auto-upgrade should happen and to which version
*/
getAutoUpgradeStatus(): AutoUpgradeStatus {
const newerReleases = this.releases.filter((r) => r.isNewer);
if (newerReleases.length === 0) {
return {
enabled: true,
targetVersion: null,
scheduledIn: null,
waitingForStability: false,
};
}
// Find the latest newer release
const latest = newerReleases[0];
const hoursUntilUpgrade = 24 - latest.ageHours;
if (hoursUntilUpgrade <= 0) {
// Ready to upgrade now
return {
enabled: true,
targetVersion: latest.version,
scheduledIn: 'now',
waitingForStability: false,
};
}
// Still waiting for stability period
const hours = Math.floor(hoursUntilUpgrade);
const minutes = Math.round((hoursUntilUpgrade - hours) * 60);
const scheduledIn = hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
return {
enabled: true,
targetVersion: latest.version,
scheduledIn,
waitingForStability: true,
};
}
/**
* Get full update info for API response
*/
getUpdateInfo(): UpdateInfo {
return {
currentVersion: VERSION,
releases: this.releases,
autoUpgrade: this.getAutoUpgradeStatus(),
lastCheck: this.lastCheck?.toISOString() || null,
};
}
/**
* Download and install a specific version
*/
async upgradeToVersion(version: string): Promise<{ success: boolean; message: string }> {
const release = this.releases.find((r) => r.version === version);
if (!release) {
return { success: false, message: `Version ${version} not found` };
}
if (release.isCurrent) {
return { success: false, message: `Already running version ${version}` };
}
this.log(`Starting upgrade to version ${version}...`);
try {
// Download new binary
const tempPath = '/tmp/eco-daemon-new';
this.log(`Downloading from ${release.downloadUrl}...`);
const response = await fetch(release.downloadUrl);
if (!response.ok) {
throw new Error(`Download failed: HTTP ${response.status}`);
}
const data = await response.arrayBuffer();
await Deno.writeFile(tempPath, new Uint8Array(data));
// Verify download
const stat = await Deno.stat(tempPath);
if (stat.size < 1000000) {
// Daemon should be at least 1MB
throw new Error(`Downloaded file too small: ${stat.size} bytes`);
}
this.log(`Downloaded ${stat.size} bytes`);
// Make executable
await Deno.chmod(tempPath, 0o755);
// Replace binary
this.log('Replacing binary...');
await runCommand('mv', [tempPath, this.binaryPath]);
await Deno.chmod(this.binaryPath, 0o755);
// Restart daemon via systemd
this.log('Restarting daemon...');
// Use spawn to avoid waiting for the restart
const restartCmd = new Deno.Command('systemctl', {
args: ['restart', 'eco-daemon'],
stdout: 'null',
stderr: 'null',
});
restartCmd.spawn();
return { success: true, message: `Upgrading to v${version}...` };
} catch (error) {
this.log(`Upgrade failed: ${error}`);
return { success: false, message: String(error) };
}
}
/**
* Check and perform auto-upgrade if conditions are met
*/
async checkAutoUpgrade(): Promise<void> {
await this.checkForUpdates();
const status = this.getAutoUpgradeStatus();
if (status.targetVersion && status.scheduledIn === 'now') {
this.log(`Auto-upgrading to version ${status.targetVersion}...`);
await this.upgradeToVersion(status.targetVersion);
}
}
}