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
155 lines
6.3 KiB
TypeScript
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');
|
|
}
|