hmgr 0.0.45 → 0.0.47

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.
@@ -1,7 +1,9 @@
1
1
  // Git über die CLI, keine Bibliothek. Checkout-Schema und Worktrees nach E22, Discovery nach E11.
2
2
  import { spawn } from 'node:child_process';
3
- import { readFile, readdir, mkdir, realpath, rm, stat } from 'node:fs/promises';
3
+ import { readFile, readdir, mkdir, realpath, rm, stat, writeFile } from 'node:fs/promises';
4
+ import { homedir } from 'node:os';
4
5
  import path from 'node:path';
6
+ import { SOPS_FILTER, SOPS_MARKE, checkAttrLesen, filterBefehle, musterAuswahl, probe, sopsFehlertext, standardSops, wrapperFinden, } from "./sops.js";
5
7
  import { bundleLesen, istBundleDatei } from "./bundle-yaml.js";
6
8
  import { marketplaceLesen } from "./marketplace-json.js";
7
9
  import { SKILL_DATEI, skillAusPfad, skillName } from "./skill-md.js";
@@ -131,9 +133,11 @@ const SPERRE_BELEGT = /Unable to create '[^']+\.lock': File exists/;
131
133
  export class GitQuellen {
132
134
  wurzel;
133
135
  sperreWartezeitenMs;
136
+ sops;
134
137
  constructor(wurzel, optionen = {}) {
135
138
  this.wurzel = wurzel;
136
139
  this.sperreWartezeitenMs = optionen.sperreWartezeitenMs ?? SPERRE_WARTEZEITEN_MS;
140
+ this.sops = optionen.sops ?? standardSops(homedir());
137
141
  }
138
142
  /**
139
143
  * Wiederholt einen git-Aufruf, wenn eine fremde Sperre ihn blockiert (E99): GUI-Aktionen und der
@@ -379,15 +383,10 @@ export class GitQuellen {
379
383
  async checkout(quelle, ref, skills) {
380
384
  // Lokale Quellen werden nicht geklont, der Pfad selbst ist der Checkout (B11).
381
385
  if (quelle.art === 'lokal')
382
- return { quelle, ref, pfad: quelle.pfad };
386
+ return { quelle, ref, pfad: quelle.pfad, sopsVerschluesselt: [] };
383
387
  const basis = await this.basis(quelle);
384
388
  const pfad = worktreePfad(this.wurzel, quelleZerlegen(quelle), ref);
385
389
  const neu = !(await existiert(pfad));
386
- if (neu) {
387
- await mkdir(path.dirname(pfad), { recursive: true });
388
- await this.git(['worktree', 'add', '--detach', '--no-checkout', pfad, ref], basis);
389
- await this.git(['sparse-checkout', 'init', '--no-cone'], pfad);
390
- }
391
390
  const vorhanden = neu
392
391
  ? []
393
392
  : (await this.git(['sparse-checkout', 'list'], pfad)).split('\n').filter((z) => z.length > 0);
@@ -404,10 +403,201 @@ export class GitQuellen {
404
403
  // stört nicht — wohl aber sähe ein Nutzer, der den Link im Finder öffnet, den vollen Worktree.
405
404
  for (const skill of skills)
406
405
  muster.add(skill.pfad === '' ? '/**' : `/${skill.pfad}/`);
406
+ // SOPS-Filter vor dem ersten Schreiben (E115): `sparse-checkout set` und `checkout` schreiben
407
+ // die Dateien, ein später gesetzter Filter ändert an ihnen nichts mehr. Ein bestehender
408
+ // Worktree steht auf seinem eigenen HEAD, ein neuer kommt auf `ref`.
409
+ const sopsVerschluesselt = await this.sopsVorbereiten(neu ? basis : pfad, neu ? ref : 'HEAD', basis, muster);
410
+ if (neu) {
411
+ await mkdir(path.dirname(pfad), { recursive: true });
412
+ await this.git(['worktree', 'add', '--detach', '--no-checkout', pfad, ref], basis);
413
+ await this.git(['sparse-checkout', 'init', '--no-cone'], pfad);
414
+ }
407
415
  await this.git(['sparse-checkout', 'set', '--no-cone', '--', ...muster], pfad);
408
416
  if (neu)
409
417
  await this.git(['checkout', '--detach', ref], pfad);
410
- return { quelle, ref, pfad };
418
+ return { quelle, ref, pfad, sopsVerschluesselt };
419
+ }
420
+ /**
421
+ * Dateien eines Refs unter einem SOPS-Filter, den hmgr kennt (E115), beschränkt auf `auswahl`.
422
+ * Der Normalfall kostet einen `ls-tree`: ohne `.gitattributes`, die für eine gewählte Datei
423
+ * gelten kann, oder ohne „sops_" darin endet die Suche dort. Erst dann `check-attr`, mit
424
+ * `--source` (git ≥ 2.40), weil der Basis-Clone bare ist und ein neuer Worktree noch leer.
425
+ * Scheitert ein Aufruf (etwa ein älteres git), gilt die Liste als leer: dann bleibt es beim
426
+ * Auschecken wie vor E115.
427
+ */
428
+ async sopsKandidaten(cwd, ref, auswahl) {
429
+ try {
430
+ const alle = (await this.git(['ls-tree', '-r', '-z', '--name-only', ref], cwd)).split('\0').filter((z) => z.length > 0);
431
+ const istAttribute = (d) => d === '.gitattributes' || d.endsWith('/.gitattributes');
432
+ const gewaehlt = alle.filter((d) => auswahl(d) && !istAttribute(d));
433
+ if (gewaehlt.length === 0)
434
+ return [];
435
+ // Eine .gitattributes gilt für ihr Verzeichnis und alles darunter.
436
+ const attribute = alle.filter((d) => {
437
+ if (!istAttribute(d))
438
+ return false;
439
+ const verzeichnis = d.slice(0, -'.gitattributes'.length);
440
+ return gewaehlt.some((g) => g.startsWith(verzeichnis));
441
+ });
442
+ if (attribute.length === 0)
443
+ return [];
444
+ const inhalte = await this.dateiInhalte(cwd, ref, attribute);
445
+ if (![...inhalte.values()].some((text) => text.includes('sops_')))
446
+ return [];
447
+ const ausgabe = await this.gitMitEingabe(['check-attr', `--source=${ref}`, '-z', '--stdin', 'filter'], cwd, `${gewaehlt.join('\0')}\0`);
448
+ const werte = checkAttrLesen(ausgabe.toString('utf8'));
449
+ return gewaehlt
450
+ .map((datei) => ({ datei, filter: werte.get(datei) ?? '' }))
451
+ .filter((k) => SOPS_FILTER.includes(k.filter));
452
+ }
453
+ catch {
454
+ return [];
455
+ }
456
+ }
457
+ /** Ob `filter.<name>.smudge` wirksam gesetzt ist, global oder im Basis-Clone (E115). */
458
+ async filterGesetzt(basis, name) {
459
+ const wert = await this.git(['config', '--get', `filter.${name}.smudge`], basis).catch(() => '');
460
+ return wert.trim().length > 0;
461
+ }
462
+ /** Filter wie sops-setup.sh im Basis-Clone setzen; er gilt für alle Worktrees der Quelle (E115). */
463
+ async filterSetzen(basis, namen, wrapper) {
464
+ const { clean, smudge } = filterBefehle(this.sops.programm, wrapper);
465
+ for (const name of namen) {
466
+ await this.git(['config', `filter.${name}.clean`, clean], basis);
467
+ await this.git(['config', `filter.${name}.smudge`, smudge], basis);
468
+ await this.git(['config', `filter.${name}.required`, 'true'], basis);
469
+ }
470
+ }
471
+ /**
472
+ * Probe über alle Dateien, Abbruch beim ersten Fehlschlag (E115): nur wenn alle klappen, kommen
473
+ * Filter in Frage. Ein Fehlschlag gilt für alle, denn ohne Filter bleiben alle verschlüsselt.
474
+ */
475
+ async sopsProbe(cwd, ref, dateien) {
476
+ const inhalte = await this.dateiInhalte(cwd, ref, dateien);
477
+ for (const datei of dateien) {
478
+ const inhalt = inhalte.get(datei);
479
+ if (inhalt === undefined)
480
+ return 'entschluesselung-scheitert';
481
+ const ergebnis = await probe(this.sops.programm, datei, inhalt);
482
+ if (!ergebnis.ok)
483
+ return ergebnis.grund;
484
+ }
485
+ return undefined;
486
+ }
487
+ /**
488
+ * Vor dem Schreiben eines Checkouts (E115): SOPS-Dateien unter den Mustern finden, für Filter,
489
+ * die noch nicht wirksam sind, die Probe machen und bei Erfolg die Filter setzen. Ein schon
490
+ * gesetzter Filter (global oder lokal) bleibt unangetastet und braucht keine Probe. Ergebnis:
491
+ * die Dateien, die verschlüsselt ankommen werden.
492
+ */
493
+ async sopsVorbereiten(cwd, ref, basis, muster) {
494
+ const kandidaten = await this.sopsKandidaten(cwd, ref, musterAuswahl(muster));
495
+ if (kandidaten.length === 0)
496
+ return [];
497
+ const offen = [];
498
+ for (const name of new Set(kandidaten.map((k) => k.filter)))
499
+ if (!(await this.filterGesetzt(basis, name)))
500
+ offen.push(name);
501
+ if (offen.length === 0)
502
+ return [];
503
+ const betroffen = kandidaten.filter((k) => offen.includes(k.filter)).map((k) => k.datei);
504
+ // Ohne sops-clean.sh keine Filter (F140): git hielte die entschlüsselte Datei sonst sofort für
505
+ // geändert (echtes `sops --encrypt` verschlüsselt jedes Mal anders). Die Probe entfällt dann.
506
+ const wrapper = await wrapperFinden(this.sops.wrapperKandidaten);
507
+ if (wrapper === undefined)
508
+ return betroffen.map((datei) => ({ datei, grund: 'wrapper-fehlt' }));
509
+ const grund = await this.sopsProbe(cwd, ref, betroffen);
510
+ if (grund !== undefined)
511
+ return betroffen.map((datei) => ({ datei, grund }));
512
+ await this.filterSetzen(basis, offen, wrapper);
513
+ return [];
514
+ }
515
+ /** SOPS-Dateien eines bestehenden Worktrees samt Inhalt auf der Platte (E115). */
516
+ async sopsAufPlatte(checkout) {
517
+ if (checkout.quelle.art === 'lokal' || !(await existiert(checkout.pfad)))
518
+ return [];
519
+ const kandidaten = await this.sopsKandidaten(checkout.pfad, 'HEAD', () => true);
520
+ const ergebnis = [];
521
+ for (const kandidat of kandidaten) {
522
+ // Was der Sparse-Checkout nicht geschrieben hat, liegt nicht auf der Platte und zählt nicht.
523
+ const inhalt = await readFile(path.join(checkout.pfad, kandidat.datei), 'utf8').catch(() => undefined);
524
+ if (inhalt !== undefined)
525
+ ergebnis.push({ ...kandidat, inhalt });
526
+ }
527
+ return ergebnis;
528
+ }
529
+ async sopsDateien(checkout) {
530
+ return (await this.sopsAufPlatte(checkout)).map((k) => ({
531
+ datei: k.datei,
532
+ verschluesselt: k.inhalt.includes(SOPS_MARKE),
533
+ }));
534
+ }
535
+ /**
536
+ * Bestand (E115): eine SOPS-Datei, die vor E115 verschlüsselt ankam, bleibt so, denn git hält
537
+ * sie für unverändert. Wo die Probe klappt: Filter setzen (falls noch nicht wirksam), Datei
538
+ * löschen, `git checkout -- <datei>` schreibt sie durch den smudge-Filter neu. Danach muss sie
539
+ * Klartext sein und `git status` für sie leer, sonst gilt das Neuschreiben als gescheitert.
540
+ * Eine Datei, die nicht mehr dem Commit entspricht (von Hand geändert), fasst hmgr nicht an —
541
+ * verglichen werden die Bytes mit dem Blob, unabhängig von Filtern. Scheitert `checkout`, kommt
542
+ * der alte Inhalt zurück, damit nie eine Datei fehlt.
543
+ */
544
+ async sopsReparieren(checkout) {
545
+ if (checkout.quelle.art === 'lokal')
546
+ return [];
547
+ const verschluesselt = (await this.sopsAufPlatte(checkout)).filter((k) => k.inhalt.includes(SOPS_MARKE));
548
+ if (verschluesselt.length === 0)
549
+ return [];
550
+ const ergebnis = [];
551
+ const blobs = await this.dateiInhalte(checkout.pfad, 'HEAD', verschluesselt.map((k) => k.datei));
552
+ const unveraendert = verschluesselt.filter((k) => {
553
+ if (blobs.get(k.datei) === k.inhalt)
554
+ return true;
555
+ ergebnis.push({ datei: k.datei, ergebnis: 'verschluesselt', grund: 'lokale-aenderung' });
556
+ return false;
557
+ });
558
+ if (unveraendert.length === 0)
559
+ return ergebnis;
560
+ const basis = await this.basis(checkout.quelle);
561
+ const offen = [];
562
+ for (const name of new Set(unveraendert.map((k) => k.filter)))
563
+ if (!(await this.filterGesetzt(basis, name)))
564
+ offen.push(name);
565
+ // Müssen Filter erst gesetzt werden, braucht es sops-clean.sh (F140), sonst bleibt alles, wie es ist.
566
+ const wrapper = offen.length === 0 ? undefined : await wrapperFinden(this.sops.wrapperKandidaten);
567
+ if (offen.length > 0 && wrapper === undefined) {
568
+ for (const k of unveraendert)
569
+ ergebnis.push({ datei: k.datei, ergebnis: 'verschluesselt', grund: 'wrapper-fehlt' });
570
+ return ergebnis;
571
+ }
572
+ const grund = await this.sopsProbe(checkout.pfad, 'HEAD', unveraendert.map((k) => k.datei));
573
+ if (grund !== undefined) {
574
+ for (const k of unveraendert)
575
+ ergebnis.push({ datei: k.datei, ergebnis: 'verschluesselt', grund });
576
+ return ergebnis;
577
+ }
578
+ if (wrapper !== undefined)
579
+ await this.filterSetzen(basis, offen, wrapper);
580
+ for (const k of unveraendert) {
581
+ const datei = path.join(checkout.pfad, k.datei);
582
+ try {
583
+ await rm(datei, { force: true });
584
+ await this.git(['checkout', '--', k.datei], checkout.pfad);
585
+ }
586
+ catch (fehler) {
587
+ await writeFile(datei, k.inhalt);
588
+ ergebnis.push({ datei: k.datei, ergebnis: 'fehlgeschlagen', fehler: fehler instanceof Error ? fehler.message : String(fehler) });
589
+ continue;
590
+ }
591
+ const neu = await readFile(datei, 'utf8').catch(() => undefined);
592
+ const status = await this.git(['--no-optional-locks', 'status', '--porcelain', '--', k.datei], checkout.pfad).catch(() => 'unlesbar');
593
+ if (neu === undefined || neu.includes(SOPS_MARKE))
594
+ ergebnis.push({ datei: k.datei, ergebnis: 'fehlgeschlagen', fehler: 'nach dem Neuschreiben noch verschlüsselt' });
595
+ else if (status.trim().length > 0)
596
+ ergebnis.push({ datei: k.datei, ergebnis: 'fehlgeschlagen', fehler: 'git meldet die Datei danach als geändert' });
597
+ else
598
+ ergebnis.push({ datei: k.datei, ergebnis: 'repariert' });
599
+ }
600
+ return ergebnis;
411
601
  }
412
602
  /**
413
603
  * Alle Checkouts unter der Wurzel, aus dem Dateisystem gelesen (E19). Die Quelle kommt aus der
@@ -446,7 +636,15 @@ export class GitQuellen {
446
636
  async aktualisieren(checkout) {
447
637
  if (checkout.quelle.art === 'lokal')
448
638
  return { ergebnis: 'unveraendert' };
449
- if (await this.lokaleAenderungen(checkout))
639
+ // Auch `status` kann an einem SOPS-Filter scheitern (E115): nur dieser Checkout schlägt fehl.
640
+ let lokalGeaendert;
641
+ try {
642
+ lokalGeaendert = await this.lokaleAenderungen(checkout);
643
+ }
644
+ catch (fehler) {
645
+ return { ergebnis: 'fehlgeschlagen', fehler: sopsFehlertext(fehler) };
646
+ }
647
+ if (lokalGeaendert)
450
648
  return { ergebnis: 'uebersprungen', grund: 'lokale-aenderung' }; // E15
451
649
  const basis = await this.basis(checkout.quelle);
452
650
  const von = (await this.git(['rev-parse', 'HEAD'], checkout.pfad)).trim();
@@ -460,7 +658,14 @@ export class GitQuellen {
460
658
  const nach = (await this.git(['rev-parse', checkout.ref], basis)).trim();
461
659
  if (von === nach)
462
660
  return { ergebnis: 'unveraendert' };
463
- await this.git(['checkout', '--detach', checkout.ref], checkout.pfad);
661
+ try {
662
+ await this.git(['checkout', '--detach', checkout.ref], checkout.pfad);
663
+ }
664
+ catch (fehler) {
665
+ // Ein required-Filter, der nicht entschlüsseln kann, bricht das Nachziehen ab (E115). Das
666
+ // trifft nur diesen Checkout, die übrigen laufen weiter.
667
+ return { ergebnis: 'fehlgeschlagen', fehler: sopsFehlertext(fehler) };
668
+ }
464
669
  return { ergebnis: 'aktualisiert', von, nach };
465
670
  }
466
671
  /**
@@ -0,0 +1,107 @@
1
+ // SOPS-Filter für Checkouts (E115): Probe-Entschlüsselung, Filterbefehle und das Lesen von
2
+ // `git check-attr`. Die Filter selbst setzt GitQuellen (git.ts) im Basis-Clone; hier liegt nur, was
3
+ // ohne git auskommt. Vorlage der Filterbefehle: sops-setup.sh im Skill sops-secrets-manager.
4
+ import { spawn } from 'node:child_process';
5
+ import { access, constants } from 'node:fs/promises';
6
+ import path from 'node:path';
7
+ /** Die Filternamen, die hmgr kennt und selbst einrichtet (E115). */
8
+ export const SOPS_FILTER = ['sops_dotenv', 'sops_yaml', 'sops_generic'];
9
+ /** Woran hmgr eine noch verschlüsselte SOPS-Datei im Arbeitsbaum erkennt. */
10
+ export const SOPS_MARKE = 'ENC[';
11
+ /** Die Einstellung im Betrieb: sops vom PATH, sops-clean.sh unter /usr/local/bin oder ~/bin. */
12
+ export function standardSops(home) {
13
+ return {
14
+ programm: 'sops',
15
+ wrapperKandidaten: ['/usr/local/bin/sops-clean.sh', path.join(home, 'bin', 'sops-clean.sh')],
16
+ };
17
+ }
18
+ /**
19
+ * Ein Wort für die Shell, die git für Filterbefehle startet: unverändert, wenn nichts darin die
20
+ * Shell stört, sonst in einfachen Anführungszeichen. So bricht ein Home-Pfad mit Leerzeichen nicht.
21
+ */
22
+ export function shellWort(wert) {
23
+ if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(wert))
24
+ return wert;
25
+ return `'${wert.replace(/'/g, `'\\''`)}'`;
26
+ }
27
+ /** clean- und smudge-Befehl wie sops-setup.sh; `%f` setzt git selbst (schon für die Shell gequotet). */
28
+ export function filterBefehle(programm, wrapper) {
29
+ const sops = shellWort(programm);
30
+ const verschluesseln = `${sops} --encrypt --filename-override %f /dev/stdin`;
31
+ return {
32
+ clean: wrapper === undefined ? verschluesseln : `${shellWort(wrapper)} %f ${verschluesseln}`,
33
+ smudge: `${sops} --decrypt --filename-override %f /dev/stdin`,
34
+ };
35
+ }
36
+ /** Der erste ausführbare Kandidat für sops-clean.sh, sonst undefined. */
37
+ export async function wrapperFinden(kandidaten) {
38
+ for (const kandidat of kandidaten) {
39
+ const ausfuehrbar = await access(kandidat, constants.X_OK).then(() => true, () => false);
40
+ if (ausfuehrbar)
41
+ return kandidat;
42
+ }
43
+ return undefined;
44
+ }
45
+ /**
46
+ * Entschlüsselt `inhalt` probeweise so, wie es der smudge-Filter täte. Der Klartext wird verworfen,
47
+ * es zählt nur der Exit-Code. Fehlt das Programm, heißt das „sops fehlt", kein Absturz.
48
+ */
49
+ export async function probe(programm, datei, inhalt) {
50
+ return await new Promise((erfuellen) => {
51
+ let fertig = false;
52
+ const ende = (ergebnis) => {
53
+ if (fertig)
54
+ return;
55
+ fertig = true;
56
+ erfuellen(ergebnis);
57
+ };
58
+ const kind = spawn(programm, ['--decrypt', '--filename-override', datei, '/dev/stdin'], {
59
+ stdio: ['pipe', 'ignore', 'ignore'],
60
+ timeout: 60_000,
61
+ killSignal: 'SIGKILL',
62
+ });
63
+ kind.on('error', (fehler) => ende({ ok: false, grund: fehler.code === 'ENOENT' ? 'sops-fehlt' : 'entschluesselung-scheitert' }));
64
+ kind.on('close', (code) => ende(code === 0 ? { ok: true } : { ok: false, grund: 'entschluesselung-scheitert' }));
65
+ // Endet sops vor dem Lesen, schreibt Node in eine geschlossene Pipe: das ist kein eigener Fehler.
66
+ kind.stdin.on('error', () => { });
67
+ kind.stdin.end(inhalt);
68
+ });
69
+ }
70
+ /**
71
+ * Fehlertext für einen gescheiterten git-Aufruf beim Nachziehen (E115): Scheitert er an einem
72
+ * SOPS-Filter, steht vorn ein verständlicher Satz, die Meldung von git folgt dahinter.
73
+ */
74
+ export function sopsFehlertext(fehler) {
75
+ const meldung = fehler instanceof Error ? fehler.message : String(fehler);
76
+ // git meldet je nach Sprache anders; sicher drin stehen der Filtername oder der sops-Befehl.
77
+ if (!/sops_(?:dotenv|yaml|generic)|--decrypt --filename-override/.test(meldung))
78
+ return meldung;
79
+ return `Eine mit SOPS verschlüsselte Datei ließ sich nicht entschlüsseln (sops fehlt oder der Schlüssel passt nicht), dieser Checkout wurde nicht nachgezogen. git meldet: ${meldung}`;
80
+ }
81
+ /** Ausgabe von `git check-attr -z … filter`: je Datei drei NUL-getrennte Felder Pfad, Attribut, Wert. */
82
+ export function checkAttrLesen(ausgabe) {
83
+ const teile = ausgabe.split('\0');
84
+ const ergebnis = new Map();
85
+ for (let i = 0; i + 2 < teile.length; i += 3)
86
+ ergebnis.set(teile[i], teile[i + 2]);
87
+ return ergebnis;
88
+ }
89
+ /**
90
+ * Welche Dateien ein Sparse-Checkout mit diesen Mustern schreibt (E11): `/*` die Dateien an der
91
+ * Wurzel, `/**` alles, `/<pfad>/` alles darunter. Ausschlüsse (`!…`) nehmen nur Verzeichnisse an
92
+ * der Wurzel heraus, die über `/*` ohnehin nicht als Datei zählen.
93
+ */
94
+ export function musterAuswahl(muster) {
95
+ let wurzelDateien = false;
96
+ let alles = false;
97
+ const praefixe = [];
98
+ for (const m of muster) {
99
+ if (m === '/*')
100
+ wurzelDateien = true;
101
+ else if (m === '/**')
102
+ alles = true;
103
+ else if (!m.startsWith('!') && m.startsWith('/') && m.endsWith('/') && m.length > 2)
104
+ praefixe.push(m.slice(1));
105
+ }
106
+ return (datei) => alles || (wurzelDateien && !datei.includes('/')) || praefixe.some((praefix) => datei.startsWith(praefix));
107
+ }
@@ -18,7 +18,8 @@ function deuten(roh) {
18
18
  aktiveHarnesses: (daten.aktiveHarnesses ?? ['claude-code']),
19
19
  // Fehlt das Feld (state.json vor E43), gilt: nichts ignoriert.
20
20
  ignorierteEintraege: (daten.ignorierteEintraege ?? []),
21
- // Fehlt das Feld (state.json vor E69), gilt: kein Bundle installiert.
21
+ // Fehlt das Feld (state.json vor E69), gilt: kein Bundle installiert. Ein Eintrag ohne
22
+ // `herausgenommen` (vor E114) hat keine herausgenommenen Mitglieder, das Feld bleibt dann weg.
22
23
  bundleInstallationen: (daten.bundleInstallationen ?? []),
23
24
  // Fehlt das Feld (state.json vor E78), gilt: jeder Harness hat nur sein Standard-Homedir.
24
25
  harnessHeime: (daten.harnessHeime ?? []),
@@ -1,6 +1,12 @@
1
1
  {
2
2
  "fassung": 1,
3
3
  "versionen": {
4
+ "0.0.47": [
5
+ "Skills mit SOPS-verschlüsselten Dateien kommen jetzt entschlüsselt an, wenn sops, der passende Schlüssel und sops-clean.sh vorhanden sind; bereits verschlüsselt ausgecheckte Dateien repariert hmgr beim nächsten Aktualisieren. Klappt das nicht, bleibt die Datei verschlüsselt und hmgr sagt im Verlauf und in hmgr status, warum."
6
+ ],
7
+ "0.0.46": [
8
+ "Bundles lassen sich aufklappen und zeigen ihre Skills mit Beschreibung, auch schon im Dialog „Skills hinzufügen“. Entfernst du einen einzelnen Skill, bleibt das Bundle bestehen und zeigt, was fehlt; mit „Vervollständigen“ oder „Wieder verlinken“ holst du Skills zurück, und „Entfernen“ am Bundle räumt den Rest weg."
9
+ ],
4
10
  "0.0.45": [
5
11
  "MCP-Server und die Listen in den Einstellungen (Harnesses, zusätzliche Homedirs) erscheinen jetzt alphabetisch sortiert, ebenso in hmgr mcp list."
6
12
  ],
package/dist/cli/hilfe.js CHANGED
@@ -315,9 +315,13 @@ Liegt am Ziel schon derselbe Skill aus derselben Quelle als hmgr-Link, wird er a
315
315
  übernommen: aus Bestand wird ein Bundle, ohne erst Links von Hand zu entfernen. Alles andere unter
316
316
  demselben Namen ist ein Konflikt und wird genannt.
317
317
 
318
- hmgr unlink <skill> auf ein einzelnes Mitglied zerbricht das Bundle in diesem Ziel: die übrigen
319
- Links bleiben als einzelne Links liegen, der Bundle-Verweis fällt weg. Das ist kein Fehler, hmgr
320
- sagt es nur.
318
+ hmgr unlink <skill> auf ein einzelnes Mitglied nimmt es aus dem Bundle heraus: nur dieser Link
319
+ verschwindet, das Bundle bleibt im Ziel und merkt sich den Skill als herausgenommen. sync und
320
+ update legen ihn nicht wieder an, neue Mitglieder der Definition ergänzen sie weiter. Zurück kommt
321
+ er mit hmgr link <skill> desselben Skills aus derselben Quelle oder mit bundle sync --complete, das
322
+ alle fehlenden Mitglieder verlinkt. remove nimmt die noch verlinkten Mitglieder weg, auch wenn
323
+ schon welche herausgenommen sind. hmgr status und bundle list zeigen die Lage je Ziel, etwa
324
+ „4/5 … fehlt: x".
321
325
 
322
326
  Den Abgleich macht schon hmgr update für alle installierten Bundles mit, es ist derselbe Vorgang
323
327
  und dieselbe Ausgabe (E69): eine geänderte Definition kommt also mit dem gewöhnlichen
@@ -335,11 +339,14 @@ Schalter (add, remove und sync):
335
339
  kennt nur hermes, wsl:<distro> nur claude-code unter Windows (E78)
336
340
  --composer <pfad> composer.json des Arbeitsverzeichnisses, wenn mehrere in Frage kommen
337
341
  --quelle <teil> nur bei add: Quelle eingrenzen, wenn den Namen mehrere Quellen führen
342
+ --complete nur bei sync: auch herausgenommene Mitglieder verlinken (Vervollständigen),
343
+ die Liste der herausgenommenen ist danach leer. Auch --vervollstaendigen
338
344
 
339
345
  Beispiele:
340
346
  hmgr bundle list
341
347
  hmgr bundle add wordpress --ort dir:.
342
348
  hmgr bundle sync
349
+ hmgr bundle sync wordpress --ort dir:. --complete
343
350
  hmgr bundle remove wordpress --ort dir:.
344
351
 
345
352
  Häufige Fälle:
@@ -524,7 +531,9 @@ ohne den übrigen Lauf aufzuhalten. hmgr bundle sync macht dasselbe für ein ein
524
531
  Danach räumt update verwaiste Ref-Verzeichnisse auf: Checkouts, in die kein Link mehr zeigt, weil
525
532
  ein Link inzwischen auf einen anderen Ref umgelegt wurde. Vor dem Löschen fragt hmgr einmal für
526
533
  alle Kandidaten. Ohne Bestätigung, also auch ohne Terminal, bleibt alles liegen und wird nur
527
- gemeldet. Ein verwaister Checkout mit lokaler Änderung wird nie gelöscht (E15).
534
+ gemeldet. Ein verwaister Checkout mit lokaler Änderung wird nie gelöscht (E15). Danach folgen die
535
+ Prüfungen von hmgr doctor: fehlende Arbeitsverzeichnisse und mit SOPS verschlüsselte Dateien, die
536
+ noch verschlüsselt im Checkout liegen (siehe hmgr help doctor).
528
537
 
529
538
  So wird eine Änderung an einem Skill überall wirksam: in der Quelle committen und pushen, dann
530
539
  hmgr update.
@@ -544,7 +553,8 @@ Eingeschränkter Lauf (E89): hmgr update <skill> zieht nur die Quelle nach, in d
544
553
  von URL, Pfad oder Anzeigenamen der Quelle, Groß- und Kleinschreibung egal; passt keine oder
545
554
  mehr als eine, bricht update mit der Liste ab. Beides zusammen geht nicht. Der Lauf umfasst die
546
555
  Checkouts dieser Quelle und die Bundles, deren Definition in ihr liegt. Es entfallen: Plugins,
547
- Aufräumen verwaister Checkouts, der Check auf fehlende Arbeitsverzeichnisse (doctor) sowie alle
556
+ Aufräumen verwaister Checkouts, die doctor-Prüfungen (fehlende Arbeitsverzeichnisse,
557
+ verschlüsselte SOPS-Dateien) sowie alle
548
558
  übrigen Quellen und Bundles. Der Verlaufseintrag entsteht wie im vollen Lauf.
549
559
 
550
560
  Schalter:
@@ -580,7 +590,10 @@ ausgecheckte Skills (E70). Quellen ohne Skill-Änderung sammelt eine Zeile am En
580
590
  Ereignisse, je eine Zeile: ein nachgezogenes Bundle mit gelegten und entfernten Links, ein
581
591
  aufgeräumter (gelöschter) Checkout, ein Fehler zu einer Quelle oder einem Bundle, dazu
582
592
  hmgr-Ereignisse: ein Versionssprung von hmgr selbst (GUI oder CLI, erkannt beim Start bzw. bei
583
- jedem CLI-Aufruf) oder ein gefundenes Update, das noch nicht installiert ist. Einträge aus der
593
+ jedem CLI-Aufruf) oder ein gefundenes Update, das noch nicht installiert ist. Dazu Dateien, die
594
+ eine Quelle mit SOPS verschlüsselt: eine, die verschlüsselt im Checkout blieb, mit Grund (einmal,
595
+ auch direkt nach einem hmgr link), und eine, die hmgr nachträglich entschlüsselt hat (siehe
596
+ hmgr help doctor). Einträge aus der
584
597
  Zeit vor E109 zeigen nur „Geändert". Plugins stehen nicht im Verlauf (siehe hmgr help update).
585
598
 
586
599
  Unter einem „installiert"-Eintrag von hmgr stehen die Änderungen: je Version seit der zuvor
@@ -604,8 +617,21 @@ Prüft, ob jedes gemerkte Arbeitsverzeichnis (hmgr dir add) noch auf der Platte
604
617
  umbenanntes oder woanders neu geklontes Projekt hinterlässt sonst für immer einen toten Pfad im
605
618
  Zustand, den nur hmgr dir forget von Hand entfernt. Bei einem Fund fragt doctor wie hmgr update
606
619
  einmal für alle Funde, ob sie vergessen werden sollen; ohne Bestätigung bleiben sie gemeldet, aber
607
- gemerkt. Anders als update holt doctor nichts aus dem Netz und schreibt keinen Eintrag in den
608
- Verlauf — ein reiner Konsolen-Check, kein Lauf im E80-Sinn.
620
+ gemerkt. Anders als update holt doctor nichts aus dem Netz.
621
+
622
+ Dazu prüft doctor Dateien, die eine Quelle mit SOPS verschlüsselt (.gitattributes mit
623
+ filter=sops_dotenv, sops_yaml oder sops_generic) und die noch verschlüsselt im Checkout liegen,
624
+ etwa weil sie vor dieser Prüfung ausgecheckt wurden oder sops damals fehlte. Liegt
625
+ sops-clean.sh unter /usr/local/bin oder ~/bin und klappt eine probeweise Entschlüsselung mit sops,
626
+ richtet doctor die git-Filter für die Quelle ein (wie sops-setup.sh, mit sops-clean.sh) und
627
+ schreibt die Datei entschlüsselt neu. Sonst bleibt die Datei, wie sie ist, und doctor nennt den
628
+ Grund: sops-clean.sh fehlt, sops fehlt, der Schlüssel passt nicht, oder die Datei ist im Checkout
629
+ von Hand geändert. Ohne sops-clean.sh richtet hmgr nie Filter ein, weil git die entschlüsselte
630
+ Datei sonst sofort für geändert hielte und hmgr update den Checkout danach immer überspränge.
631
+ Dieselbe Regel gilt schon beim ersten Auschecken (hmgr link).
632
+ Diese Befunde stehen auch im Verlauf (hmgr log), eine verschlüsselt gebliebene Datei nur einmal.
633
+ Fehlende Arbeitsverzeichnisse schreibt doctor nicht in den Verlauf. hmgr update macht dieselbe
634
+ Prüfung im selben Lauf.
609
635
 
610
636
  Schalter: keine.
611
637
 
@@ -615,6 +641,11 @@ Beispiel:
615
641
  Häufige Fälle:
616
642
  „liegen lassen … ohne Bestätigung nicht vergessen": die Frage kam ohne Ja zurück. In einem
617
643
  Terminal erneut hmgr doctor aufrufen, oder hmgr dir forget <pfad> gezielt.
644
+ „verschlüsselt … sops-clean.sh fehlt": sops-clean.sh ausführbar nach ~/bin oder
645
+ /usr/local/bin legen, dann erneut hmgr doctor.
646
+ „verschlüsselt … sops ist auf diesem Rechner nicht installiert": sops installieren, dann erneut
647
+ hmgr doctor. „… vermutlich fehlt der passende Schlüssel": der Schlüssel dieses Rechners steht
648
+ nicht unter den Empfängern der Datei, oder sops findet ihn nicht.
618
649
 
619
650
  Siehe auch: hmgr help update, hmgr help dir`;
620
651
  const STATUS = `hmgr status
@@ -624,7 +655,8 @@ und Ref, davor die Versionseinschränkung, falls der Zeiger eine nennt. Dahinter
624
655
  abgeleitete Grund des Links: das Plugin, zu dem er gehört, oder „über Zeiger", von Hand gelegte
625
656
  Links bleiben ohne Zusatz. Fremde Einträge und Links ohne bekannte Quelle sind eigens
626
657
  gekennzeichnet, dazu kommen die Plugins des Ziels. Am Ende stehen die Harnesses, die auf diesem
627
- Rechner nicht liegen.
658
+ Rechner nicht liegen, und, falls es welche gibt, mit SOPS verschlüsselte Dateien, die noch
659
+ verschlüsselt im Checkout liegen. status repariert nichts, das macht hmgr doctor.
628
660
 
629
661
  Schalter: keine.
630
662
 
@@ -637,10 +669,12 @@ Häufige Fälle:
637
669
  Zustand steht. Mit hmgr source add <url> die Quelle wieder eintragen.
638
670
  „Nicht vorhanden": der Harness hat sein Verzeichnis auf diesem Rechner nicht angelegt, hmgr
639
671
  überspringt seine Ziele.
672
+ „Noch verschlüsselt im Checkout": hmgr doctor versucht, die Datei zu entschlüsseln, und nennt
673
+ den Grund, wenn es nicht klappt.
640
674
  Ein Ziel fehlt: Ziele inaktiver Harness-Typen zeigt status nicht, auch wenn dort noch Links
641
675
  liegen. hmgr harness list zeigt den Stand, hmgr harness enable <name> holt die Ziele zurück.
642
676
 
643
- Siehe auch: hmgr help adopt, hmgr help sync`;
677
+ Siehe auch: hmgr help adopt, hmgr help sync, hmgr help doctor`;
644
678
  const HARNESS = `hmgr harness list
645
679
  hmgr harness enable <name>
646
680
  hmgr harness disable <name>