/* ===================================================================== pruef-arbeitsschloss.mjs — hält das Schloss, was es verspricht? WARUM ES DAS SCHLOSS GIBT (01.10.2026): Zwei Claude-Sitzungen arbeiteten gleichzeitig in DogiHompage, ohne voneinander zu wissen. Beide haben `git add -A` benutzt, beide den Stempellauf. Zweimal ist es nur gutgegangen. WARUM ES DIESE PRÜFUNG GIBT: Ein Schloss ist eine Sicherung, und eine Sicherung, die man nie ausgelöst hat, ist eine Hoffnung. Die teuren Fehler liegen bei einem Schloss alle in derselben Richtung — es blockiert zu viel: * Es blockiert das EIGENE Arbeiten -> wird abgeschafft. * Es blockiert nach einem Absturz dauerhaft -> wird umgangen. * Es blockiert, wenn es selbst kaputt ist -> wird entfernt. Deshalb prüft diese Datei NICHT nur, dass es zuschlägt, sondern vor allem, dass es in genau diesen drei Fällen DURCHLÄSST. --------------------------------------------------------------------- ALLES IN EINEM WEGWERF-VERZEICHNIS Die Prüfung legt ein eigenes kleines git-Verzeichnis an, kopiert das Schloss und den Haken hinein und spielt dort. Das echte `.arbeitsschloss` wird nicht angefasst — sonst nähme eine Prüfung jemandem sein Schloss weg, und das wäre genau der Schaden, den sie verhindern soll. Aufrufen mit: node server/pruef-arbeitsschloss.mjs ===================================================================== */ import { mkdtempSync, rmSync, mkdirSync, copyFileSync, writeFileSync, readFileSync, existsSync, chmodSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { execFileSync, spawnSync } from "node:child_process"; let ok_ = 0, fehl = 0, offen = 0; const ok = (b, text) => { if (b) { ok_++; console.log(" ok " + text); } else { fehl++; console.log(" FEHL " + text); } }; const unklar = (text) => { offen++; console.log(" -- " + text); }; const HIER = dirname(fileURLToPath(import.meta.url)); const WURZEL = join(HIER, ".."); const SCHLOSS = join(WURZEL, "tools", "arbeitsschloss.mjs"); const HAKEN = join(WURZEL, "tools", "git-haken", "pre-commit"); /* --------------------------------------------------------------------- 1. Steht überhaupt alles da? --------------------------------------------------------------------- */ console.log("\n=== 1. Die Teile ==="); ok(existsSync(SCHLOSS), "tools/arbeitsschloss.mjs ist da"); ok(existsSync(HAKEN), "tools/git-haken/pre-commit ist da"); /* DAS SCHLOSS DARF NICHT IM VERLAUF LANDEN. Committet wäre es eine Behauptung von gestern: Nach einem Klon stünde dort, dass jemand arbeitet, der längst fertig ist — und dann blockiert es, bis es verfällt. */ { const ignoriert = readFileSync(join(WURZEL, ".gitignore"), "utf8"); ok(/^\.arbeitsschloss$/m.test(ignoriert), ".arbeitsschloss steht in .gitignore (sonst waere es ein Schloss von gestern)"); } /* ==== DER HAKEN BRAUCHT LINUX-ZEILENENDEN ========================= Er hat KEINE Dateiendung, also greift keine der Regeln in .gitattributes von sich aus. Ohne eine eigene Zeile dort wandelt git ihn beim Auschecken auf Windows in CRLF -- und auf Linux ist `#!/bin/sh` mit einem CR dahinter ein Programmname mit einem unsichtbaren Zeichen am Ende („bad interpreter"). NACHGEMESSEN AM 01.10.2026, damit hier keine Behauptung steht: Auf Windows blockiert auch ein CRLF-Haken richtig (Rueckgabe 1, null Commits). Die Regel ist also Vorsorge und keine Reparatur -- aber ein Haken, der still nicht laeuft, ist genau die Sicherung, die aussieht, als waere sie da. UND EINE LEHRE AUS DEM MESSEN SELBST: Meine erste Messung meldete 73 CR-Bytes, wo keines war. Gemessen hatte ich mit einer Rohrkette aus `tr` und `od`, und `tr` nimmt in dieser Shell die Maskierung fuer das Wagenruecklauf-Zeichen nicht als Zeichen -- gezaehlt wurden am Ende Zeilenenden. Byteweise wird deshalb hier gemessen, nicht in einer Rohrkette: `readFileSync` ohne Zeichensatz gibt Bytes, und 13 ist 13. */ { const roh = readFileSync(HAKEN); ok(!roh.includes(13), `der Haken hat reine LF-Zeilenenden (${roh.filter((b) => b === 13).length} CR)`); const regeln = readFileSync(join(WURZEL, ".gitattributes"), "utf8"); ok(/tools\/git-haken\/\*\s+text\s+eol=lf/.test(regeln), ".gitattributes haelt den Haken auf LF (er hat keine Endung)"); } /* DER HAKEN MUSS AUCH GEFUNDEN WERDEN. `.git/hooks` wird nicht versioniert; ohne `core.hooksPath` liegt der Haken im Baum und tut nichts. Das ist die Sorte Sicherung, die aussieht, als wäre sie da. */ { let pfad = ""; try { pfad = execFileSync("git", ["config", "--get", "core.hooksPath"], { cwd: WURZEL, encoding: "utf8" }).trim(); } catch { pfad = ""; } ok(pfad === "tools/git-haken", `core.hooksPath zeigt auf den Haken ("${pfad || "nicht gesetzt"}")` + (pfad === "tools/git-haken" ? "" : " — node tools/arbeitsschloss.mjs einrichten")); } /* DIE STEMPELWERKZEUGE NEHMEN ES SELBST. Abgeleitet aus dem Quelltext, nicht angenommen: Ein Schloss, das man von Hand nehmen muss, wird vergessen — und dann umgangen. */ for (const w of ["workspace-stempel.mjs", "seiten-stempel.mjs"]) { const t = readFileSync(join(WURZEL, "tools", w), "utf8"); ok(/schlossKurz\(/.test(t) && /arbeitsschloss\.mjs/.test(t), `tools/${w} nimmt das Schloss selbst`); } /* --------------------------------------------------------------------- 2. Wer bin ich? --------------------------------------------------------------------- */ console.log("\n=== 2. Die Kennung ==="); const { werIch } = await import("file://" + SCHLOSS.replace(/\\/g, "/")); { const alt = process.env.ARBEITER; delete process.env.ARBEITER; const ohneName = werIch(); ok(ohneName.woher === "Sitzungskennung" && /^claude-[0-9a-f]{8}$/.test(ohneName.name), `ohne ARBEITER kommt die Sitzungskennung ("${ohneName.name}")`); /* DAS IST DER PUNKT, AN DEM RUNONE HÄNGT: Dort fällt es auf den Systembenutzer zurück, und zwei Sitzungen desselben Benutzers sehen damit gleich aus — die Sicherung griff ausgerechnet zwischen den zwei Fällen nicht, für die sie gebaut wurde. */ ok(ohneName.name !== process.env.USERNAME, "und nicht der Benutzername — zwei Sitzungen desselben Benutzers" + " muessen sich unterscheiden"); process.env.ARBEITER = "probe-name"; ok(werIch().name === "probe-name", "ein ausdrueckliches ARBEITER gewinnt"); if (alt === undefined) delete process.env.ARBEITER; else process.env.ARBEITER = alt; } /* --------------------------------------------------------------------- 3. Das Verhalten — im Wegwerf-Verzeichnis --------------------------------------------------------------------- */ console.log("\n=== 3. Nehmen, sehen, freigeben ==="); const spiel = mkdtempSync(join(tmpdir(), "schlossprobe-")); mkdirSync(join(spiel, "tools", "git-haken"), { recursive: true }); copyFileSync(SCHLOSS, join(spiel, "tools", "arbeitsschloss.mjs")); copyFileSync(HAKEN, join(spiel, "tools", "git-haken", "pre-commit")); try { chmodSync(join(spiel, "tools", "git-haken", "pre-commit"), 0o755); } catch { /* egal */ } const SCHLOSSDATEI = join(spiel, ".arbeitsschloss"); const ICH = "claude-probe01"; const ANDERER = "claude-probe02"; /** Ruft das Schloss im Spielverzeichnis auf. */ function rufen(befehl, { wer = ICH, grund = "", verfall } = {}) { const e = spawnSync(process.execPath, [join(spiel, "tools", "arbeitsschloss.mjs"), befehl, grund], { cwd: spiel, encoding: "utf8", env: { ...process.env, ARBEITER: wer, ...(verfall ? { SCHLOSS_VERFALL: String(verfall) } : {}) } }); return { stand: e.status, aus: (e.stdout || "") + (e.stderr || "") }; } const schlossSetzen = (wer, vorMinuten = 0, was = "etwas") => writeFileSync(SCHLOSSDATEI, `wer=${wer}\nseit=${new Date(Date.now() - vorMinuten * 60000).toISOString()}\n` + `was=${was}\nrechner=probe\n`); ok(rufen("ansehen").aus.trim() === "frei", "ein leeres Verzeichnis ist frei"); { const r = rufen("nehmen", { grund: "Probe" }); ok(r.stand === 0 && existsSync(SCHLOSSDATEI), `nehmen legt es an (${r.stand})`); ok(/Sitzungskennung|ARBEITER/.test(r.aus), "und sagt, woher die Kennung kommt"); } ok(rufen("ansehen").stand === 1, "danach meldet ansehen: belegt (Stand 1)"); ok(rufen("nehmen").stand === 0, "zweimal nehmen vom selben ist in Ordnung"); /* ---- DER FALL, DER ALLES ENTSCHEIDET ------------------------------- `fremd` muss zwischen „ich halte es" und „ein anderer haelt es" unterscheiden. RunOnes erste Fassung konnte das nicht und hat damit genau das richtige Verhalten bestraft. */ ok(rufen("fremd").stand === 0, "fremd: mein EIGENES Schloss blockiert mich nicht"); schlossSetzen(ANDERER); ok(rufen("fremd").stand === 1, "fremd: ein FREMDES blockiert (Stand 1)"); ok(rufen("nehmen").stand === 1, "nehmen prallt an einem fremden ab"); { const r = rufen("freigeben"); ok(r.stand === 1 && /gehört/.test(r.aus), "und ein fremdes gibt man nicht einfach frei"); } /* ---- ES VERFÄLLT. Sonst blockiert ein Absturz dauerhaft. ---------- */ schlossSetzen(ANDERER, 180); ok(rufen("fremd").stand === 0, "ein verfallenes fremdes Schloss blockiert NICHT mehr"); { const r = rufen("nehmen"); ok(r.stand === 0 && /verfallen/.test(r.aus), "es wird uebernommen — und dass da jemand war, steht dabei"); } schlossSetzen(ANDERER, 180); ok(rufen("fremd", { verfall: 400 }).stand === 1, "Gegenprobe: mit laengerer Frist gilt dasselbe Schloss wieder (SCHLOSS_VERFALL)"); /* ---- UNLESBAR IST NICHT BELEGT ------------------------------------ Ein kaputtes Schloss darf die Arbeit nicht anhalten. */ writeFileSync(SCHLOSSDATEI, "das ist kein Schloss\n"); { const r = rufen("fremd"); ok(r.stand === 0, `ein unbrauchbarer Inhalt blockiert nicht (${r.stand})`); } writeFileSync(SCHLOSSDATEI, `wer=${ANDERER}\nseit=voelliger-unsinn\nwas=x\n`); ok(rufen("fremd").stand === 0, "und ein unlesbarer Zeitstempel gilt als verfallen, nicht als eben genommen"); /* ---- Freigeben ---------------------------------------------------- */ schlossSetzen(ICH); ok(rufen("freigeben").stand === 0 && !existsSync(SCHLOSSDATEI), "das eigene gibt man frei, und danach ist die Datei weg"); ok(rufen("freigeben").stand === 0, "zweimal freigeben ist kein Fehler"); /* --------------------------------------------------------------------- 4. Der git-Haken — am echten git, nicht an der Behauptung --------------------------------------------------------------------- */ console.log("\n=== 4. Der Haken am echten git ==="); { const git = (...a) => spawnSync("git", a, { cwd: spiel, encoding: "utf8", env: { ...process.env, ARBEITER: ICH } }); git("init", "-q"); git("config", "user.email", "probe@example.invalid"); git("config", "user.name", "Probe"); git("config", "core.hooksPath", "tools/git-haken"); writeFileSync(join(spiel, "a.txt"), "eins\n"); git("add", "-A"); const erst = git("commit", "-q", "-m", "erster"); ok(erst.status === 0, `ohne Schloss laesst der Haken committen (${erst.status})`); writeFileSync(join(spiel, "a.txt"), "zwei\n"); schlossSetzen(ICH); git("add", "-A"); const eigen = git("commit", "-q", "-m", "mit eigenem Schloss"); ok(eigen.status === 0, `mit dem EIGENEN Schloss ebenfalls (${eigen.status}) — sonst waere ordentliches` + " Abschliessen bestraft"); writeFileSync(join(spiel, "a.txt"), "drei\n"); schlossSetzen(ANDERER, 0, "arbeitet gerade"); git("add", "-A"); const fremd = git("commit", "-q", "-m", "darf nicht entstehen"); const text = (fremd.stdout || "") + (fremd.stderr || ""); ok(fremd.status === 1, `mit einem FREMDEN Schloss bricht der Commit ab (${fremd.status})`); ok(/arbeitet gerade jemand anders/.test(text), "und sagt, warum"); ok(/claude-probe02/.test(text), "und WER es ist"); /* UND DIE ZAHL DER COMMITS BEWEIST ES. Eine Meldung allein könnte auch bei einem erfolgreichen Commit erscheinen. */ const anzahl = Number(spawnSync("git", ["rev-list", "--count", "HEAD"], { cwd: spiel, encoding: "utf8" }).stdout.trim()); ok(anzahl === 2, `es stehen genau zwei Commits da, nicht drei (${anzahl})`); /* DER NOTAUSGANG MUSS GEHEN. Ein Riegel ohne Notausgang wird ausgebaut, nicht benutzt. */ const zwang = spawnSync("git", ["commit", "-q", "-m", "mit Zwang"], { cwd: spiel, encoding: "utf8", env: { ...process.env, ARBEITER: ICH, SCHLOSS_ZWANG: "ja" } }); ok(zwang.status === 0, `SCHLOSS_ZWANG=ja kommt vorbei (${zwang.status})`); /* UND OHNE DAS SCHLOSS-WERKZEUG DARF ER NICHT BLOCKIEREN: Wer den Haken in ein Verzeichnis ohne Schloss mitnimmt, soll arbeiten koennen. */ rmSync(join(spiel, "tools", "arbeitsschloss.mjs")); schlossSetzen(ANDERER); writeFileSync(join(spiel, "a.txt"), "vier\n"); git("add", "-A"); const ohneWerkzeug = git("commit", "-q", "-m", "ohne Werkzeug"); ok(ohneWerkzeug.status === 0, `ohne das Schloss-Werkzeug laesst der Haken durch (${ohneWerkzeug.status})`); } try { rmSync(spiel, { recursive: true, force: true }); } catch { /* Aufraeumrest */ } console.log(`\n${ok_} Pruefungen, ${fehl} Fehler, ${offen} nicht nachsehbar`); console.log(fehl ? "NICHT IN ORDNUNG\n" : "ALLES IN ORDNUNG\n"); process.exit(fehl ? 1 : 0);