Files
abdulive/apps/cli/update.ts
T
ZeasyAlexandClaude Opus 5 08f7c21450 Veroeffentlichung ueber Gitea, BOM-Toleranz, Tests fuer den YAML-Leser
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
2026-09-09 17:43:43 +02:00

161 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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');
}