hmgr 0.0.6 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -695,6 +695,26 @@ dialog::backdrop{background:rgba(9,45,37,.62)}
695
695
  /* Wo ein Eintrag steht (E64): kein margin-left:auto, sonst teilte er sich den freien Platz mit dem
696
696
  Übernehmen-Knopf und stünde in der Mitte statt beim Text. */
697
697
  .mcpkartezeilen .geteiltvermerk{font-size:16px;color:var(--tinte-leise)}
698
+ /* Bundle-Gruppe an der Ziel-Karte (E69, F68): eine Einheit über ihren Skill-Zeilen, sichtbar
699
+ zusammengefasst durch eine linke Kante statt durch einen eigenen Kasten — die Zeilen darin sind
700
+ dieselbe Tabelle wie darunter und sollen mit ihr fluchten. */
701
+ .bundlegruppe{margin:14px 0;border-left:3px solid var(--emaille-tief);padding-left:12px}
702
+ .bundlekopf{display:flex;flex-wrap:wrap;align-items:center;gap:5px 14px;padding-bottom:8px}
703
+ .bundletitel{display:inline-flex;align-items:center;gap:8px}
704
+ .bundlename{font-family:var(--schrift);font-weight:700;font-size:17px;letter-spacing:-.012em}
705
+ .bundlequelle{font-size:16px;color:var(--tinte-weich)}
706
+ .bundlezahl{font-size:16px;color:var(--tinte-leise)}
707
+ .bundlemitglieder{font-family:var(--mono);font-size:16px;letter-spacing:-.012em;color:var(--tinte-weich)}
708
+ .bundlekopf .knopf{margin-left:auto}
709
+ .bundlegruppe .linkliste li:last-child{border-bottom:1px solid var(--strich-zart)}
710
+
711
+ /* Bundle- und Skill-Sektion im Dialog „Skills hinzufügen" (E69): zwei benannte Gruppen unter
712
+ demselben Suchfeld. */
713
+ .dskgruppe + .dskgruppe{margin-top:18px}
714
+ .dskgruppe > .etikett{display:block;margin-bottom:9px}
715
+ .dskgruppentext{margin:0 0 10px;color:var(--tinte-weich)}
716
+ .dskgruppenfehler{margin:10px 0 0;color:var(--messing-satt);white-space:pre-line}
717
+
698
718
  /* Leerer Katalog: keine Aktion, sondern der Weg zur Datei. */
699
719
  .startfeld.mcpstart{display:block}
700
720
  .startfeld.mcpstart p{max-width:62ch}
@@ -1,6 +1,6 @@
1
1
  // Anwendungsfälle. Pfade werden hier nur als Zeichenketten normalisiert, gelesen wird über die Ports.
2
2
  import path from 'node:path';
3
- import { AbhaengigkeitFehlt, EinschraenkungOhneZeiger, FremderEintragUnbekannt, HarnessNichtVorhanden, HarnessTypUnbekannt, KeinPassenderRef, McpEbeneNichtUnterstuetzt, McpFaehigkeitFehlt, McpUebernahmeNichtMoeglich, McpUnbekannt, OrtNichtUnterstuetzt, PluginMehrdeutig, PluginUnbekannt, QuelleAufraeumenLokaleAenderungen, QuelleNameBelegt, QuelleUnbekannt, SkillMehrdeutig, SkillOrdnerBereitsVerwaltet, SkillOrdnerFaehigkeitFehlt, SkillOrdnerNichtVerwaltet, SkillUnbekannt, ZeigerMehrdeutig, ZeigerSchreibenGesperrt, marketplaceUrl, pluginSchluessel, pluginSchluesselZerlegen, quelleAlsText, quelleMitNamen, quelleName, quelleNameAbleiten, quelleNameFrei, quelleSchluessel, zielSchluessel, } from "./modell.js";
3
+ import { AbhaengigkeitFehlt, BundleMehrdeutig, BundleMitgliederFehlen, BundleNamenskonflikte, BundleNichtInstalliert, BundleUnbekannt, EinschraenkungOhneZeiger, FremderEintragUnbekannt, HarnessNichtVorhanden, HarnessTypUnbekannt, KeinPassenderRef, McpEbeneNichtUnterstuetzt, McpFaehigkeitFehlt, McpUebernahmeNichtMoeglich, McpUnbekannt, OrtNichtUnterstuetzt, PluginMehrdeutig, PluginUnbekannt, QuelleAufraeumenLokaleAenderungen, QuelleNameBelegt, QuelleUnbekannt, SkillMehrdeutig, SkillOrdnerBereitsVerwaltet, SkillOrdnerFaehigkeitFehlt, SkillOrdnerNichtVerwaltet, SkillUnbekannt, ZeigerMehrdeutig, ZeigerSchreibenGesperrt, bundleSchluessel, marketplaceUrl, pluginSchluessel, pluginSchluesselZerlegen, quelleAlsText, quelleMitNamen, quelleName, quelleNameAbleiten, quelleNameFrei, quelleSchluessel, zielSchluessel, } from "./modell.js";
4
4
  import { passenderRef } from "./version.js";
5
5
  import { kannIstRegistriert, kannMcp, kannPlugins, kannProfile, kannSkillOrdner, kannZurueckbauen, } from "../ports/harness.js";
6
6
  /**
@@ -364,6 +364,10 @@ export class Anwendungsfaelle {
364
364
  for (const pfad of zustand.arbeitsverzeichnisse)
365
365
  ziele.push(arbeitsverzeichnisZiel(harness.name, pfad));
366
366
  const zielErgebnisse = [];
367
+ // Mit den Links verschwinden auch die Bundle-Verweise dieser Ziele (E69): ein Verweis auf Links,
368
+ // die es nicht mehr gibt, wäre ein Rest, den niemand mehr sieht.
369
+ const nachZustand = await this.p.zustand.lesen();
370
+ let bundlesGeaendert = false;
367
371
  for (const ziel of ziele) {
368
372
  const verzeichnis = harness.zielVerzeichnis(ziel.ort);
369
373
  const eintraege = await this.p.ziele.eintraege(ziel, verzeichnis).catch(() => []);
@@ -375,9 +379,13 @@ export class Anwendungsfaelle {
375
379
  await this.p.ziele.linkEntfernen(ziel, verzeichnis, name);
376
380
  entfernteLinks.push(name);
377
381
  }
382
+ if (Anwendungsfaelle.bundlesZerbrechen(nachZustand, ziel, entfernteLinks).length > 0)
383
+ bundlesGeaendert = true;
378
384
  const konfigurationZurueckgebaut = kannZurueckbauen(harness) ? await harness.zurueckbauen(ziel.ort) : false;
379
385
  zielErgebnisse.push({ ziel, entfernteLinks, konfigurationZurueckgebaut });
380
386
  }
387
+ if (bundlesGeaendert)
388
+ await this.p.zustand.schreiben(nachZustand);
381
389
  return { harness: harness.name, ziele: zielErgebnisse };
382
390
  }
383
391
  /**
@@ -513,6 +521,15 @@ export class Anwendungsfaelle {
513
521
  throw new QuelleAufraeumenLokaleAenderungen(inventar.checkoutsMitLokalenAenderungen);
514
522
  for (const link of inventar.links)
515
523
  await this.p.ziele.linkEntfernen(link.ziel, link.verzeichnis, link.name);
524
+ // Wo dabei ein Bundle-Mitglied fiel, zerbricht das Bundle in diesem Ziel (E69), wie beim
525
+ // einzelnen `linkEntfernen`: die übrigen Links bleiben als Einzel-Installationen liegen.
526
+ const zustand = await this.p.zustand.lesen();
527
+ let bundlesGeaendert = false;
528
+ for (const link of inventar.links)
529
+ if (Anwendungsfaelle.bundlesZerbrechen(zustand, link.ziel, [link.name]).length > 0)
530
+ bundlesGeaendert = true;
531
+ if (bundlesGeaendert)
532
+ await this.p.zustand.schreiben(zustand);
516
533
  for (const checkout of inventar.checkouts)
517
534
  await this.p.quellen.entfernen(checkout);
518
535
  return { entfernteLinks: inventar.links.length, entfernteCheckouts: inventar.checkouts.length };
@@ -551,7 +568,20 @@ export class Anwendungsfaelle {
551
568
  try {
552
569
  const ref = await this.p.quellen.standardRef(quelle);
553
570
  const skills = await this.p.quellen.skills(quelle, ref);
554
- ergebnis.push({ quelle, ref, skills });
571
+ // Änderungsdatum je Skill (E70, F70=O1), in einem Aufruf für die ganze Quelle. Ein
572
+ // Fehlschlag kostet nur die Daten, nicht den Katalog: der Reiter zeigt dann die Skills
573
+ // ohne Zusatzangabe.
574
+ const daten = await this.p.quellen
575
+ .letzteAenderungen(quelle, ref, skills.map((s) => s.pfad))
576
+ .catch(() => new Map());
577
+ ergebnis.push({
578
+ quelle,
579
+ ref,
580
+ skills: skills.map((skill) => {
581
+ const geaendert = daten.get(skill.pfad);
582
+ return geaendert === undefined ? skill : { ...skill, geaendert };
583
+ }),
584
+ });
555
585
  }
556
586
  catch (fehler) {
557
587
  ergebnis.push({ quelle, skills: [], fehler: fehlertext(fehler) });
@@ -630,6 +660,359 @@ export class Anwendungsfaelle {
630
660
  }
631
661
  return skills;
632
662
  }
663
+ /**
664
+ * Bundle-Katalog (E69): je bekannter Quelle ihre Bundle-Definitionen am Standard-Ref. Bundles
665
+ * sind Quell-Inhalt und werden beim Quellen-Scan entdeckt, es gibt keinen eigenen Katalog und
666
+ * keinen Pflegebefehl (F68). Eine nicht erreichbare Quelle bekommt einen Fehlervermerk, statt den
667
+ * Lauf abzubrechen, wie bei `katalog` und `marketplaces`.
668
+ */
669
+ async bundleKatalog() {
670
+ const zustand = await this.p.zustand.lesen();
671
+ const ergebnis = [];
672
+ for (const quelle of zustand.quellen) {
673
+ try {
674
+ const ref = await this.p.quellen.standardRef(quelle);
675
+ const funde = await this.p.quellen.bundles(quelle, ref);
676
+ ergebnis.push({
677
+ quelle,
678
+ ref,
679
+ bundles: funde.flatMap((f) => (f.art === 'bundle' ? [f.bundle] : [])),
680
+ kaputte: funde.flatMap((f) => (f.art === 'fehler' ? [{ datei: f.datei, fehler: f.fehler }] : [])),
681
+ });
682
+ }
683
+ catch (fehler) {
684
+ ergebnis.push({ quelle, bundles: [], kaputte: [], fehler: fehlertext(fehler) });
685
+ }
686
+ }
687
+ return ergebnis;
688
+ }
689
+ /**
690
+ * Bundle über alle bekannten Quellen suchen (E69). Mehrere Treffer sind nur mit Quellenhinweis
691
+ * erlaubt, dieselbe Regel wie bei Skills (E12). Eine nicht erreichbare Quelle fällt aus der Suche,
692
+ * statt sie abzubrechen — sonst fände kein einziges Bundle mehr statt, weil eine Quelle klemmt.
693
+ */
694
+ async bundleFinden(name, quelleHinweis) {
695
+ const funde = [];
696
+ for (const eintrag of await this.bundleKatalog()) {
697
+ if (!quellePasst(eintrag.quelle, quelleHinweis))
698
+ continue;
699
+ for (const bundle of eintrag.bundles)
700
+ if (bundle.name === name)
701
+ funde.push(bundle);
702
+ }
703
+ if (funde.length === 0)
704
+ throw new BundleUnbekannt(name);
705
+ if (funde.length > 1)
706
+ throw new BundleMehrdeutig(name, funde.map((b) => b.quelle));
707
+ return funde[0];
708
+ }
709
+ /** Die Definition zu einer Installation, aus der Quelle, in der sie steht (E69, für den Abgleich). */
710
+ async bundleAusQuelle(name, quelle) {
711
+ const key = quelleSchluessel(quelle);
712
+ const gefuehrt = (await this.p.zustand.lesen()).quellen.find((q) => quelleSchluessel(q) === key);
713
+ if (gefuehrt === undefined)
714
+ throw new QuelleUnbekannt(quelle);
715
+ const ref = await this.p.quellen.standardRef(gefuehrt);
716
+ for (const fund of await this.p.quellen.bundles(gefuehrt, ref))
717
+ if (fund.art === 'bundle' && fund.bundle.name === name)
718
+ return fund.bundle;
719
+ throw new BundleUnbekannt(name);
720
+ }
721
+ /**
722
+ * Mitglieder einer Definition auflösen (E69, F66): je Mitglied die Quelle (die geführte, wenn hmgr
723
+ * sie schon kennt, sonst die Adresse aus der Definition), ihren Standard-Ref und den Skill am
724
+ * genannten Pfad. Schreibt nichts: eine noch unbekannte Quelle wird hier nur gelesen und kommt
725
+ * erst in den Zustand, wenn das ganze Bundle steht (F66b, alles oder nichts). Lässt sich ein
726
+ * Mitglied nicht auflösen, wirft der Aufruf mit allen Gründen auf einmal.
727
+ */
728
+ async bundleAufloesen(bundle) {
729
+ const zustand = await this.p.zustand.lesen();
730
+ const gefuehrt = new Map(zustand.quellen.map((q) => [quelleSchluessel(q), q]));
731
+ const funde = [];
732
+ const neue = [];
733
+ const fehler = [];
734
+ for (const mitglied of bundle.mitglieder) {
735
+ const key = quelleSchluessel(mitglied.quelle);
736
+ const quelle = gefuehrt.get(key) ?? neue.find((q) => quelleSchluessel(q) === key) ?? mitglied.quelle;
737
+ const adresse = quelleAlsText(mitglied.quelle);
738
+ try {
739
+ const ref = await this.p.quellen.standardRef(quelle);
740
+ const skill = (await this.p.quellen.skills(quelle, ref)).find((s) => s.pfad === mitglied.pfad);
741
+ if (skill === undefined) {
742
+ fehler.push({ adresse, pfad: mitglied.pfad, grund: 'kein Skill an diesem Pfad' });
743
+ continue;
744
+ }
745
+ funde.push({ quelle, ref, skill });
746
+ if (!gefuehrt.has(key) && !neue.some((q) => quelleSchluessel(q) === key))
747
+ neue.push(quelle);
748
+ }
749
+ catch (f) {
750
+ fehler.push({ adresse, pfad: mitglied.pfad, grund: fehlertext(f) });
751
+ }
752
+ }
753
+ if (fehler.length > 0)
754
+ throw new BundleMitgliederFehlen(bundle.name, fehler);
755
+ return { funde, neue };
756
+ }
757
+ /** Ziel einer Bundle-Installation aus dem Zustand (E69). */
758
+ static installationZiel(installation) {
759
+ return { harness: installation.harness, ort: installation.ort };
760
+ }
761
+ /** Bundle-Installationen eines Ziels aus dem Zustand (E69). */
762
+ static installationenAn(zustand, ziel) {
763
+ const schluessel = zielSchluessel(ziel);
764
+ return zustand.bundleInstallationen.filter((i) => zielSchluessel(Anwendungsfaelle.installationZiel(i)) === schluessel);
765
+ }
766
+ /**
767
+ * Namenskonflikte am Ziel (E69, F67): Liegt dort schon derselbe Skill aus derselben Quelle als
768
+ * hmgr-Link, wird er als Mitglied übernommen — so wird aus Bestand ein Bundle, ohne erst Links von
769
+ * Hand zu entfernen. Alles andere ist ein Konflikt: ein anderer Skill gleichen Namens, ein fremder
770
+ * Eintrag, ein Link ohne bekannte Quelle, ein Link, der schon zu einem anderen Bundle gehört (den
771
+ * zu übernehmen zerbräche jenes, ohne dass es jemand sagt), und zwei Mitglieder desselben Bundles
772
+ * mit demselben Namen (am Ziel gibt es den Namen nur einmal, E12).
773
+ */
774
+ async bundleKonflikte(bundle, ziel, verzeichnis, funde) {
775
+ const eintraege = await this.p.ziele.eintraege(ziel, verzeichnis).catch(() => []);
776
+ const nachName = new Map();
777
+ for (const eintrag of eintraege)
778
+ nachName.set(eintrag.art === 'link' ? eintrag.link.name : eintrag.name, eintrag);
779
+ const zustand = await this.p.zustand.lesen();
780
+ const eigenerSchluessel = bundleSchluessel(bundle);
781
+ const fremdesBundle = new Map();
782
+ for (const installation of Anwendungsfaelle.installationenAn(zustand, ziel)) {
783
+ if (bundleSchluessel({ name: installation.bundle, quelle: installation.quelle }) === eigenerSchluessel)
784
+ continue;
785
+ for (const name of installation.skills)
786
+ fremdesBundle.set(name, installation.bundle);
787
+ }
788
+ const konflikte = [];
789
+ const uebernommen = [];
790
+ const gesehen = new Set();
791
+ for (const fund of funde) {
792
+ const name = fund.skill.name;
793
+ if (gesehen.has(name)) {
794
+ konflikte.push({ name, grund: 'das Bundle nennt zwei Skills dieses Namens, am Ziel gibt es ihn nur einmal' });
795
+ continue;
796
+ }
797
+ gesehen.add(name);
798
+ const eintrag = nachName.get(name);
799
+ if (eintrag === undefined)
800
+ continue;
801
+ if (eintrag.art === 'fremd') {
802
+ konflikte.push({ name, grund: 'dort liegt ein fremder Eintrag' });
803
+ continue;
804
+ }
805
+ if (eintrag.art === 'link-ohne-quelle') {
806
+ konflikte.push({ name, grund: 'dort liegt ein Link, dessen Quelle hmgr nicht kennt' });
807
+ continue;
808
+ }
809
+ const gleicheQuelle = quelleSchluessel(eintrag.link.checkout.quelle) === quelleSchluessel(fund.quelle);
810
+ if (!gleicheQuelle || eintrag.link.skill.pfad !== fund.skill.pfad) {
811
+ konflikte.push({
812
+ name,
813
+ grund: `dort liegt schon ein Link aus ${quelleAlsText(eintrag.link.checkout.quelle)} (${eintrag.link.skill.pfad})`,
814
+ });
815
+ continue;
816
+ }
817
+ const anderes = fremdesBundle.get(name);
818
+ if (anderes !== undefined) {
819
+ konflikte.push({ name, grund: `der Link gehört zum Bundle „${anderes}"` });
820
+ continue;
821
+ }
822
+ uebernommen.push(name);
823
+ }
824
+ return { konflikte, uebernommen };
825
+ }
826
+ /**
827
+ * Quellen aus einer Bundle-Definition anlegen (E69, F66), nachdem alles geprüft ist. Der
828
+ * Anzeigename ist der abgeleitete, bei Kollision der nächste freie (dieselbe Regel wie bei der
829
+ * Übernahme, E31): hier nennt niemand einen Namen, und ein Abbruch am Anzeigenamen wäre die
830
+ * falsche Sorte Hürde.
831
+ */
832
+ async bundleQuellenAnlegen(neue) {
833
+ const angelegt = [];
834
+ for (const quelle of neue) {
835
+ const zustand = await this.p.zustand.lesen();
836
+ const key = quelleSchluessel(quelle);
837
+ if (zustand.quellen.some((q) => quelleSchluessel(q) === key))
838
+ continue;
839
+ const name = quelleNameFrei(quelleNameAbleiten(quelle), zustand.quellen);
840
+ const { quelle: gefuehrt } = await this.quelleHinzufuegen(quelle, name);
841
+ angelegt.push(gefuehrt);
842
+ }
843
+ return angelegt;
844
+ }
845
+ /** Die Zugehörigkeit der Links eines Bundles im Ziel merken (E69), einen alten Eintrag ersetzend. */
846
+ async bundleInstallationMerken(ziel, bundle, skills) {
847
+ const zustand = await this.p.zustand.lesen();
848
+ const schluessel = zielSchluessel(ziel);
849
+ const eigener = bundleSchluessel(bundle);
850
+ zustand.bundleInstallationen = zustand.bundleInstallationen.filter((i) => zielSchluessel(Anwendungsfaelle.installationZiel(i)) !== schluessel ||
851
+ bundleSchluessel({ name: i.bundle, quelle: i.quelle }) !== eigener);
852
+ zustand.bundleInstallationen.push({
853
+ harness: ziel.harness,
854
+ ort: ziel.ort,
855
+ bundle: bundle.name,
856
+ quelle: bundle.quelle,
857
+ skills,
858
+ });
859
+ await this.p.zustand.schreiben(zustand);
860
+ }
861
+ /**
862
+ * Zerbrechen (E69): Wird ein einzelner Link entfernt, der zu einem Bundle gehört, zerbricht das
863
+ * Bundle in diesem Ziel — die übrigen Links werden Einzel-Installationen, der Bundle-Verweis
864
+ * fällt weg. Ändert nur den übergebenen Zustand, geschrieben wird er vom Aufrufer, der ohnehin
865
+ * schreibt. Gibt die Namen der zerbrochenen Bundles zurück, damit die Oberfläche es sagen kann.
866
+ */
867
+ static bundlesZerbrechen(zustand, ziel, namen) {
868
+ const schluessel = zielSchluessel(ziel);
869
+ const zerbrochen = [];
870
+ zustand.bundleInstallationen = zustand.bundleInstallationen.filter((installation) => {
871
+ if (zielSchluessel(Anwendungsfaelle.installationZiel(installation)) !== schluessel)
872
+ return true;
873
+ if (!installation.skills.some((name) => namen.includes(name)))
874
+ return true;
875
+ zerbrochen.push(installation.bundle);
876
+ return false;
877
+ });
878
+ return zerbrochen;
879
+ }
880
+ /**
881
+ * Bundle installieren (E69): das Bundle als Ganzes in ein Ziel. Erst wird alles geprüft — jedes
882
+ * Mitglied auflösbar (F66b), kein Namenskonflikt am Ziel (F67) —, dann werden fehlende Quellen
883
+ * angelegt und die Links gelegt. Scheitert die Prüfung, ist nichts geschehen: weder ein Link noch
884
+ * ein Eintrag im Zustand, denn ein halbes Bundle wäre stilles Fehlen von Skills (B13).
885
+ *
886
+ * Der Zeiger bleibt außen vor: Bundles stehen im Zustand, nicht in der composer.json (E69), also
887
+ * legt der Aufruf seine Links wie mit `--local` und trifft die Sperre aus E57 nie.
888
+ */
889
+ async bundleInstallieren(args) {
890
+ const harness = await this.harnessGeprueft(args.ziel.harness);
891
+ if (!harness.orte().includes(args.ziel.ort.art))
892
+ throw new OrtNichtUnterstuetzt(harness.name, args.ziel.ort.art);
893
+ const bundle = await this.bundleFinden(args.bundle, args.quelle);
894
+ const { funde, neue } = await this.bundleAufloesen(bundle);
895
+ const verzeichnis = harness.zielVerzeichnis(args.ziel.ort);
896
+ const { konflikte, uebernommen } = await this.bundleKonflikte(bundle, args.ziel, verzeichnis, funde);
897
+ if (konflikte.length > 0)
898
+ throw new BundleNamenskonflikte(bundle.name, args.ziel, konflikte);
899
+ const neueQuellen = await this.bundleQuellenAnlegen(neue);
900
+ for (const fund of funde)
901
+ await this.linkAnlegen(harness, args.ziel, fund);
902
+ const namen = funde.map((f) => f.skill.name);
903
+ await this.bundleInstallationMerken(args.ziel, bundle, namen);
904
+ // Der Hinweis des Harness gilt dem Ort, also einmal für das ganze Bundle, nicht je Skill (E34).
905
+ const hinweis = await this.harnessHinweis(harness, args.ziel.ort);
906
+ return {
907
+ bundle,
908
+ ziel: args.ziel,
909
+ gelegt: namen.filter((n) => !uebernommen.includes(n)),
910
+ uebernommen,
911
+ neueQuellen,
912
+ ...(hinweis === undefined ? {} : { hinweis }),
913
+ };
914
+ }
915
+ /** Die Installation eines Bundles in einem Ziel, über seinen Namen (E69). */
916
+ async installationFinden(bundleName, ziel) {
917
+ const zustand = await this.p.zustand.lesen();
918
+ const treffer = Anwendungsfaelle.installationenAn(zustand, ziel).filter((i) => i.bundle === bundleName);
919
+ if (treffer.length === 0)
920
+ throw new BundleNichtInstalliert(bundleName, ziel);
921
+ // Zwei Quellen dürfen denselben Bundle-Namen führen (E69); sind beide hier installiert, muss
922
+ // die Quelle genannt werden, wie bei einem mehrdeutigen Skill-Namen (E12).
923
+ if (treffer.length > 1)
924
+ throw new BundleMehrdeutig(bundleName, treffer.map((i) => i.quelle));
925
+ return treffer[0];
926
+ }
927
+ /**
928
+ * Bundle entfernen (E69): alle seine Links im Ziel und den Bundle-Verweis. Entfernt wird direkt
929
+ * über den Ziele-Port, nicht über den Anwendungsfall `linkEntfernen` — der macht Zeiger-Arbeit
930
+ * (E57) und ließe das Bundle bei jedem einzelnen Link zerbrechen, das es hier gerade verlässt.
931
+ */
932
+ async bundleEntfernen(args) {
933
+ const harness = await this.harnessGeprueft(args.ziel.harness);
934
+ const installation = await this.installationFinden(args.bundle, args.ziel);
935
+ const verzeichnis = harness.zielVerzeichnis(args.ziel.ort);
936
+ const eintraege = await this.p.ziele.eintraege(args.ziel, verzeichnis).catch(() => []);
937
+ const eigene = new Set(eintraege.filter((e) => e.art !== 'fremd').map((e) => (e.art === 'link' ? e.link.name : e.name)));
938
+ const entfernt = [];
939
+ for (const name of installation.skills) {
940
+ // Was nicht mehr liegt, wird nicht gemeldet: ein von Hand gelöschter Link ist kein Fehler.
941
+ if (!eigene.has(name))
942
+ continue;
943
+ await this.p.ziele.linkEntfernen(args.ziel, verzeichnis, name);
944
+ entfernt.push(name);
945
+ }
946
+ const zustand = await this.p.zustand.lesen();
947
+ const schluessel = zielSchluessel(args.ziel);
948
+ const eigenerSchluessel = bundleSchluessel({ name: installation.bundle, quelle: installation.quelle });
949
+ zustand.bundleInstallationen = zustand.bundleInstallationen.filter((i) => zielSchluessel(Anwendungsfaelle.installationZiel(i)) !== schluessel ||
950
+ bundleSchluessel({ name: i.bundle, quelle: i.quelle }) !== eigenerSchluessel);
951
+ await this.p.zustand.schreiben(zustand);
952
+ return { bundle: installation.bundle, quelle: installation.quelle, ziel: args.ziel, entfernt };
953
+ }
954
+ /**
955
+ * Bundle-Abgleich (E69): jedes installierte Bundle gegen seine Definition ziehen — neue Mitglieder
956
+ * verlinken, entfallene Links wegnehmen, bestehende auf den aktuellen Stand ihrer Quelle legen.
957
+ * Wirkt auf das Bundle als Ganzes: Lässt sich eine Definitionsänderung nicht vollständig nachziehen
958
+ * (Quelle weg, Pfad kein Skill mehr, Namenskonflikt), bleibt das Bundle im betroffenen Ziel auf dem
959
+ * alten Stand und der Lauf meldet es, statt es halb nachzuziehen (F66b).
960
+ */
961
+ async bundleAbgleich(args) {
962
+ const zustand = await this.p.zustand.lesen();
963
+ const zielSchl = args?.ziel === undefined ? undefined : zielSchluessel(args.ziel);
964
+ const installationen = zustand.bundleInstallationen.filter((i) => (args?.bundle === undefined || i.bundle === args.bundle) &&
965
+ (zielSchl === undefined || zielSchluessel(Anwendungsfaelle.installationZiel(i)) === zielSchl));
966
+ const ergebnis = [];
967
+ for (const installation of installationen) {
968
+ const ziel = Anwendungsfaelle.installationZiel(installation);
969
+ const kopf = { ziel, bundle: installation.bundle, quelle: installation.quelle };
970
+ try {
971
+ const harness = await this.harnessGeprueft(ziel.harness);
972
+ // Die Definition liegt in der Quelle: ohne Nachziehen bliebe eine Änderung unsichtbar,
973
+ // dieselbe Vorsorge wie bei einer Versionseinschränkung (E33). Einmal je Lauf und Quelle,
974
+ // ein Fehlschlag (kein Netz) arbeitet mit dem vorhandenen Stand weiter.
975
+ await this.refsNachziehen(installation.quelle);
976
+ const bundle = await this.bundleAusQuelle(installation.bundle, installation.quelle);
977
+ const { funde, neue } = await this.bundleAufloesen(bundle);
978
+ const verzeichnis = harness.zielVerzeichnis(ziel.ort);
979
+ const { konflikte } = await this.bundleKonflikte(bundle, ziel, verzeichnis, funde);
980
+ if (konflikte.length > 0)
981
+ throw new BundleNamenskonflikte(bundle.name, ziel, konflikte);
982
+ const neueQuellen = await this.bundleQuellenAnlegen(neue);
983
+ for (const fund of funde)
984
+ await this.linkAnlegen(harness, ziel, fund);
985
+ const namen = funde.map((f) => f.skill.name);
986
+ const entfernt = [];
987
+ for (const name of installation.skills) {
988
+ if (namen.includes(name))
989
+ continue;
990
+ await this.p.ziele.linkEntfernen(ziel, verzeichnis, name);
991
+ entfernt.push(name);
992
+ }
993
+ await this.bundleInstallationMerken(ziel, bundle, namen);
994
+ const gelegt = namen.filter((n) => !installation.skills.includes(n));
995
+ ergebnis.push({
996
+ ...kopf,
997
+ ergebnis: gelegt.length === 0 && entfernt.length === 0 ? 'unveraendert' : 'aktualisiert',
998
+ gelegt,
999
+ entfernt,
1000
+ neueQuellen,
1001
+ });
1002
+ }
1003
+ catch (fehler) {
1004
+ ergebnis.push({
1005
+ ...kopf,
1006
+ ergebnis: 'fehlgeschlagen',
1007
+ gelegt: [],
1008
+ entfernt: [],
1009
+ neueQuellen: [],
1010
+ fehler: fehlertext(fehler),
1011
+ });
1012
+ }
1013
+ }
1014
+ return ergebnis;
1015
+ }
633
1016
  /**
634
1017
  * Arbeitsverzeichnis hinzufügen (E21, E26): Zeiger suchen, Bindung je vorhandenem Harness merken.
635
1018
  * Kein Zeiger heißt: Verzeichnis merken, keine Bindung, Hinweis. Mehrdeutig heißt: Fehler,
@@ -849,28 +1232,36 @@ export class Anwendungsfaelle {
849
1232
  /**
850
1233
  * Link entfernen. Ohne `lokal` verschwindet der Skill auch aus dem Zeiger (E17). Mit `lokal` an
851
1234
  * einem gebundenen Ziel bleibt der Zeiger unangetastet, ein Hinweis sagt das (E57, F32).
1235
+ *
1236
+ * Gehörte der Link zu einem Bundle, zerbricht es in diesem Ziel (E69): die übrigen Links bleiben
1237
+ * als Einzel-Installationen liegen, der Bundle-Verweis fällt weg. `zerbrochen` nennt die Bundles,
1238
+ * damit die Oberfläche es sagen kann.
852
1239
  */
853
1240
  async linkEntfernen(args) {
854
1241
  const harness = await this.harnessGeprueft(args.ziel.harness);
855
1242
  // Vor jeder Wirkung: kein Entfernen, wenn danach das Schreiben in den Zeiger scheitern würde (E57).
856
1243
  await this.zeigerSchreibenPruefen(args.ziel, args.lokal);
857
1244
  await this.p.ziele.linkEntfernen(args.ziel, harness.zielVerzeichnis(args.ziel.ort), args.skill);
1245
+ const zustand = await this.p.zustand.lesen();
1246
+ const zerbrochen = Anwendungsfaelle.bundlesZerbrechen(zustand, args.ziel, [args.skill]);
1247
+ if (zerbrochen.length > 0)
1248
+ await this.p.zustand.schreiben(zustand);
858
1249
  if (args.lokal === true && args.ziel.ort.art === 'arbeitsverzeichnis') {
859
1250
  const zeigerPfad = await this.bindungFinden(args.ziel);
860
1251
  if (zeigerPfad !== undefined)
861
- return { hinweis: 'Der Link ist nur lokal entfernt, der Zeiger bleibt unangetastet (F32).' };
862
- return {};
1252
+ return { hinweis: 'Der Link ist nur lokal entfernt, der Zeiger bleibt unangetastet (F32).', zerbrochen };
1253
+ return { zerbrochen };
863
1254
  }
864
1255
  if (args.lokal === true || args.ziel.ort.art !== 'arbeitsverzeichnis')
865
- return {};
1256
+ return { zerbrochen };
866
1257
  const zeigerPfad = await this.bindungFinden(args.ziel);
867
1258
  if (zeigerPfad === undefined)
868
- return {};
1259
+ return { zerbrochen };
869
1260
  const zeiger = await this.p.zeiger.lesen(zeigerPfad);
870
1261
  if (!zeiger.skills.delete(args.skill))
871
- return {};
1262
+ return { zerbrochen };
872
1263
  await this.p.zeiger.schreiben(zeigerPfad, zeiger);
873
- return {};
1264
+ return { zerbrochen };
874
1265
  }
875
1266
  /**
876
1267
  * Ignoriert einen fremden Eintrag (E43): idempotent, kein Duplikat. Gleichheit über harness, Ort
@@ -1211,7 +1602,14 @@ export class Anwendungsfaelle {
1211
1602
  checkouts.push({
1212
1603
  checkout: { ...checkout, quelle: quelleMitNamen(checkout.quelle, gefuehrt.name) },
1213
1604
  aktualisierung,
1605
+ geaenderteSkills: await this.geaenderteSkills(checkout, aktualisierung),
1214
1606
  });
1607
+ // `aktualisieren` holt für einen Checkout dieselben Refs wie `refsNachziehen` (fetch auf dem
1608
+ // Basis-Clone). Ist das gelaufen, braucht der Bundle-Abgleich unten die Quelle nicht ein
1609
+ // zweites Mal zu holen. Ein übersprungener oder gescheiterter Checkout hat nichts geholt und
1610
+ // wird deshalb nicht vermerkt.
1611
+ if (aktualisierung.ergebnis === 'aktualisiert' || aktualisierung.ergebnis === 'unveraendert')
1612
+ this.nachgezogen.add(quelleSchluessel(checkout.quelle));
1215
1613
  }
1216
1614
  // Nach den Checkouts die Plugins nachziehen (E14).
1217
1615
  const pluginHarnesses = [];
@@ -1221,7 +1619,41 @@ export class Anwendungsfaelle {
1221
1619
  await harness.plugins.aktualisieren();
1222
1620
  pluginHarnesses.push(harness.name);
1223
1621
  }
1224
- return { checkouts, pluginHarnesses, aufraeumen: await this.verwaisteAufraeumen(checkouts, rueckfragen) };
1622
+ // Bundles vor dem Aufräumen (E69, F64=O1): der Abgleich legt und entfernt Links, und erst
1623
+ // danach steht fest, in welchen Checkout noch ein Link zeigt. Ein Fehlschlag je Bundle steckt
1624
+ // in seinem Eintrag, er bricht den Lauf nicht ab — wie eine unerreichbare Quelle (E51).
1625
+ const bundles = await this.bundleAbgleich();
1626
+ return {
1627
+ checkouts,
1628
+ pluginHarnesses,
1629
+ bundles,
1630
+ aufraeumen: await this.verwaisteAufraeumen(checkouts, rueckfragen),
1631
+ };
1632
+ }
1633
+ /**
1634
+ * Welche Skills ein aktualisierter Checkout mitgebracht hat (E70). Die geänderten Pfade kommen
1635
+ * aus dem Vergleich von altem und neuem Commit, die Skills aus dem Checkout selbst: ein Skill,
1636
+ * der in dieser Quelle liegt, hier aber nicht ausgecheckt ist, geht den Nutzer nichts an. Ein
1637
+ * Skill zählt, wenn eine geänderte Datei in seinem Ordner liegt; ein Wurzel-Skill ist das ganze
1638
+ * Repo und zählt bei jeder Änderung.
1639
+ *
1640
+ * Nur bei `aktualisiert` gibt es zwei Commits zum Vergleichen. Scheitert die Ermittlung, bleibt
1641
+ * die Liste leer: der Bericht wird ungenauer, der Lauf nicht kaputt.
1642
+ */
1643
+ async geaenderteSkills(checkout, aktualisierung) {
1644
+ if (aktualisierung.ergebnis !== 'aktualisiert')
1645
+ return [];
1646
+ const dateien = await this.p.quellen
1647
+ .geaenderteDateien(checkout.quelle, aktualisierung.von, aktualisierung.nach)
1648
+ .catch(() => []);
1649
+ if (dateien.length === 0)
1650
+ return [];
1651
+ const skills = await this.p.quellen.ausgecheckteSkills(checkout).catch(() => []);
1652
+ const namen = new Set();
1653
+ for (const skill of skills)
1654
+ if (dateien.some((datei) => skill.pfad === '' || datei === skill.pfad || datei.startsWith(`${skill.pfad}/`)))
1655
+ namen.add(skill.name);
1656
+ return [...namen].sort((a, b) => a.localeCompare(b, 'de'));
1225
1657
  }
1226
1658
  /**
1227
1659
  * Pfade, in die ein Link zeigt. Gelesen wird wie beim Ist-Zustand (E19): die Ziele der
@@ -1609,6 +2041,14 @@ export class Anwendungsfaelle {
1609
2041
  for (const ei of eintraege)
1610
2042
  if (ei.art === 'fremd')
1611
2043
  uebernahme.set(ei.name, await this.erkennenFremd(harness, verzeichnis, ei, bekannt, true));
2044
+ // Bundles dieses Ziels (E69), rein aus dem Zustand: nur die Mitglieder, die dort auch
2045
+ // wirklich noch liegen — ein von Hand gelöschter Link soll die Gruppe nicht länger führen.
2046
+ const eigeneNamen = new Set(eintraege.filter((ei) => ei.art !== 'fremd').map((ei) => (ei.art === 'link' ? ei.link.name : ei.name)));
2047
+ const bundles = Anwendungsfaelle.installationenAn(zustand, ziel).map((i) => ({
2048
+ name: i.bundle,
2049
+ quelle: i.quelle,
2050
+ skills: i.skills.filter((name) => eigeneNamen.has(name)),
2051
+ }));
1612
2052
  ergebnis.push({
1613
2053
  ziel,
1614
2054
  verzeichnis,
@@ -1617,6 +2057,7 @@ export class Anwendungsfaelle {
1617
2057
  gruende: await this.gruende(harness, ziel, eintraege, zeiger, marktplatzFunde),
1618
2058
  ignoriert,
1619
2059
  uebernahme,
2060
+ bundles,
1620
2061
  istRegistriert,
1621
2062
  kannSkillOrdner: skillOrdnerFaehig,
1622
2063
  ...(plugins === undefined ? {} : { plugins }),
@@ -1,5 +1,12 @@
1
1
  // Begriffe aus CONTEXT.md. Nur Werte und Regeln, keine Pfade zu Dateien außerhalb des Modells.
2
2
  import { ortSchluessel, urlOrt } from "./quelle-ort.js";
3
+ /**
4
+ * Identität eines Bundles (E69): Quelle der Definition plus Name. Zwei Quellen dürfen denselben
5
+ * Bundle-Namen führen, mehrdeutig wird es erst beim Suchen, wie bei Skills (E12).
6
+ */
7
+ export function bundleSchluessel(bundle) {
8
+ return `${quelleSchluessel(bundle.quelle)}#${bundle.name}`;
9
+ }
3
10
  /** Ansprache eines Plugins im Zeiger und gegenüber der claude-CLI: `<plugin>@<marketplace>`. */
4
11
  export function pluginSchluessel(plugin) {
5
12
  return `${plugin.name}@${plugin.marketplace}`;
@@ -448,6 +455,79 @@ export class McpUebernahmeUnvollstaendig extends Error {
448
455
  this.datei = datei;
449
456
  }
450
457
  }
458
+ /** Ein Bundle-Name, den keine bekannte Quelle führt (E69). */
459
+ export class BundleUnbekannt extends Error {
460
+ bundleName;
461
+ constructor(bundleName) {
462
+ super(`Bundle „${bundleName}" steht in keiner Quelle`);
463
+ this.bundleName = bundleName;
464
+ }
465
+ }
466
+ /** Denselben Bundle-Namen führen mehrere Quellen (E69, Regel wie E12 bei Skills). */
467
+ export class BundleMehrdeutig extends Error {
468
+ bundleName;
469
+ quellen;
470
+ constructor(bundleName, quellen) {
471
+ super(`Bundle „${bundleName}" liegt in mehreren Quellen: ${quellen.map(quelleSchluessel).join(', ')}`);
472
+ this.bundleName = bundleName;
473
+ this.quellen = quellen;
474
+ }
475
+ }
476
+ /**
477
+ * Die Definition nennt Mitglieder, die sich auf dieser Maschine nicht auflösen lassen (E69, F66b):
478
+ * eine Quelle ohne Zugriff oder ein Pfad, der in ihr kein Skill ist. Ein halbes Bundle wäre stilles
479
+ * Fehlen von Skills, die falsche Fehlersorte (B13) — deshalb wird abgebrochen, bevor irgendetwas
480
+ * verlinkt ist, und bevor eine fehlende Quelle in den Zustand kommt.
481
+ */
482
+ export class BundleMitgliederFehlen extends Error {
483
+ bundleName;
484
+ fehler;
485
+ constructor(bundleName, fehler) {
486
+ const zeilen = fehler.map((f) => ` ${f.adresse} (${f.pfad}): ${f.grund}`).join('\n');
487
+ super(`Bundle „${bundleName}" ist auf dieser Maschine nicht vollständig herstellbar, nichts wurde verlinkt:\n${zeilen}`);
488
+ this.bundleName = bundleName;
489
+ this.fehler = fehler;
490
+ }
491
+ }
492
+ /**
493
+ * Am Ziel liegt unter dem Namen eines Mitglieds schon etwas anderes (E69, F67): ein anderer Skill
494
+ * gleichen Namens, ein fremder Eintrag oder ein Link, der zu einem anderen Bundle gehört. Derselbe
495
+ * Skill aus derselben Quelle ist kein Konflikt, er wird als Mitglied übernommen. Abbruch wie bei
496
+ * `BundleMitgliederFehlen`, die Meldung listet alle Konflikte.
497
+ */
498
+ export class BundleNamenskonflikte extends Error {
499
+ bundleName;
500
+ ziel;
501
+ konflikte;
502
+ constructor(bundleName, ziel, konflikte) {
503
+ const zeilen = konflikte.map((k) => ` ${k.name}: ${k.grund}`).join('\n');
504
+ super(`Bundle „${bundleName}" lässt sich in ${zielSchluessel(ziel)} nicht anlegen, nichts wurde verlinkt:\n${zeilen}`);
505
+ this.bundleName = bundleName;
506
+ this.ziel = ziel;
507
+ this.konflikte = konflikte;
508
+ }
509
+ }
510
+ /** Ein Bundle, das in diesem Ziel nicht installiert ist (E69): Entfernen kennt nur installierte. */
511
+ export class BundleNichtInstalliert extends Error {
512
+ bundleName;
513
+ ziel;
514
+ constructor(bundleName, ziel) {
515
+ super(`In ${zielSchluessel(ziel)} ist kein Bundle „${bundleName}" installiert`);
516
+ this.bundleName = bundleName;
517
+ this.ziel = ziel;
518
+ }
519
+ }
520
+ /**
521
+ * Eine Bundle-Definition lässt sich nicht lesen (E69): kaputtes YAML oder ein Feld außerhalb des
522
+ * dokumentierten Formats. Wie beim MCP-Katalog (E61) mit Datei, Feld und Grund in der Meldung.
523
+ */
524
+ export class BundleDateiFehler extends Error {
525
+ datei;
526
+ constructor(datei, grund) {
527
+ super(`Bundle-Datei ${datei}: ${grund}`);
528
+ this.datei = datei;
529
+ }
530
+ }
451
531
  /** Der MCP-Katalog ~/.hmgr/mcp.yaml lässt sich nicht lesen (E61): kaputtes YAML oder falsches Format. */
452
532
  export class McpKatalogFehler extends Error {
453
533
  pfad;