// Nach einer neuen Fassung sehen und sie bereitlegen. // // Bereitlegen, nicht einspielen: Windows sperrt die Dateien laufender // Programme, und mitten im Stream soll ohnehin nichts ausgetauscht werden. // Das neue Paket landet unter `pending\`; `launcher.ps1` tauscht es beim // naechsten Start aus, wenn nichts laeuft. Siehe ADR-0007. // // Die Adresse steht in der Konfiguration und hat bewusst KEINE Vorgabe. Ein // erfundener Standardwert sieht so lange richtig aus, bis jemand ihn aufruft // und eine Anmeldeseite als Skript ausgefuehrt bekommt. import crypto from 'node:crypto'; import fs from 'node:fs/promises'; import path from 'node:path'; import { loadConfig } from '#core/config.ts'; import { installRoot } from '#core/platform.ts'; import { readZip } from '#core/zip.ts'; import { readSecrets } from '#twitch/secrets.ts'; type Manifest = { version: string; datei: string; groesse: number; sha256: string; }; /** * Versionen vergleichen. `2026.9.9` ist aelter als `2026.10.1` - ein * Zeichenkettenvergleich saehe das andersherum. */ export function isNewer(candidate: string, current: string): boolean { const parts = (v: string) => v.split(/[.\-+]/).map((p) => (/^\d+$/.test(p) ? Number(p) : p)); const a = parts(candidate); const b = parts(current); for (let i = 0; i < Math.max(a.length, b.length); i++) { const x = a[i] ?? 0; const y = b[i] ?? 0; if (x === y) continue; if (typeof x === 'number' && typeof y === 'number') return x > y; return String(x) > String(y); } return false; } /** * Eine Antwort, die wie eine Anmeldeseite aussieht, ist keine Datei. * * Genau das ist einmal passiert: `git.zeasy.dev` ist komplett angemeldet-only * und antwortet auf jede Adresse mit 303 auf `/user/login`. Wer dem folgt, * bekommt HTML - und `iex` versucht, es als PowerShell auszufuehren. */ export function looksLikeLogin(contentType: string | null, body: Buffer): boolean { if (contentType?.includes('text/html')) return true; const head = body.subarray(0, 200).toString('utf8').toLowerCase(); return head.includes(' { const raw = await fs.readFile(path.join(installRoot, 'version.json'), 'utf8').catch(() => null); if (!raw) return '0'; return (JSON.parse(raw) as { version?: string }).version ?? '0'; } /** Adresse und - falls hinterlegt - der Zugriffstoken. */ function quelle(): { url: string; headers: Record } { const url = (process.env['ABDULIVE_RELEASE_URL'] ?? loadConfig().update?.url ?? '').trim(); if (!url) { throw new Error( 'Keine Update-Adresse hinterlegt.\n\n' + ' In config.yaml eintragen:\n' + ' update:\n' + ' url: "https://.../abdulive"\n\n' + ' Dort muessen `abdulive.json` und die darin genannte ZIP-Datei liegen.\n' + ' Gebaut werden beide mit: node scripts/build-package.ts'); } const headers: Record = { 'user-agent': 'abdulive-update' }; const token = process.env['ABDULIVE_UPDATE_TOKEN'] ?? readSecrets().update?.token; // Gitea und Forgejo erwarten genau diese Form; ein Bearer-Token lehnen sie ab. if (token) headers['authorization'] = `token ${token}`; return { url: url.replace(/\/+$/, ''), headers }; } async function hole(url: string, headers: Record): Promise { // Umleitungen NICHT automatisch folgen: eine Anmeldeseite ist genau das, // und ihr zu folgen liefert HTML statt einer Fehlermeldung. const res = await fetch(url, { headers, redirect: 'manual' }); if (res.status >= 300 && res.status < 400) { const ziel = res.headers.get('location') ?? '(ohne Angabe)'; throw new Error( `${url}\n antwortet mit ${res.status} und leitet nach ${ziel} um.\n\n` + (ziel.includes('login') ? ' Das ist eine Anmeldeseite - die Ablage ist nicht oeffentlich.\n' + ' Entweder die Pakete an einen Ort ohne Anmeldung legen, oder einen\n' + ' Lese-Token auf der Einrichtungsseite hinterlegen.' : ' Umleitungen werden hier absichtlich nicht verfolgt.')); } if (!res.ok) throw new Error(`${url}\n antwortet mit HTTP ${res.status}.`); const body = Buffer.from(await res.arrayBuffer()); if (looksLikeLogin(res.headers.get('content-type'), body)) { throw new Error( `${url}\n liefert eine HTML-Seite statt einer Datei.\n\n` + ' Vermutlich eine Anmelde- oder Fehlerseite. Die Adresse im Browser\n' + ' aufrufen zeigt, was dort wirklich steht.'); } return body; } export async function checkForUpdate(opts: { force?: boolean } = {}): Promise { const { url, headers } = quelle(); const current = await localVersion(); console.log(`Quelle: ${url}`); console.log(`Installiert: ${current}`); const manifest = JSON.parse((await hole(`${url}/abdulive.json`, headers)).toString('utf8')) as Manifest; console.log(`Angeboten: ${manifest.version}`); if (!opts.force && !isNewer(manifest.version, current)) { console.log('\nNichts zu tun.'); return; } const pending = path.join(installRoot, 'pending'); // Ein Rest von einem abgebrochenen Versuch darf nicht mit eingespielt // werden - also erst weg damit. await fs.rm(pending, { recursive: true, force: true }); process.stdout.write(`\nLade ${manifest.datei} ... `); const zip = await hole(`${url}/${manifest.datei}`, headers); console.log(`${(zip.length / 1e6).toFixed(0)} MB`); const sum = crypto.createHash('sha256').update(zip).digest('hex'); if (sum !== manifest.sha256.toLowerCase()) { throw new Error( `Pruefsumme stimmt nicht.\n erwartet ${manifest.sha256}\n bekommen ${sum}\n` + 'Das Paket wird nicht eingespielt.'); } console.log('Pruefsumme stimmt.'); let count = 0; for (const entry of readZip(zip)) { if (entry.isDirectory) continue; const target = path.join(pending, entry.name); await fs.mkdir(path.dirname(target), { recursive: true }); await fs.writeFile(target, entry.data()); count++; } console.log(`${count} Dateien nach ${pending} gelegt.`); console.log('\nEingespielt wird beim naechsten Start - waehrend eines Streams'); console.log('wird nichts ausgetauscht. Sofort uebernehmen:'); console.log(' Stop-ScheduledTask -TaskName abdulive; Start-ScheduledTask -TaskName abdulive'); }