From 98bce1b0da675715b2e5482c0ad61ba21135f62f Mon Sep 17 00:00:00 2001
From: Server-Bibliothek <server-bibliothek@lokal>
Date: Di, 04 Aug 2026 07:00:04 +0200
Subject: [PATCH] Add a desktop client that knows what is installed locally

---
 packages/bibliothek-server/src/bezugsquellen/git-build.ts |  244 ++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 244 insertions(+), 0 deletions(-)

diff --git a/packages/bibliothek-server/src/bezugsquellen/git-build.ts b/packages/bibliothek-server/src/bezugsquellen/git-build.ts
new file mode 100644
index 0000000..f5ce3c2
--- /dev/null
+++ b/packages/bibliothek-server/src/bezugsquellen/git-build.ts
@@ -0,0 +1,244 @@
+import { exec, execFile } from 'child_process';
+import { promisify } from 'util';
+import * as fs from 'fs';
+import * as path from 'path';
+import {
+  abgleichMisserfolg,
+  alsIsoDatum,
+  dateiVorhanden,
+  ersetzeDateiSicher,
+  inhaltBeanstanden,
+  type Abgleichauftrag,
+  type Bezugsquelle,
+  type GeheimnisBedarf,
+  type QuellenAbgleich,
+} from './basis';
+
+const execAsync = promisify(exec);
+const execFileAsync = promisify(execFile);
+
+/** Ein Bau darf lange dauern (z. B. PyInstaller mit vielen Abhängigkeiten). */
+const BAU_ZEITGRENZE_MS = 30 * 60 * 1000;
+const GIT_ZEITGRENZE_MS = 10 * 60 * 1000;
+
+/** Name der Bauanleitung, die ein Repository selbst mitbringt. */
+export const BAUANLEITUNG_DATEI = 'software-bibliothek.json';
+
+interface Bauanleitung {
+  bauBefehl: string;
+  ergebnis: string;
+  geheimnisse: GeheimnisBedarf[];
+}
+
+/**
+ * Aufbau der Anleitung, wie sie im Repository steht. Die Feldnamen sind englisch,
+ * weil sie ein Vertrag nach außen sind — jedes anzubindende Tool schreibt sie.
+ * Beschrieben im Skill „build-secrets" des Repos dh-claude-rules.
+ */
+interface AnleitungImRepo {
+  buildCommand?: string;
+  artifact?: string;
+  secrets?: Array<{ name?: string; description?: string }>;
+}
+
+/**
+ * Interne Tools aus einem Git-Repository: Arbeitskopie holen bzw. aktualisieren,
+ * bauen und das erzeugte Programm bereitstellen.
+ *
+ * Angabe der Quelle: nur die Repo-URL, optional mit `#zweig` (z. B. `ssh://…/tool.git#main`).
+ * Wie gebaut wird, beschreibt das Repository selbst in `software-bibliothek.json`:
+ *
+ *   { "buildCommand": "powershell -ExecutionPolicy Bypass -File build_exe.ps1",
+ *     "artifact":     "dist/MeinTool.exe" }
+ *
+ * So bleibt die Bauanleitung dort, wo sie hingehört — beim Tool. In der Bibliothek
+ * lassen sich beide Angaben bei Bedarf überschreiben (für fremde Repos, die man
+ * nicht ändern kann).
+ *
+ * Die Arbeitskopie legt die Anwendung selbst an (`<Datenordner>\quellen\<id>`) — es
+ * wird nichts vorausgesetzt, was auf dem jeweiligen Rechner schon vorhanden sein müsste.
+ *
+ * Hinweis zur Sicherheit: Der Bau-Befehl wird auf dem Server mit den Rechten des
+ * Dienstbenutzers ausgeführt. Pflegen kann ihn nur, wer lokal am Server arbeitet
+ * bzw. wer ins Repository schreiben darf.
+ */
+export class GitBuildQuelle implements Bezugsquelle {
+  async gleicheAb(auftrag: Abgleichauftrag): Promise<QuellenAbgleich> {
+    const [repoUrl, gewuenschterZweig] = auftrag.wert.split('#');
+    let commit = '';
+    try {
+      commit = await arbeitskopieAktualisieren(repoUrl.trim(), gewuenschterZweig?.trim() ?? '', auftrag.arbeitsordner, auftrag.gitSshBefehl);
+    } catch (fehler) {
+      return abgleichMisserfolg(auftrag.aktuelleVersion, gitFehlertext(fehler));
+    }
+
+    const veroeffentlichtAm = await commitZeitpunkt(auftrag.arbeitsordner);
+    const anleitung = leseBauanleitung(auftrag);
+    if (!anleitung.bauBefehl || !anleitung.ergebnis) {
+      return abgleichMisserfolg(
+        auftrag.aktuelleVersion,
+        `Im Repository fehlt die Datei ${BAUANLEITUNG_DATEI} mit „buildCommand" und „artifact" — alternativ beides in der Bibliothek hinterlegen.`
+      );
+    }
+
+    // Nur bauen, wenn sich seit dem letzten Mal etwas geändert hat.
+    const bekannterDateiname = ergebnisDateiname(anleitung.ergebnis);
+    if (commit === auftrag.aktuelleVersion && dateiVorhanden(auftrag.zielordner, bekannterDateiname)) {
+      return { erfolg: true, geaendert: false, version: commit, dateiname: bekannterDateiname, meldung: 'Bereits aktuell (unveränderter Stand).', veroeffentlichtAm, benoetigteGeheimnisse: anleitung.geheimnisse };
+    }
+
+    const fehlende = anleitung.geheimnisse.filter((bedarf) => !auftrag.geheimnisse[bedarf.name]).map((bedarf) => bedarf.name);
+    try {
+      // Geheimnisse gehen als Umgebungsvariablen in den Bau — das Tool entscheidet selbst,
+      // was es daraus macht. Sie landen dadurch nirgends auf der Platte.
+      await execAsync(anleitung.bauBefehl, {
+        cwd: auftrag.arbeitsordner,
+        timeout: BAU_ZEITGRENZE_MS,
+        windowsHide: true,
+        maxBuffer: 32 * 1024 * 1024,
+        env: { ...process.env, ...auftrag.geheimnisse },
+      });
+    } catch (fehler) {
+      const hinweis = fehlende.length > 0 ? ` Nicht hinterlegte Schlüssel: ${fehlende.join(', ')}.` : '';
+      return abgleichMisserfolg(auftrag.aktuelleVersion, `Bau fehlgeschlagen: ${kurzeFehlermeldung(fehler)}${hinweis}`);
+    }
+
+    const ergebnisPfad = findeErgebnis(auftrag.arbeitsordner, anleitung.ergebnis);
+    if (!ergebnisPfad) {
+      return abgleichMisserfolg(auftrag.aktuelleVersion, `Der Bau lief durch, aber unter „${anleitung.ergebnis}" liegt keine Datei.`);
+    }
+
+    const dateiname = path.basename(ergebnisPfad);
+    const inhalt = fs.readFileSync(ergebnisPfad);
+    const beanstandung = inhaltBeanstanden(inhalt, dateiname, null, auftrag.vorherigeGroesse);
+    if (beanstandung) {
+      return abgleichMisserfolg(auftrag.aktuelleVersion, beanstandung);
+    }
+
+    ersetzeDateiSicher(auftrag.zielordner, dateiname, inhalt);
+    return {
+      erfolg: true,
+      geaendert: true,
+      version: commit,
+      dateiname,
+      meldung: `Aus Git gebaut (Stand ${commit.slice(0, 8)}).`,
+      veroeffentlichtAm,
+      benoetigteGeheimnisse: anleitung.geheimnisse,
+    };
+  }
+}
+
+/**
+ * Bauanleitung ermitteln: bevorzugt aus dem Repository selbst, ersatzweise aus den
+ * in der Bibliothek hinterlegten Angaben (die dort gepflegten haben Vorrang, damit
+ * sich fremde Repos übersteuern lassen).
+ */
+function leseBauanleitung(auftrag: Abgleichauftrag): Bauanleitung {
+  let ausRepo: AnleitungImRepo = {};
+  const anleitungsPfad = path.join(auftrag.arbeitsordner, BAUANLEITUNG_DATEI);
+  if (fs.existsSync(anleitungsPfad)) {
+    try {
+      ausRepo = JSON.parse(fs.readFileSync(anleitungsPfad, 'utf8')) as AnleitungImRepo;
+    } catch {
+      // Unlesbare Anleitung wie „nicht vorhanden" behandeln — die Angaben aus der
+      // Bibliothek können den Bau trotzdem ermöglichen.
+    }
+  }
+  const angeforderte = Array.isArray(ausRepo.secrets) ? ausRepo.secrets : [];
+  return {
+    bauBefehl: auftrag.bauBefehl.trim() || (ausRepo.buildCommand ?? '').trim(),
+    ergebnis: auftrag.bauErgebnis.trim() || (ausRepo.artifact ?? '').trim(),
+    geheimnisse: angeforderte
+      .filter((bedarf): bedarf is { name: string; description?: string } => typeof bedarf?.name === 'string' && !!bedarf.name.trim())
+      .map((bedarf) => ({ name: bedarf.name.trim(), beschreibung: String(bedarf.description ?? '') })),
+  };
+}
+
+/** Klont die Arbeitskopie oder bringt sie auf den neuesten Stand. Liefert den Commit. */
+async function arbeitskopieAktualisieren(repoUrl: string, gewuenschterZweig: string, arbeitsordner: string, sshBefehl: string): Promise<string> {
+  // Die Kennung kommt aus den Einstellungen, nicht aus der URL — so bleibt der
+  // Katalogeintrag frei von Konto-Angaben und ein Kontowechsel ist eine Änderung.
+  const umgebung = sshBefehl ? { ...process.env, GIT_SSH_COMMAND: sshBefehl } : process.env;
+  const git = (argumente: string[], cwd?: string) =>
+    execFileAsync('git', argumente, { cwd, timeout: GIT_ZEITGRENZE_MS, windowsHide: true, maxBuffer: 16 * 1024 * 1024, env: umgebung });
+
+  if (!fs.existsSync(path.join(arbeitsordner, '.git'))) {
+    fs.rmSync(arbeitsordner, { recursive: true, force: true });
+    fs.mkdirSync(path.dirname(arbeitsordner), { recursive: true });
+    await git(['clone', repoUrl, arbeitsordner]);
+  } else {
+    await git(['remote', 'set-url', 'origin', repoUrl], arbeitsordner);
+    await git(['fetch', '--prune', 'origin'], arbeitsordner);
+  }
+
+  const zweig = gewuenschterZweig || (await ermittleStandardZweig(arbeitsordner));
+  // Harter Abgleich auf den Fernstand — lokale Reste aus einem früheren Bau stören sonst.
+  await git(['checkout', '-B', zweig, `origin/${zweig}`], arbeitsordner);
+  await git(['reset', '--hard', `origin/${zweig}`], arbeitsordner);
+  await git(['clean', '-ffdx', '-e', 'node_modules', '-e', '.venv'], arbeitsordner);
+
+  const { stdout } = await git(['rev-parse', 'HEAD'], arbeitsordner);
+  return stdout.trim();
+}
+
+/** Zeitpunkt des ausgecheckten Commits — das ist der „Stand" des internen Tools. */
+async function commitZeitpunkt(arbeitsordner: string): Promise<string> {
+  try {
+    const { stdout } = await execFileAsync('git', ['log', '-1', '--format=%cI'], {
+      cwd: arbeitsordner,
+      timeout: 30000,
+      windowsHide: true,
+    });
+    return alsIsoDatum(stdout.trim());
+  } catch {
+    return '';
+  }
+}
+
+async function ermittleStandardZweig(arbeitsordner: string): Promise<string> {
+  try {
+    const { stdout } = await execFileAsync('git', ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], {
+      cwd: arbeitsordner,
+      timeout: 30000,
+      windowsHide: true,
+    });
+    return stdout.trim().replace(/^origin\//, '') || 'main';
+  } catch {
+    return 'main'; // Repos ohne gesetzten HEAD-Zeiger
+  }
+}
+
+/** Löst den Ergebnis-Pfad auf; im Dateinamen ist ein `*` erlaubt. */
+function findeErgebnis(arbeitsordner: string, muster: string): string | undefined {
+  const vollstaendig = path.resolve(arbeitsordner, muster);
+  if (!muster.includes('*')) {
+    return fs.existsSync(vollstaendig) ? vollstaendig : undefined;
+  }
+  const ordner = path.dirname(vollstaendig);
+  if (!fs.existsSync(ordner)) {
+    return undefined;
+  }
+  const regel = new RegExp(`^${path.basename(vollstaendig).replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`, 'i');
+  const treffer = fs.readdirSync(ordner).filter((name) => regel.test(name));
+  return treffer.length > 0 ? path.join(ordner, treffer[0]) : undefined;
+}
+
+function ergebnisDateiname(muster: string): string {
+  const name = path.basename(muster);
+  return name.includes('*') ? '' : name;
+}
+
+function gitFehlertext(fehler: unknown): string {
+  const text = kurzeFehlermeldung(fehler);
+  if (/authenticat|permission denied|could not read|host key|publickey/i.test(text)) {
+    return `Git-Anmeldung fehlgeschlagen. Der Dienstbenutzer braucht Zugriff auf das Repository (SSH-Schlüssel bzw. einmalige Anmeldung im Windows-Anmeldespeicher). Ursprüngliche Meldung: ${text}`;
+  }
+  return `Git fehlgeschlagen: ${text}`;
+}
+
+/** Kommandozeilen-Fehler enthalten oft seitenlange Ausgaben — auf das Wesentliche kürzen. */
+function kurzeFehlermeldung(fehler: unknown): string {
+  const roh = fehler instanceof Error ? ((fehler as Error & { stderr?: string }).stderr || fehler.message) : String(fehler);
+  const zeilen = roh.split('\n').map((zeile) => zeile.trim()).filter(Boolean);
+  return zeilen.slice(-4).join(' | ').slice(0, 500);
+}

--
Gitblit v1.9.3