diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 35c4ec9..7c2b6cf 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -32,16 +32,8 @@ jobs: npm version ${{ steps.version.outputs.version_number }} --no-git-tag-version --allow-same-version echo "export const VERSION = \"${{ steps.version.outputs.version_number }}\";" > ecoos_daemon/ts/version.ts - - name: Build daemon binary - run: pnpm run daemon:bundle - - - name: Build ISO with Docker - run: | - cp ecoos_daemon/bundle/eco-daemon isobuild/config/includes.chroot/opt/eco/bin/ - mkdir -p .nogit/iso - docker build -t ecoos-builder -f isobuild/Dockerfile . - docker run --rm --privileged -v ${{ github.workspace }}/.nogit/iso:/output ecoos-builder - ls -la .nogit/iso/ + - name: Build ISO + run: pnpm run build - name: Prepare release assets run: | diff --git a/changelog.md b/changelog.md index f4c3621..a659fe2 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,15 @@ # Changelog +## 2026-01-09 - 0.3.0 - feat(daemon) +add automatic update mechanism (Updater), switch to system journal logs, and expose update controls in the UI + +- Introduce Updater class: fetches releases from Gitea, computes auto-upgrade eligibility, downloads daemon binary, replaces binary and restarts service. +- Integrate updater into EcoDaemon: new methods getUpdateInfo, checkForUpdates, upgradeToVersion; run initial update check on startup and periodic auto-upgrade checks (hourly). +- Replace serial console reader with a journalctl-based system journal reader; rename serialLogs → systemLogs and update related logic and limits. +- UI/server: add API endpoints /api/updates, /api/updates/check and /api/upgrade; add an Updates panel to show current version, available releases, auto-upgrade status, and client-side actions to check and trigger upgrades; poll update info periodically. +- Version bump to 0.2.2 (package.json and ecoos_daemon/ts/version.ts). +- Build/workflow changes: release workflow now runs build step (Build ISO) and package.json build script adjusted for CI and updated Docker build/run handling. + ## 2026-01-09 - 0.2.1 - fix(ci) use GitHub Actions workspace for docker volume and add listing of build output directory for debugging diff --git a/ecoos_daemon/ts/daemon/index.ts b/ecoos_daemon/ts/daemon/index.ts index c648f5c..e0a31aa 100644 --- a/ecoos_daemon/ts/daemon/index.ts +++ b/ecoos_daemon/ts/daemon/index.ts @@ -6,6 +6,7 @@ import { ProcessManager } from './process-manager.ts'; import { SystemInfo } from './system-info.ts'; +import { Updater } from './updater.ts'; import { UIServer } from '../ui/server.ts'; import { runCommand } from '../utils/command.ts'; import { VERSION } from '../version.ts'; @@ -28,12 +29,14 @@ export class EcoDaemon { private config: DaemonConfig; private processManager: ProcessManager; private systemInfo: SystemInfo; + private updater: Updater; private uiServer: UIServer; private logs: string[] = []; - private serialLogs: string[] = []; + private systemLogs: string[] = []; private swayStatus: ServiceStatus = { state: 'stopped' }; private chromiumStatus: ServiceStatus = { state: 'stopped' }; private manualRestartUntil: number = 0; // Timestamp until which auto-restart is disabled + private lastAutoUpgradeCheck: number = 0; // Timestamp of last auto-upgrade check constructor(config?: Partial) { this.config = { @@ -45,6 +48,7 @@ export class EcoDaemon { this.processManager = new ProcessManager(this.config.user); this.systemInfo = new SystemInfo(); + this.updater = new Updater((msg) => this.log(msg)); this.uiServer = new UIServer(this.config.uiPort, this); } @@ -64,8 +68,8 @@ export class EcoDaemon { return [...this.logs]; } - getSerialLogs(): string[] { - return [...this.serialLogs]; + getSystemLogs(): string[] { + return [...this.systemLogs]; } async getStatus(): Promise> { @@ -78,7 +82,7 @@ export class EcoDaemon { chromiumStatus: this.chromiumStatus, systemInfo, logs: this.logs.slice(-50), - serialLogs: this.serialLogs.slice(-50), + systemLogs: this.systemLogs.slice(-50), }; } @@ -131,6 +135,18 @@ export class EcoDaemon { } } + async getUpdateInfo(): Promise { + return this.updater.getUpdateInfo(); + } + + async checkForUpdates(): Promise { + await this.updater.checkForUpdates(); + } + + async upgradeToVersion(version: string): Promise<{ success: boolean; message: string }> { + return this.updater.upgradeToVersion(version); + } + async start(): Promise { this.log('EcoOS Daemon starting...'); @@ -139,8 +155,11 @@ export class EcoDaemon { await this.uiServer.start(); this.log('Management UI started successfully'); - // Start serial console reader in the background - this.startSerialReader(); + // Start system journal reader in the background + this.startJournalReader(); + + // Check for updates on startup + this.updater.checkForUpdates().catch((e) => this.log(`Initial update check failed: ${e}`)); // Start the Sway/Chromium initialization in the background // This allows the UI server to remain responsive even if Sway fails @@ -313,12 +332,19 @@ export class EcoDaemon { return parseInt(result.stdout.trim(), 10); } - private startSerialReader(): void { + private startJournalReader(): void { (async () => { try { - const file = await Deno.open('/dev/ttyS0', { read: true }); - this.log('Serial console reader started on /dev/ttyS0'); - const reader = file.readable.getReader(); + const cmd = new Deno.Command('journalctl', { + args: ['-f', '--no-pager', '-n', '100', '-o', 'short-iso'], + stdout: 'piped', + stderr: 'piped', + }); + + const process = cmd.spawn(); + this.log('System journal reader started'); + + const reader = process.stdout.getReader(); const decoder = new TextDecoder(); while (true) { @@ -326,14 +352,14 @@ export class EcoDaemon { if (done) break; const text = decoder.decode(value); for (const line of text.split('\n').filter((l) => l.trim())) { - this.serialLogs.push(line); - if (this.serialLogs.length > 1000) { - this.serialLogs = this.serialLogs.slice(-1000); + this.systemLogs.push(line); + if (this.systemLogs.length > 1000) { + this.systemLogs = this.systemLogs.slice(-1000); } } } } catch (error) { - this.log(`Serial reader not available: ${error}`); + this.log(`Journal reader not available: ${error}`); } })(); } @@ -383,6 +409,16 @@ export class EcoDaemon { await this.tryStartSwayAndChromium(); } } + + // Check for auto-upgrades every hour + const now = Date.now(); + const oneHour = 60 * 60 * 1000; + if (now - this.lastAutoUpgradeCheck > oneHour) { + this.lastAutoUpgradeCheck = now; + this.updater.checkAutoUpgrade().catch((e) => + this.log(`Auto-upgrade check failed: ${e}`) + ); + } } catch (error) { this.log(`Error in monitoring loop: ${error}`); } diff --git a/ecoos_daemon/ts/daemon/updater.ts b/ecoos_daemon/ts/daemon/updater.ts new file mode 100644 index 0000000..5b6067e --- /dev/null +++ b/ecoos_daemon/ts/daemon/updater.ts @@ -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 { + 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); + } + } +} diff --git a/ecoos_daemon/ts/ui/server.ts b/ecoos_daemon/ts/ui/server.ts index 64a4cf5..0219324 100644 --- a/ecoos_daemon/ts/ui/server.ts +++ b/ecoos_daemon/ts/ui/server.ts @@ -104,6 +104,31 @@ export class UIServer { return new Response(JSON.stringify(result), { headers }); } + if (path === '/api/updates') { + const updates = await this.daemon.getUpdateInfo(); + return new Response(JSON.stringify(updates), { headers }); + } + + if (path === '/api/updates/check' && req.method === 'POST') { + await this.daemon.checkForUpdates(); + const updates = await this.daemon.getUpdateInfo(); + return new Response(JSON.stringify(updates), { headers }); + } + + if (path === '/api/upgrade' && req.method === 'POST') { + try { + const body = await req.json(); + const version = body.version; + if (!version) { + return new Response(JSON.stringify({ success: false, message: 'Version required' }), { headers }); + } + const result = await this.daemon.upgradeToVersion(version); + return new Response(JSON.stringify(result), { headers }); + } catch (error) { + return new Response(JSON.stringify({ success: false, message: String(error) }), { headers }); + } + } + return new Response(JSON.stringify({ error: 'Not Found' }), { status: 404, headers, @@ -347,6 +372,18 @@ export class UIServer {
+
+

Updates

+
+
Current Version
+
-
+
+
+
+ +

Input Devices

@@ -362,7 +399,7 @@ export class UIServer {
Daemon Logs
-
Serial Console
+
System Logs
@@ -522,13 +559,13 @@ export class UIServer { logsEl.scrollTop = logsEl.scrollHeight; } - // Serial Logs - if (data.serialLogs) { + // System Logs + if (data.systemLogs) { const serialEl = document.getElementById('serial-logs'); - if (data.serialLogs.length === 0) { - serialEl.innerHTML = '
No serial data available
'; + if (data.systemLogs.length === 0) { + serialEl.innerHTML = '
No system logs available
'; } else { - serialEl.innerHTML = data.serialLogs.map(l => + serialEl.innerHTML = data.systemLogs.map(l => '
' + l + '
' ).join(''); serialEl.scrollTop = serialEl.scrollHeight; @@ -590,6 +627,77 @@ export class UIServer { }); } + function checkForUpdates() { + fetch('/api/updates/check', { method: 'POST' }) + .then(r => r.json()) + .then(updateUpdatesUI) + .catch(err => console.error('Failed to check updates:', err)); + } + + function upgradeToVersion(version) { + if (!confirm('Upgrade to version ' + version + '? The daemon will restart.')) return; + + fetch('/api/upgrade', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ version: version }) + }) + .then(r => r.json()) + .then(result => { + if (result.success) { + document.getElementById('auto-upgrade-status').textContent = result.message; + } else { + alert('Upgrade failed: ' + result.message); + } + }) + .catch(err => alert('Upgrade error: ' + err)); + } + + function updateUpdatesUI(data) { + document.getElementById('current-version').textContent = 'v' + data.currentVersion; + + const list = document.getElementById('updates-list'); + const newerReleases = data.releases.filter(r => r.isNewer); + + if (newerReleases.length === 0) { + list.innerHTML = '
No updates available
'; + } else { + list.innerHTML = newerReleases.map(r => + '
' + + 'v' + r.version + ' (' + formatAge(r.ageHours) + ')' + + '' + + '
' + ).join(''); + } + + const autoStatus = document.getElementById('auto-upgrade-status'); + if (data.autoUpgrade.targetVersion) { + if (data.autoUpgrade.waitingForStability) { + autoStatus.textContent = 'Auto-upgrade to v' + data.autoUpgrade.targetVersion + ' in ' + data.autoUpgrade.scheduledIn + ' (stability period)'; + } else { + autoStatus.textContent = 'Auto-upgrade to v' + data.autoUpgrade.targetVersion + ' pending...'; + } + } else { + autoStatus.textContent = data.lastCheck ? 'Last checked: ' + new Date(data.lastCheck).toLocaleTimeString() : ''; + } + } + + function formatAge(hours) { + if (hours < 1) return Math.round(hours * 60) + 'm ago'; + if (hours < 24) return Math.round(hours) + 'h ago'; + return Math.round(hours / 24) + 'd ago'; + } + + // Fetch updates info periodically + function fetchUpdates() { + fetch('/api/updates') + .then(r => r.json()) + .then(updateUpdatesUI) + .catch(err => console.error('Failed to fetch updates:', err)); + } + fetchUpdates(); + setInterval(fetchUpdates, 60000); // Check every minute + // Initial fetch fetch('/api/status') .then(r => r.json()) diff --git a/ecoos_daemon/ts/version.ts b/ecoos_daemon/ts/version.ts index 782835e..f6c20ef 100644 --- a/ecoos_daemon/ts/version.ts +++ b/ecoos_daemon/ts/version.ts @@ -1 +1 @@ -export const VERSION = "0.1.3"; +export const VERSION = "0.2.3"; diff --git a/isobuild/config/includes.chroot/opt/eco/bin/eco-daemon b/isobuild/config/includes.chroot/opt/eco/bin/eco-daemon index ba03775..e14c1df 100755 Binary files a/isobuild/config/includes.chroot/opt/eco/bin/eco-daemon and b/isobuild/config/includes.chroot/opt/eco/bin/eco-daemon differ diff --git a/package.json b/package.json index fb7ea2d..9b6f489 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,9 @@ { "name": "@ecobridge/eco-os", - "version": "0.2.1", + "version": "0.2.3", "private": true, "scripts": { - "build": "npm version patch --no-git-tag-version && node -e \"const v=require('./package.json').version; require('fs').writeFileSync('ecoos_daemon/ts/version.ts', 'export const VERSION = \\\"'+v+'\\\";\\n');\" && pnpm run daemon:bundle && cp ecoos_daemon/bundle/eco-daemon isobuild/config/includes.chroot/opt/eco/bin/ && mkdir -p .nogit/iso && docker build --no-cache -t ecoos-builder -f isobuild/Dockerfile . && docker run --rm --privileged -v $(pwd)/.nogit/iso:/output ecoos-builder", + "build": "[ -z \"$CI\" ] && npm version patch --no-git-tag-version || true && node -e \"const v=require('./package.json').version; require('fs').writeFileSync('ecoos_daemon/ts/version.ts', 'export const VERSION = \\\"'+v+'\\\";\\n');\" && pnpm run daemon:bundle && cp ecoos_daemon/bundle/eco-daemon isobuild/config/includes.chroot/opt/eco/bin/ && mkdir -p .nogit/iso && docker build --no-cache -t ecoos-builder -f isobuild/Dockerfile . && docker run --privileged --name ecoos-build ecoos-builder && docker cp ecoos-build:/output/ecoos.iso .nogit/iso/ecoos.iso && docker rm ecoos-build", "daemon:dev": "cd ecoos_daemon && deno run --allow-all --watch mod.ts", "daemon:start": "cd ecoos_daemon && deno run --allow-all mod.ts", "daemon:typecheck": "cd ecoos_daemon && deno check mod.ts",