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/winget.ts |   81 ++++++++++++++++++++++++++++++++++++++++
 1 files changed, 81 insertions(+), 0 deletions(-)

diff --git a/packages/bibliothek-server/src/bezugsquellen/winget.ts b/packages/bibliothek-server/src/bezugsquellen/winget.ts
new file mode 100644
index 0000000..0c9264a
--- /dev/null
+++ b/packages/bibliothek-server/src/bezugsquellen/winget.ts
@@ -0,0 +1,81 @@
+import { execFile } from 'child_process';
+import { promisify } from 'util';
+import * as fs from 'fs';
+import * as path from 'path';
+import { abgleichMisserfolg, alsIsoDatum, mitTemporaeremOrdner, type Abgleichauftrag, type Bezugsquelle, type QuellenAbgleich } from './basis';
+
+const execFileAsync = promisify(execFile);
+
+/** winget: lädt das Paket per `winget download` (nur unter Windows verfügbar). */
+export class WingetQuelle implements Bezugsquelle {
+  async gleicheAb(auftrag: Abgleichauftrag): Promise<QuellenAbgleich> {
+    // In einen temporären Ordner laden — der bisherige Stand bleibt erhalten, falls etwas schiefgeht.
+    const ablage = mitTemporaeremOrdner(auftrag.zielordner);
+    try {
+      const { stdout } = await execFileAsync(
+        'winget',
+        [
+          'download',
+          '--id',
+          auftrag.wert,
+          '--exact',
+          '--download-directory',
+          ablage.ordner,
+          '--accept-package-agreements',
+          '--accept-source-agreements',
+          '--disable-interactivity',
+        ],
+        { timeout: 300000, windowsHide: true }
+      );
+      const geladeneDateien = fs.readdirSync(ablage.ordner);
+      const installer = geladeneDateien.find((name) => /\.(exe|msi|msix|appx|appxbundle|zip)$/i.test(name));
+      if (!installer) {
+        ablage.verwerfen();
+        return abgleichMisserfolg(auftrag.aktuelleVersion, 'winget hat keine Installer-Datei geliefert.');
+      }
+      // winget legt zusätzlich sein Manifest (.yaml) ab — im Ablageordner soll nur der Installer liegen.
+      for (const name of geladeneDateien) {
+        if (name !== installer) {
+          fs.rmSync(path.join(ablage.ordner, name), { recursive: true, force: true });
+        }
+      }
+      const version = versionAusAusgabe(stdout) || new Date().toISOString();
+      ablage.uebernehmen();
+      return {
+        erfolg: true,
+        geaendert: true,
+        version,
+        dateiname: installer,
+        meldung: `Über winget geladen (${version}).`,
+        // Das Veröffentlichungsdatum steht nur in der Paket-Auskunft, nicht in der Download-Ausgabe.
+        veroeffentlichtAm: alsIsoDatum(await veroeffentlichungsdatum(auftrag.wert)),
+      };
+    } catch (fehler) {
+      ablage.verwerfen();
+      return abgleichMisserfolg(auftrag.aktuelleVersion, `winget fehlgeschlagen: ${fehler instanceof Error ? fehler.message : String(fehler)}`);
+    }
+  }
+}
+
+function versionAusAusgabe(ausgabe: string): string {
+  const treffer = ausgabe.match(/[Vv]ersion[:\s]+([0-9][0-9.]*)/);
+  return treffer ? treffer[1] : '';
+}
+
+/**
+ * Fragt das Veröffentlichungsdatum beim Paket-Verzeichnis ab. `winget download`
+ * gibt es nicht aus, `winget show` schon — dafür ein zweiter, kurzer Aufruf.
+ */
+async function veroeffentlichungsdatum(paketId: string): Promise<string> {
+  try {
+    const { stdout } = await execFileAsync(
+      'winget',
+      ['show', '--id', paketId, '--exact', '--accept-source-agreements', '--disable-interactivity'],
+      { timeout: 120000, windowsHide: true }
+    );
+    const treffer = stdout.match(/(?:Release\s*Date|Ver[öo]ffentlich\w*)[:\s]+([0-9]{4}-[0-9]{2}-[0-9]{2})/i);
+    return treffer ? treffer[1] : '';
+  } catch {
+    return ''; // ohne Datum ist die Zeile nur weniger aussagekräftig, kein Grund zu scheitern
+  }
+}

--
Gitblit v1.9.3