Das Repo ist jetzt oeffentlich, die Pakete haengen an der Veroeffentlichung `latest`. Damit funktioniert `abdulive update` ohne Token. - config.example.yaml traegt die gepruefte Update-Adresse - .gitea/workflows/release.yml baut beim Tag und ersetzt `latest` - Byte Order Mark: Notepad und `Set-Content -Encoding utf8` schreiben eines, und ohne Behandlung hiess der erste Schluessel in der config.yaml "\ufeffchannel" - die Einstellung waere stillschweigend ignoriert worden - Der YAML-Leser hatte gar keine Tests; er liest die Konfiguration, an der alles haengt. Jetzt acht. Der Updater ist einmal komplett durchgelaufen: 0.4.0 erkannt, 119 MB geladen, Pruefsumme geprueft, bereitgelegt, von launcher.ps1 getauscht. 93 Tests, Typpruefung sauber. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AhVSfMFdWnXKCv5JfLnArZ
161 lines
6.5 KiB
TypeScript
161 lines
6.5 KiB
TypeScript
// 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('<!doctype html') || head.includes('<html') ||
|
||
head.includes('/user/login');
|
||
}
|
||
|
||
/** JSON.parse verschluckt sich an einem Byte Order Mark - Windows schreibt eines. */
|
||
export function parseJsonLoose(text: string): unknown {
|
||
return JSON.parse(text.replace(/^/, ''));
|
||
}
|
||
|
||
async function localVersion(): Promise<string> {
|
||
const raw = await fs.readFile(path.join(installRoot, 'version.json'), 'utf8').catch(() => null);
|
||
if (!raw) return '0';
|
||
return (parseJsonLoose(raw) as { version?: string }).version ?? '0';
|
||
}
|
||
|
||
/** Adresse und - falls hinterlegt - der Zugriffstoken. */
|
||
function quelle(): { url: string; headers: Record<string, string> } {
|
||
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<string, string> = { '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<string, string>): Promise<Buffer> {
|
||
// 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<void> {
|
||
const { url, headers } = quelle();
|
||
const current = await localVersion();
|
||
console.log(`Quelle: ${url}`);
|
||
console.log(`Installiert: ${current}`);
|
||
|
||
const manifest = parseJsonLoose(
|
||
(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');
|
||
}
|