hmgr 0.0.5 → 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.
@@ -0,0 +1,108 @@
1
+ // Datei-Watcher für externe Änderungen (E62): main.ts verdrahtet nur, die eigentliche Logik ist
2
+ // hier rein (kein Electron-Import), nach dem Muster von oeffnen.ts und icons.ts, damit sie ohne
3
+ // Electron testbar ist.
4
+ //
5
+ // Beobachtet wird eine feste Liste von Pfaden (hmgr-Zustand, MCP-Katalog, Harness-MCP-Konfigs,
6
+ // .env-Dateien, siehe main.ts). Zwei Eigenheiten des Dateisystems bestimmen den Aufbau, beide am
7
+ // laufenden System gemessen (macOS 25, Node 22):
8
+ //
9
+ // 1. Beobachtet wird nie die Datei selbst, sondern immer ihr Elternverzeichnis mit einem Filter auf
10
+ // die Dateinamen darin. `fs.watch` auf eine Datei hängt am Inode und wird nach dem ersten
11
+ // atomaren Ersetzen (schreiben in eine Temp-Datei, dann `rename` darüber) dauerhaft taub — kein
12
+ // weiteres Event, weder bei erneutem `rename` noch beim Schreiben an Ort und Stelle. Genau so
13
+ // schreiben viele Editoren, und so wird auch `~/.claude.json` ersetzt. Ein Watcher auf das
14
+ // Verzeichnis überlebt das und meldet den Dateinamen mit. Nebenbei deckt er Dateien ab, die beim
15
+ // Start noch gar nicht existieren: tauchen sie später auf, fängt derselbe Watcher es ab.
16
+ //
17
+ // 2. Ein Event heißt nicht, dass sich etwas geändert hat. `claude plugin list` — das hmgr bei jedem
18
+ // Anzeige-Aufbau je Claude-Code-Ziel ruft — ersetzt `~/.claude.json` jedes Mal per `rename`, mit
19
+ // unverändertem Inhalt. Ohne Gegenmaßnahme entstünde daraus eine Endlosschleife: Anzeige laden →
20
+ // Datei ersetzt → Watcher feuert → Anzeige laden. Darum stößt der Watcher erst an, wenn sich der
21
+ // Inhalt mindestens einer beobachteten Datei gegenüber dem letzten Stand wirklich unterscheidet.
22
+ //
23
+ // Fehlt das Elternverzeichnis, bleibt der Pfad unbeobachtet, bis ihn der nächste Fokus-Reload (E62)
24
+ // ohnehin erfasst. Mehrere Events in kurzer Folge (ein Editor schreibt beim Speichern oft mehrfach,
25
+ // ein `rename` meldet mehrere Events) lösen genau einen Callback aus: jedes Event verschiebt einen
26
+ // Timer um `entprellenMs` nach hinten, erst danach wird verglichen.
27
+ import { readFileSync, watch } from 'node:fs';
28
+ import { createHash } from 'node:crypto';
29
+ import path from 'node:path';
30
+ const STANDARD_ENTPRELLEN_MS = 400;
31
+ /** Marke für eine Datei, die es (noch) nicht gibt — unterscheidbar von jedem echten Inhalt. */
32
+ const FEHLT = 'fehlt';
33
+ /** Fingerabdruck des Inhalts. Unlesbar (fehlend, keine Rechte) zählt als `FEHLT`, nicht als Änderung. */
34
+ function fingerabdruck(pfad) {
35
+ try {
36
+ return createHash('sha256').update(readFileSync(pfad)).digest('hex');
37
+ }
38
+ catch {
39
+ return FEHLT;
40
+ }
41
+ }
42
+ /**
43
+ * Beobachtet `pfade` auf inhaltliche Änderungen und ruft `callback` entprellt auf. Doppelte Pfade
44
+ * werden nur einmal beobachtet, Pfade im selben Verzeichnis teilen sich einen Watcher.
45
+ */
46
+ export function beobachten(pfade, callback, optionen = {}) {
47
+ const entprellenMs = optionen.entprellenMs ?? STANDARD_ENTPRELLEN_MS;
48
+ const watcher = [];
49
+ let timer;
50
+ let geschlossen = false;
51
+ const eindeutige = [...new Set(pfade)];
52
+ const stand = new Map(eindeutige.map((p) => [p, fingerabdruck(p)]));
53
+ /** Zieht den Stand nach und sagt, ob sich dabei etwas geändert hat. */
54
+ function hatSichGeaendert() {
55
+ let geaendert = false;
56
+ for (const pfad of eindeutige) {
57
+ const neu = fingerabdruck(pfad);
58
+ if (stand.get(pfad) !== neu) {
59
+ stand.set(pfad, neu);
60
+ geaendert = true;
61
+ }
62
+ }
63
+ return geaendert;
64
+ }
65
+ function anstossen() {
66
+ if (geschlossen)
67
+ return;
68
+ if (timer !== undefined)
69
+ clearTimeout(timer);
70
+ timer = setTimeout(() => {
71
+ timer = undefined;
72
+ if (geschlossen)
73
+ return;
74
+ if (hatSichGeaendert())
75
+ callback();
76
+ }, entprellenMs);
77
+ }
78
+ // Je Verzeichnis ein Watcher, der die dort gesuchten Dateinamen kennt.
79
+ const jeVerzeichnis = new Map();
80
+ for (const pfad of eindeutige) {
81
+ const verzeichnis = path.dirname(pfad);
82
+ const namen = jeVerzeichnis.get(verzeichnis) ?? new Set();
83
+ namen.add(path.basename(pfad));
84
+ jeVerzeichnis.set(verzeichnis, namen);
85
+ }
86
+ for (const [verzeichnis, namen] of jeVerzeichnis) {
87
+ try {
88
+ watcher.push(watch(verzeichnis, (_ereignis, geaenderterName) => {
89
+ // Ohne Namen (kommt vor, je nach Plattform) wird konservativ angestoßen; der
90
+ // Inhaltsvergleich entscheidet ohnehin.
91
+ if (geaenderterName === null || namen.has(path.basename(geaenderterName)))
92
+ anstossen();
93
+ }));
94
+ }
95
+ catch {
96
+ // Das Verzeichnis fehlt: diese Pfade bleiben unbeobachtet (siehe Kommentar oben).
97
+ }
98
+ }
99
+ return {
100
+ close() {
101
+ geschlossen = true;
102
+ if (timer !== undefined)
103
+ clearTimeout(timer);
104
+ for (const w of watcher)
105
+ w.close();
106
+ },
107
+ };
108
+ }
@@ -0,0 +1,120 @@
1
+ // Ermittelt den PATH einer Login-Shell des Nutzers (F47=O2). Grund: die gepackte hmgr.app startet
2
+ // aus dem Finder mit dem macOS-Minimal-PATH (/usr/bin:/bin:/usr/sbin:/sbin), darin fehlt z. B.
3
+ // ~/.local/bin mit der claude-CLI (E63). Reine Funktion, ohne Electron, nach dem Muster von
4
+ // oeffnen.ts: main.ts ruft sie beim Start und übernimmt nur process.env.PATH.
5
+ //
6
+ // Unter Windows unterbleibt die Ermittlung ganz (E67): dort erbt ein aus dem Startmenü gestarteter
7
+ // Prozess den vollen Benutzer- und System-PATH aus der Registry, es gibt keine Lücke zu schließen,
8
+ // und eine Login-Shell im POSIX-Sinn gibt es nicht.
9
+ import { spawn } from 'node:child_process';
10
+ import path from 'node:path';
11
+ /** Zeitgrenze für die Shell. Gemessen auf picasso: `zsh -i -l -c` braucht ~2,0 s, das Doppelte
12
+ als Puffer reicht; wird sie überschritten, bleibt der PATH eben unverändert. */
13
+ export const ZEITGRENZE_MS = 5_000;
14
+ /**
15
+ * Standard-Ausführer: die angegebene Shell, stdin zu (sonst kann sie an einem Prompt hängen),
16
+ * stderr verworfen. Ein Fehlschlag wird zum Code, nicht zur Ausnahme — die PATH-Ermittlung darf
17
+ * die App nie zum Absturz bringen.
18
+ *
19
+ * Eigener Wecker statt der `timeout`-Option von `spawn`: die killt zwar die Shell, aber `close`
20
+ * feuert erst, wenn auch alle Erben die stdout-Pipe losgelassen haben — eine interaktive Shell
21
+ * kann Hintergrundprozesse hinterlassen. Der Wecker löst unabhängig davon auf, damit dieser Aufruf
22
+ * unter keinen Umständen länger als `ZEITGRENZE_MS` offen bleibt. `unref`, damit ein laufender
23
+ * Wecker das Beenden des Prozesses nicht aufhält.
24
+ */
25
+ export const shellAusfuehrer = (shell, args) => new Promise((aufloesen) => {
26
+ let erledigt = false;
27
+ const beenden = (ergebnis) => {
28
+ if (erledigt)
29
+ return;
30
+ erledigt = true;
31
+ clearTimeout(wecker);
32
+ aufloesen(ergebnis);
33
+ };
34
+ const kind = spawn(shell, args, { stdio: ['ignore', 'pipe', 'ignore'] });
35
+ const wecker = setTimeout(() => {
36
+ kind.kill('SIGKILL');
37
+ beenden({ stdout: '', code: 1 });
38
+ }, ZEITGRENZE_MS);
39
+ wecker.unref();
40
+ let stdout = '';
41
+ kind.stdout.on('data', (d) => (stdout += d));
42
+ kind.on('error', () => beenden({ stdout: '', code: 1 }));
43
+ kind.on('close', (code) => beenden({ stdout, code: code ?? 1 }));
44
+ });
45
+ /**
46
+ * Liefert den PATH einer interaktiven Login-Shell des Nutzers, oder `undefined` bei Fehler,
47
+ * Zeitüberschreitung oder unplausibler Ausgabe. Shell aus `process.env.SHELL`, Fallback `/bin/zsh`
48
+ * (macOS-Default).
49
+ *
50
+ * `-i` ist nötig, nicht bequem: zsh liest `~/.zshrc` nur interaktiv, `-l` allein nur
51
+ * `~/.zshenv`/`~/.zprofile`/`~/.zlogin`. Wer wie auf picasso gar kein `~/.zprofile` hat und alle
52
+ * PATH-Erweiterungen in `~/.zshrc` stehen hat, bekäme mit `-l` allein nur das Ergebnis von
53
+ * `path_helper` zurück — also genau nicht `~/.local/bin` mit der claude-CLI, um die es hier geht
54
+ * (gemessen 2026-09-05: `-l` ohne `-i` liefert dort kein `~/.local/bin`, `-i -l` liefert es).
55
+ * Das Hänge-Risiko einer interaktiven Shell fängt der Ausführer ab: stdin zu und harte Zeitgrenze.
56
+ *
57
+ * Der Marker trennt den PATH von allem, was eine gesprächige `~/.zshrc` vorher auf stdout schreibt
58
+ * (Begrüßung, Versionshinweis, Prompt-Framework) — ohne ihn landete das Geschwätz im ersten
59
+ * PATH-Eintrag. Alles vor dem letzten Marker wird verworfen; fehlt er, gilt die Ausgabe als
60
+ * unbrauchbar.
61
+ */
62
+ export const MARKER = '__hmgr_path__';
63
+ export async function pathAusLoginShellErmitteln(ausfuehrer = shellAusfuehrer) {
64
+ const shell = process.env.SHELL && process.env.SHELL.trim() !== '' ? process.env.SHELL : '/bin/zsh';
65
+ const { stdout, code } = await ausfuehrer(shell, ['-i', '-l', '-c', `printf %s%s '${MARKER}' "$PATH"`]);
66
+ if (code !== 0)
67
+ return undefined;
68
+ const ab = stdout.lastIndexOf(MARKER);
69
+ if (ab === -1)
70
+ return undefined;
71
+ const pfad = stdout.slice(ab + MARKER.length).trim();
72
+ if (pfad === '' || !pfad.includes('/'))
73
+ return undefined;
74
+ return pfad;
75
+ }
76
+ /**
77
+ * Führt den Login-PATH vor den bestehenden PATH: neue Einträge zuerst, danach der bisherige PATH,
78
+ * Duplikate raus (erstes Vorkommen zählt). So verliert die App nie einen Eintrag aus dem
79
+ * bisherigen PATH, gewinnt aber die zusätzlichen Pfade der Login-Shell. Kein Voll-Env-Import
80
+ * anderer Variablen — nur der PATH wird übernommen, um Überraschungen zu vermeiden.
81
+ *
82
+ * Getrennt wird an `path.delimiter`, nicht fest an ':' (E67): unter Windows ist der Trenner ';',
83
+ * und ein hart verdrahteter Doppelpunkt zerlegte dort `C:\Windows;C:\Git\cmd` in „C", „\Windows;C"
84
+ * und „\Git\cmd" — der PATH wäre zerstört, sobald ein Laufwerksbuchstabe doppelt vorkommt und die
85
+ * Duplikatsprüfung zuschlägt. Aufgerufen wird die Funktion unter Windows zwar nicht mehr
86
+ * (`loginPathUebernehmen` steigt dort vorher aus), sie soll aber für sich genommen richtig sein.
87
+ * Der Trenner ist ein Parameter, damit das auf jedem Rechner prüfbar ist.
88
+ */
89
+ export function pathZusammenfuehren(bisherigerPath, loginPath, trenner = path.delimiter) {
90
+ const eintraege = [...loginPath.split(trenner), ...(bisherigerPath ?? '').split(trenner)].filter((eintrag) => eintrag !== '');
91
+ const gesehen = new Set();
92
+ const ergebnis = [];
93
+ for (const eintrag of eintraege) {
94
+ if (gesehen.has(eintrag))
95
+ continue;
96
+ gesehen.add(eintrag);
97
+ ergebnis.push(eintrag);
98
+ }
99
+ return ergebnis.join(trenner);
100
+ }
101
+ /**
102
+ * Ermittelt den Login-PATH und setzt ihn (vorangestellt) auf `process.env.PATH`. Bei Fehler,
103
+ * Timeout oder leerer/unplausibler Ausgabe bleibt `process.env.PATH` unverändert — dieser Aufruf
104
+ * darf die App nie am Start hindern.
105
+ *
106
+ * Unter Windows passiert nichts (E67). Zwei Gründe, jeder für sich ausreichend: dort gibt es keine
107
+ * Login-Shell im POSIX-Sinn, `$SHELL` ist normalerweise gar nicht gesetzt und der Aufruf liefe
108
+ * gegen das nicht vorhandene `/bin/zsh` ins Leere; wo `$SHELL` doch gesetzt ist (Git-Bash- und
109
+ * MSYS-Umgebungen setzen es), käme ein POSIX-PATH mit ':' als Trenner zurück und würde dem echten
110
+ * Windows-PATH vorangestellt — kein Gewinn, aber ein Risiko. Die Plattform ist ein Parameter, damit
111
+ * der Ausstieg auf jedem Rechner prüfbar ist.
112
+ */
113
+ export async function loginPathUebernehmen(ausfuehrer = shellAusfuehrer, plattform = process.platform) {
114
+ if (plattform === 'win32')
115
+ return;
116
+ const loginPath = await pathAusLoginShellErmitteln(ausfuehrer);
117
+ if (loginPath === undefined)
118
+ return;
119
+ process.env.PATH = pathZusammenfuehren(process.env.PATH, loginPath);
120
+ }