271 lines
7.3 KiB
TypeScript
271 lines
7.3 KiB
TypeScript
/**
|
|
* 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<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);
|
|
}
|
|
}
|
|
}
|