Files
dogfather-universe/server/pruef-chat-neu-stelle.mjs
T
DogFatherGit 077925ab25 Dreizehn rote Pruefungen, eine Ursache: openssl steht nicht im PATH
DER BEFUND

Im naechtlichen Lauf standen dreizehn Pruefungen mit derselben Zeile
rot:

    [uncaughtException] Error: spawnSync openssl ENOENT

Kein Programmfehler. Die Pruefungen brauchen einen https-Vorbau (der
Sitzungskeks des Hauses ist `secure` und kommt ueber http nicht an),
und dafuer erzeugen sie ein Wegwerf-Zertifikat mit openssl.

GEMESSEN, NICHT VERMUTET

  openssl liegt hier:   C:\Program Files\Git\mingw64\bin\openssl.exe
                        C:\Program Files\Git\usr\bin\openssl.exe
  im System-PATH steht: C:\Program Files\Git\cmd   (nur git.exe)
  im Benutzer-PATH:     nichts mit Git

Wer aus Git Bash startet, erbt die mingw-Pfade und merkt nie etwas.
Die Aufgabenplanung startet `node.exe` DIREKT, ohne Shell -- und
bekommt sie nicht. Bei mir gruen, nachts rot, und der Grund steht
nicht im Code.

Das Schlimmste daran ist nicht der Ausfall: Dreizehn dauerhaft rote
Zeilen in einer Notiz, die Filipe morgens liest, gewoehnen einem das
Hinsehen ab -- und decken dabei die echten Befunde zu.

DIE REPARATUR

`server/helfer-openssl.mjs` sucht openssl erst im PATH (ohne `where`
oder `which` -- beides sind selbst Programme und koennen genauso
fehlen), danach an den Orten, an denen es auf einem Windows-Rechner
mit Git wirklich liegt.

Dazu `zertifikatBauen(schluessel, zertifikat, host)`. Die acht
Aufrufformen im Haus waren identisch bis auf die Variablennamen
(schl/zert, schluesselDatei/zertDatei, zKey/zCrt, sk/zt, key/crt) --
eine Stelle traegt alle neunundzwanzig. Zwei Tage zuvor war eine
davon um ein `-addext` aermer als die anderen; gemerkt hat es
niemand, weil der Browser den fehlenden Alternativnamen erst bei
einer Weiterleitung anmahnt. Eine Stelle kann nicht von sich selbst
abweichen.

KEIN EINTRAG IN DEN SYSTEM-PATH. `mingw64\bin` enthaelt rund hundert
Programme mit Unix-Namen (`find`, `sort`, `link`), die gleichnamige
Windows-Befehle verdecken. Das fuer eine Pruefung zu aendern haette
an ganz anderen Stellen Fehler verursacht, die niemand hierher
zurueckverfolgt.

GEGENPROBE IN BEIDE RICHTUNGEN, mit dem PATH des Nachtlaufs:

  vorher (direkter Aufruf):  ABSTURZ: spawnSync openssl ENOENT
  nachher (ueber den Helfer): Zertifikat gebaut, 1236 Bytes

Und eine echte Pruefung unter denselben Bedingungen:

  PATH ohne mingw64 -> pruef-chat-neu-stelle
  63 Pruefungen, 0 Fehler, 0 Abstuerze

Genau diese Datei stand im Nachtlauf mit ENOENT rot.

EINE WACHE DAGEGEN

pruef-struktur prueft ab jetzt, dass niemand openssl wieder direkt
ruft -- der naechste merkt es dort und nicht erst in einem
Nachtlauf, den niemand liest. Drei Gegenproben.

Beim ersten Anlauf schlug sie auf ihre EIGENEN Probetexte an: Sie
liest alle Serverdateien, und dazu gehoert sie selbst. Die Texte
werden jetzt zusammengesetzt. Derselbe Selbsttreffer ist mir heute
schon zweimal passiert.

DER DRITTE AUSGANG BLEIBT, WO ER HINGEHOERT

`helfer-kachel-echtfarbe.mjs` faengt den Fehler weiterhin ab und
meldet `moeglich: false` mit Grund, statt abzubrechen. Eine
Sicherung abzuschaffen, weil ihr Anlass gerade behoben ist, ist der
Anfang des naechsten stillen Fehlschlags.

NEBENBEI: pruef-community-sicht

Sie war rot mit "ZU VIEL: reaktion.html" -- mein eigener Rueckstand
vom 28.09. Die Reaction steht als Kachel im Community-Bereich; die
Liste war nicht nachgezogen. Jetzt 10 / 0.

Die Liste bleibt bewusst von Hand gepflegt: Sie ist eine ABSICHT,
keine Ableitung. Aus rechte.js gelesen verglichen sich zwei Kopien
derselben Quelle -- immer gruen, nie ein Beweis.

GEPRUEFT
  pruef-struktur         ALLES IN ORDNUNG (mit der neuen Wache)
  pruef-community-sicht  10 / 0
  pruef-chat-neu-stelle  63 / 0  (mit dem PATH des Nachtlaufs)
  pruef-notizen, -teamlage-karten, -werdegang: EXIT 0, keine Abstuerze
  mess-quer              EXIT 0
  29 Dateien: node --check auf allen, kein direkter Aufruf mehr
2026-09-29 16:14:13 +02:00

379 lines
18 KiB
JavaScript

/* =====================================================================
DER CHAT OEFFNET DORT, WO DAS NEUE ANFAENGT (25.09.2026)
=====================================================================
Filipe: „wenn ich in den chat rein gehe und es neue kommentare gibt,
will ich dass mein chat sich da öffnet wo die neuen nachrichten
anfangen die ich noch nicht gesehen hab bitte und nicht immer ganz
unten. sonst muss man immer hoch scrollen um die neuen zu lesen und
das ist scheisse. perfektionier das bei allen rollen."
---------------------------------------------------------------------
WARUM ES DIESE PRUEFUNG GIBT, OBWOHL DIE FUNKTION SEIT DEM 09.09. DA IST
Sie war da und sass falsch. Der Sprung rechnete mit `offsetTop` --
dem Abstand zum `offsetParent`, und das ist nur dann der Verlauf
selbst, wenn dieser `position: relative` traegt. Tut er nicht.
Gemessen auf 390 px landete die Linie „Ab hier neu" 23 px OBERHALB
des sichtbaren Bereichs: Man musste genau das tun, was Filipe nicht
mehr tun wollte. Am Rechner stimmte es zufaellig, weil dort weniger
dazwischenliegt -- deshalb ist es nie aufgefallen.
EIN FEHLER, DEN NUR EINE MESSUNG IN PIXELN FINDET. „Die Linie ist
da" war die ganze Zeit wahr. Gemessen wird hier deshalb, ob sie IM
BILD steht und wie weit sie vom oberen Rand entfernt ist.
---------------------------------------------------------------------
BEI ALLEN ROLLEN, UND ZWAR IN BEIDEN HAEUSERN
Die Mechanik ist dieselbe Datei fuer alle -- aber „dieselbe Datei"
war schon oefter ein Trugschluss: Die Kopfleiste ist je nach Rolle
verschieden hoch, der Treff hat eigene Regeln, und die Agentur laedt
ihre eigene Wand. Ein Verlauf, der bei DogFather richtig sitzt und
beim Modi 40 px daneben, ist fuer den Modi kaputt.
Aufrufen mit: node server/pruef-chat-neu-stelle.mjs
===================================================================== */
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
import { request as httpAnfrage } from "node:http";
import { createServer as httpsServer } from "node:https";
const ordner = mkdtempSync(join(tmpdir(), "ws-neustelle-p-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
const { eigenerPort } = await import("./helfer-port.mjs");
const PORT = await eigenerPort(import.meta, "die Pruefung der Oeffnungsstelle");
const HP = PORT + 1;
process.env.PORT = `${PORT}`;
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) {
return ec.call(this, n, w, { ...(o || {}), secure: false });
};
import { notbremse } from "./helfer-notbremse.mjs";
import { zertifikatBauen } from "./helfer-openssl.mjs";
await import("./index.js");
notbremse(300_000, "pruef-chat-neu-stelle");
await new Promise((r) => setTimeout(r, 1000));
process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); });
const CREW = "crew.dogfather-universe.com";
const AGENTUR = "workspace.dogfather-universe.com";
let fehler = 0, geprueft = 0;
const melde = (t) => console.log(t);
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
const sch = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", sch, jetzt);
const anlegen = (name, rolle, code) => {
const s = randomBytes(16).toString("hex");
const h = scryptSync(code, s, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
return d.prepare("INSERT INTO personen"
+ " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,?,1,?) RETURNING id")
.get(name, rolle, h, s, 32768,
createHmac("sha256", sch).update(code).digest("hex"), jetzt).id;
};
const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
const idHand = anlegen("Rieke", "hand", "CODE-HAND-0001");
const idLinke = anlegen("Mara", "linke", "CODE-LINK-0001");
const idModi = anlegen("Kessi", "modi", "CODE-MODI-0001");
const idManager = anlegen("Cigdem", "manager", "CODE-MANA-0001");
const idCreator = anlegen("Luna", "creator", "CODE-CREA-0001");
/* DIE BETREUUNG GEHOERT DAZU. Im Agenturhaus darf ein Manager nur mit
SEINEN Creators schreiben -- ohne diese Zeile antwortet die Route
mit 403, und die Pruefung meldet einen Fehler, den es nicht gibt.
Im ersten Anlauf genau so passiert: „Manager: ein Gespraech entsteht
(403)". Der Server hatte recht; die Lage war unvollstaendig. */
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit, gesetzt_von)"
+ " VALUES (?,?,?,?) ON CONFLICT(creator_id) DO UPDATE SET betreuer_id = excluded.betreuer_id")
.run(idCreator, idManager, jetzt, idDogi);
d.close();
const roh = (host, keks, pfad, methode = "GET", koerper = null) =>
new Promise((fertig, schief) => {
const daten = koerper === null ? null : JSON.stringify(koerper);
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: pfad, method: methode,
headers: {
Host: host, ...(keks ? { Cookie: keks } : {}),
Origin: `https://${host}`, Referer: `https://${host}/workspace/chat.html`,
...(daten ? { "Content-Type": "application/json",
"Content-Length": Buffer.byteLength(daten) } : {}),
},
}, (r) => {
let t = ""; r.on("data", (x) => { t += x; });
r.on("end", () => fertig({ status: r.statusCode, kopf: r.headers,
json: () => { try { return JSON.parse(t); } catch { return {}; } } }));
});
a.on("error", schief);
if (daten) a.write(daten);
a.end();
});
const anmelden = async (host, rolle, code) => {
const a = await roh(host, null, "/workspace/api/anmelden", "POST", { rolle, code });
if (a.status !== 200) { console.log(`ABBRUCH: ${rolle} ${a.status}`); process.exit(2); }
return [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ");
};
/* =======================================================================
DIE LAGE: je Rolle ein Gespraech mit 20 alten und 12 neuen Zeilen
======================================================================= */
const LEUTE = [
{ wer: "DogFather", host: CREW, rolle: "admin", code: "CODE-DOGI-0001", id: idDogi,
partner: idModi, partnerCode: "CODE-MODI-0001", partnerRolle: "modi", partnerHost: CREW },
{ wer: "rechte Hand", host: CREW, rolle: "hand", code: "CODE-HAND-0001", id: idHand,
partner: idModi, partnerCode: "CODE-MODI-0001", partnerRolle: "modi", partnerHost: CREW },
{ wer: "linke Hand", host: CREW, rolle: "linke", code: "CODE-LINK-0001", id: idLinke,
partner: idModi, partnerCode: "CODE-MODI-0001", partnerRolle: "modi", partnerHost: CREW },
{ wer: "Modi", host: CREW, rolle: "modi", code: "CODE-MODI-0001", id: idModi,
partner: idHand, partnerCode: "CODE-HAND-0001", partnerRolle: "hand", partnerHost: CREW },
{ wer: "Manager", host: AGENTUR, rolle: "manager", code: "CODE-MANA-0001", id: idManager,
partner: idCreator, partnerCode: "CODE-CREA-0001", partnerRolle: "creator",
partnerHost: AGENTUR },
{ wer: "Creator", host: AGENTUR, rolle: "creator", code: "CODE-CREA-0001", id: idCreator,
partner: idManager, partnerCode: "CODE-MANA-0001", partnerRolle: "manager",
partnerHost: AGENTUR },
];
/* ---- Zertifikat und Vorbau (eine Adresse je Haus) ------------------- */
const schl = join(ordner, "b.key"), zert = join(ordner, "b.crt");
zertifikatBauen(schl, zert, CREW);
const vorbau = httpsServer({ key: readFileSync(schl), cert: readFileSync(zert) },
(von, nach) => {
const w = httpAnfrage({
host: "127.0.0.1", port: PORT, path: von.url, method: von.method,
headers: { ...von.headers },
}, (a) => { nach.writeHead(a.statusCode || 502, a.headers); a.pipe(nach); });
von.pipe(w);
});
await new Promise((r) => vorbau.listen(HP, "127.0.0.1", r));
const pw = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch({
args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1,MAP ${AGENTUR} 127.0.0.1`,
"--ignore-certificate-errors"],
});
/** Wo steht der Verlauf, nachdem die Seite geoeffnet wurde? */
async function blick(person, breite, hoehe, keks, raumId) {
const kontext = await browser.newContext({
viewport: { width: breite, height: hoehe },
deviceScaleFactor: breite < 700 ? 2 : 1,
isMobile: breite < 700, hasTouch: breite < 700,
});
await kontext.addCookies(keks.split("; ").map((k) => {
const [x, ...r] = k.split("=");
return { name: x, value: r.join("="), domain: person.host, path: "/" };
}));
const seite = await kontext.newPage();
const konsole = [];
seite.on("pageerror", (e) => konsole.push("Absturz: " + e.message.slice(0, 120)));
await seite.goto(`https://${person.host}:${HP}/workspace/chat.html?raum=${raumId}`,
{ waitUntil: "networkidle" });
await seite.waitForTimeout(2200);
const lage = await seite.evaluate(() => {
const v = document.getElementById("verlauf");
if (!v) return { da: false };
const linie = v.querySelector(".chat-neu");
const sicht = v.getBoundingClientRect();
return {
da: true,
hoehe: Math.round(v.clientHeight),
amEnde: v.scrollHeight - v.scrollTop - v.clientHeight < 40,
linieDa: !!linie,
/* DIE EIGENTLICHE FRAGE: Steht sie im Bild, und wie weit unter
dem oberen Rand? Ein negativer Wert heisst: darueber, also
unsichtbar -- genau der Fehler, um den es hier geht. */
abstand: linie
? Math.round(linie.getBoundingClientRect().top - sicht.top) : null,
sichtbar: linie
? (linie.getBoundingClientRect().top >= sicht.top - 2
&& linie.getBoundingClientRect().bottom <= sicht.bottom + 2)
: false,
/* Was steht ganz oben im Bild? Ueber der Linie soll Zusammenhang
stehen, nicht schon das Neue. */
obenImBild: (() => {
const z = [...v.querySelectorAll(".chat-nachricht")]
.find((x) => x.getBoundingClientRect().bottom > sicht.top + 4);
return z ? (z.textContent || "").trim().slice(0, 24) : "-";
})(),
};
});
await kontext.close();
return { ...lage, konsole };
}
melde("");
melde("=== 1. Bei jeder Rolle oeffnet der Chat an der neuen Stelle ===");
/* ==== JEDER BLICK BEKOMMT SEINEN EIGENEN RAUM ======================
Der erste Anlauf benutzte einen Raum je Rolle und setzte den
Lesestand zwischen Handy und Rechner zurueck. Das Ergebnis
FLATTERTE: Mal war DogFather rot, mal die rechte Hand, mal der Modi
-- bei unveraendertem Code.
Der Grund ist ein Wettlauf: Die Seite meldet „gelesen bis hier" beim
Oeffnen, und zwar nicht sofort. Wird der Stand danach
zurueckgesetzt, gewinnt mal das eine, mal das andere. Eine Pause
davor macht es wahrscheinlicher und nicht sicher -- und eine
Pruefung, die von Wahrscheinlichkeit lebt, ist keine.
MIT EINEM EIGENEN RAUM JE BLICK gibt es nichts zurueckzusetzen. Das
ist mehr Aufbau und dafuer eine Messung, die immer dasselbe sagt. */
for (const person of LEUTE) {
const keks = await anmelden(person.host, person.rolle, person.code);
const kPartner = await anmelden(person.partnerHost, person.partnerRolle, person.partnerCode);
for (const [wie, breite, hoehe] of [["Handy", 390, 844], ["Rechner", 1280, 900]]) {
const gr = await roh(person.host, keks, "/workspace/api/chat/gruppe", "POST",
{ name: `${person.wer} ${wie}`, mit: [person.partner] });
const raumId = gr.json().raum_id;
if (!raumId) {
ok(false, `${person.wer} / ${wie}: ein Raum entsteht (${gr.status})`);
continue;
}
let letzteAlte = 0;
for (let i = 1; i <= 20; i++) {
const a = await roh(person.partnerHost, kPartner,
`/workspace/api/chat/raeume/${raumId}/nachrichten`, "POST",
{ text: `Alte Nachricht ${i} -- lange genug, damit sie auf einem Handy umbricht.` });
letzteAlte = a.json().nachricht?.id || a.json().id || letzteAlte;
}
await roh(person.host, keks, `/workspace/api/chat/raeume/${raumId}/gelesen`,
"PUT", { bis: letzteAlte });
for (let i = 1; i <= 12; i++) {
await roh(person.partnerHost, kPartner,
`/workspace/api/chat/raeume/${raumId}/nachrichten`, "POST",
{ text: `NEUE Nachricht ${i} -- auch diese ist lang genug zum Umbrechen.` });
}
/* Was sagt der Server unmittelbar vor dem Blick? Ohne diese Zeile
steht bei einem Fehlschlag nur „die Linie fehlt" da -- und man
weiss nicht, ob die Oberflaeche sie nicht zeichnet oder der
Server schon alles als gelesen fuehrt. */
const vorher = await roh(person.host, keks,
`/workspace/api/chat/raeume/${raumId}/nachrichten`);
const standVor = vorher.json().gelesen_bis;
const l = await blick(person, breite, hoehe, keks, raumId);
const wo = `${person.wer} / ${wie}`;
ok(l.da && l.linieDa, `${wo}: die Linie „Ab hier neu" steht im Verlauf `
+ `(gelesen_bis ${standVor}, letzte alte #${letzteAlte})`);
ok(l.sichtbar === true,
`${wo}: und sie ist zu SEHEN (${l.abstand} px unter dem oberen Rand)`);
/* Nicht am Rand kleben: Ueber der Linie soll Zusammenhang stehen.
Ein Viertel der Hoehe ist die Vorgabe; gemessen wird grosszuegig
(mindestens 24 px), damit eine andere Kopfhoehe nicht sofort rot
wird -- die Aussage ist „es steht etwas darueber", nicht „genau
ein Viertel". */
ok((l.abstand || 0) >= 24,
`${wo}: mit Zusammenhang darueber ("${l.obenImBild}")`);
ok(l.amEnde === false, `${wo}: und NICHT ganz unten`);
ok(l.konsole.length === 0, `${wo}: keine Abstuerze (${l.konsole.join(" | ") || "keine"})`);
}
}
/* =======================================================================
2. GEGENPROBEN
======================================================================= */
melde("");
melde("=== 2. Gegenproben (jede MUSS anschlagen) ===");
{
const person = LEUTE[0];
const keks = await anmelden(person.host, person.rolle, person.code);
const kPartner = await anmelden(CREW, "modi", "CODE-MODI-0001");
const gr = await roh(person.host, keks, "/workspace/api/chat/direkt", "POST",
{ mit: idModi });
const raumId = gr.json().raum_id;
/* (a) NICHTS NEUES -> ans Ende. Ohne diese Zeile koennte der Sprung
immer stattfinden, und wer nichts verpasst hat, laendete
grundlos mitten im Verlauf. */
let letzte = 0;
for (let i = 1; i <= 8; i++) {
const a = await roh(CREW, kPartner, `/workspace/api/chat/raeume/${raumId}/nachrichten`,
"POST", { text: `Alles gelesen ${i}.` });
letzte = a.json().nachricht?.id || a.json().id || letzte;
}
await roh(person.host, keks, `/workspace/api/chat/raeume/${raumId}/gelesen`,
"PUT", { bis: letzte });
await new Promise((r) => setTimeout(r, 400));
const ohneNeues = await blick(person, 390, 844, keks, raumId);
ok(ohneNeues.linieDa === false,
`ohne Ungelesenes gibt es keine Linie (${ohneNeues.linieDa})`);
ok(ohneNeues.amEnde === true,
"und der Verlauf steht am Ende -- da, wo man weiterschreibt");
/* (b) Kann die Messung „nicht sichtbar" ueberhaupt erkennen? Sonst
waere jede gruene Zeile oben wertlos. Gemessen wird an einem
Verlauf, der absichtlich nach unten gescrollt wird. */
for (let i = 1; i <= 12; i++) {
await roh(CREW, kPartner, `/workspace/api/chat/raeume/${raumId}/nachrichten`,
"POST", { text: `Danach noch etwas ${i}.` });
}
{
const dd = new DatabaseSync(process.env.WORKSPACE_DB);
dd.prepare("UPDATE chat_teilnehmer SET gelesen_bis = ? WHERE raum_id = ? AND person_id = ?")
.run(letzte, raumId, person.id);
dd.close();
}
const kontext = await browser.newContext({ viewport: { width: 390, height: 844 },
deviceScaleFactor: 2, isMobile: true, hasTouch: true });
await kontext.addCookies(keks.split("; ").map((k) => {
const [x, ...r] = k.split("=");
return { name: x, value: r.join("="), domain: person.host, path: "/" };
}));
const seite = await kontext.newPage();
await seite.goto(`https://${person.host}:${HP}/workspace/chat.html?raum=${raumId}`,
{ waitUntil: "networkidle" });
await seite.waitForTimeout(2000);
const kuenstlich = await seite.evaluate(() => {
const v = document.getElementById("verlauf");
v.scrollTop = v.scrollHeight; /* mit Absicht ans Ende */
const linie = v.querySelector(".chat-neu");
const sicht = v.getBoundingClientRect();
if (!linie) return null;
return {
abstand: Math.round(linie.getBoundingClientRect().top - sicht.top),
sichtbar: linie.getBoundingClientRect().top >= sicht.top - 2
&& linie.getBoundingClientRect().bottom <= sicht.bottom + 2,
};
});
await kontext.close();
ok(kuenstlich && kuenstlich.sichtbar === false && kuenstlich.abstand < 0,
`am Ende ist die Linie NICHT sichtbar (${kuenstlich?.abstand} px) `
+ "-- die Messung kann also auch nein sagen");
}
await browser.close();
vorbau.close();
melde("");
melde(`${geprueft} Pruefungen, ${fehler} Fehler`);
melde(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG");
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(fehler === 0 ? 0 : 1);