hmgr 0.0.3 → 0.0.4

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,6 +1,7 @@
1
1
  // Pfadschema der Checkouts (E22): <wurzel>/<host>/<besitzer>/<repo>/<ref>/,
2
2
  // je Repo ein Basis-Clone unter <wurzel>/<host>/<besitzer>/<repo>/.repo.
3
3
  import path from 'node:path';
4
+ import { ortAusSegmenten, urlOrt } from "../kern/quelle-ort.js";
4
5
  export const BASIS_NAME = '.repo';
5
6
  /** Refs mit „/" im Namen werden im Pfad kodiert, damit ein Ref ein Verzeichnis bleibt. */
6
7
  export function refKodieren(ref) {
@@ -9,29 +10,13 @@ export function refKodieren(ref) {
9
10
  export function refDekodieren(text) {
10
11
  return text.replaceAll('%2F', '/').replaceAll('%25', '%');
11
12
  }
12
- function ohneGitSuffix(name) {
13
- return name.endsWith('.git') ? name.slice(0, -4) : name;
14
- }
15
- function ausSegmenten(host, segmente) {
16
- const gefiltert = segmente.filter((s) => s.length > 0);
17
- const repo = ohneGitSuffix(gefiltert[gefiltert.length - 1] ?? 'repo');
18
- const besitzer = gefiltert[gefiltert.length - 2] ?? '_';
19
- return { host, besitzer, repo };
20
- }
21
13
  /** Zerlegt „git@host:besitzer/repo.git", „https://host/besitzer/repo", „file:///pfad" und nackte Pfade. */
22
14
  export function urlZerlegen(url) {
23
- const text = url.trim().replace(/\/+$/, '');
24
- if (text.includes('://')) {
25
- const u = new URL(text);
26
- if (u.protocol === 'file:')
27
- return ausSegmenten('local', decodeURIComponent(u.pathname).split('/'));
28
- return ausSegmenten(u.hostname, decodeURIComponent(u.pathname).split('/'));
29
- }
30
- const scp = /^(?:[^@/]+@)?([^@/:]+):(.+)$/.exec(text);
31
- if (scp)
32
- return ausSegmenten(scp[1], scp[2].split('/'));
15
+ const ort = urlOrt(url);
16
+ if (ort !== undefined)
17
+ return ort;
33
18
  // Nackter lokaler Pfad: Besitzer aus dem Elternverzeichnis.
34
- return ausSegmenten('local', path.resolve(text).split(path.sep));
19
+ return ortAusSegmenten('local', path.resolve(url.trim()).split(path.sep));
35
20
  }
36
21
  export function quelleZerlegen(quelle) {
37
22
  return urlZerlegen(quelle.art === 'git' ? quelle.url : quelle.pfad);
@@ -1,6 +1,6 @@
1
1
  // Git über die CLI, keine Bibliothek. Checkout-Schema und Worktrees nach E22, Discovery nach E11.
2
2
  import { execFile, spawn } from 'node:child_process';
3
- import { readFile, readdir, mkdir, realpath, stat } from 'node:fs/promises';
3
+ import { readFile, readdir, mkdir, realpath, rm, stat } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { promisify } from 'node:util';
6
6
  import { marketplaceLesen } from "./marketplace-json.js";
@@ -225,6 +225,15 @@ export class GitQuellen {
225
225
  const ausgabe = await this.git(['for-each-ref', '--format=%(refname:short)', 'refs/tags'], basis);
226
226
  return ausgabe.split('\n').filter((z) => z.length > 0);
227
227
  }
228
+ /**
229
+ * Refs vom Remote nachziehen, damit eine Versionseinschränkung auch Tags sieht, die nach dem
230
+ * Clone entstanden sind (E33). Lokale Quellen haben keinen Clone und keinen Remote (B11).
231
+ */
232
+ async refsNachziehen(quelle) {
233
+ if (quelle.art === 'lokal')
234
+ return;
235
+ await this.git(['fetch', '--prune', '--tags', 'origin'], await this.basis(quelle));
236
+ }
228
237
  async standardRef(quelle) {
229
238
  if (quelle.art === 'lokal')
230
239
  return LOKAL_REF;
@@ -305,6 +314,24 @@ export class GitQuellen {
305
314
  await this.git(['checkout', '--detach', checkout.ref], checkout.pfad);
306
315
  return { ergebnis: 'aktualisiert', von, nach };
307
316
  }
317
+ /**
318
+ * Checkout von der Platte nehmen. Ein Worktree steht im Basis-Clone eingetragen, ein blankes
319
+ * Löschen des Verzeichnisses ließe den Eintrag stehen und ein späterer `worktree add` auf
320
+ * denselben Pfad scheiterte daran. Bleibt der Eintrag defekt, wird das Verzeichnis entfernt und
321
+ * der Eintrag danach mit `prune` aufgeräumt. Der Basis-Clone bleibt, er trägt alle Refs.
322
+ */
323
+ async entfernen(checkout) {
324
+ if (checkout.quelle.art === 'lokal')
325
+ return;
326
+ const basis = await this.basis(checkout.quelle);
327
+ try {
328
+ await this.git(['worktree', 'remove', '--force', checkout.pfad], basis);
329
+ }
330
+ catch {
331
+ await rm(checkout.pfad, { recursive: true, force: true });
332
+ await this.git(['worktree', 'prune'], basis);
333
+ }
334
+ }
308
335
  /**
309
336
  * Arbeitskopie um einen Pfad herum (B12). Die Wurzel kommt aus `rev-parse --show-toplevel`, vom
310
337
  * Verzeichnis des Pfads aus gefragt. Beide Pfade werden über realpath aufgelöst, sonst passen
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path';
2
+ export { quelleAlsText } from "../kern/modell.js";
2
3
  /** URL heißt Git-Quelle mit Checkout, ein nackter Pfad heißt lokale Arbeitskopie ohne Clone (B11). */
3
4
  export function quelleAusText(text) {
4
5
  const roh = text.trim();
@@ -6,6 +7,3 @@ export function quelleAusText(text) {
6
7
  return { art: 'git', url: roh };
7
8
  return { art: 'lokal', pfad: path.resolve(roh) };
8
9
  }
9
- export function quelleAlsText(quelle) {
10
- return quelle.art === 'git' ? quelle.url : quelle.pfad;
11
- }
@@ -21,4 +21,27 @@ export class TerminalRueckfrage {
21
21
  leser.close();
22
22
  }
23
23
  }
24
+ /**
25
+ * Nummerierte Liste, eine gelesene Zahl. Dieselbe Weiche wie bei `bestaetigen`: ohne Terminal auf
26
+ * beiden Seiten fragt hmgr nicht und gibt nichts aus. Alles, was keine der Nummern ist, auch die
27
+ * leere Eingabe, gilt als keine Antwort. Der Adapter rät nicht, er reicht das Nichts weiter.
28
+ */
29
+ async auswaehlen(frage, optionen) {
30
+ if (optionen.length === 0)
31
+ return undefined;
32
+ if (process.stdin.isTTY !== true || process.stdout.isTTY !== true)
33
+ return undefined;
34
+ const leser = createInterface({ input: process.stdin, output: process.stdout });
35
+ try {
36
+ const liste = optionen.map((option, i) => ` ${i + 1}) ${option}`).join('\n');
37
+ const antwort = await leser.question(`${frage}\n${liste}\nNummer [1-${optionen.length}]: `);
38
+ const nummer = Number(antwort.trim());
39
+ if (!Number.isInteger(nummer) || nummer < 1 || nummer > optionen.length)
40
+ return undefined;
41
+ return nummer - 1;
42
+ }
43
+ finally {
44
+ leser.close();
45
+ }
46
+ }
24
47
  }
package/dist/cli/hilfe.js CHANGED
@@ -33,6 +33,10 @@ und ihren Marketplace und merkt sie unter ~/.hmgr. Erst danach kennt hmgr die Sk
33
33
  denen link und plugin add arbeiten. Eine URL wird geklont, ein Pfad bleibt die Arbeitskopie, die
34
34
  dort schon liegt, und wird von hmgr nicht verändert.
35
35
 
36
+ Welche Quelle gemeint ist, hängt an Host, Besitzer und Repo, nicht an der Schreibweise der URL. Ein
37
+ Repo in SSH- und in HTTPS-Form ist dieselbe Quelle, mit und ohne .git-Suffix ebenso. Gefetcht wird
38
+ unter der Schreibweise, die beim ersten source add stand.
39
+
36
40
  Schalter: keine.
37
41
 
38
42
  Beispiele:
@@ -43,6 +47,7 @@ Beispiele:
43
47
  Häufige Fälle:
44
48
  „Skill … liegt in keiner Quelle": die Quelle fehlt, erst source add, dann link.
45
49
  Neue Commits einer bekannten Quelle holt hmgr update, nicht source add.
50
+ „Quelle ist schon bekannt": dasselbe Repo steht schon im Zustand, hier in anderer Schreibweise.
46
51
 
47
52
  Siehe auch: hmgr help link, hmgr help plugin, hmgr help update`;
48
53
  const DIR = `hmgr dir add <pfad> [--composer <pfad>]
@@ -55,7 +60,8 @@ Plugins des Vorhabens stehen. Ohne Zeiger bleibt das Verzeichnis gemerkt und jed
55
60
  (E26). dir forget löst Bindung und Eintrag, die gelegten Links bleiben liegen.
56
61
 
57
62
  Schalter:
58
- --composer <pfad> composer.json wählen, wenn unter dem Verzeichnis mehrere in Frage kommen
63
+ --composer <pfad> nur bei add: composer.json wählen, wenn unter dem Verzeichnis mehrere in
64
+ Frage kommen
59
65
 
60
66
  Beispiele:
61
67
  hmgr dir add ~/Code/site
@@ -63,7 +69,8 @@ Beispiele:
63
69
  hmgr dir list
64
70
 
65
71
  Häufige Fälle:
66
- „Mehrere composer.json unter …": eine mit --composer nennen, hmgr rät nicht.
72
+ „Mehrere composer.json unter …": hmgr fragt am Terminal, welche es ist. Ohne Antwort, etwa in
73
+ einem Script, bleibt der Fehler und --composer nennt sie, hmgr rät nicht.
67
74
  link --ort dir:<pfad> merkt das Verzeichnis von selbst. dir add braucht es vorher nur, wenn die
68
75
  composer.json mehrdeutig ist.
69
76
 
@@ -201,6 +208,11 @@ Plugin-Fähigkeit deren Plugins nach (E14). Die Links zeigen weiter auf dieselbe
201
208
  nicht erneuert werden. Ein Checkout mit lokaler Änderung wird übersprungen und gemeldet (E15),
202
209
  damit nichts verloren geht. Lokale Quellen haben keinen Checkout und bleiben unberührt.
203
210
 
211
+ Danach räumt update verwaiste Ref-Verzeichnisse auf: Checkouts, in die kein Link mehr zeigt, weil
212
+ ein Link inzwischen auf einen anderen Ref umgelegt wurde. Vor dem Löschen fragt hmgr einmal für
213
+ alle Kandidaten. Ohne Bestätigung, also auch ohne Terminal, bleibt alles liegen und wird nur
214
+ gemeldet. Ein verwaister Checkout mit lokaler Änderung wird nie gelöscht (E15).
215
+
204
216
  So wird eine Änderung an einem Skill überall wirksam: in der Quelle committen und pushen, dann
205
217
  hmgr update.
206
218
 
@@ -213,6 +225,8 @@ Häufige Fälle:
213
225
  „übersprungen … lokale Änderung (E15)": im Checkout liegt eine Änderung, die durch einen Link
214
226
  hindurch gemacht wurde. Dort committen und pushen, dann erneut hmgr update.
215
227
  „Keine Checkouts": es gibt nur lokale Quellen oder noch keinen Link.
228
+ „liegen lassen … ohne Bestätigung nicht gelöscht": die Frage kam ohne Ja zurück, etwa in einer
229
+ Pipe oder einem Composer-Script. In einem Terminal erneut hmgr update aufrufen.
216
230
 
217
231
  Siehe auch: hmgr help sync, hmgr help status`;
218
232
  const STATUS = `hmgr status
package/dist/cli/main.js CHANGED
@@ -36,9 +36,44 @@ export function bauen(umgebung, optionen = {}) {
36
36
  };
37
37
  return { anwendungsfaelle: new Anwendungsfaelle(ports), ports };
38
38
  }
39
- /** Schalter ohne Wert. Alles andere braucht einen. `help` steht hier, damit --help nie greift. */
40
- const FLAGGEN = new Set(['ersetzen', 'local', 'lokal', 'probelauf', 'help']);
39
+ /**
40
+ * Was jedes Kommando auswertet, gleichlautend mit seiner Hilfe. Alles andere bricht ab. `--help`
41
+ * steht nirgends, weil der Hilfe-Dispatch vor jedem schalterLesen läuft.
42
+ */
43
+ const SCHALTER = {
44
+ source: { werte: [], flaggen: [] },
45
+ dir: { werte: ['composer'], flaggen: [] },
46
+ link: {
47
+ werte: ['harness', 'ort', 'composer', 'quelle', 'version'],
48
+ flaggen: ['local', 'lokal', 'ersetzen'],
49
+ },
50
+ unlink: { werte: ['harness', 'ort', 'composer'], flaggen: ['local', 'lokal'] },
51
+ adopt: { werte: ['harness', 'ort', 'composer'], flaggen: ['local', 'lokal', 'probelauf'] },
52
+ plugin: { werte: ['harness', 'ort', 'composer', 'quelle'], flaggen: ['local', 'lokal'] },
53
+ sync: { werte: [], flaggen: [] },
54
+ update: { werte: [], flaggen: [] },
55
+ status: { werte: [], flaggen: [] },
56
+ };
57
+ /**
58
+ * Was ein einzelnes Unterkommando von seiner Familie liest, gleichlautend mit ihrer Hilfe. Ohne
59
+ * `schalter` bleibt es bei der Familienliste, das reicht, solange alle Unterkommandos dieselben
60
+ * Schalter lesen.
61
+ */
62
+ const UNTERKOMMANDOS = {
63
+ source: { add: { woerter: 2 }, list: { woerter: 1 } },
64
+ dir: {
65
+ add: { woerter: 2, schalter: ['composer'] },
66
+ forget: { woerter: 2, schalter: [] },
67
+ list: { woerter: 1, schalter: [] },
68
+ },
69
+ plugin: {
70
+ add: { woerter: 2, schalter: ['harness', 'ort', 'composer', 'quelle', 'local', 'lokal'] },
71
+ remove: { woerter: 2, schalter: ['harness', 'ort', 'composer', 'local', 'lokal'] },
72
+ list: { woerter: 1, schalter: [] },
73
+ },
74
+ };
41
75
  function schalterLesen(args, kommando) {
76
+ const erlaubt = SCHALTER[kommando];
42
77
  const rest = [];
43
78
  const werte = new Map();
44
79
  const flaggen = new Set();
@@ -49,18 +84,50 @@ function schalterLesen(args, kommando) {
49
84
  continue;
50
85
  }
51
86
  const name = arg.slice(2);
52
- if (FLAGGEN.has(name)) {
87
+ if (erlaubt.flaggen.includes(name)) {
53
88
  flaggen.add(name);
54
89
  continue;
55
90
  }
91
+ if (!erlaubt.werte.includes(name))
92
+ throw new Error(`Unbekannter Schalter --${name} für ${kommando}. hmgr help ${kommando} zeigt die Schalter.`);
56
93
  const wert = args[++i];
57
94
  if (wert === undefined)
58
95
  throw new Error(`Schalter --${name} braucht einen Wert, etwa --${name} <wert>. ` +
59
- `${kommando === undefined ? 'hmgr --help' : `hmgr help ${kommando}`} zeigt die Schalter.`);
96
+ `hmgr help ${kommando} zeigt die Schalter.`);
60
97
  werte.set(name, wert);
61
98
  }
62
99
  return { rest, werte, flaggen };
63
100
  }
101
+ /** Ein Wort mehr, als das Kommando liest, ist ein Tippfehler und kein stiller Zusatz. */
102
+ function restPruefen(rest, woerter, kommando, unterkommando) {
103
+ const zuviel = rest[woerter];
104
+ if (zuviel === undefined)
105
+ return;
106
+ const verwendung = unterkommando === undefined ? kommando : `${kommando} ${unterkommando}`;
107
+ // Der Name des Unterkommandos zählt zu den Wörtern, aber nicht zu den Argumenten.
108
+ const argumente = woerter - (unterkommando === undefined ? 0 : 1);
109
+ throw new Error(`${verwendung} erwartet ${argumente === 0 ? 'kein' : 'kein weiteres'} Argument „${zuviel}". ` +
110
+ `hmgr help ${kommando} zeigt die Verwendung.`);
111
+ }
112
+ /**
113
+ * Prüft, was erst mit dem Unterkommando feststeht: liest es die gesetzten Schalter, und steht ein
114
+ * Wort zu viel da. Ein unbekanntes Unterkommando meldet die Familie selbst.
115
+ */
116
+ function unterkommandoPruefen(kommando, schalter) {
117
+ const unterkommando = schalter.rest[0];
118
+ if (unterkommando === undefined)
119
+ return;
120
+ const menge = UNTERKOMMANDOS[kommando][unterkommando];
121
+ if (menge === undefined)
122
+ return;
123
+ const erlaubt = menge.schalter;
124
+ if (erlaubt !== undefined)
125
+ for (const name of [...schalter.werte.keys(), ...schalter.flaggen])
126
+ if (!erlaubt.includes(name))
127
+ throw new Error(`Unbekannter Schalter --${name} für ${kommando} ${unterkommando}. ` +
128
+ `hmgr help ${kommando} zeigt die Schalter.`);
129
+ restPruefen(schalter.rest, menge.woerter, kommando, unterkommando);
130
+ }
64
131
  function ortLesen(text) {
65
132
  const roh = text ?? 'home';
66
133
  if (roh === 'home')
@@ -107,13 +174,21 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
107
174
  return 0;
108
175
  }
109
176
  if (kommando === 'source') {
110
- const unterkommando = argv[1];
177
+ const schalter = schalterLesen(argv.slice(1), 'source');
178
+ unterkommandoPruefen('source', schalter);
179
+ const rest = schalter.rest;
180
+ const unterkommando = rest[0];
111
181
  if (unterkommando === 'add') {
112
- const text = argv[2];
182
+ const text = rest[1];
113
183
  if (text === undefined)
114
184
  throw new Error('source add braucht eine URL oder einen Pfad, etwa hmgr source add https://example.com/skills.git');
115
- const quelle = quelleAusText(text);
116
- const { skills, marketplace } = await anwendungsfaelle.quelleHinzufuegen(quelle);
185
+ const genannt = quelleAusText(text);
186
+ const { quelle, skills, marketplace, bekannt } = await anwendungsfaelle.quelleHinzufuegen(genannt);
187
+ // Dieselbe Quelle in anderer Schreibweise legt keine zweite an (E36).
188
+ if (bekannt)
189
+ console.log(quelleAlsText(quelle) === quelleAlsText(genannt)
190
+ ? 'Quelle ist schon bekannt, keine zweite angelegt.'
191
+ : `Quelle ist schon bekannt, geführt als ${quelleAlsText(quelle)}. Keine zweite angelegt.`);
117
192
  console.log(`Quelle ${quelleAlsText(quelle)} (${quelle.art}), ${skills.length} Skills:`);
118
193
  for (const skill of skills)
119
194
  console.log(` ${skill.name} ${skill.pfad}`);
@@ -137,7 +212,9 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
137
212
  throw new Error(`${unterkommando === undefined ? 'source braucht ein Unterkommando' : `Unbekanntes Unterkommando „${unterkommando}" für source`}: source add <url|pfad> oder source list. hmgr help source zeigt Beispiele.`);
138
213
  }
139
214
  if (kommando === 'dir') {
140
- const { rest, werte } = schalterLesen(argv.slice(1), 'dir');
215
+ const schalter = schalterLesen(argv.slice(1), 'dir');
216
+ unterkommandoPruefen('dir', schalter);
217
+ const { rest, werte } = schalter;
141
218
  const unterkommando = rest[0];
142
219
  if (unterkommando === 'add') {
143
220
  const pfad = rest[1];
@@ -176,6 +253,7 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
176
253
  }
177
254
  if (kommando === 'link' || kommando === 'unlink') {
178
255
  const { rest, werte, flaggen } = schalterLesen(argv.slice(1), kommando);
256
+ restPruefen(rest, 1, kommando);
179
257
  const skill = rest[0];
180
258
  if (skill === undefined)
181
259
  throw new Error(`${kommando} braucht einen Skill-Namen, etwa hmgr ${kommando} <skill>. ` +
@@ -213,6 +291,7 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
213
291
  }
214
292
  if (kommando === 'adopt') {
215
293
  const { rest, werte, flaggen } = schalterLesen(argv.slice(1), 'adopt');
294
+ restPruefen(rest, 1, 'adopt');
216
295
  const ziel = { harness: werte.get('harness') ?? 'claude-code', ort: ortLesen(werte.get('ort')) };
217
296
  const probelauf = flaggen.has('probelauf');
218
297
  const name = rest[0];
@@ -251,7 +330,9 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
251
330
  return 0;
252
331
  }
253
332
  if (kommando === 'plugin') {
254
- const { rest, werte, flaggen } = schalterLesen(argv.slice(1), 'plugin');
333
+ const schalter = schalterLesen(argv.slice(1), 'plugin');
334
+ unterkommandoPruefen('plugin', schalter);
335
+ const { rest, werte, flaggen } = schalter;
255
336
  const unterkommando = rest[0];
256
337
  if (unterkommando === 'list') {
257
338
  const marketplaces = await anwendungsfaelle.marketplaces();
@@ -301,6 +382,7 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
301
382
  }
302
383
  if (kommando === 'sync') {
303
384
  const { rest } = schalterLesen(argv.slice(1), 'sync');
385
+ restPruefen(rest, 1, 'sync');
304
386
  const ergebnis = await anwendungsfaelle.abgleich(rest[0]);
305
387
  if (ergebnis.zeigerPfad !== undefined)
306
388
  console.log(`Zeiger ${ergebnis.zeigerPfad}`);
@@ -317,7 +399,8 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
317
399
  return 0;
318
400
  }
319
401
  if (kommando === 'update') {
320
- const { checkouts: liste, pluginHarnesses } = await anwendungsfaelle.aktualisieren();
402
+ restPruefen(schalterLesen(argv.slice(1), 'update').rest, 0, 'update');
403
+ const { checkouts: liste, pluginHarnesses, aufraeumen } = await anwendungsfaelle.aktualisieren();
321
404
  if (liste.length === 0)
322
405
  console.log('Keine Checkouts.');
323
406
  for (const { checkout, aktualisierung } of liste) {
@@ -331,9 +414,24 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
331
414
  }
332
415
  for (const name of pluginHarnesses)
333
416
  console.log(` Plugins ${name} aktualisiert (E14)`);
417
+ // Zum Schluss die verwaisten Checkouts: was gelöscht wurde und was liegen bleibt.
418
+ for (const verwaist of aufraeumen.verwaiste) {
419
+ const kopf = `${quelleAlsText(verwaist.checkout.quelle)} @ ${verwaist.checkout.ref}`;
420
+ if (verwaist.geloescht)
421
+ console.log(` gelöscht ${kopf} verwaist (${verwaist.checkout.pfad})`);
422
+ else if (verwaist.lokaleAenderung)
423
+ console.log(` liegen lassen ${kopf} verwaist, aber lokale Änderung (E15)`);
424
+ else
425
+ console.log(` liegen lassen ${kopf} verwaist, ohne Bestätigung nicht gelöscht`);
426
+ }
427
+ if (aufraeumen.verwaiste.length > 0) {
428
+ const geloescht = aufraeumen.verwaiste.filter((v) => v.geloescht).length;
429
+ console.log(`${aufraeumen.verwaiste.length} verwaist, ${geloescht} gelöscht, ${aufraeumen.verwaiste.length - geloescht} liegen geblieben.`);
430
+ }
334
431
  return 0;
335
432
  }
336
433
  if (kommando === 'status') {
434
+ restPruefen(schalterLesen(argv.slice(1), 'status').rest, 0, 'status');
337
435
  for (const zielZustand of await anwendungsfaelle.istZustand()) {
338
436
  console.log(`Ziel ${zielZustand.ziel.harness} ${beschreibeOrt(zielZustand.ziel.ort)} (${zielZustand.verzeichnis})`);
339
437
  if (zielZustand.eintraege.length === 0)
@@ -398,7 +496,7 @@ function naechsterSchritt(fehler) {
398
496
  if (fehler instanceof AbhaengigkeitFehlt)
399
497
  return 'Die Abhängigkeit steht in keinem Plugin desselben Marketplace, das kann nur die Quelle beheben.';
400
498
  if (fehler instanceof KeinPassenderRef)
401
- return 'hmgr update holt neue Tags der Quelle. Sonst einen vorhandenen Tag- oder Branchnamen als --version nennen.';
499
+ return 'Die Tags der Quelle sind frisch geholt, keiner passt. Sonst einen vorhandenen Tag- oder Branchnamen als --version nennen.';
402
500
  if (fehler instanceof HarnessNichtVorhanden)
403
501
  return 'Der Harness legt sein Verzeichnis beim ersten Start an. hmgr status zeigt, welche Harnesses hier liegen.';
404
502
  if (fehler instanceof OrtNichtUnterstuetzt)
@@ -1,18 +1,41 @@
1
1
  // Anwendungsfälle. Pfade werden hier nur als Zeichenketten normalisiert, gelesen wird über die Ports.
2
2
  import path from 'node:path';
3
- import { AbhaengigkeitFehlt, EinschraenkungOhneZeiger, HarnessNichtVorhanden, KeinPassenderRef, OrtNichtUnterstuetzt, PluginMehrdeutig, PluginUnbekannt, SkillMehrdeutig, SkillUnbekannt, ZeigerMehrdeutig, marketplaceUrl, pluginSchluessel, pluginSchluesselZerlegen, quelleSchluessel, zielSchluessel, } from "./modell.js";
3
+ import { AbhaengigkeitFehlt, EinschraenkungOhneZeiger, HarnessNichtVorhanden, KeinPassenderRef, OrtNichtUnterstuetzt, PluginMehrdeutig, PluginUnbekannt, SkillMehrdeutig, SkillUnbekannt, ZeigerMehrdeutig, marketplaceUrl, pluginSchluessel, pluginSchluesselZerlegen, quelleAlsText, quelleSchluessel, zielSchluessel, } from "./modell.js";
4
4
  import { passenderRef } from "./version.js";
5
5
  import { kannPlugins, kannProfile } from "../ports/harness.js";
6
+ /**
7
+ * Frage vor dem Löschen, eine für alle Kandidaten (E28). Der Kern formuliert sie fertig, der
8
+ * Adapter stellt sie: Anzahl und Pfade, damit der Nutzer sieht, worüber er entscheidet.
9
+ */
10
+ export function aufraeumFrage(checkouts) {
11
+ const kopf = checkouts.length === 1
12
+ ? 'Ein Checkout ist verwaist, kein Link zeigt hinein:'
13
+ : `${checkouts.length} Checkouts sind verwaist, kein Link zeigt hinein:`;
14
+ const pfade = checkouts.map((c) => ` ${c.pfad}`).join('\n');
15
+ return `${kopf}\n${pfade}\nLöschen? [j/N]`;
16
+ }
6
17
  function arbeitsverzeichnisZiel(harness, pfad) {
7
18
  return { harness, ort: { art: 'arbeitsverzeichnis', pfad } };
8
19
  }
9
20
  function fehlertext(fehler) {
10
21
  return fehler instanceof Error ? fehler.message : String(fehler);
11
22
  }
23
+ /**
24
+ * Quellenhinweis bei Mehrdeutigkeit (E12): Teilstring des Schlüssels oder der Schreibweise. Beides
25
+ * zählt, damit `host/besitzer/repo` genauso greift wie ein Stück der URL, unter der die Quelle
26
+ * geführt wird (E36).
27
+ */
28
+ function quellePasst(quelle, hinweis) {
29
+ if (hinweis === undefined)
30
+ return true;
31
+ return quelleSchluessel(quelle).includes(hinweis) || quelleAlsText(quelle).includes(hinweis);
32
+ }
12
33
  export class Anwendungsfaelle {
13
34
  p;
14
35
  /** Schon beantwortete Orte, je Ziel der Hinweis, der davon übrig blieb (E34). */
15
36
  beantwortet = new Map();
37
+ /** Quellen, deren Refs in diesem Lauf schon nachgezogen wurden (E33). */
38
+ nachgezogen = new Set();
16
39
  constructor(p) {
17
40
  this.p = p;
18
41
  }
@@ -45,18 +68,25 @@ export class Anwendungsfaelle {
45
68
  ergebnis.push(harness.name);
46
69
  return ergebnis;
47
70
  }
48
- /** Quelle hinzufügen: prüft, dass sie erreichbar ist, und liest Skills und Marketplace (E24). */
71
+ /**
72
+ * Quelle hinzufügen: prüft, dass sie erreichbar ist, und liest Skills und Marketplace (E24).
73
+ * Steht dieselbe Quelle schon in einer anderen Schreibweise im Zustand, kommt keine zweite dazu
74
+ * (E36). Gelesen wird dann unter der Schreibweise, unter der sie geführt wird, denn mit ihr ist
75
+ * der Basis-Clone angelegt. `bekannt` sagt, ob es so kam, `quelle` nennt die geführte Fassung.
76
+ */
49
77
  async quelleHinzufuegen(quelle) {
50
- const ref = await this.p.quellen.standardRef(quelle);
51
- const skills = await this.p.quellen.skills(quelle, ref);
52
- const marketplace = await this.p.quellen.marketplace(quelle, ref);
53
78
  const zustand = await this.p.zustand.lesen();
54
79
  const key = quelleSchluessel(quelle);
55
- if (!zustand.quellen.some((q) => quelleSchluessel(q) === key)) {
80
+ const gefuehrt = zustand.quellen.find((q) => quelleSchluessel(q) === key);
81
+ const wirksam = gefuehrt ?? quelle;
82
+ const ref = await this.p.quellen.standardRef(wirksam);
83
+ const skills = await this.p.quellen.skills(wirksam, ref);
84
+ const marketplace = await this.p.quellen.marketplace(wirksam, ref);
85
+ if (gefuehrt === undefined) {
56
86
  zustand.quellen.push(quelle);
57
87
  await this.p.zustand.schreiben(zustand);
58
88
  }
59
- return { skills, marketplace };
89
+ return { quelle: wirksam, skills, marketplace, bekannt: gefuehrt !== undefined };
60
90
  }
61
91
  /** Marketplace einer Quelle am Standard-Ref, mit den Plugins als Modellwerte. */
62
92
  async marketplaceLesen(quelle) {
@@ -95,7 +125,7 @@ export class Anwendungsfaelle {
95
125
  const zustand = await this.p.zustand.lesen();
96
126
  const funde = [];
97
127
  for (const quelle of zustand.quellen) {
98
- if (quelleHinweis && !quelleSchluessel(quelle).includes(quelleHinweis))
128
+ if (!quellePasst(quelle, quelleHinweis))
99
129
  continue;
100
130
  const gelesen = await this.marketplaceLesen(quelle).catch(() => null);
101
131
  if (gelesen === null)
@@ -168,7 +198,7 @@ export class Anwendungsfaelle {
168
198
  if (fund.ergebnis === 'gefunden')
169
199
  zeigerPfad = fund.pfad;
170
200
  else if (fund.ergebnis === 'mehrdeutig')
171
- throw new ZeigerMehrdeutig(verzeichnis, fund.kandidaten);
201
+ zeigerPfad = await this.zeigerWaehlen(verzeichnis, fund.kandidaten);
172
202
  else
173
203
  hinweis = `Kein Zeiger unter ${verzeichnis}, alles bleibt lokal (E26).`;
174
204
  }
@@ -201,6 +231,20 @@ export class Anwendungsfaelle {
201
231
  zustand.bindungen = zustand.bindungen.filter((b) => !(b.ziel.ort.art === 'arbeitsverzeichnis' && b.ziel.ort.pfad === verzeichnis));
202
232
  await this.p.zustand.schreiben(zustand);
203
233
  }
234
+ /**
235
+ * Mehrere composer.json unter einem Verzeichnis (E26): hmgr rät nicht, es fragt. Bleibt die Frage
236
+ * ohne Antwort, weil niemand antworten kann oder die Eingabe keine der Kandidaten trifft, gilt
237
+ * wie bisher der Fehler mit dem Weg über --composer. Der Fragetext kommt von hier, der Port kennt
238
+ * kein Terminal.
239
+ */
240
+ async zeigerWaehlen(verzeichnis, kandidaten) {
241
+ const frage = `Mehrere composer.json unter ${verzeichnis}. Welche ist der Zeiger?`;
242
+ const gewaehlt = await this.p.rueckfrage.auswaehlen(frage, kandidaten);
243
+ const pfad = gewaehlt === undefined ? undefined : kandidaten[gewaehlt];
244
+ if (pfad === undefined)
245
+ throw new ZeigerMehrdeutig(verzeichnis, kandidaten);
246
+ return pfad;
247
+ }
204
248
  /** Zeiger eines Arbeitsverzeichnisses aus dem Zustand, egal für welchen Harness (E26). */
205
249
  async bindungFuerVerzeichnis(verzeichnis) {
206
250
  const zustand = await this.p.zustand.lesen();
@@ -217,7 +261,7 @@ export class Anwendungsfaelle {
217
261
  const zustand = await this.p.zustand.lesen();
218
262
  const funde = [];
219
263
  for (const quelle of zustand.quellen) {
220
- if (quelleHinweis && !quelleSchluessel(quelle).includes(quelleHinweis))
264
+ if (!quellePasst(quelle, quelleHinweis))
221
265
  continue;
222
266
  const ref = await this.p.quellen.standardRef(quelle);
223
267
  const skills = await this.p.quellen.skills(quelle, ref);
@@ -457,6 +501,19 @@ export class Anwendungsfaelle {
457
501
  await this.p.zeiger.schreiben(zeigerPfad, zeiger);
458
502
  return { ...ergebnis, imZeiger: true };
459
503
  }
504
+ /**
505
+ * Refs einer Quelle nachziehen, bevor eine Einschränkung gegen sie ausgewertet wird (E33). Ohne
506
+ * das sähe die Auswertung nur die Tags, die beim Clone schon da waren, ein später entstandener
507
+ * Tag bliebe unsichtbar. Einmal je Lauf und Quelle. Scheitert das Nachziehen, etwa ohne Netz,
508
+ * wird mit dem vorhandenen Stand weitergearbeitet.
509
+ */
510
+ async refsNachziehen(quelle) {
511
+ const schluessel = quelleSchluessel(quelle);
512
+ if (this.nachgezogen.has(schluessel))
513
+ return;
514
+ this.nachgezogen.add(schluessel);
515
+ await this.p.quellen.refsNachziehen(quelle).catch(() => undefined);
516
+ }
460
517
  /**
461
518
  * Fund zu einem Skill aus dem Zeiger (E33). Die Einschränkung wird zuerst nach Composer-Regeln
462
519
  * gegen die Tags der Quellen ausgewertet, der höchste passende Tag gewinnt. Findet sich keiner,
@@ -467,8 +524,9 @@ export class Anwendungsfaelle {
467
524
  if (einschraenkung === null)
468
525
  return await this.skillFinden(name, quelleHinweis);
469
526
  const zustand = await this.p.zustand.lesen();
470
- const quellen = zustand.quellen.filter((q) => quelleHinweis === undefined || quelleSchluessel(q).includes(quelleHinweis));
527
+ const quellen = zustand.quellen.filter((q) => quellePasst(q, quelleHinweis));
471
528
  for (const quelle of quellen) {
529
+ await this.refsNachziehen(quelle);
472
530
  const tags = await this.p.quellen.tags(quelle).catch(() => []);
473
531
  const ref = passenderRef(tags, einschraenkung);
474
532
  if (ref === null)
@@ -502,7 +560,7 @@ export class Anwendungsfaelle {
502
560
  if (fund.ergebnis === 'gefunden')
503
561
  zeigerPfad = fund.pfad;
504
562
  else if (fund.ergebnis === 'mehrdeutig')
505
- throw new ZeigerMehrdeutig(verzeichnis, fund.kandidaten);
563
+ zeigerPfad = await this.zeigerWaehlen(verzeichnis, fund.kandidaten);
506
564
  }
507
565
  if (zeigerPfad === undefined) {
508
566
  ergebnis.hinweise.push(`Kein Zeiger unter ${verzeichnis}, nichts zu tun.`);
@@ -520,7 +578,7 @@ export class Anwendungsfaelle {
520
578
  if (zustand.quellen.some((q) => quelleSchluessel(q) === key))
521
579
  continue;
522
580
  zustand.quellen.push(quelle);
523
- ergebnis.gelegt.push(`Quelle ${key}`);
581
+ ergebnis.gelegt.push(`Quelle ${quelleAlsText(quelle)}`);
524
582
  }
525
583
  if (!zustand.arbeitsverzeichnisse.includes(verzeichnis))
526
584
  zustand.arbeitsverzeichnisse.push(verzeichnis);
@@ -615,7 +673,51 @@ export class Anwendungsfaelle {
615
673
  await harness.plugins.aktualisieren();
616
674
  pluginHarnesses.push(harness.name);
617
675
  }
618
- return { checkouts, pluginHarnesses };
676
+ return { checkouts, pluginHarnesses, aufraeumen: await this.verwaisteAufraeumen(checkouts) };
677
+ }
678
+ /**
679
+ * Pfade, in die ein Link zeigt. Gelesen wird wie beim Ist-Zustand (E19): die Ziele der
680
+ * vorhandenen Harnesses, die Arbeitsverzeichnisse aus dem Zustand. Ein Link, dessen Quelle nicht
681
+ * im Zustand steht, zeigt trotzdem in einen Checkout und hält ihn.
682
+ */
683
+ async belegteCheckoutPfade() {
684
+ const pfade = new Set();
685
+ for (const zielZustand of await this.istZustand())
686
+ for (const eintrag of zielZustand.eintraege) {
687
+ if (eintrag.art === 'link')
688
+ pfade.add(path.resolve(eintrag.link.checkout.pfad));
689
+ else if (eintrag.art === 'link-ohne-quelle')
690
+ pfade.add(path.resolve(eintrag.checkoutPfad));
691
+ }
692
+ return pfade;
693
+ }
694
+ /**
695
+ * Verwaiste Ref-Checkouts aufräumen. Verwaist heißt: kein Link in irgendeinem Ziel zeigt hinein.
696
+ * Der Basis-Clone je Repo ist nie dabei, er steht in keiner Checkout-Liste (E22). Ein Checkout
697
+ * mit lokaler Änderung bleibt liegen und wird nur gemeldet (E15), er kommt gar nicht erst in die
698
+ * Frage. Gefragt wird einmal für alle Kandidaten (E28); wo niemand antwortet, gilt Nein und es
699
+ * wird nichts gelöscht.
700
+ */
701
+ async verwaisteAufraeumen(checkouts) {
702
+ const belegt = await this.belegteCheckoutPfade();
703
+ const verwaiste = [];
704
+ for (const { checkout, aktualisierung } of checkouts) {
705
+ if (belegt.has(path.resolve(checkout.pfad)))
706
+ continue;
707
+ // Übersprungen wird genau der Checkout mit lokaler Änderung, ein zweites Nachfragen erübrigt sich.
708
+ const lokaleAenderung = aktualisierung.ergebnis === 'uebersprungen';
709
+ verwaiste.push({ checkout, lokaleAenderung, geloescht: false });
710
+ }
711
+ const kandidaten = verwaiste.filter((v) => !v.lokaleAenderung);
712
+ if (kandidaten.length === 0)
713
+ return { verwaiste, gefragt: false };
714
+ if (!(await this.p.rueckfrage.bestaetigen(aufraeumFrage(kandidaten.map((k) => k.checkout)))))
715
+ return { verwaiste, gefragt: true };
716
+ for (const kandidat of kandidaten) {
717
+ await this.p.quellen.entfernen(kandidat.checkout);
718
+ kandidat.geloescht = true;
719
+ }
720
+ return { verwaiste, gefragt: true };
619
721
  }
620
722
  /**
621
723
  * Übernahme (B12, E31): fremde Symlinks eines Ziels durch eigene Links auf einen hmgr-Checkout
@@ -633,7 +735,10 @@ export class Anwendungsfaelle {
633
735
  throw new OrtNichtUnterstuetzt(harness.name, args.ziel.ort.art);
634
736
  const probelauf = args.probelauf === true;
635
737
  const verzeichnis = harness.zielVerzeichnis(args.ziel.ort);
636
- const bekannt = new Set((await this.p.zustand.lesen()).quellen.map(quelleSchluessel));
738
+ // Je Identität die Quelle, unter deren Schreibweise sie geführt wird (E36).
739
+ const bekannt = new Map();
740
+ for (const q of (await this.p.zustand.lesen()).quellen)
741
+ bekannt.set(quelleSchluessel(q), q);
637
742
  const eintraege = [];
638
743
  for (const eintrag of await this.p.ziele.eintraege(args.ziel, verzeichnis)) {
639
744
  // Eigene Links sind schon fertig, sie kommen nicht in die Meldung.
@@ -661,7 +766,9 @@ export class Anwendungsfaelle {
661
766
  liegenLassen('kein-branch');
662
767
  continue;
663
768
  }
664
- const quelle = { art: 'git', url: kopie.url };
769
+ // Kennt hmgr das Repo schon in anderer Schreibweise, zählt die geführte Fassung (E36).
770
+ const gefuehrt = bekannt.get(quelleSchluessel({ art: 'git', url: kopie.url }));
771
+ const quelle = gefuehrt ?? { art: 'git', url: kopie.url };
665
772
  const ref = kopie.ref;
666
773
  const skills = await this.p.quellen.skills(quelle, ref);
667
774
  const skill = skills.find((s) => s.pfad === kopie.relativerPfad);
@@ -669,8 +776,8 @@ export class Anwendungsfaelle {
669
776
  liegenLassen('skill-unbekannt');
670
777
  continue;
671
778
  }
672
- const quelleNeu = !bekannt.has(quelleSchluessel(quelle));
673
- bekannt.add(quelleSchluessel(quelle));
779
+ const quelleNeu = gefuehrt === undefined;
780
+ bekannt.set(quelleSchluessel(quelle), quelle);
674
781
  if (!probelauf) {
675
782
  if (quelleNeu)
676
783
  await this.quelleHinzufuegen(quelle);
@@ -1,4 +1,5 @@
1
1
  // Begriffe aus CONTEXT.md. Nur Werte und Regeln, keine Pfade zu Dateien außerhalb des Modells.
2
+ import { ortSchluessel, urlOrt } from "./quelle-ort.js";
2
3
  /** Ansprache eines Plugins im Zeiger und gegenüber der claude-CLI: `<plugin>@<marketplace>`. */
3
4
  export function pluginSchluessel(plugin) {
4
5
  return `${plugin.name}@${plugin.marketplace}`;
@@ -45,8 +46,22 @@ export function zielSchluessel(ziel) {
45
46
  const ort = o.art === 'home' ? 'home' : o.art === 'profil' ? `profil:${o.name}` : `dir:${o.pfad}`;
46
47
  return `${ziel.harness}/${ort}`;
47
48
  }
49
+ /** Wie eine Quelle geschrieben steht: ihre URL oder ihr Pfad. Für Ausgaben, nicht für Vergleiche. */
50
+ export function quelleAlsText(quelle) {
51
+ return quelle.art === 'git' ? quelle.url : quelle.pfad;
52
+ }
53
+ /**
54
+ * Identität einer Quelle (E36): bei einer Git-Quelle Host, Besitzer und Repo, nicht die
55
+ * Schreibweise der URL. SSH- und HTTPS-Form desselben Repos sind dieselbe Quelle, mit und ohne
56
+ * `.git`-Suffix ebenso, und der Host zählt ohne Rücksicht auf Groß- und Kleinschreibung. Ein Text,
57
+ * der sich nicht als URL lesen lässt, bleibt sein eigener Schlüssel. Lokale Quellen bleiben ihr
58
+ * Pfad (B11).
59
+ */
48
60
  export function quelleSchluessel(quelle) {
49
- return quelle.art === 'git' ? quelle.url : `lokal:${quelle.pfad}`;
61
+ if (quelle.art === 'lokal')
62
+ return `lokal:${quelle.pfad}`;
63
+ const ort = urlOrt(quelle.url);
64
+ return ort === undefined ? quelle.url : ortSchluessel(ort);
50
65
  }
51
66
  export class NameBelegt extends Error {
52
67
  ziel;
@@ -0,0 +1,42 @@
1
+ // Identität einer Git-Quelle (E36): Host, Besitzer und Repo, nicht die Schreibweise der URL.
2
+ function ohneGitSuffix(name) {
3
+ return name.endsWith('.git') ? name.slice(0, -4) : name;
4
+ }
5
+ /**
6
+ * Die letzten beiden nicht leeren Segmente sind Besitzer und Repo. Der Host wird kleingeschrieben,
7
+ * Hostnamen sind case-insensitiv; der Pfadanteil bleibt, wie er geschrieben steht (E36).
8
+ */
9
+ export function ortAusSegmenten(host, segmente) {
10
+ const gefiltert = segmente.filter((s) => s.length > 0);
11
+ const repo = ohneGitSuffix(gefiltert[gefiltert.length - 1] ?? 'repo');
12
+ const besitzer = gefiltert[gefiltert.length - 2] ?? '_';
13
+ return { host: host.toLowerCase(), besitzer, repo };
14
+ }
15
+ /**
16
+ * Zerlegt eine Git-URL: `git@host:besitzer/repo.git`, `ssh://git@host/besitzer/repo`,
17
+ * `https://host/besitzer/repo.git`, `file:///pfad`. Ein Text, der keine URL ist, gibt undefined.
18
+ */
19
+ export function urlOrt(url) {
20
+ const text = url.trim().replace(/\/+$/, '');
21
+ if (text.includes('://')) {
22
+ let zerlegt;
23
+ try {
24
+ zerlegt = new URL(text);
25
+ }
26
+ catch {
27
+ return undefined;
28
+ }
29
+ const segmente = decodeURIComponent(zerlegt.pathname).split('/');
30
+ // file: hat keinen Host, alle lokalen Quellen liegen unter „local".
31
+ if (zerlegt.protocol === 'file:')
32
+ return ortAusSegmenten('local', segmente);
33
+ return ortAusSegmenten(zerlegt.hostname, segmente);
34
+ }
35
+ const scp = /^(?:[^@/]+@)?([^@/:]+):(.+)$/.exec(text);
36
+ if (scp)
37
+ return ortAusSegmenten(scp[1], scp[2].split('/'));
38
+ return undefined;
39
+ }
40
+ export function ortSchluessel(ort) {
41
+ return `${ort.host}/${ort.besitzer}/${ort.repo}`;
42
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hmgr",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "description": "Harness Manager: Skills und Plugins für AI-Harnesses verwalten",
5
5
  "license": "MIT",
6
6
  "author": "Tim Kaufmann",