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