/** * 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.xyz/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 { 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 { 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); } } }