hmgr 0.0.6 → 0.0.12

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,76 @@
1
+ // Homedirs eines Harness aus dem Zustand (E78). Synchron, siehe HeimePort.
2
+ import path from 'node:path';
3
+ import { HeimUnbekannt, OrtNichtUnterstuetzt, heimAdresseZerlegen } from "../kern/modell.js";
4
+ /**
5
+ * Pfade in der Welt eines Homedirs zusammensetzen (E78): POSIX in einer WSL-Distro, sonst die
6
+ * Schreibweise des Betriebssystems. Ohne das ergäbe `path.join` unter Windows `\`-Pfade für ein
7
+ * Ziel, das nur Linux zu Gesicht bekommt.
8
+ */
9
+ export function heimVerbinden(ort, ...teile) {
10
+ const adresse = ort.art === 'home' ? ort.heim : undefined;
11
+ return adresse !== undefined && heimAdresseZerlegen(adresse) !== undefined
12
+ ? path.posix.join(...teile)
13
+ : path.join(...teile);
14
+ }
15
+ /**
16
+ * Ob eine Adresse auf dieser Plattform bedienbar ist (E78, Entscheidung 7). `wsl:`-Heime gibt es
17
+ * nur unter Windows; steht eines im Zustand, weil er von einem Windows-Rechner stammt, wird sein
18
+ * Ziel anderswo nicht angezeigt und nicht bedient.
19
+ */
20
+ export function heimBedienbar(adresse, plattform) {
21
+ const zerlegt = heimAdresseZerlegen(adresse);
22
+ if (zerlegt === undefined)
23
+ return false;
24
+ return plattform === 'win32';
25
+ }
26
+ export class ZustandHeime {
27
+ zustand;
28
+ home;
29
+ plattform;
30
+ /** `home` ist das HOME des Nutzers: das primäre Homedir, solange nichts anderes eingetragen ist. */
31
+ constructor(zustand, home, plattform = process.platform) {
32
+ this.zustand = zustand;
33
+ this.home = home;
34
+ this.plattform = plattform;
35
+ }
36
+ eintrag(harness) {
37
+ return this.zustand.lesenSync().harnessHeime.find((h) => h.harness === harness);
38
+ }
39
+ primaer(harness) {
40
+ const pfad = this.eintrag(harness)?.pfad;
41
+ return pfad !== undefined && pfad.length > 0 ? pfad : this.home;
42
+ }
43
+ zusaetzliche(harness) {
44
+ return (this.eintrag(harness)?.zusaetzliche ?? [])
45
+ .filter((h) => heimBedienbar(h.adresse, this.plattform))
46
+ .map((h) => ({ adresse: h.adresse, pfad: h.pfad }));
47
+ }
48
+ alle(harness) {
49
+ return [{ pfad: this.primaer(harness) }, ...this.zusaetzliche(harness)];
50
+ }
51
+ pfad(harness, ort) {
52
+ if (ort.art !== 'home')
53
+ throw new OrtNichtUnterstuetzt(harness, ort.art);
54
+ if (ort.heim === undefined)
55
+ return this.primaer(harness);
56
+ const treffer = this.zusaetzliche(harness).find((h) => h.adresse === ort.heim);
57
+ if (treffer === undefined)
58
+ throw new HeimUnbekannt(harness, ort.heim);
59
+ return treffer.pfad;
60
+ }
61
+ }
62
+ /** Der Standardfall ohne Zustand: ein Harness hat genau ein Homedir, das Home des Nutzers. */
63
+ export function nurHome(home) {
64
+ return {
65
+ primaer: () => home,
66
+ zusaetzliche: () => [],
67
+ alle: () => [{ pfad: home }],
68
+ pfad: (harness, ort) => {
69
+ if (ort.art !== 'home')
70
+ throw new OrtNichtUnterstuetzt(harness, ort.art);
71
+ if (ort.heim !== undefined)
72
+ throw new HeimUnbekannt(harness, ort.heim);
73
+ return home;
74
+ },
75
+ };
76
+ }
@@ -6,6 +6,7 @@ import path from 'node:path';
6
6
  import { Document, isMap, isScalar, isSeq, parseDocument } from 'yaml';
7
7
  import { McpAbweichenderEintrag, OrtNichtUnterstuetzt, SkillOrdnerUngueltig, } from "../kern/modell.js";
8
8
  import { gitignoreEintragen, istGitRepo } from "./gitignore.js";
9
+ import { nurHome } from "./heime.js";
9
10
  import { tiefGleich } from "./vergleich.js";
10
11
  const HERMES_VERZEICHNIS = '.hermes';
11
12
  const PROFILE_VERZEICHNIS = 'profiles';
@@ -68,7 +69,7 @@ function mcpKonfigAlsObjekt(konfig) {
68
69
  }
69
70
  export class Hermes {
70
71
  name = 'hermes';
71
- home;
72
+ heime;
72
73
  /**
73
74
  * MCP-Server (E61): nur Home und Profil, denn Hermes kennt keine Projektebene für MCPs. Bindung
74
75
  * über Arrow-Funktionen auf `this`, damit die Fähigkeit ohne eigene Klasse auskommt.
@@ -80,9 +81,16 @@ export class Hermes {
80
81
  eingehaengte: async (ort) => this.mcpEingehaengte(ort),
81
82
  envDatei: (ort) => path.join(this.mcpHermesHome(ort), '.env'),
82
83
  };
83
- /** `home` ist das HOME des Nutzers, das Hermes-Home liegt darunter als `.hermes`. */
84
- constructor(home) {
85
- this.home = home;
84
+ /**
85
+ * `heime` sagt, wo das Homedir liegt (E78, F89): umstellbar, aber ohne zusätzliche Homedirs.
86
+ * Hermes' Zielverzeichnis steht in einer config.yaml; die über die Bridge zu lesen und zu
87
+ * schreiben wäre ein eigener Schnitt. Ein blanker Pfad steht für „genau dieses Home".
88
+ */
89
+ constructor(heime) {
90
+ this.heime = typeof heime === 'string' ? nurHome(heime) : heime;
91
+ }
92
+ get home() {
93
+ return this.heime.primaer(this.name);
86
94
  }
87
95
  /** Hermes liegt auf dem Rechner, wenn es sein Home angelegt hat. */
88
96
  async vorhanden() {
@@ -93,11 +101,12 @@ export class Hermes {
93
101
  }
94
102
  /** Home und Profil sind dasselbe Konzept: ein vollständiges Hermes-Home (E13). */
95
103
  hermesHome(ort) {
96
- const basis = path.join(this.home, HERMES_VERZEICHNIS);
104
+ // Über `heime.pfad` statt über `this.home`: nennt der Ort ein zusätzliches Homedir, wirft der
105
+ // Port (E78). Hermes hat die Fähigkeit nicht, ein solcher Ort dürfte hier gar nicht ankommen.
97
106
  if (ort.art === 'home')
98
- return basis;
107
+ return path.join(this.heime.pfad(this.name, ort), HERMES_VERZEICHNIS);
99
108
  if (ort.art === 'profil')
100
- return path.join(basis, PROFILE_VERZEICHNIS, ort.name);
109
+ return path.join(this.home, HERMES_VERZEICHNIS, PROFILE_VERZEICHNIS, ort.name);
101
110
  throw new OrtNichtUnterstuetzt(this.name, ort.art);
102
111
  }
103
112
  /** Tilde gegen HOME, relative Pfade gegen das Hermes-Home. Variablen löst hmgr nicht auf. */
@@ -0,0 +1,61 @@
1
+ // Die auf npm veröffentlichte Version der hmgr-CLI (E77). npm ist der Kanal der CLI (F83=O1), der
2
+ // Gitea-Feed (E73) gehört allein der GUI — deshalb fragt dieser Adapter die npm-Registry und nennt
3
+ // in der Meldung genau den Befehl, der über npm installiert.
4
+ //
5
+ // Jeder Fehlschlag ist still: offline, DNS weg, Registry langsam, unerwartetes JSON. Ein
6
+ // Update-Hinweis ist eine Beigabe, nie ein Grund, einen Lauf scheitern zu lassen oder den Nutzer
7
+ // mit einer Netzwerkmeldung zu behelligen. Wer mitlesen will, gibt `protokoll` mit.
8
+ import { versionIstNeuer } from "../kern/version.js";
9
+ /** Der Befehl, den die Meldung verspricht — und das einzige, was sie verspricht: hmgr installiert nie selbst. */
10
+ export const NPM_BEFEHL = 'npm install -g hmgr@latest';
11
+ /** Kurzer Timeout: die Prüfung hängt an einem Knopfdruck, sie darf ihn nicht spürbar aufhalten. */
12
+ const TIMEOUT_MS = 4000;
13
+ /**
14
+ * Die aktuelle Version eines Pakets laut npm-Registry, oder null. `/<paket>/latest` liefert das
15
+ * Manifest der Dist-Tag-Version `latest`, daraus zählt nur das Feld `version`.
16
+ */
17
+ export async function npmVersion(paket, optionen = {}) {
18
+ const protokoll = optionen.protokoll ?? (() => undefined);
19
+ const abbruch = new AbortController();
20
+ const wecker = setTimeout(() => abbruch.abort(), optionen.timeoutMs ?? TIMEOUT_MS);
21
+ try {
22
+ const antwort = await fetch(`https://registry.npmjs.org/${paket}/latest`, {
23
+ signal: abbruch.signal,
24
+ headers: { accept: 'application/json' },
25
+ });
26
+ if (!antwort.ok) {
27
+ protokoll(`[npm] ${paket}: HTTP ${antwort.status}`);
28
+ return null;
29
+ }
30
+ const daten = (await antwort.json());
31
+ if (typeof daten.version !== 'string') {
32
+ protokoll(`[npm] ${paket}: Antwort ohne Versionsfeld`);
33
+ return null;
34
+ }
35
+ return daten.version;
36
+ }
37
+ catch (fehler) {
38
+ protokoll(`[npm] ${paket}: ${fehler instanceof Error ? fehler.message : String(fehler)}`);
39
+ return null;
40
+ }
41
+ finally {
42
+ clearTimeout(wecker);
43
+ }
44
+ }
45
+ /** Die eine Zeile, die GUI-Sidebar und `hmgr update` gleichlautend zeigen. */
46
+ export function cliHinweisText(installiert, neu) {
47
+ return `hmgr-CLI ${installiert} — npm hat ${neu}: ${NPM_BEFEHL}`;
48
+ }
49
+ /**
50
+ * Fragt die Registry und gibt die fertige Meldung zurück — oder null, wenn nichts zu melden ist:
51
+ * Registry unerreichbar, gleiche Version, oder die installierte ist die neuere (ein Selbstbau aus
52
+ * dem Clone liegt regelmäßig vor npm).
53
+ */
54
+ export async function cliUpdateHinweis(installiert, optionen = {}) {
55
+ const neu = await npmVersion('hmgr', optionen);
56
+ if (neu === null || !versionIstNeuer(neu, installiert))
57
+ return null;
58
+ // `text` für die CLI (eine Zeile), die Einzelteile für die GUI, die den Befehl hervorhebt. Der
59
+ // Befehl kommt mit, damit er nicht ein zweites Mal im Renderer steht.
60
+ return { installiert, neu, befehl: NPM_BEFEHL, text: cliHinweisText(installiert, neu) };
61
+ }
@@ -0,0 +1,25 @@
1
+ // Die Version des Pakets, gelesen aus seiner package.json (E72). Keine Kopie im Code: eine
2
+ // zweite Stelle mit derselben Zahl geht früher oder später auseinander.
3
+ import { readFileSync } from 'node:fs';
4
+ /**
5
+ * Die package.json liegt in jeder Lage zwei Ebenen über diesem Modul: in der Quelle
6
+ * `src/adapter/` → Wurzel, im Build `dist/adapter/` → Wurzel, in der npm-Installation
7
+ * `node_modules/hmgr/dist/adapter/` → `node_modules/hmgr/`, im gepackten App-Bundle
8
+ * `app.asar/dist/adapter/` → `app.asar/`. Aufgelöst über `import.meta.url`, damit weder das
9
+ * Arbeitsverzeichnis noch der Aufrufweg hineinspielt.
10
+ *
11
+ * Bewusst kein Import mit `with { type: 'json' }`: der zöge die package.json beim Bauen nach
12
+ * `dist` und legte damit eine zweite Wahrheit an.
13
+ */
14
+ const PAKET_JSON = new URL('../../package.json', import.meta.url);
15
+ let gemerkt;
16
+ /** Die Versionsnummer aus der package.json des Pakets, etwa „0.0.6". */
17
+ export function paketVersion() {
18
+ if (gemerkt === undefined) {
19
+ const inhalt = JSON.parse(readFileSync(PAKET_JSON, 'utf8'));
20
+ if (typeof inhalt.version !== 'string')
21
+ throw new Error(`Die package.json unter ${PAKET_JSON.pathname} nennt keine Version.`);
22
+ gemerkt = inhalt.version;
23
+ }
24
+ return gemerkt;
25
+ }
@@ -2,8 +2,13 @@
2
2
  import { readFile } from 'node:fs/promises';
3
3
  export class ProzessUmgebung {
4
4
  env;
5
- constructor(env = process.env) {
5
+ plattformName;
6
+ constructor(env = process.env, plattform = process.platform) {
6
7
  this.env = env;
8
+ this.plattformName = plattform;
9
+ }
10
+ plattform() {
11
+ return this.plattformName;
7
12
  }
8
13
  variablen() {
9
14
  const ergebnis = {};
@@ -0,0 +1,39 @@
1
+ // Der Verlauf als eine JSON-Datei, Standard ~/.hmgr/verlauf.json (E80). Analog ZustandJson
2
+ // (zustand-json.ts), aber ohne Fassungs-Migration: das Format ist neu, `fassung: 1` reicht.
3
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { leererVerlauf } from "../ports/verlauf.js";
6
+ import { ohneBom } from "./datei-text.js";
7
+ /** Höchstzahl der Einträge in der Datei (E80): ein Changelog, keine Historie ohne Ende. */
8
+ const MAX_EINTRAEGE = 20;
9
+ /** Der gelesene Text als Verlauf. Fehlt ein Feld oder ist die Form falsch, gilt der leere Verlauf. */
10
+ function deuten(roh) {
11
+ const daten = JSON.parse(ohneBom(roh));
12
+ const gemerkt = typeof daten.gemerkt === 'object' && daten.gemerkt !== null ? daten.gemerkt : {};
13
+ const eintraege = Array.isArray(daten.eintraege) ? daten.eintraege : [];
14
+ return { gemerkt, eintraege };
15
+ }
16
+ export class VerlaufJson {
17
+ pfad;
18
+ constructor(hmgrHome) {
19
+ this.pfad = path.join(hmgrHome, 'verlauf.json');
20
+ }
21
+ async lesen() {
22
+ const roh = await readFile(this.pfad, 'utf8').catch(() => undefined);
23
+ if (roh === undefined)
24
+ return leererVerlauf();
25
+ try {
26
+ return deuten(roh);
27
+ }
28
+ catch {
29
+ // Kaputtes JSON (analog ZustandJson, deuten) darf den Lauf nicht abbrechen: leerer Verlauf.
30
+ return leererVerlauf();
31
+ }
32
+ }
33
+ async schreiben(daten) {
34
+ await mkdir(path.dirname(this.pfad), { recursive: true });
35
+ // Neueste zuerst kappen, nicht sortieren: die Reihenfolge liegt beim Aufrufer.
36
+ const gekappt = { gemerkt: daten.gemerkt, eintraege: daten.eintraege.slice(0, MAX_EINTRAEGE) };
37
+ await writeFile(this.pfad, `${JSON.stringify({ fassung: 1, ...gekappt }, null, 2)}\n`);
38
+ }
39
+ }
@@ -0,0 +1,212 @@
1
+ // Die Bridge in eine WSL-Distro (E78): `wsl.exe -d <distro> <linux-kommando>`. Über `\\wsl$` geht
2
+ // nichts davon — Windows legt dort keine Links an und liest durch Linux-Symlinks nicht hindurch
3
+ // (gemessen, HARNESS-FAKTEN „Links zwischen Windows und WSL").
4
+ import spawn from 'cross-spawn';
5
+ import path from 'node:path';
6
+ import { WslBrueckeFehlgeschlagen } from "../kern/modell.js";
7
+ /**
8
+ * Standard-Ausführer: `wsl.exe` vom PATH, über cross-spawn wie die claude-CLI (E65/B7). stdin ist
9
+ * zu, sonst hängt eine Distro, die auf Eingabe wartet; der Timeout fängt alles Übrige. Ein
10
+ * Fehlschlag wird zum Code, nicht zur Ausnahme.
11
+ */
12
+ export const wslAusfuehrer = (args) => new Promise((aufloesen) => {
13
+ const kind = spawn('wsl.exe', args, { stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000, killSignal: 'SIGKILL' });
14
+ const teile = [];
15
+ const fehlerTeile = [];
16
+ kind.stdout.on('data', (d) => teile.push(d));
17
+ kind.stderr.on('data', (d) => fehlerTeile.push(d));
18
+ kind.on('error', (fehler) => aufloesen({ ausgabe: Buffer.from(fehler.message, 'utf8'), code: 1 }));
19
+ kind.on('close', (code) => aufloesen({ ausgabe: Buffer.concat(code === 0 ? teile : [...teile, ...fehlerTeile]), code: code ?? 1 }));
20
+ });
21
+ /**
22
+ * Eine wsl.exe-eigene Ausgabe als Text (E78, gemessen): UTF-16LE, mit BOM. Node kennt die Kodierung
23
+ * als „utf16le"; die BOM bleibt beim Dekodieren als `\uFEFF` stehen und wird hier entfernt. Ein
24
+ * ungerades Byte am Ende (abgeschnittene Ausgabe) fällt weg statt ein Ersatzzeichen zu ergeben.
25
+ */
26
+ export function utf16Text(ausgabe) {
27
+ const gerade = ausgabe.length % 2 === 0 ? ausgabe : ausgabe.subarray(0, ausgabe.length - 1);
28
+ return gerade.toString('utf16le').replace(/^\uFEFF/, '');
29
+ }
30
+ /**
31
+ * Die Distronamen aus `wsl -l -q` (E78): eine je Zeile, UTF-16LE. Leerzeilen und Reste des
32
+ * Zeilenumbruchs fallen weg. Der Standard-Distro hängt in anderen Listenformen ein „(Standard)" an,
33
+ * `-q` liefert nur die nackten Namen.
34
+ */
35
+ export function distroListeLesen(ausgabe) {
36
+ return utf16Text(ausgabe)
37
+ .split(/\r?\n/)
38
+ .map((zeile) => zeile.trim())
39
+ .filter((zeile) => zeile.length > 0);
40
+ }
41
+ /**
42
+ * Ein Wert für die einfach zitierte Form der Shell: alles bleibt wörtlich, ein enthaltenes
43
+ * Apostroph wird ausgeklammert. Ohne das zerfiele ein Pfad mit Leerzeichen in mehrere Argumente,
44
+ * sobald wsl.exe die Kommandozeile wieder zusammensetzt.
45
+ */
46
+ export function shZitat(text) {
47
+ return `'${text.replace(/'/g, `'\\''`)}'`;
48
+ }
49
+ /**
50
+ * Ein POSIX-Pfad in die Windows-Schreibweise (E78): `/mnt/c/work/quelle` wird `C:\work\quelle`.
51
+ * `wurzel` ist die Automount-Wurzel der Distro, üblich `/mnt`, aber über `[automount] root`
52
+ * einstellbar — deshalb wird sie einmal per `wslpath` ermittelt statt geraten. Ein Pfad außerhalb
53
+ * (im Linux-Dateisystem) hat keine Windows-Entsprechung: `undefined`.
54
+ */
55
+ export function wslNachWindows(pfad, wurzel) {
56
+ const basis = wurzel.endsWith('/') ? wurzel.slice(0, -1) : wurzel;
57
+ const treffer = new RegExp(`^${basis.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/([a-zA-Z])(/.*)?$`).exec(pfad);
58
+ if (treffer === null)
59
+ return undefined;
60
+ const rest = (treffer[2] ?? '').replace(/\//g, '\\');
61
+ return `${treffer[1].toUpperCase()}:${rest.length === 0 ? '\\' : rest}`;
62
+ }
63
+ /**
64
+ * Die Gegenrichtung: `C:\work\quelle` wird `/mnt/c/work/quelle`. Ein Pfad ohne Laufwerksbuchstaben
65
+ * (UNC, relativ) hat keine Entsprechung unter der Automount-Wurzel: `undefined`.
66
+ */
67
+ export function windowsNachWsl(pfad, wurzel) {
68
+ const treffer = /^([a-zA-Z]):[\\/](.*)$/.exec(pfad);
69
+ if (treffer === null)
70
+ return undefined;
71
+ const basis = wurzel.endsWith('/') ? wurzel.slice(0, -1) : wurzel;
72
+ const rest = treffer[2].replace(/\\/g, '/').replace(/\/+$/, '');
73
+ return `${basis}/${treffer[1].toLowerCase()}${rest.length === 0 ? '' : `/${rest}`}`;
74
+ }
75
+ /**
76
+ * Eine WSL-Distro über die Bridge. Jede Dateisystem-Operation läuft als `/bin/sh -c "<skript>"` in
77
+ * der Distro; Pfade gehen einfach zitiert hinein (`shZitat`), weil wsl.exe die Kommandozeile wieder
78
+ * zusammensetzt und ein Leerzeichen sonst trennte.
79
+ */
80
+ export class WslBruecke {
81
+ distro;
82
+ ausfuehren;
83
+ /** Automount-Wurzel, einmal je Lauf ermittelt: ein `wslpath`-Aufruf statt einem je Linkziel. */
84
+ wurzelVersprechen;
85
+ constructor(distro, ausfuehren = wslAusfuehrer) {
86
+ this.distro = distro;
87
+ this.ausfuehren = ausfuehren;
88
+ }
89
+ /** Ein Linux-Kommando in der Distro, Ausgabe als UTF-8. Wirft, wenn die Bridge selbst scheitert. */
90
+ async sh(skript) {
91
+ const { ausgabe, code } = await this.ausfuehren(['-d', this.distro, '/bin/sh', '-c', skript]);
92
+ const text = ausgabe.toString('utf8');
93
+ if (code !== 0)
94
+ throw new WslBrueckeFehlgeschlagen(this.distro, text);
95
+ return text;
96
+ }
97
+ /** Dasselbe, aber ein Fehlschlag ist kein Fehler: `undefined`. Für lesende Abfragen. */
98
+ async shWeich(skript) {
99
+ const { ausgabe, code } = await this.ausfuehren(['-d', this.distro, '/bin/sh', '-c', skript]);
100
+ return code === 0 ? ausgabe.toString('utf8') : undefined;
101
+ }
102
+ /**
103
+ * Das Home des Standardbenutzers der Distro (F87). wsl.exe startet als dieser Benutzer — derselbe,
104
+ * der Claude Code dort interaktiv nutzt; eine Benutzer-Option gibt es deshalb nicht.
105
+ */
106
+ async home() {
107
+ const text = (await this.sh('echo $HOME')).trim();
108
+ if (text.length === 0)
109
+ throw new WslBrueckeFehlgeschlagen(this.distro, 'kein $HOME in der Distro');
110
+ return text;
111
+ }
112
+ /**
113
+ * Die Automount-Wurzel, aus `wslpath -u 'C:\'`. wslpath kommt von WSL selbst und liegt auch in
114
+ * einer Minimal-Distro (gemessen). Schlägt der Aufruf fehl, gilt der Standard `/mnt`: eine
115
+ * Übersetzung mit der üblichen Wurzel ist besser als gar keine.
116
+ */
117
+ async wurzel() {
118
+ this.wurzelVersprechen ??= (async () => {
119
+ const text = await this.shWeich(`wslpath -u ${shZitat('C:\\')}`);
120
+ const pfad = text?.trim() ?? '';
121
+ const treffer = /^(.*)\/[a-zA-Z]\/?$/.exec(pfad);
122
+ return treffer?.[1] !== undefined && treffer[1].length > 0 ? treffer[1] : '/mnt';
123
+ })();
124
+ return this.wurzelVersprechen;
125
+ }
126
+ /** Einen Pfad der Distro in die Schreibweise des Windows-Rechners. Ohne Entsprechung: unverändert. */
127
+ async nachHost(pfad) {
128
+ return wslNachWindows(pfad, await this.wurzel()) ?? pfad;
129
+ }
130
+ /** Die Gegenrichtung. Ohne Entsprechung: unverändert — der Link zeigt dann eben ins Leere. */
131
+ async vomHost(pfad) {
132
+ return windowsNachWsl(pfad, await this.wurzel()) ?? pfad;
133
+ }
134
+ }
135
+ /** Die installierten Distros (`wsl -l -q`). Ohne wsl.exe oder ohne Distro: leere Liste. */
136
+ export async function wslDistros(ausfuehren = wslAusfuehrer) {
137
+ const { ausgabe, code } = await ausfuehren(['-l', '-q']);
138
+ if (code !== 0)
139
+ return [];
140
+ return distroListeLesen(ausgabe);
141
+ }
142
+ /**
143
+ * Zugriff auf ein Zielverzeichnis in einer WSL-Distro (E78). Jede Operation ist ein Aufruf über die
144
+ * Bridge; Pfade sind POSIX-Pfade der Distro, Linkziele übersetzt der Zugriff selbst nach `/mnt/c`
145
+ * und zurück. Ein Symlink im WSL-Home darf auf `/mnt/c/…` zeigen — die Distro liest selbst
146
+ * hindurch, also bleiben die vorhandenen Windows-Checkouts die Linkziele (gemessen).
147
+ */
148
+ export class WslZugriff {
149
+ trenner = '/';
150
+ bruecke;
151
+ constructor(bruecke) {
152
+ this.bruecke = bruecke;
153
+ }
154
+ verbinden(verzeichnis, name) {
155
+ return path.posix.join(verzeichnis, name);
156
+ }
157
+ /**
158
+ * `ls -1A` statt `find -printf`: letzteres ist eine GNU-Erweiterung und fehlt in einer
159
+ * Busybox-Distro. Ein Name mit Zeilenumbruch zerfiele dabei — bei Skill-Ordnern kommt das nicht
160
+ * vor, und der Preis wäre ein Sonderformat für einen Fall, den es nicht gibt.
161
+ */
162
+ async namen(verzeichnis) {
163
+ const text = await this.weich(`ls -1A ${shZitat(verzeichnis)} 2>/dev/null`);
164
+ if (text === undefined)
165
+ return [];
166
+ return text
167
+ .split('\n')
168
+ .map((zeile) => zeile.replace(/\r$/, ''))
169
+ .filter((zeile) => zeile.length > 0);
170
+ }
171
+ /**
172
+ * Drei Ja/Nein-Fragen in einem Aufruf: gibt es den Eintrag (auch als toten Link), ist er ein Link,
173
+ * zeigt er auf ein Verzeichnis. `-d` folgt dem Link, `-L` nicht — genau die Aufteilung, die der
174
+ * lokale Zugriff mit lstat und stat trifft.
175
+ */
176
+ async info(pfad) {
177
+ const p = shZitat(pfad);
178
+ const text = await this.weich(`if [ -e ${p} ] || [ -L ${p} ]; then printf e; fi; if [ -L ${p} ]; then printf l; fi; if [ -d ${p} ]; then printf d; fi`);
179
+ if (text === undefined || !text.includes('e'))
180
+ return undefined;
181
+ return { istLink: text.includes('l'), istVerzeichnis: text.includes('d') };
182
+ }
183
+ async linkZiel(pfad) {
184
+ const text = await this.weich(`readlink ${shZitat(pfad)}`);
185
+ if (text === undefined)
186
+ return undefined;
187
+ const ziel = text.trim();
188
+ if (ziel.length === 0)
189
+ return undefined;
190
+ // Ein relatives Linkziel gibt es bei hmgr-Links nicht, aber ein von Hand gelegter Link könnte
191
+ // eines haben: erst gegen das Verzeichnis auflösen, dann übersetzen.
192
+ const absolut = ziel.startsWith('/') ? ziel : path.posix.resolve(path.posix.dirname(pfad), ziel);
193
+ return await this.bruecke.nachHost(absolut);
194
+ }
195
+ async verzeichnisAnlegen(pfad) {
196
+ await this.bruecke.sh(`mkdir -p ${shZitat(pfad)}`);
197
+ }
198
+ async linkAnlegen(pfad, hostZiel) {
199
+ const ziel = await this.bruecke.vomHost(hostZiel);
200
+ await this.bruecke.sh(`ln -sfn ${shZitat(ziel)} ${shZitat(pfad)}`);
201
+ }
202
+ async linkEntfernen(pfad) {
203
+ await this.bruecke.sh(`rm -f ${shZitat(pfad)}`);
204
+ }
205
+ async loeschen(pfad) {
206
+ await this.bruecke.sh(`rm -rf ${shZitat(pfad)}`);
207
+ }
208
+ /** Lesende Abfragen brechen nicht ab, wenn die Distro schweigt: sie ergeben dann „nichts da". */
209
+ async weich(skript) {
210
+ return await this.bruecke.sh(skript).catch(() => undefined);
211
+ }
212
+ }
@@ -1,8 +1,29 @@
1
1
  // Lokaler Zustand als eine JSON-Datei, Standard ~/.hmgr/state.json (E29).
2
+ import { readFileSync } from 'node:fs';
2
3
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
4
  import path from 'node:path';
4
- import { leererZustand } from "../ports/zustand.js";
5
+ import { ZUSTAND_FASSUNG, leererZustand, } from "../ports/zustand.js";
5
6
  import { ohneBom } from "./datei-text.js";
7
+ /** Der gelesene Text als Zustand. Fehlende Felder bekommen den Wert, der dem Bestand entspricht. */
8
+ function deuten(roh) {
9
+ const daten = JSON.parse(ohneBom(roh));
10
+ return {
11
+ // Fehlt das Feld (state.json vor E69), ist es die erste Fassung: alles ohne Bundles.
12
+ // Geschrieben wird danach in der aktuellen Fassung, ein Rückweg ist nicht vorgesehen.
13
+ fassung: typeof daten.fassung === 'number' ? daten.fassung : 1,
14
+ quellen: (daten.quellen ?? []),
15
+ arbeitsverzeichnisse: (daten.arbeitsverzeichnisse ?? []),
16
+ bindungen: (daten.bindungen ?? []),
17
+ // Fehlt das Feld (state.json vor E38), gilt der bisherige Ist-Zustand: nur claude-code aktiv.
18
+ aktiveHarnesses: (daten.aktiveHarnesses ?? ['claude-code']),
19
+ // Fehlt das Feld (state.json vor E43), gilt: nichts ignoriert.
20
+ ignorierteEintraege: (daten.ignorierteEintraege ?? []),
21
+ // Fehlt das Feld (state.json vor E69), gilt: kein Bundle installiert.
22
+ bundleInstallationen: (daten.bundleInstallationen ?? []),
23
+ // Fehlt das Feld (state.json vor E78), gilt: jeder Harness hat nur sein Standard-Homedir.
24
+ harnessHeime: (daten.harnessHeime ?? []),
25
+ };
26
+ }
6
27
  export class ZustandJson {
7
28
  pfad;
8
29
  constructor(pfad) {
@@ -12,19 +33,26 @@ export class ZustandJson {
12
33
  const roh = await readFile(this.pfad, 'utf8').catch(() => undefined);
13
34
  if (roh === undefined)
14
35
  return leererZustand();
15
- const daten = JSON.parse(ohneBom(roh));
16
- return {
17
- quellen: (daten.quellen ?? []),
18
- arbeitsverzeichnisse: (daten.arbeitsverzeichnisse ?? []),
19
- bindungen: (daten.bindungen ?? []),
20
- // Fehlt das Feld (state.json vor E38), gilt der bisherige Ist-Zustand: nur claude-code aktiv.
21
- aktiveHarnesses: (daten.aktiveHarnesses ?? ['claude-code']),
22
- // Fehlt das Feld (state.json vor E43), gilt: nichts ignoriert.
23
- ignorierteEintraege: (daten.ignorierteEintraege ?? []),
24
- };
36
+ return deuten(roh);
37
+ }
38
+ /**
39
+ * Derselbe Zustand, synchron gelesen (E78). Nötig für die Auflösung der Homedirs: sie hängt an
40
+ * `HarnessTyp.zielVerzeichnis`, und das ist synchron. Ein kaputter oder fehlender Zustand ergibt
41
+ * hier den leeren — die Homedir-Auflösung darf keine Ansicht abbrechen, sie fällt dann auf das
42
+ * Home des Nutzers zurück. Nicht Teil von `ZustandPort`: dort bleibt alles asynchron.
43
+ */
44
+ lesenSync() {
45
+ try {
46
+ return deuten(readFileSync(this.pfad, 'utf8'));
47
+ }
48
+ catch {
49
+ return leererZustand();
50
+ }
25
51
  }
26
52
  async schreiben(zustand) {
27
53
  await mkdir(path.dirname(this.pfad), { recursive: true });
28
- await writeFile(this.pfad, `${JSON.stringify(zustand, null, 2)}\n`);
54
+ // Immer in der aktuellen Fassung, egal welche gelesen wurde: das Lesen hat den Bestand schon
55
+ // auf die aktuelle Form gebracht, ein alter Vermerk in der Datei wäre danach eine Lüge.
56
+ await writeFile(this.pfad, `${JSON.stringify({ ...zustand, fassung: ZUSTAND_FASSUNG }, null, 2)}\n`);
29
57
  }
30
58
  }