Files
abdulive/apps/cli/update.ts
T
ZeasyAlexandClaude Opus 5 5f6301268a Native Windows-Auslieferung statt Container
Docker faellt weg. Das Projekt hat keine npm-Abhaengigkeiten und Node fuehrt
TypeScript direkt aus - der Container schrieb nur noch drei Programme fest,
und dafuer braucht es keine Virtualisierung, sondern einen Ordner.

Der Anlass war, dass ADR-0004 zwar `docker compose pull` versprach, es aber
nie ein veroeffentlichtes Image gab. Wer installieren wollte, musste
whisper.cpp aus dem Quelltext uebersetzen.

Plattformschicht (packages/core/src/platform.ts):
- Programme werden neben der Anwendung gesucht, nicht im PATH
- Daten unter %LOCALAPPDATA%, damit Installation und Update ohne
  Administratorrechte auskommen
- Schriften mitgeliefert: ohne fontsdir setzt libass wortlos eine
  Ersatzschrift ein - gemessen 720 statt 826 Pixel Zeilenbreite

Windows-Eigenheiten, an echtem ffmpeg 8.1 durchprobiert:
- Pfade im Filtergraphen brauchen alle drei Schritte (Vorwaertsschraegstriche,
  escapter Doppelpunkt, Anfuehrungszeichen); je zwei davon reichen nicht
- `nice` gibt es nicht, ersetzt durch os.setPriority

Auslieferung:
- scripts/build-package.ts baut das Paket, laeuft auch unter Linux
- install.ps1 ohne Administratorrechte, Autostart ueber den Aufgabenplaner
- update legt neue Fassungen bereit; eingespielt wird beim naechsten Start,
  nie mitten im Stream
- ZIP lesen und schreiben ohne Abhaengigkeiten (packages/core/src/zip.ts)

Kein `irm ... | iex`-Einzeiler: git.zeasy.dev antwortete instanzweit mit einer
Umleitung auf /user/login, und `iex` fuehrte die Anmeldeseite als PowerShell
aus. Der Updater verfolgt Umleitungen jetzt nicht und hat keine erfundene
Vorgabeadresse mehr.

Ausserdem: backup.ps1 war an zwei Stellen dokumentiert, aber nie geschrieben.

ADR-0007 ersetzt ADR-0004. 84 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:39:26 +02:00

155 lines
6.3 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');
}
async function localVersion(): Promise<string> {
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<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 = 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');
}