hmgr 0.0.46 → 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
+ }
@@ -1,6 +1,9 @@
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
+ ],
4
7
  "0.0.46": [
5
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."
6
9
  ],
package/dist/cli/hilfe.js CHANGED
@@ -531,7 +531,9 @@ ohne den übrigen Lauf aufzuhalten. hmgr bundle sync macht dasselbe für ein ein
531
531
  Danach räumt update verwaiste Ref-Verzeichnisse auf: Checkouts, in die kein Link mehr zeigt, weil
532
532
  ein Link inzwischen auf einen anderen Ref umgelegt wurde. Vor dem Löschen fragt hmgr einmal für
533
533
  alle Kandidaten. Ohne Bestätigung, also auch ohne Terminal, bleibt alles liegen und wird nur
534
- 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).
535
537
 
536
538
  So wird eine Änderung an einem Skill überall wirksam: in der Quelle committen und pushen, dann
537
539
  hmgr update.
@@ -551,7 +553,8 @@ Eingeschränkter Lauf (E89): hmgr update <skill> zieht nur die Quelle nach, in d
551
553
  von URL, Pfad oder Anzeigenamen der Quelle, Groß- und Kleinschreibung egal; passt keine oder
552
554
  mehr als eine, bricht update mit der Liste ab. Beides zusammen geht nicht. Der Lauf umfasst die
553
555
  Checkouts dieser Quelle und die Bundles, deren Definition in ihr liegt. Es entfallen: Plugins,
554
- 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
555
558
  übrigen Quellen und Bundles. Der Verlaufseintrag entsteht wie im vollen Lauf.
556
559
 
557
560
  Schalter:
@@ -587,7 +590,10 @@ ausgecheckte Skills (E70). Quellen ohne Skill-Änderung sammelt eine Zeile am En
587
590
  Ereignisse, je eine Zeile: ein nachgezogenes Bundle mit gelegten und entfernten Links, ein
588
591
  aufgeräumter (gelöschter) Checkout, ein Fehler zu einer Quelle oder einem Bundle, dazu
589
592
  hmgr-Ereignisse: ein Versionssprung von hmgr selbst (GUI oder CLI, erkannt beim Start bzw. bei
590
- 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
591
597
  Zeit vor E109 zeigen nur „Geändert". Plugins stehen nicht im Verlauf (siehe hmgr help update).
592
598
 
593
599
  Unter einem „installiert"-Eintrag von hmgr stehen die Änderungen: je Version seit der zuvor
@@ -611,8 +617,21 @@ Prüft, ob jedes gemerkte Arbeitsverzeichnis (hmgr dir add) noch auf der Platte
611
617
  umbenanntes oder woanders neu geklontes Projekt hinterlässt sonst für immer einen toten Pfad im
612
618
  Zustand, den nur hmgr dir forget von Hand entfernt. Bei einem Fund fragt doctor wie hmgr update
613
619
  einmal für alle Funde, ob sie vergessen werden sollen; ohne Bestätigung bleiben sie gemeldet, aber
614
- gemerkt. Anders als update holt doctor nichts aus dem Netz und schreibt keinen Eintrag in den
615
- 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.
616
635
 
617
636
  Schalter: keine.
618
637
 
@@ -622,6 +641,11 @@ Beispiel:
622
641
  Häufige Fälle:
623
642
  „liegen lassen … ohne Bestätigung nicht vergessen": die Frage kam ohne Ja zurück. In einem
624
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.
625
649
 
626
650
  Siehe auch: hmgr help update, hmgr help dir`;
627
651
  const STATUS = `hmgr status
@@ -631,7 +655,8 @@ und Ref, davor die Versionseinschränkung, falls der Zeiger eine nennt. Dahinter
631
655
  abgeleitete Grund des Links: das Plugin, zu dem er gehört, oder „über Zeiger", von Hand gelegte
632
656
  Links bleiben ohne Zusatz. Fremde Einträge und Links ohne bekannte Quelle sind eigens
633
657
  gekennzeichnet, dazu kommen die Plugins des Ziels. Am Ende stehen die Harnesses, die auf diesem
634
- 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.
635
660
 
636
661
  Schalter: keine.
637
662
 
@@ -644,10 +669,12 @@ Häufige Fälle:
644
669
  Zustand steht. Mit hmgr source add <url> die Quelle wieder eintragen.
645
670
  „Nicht vorhanden": der Harness hat sein Verzeichnis auf diesem Rechner nicht angelegt, hmgr
646
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.
647
674
  Ein Ziel fehlt: Ziele inaktiver Harness-Typen zeigt status nicht, auch wenn dort noch Links
648
675
  liegen. hmgr harness list zeigt den Stand, hmgr harness enable <name> holt die Ziele zurück.
649
676
 
650
- Siehe auch: hmgr help adopt, hmgr help sync`;
677
+ Siehe auch: hmgr help adopt, hmgr help sync, hmgr help doctor`;
651
678
  const HARNESS = `hmgr harness list
652
679
  hmgr harness enable <name>
653
680
  hmgr harness disable <name>
package/dist/cli/main.js CHANGED
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import { realpathSync } from 'node:fs';
5
5
  import { homedir } from 'node:os';
6
6
  import { pathToFileURL } from 'node:url';
7
- import { checkoutGruppen, quellenAnzeigeName, sammelZeile, verlaufZeitpunkt } from "./verlauf-anzeige.js";
7
+ import { checkoutGruppen, quellenAnzeigeName, sammelZeile, sopsGrundText, sopsZeile, verlaufZeitpunkt, } from "./verlauf-anzeige.js";
8
8
  import { Anwendungsfaelle, pluginWeltText, } from "../kern/anwendungsfaelle.js";
9
9
  import { AbhaengigkeitFehlt, BundleDateiFehler, BundleMehrdeutig, BundleMitgliederFehlen, BundleNamenskonflikte, BundleNichtInstalliert, BundleUnbekannt, EinschraenkungOhneZeiger, HarnessInaktiv, HarnessNichtVorhanden, HarnessTypUnbekannt, HeimAdresseUngueltig, HeimBereitsVorhanden, HeimFaehigkeitFehlt, HeimNurUnterWindows, ClaudeCliNichtErreichbar, HeimUnbekannt, KeinPassenderRef, LinkAnlegenFehlgeschlagen, McpAbweichenderEintrag, McpEbeneNichtUnterstuetzt, McpFaehigkeitFehlt, McpKatalogFehler, McpNichtInGeteilterDatei, McpNurGeteilterFundort, McpOrtUnterGitWurzel, McpUebernahmeNichtMoeglich, McpUebernahmeUnvollstaendig, McpUnbekannt, NameBelegt, OrtNichtUnterstuetzt, PluginMehrdeutig, PluginUnbekannt, QuelleAufraeumenLokaleAenderungen, QuelleNameBelegt, QuelleUnbekannt, SkillMehrdeutig, SkillOrdnerBereitsVerwaltet, SkillOrdnerFaehigkeitFehlt, SkillOrdnerNichtVerwaltet, SkillOrdnerUngueltig, SkillUnbekannt, ZeigerMehrdeutig, WslBrueckeFehlgeschlagen, ZeigerSchreibenGesperrt, heimAdresseAlsText, heimAdresseZerlegen, wslDistroVonOrt, wslUncZerlegen, pluginSchluessel, quelleName, quelleSchluessel, } from "../kern/modell.js";
10
10
  import { Agents } from "../adapter/agents.js";
@@ -19,6 +19,7 @@ import { DateiMcpKatalog } from "../adapter/mcp-katalog.js";
19
19
  import { cliUpdateHinweis } from "../adapter/npm-registry.js";
20
20
  import { paketVersion } from "../adapter/paket-version.js";
21
21
  import { quelleAlsText, quelleAusText } from "../adapter/quelle-text.js";
22
+ import { standardSops } from "../adapter/sops.js";
22
23
  import { TerminalRueckfrage } from "../adapter/terminal.js";
23
24
  import { ProzessUmgebung } from "../adapter/umgebung.js";
24
25
  import { signalSchreiben } from "../adapter/gui-signal.js";
@@ -92,7 +93,7 @@ export function bauen(umgebung, optionen = {}) {
92
93
  });
93
94
  const ports = {
94
95
  harnesses: [claude, new Hermes(heime, zugriffFuerOrt), new Agents(heime, zugriffFuerOrt)],
95
- quellen: new GitQuellen(wurzel),
96
+ quellen: new GitQuellen(wurzel, { sops: optionen.sops ?? standardSops(umgebung.home) }),
96
97
  ziele: new DateisystemZiele(wurzel, zustand, umgebung.xdgStateHome, (ziel) => zugriffFuerOrt(ziel.ort)),
97
98
  zeiger: new ComposerZeiger(),
98
99
  zustand,
@@ -855,12 +856,17 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
855
856
  else
856
857
  console.log(` liegen lassen Arbeitsverzeichnis ${pfad} (nicht mehr auf der Platte, ohne Bestätigung nicht vergessen)`);
857
858
  }
859
+ // SOPS-Dateien, die verschlüsselt im Checkout lagen (E115).
860
+ const sopsQuellen = doctor.sops.length === 0 ? [] : await anwendungsfaelle.quellen();
861
+ for (const zeile of sopsBerichtZeilen(doctor.sops, sopsQuellen))
862
+ console.log(zeile);
858
863
  // Die Sammelzeile steht ganz am Ende des Berichts (E109); „Außerdem" nur, wenn sonst etwas im Lauf stand.
859
864
  const updateSammel = sammelZeile(updateOhne, !updateAndere &&
860
865
  pluginFehler.length === 0 &&
861
866
  bundleBericht(bundles).length === 0 &&
862
867
  aufraeumen.verwaiste.length === 0 &&
863
- doctor.fehlendeArbeitsverzeichnisse.length === 0);
868
+ doctor.fehlendeArbeitsverzeichnisse.length === 0 &&
869
+ doctor.sops.length === 0);
864
870
  if (updateSammel !== undefined)
865
871
  console.log(updateSammel);
866
872
  // Zuletzt hmgr selbst (E77): wer schon aktualisiert, will auch wissen, dass sein hmgr alt ist.
@@ -907,6 +913,15 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
907
913
  const fehlend = await anwendungsfaelle.nichtVorhandeneHarnesses();
908
914
  if (fehlend.length > 0)
909
915
  console.log(`Nicht vorhanden: ${fehlend.join(', ')}`);
916
+ // SOPS-Dateien, die noch verschlüsselt im Checkout liegen (E115); ohne Fund keine Zeile.
917
+ const verschluesselt = await anwendungsfaelle.sopsVerschluesselteDateien();
918
+ if (verschluesselt.length > 0) {
919
+ const quellen = await anwendungsfaelle.quellen();
920
+ console.log('Noch verschlüsselt im Checkout:');
921
+ for (const { checkout, datei } of verschluesselt)
922
+ console.log(` ${datei} aus ${quellenAnzeigeName({ quelle: quelleAlsText(checkout.quelle), ref: checkout.ref }, quellen)} (${path.join(checkout.pfad, datei)})`);
923
+ console.log('hmgr doctor versucht, sie zu entschlüsseln, und nennt den Grund, wenn es nicht klappt.');
924
+ }
910
925
  return 0;
911
926
  }
912
927
  if (kommando === 'log') {
@@ -936,7 +951,7 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
936
951
  if (ereignis.art === 'checkout')
937
952
  continue;
938
953
  andere = true;
939
- console.log(` ${verlaufEreignisZeile(ereignis)}`);
954
+ console.log(` ${verlaufEreignisZeile(ereignis, quellenListe)}`);
940
955
  // Unter einem Versionssprung die Commits der neuen Version(en), eingerückt (E108).
941
956
  if (ereignis.art === 'hmgr-installiert') {
942
957
  const aenderungen = await anwendungsfaelle.hmgrAenderungen(ereignis.vorher, ereignis.version);
@@ -952,7 +967,7 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
952
967
  }
953
968
  if (kommando === 'doctor') {
954
969
  restPruefen(schalterLesen(argv.slice(1), 'doctor').rest, 0, 'doctor');
955
- const { fehlendeArbeitsverzeichnisse, vergessen } = await anwendungsfaelle.doctor();
970
+ const { fehlendeArbeitsverzeichnisse, vergessen, sops } = await anwendungsfaelle.doctor();
956
971
  if (fehlendeArbeitsverzeichnisse.length === 0)
957
972
  console.log('Keine fehlenden Arbeitsverzeichnisse.');
958
973
  for (const pfad of fehlendeArbeitsverzeichnisse) {
@@ -961,6 +976,10 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
961
976
  else
962
977
  console.log(` liegen lassen ${pfad} (ohne Bestätigung nicht vergessen)`);
963
978
  }
979
+ // SOPS-Dateien (E115): nur bei einem Fund eine Zeile, wer kein SOPS nutzt, sieht nichts davon.
980
+ const sopsQuellen = sops.length === 0 ? [] : await anwendungsfaelle.quellen();
981
+ for (const zeile of sopsBerichtZeilen(sops, sopsQuellen))
982
+ console.log(zeile);
964
983
  return 0;
965
984
  }
966
985
  if (kommando === 'harness') {
@@ -1185,8 +1204,21 @@ function bundleBericht(eintraege) {
1185
1204
  function aenderungsZeilen(aenderungen) {
1186
1205
  return aenderungen.flatMap((a) => [` ${a.version}`, ...a.commits.map((c) => ` ${c}`)]);
1187
1206
  }
1207
+ /** Bericht zu SOPS-Dateien aus doctor und update (E115), Spalten wie die übrigen Berichtszeilen. */
1208
+ function sopsBerichtZeilen(befunde, quellen) {
1209
+ return befunde.map((befund) => {
1210
+ const ort = `${befund.datei} aus ${quellenAnzeigeName({ quelle: quelleAlsText(befund.checkout.quelle), ref: befund.checkout.ref }, quellen)}`;
1211
+ if (befund.ergebnis === 'repariert')
1212
+ return ` entschlüsselt ${ort}`;
1213
+ if (befund.ergebnis === 'verschluesselt')
1214
+ return ` verschlüsselt ${ort}: ${sopsGrundText(befund.grund)}`;
1215
+ return ` fehlgeschlagen ${ort}: ${befund.fehler}`;
1216
+ });
1217
+ }
1188
1218
  /** Eine Ereigniszeile im Verlauf (E80), eingerückt unter der Kopfzeile ihres Laufs. Checkouts stellt `checkoutGruppen` dar (E109). */
1189
- function verlaufEreignisZeile(ereignis) {
1219
+ function verlaufEreignisZeile(ereignis, quellen) {
1220
+ if (ereignis.art === 'sops-verschluesselt' || ereignis.art === 'sops-repariert')
1221
+ return sopsZeile(ereignis, quellen);
1190
1222
  if (ereignis.art === 'bundle') {
1191
1223
  const teile = [
1192
1224
  ereignis.gelegt.length > 0 ? `gelegt ${ereignis.gelegt.join(', ')}` : undefined,
@@ -59,3 +59,20 @@ export function verlaufZeitpunkt(iso, jetzt = new Date()) {
59
59
  return `Gestern, ${uhr}`;
60
60
  return `${zwei(datum.getDate())}.${zwei(datum.getMonth() + 1)}.${datum.getFullYear()}, ${uhr}`;
61
61
  }
62
+ /** Warum eine SOPS-Datei verschlüsselt blieb (E115), in Alltagssprache. GUI: renderer.js, sopsGrundText. */
63
+ export function sopsGrundText(grund) {
64
+ if (grund === 'sops-fehlt')
65
+ return 'sops ist auf diesem Rechner nicht installiert';
66
+ if (grund === 'lokale-aenderung')
67
+ return 'die Datei ist im Checkout von Hand geändert';
68
+ if (grund === 'wrapper-fehlt')
69
+ return 'sops-clean.sh fehlt (weder in /usr/local/bin noch in ~/bin)';
70
+ return 'die Entschlüsselung klappt nicht, vermutlich fehlt der passende Schlüssel';
71
+ }
72
+ /** Verlaufszeile zu einer SOPS-Datei (E115): verschlüsselt geblieben oder nachträglich entschlüsselt. */
73
+ export function sopsZeile(ereignis, quellen) {
74
+ const ort = `${ereignis.datei} aus ${quellenAnzeigeName(ereignis, quellen)}`;
75
+ return ereignis.grund === undefined
76
+ ? `Verschlüsselte Datei ${ort} entschlüsselt`
77
+ : `Datei ${ort} bleibt verschlüsselt: ${sopsGrundText(ereignis.grund)}`;
78
+ }
@@ -3202,9 +3202,25 @@
3202
3202
  } else if (ereignis.art === 'cli-verfuegbar') {
3203
3203
  li.appendChild(e('span', 'marke-ok', 'verfügbar'));
3204
3204
  li.appendChild(e('span', 'pfad', 'CLI ' + ereignis.version));
3205
+ } else if (ereignis.art === 'sops-verschluesselt') {
3206
+ // E115: Wortlaut wie sopsZeile/sopsGrundText in src/cli/verlauf-anzeige.ts.
3207
+ li.appendChild(e('span', 'marke-aus', 'verschlüsselt'));
3208
+ li.appendChild(e('span', 'pfad', ereignis.datei + ' aus ' + verlaufQuellenName(ereignis.quelle, ereignis.ref)
3209
+ + ' bleibt verschlüsselt: ' + sopsGrundText(ereignis.grund)));
3210
+ } else if (ereignis.art === 'sops-repariert') {
3211
+ li.appendChild(e('span', 'marke-ok', 'entschlüsselt'));
3212
+ li.appendChild(e('span', 'pfad', ereignis.datei + ' aus ' + verlaufQuellenName(ereignis.quelle, ereignis.ref)));
3205
3213
  }
3206
3214
  }
3207
3215
 
3216
+ /** Warum eine SOPS-Datei verschlüsselt blieb (E115), wie sopsGrundText in src/cli/verlauf-anzeige.ts. */
3217
+ function sopsGrundText(grund) {
3218
+ if (grund === 'sops-fehlt') return 'sops ist auf diesem Rechner nicht installiert';
3219
+ if (grund === 'lokale-aenderung') return 'die Datei ist im Checkout von Hand geändert';
3220
+ if (grund === 'wrapper-fehlt') return 'sops-clean.sh fehlt (weder in /usr/local/bin noch in ~/bin)';
3221
+ return 'die Entschlüsselung klappt nicht, vermutlich fehlt der passende Schlüssel';
3222
+ }
3223
+
3208
3224
  /**
3209
3225
  * Anzeigename einer Quelle im Verlauf (E109). Baut quelleVerlaufName (src/kern/modell.ts) nach,
3210
3226
  * der Renderer kann die Kern-Datei nicht importieren: Quelle aus QUELLEN über die Identität
@@ -1387,19 +1387,101 @@ export class Anwendungsfaelle {
1387
1387
  * dieselbe Weiche wie bei `verwaisteAufraeumen`.
1388
1388
  */
1389
1389
  async doctor(args) {
1390
- const rueckfragen = args?.rueckfragen ?? true;
1390
+ const ergebnis = await this.doctorPruefen(args?.rueckfragen ?? true);
1391
+ // Nur die SOPS-Befunde kommen in den Verlauf (E115); fehlende Arbeitsverzeichnisse meldet der
1392
+ // alleinstehende doctor wie bisher nur auf der Konsole.
1393
+ await this.sopsVermerken(args?.ausloeser ?? 'cli', ergebnis.sops, true);
1394
+ return ergebnis;
1395
+ }
1396
+ /**
1397
+ * doctor ohne Verlauf, damit der Aktualisieren-Lauf die Befunde in seinen eigenen Eintrag nimmt.
1398
+ * Dazu die SOPS-Prüfung (E115): Checkouts geführter Quellen, deren SOPS-Datei noch verschlüsselt
1399
+ * im Arbeitsbaum liegt, schreibt der Quellen-Port neu, wo die Probe klappt. Sie ist keine
1400
+ * Rückfrage wert: die Datei entspricht dem Commit, nur entschlüsselt.
1401
+ */
1402
+ async doctorPruefen(rueckfragen) {
1391
1403
  const zustand = await this.p.zustand.lesen();
1392
1404
  const fehlend = [];
1393
1405
  for (const pfad of zustand.arbeitsverzeichnisse)
1394
1406
  if (!(await this.p.zeiger.verzeichnisExistiert(pfad)))
1395
1407
  fehlend.push(pfad);
1396
- if (fehlend.length === 0 || !rueckfragen)
1397
- return { fehlendeArbeitsverzeichnisse: fehlend, vergessen: [] };
1398
- if (!(await this.p.rueckfrage.bestaetigen(doctorFrage(fehlend))))
1399
- return { fehlendeArbeitsverzeichnisse: fehlend, vergessen: [] };
1400
- for (const pfad of fehlend)
1401
- await this.arbeitsverzeichnisVergessen(pfad);
1402
- return { fehlendeArbeitsverzeichnisse: fehlend, vergessen: [...fehlend] };
1408
+ let vergessen = [];
1409
+ if (fehlend.length > 0 && rueckfragen && (await this.p.rueckfrage.bestaetigen(doctorFrage(fehlend)))) {
1410
+ for (const pfad of fehlend)
1411
+ await this.arbeitsverzeichnisVergessen(pfad);
1412
+ vergessen = [...fehlend];
1413
+ }
1414
+ const sops = [];
1415
+ for (const checkout of await this.gefuehrteCheckouts(zustand.quellen)) {
1416
+ const reparaturen = await this.p.quellen.sopsReparieren(checkout).catch(() => []);
1417
+ for (const reparatur of reparaturen)
1418
+ sops.push({ checkout, ...reparatur });
1419
+ }
1420
+ return { fehlendeArbeitsverzeichnisse: fehlend, vergessen, sops };
1421
+ }
1422
+ /** Checkouts, deren Quelle der Zustand führt (E54), mit dem Anzeigenamen der geführten Quelle. */
1423
+ async gefuehrteCheckouts(quellen) {
1424
+ const bekannt = new Map(quellen.map((q) => [quelleSchluessel(q), q]));
1425
+ const ergebnis = [];
1426
+ for (const checkout of await this.p.quellen.checkouts()) {
1427
+ const gefuehrt = bekannt.get(quelleSchluessel(checkout.quelle));
1428
+ if (gefuehrt !== undefined)
1429
+ ergebnis.push({ ...checkout, quelle: quelleMitNamen(checkout.quelle, gefuehrt.name) });
1430
+ }
1431
+ return ergebnis;
1432
+ }
1433
+ /**
1434
+ * SOPS-Dateien, die im Arbeitsbaum eines Checkouts noch verschlüsselt liegen (E115), für
1435
+ * `hmgr status`. Liest nur, ruft kein sops auf und repariert nichts.
1436
+ */
1437
+ async sopsVerschluesselteDateien() {
1438
+ const ergebnis = [];
1439
+ for (const checkout of await this.gefuehrteCheckouts((await this.p.zustand.lesen()).quellen)) {
1440
+ const dateien = await this.p.quellen.sopsDateien(checkout).catch(() => []);
1441
+ for (const d of dateien)
1442
+ if (d.verschluesselt)
1443
+ ergebnis.push({ checkout, datei: d.datei });
1444
+ }
1445
+ return ergebnis;
1446
+ }
1447
+ /**
1448
+ * SOPS-Befunde als Verlaufsereignisse (E115). „Verschlüsselt" und ein gescheitertes Neuschreiben
1449
+ * kommen einmal je Datei und Grund (`gemerkt.sops`), „repariert" jedes Mal, wenn es geschieht.
1450
+ * `vollstaendig`: die Befunde decken alle Checkouts ab, dann ersetzen sie die gemerkte Liste;
1451
+ * sonst (ein einzelner Checkout beim Verlinken) kommen sie dazu.
1452
+ */
1453
+ async sopsEreignisse(befunde, vollstaendig) {
1454
+ const bisher = (await this.p.verlauf.lesen()).gemerkt.sops ?? [];
1455
+ const aktuell = [];
1456
+ const ereignisse = [];
1457
+ for (const befund of befunde) {
1458
+ const quelle = quelleAlsText(befund.checkout.quelle);
1459
+ const { ref } = befund.checkout;
1460
+ if (befund.ergebnis === 'repariert') {
1461
+ ereignisse.push({ art: 'sops-repariert', quelle, ref, datei: befund.datei });
1462
+ continue;
1463
+ }
1464
+ const art = befund.ergebnis === 'verschluesselt' ? befund.grund : 'fehlgeschlagen';
1465
+ const schluessel = `${quelle} @ ${ref}: ${befund.datei} | ${art}`;
1466
+ if (aktuell.includes(schluessel))
1467
+ continue;
1468
+ aktuell.push(schluessel);
1469
+ if (bisher.includes(schluessel))
1470
+ continue;
1471
+ if (befund.ergebnis === 'verschluesselt')
1472
+ ereignisse.push({ art: 'sops-verschluesselt', quelle, ref, datei: befund.datei, grund: befund.grund });
1473
+ else
1474
+ ereignisse.push({
1475
+ art: 'fehler',
1476
+ betrifft: `${quelle} @ ${ref}: ${befund.datei}`,
1477
+ fehler: `verschlüsselte Datei nicht neu geschrieben: ${befund.fehler}`,
1478
+ });
1479
+ }
1480
+ return { ereignisse, gemerkt: vollstaendig ? aktuell : [...new Set([...bisher, ...aktuell])] };
1481
+ }
1482
+ async sopsVermerken(ausloeser, befunde, vollstaendig) {
1483
+ const { ereignisse, gemerkt } = await this.sopsEreignisse(befunde, vollstaendig);
1484
+ await this.verlaufVermerken(ausloeser, ereignisse, gemerkt);
1403
1485
  }
1404
1486
  /**
1405
1487
  * Mehrere composer.json unter einem Verzeichnis (E26): hmgr rät nicht, es fragt. Bleibt die Frage
@@ -1480,7 +1562,11 @@ export class Anwendungsfaelle {
1480
1562
  throw new OrtNichtUnterstuetzt(harness.name, ziel.ort.art);
1481
1563
  const verzeichnis = harness.zielVerzeichnis(ziel.ort);
1482
1564
  // Die Prüfung auf einen fremden Eintrag macht der Ziele-Port unmittelbar vor dem Anlegen (E12).
1483
- const checkout = await this.p.quellen.checkout(fund.quelle, fund.ref, [fund.skill]);
1565
+ // Nur Quelle, Ref und Pfad gehen in den Link, die SOPS-Befunde nicht.
1566
+ const { sopsVerschluesselt, ...checkout } = await this.p.quellen.checkout(fund.quelle, fund.ref, [fund.skill]);
1567
+ // Verschlüsselt angekommene SOPS-Dateien (E115) gleich melden, nicht erst beim nächsten Lauf.
1568
+ if (sopsVerschluesselt.length > 0)
1569
+ await this.sopsVermerken('aktion', sopsVerschluesselt.map((v) => ({ checkout, datei: v.datei, ergebnis: 'verschluesselt', grund: v.grund })), false);
1484
1570
  await harness.vorbereiten(ziel.ort);
1485
1571
  await this.p.ziele.linkLegen(ziel, verzeichnis, fund.skill.name, checkout, fund.skill, ersetzen);
1486
1572
  return { ziel, name: fund.skill.name, checkout, skill: fund.skill };
@@ -2076,9 +2162,15 @@ export class Anwendungsfaelle {
2076
2162
  ? { verwaiste: [], gefragt: false }
2077
2163
  : await this.verwaisteAufraeumen(checkouts, rueckfragen);
2078
2164
  const doctorErgebnis = nurSchluessel !== undefined
2079
- ? { fehlendeArbeitsverzeichnisse: [], vergessen: [] }
2080
- : await this.doctor({ rueckfragen });
2081
- await this.verlaufVermerken(ausloeser, this.aktualisierungsEreignisse(checkouts, pluginFehler, bundles, aufraeumen, doctorErgebnis));
2165
+ ? { fehlendeArbeitsverzeichnisse: [], vergessen: [], sops: [] }
2166
+ : await this.doctorPruefen(rueckfragen);
2167
+ // Die SOPS-Befunde (E115) kommen in denselben Eintrag; ein eingeschränkter Lauf prüft sie nicht
2168
+ // und lässt die gemerkte Liste stehen.
2169
+ const sops = nurSchluessel !== undefined ? undefined : await this.sopsEreignisse(doctorErgebnis.sops, true);
2170
+ await this.verlaufVermerken(ausloeser, [
2171
+ ...this.aktualisierungsEreignisse(checkouts, pluginFehler, bundles, aufraeumen, doctorErgebnis),
2172
+ ...(sops?.ereignisse ?? []),
2173
+ ], sops?.gemerkt);
2082
2174
  return { checkouts, pluginHarnesses, pluginFehler, bundles, aufraeumen, doctor: doctorErgebnis };
2083
2175
  }
2084
2176
  /**
@@ -2129,13 +2221,23 @@ export class Anwendungsfaelle {
2129
2221
  ereignisse.push({ art: 'arbeitsverzeichnis-vergessen', pfad });
2130
2222
  return ereignisse;
2131
2223
  }
2132
- /** Neuen Eintrag vorn anfügen, nur wenn Ereignisse da sind (E80). Kappen übernimmt der Adapter. */
2133
- async verlaufVermerken(ausloeser, ereignisse) {
2134
- if (ereignisse.length === 0)
2135
- return;
2224
+ /**
2225
+ * Neuen Eintrag vorn anfügen, nur wenn Ereignisse da sind (E80). Kappen übernimmt der Adapter.
2226
+ * `sopsGemerkt` (E115) ersetzt die gemerkten SOPS-Meldungen; hat sich nur diese Liste geändert,
2227
+ * wird sie ohne neuen Eintrag geschrieben.
2228
+ */
2229
+ async verlaufVermerken(ausloeser, ereignisse, sopsGemerkt) {
2136
2230
  const daten = await this.p.verlauf.lesen();
2231
+ const sopsGeaendert = sopsGemerkt !== undefined && JSON.stringify(sopsGemerkt) !== JSON.stringify(daten.gemerkt.sops ?? []);
2232
+ if (ereignisse.length === 0 && !sopsGeaendert)
2233
+ return;
2234
+ const gemerkt = sopsGeaendert ? { ...daten.gemerkt, sops: sopsGemerkt } : daten.gemerkt;
2235
+ if (ereignisse.length === 0) {
2236
+ await this.p.verlauf.schreiben({ gemerkt, eintraege: daten.eintraege });
2237
+ return;
2238
+ }
2137
2239
  const eintrag = { zeit: new Date().toISOString(), ausloeser, ereignisse };
2138
- await this.p.verlauf.schreiben({ gemerkt: daten.gemerkt, eintraege: [eintrag, ...daten.eintraege] });
2240
+ await this.p.verlauf.schreiben({ gemerkt, eintraege: [eintrag, ...daten.eintraege] });
2139
2241
  }
2140
2242
  /** Der Verlauf, neueste zuerst (E80). */
2141
2243
  async verlauf() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hmgr",
3
- "version": "0.0.46",
3
+ "version": "0.0.47",
4
4
  "description": "Harness Manager: Skills und Plugins für AI-Harnesses verwalten",
5
5
  "license": "MIT",
6
6
  "author": "Tim Kaufmann",