initial commit
This commit is contained in:
commit
b42f9261a5
257 changed files with 39080 additions and 0 deletions
54
.gitignore
vendored
Normal file
54
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# Hugin
|
||||
#
|
||||
# Nicht ignorieren: .claude/, .opencode/ und .hugin/ in contract/examples/bundles/*/files/
|
||||
# (goldene Bundles) und in static/css-framework/ gehören ins Repo. COMPATIBILITY.md
|
||||
# ebenfalls (Ergebnis von scripts/tool-smoke-test.sh).
|
||||
|
||||
# Betriebssystem und Editoren
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*~
|
||||
|
||||
# Lokale Einstellungen und Geheimnisse
|
||||
.env
|
||||
.env.*
|
||||
.claude/settings.local.json
|
||||
/.claude/
|
||||
/.opencode/
|
||||
/CLAUDE.local.md
|
||||
|
||||
# Python (server/)
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
.coverage
|
||||
.coverage.*
|
||||
htmlcov/
|
||||
|
||||
# Node; OpenCode legt .opencode/node_modules/ selbst an
|
||||
node_modules/
|
||||
|
||||
# Go (cli/); dist/ kommt von scripts/build-cli.sh und go build -o dist/…
|
||||
/cli/dist/
|
||||
*.test
|
||||
*.out
|
||||
|
||||
# Lokale Datenbanken und Logs (z. B. HUGIN_DATABASE_URL=sqlite:///hugin.db)
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.db-journal
|
||||
*.log
|
||||
|
||||
# Demo-Daten mit Keys (scripts/demo.sh)
|
||||
.demo/
|
||||
|
||||
# Bildschirmfotos der CSP-Prüfung (scripts/csp/web_browser.py <ordner>)
|
||||
/screenshots/
|
||||
95
BETRIEB.md
Normal file
95
BETRIEB.md
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
# Hugin betreiben
|
||||
|
||||
Für den Betrieb: Container-Image, Datenbank, Sicherung. Deployment und Ingress liegen im
|
||||
Template des Betriebs. Hintergrund in [KONZEPT.md](KONZEPT.md) Abschnitt 18.
|
||||
|
||||
## Konfiguration
|
||||
|
||||
Nur über Umgebungsvariablen:
|
||||
|
||||
| Umgebungsvariable | Bedeutung |
|
||||
|---|---|
|
||||
| `HUGIN_DATABASE_URL` | SQLite oder PostgreSQL |
|
||||
| `HUGIN_ADMIN_TOKEN` | Plattform-Admin |
|
||||
| `HUGIN_BASE_URL` | steht im Kopf verwalteter Dateien |
|
||||
| `HUGIN_DOWNLOAD_DIR` | gebaute CLI-Programme für die Seite „Programm hugin“ |
|
||||
| `HUGIN_CLI_MIN_VERSION` | ältere CLIs meldet `hugin check` |
|
||||
| `HUGIN_RATE_LIMIT` | Anfragen je Key und Minute (Standard 600) |
|
||||
| `HUGIN_FAILED_ATTEMPTS` | falsche Keys je IP in 5 Minuten, danach 429 (Standard 20) |
|
||||
| `HUGIN_PROXY_IPS` | Router, deren `X-Forwarded-For` gilt (Standard `127.0.0.1`) |
|
||||
| `HUGIN_MAX_UPLOAD_MB` | größter Request-Body (Standard 40) |
|
||||
|
||||
**Hinter dem OpenShift-Router** `HUGIN_PROXY_IPS` auf das Netz des Routers
|
||||
setzen (z. B. `10.128.0.0/14`). Sonst sehen Sperre und Audit nur die Adresse des
|
||||
Routers, und wenige falsche Keys sperren alle Nutzer für fünf Minuten.
|
||||
|
||||
## Container-Image
|
||||
|
||||
Im Hugin-Repo liegt `server/Dockerfile`: Red Hat UBI 9 minimal mit Python 3.12, die
|
||||
Abhängigkeiten fest aus `uv.lock`. Das Image läuft als beliebiger Benutzer der Gruppe 0,
|
||||
wie OpenShift es vorgibt, und auch mit schreibgeschütztem Dateisystem.
|
||||
|
||||
```sh
|
||||
docker build -t hugin server/
|
||||
docker build --build-arg UBI=<spiegel>/ubi9/ubi-minimal:9.7 -t hugin server/ # eigener Spiegel
|
||||
```
|
||||
|
||||
| Was | Wie |
|
||||
|---|---|
|
||||
| Server starten | ohne Argument (Standard `run`), Port 8080 |
|
||||
| Migrationen | dasselbe Image mit Argument `migrate`, als Job vor jedem Rollout |
|
||||
| Liveness-Probe | `GET /healthz` |
|
||||
| Readiness-Probe | `GET /readyz`: Datenbank erreichbar und Migrationen aktuell; ohne `migrate` wird der Pod nicht bereit |
|
||||
| Konfiguration | Umgebungsvariablen (oben); `HUGIN_ADMIN_TOKEN` als Secret |
|
||||
| Logs | stdout |
|
||||
|
||||
## Datenbank: SQLite oder PostgreSQL
|
||||
|
||||
Beides funktioniert gleich; der Wechsel ist nur `HUGIN_DATABASE_URL`.
|
||||
|
||||
| | SQLite | PostgreSQL |
|
||||
|---|---|---|
|
||||
| `HUGIN_DATABASE_URL` | nicht setzen: das Image nutzt `/data/hugin.db` | `postgresql+psycopg://user:pw@host:5432/hugin` |
|
||||
| Volume | PVC auf `/data`, auch im Migrations-Job | keins |
|
||||
| Pods | genau einer, Strategie `Recreate` | beliebig viele |
|
||||
| Beim Ausrollen | kurz nicht erreichbar | ohne Unterbrechung |
|
||||
| Sicherung | selbst: z. B. CronJob mit `sqlite3 /data/hugin.db ".backup /sicherung/hugin.db"`; die Datei nicht einfach kopieren, solange Hugin schreibt | übernimmt der Datenbankbetrieb |
|
||||
| Gut für | Pilot, wenige Teams | breiter Einsatz, Hochverfügbarkeit |
|
||||
|
||||
Fällt Hugin aus, laufen alle Agenten weiter, denn die Dateien liegen in den Repos; nur
|
||||
`hugin sync` geht dann nicht. Kurze Unterbrechungen beim Ausrollen sind deshalb
|
||||
unkritisch.
|
||||
|
||||
Von SQLite nach PostgreSQL umziehen: Dafür gibt es noch kein Werkzeug. Der Export
|
||||
enthält nur freigegebene Pakete, keine Keys, Personen oder Audit-Einträge.
|
||||
|
||||
Mehrere Pods: Das Rate-Limit zählt je Pod; mit drei Pods gelten also bis zu dreimal so
|
||||
viele Anfragen und Fehlversuche.
|
||||
|
||||
## Export und CLI-Downloads
|
||||
|
||||
- **Export:** `hugin-server export <ordner> --commit` schreibt alle freigegebenen Pakete
|
||||
im Quellformat in einen Git-Ordner, z. B. als täglicher CronJob. Ergibt eine lesbare
|
||||
Historie und eine Notfall-Verteilung. Dasselbe als ZIP auf der Systemseite.
|
||||
`--commit` braucht `git`, das im Image fehlt; für den Betrieb ist es nicht nötig, die
|
||||
Datenbank-Sicherung enthält alles.
|
||||
- **CLI-Downloads:** `scripts/build-cli.sh <version> <ordner>` baut das Programm `hugin`
|
||||
für macOS, Linux und Windows mit `SHA256SUMS`. Den Ordner einhängen und
|
||||
`HUGIN_DOWNLOAD_DIR` darauf setzen; ohne ihn zeigt die Seite „Programm hugin“
|
||||
„Noch keine Programme hinterlegt“.
|
||||
|
||||
## Clients mit lokalen MCP-Servern
|
||||
|
||||
Pakete mit lokalem MCP-Server (Python) installiert `hugin sync` auf jedem Rechner. Der
|
||||
Server braucht dafür nichts Zusätzliches (Upload bis `HUGIN_MAX_UPLOAD_MB`, Pakete bis
|
||||
32 MB). Auf den Rechnern (Windows, Linux) muss bereitstehen:
|
||||
|
||||
| Was | Wozu |
|
||||
|---|---|
|
||||
| `hugin` im `PATH` | Werkzeuge starten den Server über `hugin mcp run`; ein Alias reicht nicht |
|
||||
| Python 3 (Windows: mit dem Starter `py`) | venv je Server; abweichend mit `HUGIN_PYTHON` |
|
||||
| `pip.conf` (Linux `~/.config/pip/pip.conf` oder `/etc/pip.conf`, Windows `%APPDATA%\pip\pip.ini`) mit dem Artifactory als `index-url` | pip lädt die Abhängigkeiten daraus, ohne Anmeldung |
|
||||
| Schreibrecht auf `~/.local/share/mcp` (Windows `%USERPROFILE%\.local\share\mcp`) | Ablage; abweichend mit `HUGIN_MCP_HOME` |
|
||||
|
||||
Speicherplatz: je Server und Version eine venv (mit dem MCP-SDK etwa 50 MB). Alte
|
||||
Versionen löscht `hugin mcp prune`.
|
||||
37
COMPATIBILITY.md
Normal file
37
COMPATIBILITY.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Kompatibilität
|
||||
|
||||
Erzeugt von `scripts/tool-smoke-test.sh` am 25.10.2026 (→ KONZEPT.md 17.2).
|
||||
Modelle: Claude Code `haiku`, OpenCode `Standard`. Installiert mit `hugin sync`.
|
||||
|
||||
## Rauchtests
|
||||
|
||||
| Paket | Prüfung | Claude Code 2.1.307 | OpenCode 1.19.4 |
|
||||
|---|---|---|---|
|
||||
| `pub-smoke-skill` | Skill wird gefunden | ✓ | ✓ |
|
||||
| `pub-smoke-skill` | Skill wird geladen | ✓ | ✓ |
|
||||
| `pub-smoke-rules` | Regeln wirken | ✓ | ✓ |
|
||||
| `pub-smoke-command` | Befehl mit zwei Argumenten | ✓ | ✓ |
|
||||
| `pub-smoke-agent` | Subagent vorhanden | ✓ | ✓ |
|
||||
| `pub-smoke-agent` | Subagent wirkt | ✓ | ✓ |
|
||||
| `pub-smoke-mcp` | MCP eingetragen | ✓ | ✓ |
|
||||
| `pub-smoke-mcp` | MCP verbunden | ✓ | ✓ |
|
||||
| `pub-zeit-lokal` | Lokaler Server eingetragen | ✓ | – |
|
||||
| `pub-zeit-lokal` | Lokaler Server verbunden | – | ✓ |
|
||||
| `pub-zeit-lokal` | Lokaler Server antwortet | ✓ | ✓ |
|
||||
| `pub-specht-plugin` | Plugin geladen | – | ✓ |
|
||||
| `pub-specht-plugin` | Plugin wirkt | – | ✓ |
|
||||
| `pub-smoke-command` | Installieren auf Bitte | ✓ | ✓ |
|
||||
| `pub-smoke-command` | Ohne hugin nichts schreiben | ✓ | ✓ |
|
||||
|
||||
## Stufe 0
|
||||
|
||||
| Frage | Aufbau | erkannte Wörter | Folgerung |
|
||||
|---|---|---|---|
|
||||
| 4 | OpenCode, nur `CLAUDE.md` mit `@`-Verweis, keine `AGENTS.md`, keine `instructions` | DACHS-2207 | liest `CLAUDE.md`, löst `@` nicht auf: keine Doppel |
|
||||
| 5 | Claude Code, `CLAUDE.md` und `AGENTS.md` | LUCHS-3318 | liest nur `CLAUDE.md` |
|
||||
| autoinvoke | Claude Code 2.1.307, Skill mit `disable-model-invocation: true` | selbst: –, per `/`: IGEL-8140 | wirkt: Skill nur per `/name` |
|
||||
| autoinvoke | OpenCode 1.19.4, Skill mit `metadata: opencode/autoinvoke: "false"` | selbst: IGEL-8140, per `/`: IGEL-8140 | wirkt **nicht**: das Modell lädt den Skill selbst |
|
||||
| 8 | Claude Code 2.1.307, Befehl mit `arguments` und `$name` | – | benannte Argumente wirken |
|
||||
|
||||
✓ bestanden · ✗ fehlgeschlagen · – nicht geprüft (kein Modellzugang).
|
||||
Modellantworten schwanken; ein einzelnes ✗ mit `KEEP=1` wiederholen und die Logs ansehen.
|
||||
1909
KONZEPT.md
Normal file
1909
KONZEPT.md
Normal file
File diff suppressed because it is too large
Load diff
165
README.md
Normal file
165
README.md
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
# Hugin
|
||||
|
||||
Register für Skills, Regeln, Befehle, Subagenten und MCP-Anbindungen für
|
||||
Claude Code und OpenCode. Konzept: [KONZEPT.md](KONZEPT.md).
|
||||
|
||||
| Ordner | Inhalt |
|
||||
|---|---|
|
||||
| `contract/` | Bundle-Format (JSON-Schema), Beispielpakete, goldene Bundles |
|
||||
| `server/` | Python: `hugin_core` (Quellformat, Linter, Übersetzer, Auflösung, Bundles) und `hugin_app` (FastAPI, Datenbank, API, Weboberfläche) |
|
||||
| `cli/` | Go: Programm `hugin` — Teams: `init`, `sync`, `check`, `status`, `search`, `login`; Autoren: `new`, `validate`, `try`, `upload`, `import` |
|
||||
| `scripts/` | siehe „Skripte“ unten |
|
||||
| `static/css-framework/` | CSS-Framework für die Weboberfläche |
|
||||
| `BETRIEB.md` | Container-Image, SQLite oder PostgreSQL, Sicherung |
|
||||
| `TESTEN.md` | Schritt für Schritt lokal testen: Demo, Test-Repo, Autorenablauf, alle Testskripte |
|
||||
| `STUFE0.md` | Ergebnisse der Verhaltensprüfungen von Claude Code und OpenCode |
|
||||
| `COMPATIBILITY.md` | Ergebnis des letzten Rauchtests mit den echten Werkzeugen (`scripts/tool-smoke-test.sh`) |
|
||||
|
||||
## Starten und ausprobieren
|
||||
|
||||
Voraussetzungen: `uv`, Go ≥ 1.26, `zip`, `curl`. Für die Browser-Prüfungen Google Chrome.
|
||||
|
||||
```sh
|
||||
scripts/demo.sh # Server mit Beispieldaten auf http://127.0.0.1:8080; Keys stehen in .demo/keys.txt
|
||||
```
|
||||
|
||||
In der Weboberfläche mit einem der Keys anmelden, z. B. als Bert (Prüfer): unter „Prüfungen“ wartet
|
||||
`pub-munin-nutzen 1.5.0`. Als Anna (Autorin) hochladen, als Shop-Admin Personen und Keys anlegen,
|
||||
als Plattform-Admin (`demo-admin`) die Systemseite.
|
||||
|
||||
Mit dem CLI ein Repo abgleichen:
|
||||
|
||||
```sh
|
||||
(cd cli && go build -o dist/hugin ./cmd/hugin)
|
||||
cd /path/to/test-repo
|
||||
export HUGIN_KEY=<Key „Shop CI“ aus .demo/keys.txt>
|
||||
<hugin>/cli/dist/hugin init --server http://127.0.0.1:8080 --project api
|
||||
# in .hugin.yaml unter packages: z. B. - pub-munin-nutzen und - pub-release-notiz
|
||||
<hugin>/cli/dist/hugin sync
|
||||
```
|
||||
|
||||
MCP in Claude Code oder OpenCode: Server `http://127.0.0.1:8080/mcp` mit Kopfzeile
|
||||
`Authorization: Bearer <Projekt-Key>`.
|
||||
|
||||
## Testen
|
||||
|
||||
```sh
|
||||
cd server && uv run pytest # Server: Kern, API, Web, MCP, Stufe 4, Isolation der Tenants (SQLite)
|
||||
scripts/postgres-test.sh # dieselbe Suite gegen PostgreSQL in Docker
|
||||
cd server && uv run pytest --update # goldene Bundles neu schreiben (nach gewollter Änderung)
|
||||
cd cli && go test ./... # CLI, wendet die goldenen Bundles an
|
||||
```
|
||||
|
||||
Erst die Server-Tests, dann die CLI-Tests: Die CLI testet gegen die Bundles,
|
||||
die der Server erzeugt.
|
||||
|
||||
Vor einem Release und nach größeren Updates von Claude Code oder OpenCode zusätzlich
|
||||
mit den echten Werkzeugen (kostet Tokens, braucht für beide einen Modellzugang):
|
||||
|
||||
```sh
|
||||
scripts/stage0.sh # Verhalten von OpenCode, ohne Modellaufruf
|
||||
OPENCODE_MODEL=<anbieter/modell> scripts/tool-smoke-test.sh # schreibt COMPATIBILITY.md
|
||||
```
|
||||
|
||||
## Stand
|
||||
|
||||
Umgesetzt sind die Stufen 0 bis 4 aus [KONZEPT.md](KONZEPT.md) (Abschnitt 20). Offen:
|
||||
|
||||
- Stufe 5: lokale MCP-Server mit Python und OpenCode-Plugins (ohne npm-Abhängigkeiten)
|
||||
sind umgesetzt (Windows von Hand ungeprüft, `TESTEN.md` 5.11); offen sind fertige
|
||||
Programme als lokale Server, npm-Abhängigkeiten für Plugins, Hooks (Claude Code),
|
||||
Paare, Signaturen
|
||||
- OpenCode 2 ist ungeprüft (STUFE0.md, Frage 10)
|
||||
- Untergrenzen der Werkzeuge sind vorläufig: geprüft sind nur aktuelle Versionen
|
||||
- `hugin-install.ps1` ist mit PowerShell 7 getestet, unter Windows PowerShell 5.1 noch nicht
|
||||
|
||||
## CLI bauen und ohne Server ausprobieren
|
||||
|
||||
```sh
|
||||
cd cli && go build -o dist/hugin ./cmd/hugin
|
||||
cd /path/zum/test-repo
|
||||
hugin init --server https://hugin.intern --project shop
|
||||
hugin sync --bundle <hugin>/contract/examples/bundles/first-install
|
||||
hugin check --bundle <hugin>/contract/examples/bundles/first-install
|
||||
```
|
||||
|
||||
## Server lokal starten
|
||||
|
||||
```sh
|
||||
cd server
|
||||
export HUGIN_DATABASE_URL=sqlite:///hugin.db HUGIN_ADMIN_TOKEN=geheim
|
||||
uv run hugin-server migrate
|
||||
uv run hugin-server run --port 8080 # API-Doku unter http://localhost:8080/docs
|
||||
```
|
||||
|
||||
Erste Schritte mit dem Admin-Token: Tenant anlegen (`POST /api/v1/admin/tenants`),
|
||||
Projekt (`…/tenants/{slug}/projects`), Keys (`…/tenants/{slug}/keys`).
|
||||
`scripts/smoke-test-local.sh` macht genau das und gleicht danach ein Test-Repo ab.
|
||||
|
||||
| Umgebungsvariable | Bedeutung |
|
||||
|---|---|
|
||||
| `HUGIN_DATABASE_URL` | z. B. `sqlite:///hugin.db` oder `postgresql+psycopg://…` |
|
||||
| `HUGIN_ADMIN_TOKEN` | Plattform-Admin |
|
||||
| `HUGIN_BASE_URL` | steht im Kopf verwalteter Dateien |
|
||||
| `HUGIN_DOWNLOAD_DIR` | Ordner mit gebauten CLI-Programmen (`scripts/build-cli.sh`), für die Seite „Programm hugin“ |
|
||||
| `HUGIN_CLI_MIN_VERSION` | ältere CLIs meldet `hugin check` als Befund (Standard 0.1.0) |
|
||||
| `HUGIN_RATE_LIMIT` | Anfragen je Key und Minute (Standard 600) |
|
||||
| `HUGIN_FAILED_ATTEMPTS` | falsche Keys je IP in 5 Minuten, danach 429 (Standard 20) |
|
||||
| `HUGIN_PROXY_IPS` | Router/Ingress, deren `X-Forwarded-For` gilt (Standard `127.0.0.1`; OpenShift: Router-Netz, z. B. `10.128.0.0/14`) |
|
||||
| `HUGIN_MAX_UPLOAD_MB` | größter Request-Body, vor der Anmeldung geprüft (Standard 40) |
|
||||
|
||||
Die Weboberfläche liegt unter `/` (Anmeldung mit einem Key), die Online-Hilfe unter `/help` (ohne Anmeldung), die API-Doku unter `/api/docs`.
|
||||
|
||||
Mit PostgreSQL statt SQLite: `docker compose up -d`, dann
|
||||
`HUGIN_DATABASE_URL=postgresql+psycopg://hugin:hugin@127.0.0.1:55432/hugin scripts/demo.sh`.
|
||||
|
||||
## Container-Image
|
||||
|
||||
`server/Dockerfile` baut auf Red Hat UBI 9 minimal mit Python 3.12 (ca. 90 MB), die
|
||||
Abhängigkeiten kommen fest aus `uv.lock`. Das Image läuft als beliebiger Benutzer der
|
||||
Gruppe 0 (OpenShift) und auch mit schreibgeschütztem Dateisystem.
|
||||
|
||||
```sh
|
||||
docker build -t hugin server/
|
||||
docker run --rm -e HUGIN_DATABASE_URL=… hugin migrate # Migrations-Job vor dem Rollout
|
||||
docker run -p 8080:8080 -e HUGIN_DATABASE_URL=… -e HUGIN_ADMIN_TOKEN=… hugin # Server (CMD run)
|
||||
```
|
||||
|
||||
Probes: `/healthz` (lebt), `/readyz` (Datenbank erreichbar, Migrationen aktuell), Port 8080.
|
||||
Ohne `HUGIN_DATABASE_URL` nutzt das Image SQLite unter `/data/hugin.db`: dort ein Volume
|
||||
einhängen, nur ein Pod, Strategie `Recreate`. Mit PostgreSQL braucht es kein Volume.
|
||||
Ausführlich (SQLite oder PostgreSQL, Sicherung, Probes): [BETRIEB.md](BETRIEB.md).
|
||||
|
||||
## Skripte
|
||||
|
||||
| Skript | Zweck |
|
||||
|---|---|
|
||||
| `scripts/demo.sh` | Server mit Beispieldaten und Keys für alle Rollen |
|
||||
| `scripts/integration-test.sh` | Auslieferung an Integrationen mit `hugin-install.ps1` (`PWSH=<pfad zu pwsh>`) |
|
||||
| `scripts/integration-test.ps1` | dasselbe unter Windows PowerShell 5.1, gegen einen laufenden Hugin (siehe unten) |
|
||||
| `scripts/postgres-test.sh [--compose]` | ganze Server-Testsuite gegen PostgreSQL 17.6 in Docker (Wegwerf-Container oder die aus `docker-compose.yml`) |
|
||||
| `docker compose up -d` | dauerhafte PostgreSQL auf 127.0.0.1:55432 (Daten im Volume; `docker compose down -v` löscht sie) |
|
||||
| `scripts/smoke-test-local.sh` | Server starten, Pakete veröffentlichen, mit dem CLI abgleichen; dazu der Ablauf für Autoren (`new`, `validate`, `try`, `upload`) |
|
||||
| `scripts/stage0.sh` | Verhalten von OpenCode prüfen, ohne Modellaufruf (→ STUFE0.md) |
|
||||
| `scripts/tool-smoke-test.sh` | Rauchtest mit echtem Claude Code und OpenCode, schreibt `COMPATIBILITY.md`; kostet Tokens (`TOOLS`, `CLAUDE_MODEL`, `OPENCODE_MODEL`, `KEEP=1`) |
|
||||
| `scripts/csp-test.sh` | CSS-Framework unter strenger CSP: Verstöße zählen, alle `data-action` durchklicken (Chrome) |
|
||||
| `uv run --no-project --with websockets --with httpx python scripts/csp/web_browser.py <ordner>` | Weboberfläche in Chrome: CSP-Verstöße je Seite, Bildschirmfotos hell und dunkel |
|
||||
| `uv run --no-project --with websockets --with httpx python scripts/csp/web_actions.py` | Weboberfläche in Chrome bedienen: Live-Suche, Tabs, Kopieren, Rückfragen, Felder je Key-Art, Prüfungs-Dialog, Editor |
|
||||
| `scripts/build-cli.sh <version> [download-ordner]` | CLI für macOS, Linux, Windows bauen, mit SHA256SUMS |
|
||||
| `uv run hugin-server export <ordner> [--commit]` | freigegebene Pakete im Quellformat exportieren (in `server/`) |
|
||||
| `scripts/sync-framework.sh` | gebautes CSS-Framework nach `server/src/hugin_app/static/` kopieren |
|
||||
|
||||
## Integration unter Windows PowerShell 5.1 testen
|
||||
|
||||
1. Auf dem Mac oder Linux-Rechner: `HOST=0.0.0.0 scripts/demo.sh`
|
||||
2. `scripts/integration-test.ps1` auf den Windows-Rechner kopieren.
|
||||
3. Dort in Windows PowerShell 5.1 (nicht `pwsh`):
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File integration-test.ps1 `
|
||||
-Server http://<rechner>:8080 -Key <Key „Softwareverteilung“ aus .demo/keys.txt>
|
||||
```
|
||||
|
||||
Das Skript lädt `hugin-install.ps1` vom Server, installiert in einen Temp-Ordner,
|
||||
prüft Encoding, Zeilenenden, JSON, zweiten Lauf, Abbestellen, Handänderung und
|
||||
JSONC und meldet je Prüfung OK oder FEHLER. Es ändert nichts außerhalb des Temp-Ordners.
|
||||
50
STUFE0.md
Normal file
50
STUFE0.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# Stufe 0: Ergebnisse
|
||||
|
||||
Verhaltensfragen aus KONZEPT.md 20, Stufe 0: Wie lesen Claude Code und OpenCode die
|
||||
Dateien, die Hugin schreibt? Stand 19.10.2026.
|
||||
|
||||
| Werkzeug | Version |
|
||||
|---|---|
|
||||
| Claude Code | 2.1.303 (Changelog-Auswertung mit 2.1.302) |
|
||||
| OpenCode | 1.19.3; Fragen 1–3, 6, 7 zuerst mit 1.15.4, gleiches Ergebnis. OpenCode 2 nicht installiert |
|
||||
|
||||
**So geprüft** (Spalte „Wie“):
|
||||
|
||||
- **S0** – `scripts/stage0.sh`: nur `opencode debug …` und der Server von `opencode serve`, kein Modellaufruf, kostenlos.
|
||||
- **RT** – `scripts/tool-smoke-test.sh`: echte Modellaufrufe (Claude Code mit `haiku`, OpenCode mit `openai/gpt-6-luna`). Ergebnis auch in `COMPATIBILITY.md`.
|
||||
- **CL** – Changelog bzw. Release-Notes.
|
||||
|
||||
## Geklärt
|
||||
|
||||
| # | Frage | Ergebnis | Folge für Hugin | Wie |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Unbekannte Felder in `SKILL.md` (`allowed-tools`, `paths`, `effort`)? | OpenCode übergeht sie still, der Skill lädt | Zusätze für Claude stehen in derselben Datei; der Skill bleibt `portable` (→ 7.1) | S0 |
|
||||
| 2 | Gleicher Skill-Name in `.claude/skills/` und `.opencode/skills/`? | nur **eine** Fassung wird geladen, **welche, ist nicht verlässlich** | Hugin legt einen Skill nie an beiden Orten ab; `.opencode/skills/` nur bei `opencode_reads_claude: false` | S0 |
|
||||
| 3 | Liest OpenCode `.claude/commands/` oder `.claude/agents/`? | nein, beide nicht | Befehle und Subagenten je Werkzeug am eigenen Ort, keine Doppel (→ 7.3, 7.4) | S0 |
|
||||
| 3a | Liest OpenCode `.opencode/commands/` und `.opencode/agents/`? | ja; `permission` mit `deny` wirkt | Format der Übersetzer bestätigt | S0, RT |
|
||||
| 4 | Liest OpenCode ohne `AGENTS.md` die `CLAUDE.md` und löst deren `@`-Verweise auf? | liest `CLAUDE.md`, löst `@` **nicht** auf | keine Doppel: Regeln kommen in OpenCode nur über `instructions` an; keine eigene `AGENTS.md` nötig | RT |
|
||||
| 5 | Claude Code und `AGENTS.md` | liest `AGENTS.md` ab 2.1.277 nur ohne `CLAUDE.md`; mit beiden Dateien nur `CLAUDE.md` | unkritisch: Hugin legt `CLAUDE.md` an, Regeln kommen nicht doppelt | CL, RT |
|
||||
| 6 | JSONC in `opencode.json`? | ja, Kommentare und Komma am Ende werden gelesen | das CLI erhält Kommentare beim Ändern (hujson) | S0 |
|
||||
| 7 | Werkzeug- und Rechtenamen | OpenCode-Werkzeuge: `read`, `grep`, `glob`, `edit`, `write`, `bash`, `webfetch`, `websearch`, `task`, `skill`, `todowrite`, `question`; Rechte u. a. `edit`, `bash`, `webfetch`, `external_directory` | Fähigkeitsklasse `read` sperrt auch `websearch` (→ KONZEPT.md 7.4) | S0 |
|
||||
| 8a | Benannte Argumente in Claude Code (`arguments`, `$name`)? | wirken in 2.1.303 | Übersetzer bestätigt; ab welcher Version, ist offen (siehe unten) | RT |
|
||||
| 8b | Wirkt `metadata: opencode/slash`? | nein: jeder Skill ist ohnehin ein Slash-Befehl, auch mit `opencode/slash: "false"` | Feld nicht nötig; Linter-Hinweis H023 | S0 |
|
||||
| 8c | Wirkt `metadata: opencode/autoinvoke: "false"`? | nein: das Modell lädt den Skill trotzdem selbst, auch mit `disable-model-invocation: true` in derselben Datei. Claude Code hält sich an `disable-model-invocation` | „nur von Hand“ geht in OpenCode 1 nicht; Linter-Hinweis H023, Ausweg `targets: [claude]` | RT |
|
||||
| – | MCP: `type: remote`, `headers`, `{env:VAR}` | funktioniert; die Verbindung mit dem Key aus der Umgebungsvariable klappt | Übersetzer bestätigt. Ab 1.19 zeigt `debug config` Header nur maskiert | S0, RT |
|
||||
| – | MCP in Claude Code aus `.mcp.json` | Server stehen auf „Pending approval“, bis jemand zustimmt; `settings.local.json` im Repo hilft dabei nicht | in der Hilfe für Teams beschrieben; der Rauchtest stimmt mit `--settings` zu | RT |
|
||||
|
||||
Wie die Slash-Liste geprüft wird: `opencode serve` starten und `GET /command`
|
||||
abfragen. Das ist dieselbe Liste, die das TUI bei `/` anbietet.
|
||||
|
||||
## Offen
|
||||
|
||||
| # | Frage | So prüfen |
|
||||
|---|---|---|
|
||||
| 8a | Ab welcher Version kennt Claude Code benannte Argumente? Davon hängt die Untergrenze 2.1.105 ab | ältere Version installieren, dann `TOOLS=claude scripts/tool-smoke-test.sh` |
|
||||
| 9 | Unterstützt OpenCode MCP-Prompts und -Ressourcen? | nur zur Information, Hugin baut nicht darauf |
|
||||
| 10 | OpenCode 2: alte MCP-Form (`mcp.<name>`, `enabled`)? unbekannte Felder? Doppel bei Skills? `opencode/autoinvoke`? | `OPENCODE=<opencode-2> scripts/stage0.sh` und `OPENCODE=<opencode-2> OPENCODE_MODEL=… scripts/tool-smoke-test.sh` |
|
||||
|
||||
## Nebenbefund
|
||||
|
||||
Auf dem Entwicklungsrechner ist global ein OpenCode-Plugin `skill-registry`
|
||||
installiert, das Skills, Agenten und Befehle von `http://localhost:8000`
|
||||
synchronisieren will. Beide Skripte schalten es mit `--pure` ab.
|
||||
354
TESTEN.md
Normal file
354
TESTEN.md
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
# Hugin lokal testen
|
||||
|
||||
Schritt für Schritt: Demo-Server starten, Pakete in ein Test-Repo holen, als Autor ein
|
||||
Paket ausprobieren, die automatischen Tests laufen lassen und die Funktionen der
|
||||
Weboberfläche und des CLI durchgehen (Checkliste in Abschnitt 5).
|
||||
|
||||
Voraussetzungen: `uv`, Go ≥ 1.26, `zip`, `curl`; für Schritt 2, 3 und 5 außerdem
|
||||
OpenCode (vorrangig) und/oder Claude Code; für die Browser-Prüfungen Google Chrome. Alle Pfade gehen vom Hugin-Repo aus (hier `~/repos/dev/hugin`).
|
||||
|
||||
## 1. Demo-Server mit Beispieldaten
|
||||
|
||||
```sh
|
||||
cd ~/repos/dev/hugin
|
||||
scripts/demo.sh # http://127.0.0.1:8080, Keys stehen in .demo/keys.txt
|
||||
scripts/demo.sh --neu # Beispieldaten verwerfen und neu anlegen
|
||||
```
|
||||
|
||||
Im Browser mit einem Key aus `.demo/keys.txt` anmelden:
|
||||
|
||||
| Key | Rolle | Was ausprobieren |
|
||||
|---|---|---|
|
||||
| Bert Prüfer | Prüfer | unter „Prüfungen“ wartet `pub-munin-nutzen 1.5.0` |
|
||||
| Anna Autorin | Autorin | Pakete anlegen und hochladen |
|
||||
| Shop-Admin | Tenant-Admin | Projekte, Personen und Keys anlegen |
|
||||
| `demo-admin` | Plattform-Admin | Systemseite, Audit, alle Tenants |
|
||||
|
||||
Online-Hilfe: http://127.0.0.1:8080/help · API-Doku: http://127.0.0.1:8080/api/docs
|
||||
|
||||
## 2. Pakete in ein Test-Repo holen
|
||||
|
||||
In einem zweiten Terminal, während der Server läuft:
|
||||
|
||||
```sh
|
||||
cd ~/repos/dev/hugin/cli && go build -o dist/hugin ./cmd/hugin
|
||||
export PATH=~/repos/dev/hugin/cli/dist:$PATH # kein alias: OpenCode und Claude Code sehen nur den PATH
|
||||
|
||||
mkdir -p /tmp/test-repo && cd /tmp/test-repo && git init -q
|
||||
export HUGIN_KEY=<Key „Shop CI“ aus .demo/keys.txt>
|
||||
hugin init --server http://127.0.0.1:8080 --project api
|
||||
```
|
||||
|
||||
In `.hugin.yaml` Werkzeuge und Pakete eintragen:
|
||||
|
||||
```yaml
|
||||
tools: [claude, opencode] # fest eintragen: „auto“ erkennt im leeren Repo nichts Passendes
|
||||
packages:
|
||||
- pub-munin-nutzen # Skill
|
||||
- pub-hausregeln # Regeln
|
||||
- pub-release-notiz # Befehl mit Argumenten
|
||||
- pub-code-review # Subagent
|
||||
```
|
||||
|
||||
`pub-munin-anbindung` (MCP) kommt als Pflichtpaket von selbst mit.
|
||||
|
||||
```sh
|
||||
hugin sync # schreibt die Dateien, legt hugin.lock an
|
||||
hugin status # Abos, Versionen, erkannte Werkzeuge
|
||||
hugin check # ändert nichts; Rückgabewert 1, wenn etwas zu tun ist
|
||||
```
|
||||
|
||||
Dann im Werkzeug ausprobieren, z. B.:
|
||||
|
||||
```sh
|
||||
opencode # oder: claude
|
||||
> /pub-release-notiz 2.0 Kunden
|
||||
```
|
||||
|
||||
**Was sich zu testen lohnt:**
|
||||
|
||||
- Eine verwaltete Datei von Hand ändern, dann `hugin sync` → Befund „Handänderung“;
|
||||
`hugin sync --overwrite` setzt sie zurück.
|
||||
- Als Bert `pub-munin-nutzen 1.5.0` freigeben, dann `hugin check` (meldet die neue
|
||||
Version) und `hugin sync`.
|
||||
- Ein Paket aus `packages` entfernen und `hugin sync` → seine Dateien verschwinden.
|
||||
- Claude Code fragt beim ersten Start, ob es den MCP-Server aus `.mcp.json` nutzen darf.
|
||||
|
||||
## 3. Als Autor ein Paket ausprobieren
|
||||
|
||||
```sh
|
||||
cd /tmp
|
||||
export HUGIN_KEY=<Key von Anna Autorin> HUGIN_SERVER=http://127.0.0.1:8080
|
||||
hugin new skill pub-mein-test
|
||||
```
|
||||
|
||||
In `pub-mein-test/hugin.yaml` mindestens `owner: kern` und eine echte `description`
|
||||
eintragen, in `SKILL.md` den Inhalt. Format und Beispiele: Hilfe → Quellformat.
|
||||
|
||||
```sh
|
||||
hugin validate pub-mein-test # Linter und Vorschau je Werkzeug, speichert nichts
|
||||
cd /tmp/test-repo
|
||||
hugin try ../pub-mein-test # ins Repo installieren, ohne hugin.lock zu ändern
|
||||
opencode # ausprobieren (oder claude)
|
||||
hugin try --remove # wieder aufräumen
|
||||
cd /tmp && hugin upload --submit pub-mein-test # hochladen und zur Prüfung einreichen
|
||||
```
|
||||
|
||||
Danach als Bert im Browser unter „Prüfungen“ freigeben.
|
||||
|
||||
**Linter ausprobieren:**
|
||||
|
||||
| Änderung | Meldung |
|
||||
|---|---|
|
||||
| `description` aus der Vorlage stehen lassen | H003 (Fehler): noch der Platzhalter |
|
||||
| unter `extra.opencode` z. B. `temperature: 0.1` bei einem Skill | H020 (Fehler): Feld unbekannt |
|
||||
| `extra.claude.disable-model-invocation: true` | H023 (Hinweis): wirkt in OpenCode 1 nicht |
|
||||
| „nutze Read“ im Text | H021 (Hinweis): neutral formulieren |
|
||||
|
||||
## 4. Automatische Tests
|
||||
|
||||
| Befehl | Prüft | Kosten |
|
||||
|---|---|---|
|
||||
| `cd server && uv run pytest` | Server: Linter, Übersetzer, API, Web, MCP, Tenants | frei |
|
||||
| `scripts/postgres-test.sh` | dieselbe Suite gegen PostgreSQL (Docker) | frei |
|
||||
| `cd cli && go test ./...` | CLI, gegen die goldenen Bundles des Servers | frei |
|
||||
| `scripts/smoke-test-local.sh` | ganzer Ablauf Server → CLI, ohne echte Werkzeuge | frei |
|
||||
| `scripts/stage0.sh` | wie OpenCode die Dateien liest, ohne Modellaufruf | frei |
|
||||
| `uv run --no-project --with websockets --with httpx python scripts/csp/web_browser.py /tmp/shots` | Weboberfläche in Chrome: CSP-Verstöße je Seite, Bildschirmfotos | frei |
|
||||
| `uv run --no-project --with websockets --with httpx python scripts/csp/web_actions.py` | Weboberfläche in Chrome bedienen: Topbar, Live-Suche, Kopieren, Rückfragen, Formulare, Editor, Tabs | frei |
|
||||
| `OPENCODE_MODEL=openai/gpt-6-luna scripts/tool-smoke-test.sh` | echtes Claude Code und OpenCode, schreibt `COMPATIBILITY.md` | ca. 30 Modellaufrufe |
|
||||
|
||||
Reihenfolge: erst die Server-Tests, dann die CLI-Tests (die CLI testet gegen die
|
||||
Bundles, die der Server erzeugt).
|
||||
|
||||
Optionen für `tool-smoke-test.sh`:
|
||||
|
||||
| Variable | Wirkung |
|
||||
|---|---|
|
||||
| `TOOLS=opencode` | nur ein Werkzeug prüfen |
|
||||
| `CLAUDE_MODEL=sonnet` | anderes Modell für Claude Code (Standard `haiku`) |
|
||||
| `OPENCODE_MODEL=anbieter/modell` | Modell für OpenCode; ohne Angabe sein Standard |
|
||||
| `KEEP=1` | Arbeitsordner und Logs bleiben zum Nachsehen |
|
||||
| `OUT=/pfad/datei.md` | Ergebnis woandershin statt nach `COMPATIBILITY.md` |
|
||||
|
||||
OpenCode braucht dafür einen Anbieter-Zugang (`opencode providers login`); die
|
||||
kostenlosen Modelle lehnen `opencode run` ab.
|
||||
|
||||
## 5. Checkliste: Funktionen von Hand durchgehen
|
||||
|
||||
Server aus Abschnitt 1 und Test-Repo aus Abschnitt 2. Jede Zeile: was tun, was erwartet
|
||||
wird. Was die Browser-Prüfung (`web_actions.py`) schon abdeckt, ist mit ⚙ markiert; von
|
||||
Hand lohnt es trotzdem einmal, um das Verhalten zu kennen.
|
||||
|
||||
### 5.1 Weboberfläche: überall
|
||||
|
||||
| Schritt | Erwartung |
|
||||
|---|---|
|
||||
| Fenster schmal ziehen oder Handyansicht (Chrome: F12 → Gerätesymbol) | Topbar oben mit ☰, „Hilfe“, Hell/Dunkel, „Abmelden“; nichts abgeschnitten; „Programm hugin“ steht dann im Menü ⚙ |
|
||||
| Lange Seite (als `demo-admin` „Tenants, Projekte, Keys“) ganz nach unten scrollen | Topbar mit „Abmelden“ bleibt sichtbar, die Sidebar bleibt stehen ⚙ |
|
||||
| Mond/Sonne in der Topbar | Hell/Dunkel wechselt |
|
||||
| Hilfe → „Arten von Paketen“ | jede Art erklärt (Skill, Regeln, Befehl, Subagent, MCP, Hook) mit „Welche Art passt?“ |
|
||||
| Auf einer Paketseite das Abzeichen der Art (z. B. „Skill“) anklicken | führt zu „Arten von Paketen“ |
|
||||
|
||||
### 5.2 Als Shop CI (Team)
|
||||
|
||||
| Schritt | Erwartung |
|
||||
|---|---|
|
||||
| Katalog: ins Suchfeld „haus“ tippen | Liste filtert ohne Enter, Suche steht in der URL ⚙ |
|
||||
| Katalog: Spalte „Paket“ anklicken | sortiert (nur, solange alles auf eine Seite passt) |
|
||||
| Paket `pub-munin-nutzen` → „Im Repo nutzen“ → „Kopieren“ | Meldung „Kopiert“; in der Zwischenablage `packages:` mit dem Paket ⚙ |
|
||||
| Version `1.4.0` öffnen, Tab „Inhalt“ anklicken, dann ← → Pos1 Ende | Tabs wechseln per Tastatur ⚙ |
|
||||
| „Mein Projekt“ | Repos gruppiert; Filter „Alle“ / „Mit neuerer Version“; „Gemeldet“ als relative Zeit („vor 2 Tagen“), genaue Zeit im Tooltip |
|
||||
|
||||
### 5.3 Als Bert (Prüfer) und Carla (Prüferin + Tenant-Admin)
|
||||
|
||||
| Schritt | Erwartung |
|
||||
|---|---|
|
||||
| „Prüfungen“ | `pub-munin-nutzen 1.5.0` mit „eingereicht vor …“ |
|
||||
| Version öffnen → „Prüfen“ → ohne Kommentar „Zurückgeben“ | Browser verlangt einen Kommentar, nichts wird abgeschickt ⚙ |
|
||||
| Mit Kanal „stabil“ freigeben | zurück in „Prüfungen“; danach meldet im Test-Repo `hugin check` die neue Version |
|
||||
| Danach als Shop-Admin „Überblick“ → Karte „Veraltete Installationen“ | springt zur Tabelle der Repos mit alter Version |
|
||||
| Rechte-Paket: als Anna eine Skill-Version mit `extra: {claude: {allowed-tools: [Bash]}}` hochladen und einreichen, Bert gibt frei | Bert sieht im Dialog kein Kanal-Feld, sondern „Den Kanal wählt die letzte nötige Freigabe“; danach sieht Carla in „Prüfungen“ „Freigaben 1 von 2“ und „Tenant-Admin nötig“ |
|
||||
| Carla gibt frei | Kanal-Feld mit Hinweis zum Risiko; danach freigegeben |
|
||||
|
||||
### 5.4 Als Anna (Autorin)
|
||||
|
||||
| Schritt | Erwartung |
|
||||
|---|---|
|
||||
| „Hochladen“ → „direkt im Browser anlegen“ | ID ist mit `pub-` vorausgefüllt |
|
||||
| Version `pub-munin-nutzen 1.4.0` → „Neue Version auf dieser Basis“ | Editor öffnet mit 1.4.1 |
|
||||
| Im Editor Tab drücken; Esc und dann Tab | Tab rückt zwei Leerzeichen ein; Esc+Tab springt zum nächsten Feld ⚙ |
|
||||
| Etwas ändern und die Seite verlassen | Browser warnt vor ungespeicherten Änderungen ⚙ |
|
||||
| In `SKILL.md` `$1` schreiben und speichern | Linter lehnt ab (H010), der Dateiname im Befund springt zur Datei |
|
||||
| Alle Dateien unverändert speichern (nur `changes` ausfüllen) | Entwurf 1.4.1 mit denselben Dateien; nichts geht verloren |
|
||||
| ZIP mit `hugin.yaml` (`kind: hook`) hochladen | klare Ablehnung H008 „Hooks kommen mit Stufe 5“, kein Serverfehler |
|
||||
| „Zur Prüfung einreichen“ | Rückfrage „Danach lässt sich die Version nicht mehr ändern“ |
|
||||
|
||||
### 5.5 Als Shop-Admin (Verwaltung)
|
||||
|
||||
| Schritt | Erwartung |
|
||||
|---|---|
|
||||
| „Neuer Key“ → Art umschalten | Projekt-Key: nur Feld „Projekt“ (Pflicht); Integrations-Key: nur „Nur für diese Projekte“ ⚙ |
|
||||
| Rollen ansehen | lesbare Namen („Autor“, „Prüfer (öffentlich)“, „Tenant-Admin“) mit Tooltip |
|
||||
| Key anlegen → „Kopieren“ | Key in der Zwischenablage; er wird nur dieses eine Mal angezeigt |
|
||||
| „Widerrufen“ bei einem Key | Rückfrage; „Abbrechen“ ändert nichts ⚙ |
|
||||
| Als `demo-admin`: Tenant „plattform“ → Person „Sperren“; Pflichtpaket „Keine Pflicht mehr“ | jeweils Rückfrage (das Pflichtpaket gilt für alle Tenants, nur der Plattform-Admin kann es lösen) |
|
||||
| Tabelle „Keys“ | „Zuletzt genutzt“ als „vor …“ |
|
||||
| Als `demo-admin` „Audit“ → Aktion „Keys“ wählen | Liste filtert sofort; ab 50 Einträgen bleibt der Filter beim Blättern („Ältere“) erhalten ⚙ |
|
||||
|
||||
### 5.6 CLI im Test-Repo (OpenCode zuerst)
|
||||
|
||||
| Schritt | Erwartung |
|
||||
|---|---|
|
||||
| Frisches Repo, `hugin sync` | `opencode.json` beginnt mit `"$schema": "https://opencode.ai/config.json"`; `opencode debug config` läuft ohne Fehler |
|
||||
| In `.hugin.yaml` `tools: [opencode]`, dann `hugin sync` | Skills unter `.opencode/skills/`; kein `.claude/`, keine `.mcp.json`, kein Block in `CLAUDE.md`; `opencode debug skill` findet den Skill |
|
||||
| Zurück auf `tools: [claude, opencode]`, `hugin sync` | Skills wandern nach `.claude/skills/`, `.opencode/skills/` ist leer |
|
||||
| `unset MUNIN_KEY; hugin sync` | `Warnung: pub-munin-anbindung braucht die Umgebungsvariable MUNIN_KEY; sie ist nicht gesetzt …` |
|
||||
| `export MUNIN_KEY=x; hugin sync` | keine Warnung mehr |
|
||||
| `unset MUNIN_KEY; hugin check; echo $?` | Warnung, aber Rückgabewert 0 (CI ohne Keys bleibt grün) |
|
||||
| In `opencode.json` unter `mcp` von Hand `"jira": { "url": "https://jira.intern/mcp" }` eintragen, dann `hugin check; echo $?` | „OpenCode lehnt die Konfiguration ab“ mit `mcp.jira`, Rückgabewert 1, Text „das kann hugin sync nicht“ |
|
||||
| Eintrag reparieren (`"type": "remote"`, `"enabled": true`), `hugin check` | „Alles aktuell.“ |
|
||||
| `opencode` im Repo starten, `/pub-release-notiz 2.0 Kunden` | Befehl läuft; Regeln aus `pub-hausregeln` werden beachtet |
|
||||
| In `opencode.json` einen Tippfehler wie `"modell": "x"` | wird **nicht** gemeldet: OpenCode 1.19.3 nimmt unbekannte Felder hin |
|
||||
|
||||
### 5.7 Agent installiert über Hugins MCP-Endpunkt (OpenCode)
|
||||
|
||||
Frisches Test-Repo, damit nichts vom Abschnitt 2 stört:
|
||||
|
||||
```sh
|
||||
mkdir -p /tmp/mcp-repo && cd /tmp/mcp-repo && git init -q
|
||||
export HUGIN_KEY=<Key „Shop CI“ aus .demo/keys.txt>
|
||||
export MUNIN_KEY=x # nur gegen die Warnung; Munin selbst gibt es in der Demo nicht
|
||||
hugin init --server http://127.0.0.1:8080 --project api
|
||||
sed -i '' 's/^tools: auto/tools: [opencode]/' .hugin.yaml
|
||||
hugin sync # nur das Pflichtpaket pub-munin-anbindung
|
||||
```
|
||||
|
||||
In `opencode.json` unter `"mcp"` Hugin selbst als MCP-Server eintragen (von Hand; Hugin
|
||||
lässt diesen Eintrag in Ruhe):
|
||||
|
||||
```json
|
||||
"hugin": {
|
||||
"type": "remote",
|
||||
"url": "http://127.0.0.1:8080/mcp",
|
||||
"enabled": true,
|
||||
"headers": { "Authorization": "Bearer {env:HUGIN_KEY}" }
|
||||
}
|
||||
```
|
||||
|
||||
| Schritt | Erwartung |
|
||||
|---|---|
|
||||
| `opencode mcp list` | `hugin` steht auf „connected“ (`munin` verbindet in der Demo nicht, das ist erwartet) |
|
||||
| `hugin check` | „Alles aktuell.“ (OpenCode akzeptiert den Eintrag) |
|
||||
| `which hugin` in derselben Shell, aus der du `opencode` startest | zeigt `…/cli/dist/hugin`; sonst findet der Agent `hugin sync` nicht (Abschnitt 2: `PATH`, kein `alias`) |
|
||||
| `opencode` starten: „Gibt es in Hugin einen Befehl für Release-Notizen?“ | ruft `search_packages` auf, nennt `pub-release-notiz` |
|
||||
| „Zeig mir, was pub-release-notiz macht, ohne es zu installieren.“ | ruft `get_package` auf, erklärt den Inhalt; im Repo ändert sich nichts (`git status`) |
|
||||
| „Richte pub-release-notiz dauerhaft in diesem Repo ein.“ | trägt `pub-release-notiz` in `.hugin.yaml` ein und führt `hugin sync` aus (OpenCode fragt vorher nach Erlaubnis für den Befehl); **schreibt nicht selbst** nach `.opencode/commands/` |
|
||||
| Danach `hugin status` und `cat hugin.lock` | `pub-release-notiz` steht in beiden; die Datei `.opencode/commands/pub-release-notiz.md` hat Hugin geschrieben |
|
||||
| OpenCode neu starten, `/pub-release-notiz 2.0 Kunden` | der Befehl läuft |
|
||||
| Als Anna eine neue Version von `pub-release-notiz` hochladen, Bert gibt frei; dann `hugin check` | meldet die neue Version (das geht nur, weil `hugin sync` installiert hat) |
|
||||
|
||||
Gegenprobe ohne `hugin` im `PATH` (z. B. `env PATH=/usr/bin:/bin opencode`): Der Agent
|
||||
soll melden, dass `hugin` fehlt, und **nichts selbst schreiben**. Schreibt er die Datei
|
||||
doch selbst, fehlt `pub-release-notiz` in `hugin.lock`, und das nächste `hugin sync`
|
||||
meldet „Datei gibt es schon, sie stammt nicht von Hugin (Konflikt)“. Dann bitte Modell
|
||||
und Wortlaut notieren, das wäre ein Befund. Aufräumen: `hugin sync --overwrite`.
|
||||
|
||||
Der automatische Test dazu: `scripts/tool-smoke-test.sh`, Prüfungen „Installieren auf Bitte“ und „Ohne hugin nichts schreiben“.
|
||||
|
||||
### 5.8 Demo und Betrieb
|
||||
|
||||
| Schritt | Erwartung |
|
||||
|---|---|
|
||||
| `scripts/demo.sh` mit einer Datenbank aus einem alten Stand | statt Traceback: „Migration gescheitert …“ mit dem Hinweis auf `--neu` |
|
||||
| `cd server && uv run pytest tests/test_migration.py` nach einer Änderung an `0001_initial_tables.py` | Fehler „ausgelieferte Migrationen nie ändern, sondern eine neue anlegen“ |
|
||||
|
||||
### 5.9 Lokaler MCP-Server (Python)
|
||||
|
||||
Das Beispiel `pub-zeit-lokal` braucht nur die Standardbibliothek. Als Anna im Test-Repo
|
||||
aus Abschnitt 3 (mit `tools: [opencode]` oder `[claude, opencode]`):
|
||||
|
||||
```sh
|
||||
export HUGIN_MCP_HOME=/tmp/hugin-mcp ZEIT_ZONE_HINWEIS=test # Ablage zum Wegwerfen
|
||||
hugin try ~/repos/dev/hugin/contract/examples/packages/pub-zeit-lokal/1.0.0
|
||||
```
|
||||
|
||||
| Schritt | Erwartung |
|
||||
|---|---|
|
||||
| Ausgabe von `hugin try` | „installiere pub-zeit-lokal 1.0.0 (lokaler Server) nach /tmp/hugin-mcp/…“ |
|
||||
| `ls -a /tmp/hugin-mcp/pub-zeit-lokal/1.0.0` | `zeit_server.py`, `.venv`, `.hugin-install.json` |
|
||||
| `grep -A6 zeit opencode.json` | `"command": ["hugin", "mcp", "run", "pub-zeit-lokal"]`, kein Pfad |
|
||||
| `opencode mcp list` | `zeit connected` |
|
||||
| in OpenCode: „Rufe das Werkzeug zeit auf“ | Uhrzeit mit `UHR-4711 (test)` |
|
||||
| `rm -rf /tmp/hugin-mcp`, dann `opencode mcp list` | `zeit failed`; `hugin mcp run pub-zeit-lokal` sagt „nicht installiert; im Repo hugin sync ausführen“ |
|
||||
| `hugin try` erneut | installiert wieder |
|
||||
| `hugin try --remove`, dann `hugin mcp prune --dry-run` | „würde löschen pub-zeit-lokal 1.0.0“ |
|
||||
| `mkdir -p /tmp/hugin-mcp/eigener && hugin mcp prune` | löscht nur das Hugin-Paket; `eigener` bleibt |
|
||||
|
||||
Freigabe und Verteilung: als Anna `hugin upload --submit` (persönlicher Key nötig, H043
|
||||
beim Projekt-Key), Risiko „Skripte“, also zwei Freigaben (Bert und Carla). Die
|
||||
Versionsseite zeigt „Lokaler Server“ mit Ablage. Danach im Repo `pub-zeit-lokal` unter
|
||||
`packages` eintragen und `hugin sync`; `hugin check` meldet, wenn der Server auf dem
|
||||
Rechner fehlt.
|
||||
|
||||
**Mit Abhängigkeiten:** `requirements.txt` mit Hashes erzeugen (Hilfe → Quellformat,
|
||||
`uv pip compile --universal --generate-hashes`). pip nimmt den Index aus der
|
||||
`pip.conf`; zum Test einen anderen Index: `PIP_CONFIG_FILE=/tmp/pip.conf`. Ohne Hash
|
||||
lehnt der Linter mit H037 ab.
|
||||
|
||||
### 5.10 OpenCode-Plugin
|
||||
|
||||
Das Beispiel `pub-specht-plugin` gibt OpenCode das Werkzeug `specht_marke`. Als Anna im
|
||||
Test-Repo (`tools: [opencode]` oder `[claude, opencode]`):
|
||||
|
||||
```sh
|
||||
hugin validate ~/repos/dev/hugin/contract/examples/packages/pub-specht-plugin/1.0.0
|
||||
hugin try ~/repos/dev/hugin/contract/examples/packages/pub-specht-plugin/1.0.0
|
||||
```
|
||||
|
||||
| Schritt | Erwartung |
|
||||
|---|---|
|
||||
| Ausgabe von `hugin validate` | `Risiko: permissions`, `Kompatibilität: opencode-only`, keine Fehler |
|
||||
| `find .opencode/plugins` | `pub-specht-plugin.ts` und `pub-specht-plugin/index.ts`, `…/lib/marke.ts`; nichts unter `.claude/` |
|
||||
| `opencode debug config \| grep -A2 '"plugin"'` | `…/.opencode/plugins/pub-specht-plugin.ts` |
|
||||
| in OpenCode: „Rufe das Werkzeug specht_marke auf“ | `SPECHT-5127` |
|
||||
| dasselbe mit `opencode --pure` | Werkzeug unbekannt (so gewollt) |
|
||||
| `git status` | `.opencode/plugins/…` neu; `node_modules`, `package.json` unter `.opencode/` nicht (eigene `.gitignore` von OpenCode) |
|
||||
| in `index.ts` `import _ from "lodash"` ergänzen, `hugin validate` | H039 |
|
||||
| `targets` auf `[claude, opencode]`, `hugin validate` | H038 |
|
||||
| `hugin try --remove` | Ordner und Startdatei weg |
|
||||
|
||||
Freigabe: `hugin upload --submit`, dann Bert und **Carla** (Tenant-Admin); mit Bert und
|
||||
Dora allein bleibt es bei „Tenant-Admin nötig“. Danach abonnieren und `hugin sync`.
|
||||
|
||||
### 5.11 Windows (von Hand)
|
||||
|
||||
Auf einem Windows-Rechner mit Python (Starter `py`), OpenCode und Zugang zu Hugin.
|
||||
Programm bauen auf dem Mac: `cd cli && GOOS=windows GOARCH=amd64 go build -o dist/hugin.exe ./cmd/hugin`,
|
||||
`hugin.exe` in einen Ordner im `PATH` legen. In PowerShell:
|
||||
|
||||
| Schritt | Erwartung |
|
||||
|---|---|
|
||||
| `hugin init …`, `.hugin.yaml` wie in Abschnitt 2 mit `tools: [opencode]`, `hugin sync` | Dateien unter `.opencode\…`, keine unter `.claude` |
|
||||
| `opencode debug skill > s.txt; opencode agent list; opencode mcp list` | Skill, Subagent, MCP wie unter macOS |
|
||||
| `hugin try …\pub-zeit-lokal\1.0.0` (mit `$env:ZEIT_ZONE_HINWEIS="test"`) | installiert nach `%USERPROFILE%\.local\share\mcp\pub-zeit-lokal\1.0.0`, venv unter `.venv\Scripts` |
|
||||
| `opencode mcp list` | `zeit connected` |
|
||||
| in OpenCode das Werkzeug zeit aufrufen | Antwort mit `UHR-4711`, Umlaute korrekt |
|
||||
| aus einem Unterordner des Repos starten | lokaler Server verbindet trotzdem |
|
||||
| ohne `py`, nur `python` aus python.org im `PATH` | wird gefunden; nur der Store-Platzhalter `python.exe`: Meldung „kein Python gefunden“ |
|
||||
| `$env:HUGIN_PYTHON="C:\Pfad\python.exe"` | wird genommen |
|
||||
| Paket mit `requirements.txt`, `pip.ini` unter `%APPDATA%\pip\` mit Artifactory | pip installiert aus dem Artifactory |
|
||||
| `hugin mcp prune` | wie unter macOS |
|
||||
| Repo unter einem langen Pfad (> 200 Zeichen), dann `hugin sync` | Befund notieren, falls Windows-Pfadgrenze greift |
|
||||
| `hugin try …\pub-specht-plugin\1.0.0`, OpenCode neu starten, Werkzeug specht_marke aufrufen | `SPECHT-5127` |
|
||||
| `hugin-install.ps1` mit einem Paket mit lokalem Server | Warnung „ist ein lokaler MCP-Server; installieren mit dem Programm hugin“, sonst läuft alles durch |
|
||||
|
||||
Befunde bitte mit Ausgabe und Windows-Version notieren.
|
||||
|
||||
## Noch nicht testbar
|
||||
|
||||
- Hooks, Paare, Signaturen, fertige Programme als lokale Server, npm-Abhängigkeiten für
|
||||
Plugins: noch nicht gebaut (Stufe 5c–5e); ein Hook-Paket lehnt der Linter mit H008 ab.
|
||||
- OpenCode 2: ungeprüft (STUFE0.md, Frage 10); `OPENCODE=<opencode-2> scripts/stage0.sh`.
|
||||
- `hugin-install.ps1` unter Windows PowerShell 5.1: braucht einen Windows-Rechner,
|
||||
siehe README „Integration unter Windows PowerShell 5.1 testen“.
|
||||
313
cli/cmd/hugin/author.go
Normal file
313
cli/cmd/hugin/author.go
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"hugin/cli/internal/api"
|
||||
"hugin/cli/internal/bundle"
|
||||
"hugin/cli/internal/config"
|
||||
"hugin/cli/internal/keystore"
|
||||
"hugin/cli/internal/localmcp"
|
||||
"hugin/cli/internal/lock"
|
||||
"hugin/cli/internal/pkgdir"
|
||||
"hugin/cli/internal/reconcile"
|
||||
)
|
||||
|
||||
// Lock-Datei von hugin try; hugin.lock bleibt dabei unberührt (→ KONZEPT.md 11.1).
|
||||
const trialLock = localmcp.TrialLock
|
||||
|
||||
func newCommand() *cobra.Command {
|
||||
var folder string
|
||||
cmd := &cobra.Command{
|
||||
Use: "new <kind> <id>",
|
||||
Short: "legt einen Paketordner mit Manifest-Gerüst an (Art: skill, rules, command, subagent, mcp, plugin)",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
target, err := pkgdir.New(folder, args[0], args[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%s angelegt. hugin.yaml ausfüllen, dann: hugin validate %s\n", target, target)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&folder, "in", ".", "Ordner, in dem der Paketordner entsteht")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// authorClient: Server aus --server, sonst aus .hugin.yaml, sonst HUGIN_SERVER.
|
||||
func authorClient(server string) (*api.Client, error) {
|
||||
if server == "" {
|
||||
if k, err := config.Load(repoFolder); err == nil {
|
||||
server = k.Server
|
||||
}
|
||||
}
|
||||
if server == "" {
|
||||
server = os.Getenv("HUGIN_SERVER")
|
||||
}
|
||||
if server == "" {
|
||||
return nil, errors.New("kein Server: --server angeben, HUGIN_SERVER setzen oder in einem Repo mit .hugin.yaml arbeiten")
|
||||
}
|
||||
key, err := keystore.Get(server)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return api.New(server, key)
|
||||
}
|
||||
|
||||
func folderArg(args []string) string {
|
||||
if len(args) == 1 {
|
||||
return args[0]
|
||||
}
|
||||
return "."
|
||||
}
|
||||
|
||||
// printFindings druckt Linter-Befunde; true, wenn ein Fehler dabei ist.
|
||||
func printFindings(findings []api.Finding) bool {
|
||||
errs := false
|
||||
for _, b := range findings {
|
||||
location := ""
|
||||
if b.File != "" {
|
||||
location = " (" + b.File + ")"
|
||||
}
|
||||
fmt.Printf(" %-7s %s %s%s\n", b.Severity, b.Id, b.Text, location)
|
||||
errs = errs || b.Severity == "error"
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
// linterError macht aus einer 422-Antwort mit Befunden eine lesbare Ausgabe.
|
||||
func linterError(err error) error {
|
||||
var f *api.APIError
|
||||
if errors.As(err, &f) && len(f.Findings) > 0 {
|
||||
fmt.Println("Der Linter hat das Paket abgelehnt:")
|
||||
printFindings(f.Findings)
|
||||
return findingError{"Paket nicht angenommen"}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func validateCommand() *cobra.Command {
|
||||
var server string
|
||||
cmd := &cobra.Command{
|
||||
Use: "validate [folder]",
|
||||
Short: "prüft einen Paketordner über den Server und zeigt, was in jedem Werkzeug ankommt; speichert nichts",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := authorClient(server)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := pkgdir.Zip(folderArg(args))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := client.Validate(raw)
|
||||
if err != nil {
|
||||
return linterError(err)
|
||||
}
|
||||
if len(p.Findings) > 0 {
|
||||
fmt.Println("Befunde:")
|
||||
}
|
||||
errs := printFindings(p.Findings)
|
||||
fmt.Printf("Risiko: %s\n", p.Risk)
|
||||
if p.Compatibility != nil {
|
||||
fmt.Printf("Kompatibilität: %s\n", *p.Compatibility)
|
||||
}
|
||||
toolNames := make([]string, 0, len(p.Preview))
|
||||
for w := range p.Preview {
|
||||
toolNames = append(toolNames, w)
|
||||
}
|
||||
sort.Strings(toolNames)
|
||||
for _, w := range toolNames {
|
||||
fmt.Printf("\n%s:\n", w)
|
||||
for _, op := range p.Preview[w] {
|
||||
fmt.Printf(" %-10v %v\n", op["kind"], target(op))
|
||||
}
|
||||
}
|
||||
if errs {
|
||||
return findingError{"Paket hat Fehler"}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&server, "server", "", "Adresse von Hugin (sonst aus .hugin.yaml oder HUGIN_SERVER)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func target(op map[string]any) string {
|
||||
if path, ok := op["path"]; ok {
|
||||
return fmt.Sprint(path)
|
||||
}
|
||||
text := fmt.Sprint(op["file"])
|
||||
if s, ok := op["key"]; ok {
|
||||
text += fmt.Sprintf(" %v", s)
|
||||
}
|
||||
if b, ok := op["block"]; ok {
|
||||
text += fmt.Sprintf(" Block %v", b)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func tryCommand() *cobra.Command {
|
||||
var server string
|
||||
var remove, overwrite bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "try [folder]",
|
||||
Short: "installiert einen Entwurf zum Ausprobieren ins Repo, ohne hugin.lock zu ändern (--remove räumt auf)",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
lockPath := filepath.Join(repoFolder, trialLock)
|
||||
old, err := lock.LoadFrom(lockPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b := bundle.Empty()
|
||||
if !remove {
|
||||
client, err := authorClient(server)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := pkgdir.Zip(folderArg(args))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
k, _ := config.Load(repoFolder)
|
||||
detected, _ := detectTools(k)
|
||||
details := map[string]string{}
|
||||
for _, w := range detected {
|
||||
details[w.name] = w.version
|
||||
}
|
||||
zip, err := client.Trial(raw, details)
|
||||
if err != nil {
|
||||
return linterError(err)
|
||||
}
|
||||
if b, err = bundle.FromZip(zip); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
plan, err := reconcile.MakePlan(repoFolder, b, old, reconcile.Options{Overwrite: overwrite})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, f := range plan.Findings {
|
||||
fmt.Fprintln(os.Stderr, "Befund:", f)
|
||||
}
|
||||
if plan.Open() {
|
||||
return findingError{"nichts geändert; mit --overwrite setzt try die Dateien trotzdem"}
|
||||
}
|
||||
if err := plan.RunWithLock(repoFolder, lockPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if !remove {
|
||||
home, err := localmcp.Home()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := install(b, home); err != nil {
|
||||
return fmt.Errorf("Probe geschrieben, aber lokaler Server nicht installiert: %w", err)
|
||||
}
|
||||
}
|
||||
if remove {
|
||||
os.Remove(lockPath)
|
||||
fmt.Printf("Probe entfernt: %d Dateien gelöscht.\n", len(plan.Delete))
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("Probe installiert: %d Dateien geschrieben (%d neu).\n", len(plan.Write), len(plan.New))
|
||||
for _, rel := range plan.New {
|
||||
fmt.Println(" ", rel)
|
||||
}
|
||||
fmt.Println("Jetzt in Claude Code oder OpenCode ausprobieren. Aufräumen mit: hugin try --remove")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&server, "server", "", "Adresse von Hugin (sonst aus .hugin.yaml oder HUGIN_SERVER)")
|
||||
cmd.Flags().BoolVar(&remove, "remove", false, "Dateien der letzten Probe wieder entfernen")
|
||||
cmd.Flags().BoolVar(&overwrite, "overwrite", false, "vorhandene Dateien überschreiben")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func uploadCommand() *cobra.Command {
|
||||
var server string
|
||||
var submit bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "upload [folder]",
|
||||
Short: "lädt einen Paketordner als Entwurf hoch (mit --submit gleich zur Prüfung)",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := authorClient(server)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := pkgdir.Zip(folderArg(args))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v, err := client.Upload(raw)
|
||||
if err != nil {
|
||||
return linterError(err)
|
||||
}
|
||||
printFindings(v.Linter)
|
||||
fmt.Printf("Entwurf %s %s gespeichert (Risiko %s).\n", v.Package, v.Version, v.Risk)
|
||||
if submit {
|
||||
if v, err = client.Submit(v.Package, v.Version); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Eingereicht: %s %s ist jetzt %s. Eine zweite Person prüft.\n", v.Package, v.Version, v.Status)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&server, "server", "", "Adresse von Hugin (sonst aus .hugin.yaml oder HUGIN_SERVER)")
|
||||
cmd.Flags().BoolVar(&submit, "submit", false, "nach dem Hochladen zur Prüfung einreichen")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func searchCommand() *cobra.Command {
|
||||
var server, kind string
|
||||
cmd := &cobra.Command{
|
||||
Use: "search [text]",
|
||||
Short: "sucht im Katalog in ID und Beschreibung (für Teams und Autoren)",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := authorClient(server)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
text := ""
|
||||
if len(args) == 1 {
|
||||
text = args[0]
|
||||
}
|
||||
matches, err := client.Search(text, kind)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
fmt.Println("Keine Pakete gefunden.")
|
||||
return nil
|
||||
}
|
||||
for _, t := range matches {
|
||||
fmt.Printf("%-32s %-9s %-8s %s\n", t.ID, t.Kind, t.Version, shorten(t.Description, 80))
|
||||
}
|
||||
fmt.Printf("\n%d Paket(e). Abonnieren: in .hugin.yaml unter packages eintragen, dann hugin sync.\n", len(matches))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&server, "server", "", "Adresse von Hugin (sonst aus .hugin.yaml oder HUGIN_SERVER)")
|
||||
cmd.Flags().StringVar(&kind, "kind", "", "nur diese Art: skill, rules, command, subagent, mcp, plugin")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func shorten(text string, n int) string {
|
||||
r := []rune(text)
|
||||
if len(r) <= n {
|
||||
return text
|
||||
}
|
||||
return string(r[:n-1]) + "…"
|
||||
}
|
||||
68
cli/cmd/hugin/import.go
Normal file
68
cli/cmd/hugin/import.go
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"hugin/cli/internal/config"
|
||||
"hugin/cli/internal/importer"
|
||||
)
|
||||
|
||||
func importCommand() *cobra.Command {
|
||||
var opt importer.Options
|
||||
cmd := &cobra.Command{
|
||||
Use: "import",
|
||||
Short: "wandelt vorhandene Skills, Agenten, Befehle, Regeln und MCP-Server des Repos in Hugin-Pakete um (OpenCode zuerst)",
|
||||
Long: `Liest .opencode/, opencode.json, AGENTS.md und danach .claude/, .mcp.json, CLAUDE.md
|
||||
und legt je Fund einen Paketordner im Quellformat an. Gibt es denselben Namen in
|
||||
OpenCode und Claude Code, gilt die OpenCode-Fassung. Ändert im Repo nichts.
|
||||
|
||||
Danach jedes Paket prüfen (IMPORT.md im Zielordner listet Hinweise) und mit
|
||||
hugin validate / hugin upload --submit einreichen.`,
|
||||
Example: " hugin import --prefix shop- --target ../hugin-packages\n hugin import --prefix pub- --owner kern",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opt.Repo = repoFolder
|
||||
if opt.Owner == "" {
|
||||
var k *config.Config
|
||||
if _, err := os.Stat(filepath.Join(repoFolder, config.Filename)); err == nil {
|
||||
if k, err = config.Load(repoFolder); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if k == nil || k.Project == "" {
|
||||
return errors.New("--owner fehlt (Projekt, das die Pakete pflegt); ohne .hugin.yaml bitte angeben")
|
||||
}
|
||||
opt.Owner = k.Project
|
||||
}
|
||||
b, err := importer.Import(opt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, p := range b.Packages {
|
||||
fmt.Printf("%-9s %-36s ← %v\n", p.Kind, p.ID, p.Sources[0])
|
||||
for _, h := range p.Hints {
|
||||
fmt.Printf(" Hinweis: %s\n", h)
|
||||
}
|
||||
}
|
||||
for _, g := range b.Skipped {
|
||||
fmt.Printf("nicht übernommen: %s: %s\n", g.Source, g.Text)
|
||||
}
|
||||
fmt.Printf("\n%d Paket(e) in %s, Übersicht in %s/IMPORT.md\n", len(b.Packages), opt.Target, opt.Target)
|
||||
if len(b.Packages) > 0 {
|
||||
fmt.Printf("Weiter: hugin validate %s/<paket>, dann hugin upload --submit %s/<paket>\n", opt.Target, opt.Target)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opt.Prefix, "prefix", "", "Präfix der IDs: pub- (öffentlich) oder <tenant>- (Pflicht)")
|
||||
cmd.Flags().StringVar(&opt.Owner, "owner", "", "Projekt, das die Pakete pflegt (Standard: project aus .hugin.yaml)")
|
||||
cmd.Flags().StringVar(&opt.Target, "target", "hugin-import", "Ordner für die Pakete")
|
||||
cmd.Flags().BoolVar(&opt.Overwrite, "overwrite", false, "vorhandene Paketordner im Ziel ersetzen")
|
||||
cmd.MarkFlagRequired("prefix")
|
||||
return cmd
|
||||
}
|
||||
45
cli/cmd/hugin/init.go
Normal file
45
cli/cmd/hugin/init.go
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"hugin/cli/internal/config"
|
||||
"hugin/cli/internal/tools"
|
||||
)
|
||||
|
||||
func initCommand() *cobra.Command {
|
||||
var server, project string
|
||||
cmd := &cobra.Command{
|
||||
Use: "init",
|
||||
Short: "legt .hugin.yaml an",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if server == "" || project == "" {
|
||||
return errors.New("--server und --project sind Pflicht")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(repoFolder, config.Filename)); err == nil {
|
||||
return fmt.Errorf("%s gibt es schon", config.Filename)
|
||||
}
|
||||
k := &config.Config{
|
||||
Server: server,
|
||||
Project: project,
|
||||
Tools: config.Tools{Auto: true},
|
||||
Packages: []string{},
|
||||
}
|
||||
if err := k.Save(repoFolder); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%s angelegt. Erkannte Werkzeuge: %v\n", config.Filename, tools.InRepo(repoFolder))
|
||||
fmt.Println("Pakete unter packages: eintragen, dann hugin sync.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&server, "server", "", "Adresse von Hugin, z. B. https://hugin.intern")
|
||||
cmd.Flags().StringVar(&project, "project", "", "Projekt-Slug in Hugin")
|
||||
return cmd
|
||||
}
|
||||
42
cli/cmd/hugin/main.go
Normal file
42
cli/cmd/hugin/main.go
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
// hugin ist das Kommandozeilenprogramm für Teams und Autoren (→ KONZEPT.md 11).
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Version wird beim Bauen gesetzt: -ldflags "-X main.Version=…"
|
||||
var Version = "dev"
|
||||
|
||||
// findingError bedeutet: Befehl lief, hat aber etwas gefunden (Rückgabewert 1).
|
||||
type findingError struct{ text string }
|
||||
|
||||
func (e findingError) Error() string { return e.text }
|
||||
|
||||
func main() {
|
||||
root := &cobra.Command{
|
||||
Use: "hugin",
|
||||
Short: "Skills, Regeln, Befehle, Subagenten und MCP-Anbindungen aus Hugin abgleichen",
|
||||
Version: Version,
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
}
|
||||
root.PersistentFlags().StringVarP(&repoFolder, "repo", "C", ".", "Wurzel des Repos")
|
||||
root.AddCommand(initCommand(), syncCommand(), checkCommand(), statusCommand(), loginCommand(),
|
||||
newCommand(), validateCommand(), tryCommand(), uploadCommand(), searchCommand(), importCommand(), mcpCommand())
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "hugin:", err)
|
||||
var finding findingError
|
||||
if errors.As(err, &finding) {
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
var repoFolder string
|
||||
127
cli/cmd/hugin/mcp.go
Normal file
127
cli/cmd/hugin/mcp.go
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"hugin/cli/internal/bundle"
|
||||
"hugin/cli/internal/localmcp"
|
||||
"hugin/cli/internal/lock"
|
||||
)
|
||||
|
||||
func mcpCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "mcp",
|
||||
Short: "lokale MCP-Server: starten und aufräumen",
|
||||
}
|
||||
cmd.AddCommand(mcpRunCommand(), mcpPruneCommand())
|
||||
return cmd
|
||||
}
|
||||
|
||||
// mcpRunCommand startet einen lokalen Server; so steht es in opencode.json bzw. .mcp.json.
|
||||
// Die Version kommt aus hugin.lock des Repos, in dem das Werkzeug läuft; läuft dort eine
|
||||
// Probe (hugin try), gilt deren Version.
|
||||
func mcpRunCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "run <paket>",
|
||||
Short: "lokalen MCP-Server in der Version aus hugin.lock starten (rufen OpenCode und Claude Code auf)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
id := args[0]
|
||||
repo, err := localmcp.FindRepo(repoFolder)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p, ok, err := lockedVersion(repo, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("%s steht nicht in %s; im Repo hugin sync ausführen", id, lock.Filename)
|
||||
}
|
||||
home, err := localmcp.Home()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
folder := localmcp.Folder(home, id, p.Version)
|
||||
m, err := localmcp.Read(folder)
|
||||
if err != nil || m.Checksum != p.Checksum {
|
||||
return fmt.Errorf("lokaler Server %s %s ist auf diesem Rechner nicht installiert; im Repo hugin sync ausführen",
|
||||
id, p.Version)
|
||||
}
|
||||
program, arguments, env := localmcp.Command(folder, m)
|
||||
return localmcp.Exec(program, arguments, env)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func mcpPruneCommand() *cobra.Command {
|
||||
var dryRun bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "prune",
|
||||
Short: "installierte lokale Server löschen, die kein bekanntes Repo mehr nutzt",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
home, err := localmcp.Home()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
removed, err := localmcp.Prune(home, dryRun)
|
||||
for _, r := range removed {
|
||||
if dryRun {
|
||||
fmt.Println(" würde löschen", r)
|
||||
} else {
|
||||
fmt.Println(" gelöscht ", r)
|
||||
}
|
||||
}
|
||||
if err == nil && len(removed) == 0 {
|
||||
fmt.Println("Nichts zu löschen.")
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "nur anzeigen")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func lockedVersion(repo, id string) (*lock.Package, bool, error) {
|
||||
for _, path := range []string{filepath.Join(repo, filepath.FromSlash(localmcp.TrialLock)), filepath.Join(repo, lock.Filename)} {
|
||||
l, err := lock.LoadFrom(path)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if p, ok := l.Packages[id]; ok {
|
||||
return p, true, nil
|
||||
}
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
// missingInstalls nennt die lokalen Server des Bundles, die auf diesem Rechner fehlen.
|
||||
func missingInstalls(b *bundle.Bundle, home string) []bundle.Operation {
|
||||
var missing []bundle.Operation
|
||||
for _, op := range b.Installs() {
|
||||
if !localmcp.Installed(home, op) {
|
||||
missing = append(missing, op)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
// install installiert fehlende lokale Server und merkt sich das Repo für prune.
|
||||
func install(b *bundle.Bundle, home string) error {
|
||||
for _, op := range missingInstalls(b, home) {
|
||||
fmt.Printf(" installiere %s %s (lokaler Server) nach %s\n", op.Package, op.Version,
|
||||
localmcp.Folder(home, op.Package, op.Version))
|
||||
if err := localmcp.Install(home, b, op, os.Stderr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(b.Installs()) > 0 {
|
||||
return localmcp.Register(home, repoFolder)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
106
cli/cmd/hugin/status.go
Normal file
106
cli/cmd/hugin/status.go
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"hugin/cli/internal/config"
|
||||
"hugin/cli/internal/keystore"
|
||||
"hugin/cli/internal/lock"
|
||||
"hugin/cli/internal/tools"
|
||||
)
|
||||
|
||||
func statusCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "listet Abos, installierte Versionen und Werkzeuge",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
k, err := config.Load(repoFolder)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := lock.Load(repoFolder)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Server: %s\nProjekt: %s\n\nAbos:\n", k.Server, k.Project)
|
||||
for _, subscription := range k.Packages {
|
||||
id, _, _ := strings.Cut(subscription, "@")
|
||||
installed := "nicht installiert"
|
||||
if p, ok := l.Packages[id]; ok {
|
||||
installed = p.Version
|
||||
}
|
||||
fmt.Printf(" %-40s %s\n", subscription, installed)
|
||||
}
|
||||
var more []string
|
||||
for _, id := range slices.Sorted(maps.Keys(l.Packages)) {
|
||||
if !slices.ContainsFunc(k.Packages, func(a string) bool { return strings.Split(a, "@")[0] == id }) {
|
||||
more = append(more, fmt.Sprintf(" %-40s %s", id, l.Packages[id].Version))
|
||||
}
|
||||
}
|
||||
if len(more) > 0 {
|
||||
fmt.Println("\nInstalliert ohne Abo (z. B. Pflichtpakete):")
|
||||
fmt.Println(strings.Join(more, "\n"))
|
||||
}
|
||||
fmt.Println("\nWerkzeuge:")
|
||||
names := k.Tools.List
|
||||
if len(names) == 0 {
|
||||
names = tools.InRepo(repoFolder)
|
||||
}
|
||||
for _, name := range names {
|
||||
version := tools.Version(name)
|
||||
state := "ok"
|
||||
switch {
|
||||
case version == "":
|
||||
version, state = "–", "nicht gefunden"
|
||||
case tools.Less(version, tools.Floors[name]):
|
||||
state = "älter als " + tools.Floors[name]
|
||||
}
|
||||
fmt.Printf(" %-10s %-10s %s\n", name, version, state)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func loginCommand() *cobra.Command {
|
||||
var logout bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "login",
|
||||
Short: "speichert den Key im Schlüsselbund des Betriebssystems",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
k, err := config.Load(repoFolder)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if logout {
|
||||
return keystore.Delete(k.Server)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Key für %s: ", k.Server)
|
||||
row, err := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
if err != nil && row == "" {
|
||||
return err
|
||||
}
|
||||
key := strings.TrimSpace(row)
|
||||
if key == "" {
|
||||
return errors.New("kein Key eingegeben")
|
||||
}
|
||||
if err := keystore.Save(k.Server, key); err != nil {
|
||||
return fmt.Errorf("Schlüsselbund: %w; alternativ %s setzen", err, keystore.Env)
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "gespeichert.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&logout, "logout", false, "Key aus dem Schlüsselbund löschen")
|
||||
return cmd
|
||||
}
|
||||
338
cli/cmd/hugin/sync.go
Normal file
338
cli/cmd/hugin/sync.go
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"hugin/cli/internal/api"
|
||||
"hugin/cli/internal/bundle"
|
||||
"hugin/cli/internal/config"
|
||||
"hugin/cli/internal/keystore"
|
||||
"hugin/cli/internal/localmcp"
|
||||
"hugin/cli/internal/lock"
|
||||
"hugin/cli/internal/reconcile"
|
||||
"hugin/cli/internal/repo"
|
||||
"hugin/cli/internal/tools"
|
||||
)
|
||||
|
||||
type syncOptions struct {
|
||||
bundle string
|
||||
major bool
|
||||
overwrite bool
|
||||
strict bool
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
func syncCommand() *cobra.Command {
|
||||
var opt syncOptions
|
||||
cmd := &cobra.Command{
|
||||
Use: "sync",
|
||||
Short: "holt die abonnierten und die Pflichtpakete und gleicht das Repo ab",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runSync(opt, false)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opt.bundle, "bundle", "", "Bundle aus Datei oder Ordner statt vom Server")
|
||||
cmd.Flags().BoolVar(&opt.major, "major", false, "Major-Sprünge erlauben")
|
||||
cmd.Flags().BoolVar(&opt.overwrite, "overwrite", false, "Handänderungen und Konflikte überschreiben")
|
||||
cmd.Flags().BoolVar(&opt.strict, "strict", false, "bei zu alten Werkzeugen abbrechen")
|
||||
cmd.Flags().BoolVar(&opt.dryRun, "dry-run", false, "nur anzeigen, nichts schreiben")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func checkCommand() *cobra.Command {
|
||||
var opt syncOptions
|
||||
cmd := &cobra.Command{
|
||||
Use: "check",
|
||||
Short: "ändert nichts; meldet Neues, Handänderungen und zu alte Werkzeuge (Rückgabewert 1 bei Befund)",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opt.dryRun = true
|
||||
return runSync(opt, true)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opt.bundle, "bundle", "", "Bundle aus Datei oder Ordner statt vom Server")
|
||||
return cmd
|
||||
}
|
||||
|
||||
type tool struct{ name, version string }
|
||||
|
||||
// checkCliVersion vergleicht die eigene Version mit den Angaben des Servers (→ KONZEPT.md 18).
|
||||
// Eine Entwicklungsfassung (Version "dev") prüft nichts.
|
||||
func checkCliVersion(conf *api.Configuration) []string {
|
||||
if Version == "dev" || !semverPattern.MatchString(Version) {
|
||||
return nil
|
||||
}
|
||||
if m := conf.CLI.MinVersion; m != "" && tools.Less(Version, m) {
|
||||
return []string{fmt.Sprintf("hugin %s ist älter als die nötige Version %s; neues Programm unter %s",
|
||||
Version, m, "„Programm hugin“ in der Weboberfläche")}
|
||||
}
|
||||
if n := conf.CLI.Newest; n != "" && tools.Less(Version, n) {
|
||||
fmt.Fprintf(os.Stderr, "Hinweis: hugin %s ist verfügbar (installiert: %s)\n", n, Version)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var semverPattern = regexp.MustCompile(`^\d+\.\d+\.\d+$`)
|
||||
|
||||
// detectTools liefert die Werkzeuge laut .hugin.yaml (oder erkannt im
|
||||
// Repo) mit installierter Version und Warnungen zu alten Versionen.
|
||||
func detectTools(k *config.Config) ([]tool, []string) {
|
||||
var names []string
|
||||
if k != nil {
|
||||
names = k.Tools.List
|
||||
}
|
||||
if len(names) == 0 {
|
||||
names = tools.InRepo(repoFolder)
|
||||
}
|
||||
var detected []tool
|
||||
var warnings []string
|
||||
for _, name := range names {
|
||||
w := tool{name, tools.Version(name)}
|
||||
detected = append(detected, w)
|
||||
if floor := tools.Floors[name]; w.version != "" && tools.Less(w.version, floor) {
|
||||
warnings = append(warnings, fmt.Sprintf("%s %s ist älter als die Untergrenze %s", name, w.version, floor))
|
||||
}
|
||||
}
|
||||
return detected, warnings
|
||||
}
|
||||
|
||||
func runSync(opt syncOptions, check bool) error {
|
||||
k, err := config.Load(repoFolder)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var cliWarnings []string
|
||||
old, err := lock.Load(repoFolder)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 1. Untergrenzen vom Server (sonst die eingebauten), Werkzeuge erkennen und prüfen
|
||||
var client *api.Client
|
||||
if opt.bundle == "" {
|
||||
key, err := keystore.Get(k.Server)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if client, err = api.New(k.Server, key); err != nil {
|
||||
return err
|
||||
}
|
||||
conf, err := client.Configuration()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for name, version := range conf.Floors {
|
||||
tools.Floors[name] = version
|
||||
}
|
||||
cliWarnings = checkCliVersion(conf)
|
||||
}
|
||||
detected, warnings := detectTools(k)
|
||||
warnings = append(cliWarnings, warnings...)
|
||||
for _, w := range warnings {
|
||||
fmt.Fprintln(os.Stderr, "Warnung:", w)
|
||||
}
|
||||
if len(warnings) > 0 && !check && (opt.strict || k.Strict()) {
|
||||
return errors.New("Werkzeuge zu alt (tool_check: strict)")
|
||||
}
|
||||
|
||||
// 2. Bundle holen
|
||||
var b *bundle.Bundle
|
||||
if opt.bundle != "" {
|
||||
b, err = bundle.Read(opt.bundle)
|
||||
} else {
|
||||
b, err = get(client, k, old, detected, opt.major)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, w := range detected {
|
||||
if !slices.Contains(b.Instructions.Tools, tools.Target(w.name, w.version)) {
|
||||
fmt.Fprintf(os.Stderr, "Warnung: Bundle enthält nichts für %s\n", tools.Target(w.name, w.version))
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Planen
|
||||
plan, err := reconcile.MakePlan(repoFolder, b, old, reconcile.Options{Overwrite: opt.overwrite})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
report(plan, b, old)
|
||||
// Fehlende Variablen nur melden: hugin check läuft oft in CI, wo die Keys der MCP-Server fehlen dürfen
|
||||
for _, w := range missingEnv(b.Instructions.Hints, os.LookupEnv) {
|
||||
fmt.Fprintln(os.Stderr, "Warnung:", w)
|
||||
}
|
||||
|
||||
// Lokale Server liegen außerhalb des Repos: auch ein frisch geklontes, sonst aktuelles Repo
|
||||
// braucht sie noch auf diesem Rechner
|
||||
home, err := localmcp.Home()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
missing := missingInstalls(b, home)
|
||||
for _, op := range missing {
|
||||
if check {
|
||||
fmt.Fprintf(os.Stderr, "Befund: lokaler Server %s %s ist auf diesem Rechner nicht installiert\n", op.Package, op.Version)
|
||||
} else if opt.dryRun {
|
||||
fmt.Printf(" würde installieren %s %s (lokaler Server)\n", op.Package, op.Version)
|
||||
}
|
||||
}
|
||||
|
||||
// check prüft den Ist-Stand, sync den Stand nach dem Schreiben (unten)
|
||||
var opencodeError string
|
||||
if check {
|
||||
if opencodeError = checkOpenCode(detected); opencodeError != "" {
|
||||
fmt.Fprintln(os.Stderr, "Befund: OpenCode lehnt die Konfiguration ab:\n"+opencodeError)
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case check && opencodeError != "" && !plan.Changed() && len(plan.Findings) == 0 && len(missing) == 0:
|
||||
return findingError{"OpenCode-Konfiguration reparieren; das kann hugin sync nicht"}
|
||||
case check:
|
||||
if plan.Changed() || len(plan.Findings) > 0 || len(warnings) > 0 || opencodeError != "" || len(missing) > 0 {
|
||||
return findingError{"Abweichungen gefunden; hugin sync gleicht ab"}
|
||||
}
|
||||
fmt.Println("Alles aktuell.")
|
||||
return nil
|
||||
case plan.Open():
|
||||
return findingError{"nichts geändert; Handänderungen erst klären oder mit --overwrite zurücksetzen"}
|
||||
case opt.dryRun:
|
||||
fmt.Println("Trockenlauf: nichts geschrieben.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 4. Schreiben
|
||||
if err := plan.Run(repoFolder); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := install(b, home); err != nil {
|
||||
return fmt.Errorf("Dateien geschrieben, aber lokaler Server nicht installiert: %w", err)
|
||||
}
|
||||
if !plan.Changed() && len(missing) == 0 {
|
||||
fmt.Println("Alles aktuell.")
|
||||
} else if msg := checkOpenCode(detected); msg != "" {
|
||||
return fmt.Errorf("geschrieben, aber OpenCode startet so nicht; erst die Konfiguration reparieren:\n%s", msg)
|
||||
}
|
||||
|
||||
// 5. Nutzung melden; ein Fehler hier macht den sync nicht ungültig
|
||||
if client != nil {
|
||||
if err := client.Usage(usage(plan.Lock, detected)); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Warnung: Nutzung nicht gemeldet:", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func get(client *api.Client, k *config.Config, old *lock.Lock, detected []tool, major bool) (*bundle.Bundle, error) {
|
||||
request := api.BundleRequest{
|
||||
Packages: k.Packages,
|
||||
Unsubscribed: k.Unsubscribed,
|
||||
Tools: map[string]string{},
|
||||
Mandatory: true,
|
||||
Major: major,
|
||||
Options: map[string]bool{"opencode_reads_claude": k.ReadsClaude()},
|
||||
Installed: map[string]string{},
|
||||
}
|
||||
for _, w := range detected {
|
||||
request.Tools[w.name] = w.version
|
||||
}
|
||||
for id, p := range old.Packages {
|
||||
request.Installed[id] = p.Version
|
||||
}
|
||||
raw, err := client.Bundle(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bundle.FromZip(raw)
|
||||
}
|
||||
|
||||
func usage(l *lock.Lock, detected []tool) api.Usage {
|
||||
n := api.Usage{Repo: repo.Id(repoFolder), Tools: map[string]string{}, Installed: map[string]string{}}
|
||||
for _, w := range detected {
|
||||
n.Tools[w.name] = w.version
|
||||
}
|
||||
for id, p := range l.Packages {
|
||||
n.Installed[id] = p.Version
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// checkOpenCode lässt OpenCode die Konfiguration laden, wenn das Repo OpenCode nutzt und es
|
||||
// installiert ist; liefert OpenCodes Fehlermeldung oder nichts.
|
||||
func checkOpenCode(detected []tool) string {
|
||||
for _, w := range detected {
|
||||
if w.name == "opencode" && w.version != "" {
|
||||
return tools.CheckOpenCode(repoFolder)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func report(plan *reconcile.Plan, b *bundle.Bundle, old *lock.Lock) {
|
||||
for _, p := range b.Instructions.Packages {
|
||||
before, ok := old.Packages[p.ID]
|
||||
switch {
|
||||
case !ok:
|
||||
fmt.Printf(" neu %s %s\n", p.ID, p.Version)
|
||||
case before.Version != p.Version:
|
||||
fmt.Printf(" aktualisiert %s %s → %s\n", p.ID, before.Version, p.Version)
|
||||
}
|
||||
}
|
||||
for _, id := range slices.Sorted(maps.Keys(old.Packages)) {
|
||||
if _, stays := plan.Lock.Packages[id]; !stays {
|
||||
fmt.Printf(" entfernt %s %s\n", id, old.Packages[id].Version)
|
||||
}
|
||||
}
|
||||
if plan.Changed() {
|
||||
fmt.Printf("Dateien: %d schreiben (%d neu), %d löschen\n", len(plan.Write), len(plan.New), len(plan.Delete))
|
||||
}
|
||||
for _, f := range plan.Findings {
|
||||
fmt.Fprintln(os.Stderr, "Befund:", f)
|
||||
}
|
||||
for _, h := range b.Instructions.Hints {
|
||||
if h.Kind != "env_variable" { // eigene Prüfung in missingEnv
|
||||
fmt.Println("Hinweis:", hintText(h))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// missingEnv nennt die Umgebungsvariablen, die Pakete brauchen (MCP-Kopfzeilen) und die
|
||||
// nicht oder leer gesetzt sind. Gesetzte Variablen bleiben still.
|
||||
func missingEnv(hints []bundle.Hint, lookup func(string) (string, bool)) []string {
|
||||
var missing []string
|
||||
for _, h := range hints {
|
||||
if h.Kind != "env_variable" {
|
||||
continue
|
||||
}
|
||||
if value, ok := lookup(h.Name); !ok || value == "" {
|
||||
missing = append(missing, fmt.Sprintf("%s braucht die Umgebungsvariable %s; sie ist nicht gesetzt, "+
|
||||
"der MCP-Server bekommt sonst keinen Wert (z. B. keinen Key)", h.Package, h.Name))
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
func hintText(h bundle.Hint) string {
|
||||
switch h.Kind {
|
||||
case "min_version":
|
||||
return fmt.Sprintf("%s braucht %s ≥ %s", h.Package, h.Tool, h.Version)
|
||||
case "deprecated":
|
||||
text := h.Package + " ist veraltet"
|
||||
if h.Successor != "" {
|
||||
text += "; Nachfolger: " + h.Successor
|
||||
}
|
||||
return text
|
||||
case "major_available":
|
||||
return fmt.Sprintf("%s %s ist verfügbar; übernehmen mit hugin sync --major", h.Package, h.Version)
|
||||
}
|
||||
return strings.TrimSpace(h.Kind + " " + h.Package)
|
||||
}
|
||||
31
cli/cmd/hugin/sync_test.go
Normal file
31
cli/cmd/hugin/sync_test.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hugin/cli/internal/bundle"
|
||||
)
|
||||
|
||||
func TestMissingEnv(t *testing.T) {
|
||||
hints := []bundle.Hint{
|
||||
{Kind: "env_variable", Package: "pub-munin-anbindung", Name: "MUNIN_KEY"},
|
||||
{Kind: "env_variable", Package: "pub-jira", Name: "JIRA_KEY"},
|
||||
{Kind: "env_variable", Package: "pub-wiki", Name: "WIKI_KEY"},
|
||||
{Kind: "min_version", Package: "pub-x", Tool: "claude", Version: "2.1.84"},
|
||||
}
|
||||
env := map[string]string{"MUNIN_KEY": "geheim", "WIKI_KEY": ""}
|
||||
lookup := func(name string) (string, bool) { v, ok := env[name]; return v, ok }
|
||||
|
||||
got := missingEnv(hints, lookup)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("erwartet JIRA_KEY (fehlt) und WIKI_KEY (leer), bekommen: %q", got)
|
||||
}
|
||||
if !strings.Contains(got[0], "pub-jira") || !strings.Contains(got[0], "JIRA_KEY") ||
|
||||
!strings.Contains(got[1], "WIKI_KEY") {
|
||||
t.Errorf("falsche Meldungen: %q", got)
|
||||
}
|
||||
if missingEnv(hints[:1], lookup) != nil {
|
||||
t.Error("gesetzte Variable darf nichts melden")
|
||||
}
|
||||
}
|
||||
18
cli/go.mod
Normal file
18
cli/go.mod
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
module hugin/cli
|
||||
|
||||
go 1.26.4
|
||||
|
||||
require (
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/tailscale/hujson v0.0.0-20260727124030-b80ff77dac4f
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
golang.org/x/sys v0.27.0 // indirect
|
||||
)
|
||||
33
cli/go.sum
Normal file
33
cli/go.sum
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
|
||||
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tailscale/hujson v0.0.0-20260727124030-b80ff77dac4f h1:9hiVElpCmKzsBKQHkBqZ8LGzt82iLfM8egxr4sew+Ys=
|
||||
github.com/tailscale/hujson v0.0.0-20260727124030-b80ff77dac4f/go.mod h1:8/zr1Tv0+cKpVtGCEB/7YfRXr2TszsMxMXLaT8YuBgU=
|
||||
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
||||
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
|
||||
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
279
cli/internal/api/api.go
Normal file
279
cli/internal/api/api.go
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
// Package api spricht mit dem Hugin-Server unter /api/v1 (→ KONZEPT.md 14.6).
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
Base string // z. B. https://hugin.intern
|
||||
Key string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
// AllowHttp: Umgebungsvariable, die Keys auch unverschlüsselt an andere Rechner schickt (nur zum Testen).
|
||||
const AllowHttp = "HUGIN_ALLOW_HTTP"
|
||||
|
||||
// New baut einen Client. Keys gehen nur über HTTPS oder an den eigenen Rechner;
|
||||
// das gilt auch für Weiterleitungen.
|
||||
func New(base, key string) (*Client, error) {
|
||||
if err := safe(base); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := &Client{Base: strings.TrimSuffix(base, "/"), Key: key, HTTP: &http.Client{Timeout: 60 * time.Second}}
|
||||
c.HTTP.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 {
|
||||
return errors.New("zu viele Weiterleitungen")
|
||||
}
|
||||
return safe(req.URL.String())
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func safe(address string) error {
|
||||
u, err := url.Parse(address)
|
||||
if err != nil || u.Host == "" {
|
||||
return fmt.Errorf("Server %q: keine gültige Adresse (z. B. https://hugin.intern)", address)
|
||||
}
|
||||
switch {
|
||||
case u.Scheme == "https":
|
||||
return nil
|
||||
case u.Scheme != "http":
|
||||
return fmt.Errorf("Server %q: nur https:// (oder http:// zum eigenen Rechner)", address)
|
||||
}
|
||||
host := u.Hostname()
|
||||
if host == "localhost" || strings.HasSuffix(host, ".localhost") {
|
||||
return nil
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
|
||||
return nil
|
||||
}
|
||||
if os.Getenv(AllowHttp) == "1" {
|
||||
fmt.Fprintf(os.Stderr, "hugin: Warnung: Key geht unverschlüsselt an %s (%s=1)\n", u.Host, AllowHttp)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("Server %s: Keys gehen nicht unverschlüsselt über http:// an andere Rechner; https:// nutzen (zum Testen: %s=1)", u.Host, AllowHttp)
|
||||
}
|
||||
|
||||
// BundleRequest entspricht contract/bundle-request.schema.json.
|
||||
type BundleRequest struct {
|
||||
Packages []string `json:"packages"`
|
||||
Unsubscribed []string `json:"unsubscribed,omitempty"`
|
||||
Tools map[string]string `json:"tools"`
|
||||
Mandatory bool `json:"mandatory"`
|
||||
Major bool `json:"major,omitempty"`
|
||||
Options map[string]bool `json:"options,omitempty"`
|
||||
Installed map[string]string `json:"installed,omitempty"`
|
||||
}
|
||||
|
||||
// Bundle holt das ZIP für die Anfrage.
|
||||
func (c *Client) Bundle(request BundleRequest) ([]byte, error) {
|
||||
return c.post("/api/v1/bundle", request, "application/zip")
|
||||
}
|
||||
|
||||
// Configuration entspricht GET /api/v1/config.
|
||||
type Configuration struct {
|
||||
Floors map[string]string `json:"floors"`
|
||||
Mandatory []string `json:"mandatory"`
|
||||
CLI struct {
|
||||
MinVersion string `json:"min_version"`
|
||||
Newest string `json:"newest"`
|
||||
} `json:"cli"`
|
||||
}
|
||||
|
||||
func (c *Client) Configuration() (*Configuration, error) {
|
||||
var k Configuration
|
||||
return &k, c.get("/api/v1/config", &k)
|
||||
}
|
||||
|
||||
// Matches ist ein Eintrag im Katalog (GET /api/v1/packages).
|
||||
type Matches struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
Risk string `json:"risk"`
|
||||
Compatibility string `json:"compatibility"`
|
||||
}
|
||||
|
||||
func (c *Client) Search(text, kind string) ([]Matches, error) {
|
||||
path := "/api/v1/packages?q=" + url.QueryEscape(text)
|
||||
if kind != "" {
|
||||
path += "&kind=" + url.QueryEscape(kind)
|
||||
}
|
||||
var t []Matches
|
||||
return t, c.get(path, &t)
|
||||
}
|
||||
|
||||
func (c *Client) get(path string, target any) error {
|
||||
req, err := http.NewRequest(http.MethodGet, c.Base+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.Key)
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Hugin nicht erreichbar: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
response, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode >= 300 {
|
||||
text, _ := fehlertext(response)
|
||||
return &APIError{Status: resp.StatusCode, Text: "GET " + strings.SplitN(path, "?", 2)[0] + ": " + resp.Status + " " + text}
|
||||
}
|
||||
return json.Unmarshal(response, target)
|
||||
}
|
||||
|
||||
// Finding ist eine Meldung des Linters (→ KONZEPT.md 8).
|
||||
type Finding struct {
|
||||
Id string `json:"id"`
|
||||
Severity string `json:"severity"`
|
||||
Text string `json:"text"`
|
||||
File string `json:"file"`
|
||||
}
|
||||
|
||||
// ValidationResult ist die Antwort von POST /api/v1/validate.
|
||||
type ValidationResult struct {
|
||||
Findings []Finding `json:"findings"`
|
||||
Risk string `json:"risk"`
|
||||
Compatibility *string `json:"compatibility"`
|
||||
Preview map[string][]map[string]any `json:"preview"`
|
||||
}
|
||||
|
||||
func (c *Client) Validate(packageZip []byte) (*ValidationResult, error) {
|
||||
raw, err := c.send("/api/v1/validate", packageZip, "application/zip", "application/json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var p ValidationResult
|
||||
return &p, json.Unmarshal(raw, &p)
|
||||
}
|
||||
|
||||
// Trial liefert ein Bundle für einen ungespeicherten Paketordner.
|
||||
func (c *Client) Trial(packageZip []byte, tools map[string]string) ([]byte, error) {
|
||||
var parts []string
|
||||
for name, version := range tools {
|
||||
parts = append(parts, name+":"+version)
|
||||
}
|
||||
path := "/api/v1/trial?tools=" + url.QueryEscape(strings.Join(parts, ","))
|
||||
return c.send(path, packageZip, "application/zip", "application/zip")
|
||||
}
|
||||
|
||||
// Version ist der Stand einer Version nach Hochladen oder Einreichen.
|
||||
type Version struct {
|
||||
Package string `json:"package"`
|
||||
Version string `json:"version"`
|
||||
Status string `json:"status"`
|
||||
Risk string `json:"risk"`
|
||||
Linter []Finding `json:"linter"`
|
||||
}
|
||||
|
||||
func (c *Client) Upload(packageZip []byte) (*Version, error) {
|
||||
raw, err := c.send("/api/v1/drafts", packageZip, "application/zip", "application/json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var v Version
|
||||
return &v, json.Unmarshal(raw, &v)
|
||||
}
|
||||
|
||||
func (c *Client) Submit(pkg, version string) (*Version, error) {
|
||||
raw, err := c.send("/api/v1/packages/"+url.PathEscape(pkg)+"/versions/"+url.PathEscape(version)+"/submit",
|
||||
nil, "application/json", "application/json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var v Version
|
||||
return &v, json.Unmarshal(raw, &v)
|
||||
}
|
||||
|
||||
// APIError ist eine Fehlerantwort des Servers; bei 422 vom Linter mit Befunden.
|
||||
type APIError struct {
|
||||
Status int
|
||||
Text string
|
||||
Findings []Finding
|
||||
}
|
||||
|
||||
func (f *APIError) Error() string { return f.Text }
|
||||
|
||||
// Usage entspricht der Meldung nach sync (→ KONZEPT.md 11.5, Schritt 7).
|
||||
type Usage struct {
|
||||
Repo string `json:"repo"`
|
||||
Tools map[string]string `json:"tools"`
|
||||
Installed map[string]string `json:"installed"`
|
||||
}
|
||||
|
||||
func (c *Client) Usage(n Usage) error {
|
||||
_, err := c.post("/api/v1/usage", n, "application/json")
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) post(path string, body any, accept string) ([]byte, error) {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.send(path, raw, "application/json", accept)
|
||||
}
|
||||
|
||||
func (c *Client) send(path string, body []byte, inhaltstyp, accept string) ([]byte, error) {
|
||||
req, err := http.NewRequest(http.MethodPost, c.Base+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", inhaltstyp)
|
||||
req.Header.Set("Accept", accept)
|
||||
req.Header.Set("Authorization", "Bearer "+c.Key)
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Hugin nicht erreichbar: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
response, err := io.ReadAll(io.LimitReader(resp.Body, 256<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode >= 300 {
|
||||
f := &APIError{Status: resp.StatusCode}
|
||||
f.Text, f.Findings = fehlertext(response)
|
||||
f.Text = fmt.Sprintf("%s %s: %s %s", http.MethodPost, strings.SplitN(path, "?", 2)[0], resp.Status, f.Text)
|
||||
return nil, f
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func fehlertext(response []byte) (string, []Finding) {
|
||||
var f struct {
|
||||
Detail json.RawMessage `json:"detail"`
|
||||
}
|
||||
if json.Unmarshal(response, &f) == nil && len(f.Detail) > 0 {
|
||||
var withFindings struct {
|
||||
Findings []Finding `json:"findings"`
|
||||
}
|
||||
if json.Unmarshal(f.Detail, &withFindings) == nil && len(withFindings.Findings) > 0 {
|
||||
return "vom Linter abgelehnt", withFindings.Findings
|
||||
}
|
||||
var text string
|
||||
if json.Unmarshal(f.Detail, &text) == nil {
|
||||
return text, nil
|
||||
}
|
||||
return string(f.Detail), nil
|
||||
}
|
||||
if len(response) > 200 {
|
||||
response = response[:200]
|
||||
}
|
||||
return strings.TrimSpace(string(response)), nil
|
||||
}
|
||||
46
cli/internal/api/secure_test.go
Normal file
46
cli/internal/api/secure_test.go
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestKeysOnlyEncryptedOrLocal(t *testing.T) {
|
||||
t.Setenv(AllowHttp, "")
|
||||
for _, good := range []string{"https://hugin.intern", "http://127.0.0.1:8080", "http://localhost:8080", "http://[::1]:8080"} {
|
||||
if _, err := New(good, "k"); err != nil {
|
||||
t.Errorf("%s: %v", good, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"http://hugin.intern", "http://10.0.0.5:8080", "ftp://x", "hugin.intern", ""} {
|
||||
if _, err := New(bad, "k"); err == nil {
|
||||
t.Errorf("%s sollte abgelehnt werden", bad)
|
||||
}
|
||||
}
|
||||
t.Setenv(AllowHttp, "1")
|
||||
if _, err := New("http://hugin.intern", "k"); err != nil {
|
||||
t.Errorf("mit %s=1: %v", AllowHttp, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoRedirectToHTTP(t *testing.T) {
|
||||
t.Setenv(AllowHttp, "")
|
||||
seen := ""
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
seen = r.Header.Get("Authorization")
|
||||
http.Redirect(w, r, "http://boese.example/api/v1/config", http.StatusFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
c, err := New(server.URL, "hk_geheim")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := c.Configuration(); err == nil || !strings.Contains(err.Error(), "nicht unverschlüsselt") {
|
||||
t.Errorf("Weiterleitung auf http:// an fremden Rechner muss an der Prüfung scheitern: %v", err)
|
||||
}
|
||||
if seen != "Bearer hk_geheim" {
|
||||
t.Errorf("erste Anfrage: %q", seen)
|
||||
}
|
||||
}
|
||||
313
cli/internal/bundle/bundle.go
Normal file
313
cli/internal/bundle/bundle.go
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
// Package bundle liest Hugin-Bundles: ZIP (oder entpackter Ordner) mit
|
||||
// hugin-bundle.json, den Dateien fürs Repo unter files/ und denen lokaler Server
|
||||
// unter local/ (→ contract/hugin-bundle.schema.json).
|
||||
package bundle
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"hugin/cli/internal/paths"
|
||||
)
|
||||
|
||||
const (
|
||||
InstructionsFile = "hugin-bundle.json"
|
||||
Format = 1
|
||||
// Obergrenze je Datei im Bundle, gegen ZIP-Bomben
|
||||
maxFile = 16 << 20
|
||||
)
|
||||
|
||||
type Instructions struct {
|
||||
Format int `json:"format"`
|
||||
Generated string `json:"generated"`
|
||||
Tools []string `json:"tools"`
|
||||
Packages []Package `json:"packages"`
|
||||
Operations []Operation `json:"operations"`
|
||||
Hints []Hint `json:"hints"`
|
||||
}
|
||||
|
||||
type Package struct {
|
||||
ID string `json:"id"`
|
||||
Version string `json:"version"`
|
||||
Checksum string `json:"checksum"`
|
||||
Kind string `json:"kind"`
|
||||
Risk string `json:"risk"`
|
||||
Compatibility string `json:"compatibility"`
|
||||
Mandatory bool `json:"mandatory"`
|
||||
}
|
||||
|
||||
// Operation fasst alle Arten zusammen; welche Felder gelten, hängt von Op ab.
|
||||
type Operation struct {
|
||||
Op string `json:"op"`
|
||||
Package string `json:"package,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Sha256 string `json:"sha256,omitempty"`
|
||||
File string `json:"file,omitempty"`
|
||||
Block string `json:"block,omitempty"`
|
||||
Content *string `json:"content,omitempty"`
|
||||
Key []string `json:"key,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Value json.RawMessage `json:"value,omitempty"`
|
||||
Create string `json:"create,omitempty"`
|
||||
// install: lokaler Server außerhalb des Repos
|
||||
Version string `json:"version,omitempty"`
|
||||
Checksum string `json:"checksum,omitempty"`
|
||||
Runtime string `json:"runtime,omitempty"`
|
||||
Entry string `json:"entry,omitempty"`
|
||||
Python string `json:"python,omitempty"`
|
||||
Files []InstallFile `json:"files,omitempty"`
|
||||
}
|
||||
|
||||
// InstallFile ist eine Datei eines lokalen Servers; Path gilt relativ zu seinem Ordner.
|
||||
type InstallFile struct {
|
||||
Path string `json:"path"`
|
||||
Source string `json:"source"`
|
||||
Sha256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
// Installs liefert die install-Operationen, also die lokalen Server des Bundles.
|
||||
func (b *Bundle) Installs() []Operation {
|
||||
var ops []Operation
|
||||
for _, op := range b.Instructions.Operations {
|
||||
if op.Op == "install" {
|
||||
ops = append(ops, op)
|
||||
}
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
// ListItem sagt, ob Value in einer Liste stehen soll statt den Schlüssel zu ersetzen.
|
||||
func (o Operation) ListItem() bool { return o.Mode == "list_item" }
|
||||
|
||||
type Hint struct {
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Package string `json:"package,omitempty"`
|
||||
Tool string `json:"tool,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Successor string `json:"successor,omitempty"`
|
||||
}
|
||||
|
||||
type Bundle struct {
|
||||
Instructions Instructions
|
||||
files map[string][]byte
|
||||
}
|
||||
|
||||
// Empty ist ein Bundle ohne Pakete; angewendet entfernt es alles, was die Lock-Datei nennt.
|
||||
func Empty() *Bundle {
|
||||
return &Bundle{Instructions: Instructions{Format: Format}, files: map[string][]byte{}}
|
||||
}
|
||||
|
||||
// File liefert den Inhalt einer Datei im Bundle (Pfad wie in source/create).
|
||||
func (b *Bundle) File(source string) ([]byte, error) {
|
||||
data, ok := b.files[source]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s fehlt im Bundle", source)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Read öffnet ein Bundle als ZIP-Datei oder entpackten Ordner und prüft es.
|
||||
func Read(path string) (*Bundle, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var files map[string][]byte
|
||||
if info.IsDir() {
|
||||
files, err = fromFolder(path)
|
||||
} else {
|
||||
var raw []byte
|
||||
if raw, err = os.ReadFile(path); err == nil {
|
||||
files, err = fromZip(raw)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fromFiles(files)
|
||||
}
|
||||
|
||||
// FromZip liest ein Bundle aus den Bytes einer ZIP-Datei (Antwort von POST /bundle).
|
||||
func FromZip(raw []byte) (*Bundle, error) {
|
||||
files, err := fromZip(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fromFiles(files)
|
||||
}
|
||||
|
||||
func fromZip(raw []byte) (map[string][]byte, error) {
|
||||
z, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Bundle ist kein ZIP: %w", err)
|
||||
}
|
||||
files := map[string][]byte{}
|
||||
for _, f := range z.File {
|
||||
if strings.HasSuffix(f.Name, "/") {
|
||||
continue
|
||||
}
|
||||
if f.UncompressedSize64 > maxFile {
|
||||
return nil, fmt.Errorf("%s ist zu groß", f.Name)
|
||||
}
|
||||
r, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(r, maxFile+1))
|
||||
r.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files[f.Name] = data
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func fromFolder(folder string) (map[string][]byte, error) {
|
||||
files := map[string][]byte{}
|
||||
err := filepath.WalkDir(folder, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(folder, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := os.ReadFile(p)
|
||||
files[filepath.ToSlash(rel)] = data
|
||||
return err
|
||||
})
|
||||
return files, err
|
||||
}
|
||||
|
||||
func fromFiles(files map[string][]byte) (*Bundle, error) {
|
||||
raw, ok := files[InstructionsFile]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s fehlt im Bundle", InstructionsFile)
|
||||
}
|
||||
var a Instructions
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", InstructionsFile, err)
|
||||
}
|
||||
b := &Bundle{Instructions: a, files: files}
|
||||
return b, b.validate()
|
||||
}
|
||||
|
||||
func (b *Bundle) validate() error {
|
||||
if b.Instructions.Format != Format {
|
||||
return fmt.Errorf("Bundle-Format %d wird nicht unterstützt (erwartet %d); hugin aktualisieren",
|
||||
b.Instructions.Format, Format)
|
||||
}
|
||||
var errs []error
|
||||
for i, op := range b.Instructions.Operations {
|
||||
if err := b.validateOp(op); err != nil {
|
||||
errs = append(errs, fmt.Errorf("Operation %d (%s): %w", i+1, op.Op, err))
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (b *Bundle) validateOp(op Operation) error {
|
||||
switch op.Op {
|
||||
case "file":
|
||||
if err := paths.Validate(op.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
return b.validateSource(op.Source, op.Sha256)
|
||||
case "remove":
|
||||
return paths.Validate(op.Path)
|
||||
case "block":
|
||||
if op.Content == nil || op.Block == "" {
|
||||
return errors.New("block und inhalt sind Pflicht")
|
||||
}
|
||||
if op.Create != "" {
|
||||
if err := b.validateSource(op.Create, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return paths.Validate(op.File)
|
||||
case "json", "json_remove":
|
||||
if len(op.Key) == 0 {
|
||||
return errors.New("key fehlt")
|
||||
}
|
||||
if op.Op == "json" && len(op.Value) == 0 {
|
||||
return errors.New("value fehlt")
|
||||
}
|
||||
if op.Create != "" {
|
||||
if err := b.validateSource(op.Create, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return paths.Validate(op.File)
|
||||
case "install":
|
||||
return b.validateInstall(op)
|
||||
}
|
||||
return fmt.Errorf("unbekannte Operation %q; hugin aktualisieren", op.Op)
|
||||
}
|
||||
|
||||
var (
|
||||
packageId = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
|
||||
semver = regexp.MustCompile(`^\d+\.\d+\.\d+$`)
|
||||
)
|
||||
|
||||
func (b *Bundle) validateInstall(op Operation) error {
|
||||
// Paket und Version werden Ordnernamen außerhalb des Repos: streng prüfen
|
||||
if !packageId.MatchString(op.Package) || !semver.MatchString(op.Version) {
|
||||
return fmt.Errorf("ungültiges Paket %q oder Version %q", op.Package, op.Version)
|
||||
}
|
||||
if op.Entry == "" || op.Checksum == "" {
|
||||
return errors.New("entry und checksum sind Pflicht")
|
||||
}
|
||||
prefix := "local/" + op.Package + "/" + op.Version + "/"
|
||||
for _, f := range op.Files {
|
||||
if err := paths.Validate(f.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
if f.Source != prefix+f.Path {
|
||||
return fmt.Errorf("source %q passt nicht zu %s%s", f.Source, prefix, f.Path)
|
||||
}
|
||||
data, err := b.File(f.Source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if Sha256(data) != f.Sha256 {
|
||||
return fmt.Errorf("%s: Prüfsumme stimmt nicht", f.Source)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bundle) validateSource(source, sha string) error {
|
||||
if !strings.HasPrefix(source, "files/") {
|
||||
return fmt.Errorf("source %q liegt nicht unter files/", source)
|
||||
}
|
||||
if err := paths.Validate(source); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := b.File(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sha != "" && Sha256(data) != sha {
|
||||
return fmt.Errorf("%s: Prüfsumme stimmt nicht", source)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Sha256(data []byte) string {
|
||||
total := sha256.Sum256(data)
|
||||
return "sha256:" + hex.EncodeToString(total[:])
|
||||
}
|
||||
92
cli/internal/bundle/bundle_test.go
Normal file
92
cli/internal/bundle/bundle_test.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package bundle
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const examples = "../../../contract/examples/bundles"
|
||||
|
||||
func TestReadExamples(t *testing.T) {
|
||||
entries, err := os.ReadDir(examples)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if _, err := Read(filepath.Join(examples, e.Name())); err != nil {
|
||||
t.Errorf("%s: %v", e.Name(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func zipWith(t *testing.T, instructions map[string]any, files map[string]string) []byte {
|
||||
t.Helper()
|
||||
var buffer bytes.Buffer
|
||||
z := zip.NewWriter(&buffer)
|
||||
raw, _ := json.Marshal(instructions)
|
||||
files[InstructionsFile] = string(raw)
|
||||
for name, content := range files {
|
||||
w, _ := z.Create(name)
|
||||
w.Write([]byte(content))
|
||||
}
|
||||
z.Close()
|
||||
return buffer.Bytes()
|
||||
}
|
||||
|
||||
func instructions(ops ...map[string]any) map[string]any {
|
||||
return map[string]any{"format": 1, "generated": "2026-10-18T12:00:00Z", "tools": []string{"claude"},
|
||||
"packages": []any{}, "operations": ops, "hints": []any{}}
|
||||
}
|
||||
|
||||
func install(pkg, version, path, source, sha string) map[string]any {
|
||||
return map[string]any{"op": "install", "package": pkg, "version": version, "checksum": sha, "runtime": "python",
|
||||
"entry": "x", "files": []any{map[string]any{"path": path, "source": source, "sha256": sha}}}
|
||||
}
|
||||
|
||||
func TestInvalidBundles(t *testing.T) {
|
||||
content := "x"
|
||||
sha := Sha256([]byte(content))
|
||||
tests := []struct {
|
||||
name string
|
||||
zip []byte
|
||||
message string
|
||||
}{
|
||||
{"Pfad nach draußen", zipWith(t, instructions(map[string]any{"op": "file", "package": "pub-x",
|
||||
"path": "../x", "source": "files/x", "sha256": sha}), map[string]string{"files/x": content}), "Segment"},
|
||||
{"absoluter Pfad", zipWith(t, instructions(map[string]any{"op": "remove", "package": "pub-x",
|
||||
"path": "/etc/passwd"}), map[string]string{}), "relative"},
|
||||
{"Windows-Pfad", zipWith(t, instructions(map[string]any{"op": "remove", "package": "pub-x",
|
||||
"path": `C:\x`}), map[string]string{}), "relative"},
|
||||
{"falsche Prüfsumme", zipWith(t, instructions(map[string]any{"op": "file", "package": "pub-x",
|
||||
"path": "x", "source": "files/x", "sha256": Sha256([]byte("y"))}), map[string]string{"files/x": content}), "Prüfsumme"},
|
||||
{"Quelle fehlt", zipWith(t, instructions(map[string]any{"op": "file", "package": "pub-x",
|
||||
"path": "x", "source": "files/x", "sha256": sha}), map[string]string{}), "fehlt"},
|
||||
{"Quelle außerhalb files/", zipWith(t, instructions(map[string]any{"op": "file", "package": "pub-x",
|
||||
"path": "x", "source": "x", "sha256": sha}), map[string]string{"x": content}), "files/"},
|
||||
{"unbekannte Operation", zipWith(t, instructions(map[string]any{"op": "ausfuehren"}), map[string]string{}), "unbekannte"},
|
||||
{"lokaler Server: Paket als Pfad", zipWith(t, instructions(install("../x", "1.0.0", "x", "local/../x/1.0.0/x", sha)),
|
||||
map[string]string{}), "ungültiges Paket"},
|
||||
{"lokaler Server: Version als Pfad", zipWith(t, instructions(install("pub-x", "..", "x", "local/pub-x/../x", sha)),
|
||||
map[string]string{}), "ungültiges Paket"},
|
||||
{"lokaler Server: Datei nach draußen", zipWith(t, instructions(install("pub-x", "1.0.0", "../x", "local/pub-x/1.0.0/../x", sha)),
|
||||
map[string]string{}), "Segment"},
|
||||
{"lokaler Server: Quelle passt nicht", zipWith(t, instructions(install("pub-x", "1.0.0", "x", "local/pub-y/1.0.0/x", sha)),
|
||||
map[string]string{"local/pub-y/1.0.0/x": content}), "passt nicht"},
|
||||
{"lokaler Server: falsche Prüfsumme", zipWith(t, instructions(install("pub-x", "1.0.0", "x", "local/pub-x/1.0.0/x", Sha256([]byte("y")))),
|
||||
map[string]string{"local/pub-x/1.0.0/x": content}), "Prüfsumme"},
|
||||
{"neues Format", zipWith(t, map[string]any{"format": 2}, map[string]string{}), "Format"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := FromZip(tt.zip)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.message) {
|
||||
t.Errorf("Fehler %v, erwartet %q", err, tt.message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
92
cli/internal/config/config.go
Normal file
92
cli/internal/config/config.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
// Package config liest und schreibt .hugin.yaml (→ KONZEPT.md 11.2).
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const Filename = ".hugin.yaml"
|
||||
|
||||
type Config struct {
|
||||
Server string `yaml:"server"`
|
||||
Project string `yaml:"project"`
|
||||
// "auto" oder Liste aus claude, opencode
|
||||
Tools Tools `yaml:"tools"`
|
||||
OpencodeReadsClaude *bool `yaml:"opencode_reads_claude,omitempty"`
|
||||
// warn | strict
|
||||
ToolCheck string `yaml:"tool_check,omitempty"`
|
||||
Packages []string `yaml:"packages"`
|
||||
Unsubscribed []string `yaml:"unsubscribed"`
|
||||
}
|
||||
|
||||
// Tools ist entweder automatisch (leer) oder eine feste Liste.
|
||||
type Tools struct {
|
||||
Auto bool
|
||||
List []string
|
||||
}
|
||||
|
||||
func (w Tools) MarshalYAML() (any, error) {
|
||||
if w.Auto || len(w.List) == 0 {
|
||||
return "auto", nil
|
||||
}
|
||||
return w.List, nil
|
||||
}
|
||||
|
||||
func (w *Tools) UnmarshalYAML(n *yaml.Node) error {
|
||||
if n.Kind == yaml.ScalarNode {
|
||||
if n.Value != "auto" {
|
||||
return fmt.Errorf("tools: %q, erwartet auto oder eine Liste", n.Value)
|
||||
}
|
||||
*w = Tools{Auto: true}
|
||||
return nil
|
||||
}
|
||||
var items []string
|
||||
if err := n.Decode(&items); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, name := range items {
|
||||
if name != "claude" && name != "opencode" {
|
||||
return fmt.Errorf("tools: unbekanntes Werkzeug %q", name)
|
||||
}
|
||||
}
|
||||
*w = Tools{List: items}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (k *Config) ReadsClaude() bool { return k.OpencodeReadsClaude == nil || *k.OpencodeReadsClaude }
|
||||
|
||||
func (k *Config) Strict() bool { return k.ToolCheck == "strict" }
|
||||
|
||||
func Load(repo string) (*Config, error) {
|
||||
raw, err := os.ReadFile(filepath.Join(repo, Filename))
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, fmt.Errorf("%s fehlt; zuerst hugin init aufrufen", Filename)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k := &Config{}
|
||||
if err := yaml.Unmarshal(raw, k); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", Filename, err)
|
||||
}
|
||||
switch k.ToolCheck {
|
||||
case "", "warn", "strict":
|
||||
default:
|
||||
return nil, fmt.Errorf("%s: tool_check %q, erwartet warn oder strict", Filename, k.ToolCheck)
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
func (k *Config) Save(repo string) error {
|
||||
raw, err := yaml.Marshal(k)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(repo, Filename), raw, 0o644)
|
||||
}
|
||||
81
cli/internal/config/config_test.go
Normal file
81
cli/internal/config/config_test.go
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func write(t *testing.T, text string) string {
|
||||
t.Helper()
|
||||
repo := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(repo, Filename), []byte(text), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return repo
|
||||
}
|
||||
|
||||
func TestLoadToolsAutoAndList(t *testing.T) {
|
||||
k, err := Load(write(t, "server: https://hugin.intern\nproject: shop\ntools: auto\npackages: [pub-a]\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !k.Tools.Auto || len(k.Packages) != 1 || !k.ReadsClaude() || k.Strict() {
|
||||
t.Errorf("auto: %+v", k)
|
||||
}
|
||||
k, err = Load(write(t, "server: x\nproject: y\ntools: [claude, opencode]\ntool_check: strict\nopencode_reads_claude: false\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if k.Tools.Auto || strings.Join(k.Tools.List, ",") != "claude,opencode" || k.ReadsClaude() || !k.Strict() {
|
||||
t.Errorf("Liste: %+v", k)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejects(t *testing.T) {
|
||||
for _, text := range []string{
|
||||
"tools: immer\n",
|
||||
"tools: [claude, cursor]\n",
|
||||
"tool_check: hart\n",
|
||||
} {
|
||||
if _, err := Load(write(t, text)); err == nil {
|
||||
t.Errorf("%q: Fehler erwartet", text)
|
||||
}
|
||||
}
|
||||
if _, err := Load(t.TempDir()); err == nil || !strings.Contains(err.Error(), "hugin init") {
|
||||
t.Errorf("fehlende Datei: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveRoundTrip(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
in := &Config{Server: "https://hugin.intern", Project: "shop", Tools: Tools{List: []string{"opencode"}},
|
||||
Packages: []string{"pub-a", "pub-b"}, Unsubscribed: []string{"pub-c"}}
|
||||
if err := in.Save(repo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := Load(repo)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Server != in.Server || strings.Join(out.Tools.List, ",") != "opencode" ||
|
||||
strings.Join(out.Packages, ",") != "pub-a,pub-b" || out.Unsubscribed[0] != "pub-c" {
|
||||
t.Errorf("zurückgelesen: %+v", out)
|
||||
}
|
||||
raw, _ := os.ReadFile(filepath.Join(repo, Filename))
|
||||
if !strings.Contains(string(raw), "tools:\n - opencode") {
|
||||
t.Errorf("tools fehlt: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyToolsIsSavedAsAuto(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
if err := (&Config{Server: "x", Project: "y"}).Save(repo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := os.ReadFile(filepath.Join(repo, Filename))
|
||||
if !strings.Contains(string(raw), "tools: auto") {
|
||||
t.Errorf("erwartet tools: auto, war:\n%s", raw)
|
||||
}
|
||||
}
|
||||
943
cli/internal/importer/importer.go
Normal file
943
cli/internal/importer/importer.go
Normal file
|
|
@ -0,0 +1,943 @@
|
|||
// Package importieren wandelt vorhandene Agenten-Dateien eines Repos in
|
||||
// Hugin-Pakete im Quellformat um (hugin import).
|
||||
//
|
||||
// OpenCode zuerst: Gibt es denselben Namen an mehreren Orten, gewinnt die
|
||||
// OpenCode-Fassung. Werkzeugspezifische Felder bleiben in extra.opencode bzw.
|
||||
// extra.claude erhalten, damit nach dem Rückweg durch Hugin dasselbe ankommt.
|
||||
package importer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/tailscale/hujson"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Repo string
|
||||
Target string
|
||||
Prefix string // z. B. "pub-" oder "shop-"
|
||||
Owner string // Projekt-Slug
|
||||
Overwrite bool
|
||||
}
|
||||
|
||||
type Package struct {
|
||||
ID string
|
||||
Kind string
|
||||
Sources []string
|
||||
Hints []string
|
||||
}
|
||||
|
||||
type Report struct {
|
||||
Packages []Package
|
||||
Skipped []Reason
|
||||
}
|
||||
|
||||
type Reason struct {
|
||||
Source string
|
||||
Text string
|
||||
}
|
||||
|
||||
// manifest entspricht hugin.yaml; Reihenfolge der Felder = Reihenfolge in der Datei.
|
||||
type manifest struct {
|
||||
ID string `yaml:"id"`
|
||||
Kind string `yaml:"kind"`
|
||||
Version string `yaml:"version"`
|
||||
Owner string `yaml:"owner"`
|
||||
Scope string `yaml:"scope"`
|
||||
Description string `yaml:"description"`
|
||||
License string `yaml:"license,omitempty"`
|
||||
Arguments []string `yaml:"arguments,omitempty,flow"`
|
||||
Capability any `yaml:"capability,omitempty"`
|
||||
Model string `yaml:"model,omitempty"`
|
||||
Server *mcpServer `yaml:"server,omitempty"`
|
||||
Extra map[string]map[string]*yaml.Node `yaml:"extra,omitempty"`
|
||||
}
|
||||
|
||||
type mcpServer struct {
|
||||
Name string `yaml:"name"`
|
||||
URL string `yaml:"url"`
|
||||
Headers map[string]string `yaml:"headers,omitempty"`
|
||||
}
|
||||
|
||||
type draft struct {
|
||||
manifest
|
||||
contentFile string
|
||||
content string
|
||||
files map[string][]byte // Hilfsdateien
|
||||
sources []string
|
||||
hints []string
|
||||
}
|
||||
|
||||
type importer struct {
|
||||
opt Options
|
||||
packages map[string]*draft // nach ID
|
||||
order []string
|
||||
report *Report
|
||||
opencode map[string]any
|
||||
ocNode *yaml.Node
|
||||
}
|
||||
|
||||
// Import liest das Repo und schreibt die Pakete nach opt.Target.
|
||||
func Import(opt Options) (*Report, error) {
|
||||
if !idPattern.MatchString(strings.TrimSuffix(opt.Prefix, "-")) || !strings.HasSuffix(opt.Prefix, "-") {
|
||||
return nil, fmt.Errorf("--prefix %q: z. B. pub- (öffentlich) oder <tenant>- / <projekt>-", opt.Prefix)
|
||||
}
|
||||
in := &importer{opt: opt, packages: map[string]*draft{}, report: &Report{}}
|
||||
var err error
|
||||
if in.opencode, in.ocNode, err = readJson(opt.Repo, "opencode.json", "opencode.jsonc"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, step := range []func() error{in.skills, in.agents, in.commands, in.rules, in.mcp, in.plugins} {
|
||||
if err := step(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return in.report, in.write()
|
||||
}
|
||||
|
||||
// ---- Hilfen ----------------------------------------------------------------
|
||||
|
||||
var (
|
||||
idPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
|
||||
notAlnum = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
managed = "Verwaltet von Hugin ("
|
||||
)
|
||||
|
||||
func (in *importer) id(name string) string {
|
||||
slug := strings.Trim(notAlnum.ReplaceAllString(strings.ToLower(name), "-"), "-")
|
||||
if !strings.HasPrefix(slug+"-", in.opt.Prefix) {
|
||||
slug = in.opt.Prefix + slug
|
||||
}
|
||||
if len(slug) > 64 {
|
||||
slug = strings.TrimRight(slug[:64], "-")
|
||||
}
|
||||
return slug
|
||||
}
|
||||
|
||||
func (in *importer) scope() string {
|
||||
if in.opt.Prefix == "pub-" {
|
||||
return "public"
|
||||
}
|
||||
return "tenant"
|
||||
}
|
||||
|
||||
func (in *importer) next(kind, name, description, source string) (*draft, bool) {
|
||||
id := in.id(name)
|
||||
if existing, ok := in.packages[id]; ok {
|
||||
return existing, false
|
||||
}
|
||||
e := &draft{manifest: manifest{ID: id, Kind: kind, Version: "1.0.0", Owner: in.opt.Owner,
|
||||
Scope: in.scope(), Description: singleLine(description)}, files: map[string][]byte{}}
|
||||
if e.Description == "" {
|
||||
e.Description = "TODO: Wann und wofür der Agent das nutzt."
|
||||
e.hints = append(e.hints, "keine Beschreibung gefunden: in hugin.yaml ergänzen")
|
||||
}
|
||||
in.packages[id] = e
|
||||
in.order = append(in.order, id)
|
||||
e.sources = append(e.sources, source)
|
||||
return e, true
|
||||
}
|
||||
|
||||
func (in *importer) skip(source, text string) {
|
||||
in.report.Skipped = append(in.report.Skipped, Reason{source, text})
|
||||
}
|
||||
|
||||
// extra übernimmt einen Wert unverändert, mit Reihenfolge (z. B. Muster unter
|
||||
// permission.bash: in OpenCode gewinnt die letzte passende Regel).
|
||||
func (e *draft) extra(tool, field string, value *yaml.Node) {
|
||||
if value == nil {
|
||||
return
|
||||
}
|
||||
clearStyle(value)
|
||||
if e.Extra == nil {
|
||||
e.Extra = map[string]map[string]*yaml.Node{}
|
||||
}
|
||||
if e.Extra[tool] == nil {
|
||||
e.Extra[tool] = map[string]*yaml.Node{}
|
||||
}
|
||||
e.Extra[tool][field] = value
|
||||
}
|
||||
|
||||
func singleLine(s string) string { return strings.Join(strings.Fields(s), " ") }
|
||||
|
||||
// child liefert den Wert eines Schlüssels in einem YAML-Mapping (oder nil).
|
||||
func child(n *yaml.Node, key string) *yaml.Node {
|
||||
if n != nil && n.Kind == yaml.DocumentNode && len(n.Content) == 1 {
|
||||
n = n.Content[0]
|
||||
}
|
||||
if n == nil || n.Kind != yaml.MappingNode {
|
||||
return nil
|
||||
}
|
||||
for i := 0; i+1 < len(n.Content); i += 2 {
|
||||
if n.Content[i].Value == key {
|
||||
return n.Content[i+1]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// clearStyle schreibt Werte aus JSON als normales YAML (Block statt {…}).
|
||||
func clearStyle(n *yaml.Node) {
|
||||
n.Style &^= yaml.FlowStyle
|
||||
if n.Kind == yaml.ScalarNode && n.Tag == "!!str" {
|
||||
n.Style = 0
|
||||
}
|
||||
n.HeadComment, n.LineComment, n.FootComment = "", "", ""
|
||||
for _, c := range n.Content {
|
||||
clearStyle(c)
|
||||
}
|
||||
}
|
||||
|
||||
// frontmatter trennt YAML-Frontmatter vom Rumpf einer Markdown-Datei. Die Felder
|
||||
// gibt es als Map (zum Auswerten) und als Knoten (zum Übernehmen).
|
||||
func frontmatter(raw []byte) (map[string]any, *yaml.Node, string, error) {
|
||||
text := strings.ReplaceAll(string(raw), "\r\n", "\n")
|
||||
text = strings.TrimPrefix(text, "\ufeff")
|
||||
if !strings.HasPrefix(text, "---\n") {
|
||||
return map[string]any{}, nil, text, nil
|
||||
}
|
||||
end := strings.Index(text[4:], "\n---")
|
||||
if end < 0 {
|
||||
return nil, nil, "", errors.New("Frontmatter ohne Ende (---)")
|
||||
}
|
||||
header, body := text[4:4+end], text[4+end+4:]
|
||||
body = strings.TrimPrefix(body, "\n")
|
||||
fields, node := map[string]any{}, &yaml.Node{}
|
||||
if err := yaml.Unmarshal([]byte(header), &fields); err != nil {
|
||||
return nil, nil, "", fmt.Errorf("Frontmatter: %w", err)
|
||||
}
|
||||
if err := yaml.Unmarshal([]byte(header), node); err != nil {
|
||||
return nil, nil, "", fmt.Errorf("Frontmatter: %w", err)
|
||||
}
|
||||
return fields, node, strings.TrimLeft(body, "\n"), nil
|
||||
}
|
||||
|
||||
func stringField(m map[string]any, field string) string {
|
||||
if v, ok := m[field].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isManaged(raw []byte) bool {
|
||||
header := raw
|
||||
if len(header) > 400 {
|
||||
header = header[:400]
|
||||
}
|
||||
return bytes.Contains(header, []byte(managed))
|
||||
}
|
||||
|
||||
func readJson(repo string, names ...string) (map[string]any, *yaml.Node, error) {
|
||||
for _, name := range names {
|
||||
p := filepath.Join(repo, name)
|
||||
if info, err := os.Lstat(p); err == nil && info.Mode()&fs.ModeSymlink != 0 {
|
||||
return nil, nil, fmt.Errorf("%s ist ein Symlink; Import liest nur Dateien im Repo", name)
|
||||
}
|
||||
raw, err := os.ReadFile(p)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
value, err := hujson.Standardize(raw)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
m, node := map[string]any{}, &yaml.Node{}
|
||||
if err := yaml.Unmarshal(value, &m); err != nil { // JSON ist gültiges YAML
|
||||
return nil, nil, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
if err := yaml.Unmarshal(value, node); err != nil {
|
||||
return nil, nil, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
return m, node, nil
|
||||
}
|
||||
return map[string]any{}, nil, nil
|
||||
}
|
||||
|
||||
func object(m map[string]any, field string) map[string]any {
|
||||
if v, ok := m[field].(map[string]any); ok {
|
||||
return v
|
||||
}
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
// markdownFiles liefert *.md unter den Ordnern (rekursiv), relativ zum Repo.
|
||||
func (in *importer) markdownFiles(folder ...string) []string {
|
||||
var result []string
|
||||
for _, o := range folder {
|
||||
base := filepath.Join(in.opt.Repo, o)
|
||||
filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() || !strings.HasSuffix(d.Name(), ".md") || d.Type()&fs.ModeSymlink != 0 {
|
||||
return nil
|
||||
}
|
||||
rel, _ := filepath.Rel(in.opt.Repo, p)
|
||||
result = append(result, filepath.ToSlash(rel))
|
||||
return nil
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// read folgt keinen Symlinks: ein Import soll nichts von außerhalb des Repos mitnehmen.
|
||||
func (in *importer) read(rel string) ([]byte, error) {
|
||||
p := filepath.Join(in.opt.Repo, filepath.FromSlash(rel))
|
||||
info, err := os.Lstat(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info.Mode()&fs.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("%s: keine normale Datei (Symlink?)", rel)
|
||||
}
|
||||
return os.ReadFile(p)
|
||||
}
|
||||
|
||||
// ---- Skills ----------------------------------------------------------------
|
||||
|
||||
// Bekannte Felder einer SKILL.md; der Rest sind Zusätze für Claude Code.
|
||||
var skillCoreFields = map[string]bool{"name": true, "description": true, "license": true, "metadata": true, "compatibility": true}
|
||||
|
||||
func (in *importer) skills() error {
|
||||
for _, folder := range []string{".opencode/skills", ".opencode/skill", ".agents/skills", ".claude/skills"} {
|
||||
entries, err := os.ReadDir(filepath.Join(in.opt.Repo, folder))
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, d := range entries {
|
||||
if !d.IsDir() {
|
||||
continue
|
||||
}
|
||||
if err := in.skill(folder + "/" + d.Name()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (in *importer) skill(folder string) error {
|
||||
source := folder + "/SKILL.md"
|
||||
raw, err := in.read(source)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
in.skip(source, err.Error())
|
||||
return nil
|
||||
}
|
||||
if isManaged(raw) {
|
||||
in.skip(source, "wird schon von Hugin verwaltet")
|
||||
return nil
|
||||
}
|
||||
fields, header, body, err := frontmatter(raw)
|
||||
if err != nil {
|
||||
in.skip(source, err.Error())
|
||||
return nil
|
||||
}
|
||||
name := stringField(fields, "name")
|
||||
if name == "" {
|
||||
name = filepath.Base(folder)
|
||||
}
|
||||
e, next := in.next("skill", name, stringField(fields, "description"), source)
|
||||
if !next {
|
||||
in.skip(source, fmt.Sprintf("gleicher Skill schon aus %s übernommen (OpenCode zuerst)", e.sources[0]))
|
||||
return nil
|
||||
}
|
||||
e.contentFile, e.content = "SKILL.md", body
|
||||
if e.ID != name {
|
||||
e.hints = append(e.hints, fmt.Sprintf("Skill heißt künftig %s statt %s; Verweise in Prompts anpassen", e.ID, name))
|
||||
}
|
||||
e.License = stringField(fields, "license")
|
||||
if meta, ok := fields["metadata"].(map[string]any); ok && len(meta) > 0 {
|
||||
e.extra("opencode", "metadata", child(header, "metadata"))
|
||||
}
|
||||
if _, ok := fields["compatibility"]; ok {
|
||||
e.hints = append(e.hints, "Feld compatibility nicht übernommen (Hugin setzt die Werkzeuge über targets)")
|
||||
}
|
||||
for field := range fields {
|
||||
if !skillCoreFields[field] {
|
||||
e.extra("claude", field, child(header, field))
|
||||
}
|
||||
}
|
||||
// Hilfsdateien des Skill-Ordners
|
||||
base := filepath.Join(in.opt.Repo, filepath.FromSlash(folder))
|
||||
return filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(base, p)
|
||||
rel = filepath.ToSlash(rel)
|
||||
if rel == "." || rel == "SKILL.md" {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(d.Name(), ".") {
|
||||
if d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if d.Type()&fs.ModeSymlink != 0 {
|
||||
e.hints = append(e.hints, "Symlink "+rel+" nicht übernommen")
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(p)
|
||||
e.files[rel] = data
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Subagenten --------------------------------------------------------------
|
||||
|
||||
// Fähigkeitsklassen aus OpenCode-Rechten bzw. Claude-Werkzeugen (→ KONZEPT.md 7.4)
|
||||
func capabilityFromPermissions(permissions map[string]any) []string {
|
||||
allowed := func(field string, includeAsk bool) bool {
|
||||
switch w := permissions[field].(type) {
|
||||
case string:
|
||||
return w == "allow" || (includeAsk && w == "ask")
|
||||
case map[string]any: // Muster: erlaubt, wenn irgendein Muster nicht deny ist
|
||||
for _, v := range w {
|
||||
if s, _ := v.(string); s == "allow" || (includeAsk && s == "ask") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
classes := []string{"read"}
|
||||
if allowed("edit", false) {
|
||||
classes = append(classes, "write")
|
||||
}
|
||||
if allowed("bash", true) {
|
||||
classes = append(classes, "shell")
|
||||
}
|
||||
if allowed("webfetch", false) || allowed("websearch", false) {
|
||||
classes = append(classes, "web")
|
||||
}
|
||||
return classes
|
||||
}
|
||||
|
||||
func capabilityFromTools(tools string) []string {
|
||||
classes := []string{"read"}
|
||||
has := func(names ...string) bool {
|
||||
for _, n := range names {
|
||||
if regexp.MustCompile(`\b` + n + `\b`).MatchString(tools) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if has("Edit", "Write", "MultiEdit") {
|
||||
classes = append(classes, "write")
|
||||
}
|
||||
if has("Bash") {
|
||||
classes = append(classes, "shell")
|
||||
}
|
||||
if has("WebFetch", "WebSearch") {
|
||||
classes = append(classes, "web")
|
||||
}
|
||||
return classes
|
||||
}
|
||||
|
||||
func capabilityValue(classes []string) any {
|
||||
if len(classes) == 1 {
|
||||
return classes[0]
|
||||
}
|
||||
return classes
|
||||
}
|
||||
|
||||
// Felder eines OpenCode-Agenten, die Hugin selbst setzt
|
||||
var agentCoreFields = map[string]bool{"description": true, "prompt": true, "name": true}
|
||||
|
||||
func (in *importer) agents() error {
|
||||
for _, source := range in.markdownFiles(".opencode/agents", ".opencode/agent") {
|
||||
raw, err := in.read(source)
|
||||
if err != nil {
|
||||
in.skip(source, err.Error())
|
||||
continue
|
||||
}
|
||||
if isManaged(raw) {
|
||||
in.skip(source, "wird schon von Hugin verwaltet")
|
||||
continue
|
||||
}
|
||||
fields, header, body, err := frontmatter(raw)
|
||||
if err != nil {
|
||||
in.skip(source, err.Error())
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSuffix(filepath.Base(source), ".md")
|
||||
in.opencodeAgent(name, fields, header, body, source)
|
||||
}
|
||||
// Agenten direkt in opencode.json
|
||||
agents := object(in.opencode, "agent")
|
||||
for _, name := range sorted(agents) {
|
||||
fields, _ := agents[name].(map[string]any)
|
||||
prompt := stringField(fields, "prompt")
|
||||
if prompt == "" || strings.HasPrefix(prompt, "{file:") {
|
||||
in.skip("opencode.json agent."+name, "ohne eigenen Prompt (Einstellungen eines eingebauten Agenten oder {file:…})")
|
||||
continue
|
||||
}
|
||||
in.opencodeAgent(name, fields, child(child(in.ocNode, "agent"), name), prompt, "opencode.json agent."+name)
|
||||
}
|
||||
// Claude Code: neue Agenten oder Zusätze zu schon übernommenen
|
||||
for _, source := range in.markdownFiles(".claude/agents") {
|
||||
raw, err := in.read(source)
|
||||
if err != nil {
|
||||
in.skip(source, err.Error())
|
||||
continue
|
||||
}
|
||||
if isManaged(raw) {
|
||||
in.skip(source, "wird schon von Hugin verwaltet")
|
||||
continue
|
||||
}
|
||||
fields, header, body, err := frontmatter(raw)
|
||||
if err != nil {
|
||||
in.skip(source, err.Error())
|
||||
continue
|
||||
}
|
||||
name := stringField(fields, "name")
|
||||
if name == "" {
|
||||
name = strings.TrimSuffix(filepath.Base(source), ".md")
|
||||
}
|
||||
e, next := in.next("subagent", name, stringField(fields, "description"), source)
|
||||
if !next {
|
||||
if e.Kind == "subagent" {
|
||||
e.sources = append(e.sources, source)
|
||||
e.hints = append(e.hints, "Prompt aus der OpenCode-Fassung; "+source+" nur für Claude-Felder genutzt")
|
||||
in.claudeAgentFields(e, fields, header)
|
||||
}
|
||||
continue
|
||||
}
|
||||
e.contentFile, e.content, e.Model = "AGENT.md", body, "inherit"
|
||||
e.Capability = capabilityValue(capabilityFromTools(fmt.Sprint(fields["tools"])))
|
||||
in.claudeAgentFields(e, fields, header)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (in *importer) opencodeAgent(name string, fields map[string]any, header *yaml.Node, prompt, source string) {
|
||||
e, next := in.next("subagent", name, stringField(fields, "description"), source)
|
||||
if !next {
|
||||
in.skip(source, "gleicher Name schon übernommen aus "+e.sources[0])
|
||||
return
|
||||
}
|
||||
e.contentFile, e.content, e.Model = "AGENT.md", strings.TrimLeft(prompt, "\n"), "inherit"
|
||||
permissions, _ := fields["permission"].(map[string]any)
|
||||
e.Capability = capabilityValue(capabilityFromPermissions(permissions))
|
||||
for field := range fields {
|
||||
if !agentCoreFields[field] {
|
||||
e.extra("opencode", field, child(header, field))
|
||||
}
|
||||
}
|
||||
if _, ok := fields["mode"]; !ok {
|
||||
e.hints = append(e.hints, "ohne mode: OpenCode nutzte den Agenten auch als Hauptagent, Hugin liefert ihn als Subagent (mode in extra.opencode setzen, falls nötig)")
|
||||
}
|
||||
if _, ok := fields["tools"]; ok {
|
||||
e.hints = append(e.hints, "OpenCode-Feld tools ist veraltet; besser permission nutzen")
|
||||
}
|
||||
}
|
||||
|
||||
func (in *importer) claudeAgentFields(e *draft, fields map[string]any, header *yaml.Node) {
|
||||
for field := range fields {
|
||||
switch field {
|
||||
case "name", "description", "tools":
|
||||
default:
|
||||
e.extra("claude", field, child(header, field))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sorted(m map[string]any) []string {
|
||||
names := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// ---- Befehle -------------------------------------------------------------------
|
||||
|
||||
var (
|
||||
numberedOpenCode = regexp.MustCompile(`\$([1-9][0-9]*)\b`)
|
||||
numberedClaude = regexp.MustCompile(`\$ARGUMENTS\[([0-9]+)\]|\$([0-9]+)\b`)
|
||||
)
|
||||
|
||||
// Platzhalter in {{argN}} umwandeln; ab bestimmt, ob $1 (OpenCode) oder $0 (Claude) das erste ist.
|
||||
func placeholder(text string, pattern *regexp.Regexp, from int) (string, []string) {
|
||||
highest := 0
|
||||
next := pattern.ReplaceAllStringFunc(text, func(t string) string {
|
||||
parts := pattern.FindStringSubmatch(t)
|
||||
number := parts[1]
|
||||
if number == "" && len(parts) > 2 {
|
||||
number = parts[2]
|
||||
}
|
||||
var n int
|
||||
fmt.Sscanf(number, "%d", &n)
|
||||
n = n - from + 1
|
||||
if n > highest {
|
||||
highest = n
|
||||
}
|
||||
return fmt.Sprintf("{{arg%d}}", n)
|
||||
})
|
||||
arguments := make([]string, highest)
|
||||
for i := range arguments {
|
||||
arguments[i] = fmt.Sprintf("arg%d", i+1)
|
||||
}
|
||||
return next, arguments
|
||||
}
|
||||
|
||||
var commandCoreFields = map[string]bool{"description": true, "template": true}
|
||||
|
||||
func (in *importer) commands() error {
|
||||
for _, source := range in.markdownFiles(".opencode/commands", ".opencode/command") {
|
||||
raw, err := in.read(source)
|
||||
if err != nil {
|
||||
in.skip(source, err.Error())
|
||||
continue
|
||||
}
|
||||
if isManaged(raw) {
|
||||
in.skip(source, "wird schon von Hugin verwaltet")
|
||||
continue
|
||||
}
|
||||
fields, header, body, err := frontmatter(raw)
|
||||
if err != nil {
|
||||
in.skip(source, err.Error())
|
||||
continue
|
||||
}
|
||||
in.opencodeCommand(strings.TrimSuffix(filepath.Base(source), ".md"), fields, header, body, source)
|
||||
}
|
||||
commands := object(in.opencode, "command")
|
||||
for _, name := range sorted(commands) {
|
||||
fields, _ := commands[name].(map[string]any)
|
||||
template := stringField(fields, "template")
|
||||
if template == "" || strings.HasPrefix(template, "{file:") {
|
||||
in.skip("opencode.json command."+name, "ohne Vorlage oder mit {file:…}")
|
||||
continue
|
||||
}
|
||||
in.opencodeCommand(name, fields, child(child(in.ocNode, "command"), name), template, "opencode.json command."+name)
|
||||
}
|
||||
for _, source := range in.markdownFiles(".claude/commands") {
|
||||
raw, err := in.read(source)
|
||||
if err != nil {
|
||||
in.skip(source, err.Error())
|
||||
continue
|
||||
}
|
||||
if isManaged(raw) {
|
||||
in.skip(source, "wird schon von Hugin verwaltet")
|
||||
continue
|
||||
}
|
||||
fields, header, body, err := frontmatter(raw)
|
||||
if err != nil {
|
||||
in.skip(source, err.Error())
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSuffix(filepath.ToSlash(strings.TrimPrefix(source, ".claude/commands/")), ".md")
|
||||
e, next := in.next("command", name, stringField(fields, "description"), source)
|
||||
if !next {
|
||||
in.skip(source, "gleicher Befehl schon übernommen aus "+e.sources[0]+" (OpenCode zuerst)")
|
||||
continue
|
||||
}
|
||||
// Benannte Argumente von Claude Code ($name aus arguments) und nummerierte ($0, $ARGUMENTS[0])
|
||||
content := body
|
||||
var arguments []string
|
||||
if names, ok := fields["arguments"].([]any); ok {
|
||||
for _, n := range names {
|
||||
s := fmt.Sprint(n)
|
||||
arguments = append(arguments, s)
|
||||
content = regexp.MustCompile(`\$`+regexp.QuoteMeta(s)+`\b`).ReplaceAllString(content, "{{"+s+"}}")
|
||||
}
|
||||
}
|
||||
if len(arguments) == 0 {
|
||||
content, arguments = placeholder(content, numberedClaude, 0)
|
||||
}
|
||||
e.contentFile, e.content, e.Arguments = "COMMAND.md", content, arguments
|
||||
for field := range fields {
|
||||
switch field {
|
||||
case "description", "arguments", "argument-hint":
|
||||
default:
|
||||
e.extra("claude", field, child(header, field))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (in *importer) opencodeCommand(name string, fields map[string]any, header *yaml.Node, template, source string) {
|
||||
e, next := in.next("command", name, stringField(fields, "description"), source)
|
||||
if !next {
|
||||
in.skip(source, "gleicher Name schon übernommen aus "+e.sources[0])
|
||||
return
|
||||
}
|
||||
content, arguments := placeholder(template, numberedOpenCode, 1)
|
||||
e.contentFile, e.content, e.Arguments = "COMMAND.md", content, arguments
|
||||
if len(arguments) > 0 {
|
||||
e.hints = append(e.hints, fmt.Sprintf("Platzhalter $1 … in {{arg1}} … umbenannt (%d); sprechende Namen in COMMAND.md und arguments setzen", len(arguments)))
|
||||
}
|
||||
for field := range fields {
|
||||
if !commandCoreFields[field] {
|
||||
e.extra("opencode", field, child(header, field))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Regeln ----------------------------------------------------------------------
|
||||
|
||||
var hugBlock = regexp.MustCompile(`(?s)\n?<!-- hugin:begin.*?<!-- hugin:end -->\n?`)
|
||||
|
||||
func (in *importer) rules() error {
|
||||
repoName := filepath.Base(mustAbs(in.opt.Repo))
|
||||
agentsMd, err := in.read("AGENTS.md")
|
||||
if err == nil {
|
||||
in.rule(repoName+"-rules", "AGENTS.md", agentsMd)
|
||||
}
|
||||
if raw, err := in.read("CLAUDE.md"); err == nil {
|
||||
if agentsMd != nil {
|
||||
in.skip("CLAUDE.md", "es gibt eine AGENTS.md; die gilt für OpenCode (OpenCode 2 liest nur AGENTS.md)")
|
||||
} else {
|
||||
in.rule(repoName+"-rules", "CLAUDE.md", raw)
|
||||
}
|
||||
}
|
||||
// Dateien aus instructions in opencode.json (lokale Pfade und Globs; URLs nicht)
|
||||
entries, _ := in.opencode["instructions"].([]any)
|
||||
for _, raw := range entries {
|
||||
pattern := fmt.Sprint(raw)
|
||||
if strings.Contains(pattern, "://") {
|
||||
in.skip("opencode.json instructions "+pattern, "URL; Hugin übernimmt nur Dateien aus dem Repo")
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(pattern, ".hugin/") {
|
||||
continue // verwaltet Hugin selbst
|
||||
}
|
||||
matches, _ := filepath.Glob(filepath.Join(in.opt.Repo, filepath.FromSlash(pattern)))
|
||||
sort.Strings(matches)
|
||||
for _, p := range matches {
|
||||
rel, _ := filepath.Rel(in.opt.Repo, p)
|
||||
rel = filepath.ToSlash(rel)
|
||||
if strings.HasPrefix(rel, "../") {
|
||||
in.skip("opencode.json instructions "+pattern, "liegt außerhalb des Repos")
|
||||
continue
|
||||
}
|
||||
data, err := in.read(rel)
|
||||
if err != nil {
|
||||
in.skip(rel, err.Error())
|
||||
continue
|
||||
}
|
||||
in.rule(strings.TrimSuffix(filepath.Base(rel), filepath.Ext(rel)), rel, data)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (in *importer) rule(name, source string, raw []byte) {
|
||||
text := strings.ReplaceAll(string(raw), "\r\n", "\n")
|
||||
text = strings.TrimSpace(hugBlock.ReplaceAllString(text, "\n"))
|
||||
if text == "" {
|
||||
in.skip(source, "leer (oder nur der Block von Hugin)")
|
||||
return
|
||||
}
|
||||
if isManaged(raw) {
|
||||
in.skip(source, "wird schon von Hugin verwaltet")
|
||||
return
|
||||
}
|
||||
title := ""
|
||||
for _, row := range strings.Split(text, "\n") {
|
||||
if strings.HasPrefix(row, "# ") {
|
||||
title = strings.TrimPrefix(row, "# ")
|
||||
break
|
||||
}
|
||||
}
|
||||
description := "Regeln aus " + source
|
||||
if title != "" {
|
||||
description = title + " (aus " + source + ")"
|
||||
}
|
||||
e, next := in.next("rules", name, description, source)
|
||||
if !next {
|
||||
in.skip(source, "Name schon vergeben durch "+e.sources[0])
|
||||
return
|
||||
}
|
||||
e.contentFile, e.content = "RULES.md", text+"\n"
|
||||
e.hints = append(e.hints, "Regeln aus einem Repo sind oft projektspezifisch: Geltung prüfen")
|
||||
}
|
||||
|
||||
func mustAbs(p string) string {
|
||||
abs, err := filepath.Abs(p)
|
||||
if err != nil {
|
||||
return p
|
||||
}
|
||||
return abs
|
||||
}
|
||||
|
||||
// ---- MCP ----------------------------------------------------------------------------
|
||||
|
||||
var (
|
||||
envOpenCode = regexp.MustCompile(`\{env:([A-Za-z_][A-Za-z0-9_]*)\}`)
|
||||
envClaude = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-[^}]*)?\}`)
|
||||
schemaWort = regexp.MustCompile(`^([A-Za-z]+)\s+`)
|
||||
)
|
||||
|
||||
// convertHeader wandelt Umgebungsvariablen um; feste Werte ersetzt sie durch eine Variable.
|
||||
func convertHeader(server, name, value string, env *regexp.Regexp) (string, string) {
|
||||
next := env.ReplaceAllString(value, "{{env:$1}}")
|
||||
if strings.Contains(next, "{{env:") {
|
||||
return next, ""
|
||||
}
|
||||
variable := strings.ToUpper(notAlnum.ReplaceAllString(strings.ToLower(server+"_"+name), "_"))
|
||||
replacement := "{{env:" + variable + "}}"
|
||||
if t := schemaWort.FindStringSubmatch(value); t != nil {
|
||||
replacement = t[1] + " " + replacement
|
||||
}
|
||||
return replacement, fmt.Sprintf("Kopfzeile %s hatte einen festen Wert; ersetzt durch {{env:%s}} (Wert nicht übernommen)", name, variable)
|
||||
}
|
||||
|
||||
func (in *importer) mcp() error {
|
||||
server := object(in.opencode, "mcp")
|
||||
if s, ok := server["servers"].(map[string]any); ok { // OpenCode 2: mcp.servers
|
||||
server = s
|
||||
}
|
||||
for _, name := range sorted(server) {
|
||||
fields, _ := server[name].(map[string]any)
|
||||
source := "opencode.json mcp." + name
|
||||
if stringField(fields, "type") != "remote" || stringField(fields, "url") == "" {
|
||||
in.skip(source, "lokaler MCP-Server (command); Hugin bindet nur entfernte Server an")
|
||||
continue
|
||||
}
|
||||
in.mcpPackage(name, stringField(fields, "url"), object(fields, "headers"), source, envOpenCode)
|
||||
}
|
||||
claude, _, err := readJson(in.opt.Repo, ".mcp.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
server = object(claude, "mcpServers")
|
||||
for _, name := range sorted(server) {
|
||||
fields, _ := server[name].(map[string]any)
|
||||
source := ".mcp.json mcpServers." + name
|
||||
typ := stringField(fields, "type")
|
||||
if (typ != "http" && typ != "sse") || stringField(fields, "url") == "" {
|
||||
in.skip(source, "lokaler MCP-Server (command); Hugin bindet nur entfernte Server an")
|
||||
continue
|
||||
}
|
||||
if _, already := in.packages[in.id(name+"-mcp")]; already {
|
||||
in.skip(source, "gleicher Server schon aus opencode.json übernommen")
|
||||
continue
|
||||
}
|
||||
in.mcpPackage(name, stringField(fields, "url"), object(fields, "headers"), source, envClaude)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (in *importer) mcpPackage(name, url string, header map[string]any, source string, env *regexp.Regexp) {
|
||||
e, next := in.next("mcp", name+"-mcp", "Bindet "+name+" als MCP-Server an.", source)
|
||||
if !next {
|
||||
in.skip(source, "Name schon vergeben durch "+e.sources[0])
|
||||
return
|
||||
}
|
||||
e.Server = &mcpServer{Name: name, URL: url}
|
||||
for _, k := range sorted(header) {
|
||||
value, hint := convertHeader(name, k, fmt.Sprint(header[k]), env)
|
||||
if e.Server.Headers == nil {
|
||||
e.Server.Headers = map[string]string{}
|
||||
}
|
||||
e.Server.Headers[k] = value
|
||||
if hint != "" {
|
||||
e.hints = append(e.hints, hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Plugins -------------------------------------------------------------------------
|
||||
|
||||
func (in *importer) plugins() error {
|
||||
for _, folder := range []string{".opencode/plugins", ".opencode/plugin"} {
|
||||
entries, _ := os.ReadDir(filepath.Join(in.opt.Repo, folder))
|
||||
for _, d := range entries {
|
||||
in.skip(folder+"/"+d.Name(), "OpenCode-Plugin: unterstützt Hugin noch nicht")
|
||||
}
|
||||
}
|
||||
if items, ok := in.opencode["plugin"].([]any); ok {
|
||||
for _, p := range items {
|
||||
in.skip("opencode.json plugin "+fmt.Sprint(p), "OpenCode-Plugin: unterstützt Hugin noch nicht")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- Schreiben -----------------------------------------------------------------------
|
||||
|
||||
func (in *importer) write() error {
|
||||
if err := os.MkdirAll(in.opt.Target, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range in.order {
|
||||
e := in.packages[id]
|
||||
folder := filepath.Join(in.opt.Target, id)
|
||||
if _, err := os.Stat(folder); err == nil && !in.opt.Overwrite {
|
||||
in.skip(strings.Join(e.sources, ", "), "Zielordner "+folder+" gibt es schon (--overwrite)")
|
||||
continue
|
||||
}
|
||||
if err := os.RemoveAll(folder); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(folder, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
var buffer bytes.Buffer
|
||||
enc := yaml.NewEncoder(&buffer)
|
||||
enc.SetIndent(2)
|
||||
if err := enc.Encode(e.manifest); err != nil {
|
||||
return err
|
||||
}
|
||||
header := fmt.Sprintf("# Importiert von hugin import aus: %s\n", strings.Join(e.sources, ", "))
|
||||
if err := os.WriteFile(filepath.Join(folder, "hugin.yaml"), append([]byte(header), buffer.Bytes()...), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if e.contentFile != "" {
|
||||
if err := os.WriteFile(filepath.Join(folder, e.contentFile), []byte(e.content), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for rel, data := range e.files {
|
||||
target := filepath.Join(folder, filepath.FromSlash(rel))
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(target, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
in.report.Packages = append(in.report.Packages, Package{e.ID, e.Kind, e.sources, e.hints})
|
||||
}
|
||||
return os.WriteFile(filepath.Join(in.opt.Target, "IMPORT.md"), []byte(in.report.markdown()), 0o644)
|
||||
}
|
||||
|
||||
func (b *Report) markdown() string {
|
||||
var s strings.Builder
|
||||
s.WriteString("# Import nach Hugin\n\nVon `hugin import` erzeugt. Jedes Paket prüfen, dann:\n\n")
|
||||
s.WriteString("```sh\nhugin validate <folder>\nhugin upload --submit <folder>\n```\n\n")
|
||||
s.WriteString("## Pakete\n\n| Paket | Art | Quelle | Hinweise |\n|---|---|---|---|\n")
|
||||
for _, p := range b.Packages {
|
||||
fmt.Fprintf(&s, "| %s | %s | %s | %s |\n", p.ID, p.Kind, strings.Join(p.Sources, ", "), strings.Join(p.Hints, "; "))
|
||||
}
|
||||
if len(b.Skipped) > 0 {
|
||||
s.WriteString("\n## Nicht übernommen\n\n| Quelle | Grund |\n|---|---|\n")
|
||||
for _, g := range b.Skipped {
|
||||
fmt.Fprintf(&s, "| %s | %s |\n", g.Source, g.Text)
|
||||
}
|
||||
}
|
||||
return s.String()
|
||||
}
|
||||
332
cli/internal/importer/importer_test.go
Normal file
332
cli/internal/importer/importer_test.go
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
package importer
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Beispiel-Repo mit OpenCode- und Claude-Fassungen, teils mit gleichen Namen.
|
||||
var example = map[string]string{
|
||||
".opencode/skills/munin/SKILL.md": `---
|
||||
name: munin
|
||||
description: Wie du Munin nutzt.
|
||||
license: intern
|
||||
metadata:
|
||||
opencode/slash: "true"
|
||||
---
|
||||
Nutze Munin.
|
||||
`,
|
||||
".opencode/skills/munin/referenz/beispiele.md": "Beispiel\n",
|
||||
".claude/skills/munin/SKILL.md": `---
|
||||
name: munin
|
||||
description: Claude-Fassung, soll verlieren.
|
||||
---
|
||||
Alt.
|
||||
`,
|
||||
".claude/skills/nur-claude/SKILL.md": `---
|
||||
name: nur-claude
|
||||
description: Gibt es nur bei Claude.
|
||||
allowed-tools: [Read]
|
||||
---
|
||||
Inhalt.
|
||||
`,
|
||||
".claude/skills/hugin-alt/SKILL.md": "---\nname: x\ndescription: y\n---\n<!-- Verwaltet von Hugin (pub-x 1.0.0) -->\n",
|
||||
".opencode/agents/review.md": `---
|
||||
description: Prüft Änderungen.
|
||||
mode: subagent
|
||||
temperature: 0.1
|
||||
permission:
|
||||
edit: deny
|
||||
bash:
|
||||
"git diff*": allow
|
||||
"*": deny
|
||||
webfetch: deny
|
||||
---
|
||||
Du prüfst Code.
|
||||
`,
|
||||
".claude/agents/review.md": `---
|
||||
name: review
|
||||
description: Claude-Fassung.
|
||||
tools: Read, Grep, Edit
|
||||
model: sonnet
|
||||
---
|
||||
Anderer Prompt.
|
||||
`,
|
||||
".claude/agents/schreiber.md": `---
|
||||
name: schreiber
|
||||
description: Schreibt Doku.
|
||||
tools: Read, Write, WebFetch
|
||||
---
|
||||
Du schreibst.
|
||||
`,
|
||||
".opencode/commands/release.md": `---
|
||||
description: Release-Notiz
|
||||
agent: build
|
||||
---
|
||||
Notiz für Version $1, gedacht für $2. Weitere: $ARGUMENTS
|
||||
`,
|
||||
".claude/commands/deploy.md": `---
|
||||
description: Deployt
|
||||
arguments: [umgebung]
|
||||
allowed-tools: Bash(make:*)
|
||||
---
|
||||
Deploye nach $umgebung.
|
||||
`,
|
||||
".claude/commands/release.md": "---\ndescription: verliert\n---\nx\n",
|
||||
"AGENTS.md": "# Regeln des Shops\n\nImmer testen.\n\n<!-- hugin:begin (nicht von Hand ändern) -->\nalt\n<!-- hugin:end -->\n",
|
||||
"CLAUDE.md": "# Claude\n\nwird ignoriert, weil AGENTS.md da ist\n",
|
||||
"doku/stil.md": "# Stil\n\nKurz schreiben.\n",
|
||||
"opencode.json": `{
|
||||
// Kommentar
|
||||
"instructions": ["doku/*.md", ".hugin/rules/*.md", "https://x.example/regeln.md"],
|
||||
"mcp": {
|
||||
"munin": {"type": "remote", "url": "https://munin.intern/mcp", "headers": {"Authorization": "Bearer {env:MUNIN_KEY}"}},
|
||||
"lokal": {"type": "local", "command": ["npx", "x"]}
|
||||
},
|
||||
"agent": {
|
||||
"build": {"model": "anthropic/claude-sonnet-5-5"},
|
||||
"doku": {"description": "Doku-Agent", "prompt": "Du dokumentierst.", "permission": {"edit": "allow"}}
|
||||
},
|
||||
"command": {"kurz": {"template": "Fasse $1 zusammen.", "description": "Kurzfassung"}},
|
||||
"plugin": ["opencode-x"]
|
||||
}`,
|
||||
".mcp.json": `{"mcpServers": {
|
||||
"munin": {"type": "http", "url": "https://andere.url/mcp"},
|
||||
"jira": {"type": "http", "url": "https://jira.intern/mcp", "headers": {"Authorization": "Bearer geheim123"}}
|
||||
}}`,
|
||||
".opencode/plugins/x.ts": "export default {}\n",
|
||||
}
|
||||
|
||||
func createRepo(t *testing.T) string {
|
||||
t.Helper()
|
||||
repo := filepath.Join(t.TempDir(), "shop-api")
|
||||
for rel, content := range example {
|
||||
p := filepath.Join(repo, filepath.FromSlash(rel))
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return repo
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T, target, id string) map[string]any {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join(target, id, "hugin.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", id, err)
|
||||
}
|
||||
m := map[string]any{}
|
||||
if err := yaml.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func file(t *testing.T, target, rel string) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join(target, filepath.FromSlash(rel)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func TestImport(t *testing.T) {
|
||||
repo := createRepo(t)
|
||||
target := filepath.Join(t.TempDir(), "import")
|
||||
b, err := Import(Options{Repo: repo, Target: target, Prefix: "shop-", Owner: "api"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := map[string]string{}
|
||||
for _, p := range b.Packages {
|
||||
ids[p.ID] = p.Kind
|
||||
}
|
||||
expected := map[string]string{
|
||||
"shop-munin": "skill", "shop-nur-claude": "skill",
|
||||
"shop-review": "subagent", "shop-doku": "subagent", "shop-schreiber": "subagent",
|
||||
"shop-release": "command", "shop-kurz": "command", "shop-deploy": "command",
|
||||
"shop-api-rules": "rules", "shop-stil": "rules",
|
||||
"shop-munin-mcp": "mcp", "shop-jira-mcp": "mcp",
|
||||
}
|
||||
for id, kind := range expected {
|
||||
if ids[id] != kind {
|
||||
t.Errorf("%s: Art %q, erwartet %q", id, ids[id], kind)
|
||||
}
|
||||
}
|
||||
if len(ids) != len(expected) {
|
||||
t.Errorf("Pakete: %v", ids)
|
||||
}
|
||||
|
||||
// Skill: OpenCode-Fassung gewinnt, Hilfsdateien und metadata bleiben
|
||||
m := readManifest(t, target, "shop-munin")
|
||||
if m["description"] != "Wie du Munin nutzt." || m["scope"] != "tenant" || m["owner"] != "api" || m["license"] != "intern" {
|
||||
t.Errorf("Manifest: %v", m)
|
||||
}
|
||||
if extra := m["extra"].(map[string]any)["opencode"].(map[string]any)["metadata"].(map[string]any); extra["opencode/slash"] != "true" {
|
||||
t.Errorf("metadata: %v", extra)
|
||||
}
|
||||
if file(t, target, "shop-munin/SKILL.md") != "Nutze Munin.\n" || file(t, target, "shop-munin/referenz/beispiele.md") != "Beispiel\n" {
|
||||
t.Error("Skill-Inhalt")
|
||||
}
|
||||
if strings.HasPrefix(file(t, target, "shop-munin/SKILL.md"), "---") {
|
||||
t.Error("Inhaltsdatei darf keine Frontmatter haben")
|
||||
}
|
||||
// Claude-only Skill: unbekannte Felder als extra.claude
|
||||
if z := readManifest(t, target, "shop-nur-claude")["extra"].(map[string]any)["claude"].(map[string]any); z["allowed-tools"] == nil {
|
||||
t.Errorf("extra.claude: %v", z)
|
||||
}
|
||||
|
||||
// Subagent: Fähigkeit aus OpenCode-Rechten (bash mit Muster = shell), Rechte bleiben als Zusatz
|
||||
m = readManifest(t, target, "shop-review")
|
||||
if f, _ := m["capability"].([]any); len(f) != 2 || f[0] != "read" || f[1] != "shell" {
|
||||
t.Errorf("capability: %v", m["capability"])
|
||||
}
|
||||
oc := m["extra"].(map[string]any)["opencode"].(map[string]any)
|
||||
if oc["mode"] != "subagent" || oc["temperature"] != 0.1 || oc["permission"] == nil {
|
||||
t.Errorf("extra.opencode: %v", oc)
|
||||
}
|
||||
if cl := m["extra"].(map[string]any)["claude"].(map[string]any); cl["model"] != "sonnet" {
|
||||
t.Errorf("Claude-Felder der Zweitfassung: %v", cl)
|
||||
}
|
||||
// Reihenfolge der Muster bleibt (in OpenCode gewinnt die letzte passende Regel)
|
||||
if y := file(t, target, "shop-review/hugin.yaml"); strings.Index(y, "git diff*") > strings.Index(y, "'*': deny") {
|
||||
t.Errorf("Reihenfolge unter permission.bash verloren:\n%s", y)
|
||||
}
|
||||
if y := file(t, target, "shop-doku/hugin.yaml"); !strings.Contains(y, "permission:\n edit: allow") {
|
||||
t.Errorf("Rechte aus opencode.json als Block-YAML:\n%s", y)
|
||||
}
|
||||
if file(t, target, "shop-review/AGENT.md") != "Du prüfst Code.\n" {
|
||||
t.Error("Prompt muss aus der OpenCode-Fassung kommen")
|
||||
}
|
||||
if f, _ := readManifest(t, target, "shop-schreiber")["capability"].([]any); len(f) != 3 || f[1] != "write" || f[2] != "web" {
|
||||
t.Errorf("Fähigkeit aus Claude-Werkzeugen: %v", f)
|
||||
}
|
||||
if file(t, target, "shop-doku/AGENT.md") != "Du dokumentierst." {
|
||||
t.Error("Agent aus opencode.json")
|
||||
}
|
||||
|
||||
// Befehle: $1/$2 → {{arg1}}/{{arg2}}, $ARGUMENTS bleibt; Claude-Namen bleiben benannt
|
||||
m = readManifest(t, target, "shop-release")
|
||||
if a, _ := m["arguments"].([]any); len(a) != 2 || a[0] != "arg1" {
|
||||
t.Errorf("arguments: %v", m["arguments"])
|
||||
}
|
||||
if s := file(t, target, "shop-release/COMMAND.md"); s != "Notiz für Version {{arg1}}, gedacht für {{arg2}}. Weitere: $ARGUMENTS\n" {
|
||||
t.Errorf("COMMAND.md: %q", s)
|
||||
}
|
||||
if m["extra"].(map[string]any)["opencode"].(map[string]any)["agent"] != "build" {
|
||||
t.Error("agent als extra.opencode")
|
||||
}
|
||||
if s := file(t, target, "shop-deploy/COMMAND.md"); s != "Deploye nach {{umgebung}}.\n" {
|
||||
t.Errorf("Claude-Befehl: %q", s)
|
||||
}
|
||||
|
||||
// Regeln: AGENTS.md ohne Hugin-Block, CLAUDE.md übersprungen, instructions-Datei übernommen
|
||||
if s := file(t, target, "shop-api-rules/RULES.md"); s != "# Regeln des Shops\n\nImmer testen.\n" {
|
||||
t.Errorf("RULES.md: %q", s)
|
||||
}
|
||||
if readManifest(t, target, "shop-api-rules")["description"] != "Regeln des Shops (aus AGENTS.md)" {
|
||||
t.Error("Beschreibung aus Überschrift")
|
||||
}
|
||||
|
||||
// MCP: OpenCode-Fassung gewinnt; feste Kopfzeile wird zur Variable, Wert nie übernommen
|
||||
m = readManifest(t, target, "shop-munin-mcp")
|
||||
s := m["server"].(map[string]any)
|
||||
if s["url"] != "https://munin.intern/mcp" || s["headers"].(map[string]any)["Authorization"] != "Bearer {{env:MUNIN_KEY}}" {
|
||||
t.Errorf("MCP munin: %v", s)
|
||||
}
|
||||
jira := file(t, target, "shop-jira-mcp/hugin.yaml")
|
||||
if strings.Contains(jira, "geheim123") || !strings.Contains(jira, "Bearer {{env:JIRA_AUTHORIZATION}}") {
|
||||
t.Errorf("Geheimnis darf nicht ins Paket:\n%s", jira)
|
||||
}
|
||||
|
||||
// Übersprungen mit Grund
|
||||
reasons := map[string]string{}
|
||||
for _, g := range b.Skipped {
|
||||
reasons[g.Source] = g.Text
|
||||
}
|
||||
for source, part := range map[string]string{
|
||||
".claude/skills/munin/SKILL.md": "OpenCode zuerst",
|
||||
".claude/skills/hugin-alt/SKILL.md": "von Hugin verwaltet",
|
||||
".claude/commands/release.md": "OpenCode zuerst",
|
||||
"CLAUDE.md": "AGENTS.md",
|
||||
"opencode.json mcp.lokal": "lokaler MCP-Server",
|
||||
".mcp.json mcpServers.munin": "schon aus opencode.json",
|
||||
"opencode.json agent.build": "ohne eigenen Prompt",
|
||||
"opencode.json instructions https://x.example/regeln.md": "URL",
|
||||
".opencode/plugins/x.ts": "noch nicht",
|
||||
"opencode.json plugin opencode-x": "noch nicht",
|
||||
} {
|
||||
if !strings.Contains(reasons[source], part) {
|
||||
t.Errorf("%s: Grund %q, erwartet %q", source, reasons[source], part)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(file(t, target, "IMPORT.md"), "| shop-munin | skill | .opencode/skills/munin/SKILL.md |") {
|
||||
t.Error("IMPORT.md")
|
||||
}
|
||||
// Im Repo ändert sich nichts
|
||||
if file(t, repo, "AGENTS.md") != example["AGENTS.md"] {
|
||||
t.Error("Repo verändert")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportPublicAndExistingTarget(t *testing.T) {
|
||||
repo := createRepo(t)
|
||||
target := t.TempDir()
|
||||
if _, err := Import(Options{Repo: repo, Target: target, Prefix: "pub-", Owner: "kern"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if readManifest(t, target, "pub-munin")["scope"] != "public" {
|
||||
t.Error("pub- heißt öffentlich")
|
||||
}
|
||||
os.WriteFile(filepath.Join(target, "pub-munin", "SKILL.md"), []byte("bearbeitet\n"), 0o644)
|
||||
b, err := Import(Options{Repo: repo, Target: target, Prefix: "pub-", Owner: "kern"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(b.Packages) != 0 || file(t, target, "pub-munin/SKILL.md") != "bearbeitet\n" {
|
||||
t.Error("vorhandene Ordner nur mit Ueberschreiben ersetzen")
|
||||
}
|
||||
if _, err := Import(Options{Repo: repo, Target: target, Prefix: "pub-", Owner: "kern", Overwrite: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if file(t, target, "pub-munin/SKILL.md") != "Nutze Munin.\n" {
|
||||
t.Error("Ueberschreiben")
|
||||
}
|
||||
if _, err := Import(Options{Repo: repo, Target: target, Prefix: "Shop"}); err == nil {
|
||||
t.Error("ungültiges Präfix")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportFollowsNoSymlinks(t *testing.T) {
|
||||
repo := createRepo(t)
|
||||
outside := filepath.Join(t.TempDir(), "geheim.md")
|
||||
os.WriteFile(outside, []byte("# Geheim\n"), 0o644)
|
||||
os.MkdirAll(filepath.Join(repo, ".opencode/skills/link"), 0o755)
|
||||
os.Symlink(outside, filepath.Join(repo, ".opencode/skills/link/SKILL.md"))
|
||||
os.Symlink(outside, filepath.Join(repo, ".opencode/skills/munin/referenz/geheim.md"))
|
||||
os.Symlink(outside, filepath.Join(repo, "doku/link.md"))
|
||||
target := t.TempDir()
|
||||
b, err := Import(Options{Repo: repo, Target: target, Prefix: "shop-", Owner: "api"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range b.Packages {
|
||||
if p.ID == "shop-link" {
|
||||
t.Error("Symlink-Skill übernommen")
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(target, "shop-munin/referenz/geheim.md")); err == nil {
|
||||
t.Error("Symlink-Hilfsdatei übernommen")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(target, "shop-link")); err == nil {
|
||||
t.Error("Symlink in instructions übernommen")
|
||||
}
|
||||
}
|
||||
54
cli/internal/keystore/keystore.go
Normal file
54
cli/internal/keystore/keystore.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
// Package keystore verwaltet den Key für den Hugin-Server: zuerst die
|
||||
// Umgebungsvariable HUGIN_KEY, sonst der Schlüsselbund des Betriebssystems.
|
||||
package keystore
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
const (
|
||||
Env = "HUGIN_KEY"
|
||||
ServerEnv = "HUGIN_SERVER"
|
||||
service = "hugin"
|
||||
)
|
||||
|
||||
func normalize(server string) string {
|
||||
return strings.ToLower(strings.TrimRight(strings.TrimSpace(server), "/"))
|
||||
}
|
||||
|
||||
var ErrNoKey = errors.New("kein Key: hugin login aufrufen oder HUGIN_KEY setzen")
|
||||
|
||||
// Get liefert den Key für server.
|
||||
//
|
||||
// Ein Key aus HUGIN_KEY gilt für jeden Server; in CI kommt server aber aus der
|
||||
// eingecheckten .hugin.yaml, die ein Pull Request ändern kann. Ist HUGIN_SERVER
|
||||
// gesetzt, geht der Key deshalb nur an diesen Server. Im Schlüsselbund ist der
|
||||
// Key ohnehin an die Adresse gebunden.
|
||||
func Get(server string) (string, error) {
|
||||
if key := os.Getenv(Env); key != "" {
|
||||
if fixed := os.Getenv(ServerEnv); fixed != "" && normalize(fixed) != normalize(server) {
|
||||
return "", fmt.Errorf("Server %s weicht von %s=%s ab; %s wird nicht gesendet", server, ServerEnv, fixed, Env)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
key, err := keyring.Get(service, server)
|
||||
if errors.Is(err, keyring.ErrNotFound) {
|
||||
return "", ErrNoKey
|
||||
}
|
||||
return key, err
|
||||
}
|
||||
|
||||
func Save(server, key string) error { return keyring.Set(service, server, key) }
|
||||
|
||||
func Delete(server string) error {
|
||||
err := keyring.Delete(service, server)
|
||||
if errors.Is(err, keyring.ErrNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
18
cli/internal/keystore/keystore_test.go
Normal file
18
cli/internal/keystore/keystore_test.go
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
package keystore
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestKeyFromEnvOnlyToFixedServer(t *testing.T) {
|
||||
t.Setenv(Env, "hk_test")
|
||||
t.Setenv(ServerEnv, "")
|
||||
if k, err := Get("https://irgendwo"); err != nil || k != "hk_test" {
|
||||
t.Fatalf("ohne HUGIN_SERVER: %q, %v", k, err)
|
||||
}
|
||||
t.Setenv(ServerEnv, "https://hugin.intern/")
|
||||
if k, err := Get("https://HUGIN.intern"); err != nil || k != "hk_test" {
|
||||
t.Errorf("gleicher Server: %q, %v", k, err)
|
||||
}
|
||||
if k, err := Get("https://boese.example"); err == nil || k != "" {
|
||||
t.Errorf("fremder Server bekommt den Key: %q", k)
|
||||
}
|
||||
}
|
||||
11
cli/internal/localmcp/exec_unix.go
Normal file
11
cli/internal/localmcp/exec_unix.go
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
//go:build !windows
|
||||
|
||||
package localmcp
|
||||
|
||||
import "syscall"
|
||||
|
||||
// Exec ersetzt hugin durch den Server: Ein- und Ausgabe, Signale und Rückgabewert
|
||||
// gehören dann direkt dem Werkzeug.
|
||||
func Exec(program string, args, env []string) error {
|
||||
return syscall.Exec(program, append([]string{program}, args...), env)
|
||||
}
|
||||
26
cli/internal/localmcp/exec_windows.go
Normal file
26
cli/internal/localmcp/exec_windows.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
//go:build windows
|
||||
|
||||
package localmcp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// Exec startet den Server als Kindprozess (Windows kennt kein exec), reicht Ein- und
|
||||
// Ausgabe durch und endet mit seinem Rückgabewert.
|
||||
func Exec(program string, args, env []string) error {
|
||||
c := exec.Command(program, args...)
|
||||
c.Env, c.Stdin, c.Stdout, c.Stderr = env, os.Stdin, os.Stdout, os.Stderr
|
||||
err := c.Run()
|
||||
var exit *exec.ExitError
|
||||
if errors.As(err, &exit) {
|
||||
os.Exit(exit.ExitCode())
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
os.Exit(0)
|
||||
return nil
|
||||
}
|
||||
340
cli/internal/localmcp/localmcp.go
Normal file
340
cli/internal/localmcp/localmcp.go
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
// Package localmcp installiert und startet lokale MCP-Server (→ KONZEPT.md 20, Stufe 5b).
|
||||
//
|
||||
// Ablage außerhalb des Repos: <HUGIN_MCP_HOME oder ~/.local/share/mcp>/<paket>/<version>/,
|
||||
// unter Windows also %USERPROFILE%\.local\share\mcp\…. Jede Version in einem eigenen
|
||||
// Ordner, mehrere Repos teilen sie. Fertig ist eine Installation erst, wenn die
|
||||
// Markierungsdatei geschrieben ist; nur Ordner mit dieser Datei gehören Hugin.
|
||||
package localmcp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"hugin/cli/internal/bundle"
|
||||
"hugin/cli/internal/lock"
|
||||
"hugin/cli/internal/paths"
|
||||
)
|
||||
|
||||
const (
|
||||
markerFile = ".hugin-install.json"
|
||||
registryFile = ".hugin-repos"
|
||||
requirements = "requirements.txt"
|
||||
// TrialLock ist die Lock-Datei von hugin try; ihre Versionen gelten im Repo vor hugin.lock
|
||||
TrialLock = ".hugin/trial.lock"
|
||||
)
|
||||
|
||||
// Marker steht in jedem fertig installierten Ordner.
|
||||
type Marker struct {
|
||||
Package string `json:"package"`
|
||||
Version string `json:"version"`
|
||||
Checksum string `json:"checksum"`
|
||||
Runtime string `json:"runtime"`
|
||||
Entry string `json:"entry"`
|
||||
}
|
||||
|
||||
// Home ist der Ordner für alle lokalen Server.
|
||||
func Home() (string, error) {
|
||||
if h := os.Getenv("HUGIN_MCP_HOME"); h != "" {
|
||||
return filepath.Abs(h)
|
||||
}
|
||||
user, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(user, ".local", "share", "mcp"), nil
|
||||
}
|
||||
|
||||
// Folder ist der Ordner einer Version.
|
||||
func Folder(home, pkg, version string) string { return filepath.Join(home, pkg, version) }
|
||||
|
||||
// Read liefert die Markierung einer Installation; fehlt sie, ist die Version nicht installiert.
|
||||
func Read(folder string) (*Marker, error) {
|
||||
raw, err := os.ReadFile(filepath.Join(folder, markerFile))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m Marker
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", markerFile, err)
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// Installed sagt, ob genau diese Fassung (Prüfsumme) fertig installiert ist.
|
||||
func Installed(home string, op bundle.Operation) bool {
|
||||
m, err := Read(Folder(home, op.Package, op.Version))
|
||||
return err == nil && m.Checksum == op.Checksum
|
||||
}
|
||||
|
||||
// Install legt Dateien und venv an und installiert die Abhängigkeiten mit pip. pip liest
|
||||
// den Index (Artifactory) aus der pip.conf bzw. pip.ini des Rechners. Ausgaben von venv
|
||||
// und pip gehen nach log.
|
||||
func Install(home string, b *bundle.Bundle, op bundle.Operation, log io.Writer) error {
|
||||
if op.Runtime != "python" {
|
||||
return fmt.Errorf("%s: Laufzeit %q kennt dieses hugin nicht; hugin aktualisieren", op.Package, op.Runtime)
|
||||
}
|
||||
folder := Folder(home, op.Package, op.Version)
|
||||
// Halb fertige oder andere Fassung derselben Version: neu anlegen
|
||||
if err := os.RemoveAll(folder); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, f := range op.Files {
|
||||
data, err := b.File(f.Source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target, err := paths.InRepo(folder, f.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(target, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
python, err := FindPython(op.Python)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", op.Package, err)
|
||||
}
|
||||
venv := filepath.Join(folder, ".venv")
|
||||
if err := run(log, folder, python[0], append(python[1:], "-m", "venv", venv)...); err != nil {
|
||||
return fmt.Errorf("%s: venv anlegen: %w", op.Package, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(folder, requirements)); err == nil {
|
||||
err := run(log, folder, VenvPython(folder), "-m", "pip", "install", "--quiet", "--no-input",
|
||||
"--disable-pip-version-check", "--require-hashes", "-r", requirements)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: pip install: %w (Index aus pip.conf bzw. pip.ini erreichbar?)", op.Package, err)
|
||||
}
|
||||
}
|
||||
marker, _ := json.MarshalIndent(Marker{op.Package, op.Version, op.Checksum, op.Runtime, op.Entry}, "", " ")
|
||||
return os.WriteFile(filepath.Join(folder, markerFile), append(marker, '\n'), 0o644)
|
||||
}
|
||||
|
||||
func run(log io.Writer, dir, name string, args ...string) error {
|
||||
c := exec.Command(name, args...)
|
||||
c.Dir, c.Stdout, c.Stderr = dir, log, log
|
||||
return c.Run()
|
||||
}
|
||||
|
||||
// FindPython sucht ein Python für die venv, das mindestens Version minimum hat (z. B. "3.10";
|
||||
// leer: jedes Python 3): HUGIN_PYTHON, unter Windows der Starter py -3, sonst python3.14 bis
|
||||
// python3.9, python3 und python. Auf vielen Rechnern ist python3 älter als ein daneben
|
||||
// installiertes python3.12.
|
||||
func FindPython(minimum string) ([]string, error) {
|
||||
var candidates [][]string
|
||||
if p := os.Getenv("HUGIN_PYTHON"); p != "" {
|
||||
candidates = [][]string{{p}}
|
||||
} else if runtime.GOOS == "windows" {
|
||||
candidates = [][]string{{"py", "-3"}, {"python"}}
|
||||
} else {
|
||||
for minor := 14; minor >= 9; minor-- {
|
||||
candidates = append(candidates, []string{fmt.Sprintf("python3.%d", minor)})
|
||||
}
|
||||
candidates = append(candidates, []string{"python3"}, []string{"python"})
|
||||
}
|
||||
var found []string
|
||||
for _, c := range candidates {
|
||||
if _, err := exec.LookPath(c[0]); err != nil {
|
||||
continue
|
||||
}
|
||||
out, err := exec.Command(c[0], append(c[1:], "-c",
|
||||
"import sys; print('%d.%d' % sys.version_info[:2])")...).Output()
|
||||
if err != nil {
|
||||
continue // z. B. der Platzhalter python.exe aus dem Microsoft Store
|
||||
}
|
||||
version := strings.TrimSpace(string(out))
|
||||
if strings.HasPrefix(version, "3.") && !olderThan(version, minimum) {
|
||||
return c, nil
|
||||
}
|
||||
found = append(found, strings.Join(c, " ")+" "+version)
|
||||
}
|
||||
need := "Python 3"
|
||||
if minimum != "" {
|
||||
need = "Python ≥ " + minimum
|
||||
}
|
||||
if len(found) > 0 {
|
||||
return nil, fmt.Errorf("%s nötig, gefunden: %s; neueres installieren oder HUGIN_PYTHON setzen",
|
||||
need, strings.Join(found, ", "))
|
||||
}
|
||||
return nil, fmt.Errorf("%s nötig, aber keins gefunden (python3, python oder unter Windows py); "+
|
||||
"installieren oder HUGIN_PYTHON setzen", need)
|
||||
}
|
||||
|
||||
// olderThan vergleicht Versionen wie 3.9 und 3.10 zahlenweise.
|
||||
func olderThan(version, minimum string) bool {
|
||||
if minimum == "" {
|
||||
return false
|
||||
}
|
||||
var a, b, c, d int
|
||||
fmt.Sscanf(version, "%d.%d", &a, &b)
|
||||
fmt.Sscanf(minimum, "%d.%d", &c, &d)
|
||||
return a < c || (a == c && b < d)
|
||||
}
|
||||
|
||||
// VenvPython ist das Python der venv einer Installation.
|
||||
func VenvPython(folder string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return filepath.Join(folder, ".venv", "Scripts", "python.exe")
|
||||
}
|
||||
return filepath.Join(folder, ".venv", "bin", "python")
|
||||
}
|
||||
|
||||
// Command liefert Programm, Argumente und Umgebung zum Start eines installierten Servers.
|
||||
func Command(folder string, m *Marker) (string, []string, []string) {
|
||||
args := []string{"-m", m.Entry}
|
||||
if strings.HasSuffix(m.Entry, ".py") {
|
||||
args = []string{filepath.Join(folder, filepath.FromSlash(m.Entry))}
|
||||
}
|
||||
pythonPath := folder
|
||||
if old := os.Getenv("PYTHONPATH"); old != "" {
|
||||
pythonPath += string(os.PathListSeparator) + old
|
||||
}
|
||||
// UTF-8 auch unter Windows, wo stdin sonst in der Codepage gelesen wird
|
||||
env := append(os.Environ(), "PYTHONPATH="+pythonPath, "PYTHONUTF8=1")
|
||||
return VenvPython(folder), args, env
|
||||
}
|
||||
|
||||
// FindRepo sucht vom Ordner start aufwärts das Repo (hugin.lock oder .hugin.yaml);
|
||||
// Werkzeuge starten lokale Server auch aus Unterordnern.
|
||||
func FindRepo(start string) (string, error) {
|
||||
folder, err := filepath.Abs(start)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for {
|
||||
for _, marker := range []string{lock.Filename, ".hugin.yaml"} {
|
||||
if _, err := os.Stat(filepath.Join(folder, marker)); err == nil {
|
||||
return folder, nil
|
||||
}
|
||||
}
|
||||
parent := filepath.Dir(folder)
|
||||
if parent == folder {
|
||||
return "", fmt.Errorf("kein %s in %s oder darüber; im Repo hugin sync ausführen", lock.Filename, start)
|
||||
}
|
||||
folder = parent
|
||||
}
|
||||
}
|
||||
|
||||
// Register merkt sich das Repo, damit Prune weiß, welche Versionen noch gebraucht werden.
|
||||
func Register(home, repo string) error {
|
||||
abs, err := filepath.Abs(repo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
repos, err := registered(home)
|
||||
if err != nil || slices.Contains(repos, abs) {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(home, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeRegistry(home, append(repos, abs))
|
||||
}
|
||||
|
||||
func registered(home string) ([]string, error) {
|
||||
f, err := os.Open(filepath.Join(home, registryFile))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
var repos []string
|
||||
s := bufio.NewScanner(f)
|
||||
for s.Scan() {
|
||||
if line := strings.TrimSpace(s.Text()); line != "" {
|
||||
repos = append(repos, line)
|
||||
}
|
||||
}
|
||||
return repos, s.Err()
|
||||
}
|
||||
|
||||
func writeRegistry(home string, repos []string) error {
|
||||
var b strings.Builder
|
||||
for _, r := range repos {
|
||||
b.WriteString(r + "\n")
|
||||
}
|
||||
return os.WriteFile(filepath.Join(home, registryFile), []byte(b.String()), 0o644)
|
||||
}
|
||||
|
||||
// Prune löscht installierte Versionen, die kein bekanntes Repo laut hugin.lock mehr nutzt.
|
||||
// Ordner ohne Markierung fasst es nicht an; sie gehören nicht Hugin.
|
||||
func Prune(home string, dryRun bool) ([]string, error) {
|
||||
repos, err := registered(home)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
used := map[string]bool{}
|
||||
var still []string
|
||||
for _, r := range repos {
|
||||
known := false
|
||||
for _, name := range []string{lock.Filename, TrialLock} {
|
||||
path := filepath.Join(r, filepath.FromSlash(name))
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
continue
|
||||
}
|
||||
l, err := lock.LoadFrom(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
known = true
|
||||
for id, p := range l.Packages {
|
||||
used[id+"/"+p.Version] = true
|
||||
}
|
||||
}
|
||||
if known {
|
||||
still = append(still, r) // sonst: Repo weg oder ohne Hugin, vergessen
|
||||
}
|
||||
}
|
||||
packages, err := os.ReadDir(home)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var removed []string
|
||||
for _, p := range packages {
|
||||
if !p.IsDir() {
|
||||
continue
|
||||
}
|
||||
versions, err := os.ReadDir(filepath.Join(home, p.Name()))
|
||||
if err != nil {
|
||||
return removed, err
|
||||
}
|
||||
before := len(removed)
|
||||
for _, v := range versions {
|
||||
folder := filepath.Join(home, p.Name(), v.Name())
|
||||
m, err := Read(folder)
|
||||
if err != nil || m.Package != p.Name() || m.Version != v.Name() || used[p.Name()+"/"+v.Name()] {
|
||||
continue
|
||||
}
|
||||
removed = append(removed, p.Name()+" "+v.Name())
|
||||
if !dryRun {
|
||||
if err := os.RemoveAll(folder); err != nil {
|
||||
return removed, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if !dryRun && len(removed) > before {
|
||||
os.Remove(filepath.Join(home, p.Name())) // nur, wenn jetzt leer
|
||||
}
|
||||
}
|
||||
if !dryRun {
|
||||
return removed, writeRegistry(home, still)
|
||||
}
|
||||
return removed, nil
|
||||
}
|
||||
186
cli/internal/localmcp/localmcp_test.go
Normal file
186
cli/internal/localmcp/localmcp_test.go
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
package localmcp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hugin/cli/internal/bundle"
|
||||
)
|
||||
|
||||
const example = "../../../contract/examples/bundles/local-mcp"
|
||||
|
||||
func TestHome(t *testing.T) {
|
||||
t.Setenv("HUGIN_MCP_HOME", "")
|
||||
t.Setenv("HOME", "/home/anna")
|
||||
t.Setenv("USERPROFILE", `C:\Users\anna`)
|
||||
home, err := Home()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user, _ := os.UserHomeDir()
|
||||
if home != filepath.Join(user, ".local", "share", "mcp") {
|
||||
t.Errorf("Home = %s", home)
|
||||
}
|
||||
t.Setenv("HUGIN_MCP_HOME", t.TempDir())
|
||||
if home, _ := Home(); home != os.Getenv("HUGIN_MCP_HOME") {
|
||||
t.Errorf("HUGIN_MCP_HOME nicht beachtet: %s", home)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindRepoFromSubfolder(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
os.WriteFile(filepath.Join(repo, "hugin.lock"), []byte("packages: {}\n"), 0o644)
|
||||
sub := filepath.Join(repo, "a", "b")
|
||||
os.MkdirAll(sub, 0o755)
|
||||
found, err := FindRepo(sub)
|
||||
if err != nil || found != repo {
|
||||
t.Errorf("FindRepo = %q, %v", found, err)
|
||||
}
|
||||
other := t.TempDir()
|
||||
os.WriteFile(filepath.Join(other, ".hugin.yaml"), []byte("server: x\n"), 0o644)
|
||||
if found, _ := FindRepo(other); found != other {
|
||||
t.Errorf("Repo ohne hugin.lock nicht gefunden: %q", found)
|
||||
}
|
||||
if _, err := FindRepo(t.TempDir()); err == nil {
|
||||
t.Error("ohne hugin.lock kein Fehler")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommand(t *testing.T) {
|
||||
folder := filepath.Join("srv", "1.0.0")
|
||||
_, args, env := Command(folder, &Marker{Entry: "jira_mcp"})
|
||||
if !slices.Equal(args, []string{"-m", "jira_mcp"}) {
|
||||
t.Errorf("Modul: %v", args)
|
||||
}
|
||||
_, args, _ = Command(folder, &Marker{Entry: "tools/server.py"})
|
||||
if !slices.Equal(args, []string{filepath.Join(folder, "tools", "server.py")}) {
|
||||
t.Errorf("Skript: %v", args)
|
||||
}
|
||||
if !slices.Contains(env, "PYTHONUTF8=1") {
|
||||
t.Error("PYTHONUTF8 fehlt")
|
||||
}
|
||||
}
|
||||
|
||||
// Echte venv mit dem Python des Rechners; das Beispiel braucht keine Abhängigkeiten.
|
||||
func TestInstallAndRun(t *testing.T) {
|
||||
if _, err := FindPython(""); err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
b, err := bundle.Read(example)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
op := b.Installs()[0]
|
||||
home := t.TempDir()
|
||||
if Installed(home, op) {
|
||||
t.Fatal("vor der Installation schon installiert")
|
||||
}
|
||||
if err := Install(home, b, op, &bytes.Buffer{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !Installed(home, op) {
|
||||
t.Fatal("nach der Installation nicht installiert")
|
||||
}
|
||||
folder := Folder(home, op.Package, op.Version)
|
||||
m, _ := Read(folder)
|
||||
program, args, env := Command(folder, m)
|
||||
c := exec.Command(program, args...)
|
||||
c.Env = env
|
||||
c.Stdin = bytes.NewBufferString(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"zeit"}}` + "\n")
|
||||
out, err := c.Output()
|
||||
if err != nil || !bytes.Contains(out, []byte("UHR-4711")) {
|
||||
t.Errorf("Server antwortet nicht: %s %v", out, err)
|
||||
}
|
||||
// Andere Fassung derselben Version gilt als nicht installiert
|
||||
op.Checksum = "sha256:anders"
|
||||
if Installed(home, op) {
|
||||
t.Error("andere Prüfsumme gilt als installiert")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOlderThan(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
version, minimum string
|
||||
older bool
|
||||
}{{"3.9", "3.10", true}, {"3.12", "3.10", false}, {"3.10", "3.10", false}, {"3.13", "", false}} {
|
||||
if olderThan(tt.version, tt.minimum) != tt.older {
|
||||
t.Errorf("olderThan(%s, %s) != %v", tt.version, tt.minimum, tt.older)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindPythonRespectsMinimum(t *testing.T) {
|
||||
if _, err := exec.LookPath("sh"); err != nil {
|
||||
t.Skip("braucht sh")
|
||||
}
|
||||
fake := filepath.Join(t.TempDir(), "python")
|
||||
os.WriteFile(fake, []byte("#!/bin/sh\necho 3.9\n"), 0o755)
|
||||
t.Setenv("HUGIN_PYTHON", fake)
|
||||
if _, err := FindPython("3.10"); err == nil || !strings.Contains(err.Error(), "3.9") {
|
||||
t.Errorf("zu altes Python nicht gemeldet: %v", err)
|
||||
}
|
||||
if got, err := FindPython("3.9"); err != nil || got[0] != fake {
|
||||
t.Errorf("passendes Python nicht genommen: %v %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPipGetsHashesAndNoIndex(t *testing.T) {
|
||||
if _, err := exec.LookPath("sh"); err != nil {
|
||||
t.Skip("braucht sh")
|
||||
}
|
||||
home, fake := t.TempDir(), t.TempDir()
|
||||
log := filepath.Join(fake, "aufrufe")
|
||||
// Stellvertreter für python: legt die venv-Struktur an und schreibt die Aufrufe mit
|
||||
script := "#!/bin/sh\nif [ \"$1\" = -c ]; then echo 3.12; exit; fi\necho \"$@\" >> " + log +
|
||||
"\nif [ \"$1\" = -m ] && [ \"$2\" = venv ]; then mkdir -p \"$3/bin\"; cp \"$0\" \"$3/bin/python\"; fi\n"
|
||||
python := filepath.Join(fake, "python")
|
||||
os.WriteFile(python, []byte(script), 0o755)
|
||||
t.Setenv("HUGIN_PYTHON", python)
|
||||
b, _ := bundle.Read(example)
|
||||
op := b.Installs()[0]
|
||||
op.Files = append(op.Files, bundle.InstallFile{Path: "requirements.txt", Source: op.Files[0].Source})
|
||||
if err := Install(home, b, op, &bytes.Buffer{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
calls, _ := os.ReadFile(log)
|
||||
want := "-m pip install --quiet --no-input --disable-pip-version-check --require-hashes -r requirements.txt"
|
||||
if !bytes.Contains(calls, []byte(want)) {
|
||||
t.Errorf("pip-Aufruf fehlt:\n%s", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneKeepsUsedAndForeign(t *testing.T) {
|
||||
home, repo := t.TempDir(), t.TempDir()
|
||||
marker := func(pkg, version string) {
|
||||
folder := Folder(home, pkg, version)
|
||||
os.MkdirAll(folder, 0o755)
|
||||
os.WriteFile(filepath.Join(folder, markerFile), []byte(`{"package":"`+pkg+`","version":"`+version+`"}`), 0o644)
|
||||
}
|
||||
marker("pub-a", "1.0.0")
|
||||
marker("pub-a", "1.1.0")
|
||||
marker("pub-a", "1.2.0") // gerade mit hugin try installiert
|
||||
os.MkdirAll(filepath.Join(repo, ".hugin"), 0o755)
|
||||
os.WriteFile(filepath.Join(repo, ".hugin", "trial.lock"), []byte("packages:\n pub-a:\n version: 1.2.0\n checksum: y\n"), 0o644)
|
||||
// Ordner ohne Markierung gehört nicht Hugin, etwa ein eigener Server
|
||||
os.MkdirAll(filepath.Join(home, "eigener-server", "bin"), 0o755)
|
||||
os.MkdirAll(filepath.Join(home, "noch-leer"), 0o755)
|
||||
os.WriteFile(filepath.Join(repo, "hugin.lock"), []byte("packages:\n pub-a:\n version: 1.1.0\n checksum: x\n"), 0o644)
|
||||
if err := Register(home, repo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
removed, err := Prune(home, false)
|
||||
if err != nil || !slices.Equal(removed, []string{"pub-a 1.0.0"}) {
|
||||
t.Fatalf("Prune = %v, %v", removed, err)
|
||||
}
|
||||
for _, keep := range []string{Folder(home, "pub-a", "1.1.0"), Folder(home, "pub-a", "1.2.0"), filepath.Join(home, "eigener-server", "bin"),
|
||||
filepath.Join(home, "noch-leer")} {
|
||||
if _, err := os.Stat(keep); err != nil {
|
||||
t.Errorf("%s gelöscht", keep)
|
||||
}
|
||||
}
|
||||
}
|
||||
134
cli/internal/lock/lock.go
Normal file
134
cli/internal/lock/lock.go
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
// Package lock liest und schreibt hugin.lock (→ KONZEPT.md 11.3).
|
||||
//
|
||||
// Die Lock-Datei sagt, welche Dateien, JSON-Schlüssel und Blöcke Hugin gehören
|
||||
// und welchen Stand sie hatten. Daran erkennt sync Handänderungen und weiß, was
|
||||
// beim Abbestellen zu entfernen ist.
|
||||
package lock
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
Filename = "hugin.lock"
|
||||
header = "# Von hugin sync erzeugt. Nicht von Hand ändern.\n"
|
||||
)
|
||||
|
||||
type Lock struct {
|
||||
Packages map[string]*Package `yaml:"packages"`
|
||||
Common Common `yaml:"common,omitempty"`
|
||||
}
|
||||
|
||||
type Package struct {
|
||||
Version string `yaml:"version"`
|
||||
Checksum string `yaml:"checksum"`
|
||||
Files map[string]string `yaml:"files,omitempty"`
|
||||
Keys []Key `yaml:"keys,omitempty"`
|
||||
}
|
||||
|
||||
// Key ist ein JSON-Schlüssel, den Hugin gesetzt hat. Bei Mode list_item
|
||||
// gehört Hugin nur der Eintrag Value in der Liste.
|
||||
type Key struct {
|
||||
File string `yaml:"file"`
|
||||
Path []string `yaml:"path,flow"`
|
||||
Mode string `yaml:"mode,omitempty"`
|
||||
Value any `yaml:"value,omitempty"`
|
||||
Sha256 string `yaml:"sha256,omitempty"`
|
||||
}
|
||||
|
||||
// Common hält, was keinem einzelnen Paket gehört.
|
||||
type Common struct {
|
||||
Keys []Key `yaml:"keys,omitempty"`
|
||||
// "<datei>#<block>" → Prüfsumme des Blockinhalts
|
||||
Blocks map[string]string `yaml:"blocks,omitempty"`
|
||||
// Dateien, die Hugin angelegt hat, z. B. CLAUDE.md oder opencode.json
|
||||
Created []string `yaml:"created,omitempty"`
|
||||
}
|
||||
|
||||
func New() *Lock { return &Lock{Packages: map[string]*Package{}} }
|
||||
|
||||
// Load liest hugin.lock aus repo; fehlt die Datei, ist das Ergebnis leer.
|
||||
func Load(repo string) (*Lock, error) {
|
||||
return LoadFrom(filepath.Join(repo, Filename))
|
||||
}
|
||||
|
||||
// LoadFrom liest eine Lock-Datei von path, z. B. .hugin/trial.lock.
|
||||
func LoadFrom(path string) (*Lock, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return New(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l := New()
|
||||
if err := yaml.Unmarshal(raw, l); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if l.Packages == nil {
|
||||
l.Packages = map[string]*Package{}
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func (l *Lock) Bytes() ([]byte, error) {
|
||||
var buffer bytes.Buffer
|
||||
buffer.WriteString(header)
|
||||
enc := yaml.NewEncoder(&buffer)
|
||||
enc.SetIndent(2)
|
||||
if err := enc.Encode(l); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buffer.Bytes(), enc.Close()
|
||||
}
|
||||
|
||||
// Files liefert alle Dateien aller Pakete: Pfad → Prüfsumme.
|
||||
func (l *Lock) Files() map[string]string {
|
||||
all := map[string]string{}
|
||||
for _, p := range l.Packages {
|
||||
for path, sha := range p.Files {
|
||||
all[path] = sha
|
||||
}
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// AllKeys liefert die Schlüssel aller Pakete und die gemeinsamen.
|
||||
func (l *Lock) AllKeys() []Key {
|
||||
var all []Key
|
||||
for _, p := range l.Packages {
|
||||
all = append(all, p.Keys...)
|
||||
}
|
||||
return append(all, l.Common.Keys...)
|
||||
}
|
||||
|
||||
// Find sucht einen Schlüssel; bei list_item muss auch der Wert passen.
|
||||
func Find(items []Key, file string, path []string, mode string, value any) *Key {
|
||||
for i, s := range items {
|
||||
if s.File != file || !slices.Equal(s.Path, path) || s.Mode != mode {
|
||||
continue
|
||||
}
|
||||
if mode == "list_item" && !equal(s.Value, value) {
|
||||
continue
|
||||
}
|
||||
return &items[i]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func equal(a, b any) bool {
|
||||
ya, _ := yaml.Marshal(a)
|
||||
yb, _ := yaml.Marshal(b)
|
||||
return bytes.Equal(ya, yb)
|
||||
}
|
||||
|
||||
func (l *Lock) Created(file string) bool {
|
||||
return slices.Contains(l.Common.Created, file)
|
||||
}
|
||||
83
cli/internal/lock/lock_test.go
Normal file
83
cli/internal/lock/lock_test.go
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
package lock
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadMissingIsEmpty(t *testing.T) {
|
||||
l, err := Load(t.TempDir())
|
||||
if err != nil || l.Packages == nil || len(l.Packages) != 0 {
|
||||
t.Fatalf("leer erwartet: %+v, %v", l, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
l := New()
|
||||
l.Packages["pub-a"] = &Package{Version: "1.0.0", Checksum: "sha256:aa",
|
||||
Files: map[string]string{".claude/skills/pub-a/SKILL.md": "sha256:01"},
|
||||
Keys: []Key{{File: ".mcp.json", Path: []string{"mcpServers", "munin"}, Sha256: "sha256:02"}}}
|
||||
l.Packages["pub-b"] = &Package{Version: "2.0.0", Checksum: "sha256:bb",
|
||||
Files: map[string]string{".hugin/rules/pub-b.md": "sha256:03"}}
|
||||
l.Common = Common{
|
||||
Keys: []Key{{File: "opencode.json", Path: []string{"instructions"}, Mode: "list_item", Value: ".hugin/rules/*.md"}},
|
||||
Blocks: map[string]string{"CLAUDE.md#hugin": "sha256:04"},
|
||||
Created: []string{"opencode.json"},
|
||||
}
|
||||
raw, err := l.Bytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(string(raw), header) {
|
||||
t.Errorf("Kopfzeile fehlt:\n%s", raw)
|
||||
}
|
||||
repo := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(repo, Filename), raw, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := Load(repo)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(back.Files()) != 2 || back.Files()[".hugin/rules/pub-b.md"] != "sha256:03" {
|
||||
t.Errorf("Files: %v", back.Files())
|
||||
}
|
||||
if len(back.AllKeys()) != 2 || !back.Created("opencode.json") || back.Created("CLAUDE.md") {
|
||||
t.Errorf("Keys/Created: %+v", back)
|
||||
}
|
||||
if back.Common.Blocks["CLAUDE.md#hugin"] != "sha256:04" {
|
||||
t.Errorf("Blocks: %v", back.Common.Blocks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFind(t *testing.T) {
|
||||
keys := []Key{
|
||||
{File: "opencode.json", Path: []string{"instructions"}, Mode: "list_item", Value: ".hugin/rules/*.md"},
|
||||
{File: ".mcp.json", Path: []string{"mcpServers", "munin"}},
|
||||
}
|
||||
if Find(keys, ".mcp.json", []string{"mcpServers", "munin"}, "", nil) == nil {
|
||||
t.Error("Schlüssel nicht gefunden")
|
||||
}
|
||||
if Find(keys, "opencode.json", []string{"instructions"}, "list_item", ".hugin/rules/*.md") == nil {
|
||||
t.Error("Listeneintrag nicht gefunden")
|
||||
}
|
||||
// Bei list_item muss auch der Wert passen
|
||||
if Find(keys, "opencode.json", []string{"instructions"}, "list_item", "andere.md") != nil {
|
||||
t.Error("anderer Wert darf nicht passen")
|
||||
}
|
||||
if Find(keys, ".mcp.json", []string{"mcpServers"}, "", nil) != nil {
|
||||
t.Error("kürzerer Pfad darf nicht passen")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrokenFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "trial.lock")
|
||||
if err := os.WriteFile(path, []byte("packages: [kaputt"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadFrom(path); err == nil {
|
||||
t.Error("Fehler erwartet")
|
||||
}
|
||||
}
|
||||
70
cli/internal/paths/paths.go
Normal file
70
cli/internal/paths/paths.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
// Package pfade prüft Pfade aus Bundles, bevor das CLI sie anfasst
|
||||
// (→ KONZEPT.md 16.2: nicht aus dem Repo ausbrechen).
|
||||
package paths
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// windowsForbidden: Zeichen, die Windows in Namen nicht erlaubt oder umdeutet.
|
||||
const windowsForbidden = `<>:"|?*`
|
||||
|
||||
// Validate meldet einen Fehler, wenn rel kein sauberer, relativer Pfad mit /
|
||||
// ist: kein führender /, kein \, kein Laufwerk, keine Segmente "." oder "..".
|
||||
// Dazu, was Windows umdeutet (Punkt oder Leerzeichen am Ende, Steuerzeichen),
|
||||
// und nichts unter .git/ (Hooks dort führt git selbst aus).
|
||||
func Validate(rel string) error {
|
||||
switch {
|
||||
case rel == "":
|
||||
return errors.New("leerer Pfad")
|
||||
case strings.HasPrefix(rel, "/"), strings.Contains(rel, `\`), strings.Contains(rel, ":"):
|
||||
return fmt.Errorf("%q: nur relative Pfade mit / erlaubt", rel)
|
||||
}
|
||||
for _, part := range strings.Split(strings.TrimSuffix(rel, "/"), "/") {
|
||||
if part == "" || part == "." || part == ".." {
|
||||
return fmt.Errorf("%q: ungültiges Segment %q", rel, part)
|
||||
}
|
||||
if strings.HasSuffix(part, ".") || strings.HasSuffix(part, " ") || strings.ContainsAny(part, windowsForbidden) ||
|
||||
strings.IndexFunc(part, func(r rune) bool { return r < 32 }) >= 0 {
|
||||
return fmt.Errorf("%q: Segment %q geht nicht auf allen Systemen", rel, part)
|
||||
}
|
||||
}
|
||||
if erstes, _, _ := strings.Cut(rel, "/"); strings.EqualFold(erstes, ".git") {
|
||||
return fmt.Errorf("%q: Hugin schreibt nicht unter .git/", rel)
|
||||
}
|
||||
if path.Clean(rel) != strings.TrimSuffix(rel, "/") {
|
||||
return fmt.Errorf("%q: Pfad ist nicht normalisiert", rel)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InRepo prüft rel und liefert den absoluten Pfad unter repo. Symlinks auf dem
|
||||
// Weg dorthin werden abgelehnt, damit kein Pfad über einen Link nach draußen führt.
|
||||
func InRepo(repo, rel string) (string, error) {
|
||||
if err := Validate(rel); err != nil {
|
||||
return "", err
|
||||
}
|
||||
target := filepath.Join(repo, filepath.FromSlash(rel))
|
||||
parts := strings.Split(strings.TrimSuffix(rel, "/"), "/")
|
||||
current := repo
|
||||
for _, part := range parts {
|
||||
current = filepath.Join(current, part)
|
||||
info, err := os.Lstat(current)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return "", fmt.Errorf("%q: Symlink im Pfad (%s)", rel, current)
|
||||
}
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
34
cli/internal/paths/paths_test.go
Normal file
34
cli/internal/paths/paths_test.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
package paths
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
for _, good := range []string{".claude/skills/a/SKILL.md", "opencode.json", ".opencode/agents/x.md", ".github/x", "a/.gitkeep"} {
|
||||
if err := Validate(good); err != nil {
|
||||
t.Errorf("%s: %v", good, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", "/etc/passwd", "../x", "a/../b", "a//b", "./a", `a\b`, "c:/x", "a/b.",
|
||||
"a /b", "a/b ", "...", "a/<b>", "a|b", "a?b", "a\x01b", ".git/hooks/pre-commit", ".GIT/config", ".git"} {
|
||||
if err := Validate(bad); err == nil {
|
||||
t.Errorf("%q sollte abgelehnt werden", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInRepoWithoutSymlinks(t *testing.T) {
|
||||
repo, outside := t.TempDir(), t.TempDir()
|
||||
if err := os.Symlink(outside, filepath.Join(repo, ".claude")); err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
if _, err := InRepo(repo, ".claude/skills/a/SKILL.md"); err == nil {
|
||||
t.Error("Symlink im Pfad muss abgelehnt werden")
|
||||
}
|
||||
if p, err := InRepo(repo, ".opencode/agents/a.md"); err != nil || p != filepath.Join(repo, ".opencode", "agents", "a.md") {
|
||||
t.Errorf("%s, %v", p, err)
|
||||
}
|
||||
}
|
||||
168
cli/internal/pkgdir/pkgdir.go
Normal file
168
cli/internal/pkgdir/pkgdir.go
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
// Package paketordner packt Paketordner für den Server und legt neue an
|
||||
// (→ KONZEPT.md 6.1, 11.1: hugin new, validate, try, upload).
|
||||
package pkgdir
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const Manifest = "hugin.yaml"
|
||||
|
||||
// Zip packt den Ordner wie der Server ihn erwartet: hugin.yaml in der Wurzel,
|
||||
// versteckte Dateien weggelassen, Symlinks abgelehnt.
|
||||
func Zip(folder string) ([]byte, error) {
|
||||
if _, err := os.Stat(filepath.Join(folder, Manifest)); err != nil {
|
||||
return nil, fmt.Errorf("%s fehlt in %s", Manifest, folder)
|
||||
}
|
||||
var buffer bytes.Buffer
|
||||
z := zip.NewWriter(&buffer)
|
||||
err := filepath.WalkDir(folder, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(folder, path)
|
||||
if err != nil || rel == "." {
|
||||
return err
|
||||
}
|
||||
if strings.HasPrefix(d.Name(), ".") {
|
||||
if d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if d.Type()&fs.ModeSymlink != 0 {
|
||||
return fmt.Errorf("Symlinks sind nicht erlaubt: %s", rel)
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w, err := z.Create(filepath.ToSlash(rel))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write(data)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := z.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buffer.Bytes(), nil
|
||||
}
|
||||
|
||||
var idPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
|
||||
|
||||
var contentFile = map[string]string{
|
||||
"skill": "SKILL.md",
|
||||
"rules": "RULES.md",
|
||||
"command": "COMMAND.md",
|
||||
"subagent": "AGENT.md",
|
||||
"mcp": "",
|
||||
"plugin": "index.ts",
|
||||
}
|
||||
|
||||
var scaffolds = map[string]string{
|
||||
"skill": `# Titel
|
||||
|
||||
Was der Agent tun soll, Schritt für Schritt. Werkzeuge neutral beschreiben
|
||||
(„lies die Datei“, nicht „nutze Read“), damit es in Claude Code und OpenCode passt.
|
||||
`,
|
||||
"rules": `# Regeln
|
||||
|
||||
- Eine Regel pro Zeile, kurz und prüfbar.
|
||||
`,
|
||||
"command": `Beschreibe die Aufgabe für {{input}}.
|
||||
|
||||
Platzhalter: {{name}} für benannte Argumente (in hugin.yaml unter arguments),
|
||||
$ARGUMENTS für alles zusammen. Keine $1, $2 …: Claude Code und OpenCode zählen verschieden.
|
||||
`,
|
||||
"subagent": `Du bist … Beschreibe Rolle, Vorgehen und Form der Antwort.
|
||||
`,
|
||||
"plugin": `import { tool } from "@opencode-ai/plugin"
|
||||
|
||||
// OpenCode ruft jede exportierte Funktion beim Start auf. Erlaubte Importe: relative
|
||||
// Dateien im Paket, node:…, bun und @opencode-ai/plugin; npm-Pakete noch nicht.
|
||||
export const MeinPlugin = async ({ worktree }) => {
|
||||
return {
|
||||
tool: {
|
||||
mein_werkzeug: tool({
|
||||
description: "Was das Werkzeug tut",
|
||||
args: {},
|
||||
async execute() {
|
||||
return "Ergebnis"
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
`,
|
||||
}
|
||||
|
||||
// New legt folder/<id> mit Manifest-Gerüst und Inhaltsdatei an.
|
||||
func New(folder, kind, id string) (string, error) {
|
||||
file, ok := contentFile[kind]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("kind %q: erwartet skill, rules, command, subagent, mcp oder plugin", kind)
|
||||
}
|
||||
if !idPattern.MatchString(id) || len(id) > 64 {
|
||||
return "", fmt.Errorf("id %q: nur a–z, 0–9 und einzelne Bindestriche, 1–64 Zeichen", id)
|
||||
}
|
||||
target := filepath.Join(folder, id)
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
return "", fmt.Errorf("%s gibt es schon", target)
|
||||
} else if !errors.Is(err, fs.ErrNotExist) {
|
||||
return "", err
|
||||
}
|
||||
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(target, Manifest), []byte(manifest(kind, id)), 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if file != "" {
|
||||
if err := os.WriteFile(filepath.Join(target, file), []byte(scaffolds[kind]), 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func manifest(kind, id string) string {
|
||||
scope := "tenant # public | tenant | project; das Präfix der id muss passen"
|
||||
if strings.HasPrefix(id, "pub-") {
|
||||
scope = "public # public | tenant | project"
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "id: %s\nkind: %s\nversion: 0.1.0\nowner: \"\" # Projekt-Slug\nscope: %s\n", id, kind, scope)
|
||||
b.WriteString("description: >\n Wann und wofür der Agent das nutzt. Die Werkzeuge wählen danach aus,\n also konkret schreiben (1–1.024 Zeichen).\n")
|
||||
switch kind {
|
||||
case "command":
|
||||
b.WriteString("arguments: [input] # Reihenfolge = Position beim Aufruf\n")
|
||||
case "subagent":
|
||||
b.WriteString("capability: read # read | write | shell | web, auch als Liste\n")
|
||||
b.WriteString("model: standard # fast | standard | thorough | inherit\n")
|
||||
case "plugin":
|
||||
b.WriteString("targets: [opencode] # Plugins gibt es nur für OpenCode\n")
|
||||
case "mcp":
|
||||
b.WriteString("server:\n name: name\n url: https://…/mcp\n headers:\n Authorization: \"Bearer {{env:NAME_KEY}}\"\n")
|
||||
b.WriteString(" # Lokaler Server statt url und headers: Code (und requirements.txt mit Hashes) ins Paket\n" +
|
||||
" # local:\n # runtime: python\n # entry: mein_server # Modul oder Skript.py\n" +
|
||||
" # python: \"3.10\"\n # env: [NAME_KEY]\n")
|
||||
}
|
||||
b.WriteString("# extra: # nur, was ein Werkzeug allein kann\n# claude: {}\n# opencode: {}\n")
|
||||
return b.String()
|
||||
}
|
||||
71
cli/internal/pkgdir/pkgdir_test.go
Normal file
71
cli/internal/pkgdir/pkgdir_test.go
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
package pkgdir
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewAndZip(t *testing.T) {
|
||||
folder := t.TempDir()
|
||||
target, err := New(folder, "command", "pub-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifest, _ := os.ReadFile(filepath.Join(target, Manifest))
|
||||
for _, part := range []string{"id: pub-test", "kind: command", "scope: public", "arguments: [input]"} {
|
||||
if !strings.Contains(string(manifest), part) {
|
||||
t.Errorf("Manifest ohne %q:\n%s", part, manifest)
|
||||
}
|
||||
}
|
||||
os.WriteFile(filepath.Join(target, ".DS_Store"), []byte("x"), 0o644)
|
||||
os.MkdirAll(filepath.Join(target, ".git"), 0o755)
|
||||
os.WriteFile(filepath.Join(target, ".git", "HEAD"), []byte("x"), 0o644)
|
||||
|
||||
raw, err := Zip(target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
z, _ := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
|
||||
var names []string
|
||||
for _, f := range z.File {
|
||||
names = append(names, f.Name)
|
||||
}
|
||||
slices.Sort(names)
|
||||
if !slices.Equal(names, []string{"COMMAND.md", "hugin.yaml"}) {
|
||||
t.Errorf("ZIP enthält %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewValidatesInputs(t *testing.T) {
|
||||
folder := t.TempDir()
|
||||
if _, err := New(folder, "hook", "pub-x"); err == nil {
|
||||
t.Error("Art hook angenommen")
|
||||
}
|
||||
if _, err := New(folder, "skill", "Pub_X"); err == nil {
|
||||
t.Error("ungültige id angenommen")
|
||||
}
|
||||
New(folder, "skill", "pub-x")
|
||||
if _, err := New(folder, "skill", "pub-x"); err == nil {
|
||||
t.Error("vorhandener Ordner überschrieben")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZipWithoutManifest(t *testing.T) {
|
||||
if _, err := Zip(t.TempDir()); err == nil {
|
||||
t.Error("Ordner ohne hugin.yaml angenommen")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZipRejectsSymlink(t *testing.T) {
|
||||
folder := t.TempDir()
|
||||
os.WriteFile(filepath.Join(folder, Manifest), []byte("id: x\n"), 0o644)
|
||||
os.Symlink("/etc/hosts", filepath.Join(folder, "SKILL.md"))
|
||||
if _, err := Zip(folder); err == nil {
|
||||
t.Error("Symlink angenommen")
|
||||
}
|
||||
}
|
||||
93
cli/internal/reconcile/block.go
Normal file
93
cli/internal/reconcile/block.go
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
package reconcile
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Verwaltete Blöcke in Textdateien wie CLAUDE.md (→ KONZEPT.md 7.2, 11.4):
|
||||
//
|
||||
// <!-- hugin:begin — von hugin sync verwaltet, nicht von Hand ändern -->
|
||||
// @.hugin/rules/pub-hausregeln.md
|
||||
// <!-- hugin:end -->
|
||||
|
||||
func beginMarker(block string) string {
|
||||
return "<!-- " + block + ":begin — von hugin sync verwaltet, nicht von Hand ändern -->"
|
||||
}
|
||||
|
||||
func endMarker(block string) string { return "<!-- " + block + ":end -->" }
|
||||
|
||||
// findBlock liefert die Zeilennummern von Anfangs- und Endmarke, -1 wenn es
|
||||
// den Block nicht gibt.
|
||||
func findBlock(lines []string, block string) (begin, end int) {
|
||||
begin, end = -1, -1
|
||||
for i, z := range lines {
|
||||
z = strings.TrimSpace(z)
|
||||
if begin < 0 && strings.HasPrefix(z, "<!-- "+block+":begin") {
|
||||
begin = i
|
||||
} else if begin >= 0 && z == endMarker(block) {
|
||||
return begin, i
|
||||
}
|
||||
}
|
||||
return -1, -1
|
||||
}
|
||||
|
||||
// readBlock liefert den Inhalt des Blocks mit \n als Zeilenende.
|
||||
func readBlock(text, block string) (string, bool) {
|
||||
lines, _ := split(text)
|
||||
begin, end := findBlock(lines, block)
|
||||
if begin < 0 {
|
||||
return "", false
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, z := range lines[begin+1 : end] {
|
||||
b.WriteString(z + "\n")
|
||||
}
|
||||
return b.String(), true
|
||||
}
|
||||
|
||||
// setBlock ersetzt den Block, hängt ihn ans Ende an oder entfernt ihn
|
||||
// (leerer inhalt). Das Zeilenende der Datei (LF oder CRLF) bleibt erhalten.
|
||||
func setBlock(text, block, content string) string {
|
||||
lines, nl := split(text)
|
||||
begin, end := findBlock(lines, block)
|
||||
|
||||
var next []string
|
||||
if content != "" {
|
||||
next = append(next, beginMarker(block))
|
||||
next = append(next, strings.Split(strings.TrimSuffix(content, "\n"), "\n")...)
|
||||
next = append(next, endMarker(block))
|
||||
}
|
||||
|
||||
switch {
|
||||
case begin >= 0:
|
||||
rest := lines[end+1:]
|
||||
if len(next) == 0 && len(rest) > 0 && strings.TrimSpace(rest[0]) == "" {
|
||||
rest = rest[1:]
|
||||
}
|
||||
lines = append(append(append([]string{}, lines[:begin]...), next...), rest...)
|
||||
case len(next) > 0:
|
||||
if len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) != "" {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
lines = append(lines, next...)
|
||||
}
|
||||
for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" {
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(lines, nl) + nl
|
||||
}
|
||||
|
||||
func split(text string) (lines []string, nl string) {
|
||||
nl = "\n"
|
||||
if strings.Contains(text, "\r\n") {
|
||||
nl = "\r\n"
|
||||
}
|
||||
text = strings.TrimSuffix(text, nl)
|
||||
if text == "" {
|
||||
return nil, nl
|
||||
}
|
||||
return strings.Split(text, nl), nl
|
||||
}
|
||||
326
cli/internal/reconcile/json.go
Normal file
326
cli/internal/reconcile/json.go
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
package reconcile
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/tailscale/hujson"
|
||||
)
|
||||
|
||||
// jsonFile ist eine JSON- oder JSONC-Datei, die über JSON-Patch geändert
|
||||
// wird. hujson erhält dabei Kommentare, Reihenfolge und die Form der übrigen
|
||||
// Inhalte. Eingefügte Werte rückt formatieren im Stil der Datei ein.
|
||||
type jsonFile struct {
|
||||
value hujson.Value
|
||||
// Einrückung der Datei, z. B. zwei Leerzeichen oder ein Tab
|
||||
indent string
|
||||
}
|
||||
|
||||
func readJson(raw []byte, next bool) (*jsonFile, error) {
|
||||
if len(bytes.TrimSpace(raw)) == 0 {
|
||||
raw = []byte("{}")
|
||||
}
|
||||
v, err := hujson.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &jsonFile{value: v, indent: detectIndent(raw)}, nil
|
||||
}
|
||||
|
||||
func detectIndent(raw []byte) string {
|
||||
for _, row := range strings.Split(string(raw), "\n") {
|
||||
rest := strings.TrimLeft(row, " \t")
|
||||
if rest != "" && len(rest) < len(row) {
|
||||
return row[:len(row)-len(rest)]
|
||||
}
|
||||
}
|
||||
return " "
|
||||
}
|
||||
|
||||
func (d *jsonFile) bytes() []byte {
|
||||
b := d.value.Pack()
|
||||
if !bytes.HasSuffix(b, []byte("\n")) {
|
||||
b = append(b, '\n')
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// read liefert den Wert unter path als Go-Wert, ok=false wenn er fehlt.
|
||||
func (d *jsonFile) read(path []string) (any, bool) {
|
||||
clone := d.value.Clone()
|
||||
clone.Standardize()
|
||||
var value any
|
||||
if err := json.Unmarshal(clone.Pack(), &value); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
for _, s := range path {
|
||||
obj, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if value, ok = obj[s]; !ok {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
// empty sagt, ob die Datei nur noch ein leeres Objekt enthält.
|
||||
// OpenCodeSchema legt hugin in eine neue opencode.json.
|
||||
const OpenCodeSchema = "https://opencode.ai/config.json"
|
||||
|
||||
// empty: ein leeres Objekt; ein $schema allein zählt nicht als Inhalt.
|
||||
func (d *jsonFile) empty() bool {
|
||||
value, ok := d.read(nil)
|
||||
obj, isObject := value.(map[string]any)
|
||||
delete(obj, "$schema")
|
||||
return ok && isObject && len(obj) == 0
|
||||
}
|
||||
|
||||
func (d *jsonFile) set(path []string, value json.RawMessage) error {
|
||||
if existing, ok := d.read(path); ok && canonical(existing) == canonicalRaw(value) {
|
||||
return nil
|
||||
}
|
||||
// Fehlende Zwischenobjekte nicht einzeln anlegen, sondern den Wert ab der
|
||||
// ersten fehlenden Ebene verschachtelt einfügen: ergibt saubere Einrückung.
|
||||
for i := 1; i < len(path); i++ {
|
||||
existing, ok := d.read(path[:i])
|
||||
if !ok {
|
||||
for j := len(path) - 1; j >= i; j-- {
|
||||
key, _ := json.Marshal(path[j])
|
||||
value = json.RawMessage("{" + string(key) + ":" + string(value) + "}")
|
||||
}
|
||||
path = path[:i]
|
||||
break
|
||||
}
|
||||
if _, isObject := existing.(map[string]any); !isObject {
|
||||
return fmt.Errorf("%s ist kein Objekt", pointer(path[:i]))
|
||||
}
|
||||
}
|
||||
if err := d.patch("add", path, value); err != nil {
|
||||
return err
|
||||
}
|
||||
return d.format(path[:len(path)-1], path[len(path)-1])
|
||||
}
|
||||
|
||||
func (d *jsonFile) remove(path []string) error {
|
||||
if _, ok := d.read(path); !ok {
|
||||
return nil
|
||||
}
|
||||
if err := d.patch("remove", path, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
return d.removeEmptyParents(path)
|
||||
}
|
||||
|
||||
// removeEmptyParents entfernt Objekte über pfad, die durch das Entfernen leer
|
||||
// geworden sind, z. B. "mcpServers": {} nach dem letzten Server.
|
||||
func (d *jsonFile) removeEmptyParents(path []string) error {
|
||||
for i := len(path) - 1; i > 0; i-- {
|
||||
value, ok := d.read(path[:i])
|
||||
obj, isObject := value.(map[string]any)
|
||||
if !ok || !isObject || len(obj) > 0 {
|
||||
return nil
|
||||
}
|
||||
if err := d.patch("remove", path[:i], nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// addToList stellt sicher, dass wert in der Liste unter pfad steht.
|
||||
func (d *jsonFile) addToList(path []string, value json.RawMessage) error {
|
||||
existing, ok := d.read(path)
|
||||
if !ok {
|
||||
return d.set(path, json.RawMessage("["+string(value)+"]"))
|
||||
}
|
||||
items, isList := existing.([]any)
|
||||
if !isList {
|
||||
return fmt.Errorf("%s ist keine Liste", pointer(path))
|
||||
}
|
||||
if index(items, value) >= 0 {
|
||||
return nil
|
||||
}
|
||||
if err := d.patch("add", append(append([]string{}, path...), "-"), value); err != nil {
|
||||
return err
|
||||
}
|
||||
return d.format(path, "")
|
||||
}
|
||||
|
||||
func (d *jsonFile) removeFromList(path []string, value json.RawMessage) error {
|
||||
existing, _ := d.read(path)
|
||||
items, _ := existing.([]any)
|
||||
i := index(items, value)
|
||||
if i < 0 {
|
||||
return nil
|
||||
}
|
||||
if len(items) == 1 {
|
||||
// Eine leere Liste nützt niemandem: den Schlüssel ganz entfernen
|
||||
return d.remove(path)
|
||||
}
|
||||
return d.patch("remove", append(append([]string{}, path...), fmt.Sprint(i)), nil)
|
||||
}
|
||||
|
||||
func index(items []any, value json.RawMessage) int {
|
||||
for i, entry := range items {
|
||||
if canonical(entry) == canonicalRaw(value) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (d *jsonFile) patch(op string, path []string, value json.RawMessage) error {
|
||||
step := map[string]any{"op": op, "path": pointer(path)}
|
||||
if value != nil {
|
||||
step["value"] = value
|
||||
}
|
||||
raw, err := json.Marshal([]any{step})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return d.value.Patch(raw)
|
||||
}
|
||||
|
||||
// pointer baut einen JSON-Pointer (RFC 6901) aus den Schlüsseln.
|
||||
func pointer(path []string) string {
|
||||
var b strings.Builder
|
||||
for _, s := range path {
|
||||
b.WriteString("/")
|
||||
b.WriteString(strings.NewReplacer("~", "~0", "/", "~1").Replace(s))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func canonical(value any) string {
|
||||
b, _ := json.Marshal(value)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func canonicalRaw(raw json.RawMessage) string {
|
||||
var value any
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return string(raw)
|
||||
}
|
||||
return canonical(value)
|
||||
}
|
||||
|
||||
// format rückt das eben gesetzte Element ein: den Member name eines
|
||||
// Objekts bzw. (name leer) das letzte Element einer Liste unter eltern.
|
||||
// Steht das Elternelement in einer Zeile, bleibt es einzeilig.
|
||||
func (d *jsonFile) format(parents []string, name string) error {
|
||||
node := d.value.Find(pointer(parents))
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
depth := len(parents) + 1
|
||||
inside := "\n" + strings.Repeat(d.indent, depth)
|
||||
outside := "\n" + strings.Repeat(d.indent, depth-1)
|
||||
|
||||
switch c := node.Value.(type) {
|
||||
case *hujson.Object:
|
||||
member := withName(c, name)
|
||||
if member == nil {
|
||||
return nil
|
||||
}
|
||||
var before []hujson.Extra // Abstand vor den übrigen Membern
|
||||
for i := range c.Members {
|
||||
if &c.Members[i] != member {
|
||||
before = append(before, c.Members[i].Name.BeforeExtra)
|
||||
}
|
||||
}
|
||||
multiline := multiline(before)
|
||||
switch {
|
||||
case multiline:
|
||||
member.Name.BeforeExtra = []byte(inside)
|
||||
case member == &c.Members[0]:
|
||||
member.Name.BeforeExtra = nil
|
||||
default:
|
||||
member.Name.BeforeExtra = []byte(" ")
|
||||
}
|
||||
if err := d.reindent(&member.Value, multiline, depth); err != nil {
|
||||
return err
|
||||
}
|
||||
member.Value.BeforeExtra = []byte(" ")
|
||||
member.Value.AfterExtra = nil
|
||||
if multiline && !comment(c.AfterExtra) {
|
||||
c.AfterExtra = []byte(outside)
|
||||
}
|
||||
case *hujson.Array:
|
||||
if len(c.Elements) == 0 {
|
||||
return nil
|
||||
}
|
||||
var before []hujson.Extra
|
||||
for _, e := range c.Elements[:len(c.Elements)-1] {
|
||||
before = append(before, e.BeforeExtra)
|
||||
}
|
||||
target := &c.Elements[len(c.Elements)-1]
|
||||
multiline := multiline(before)
|
||||
if err := d.reindent(target, multiline, depth); err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case multiline:
|
||||
target.BeforeExtra = []byte(inside)
|
||||
if !comment(c.AfterExtra) {
|
||||
c.AfterExtra = []byte(outside)
|
||||
}
|
||||
case len(c.Elements) > 1:
|
||||
target.BeforeExtra = []byte(" ")
|
||||
default:
|
||||
target.BeforeExtra = nil
|
||||
}
|
||||
target.AfterExtra = nil
|
||||
}
|
||||
d.value.UpdateOffsets()
|
||||
return nil
|
||||
}
|
||||
|
||||
// reindent ersetzt den Wert durch eine eingerückte (oder kompakte) Fassung.
|
||||
func (d *jsonFile) reindent(v *hujson.Value, multiline bool, depth int) error {
|
||||
clone := v.Clone()
|
||||
clone.Standardize()
|
||||
var buffer bytes.Buffer
|
||||
if multiline {
|
||||
if err := json.Indent(&buffer, bytes.TrimSpace(clone.Pack()), strings.Repeat(d.indent, depth), d.indent); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := json.Compact(&buffer, clone.Pack()); err != nil {
|
||||
return err
|
||||
}
|
||||
next, err := hujson.Parse(buffer.Bytes())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v.Value = next.Value
|
||||
return nil
|
||||
}
|
||||
|
||||
func withName(obj *hujson.Object, name string) *hujson.ObjectMember {
|
||||
for i := range obj.Members {
|
||||
if n, _ := strconv.Unquote(string(obj.Members[i].Name.Value.(hujson.Literal))); n == name {
|
||||
return &obj.Members[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mehrzeilig: Die übrigen Elemente stehen auf eigenen Zeilen, oder es gibt
|
||||
// keine übrigen (dann wird ein neues Objekt bzw. eine neue Liste mehrzeilig).
|
||||
func multiline(before []hujson.Extra) bool {
|
||||
if len(before) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, e := range before {
|
||||
if bytes.Contains(e, []byte("\n")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func comment(e hujson.Extra) bool { return bytes.Contains(e, []byte("/")) }
|
||||
83
cli/internal/reconcile/json_test.go
Normal file
83
cli/internal/reconcile/json_test.go
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
package reconcile
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestJsonSet(t *testing.T) {
|
||||
munin := json.RawMessage(`{"type":"http","url":"https://m"}`)
|
||||
tests := []struct {
|
||||
name, before string
|
||||
path []string
|
||||
value json.RawMessage
|
||||
expected string
|
||||
}{
|
||||
{"neue Datei", "", []string{"mcpServers", "munin"}, munin,
|
||||
"{\n \"mcpServers\": {\n \"munin\": {\n \"type\": \"http\",\n \"url\": \"https://m\"\n }\n }\n}\n"},
|
||||
{"Einrückung der Datei", "{\n \"a\": 1\n}\n", []string{"mcpServers", "munin"}, munin,
|
||||
"{\n \"a\": 1,\n \"mcpServers\": {\n \"munin\": {\n \"type\": \"http\",\n \"url\": \"https://m\"\n }\n }\n}\n"},
|
||||
{"Kommentare bleiben", "{\n // Modell\n \"model\": \"x\"\n}\n", []string{"mcp", "munin"}, json.RawMessage(`{"enabled":true}`),
|
||||
"{\n // Modell\n \"model\": \"x\",\n \"mcp\": {\n \"munin\": {\n \"enabled\": true\n }\n }\n}\n"},
|
||||
{"einzeilige Datei bleibt einzeilig", `{"a": 1}`, []string{"b"}, json.RawMessage(`{"c": 2}`),
|
||||
`{"a": 1, "b": {"c":2}}` + "\n"},
|
||||
{"vorhandenen Wert ersetzen", "{\n \"a\": 1,\n \"b\": 2\n}\n", []string{"a"}, json.RawMessage(`[1,2]`),
|
||||
"{\n \"a\": [\n 1,\n 2\n ],\n \"b\": 2\n}\n"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
d, err := readJson([]byte(tt.before), tt.before == "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.set(tt.path, tt.value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := string(d.bytes()); got != tt.expected {
|
||||
t.Errorf("\n got:\n%s\nwant:\n%s", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJsonList(t *testing.T) {
|
||||
entry := json.RawMessage(`".hugin/rules/*.md"`)
|
||||
tests := []struct{ name, before, expected string }{
|
||||
{"Liste fehlt", "{\n \"model\": \"x\"\n}\n",
|
||||
"{\n \"model\": \"x\",\n \"instructions\": [\n \".hugin/rules/*.md\"\n ]\n}\n"},
|
||||
{"einzeilige Liste", "{\n \"instructions\": [\"docs/a.md\"]\n}\n",
|
||||
"{\n \"instructions\": [\"docs/a.md\", \".hugin/rules/*.md\"]\n}\n"},
|
||||
{"mehrzeilige Liste", "{\n \"instructions\": [\n \"docs/a.md\"\n ]\n}\n",
|
||||
"{\n \"instructions\": [\n \"docs/a.md\",\n \".hugin/rules/*.md\"\n ]\n}\n"},
|
||||
{"schon vorhanden", "{\"instructions\": [\".hugin/rules/*.md\"]}\n",
|
||||
"{\"instructions\": [\".hugin/rules/*.md\"]}\n"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
d, _ := readJson([]byte(tt.before), false)
|
||||
if err := d.addToList([]string{"instructions"}, entry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := string(d.bytes()); got != tt.expected {
|
||||
t.Errorf("\n got:\n%s\nwant:\n%s", got, tt.expected)
|
||||
}
|
||||
if err := d.removeFromList([]string{"instructions"}, entry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value, ok := d.read([]string{"instructions"})
|
||||
if items, _ := value.([]any); ok && (len(items) == 0 || index(items, entry) >= 0) {
|
||||
t.Errorf("Eintrag nicht entfernt oder leere Liste übrig: %v", value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJsonSpecialCharsInKey(t *testing.T) {
|
||||
d, _ := readJson(nil, true)
|
||||
if err := d.set([]string{"a/b", "c~d", "e.f"}, json.RawMessage(`1`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value, ok := d.read([]string{"a/b", "c~d", "e.f"}); !ok || value != float64(1) {
|
||||
t.Errorf("lesen: %v %v\n%s", value, ok, d.bytes())
|
||||
}
|
||||
}
|
||||
523
cli/internal/reconcile/reconcile.go
Normal file
523
cli/internal/reconcile/reconcile.go
Normal file
|
|
@ -0,0 +1,523 @@
|
|||
// Package abgleich wendet ein Bundle auf ein Repo an (→ KONZEPT.md 11.4, 11.5).
|
||||
//
|
||||
// Ablauf in zwei Schritten: Planen liest das Repo und berechnet alle neuen
|
||||
// Inhalte im Speicher, ohne etwas zu schreiben. Erst Run schreibt, und
|
||||
// nur, wenn der Plan keine offenen Befunde hat. So entsteht kein halber Zustand,
|
||||
// wenn eine Operation scheitert.
|
||||
package reconcile
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"hugin/cli/internal/bundle"
|
||||
"hugin/cli/internal/lock"
|
||||
"hugin/cli/internal/paths"
|
||||
)
|
||||
|
||||
type FindingKind string
|
||||
|
||||
const (
|
||||
// ManualChange: eine verwaltete Datei, ein Schlüssel oder Block wurde
|
||||
// seit dem letzten sync geändert. Der Wert erscheint in der Ausgabe.
|
||||
ManualChange FindingKind = "Handänderung"
|
||||
// Conflict: Datei oder Schlüssel gibt es schon, aber nicht von Hugin.
|
||||
Conflict FindingKind = "Konflikt"
|
||||
)
|
||||
|
||||
type Finding struct {
|
||||
Kind FindingKind
|
||||
Location string
|
||||
Text string
|
||||
}
|
||||
|
||||
func (b Finding) String() string { return fmt.Sprintf("%s: %s (%s)", b.Location, b.Text, b.Kind) }
|
||||
|
||||
type Options struct {
|
||||
// Overwrite: Hugin gewinnt bei Handänderungen und Konflikten.
|
||||
Overwrite bool
|
||||
}
|
||||
|
||||
// Plan ist das Ergebnis von Planen: was geschrieben und gelöscht würde.
|
||||
type Plan struct {
|
||||
Findings []Finding
|
||||
Write map[string][]byte // Pfad relativ zum Repo → neuer Inhalt
|
||||
Delete []string
|
||||
New []string // Pfade, die es vorher nicht gab
|
||||
Lock *lock.Lock
|
||||
options Options
|
||||
unchanged int
|
||||
}
|
||||
|
||||
// Open sagt, ob Befunde ein Ausführen verhindern.
|
||||
func (p *Plan) Open() bool { return len(p.Findings) > 0 && !p.options.Overwrite }
|
||||
|
||||
// Changed sagt, ob der Plan überhaupt etwas im Repo ändert.
|
||||
func (p *Plan) Changed() bool { return len(p.Write) > 0 || len(p.Delete) > 0 }
|
||||
|
||||
type file struct {
|
||||
before []byte // nil: gab es nicht
|
||||
content []byte // nil: soll gelöscht werden bzw. bleibt fehlen
|
||||
loaded bool
|
||||
json *jsonFile
|
||||
}
|
||||
|
||||
type planner struct {
|
||||
repo string
|
||||
bundle *bundle.Bundle
|
||||
old *lock.Lock
|
||||
next *lock.Lock
|
||||
files map[string]*file
|
||||
findings []Finding
|
||||
}
|
||||
|
||||
// MakePlan berechnet, was das Bundle im Repo ändern würde.
|
||||
func MakePlan(repo string, b *bundle.Bundle, old *lock.Lock, opt Options) (*Plan, error) {
|
||||
p := &planner{repo: repo, bundle: b, old: old, next: lock.New(), files: map[string]*file{}}
|
||||
p.next.Common.Created = slices.Clone(old.Common.Created)
|
||||
for _, pk := range b.Instructions.Packages {
|
||||
p.next.Packages[pk.ID] = &lock.Package{Version: pk.Version, Checksum: pk.Checksum}
|
||||
}
|
||||
for _, op := range b.Instructions.Operations {
|
||||
var err error
|
||||
switch op.Op {
|
||||
case "file":
|
||||
err = p.file(op)
|
||||
case "remove":
|
||||
err = p.remove(op.Path)
|
||||
case "json":
|
||||
err = p.json(op)
|
||||
case "json_remove":
|
||||
err = p.removeJson(op)
|
||||
case "block":
|
||||
err = p.block(op)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s %s: %w", op.Op, op.Path+op.File, err)
|
||||
}
|
||||
}
|
||||
if err := p.removeOrphaned(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := p.removeOrphanedKeys(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := p.removeOrphanedBlocks(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.result(opt)
|
||||
}
|
||||
|
||||
func (p *planner) load(rel string) (*file, error) {
|
||||
if d, ok := p.files[rel]; ok {
|
||||
return d, nil
|
||||
}
|
||||
abs, err := paths.InRepo(p.repo, rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d := &file{loaded: true}
|
||||
raw, err := os.ReadFile(abs)
|
||||
switch {
|
||||
case errors.Is(err, fs.ErrNotExist):
|
||||
case err != nil:
|
||||
return nil, err
|
||||
default:
|
||||
d.before, d.content = raw, raw
|
||||
}
|
||||
p.files[rel] = d
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (p *planner) finding(kind FindingKind, location, text string) {
|
||||
p.findings = append(p.findings, Finding{kind, location, text})
|
||||
}
|
||||
|
||||
func (p *planner) pkg(id string) *lock.Package {
|
||||
pk, ok := p.next.Packages[id]
|
||||
if !ok {
|
||||
// Operation für ein Paket ohne Eintrag in pakete (z. B. entfernen)
|
||||
pk = &lock.Package{}
|
||||
p.next.Packages[id] = pk
|
||||
}
|
||||
return pk
|
||||
}
|
||||
|
||||
func (p *planner) file(op bundle.Operation) error {
|
||||
content, err := p.bundle.File(op.Source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d, err := p.load(op.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.before != nil && !bytes.Equal(d.before, content) {
|
||||
oldSha, managed := p.old.Files()[op.Path]
|
||||
switch {
|
||||
case !managed:
|
||||
p.finding(Conflict, op.Path, "Datei gibt es schon, sie stammt nicht von Hugin")
|
||||
case bundle.Sha256(d.before) != oldSha:
|
||||
p.finding(ManualChange, op.Path, "seit dem letzten sync von Hand geändert")
|
||||
}
|
||||
}
|
||||
d.content = content
|
||||
pk := p.pkg(op.Package)
|
||||
if pk.Files == nil {
|
||||
pk.Files = map[string]string{}
|
||||
}
|
||||
pk.Files[op.Path] = op.Sha256
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *planner) remove(rel string) error {
|
||||
d, err := p.load(rel)
|
||||
if err != nil || d.before == nil {
|
||||
return err
|
||||
}
|
||||
oldSha, managed := p.old.Files()[rel]
|
||||
if !managed {
|
||||
// Nicht von Hugin: stehen lassen
|
||||
return nil
|
||||
}
|
||||
if bundle.Sha256(d.before) != oldSha {
|
||||
p.finding(ManualChange, rel, "soll entfernt werden, wurde aber von Hand geändert")
|
||||
}
|
||||
d.content = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeOrphaned räumt Dateien ab, die laut alter Lock Hugin gehören, im
|
||||
// neuen Stand aber fehlen, auch wenn das Bundle kein entfernen dafür enthält.
|
||||
func (p *planner) removeOrphaned() error {
|
||||
next := p.next.Files()
|
||||
old := p.old.Files()
|
||||
pathList := make([]string, 0, len(old))
|
||||
for relPath := range old {
|
||||
pathList = append(pathList, relPath)
|
||||
}
|
||||
sort.Strings(pathList)
|
||||
for _, relPath := range pathList {
|
||||
if _, stays := next[relPath]; !stays {
|
||||
if err := p.remove(relPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeOrphanedKeys entfernt JSON-Schlüssel und Listeneinträge, die
|
||||
// laut alter Lock Hugin gehören, im neuen Stand aber fehlen.
|
||||
func (p *planner) removeOrphanedKeys() error {
|
||||
next := p.next.AllKeys()
|
||||
for _, s := range p.old.AllKeys() {
|
||||
var value any
|
||||
if s.Mode == "list_item" {
|
||||
value = s.Value
|
||||
}
|
||||
if lock.Find(next, s.File, s.Path, s.Mode, value) != nil {
|
||||
continue
|
||||
}
|
||||
op := bundle.Operation{Op: "json_remove", File: s.File, Key: s.Path, Mode: s.Mode}
|
||||
if s.Mode == "list_item" {
|
||||
raw, err := json.Marshal(s.Value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
op.Value = raw
|
||||
}
|
||||
if err := p.removeJson(op); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeOrphanedBlocks entfernt verwaltete Blöcke, die im neuen Stand fehlen.
|
||||
func (p *planner) removeOrphanedBlocks() error {
|
||||
for key := range p.old.Common.Blocks {
|
||||
if _, stays := p.next.Common.Blocks[key]; stays {
|
||||
continue
|
||||
}
|
||||
file, block, _ := strings.Cut(key, "#")
|
||||
d, err := p.load(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, exists := readBlock(string(d.content), block); !exists {
|
||||
continue
|
||||
}
|
||||
empty := ""
|
||||
if err := p.block(bundle.Operation{Op: "block", File: file, Block: block, Content: &empty}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *planner) jsonFile(rel, create string) (*jsonFile, error) {
|
||||
d, err := p.load(rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if d.json != nil {
|
||||
return d.json, nil
|
||||
}
|
||||
raw, next := d.before, d.before == nil
|
||||
if next {
|
||||
if create != "" {
|
||||
if raw, err = p.bundle.File(create); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if path.Base(rel) == "opencode.json" {
|
||||
// Von OpenCode empfohlen: Editoren prüfen die Datei dann beim Schreiben
|
||||
raw = []byte("{\n \"$schema\": \"" + OpenCodeSchema + "\"\n}\n")
|
||||
}
|
||||
p.created(rel)
|
||||
}
|
||||
if d.json, err = readJson(raw, next); err != nil {
|
||||
return nil, fmt.Errorf("%s ist kein gültiges JSON: %w", rel, err)
|
||||
}
|
||||
return d.json, nil
|
||||
}
|
||||
|
||||
func (p *planner) created(rel string) {
|
||||
if !slices.Contains(p.next.Common.Created, rel) {
|
||||
p.next.Common.Created = append(p.next.Common.Created, rel)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *planner) json(op bundle.Operation) error {
|
||||
j, err := p.jsonFile(op.File, op.Create)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var value any
|
||||
if err := json.Unmarshal(op.Value, &value); err != nil {
|
||||
return err
|
||||
}
|
||||
location := op.File + " " + pointer(op.Key)
|
||||
entry := lock.Key{File: op.File, Path: op.Key, Mode: op.Mode}
|
||||
|
||||
if op.ListItem() {
|
||||
entry.Value = value
|
||||
if err := j.addToList(op.Key, op.Value); err != nil {
|
||||
p.finding(Conflict, location, err.Error())
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
entry.Sha256 = bundle.Sha256([]byte(canonical(value)))
|
||||
if existing, ok := j.read(op.Key); ok && canonical(existing) != canonical(value) {
|
||||
old := lock.Find(p.old.AllKeys(), op.File, op.Key, "", nil)
|
||||
switch {
|
||||
case old == nil:
|
||||
p.finding(Conflict, location, "Schlüssel gibt es schon, er stammt nicht von Hugin")
|
||||
case bundle.Sha256([]byte(canonical(existing))) != old.Sha256:
|
||||
p.finding(ManualChange, location, "seit dem letzten sync von Hand geändert")
|
||||
}
|
||||
}
|
||||
if err := j.set(op.Key, op.Value); err != nil {
|
||||
p.finding(Conflict, location, err.Error())
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if op.Package == "" {
|
||||
p.next.Common.Keys = append(p.next.Common.Keys, entry)
|
||||
} else {
|
||||
pk := p.pkg(op.Package)
|
||||
pk.Keys = append(pk.Keys, entry)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *planner) removeJson(op bundle.Operation) error {
|
||||
var value any
|
||||
if len(op.Value) > 0 {
|
||||
if err := json.Unmarshal(op.Value, &value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
old := lock.Find(p.old.AllKeys(), op.File, op.Key, op.Mode, value)
|
||||
if old == nil {
|
||||
return nil // nicht von Hugin
|
||||
}
|
||||
d, err := p.load(op.File)
|
||||
if err != nil || d.before == nil {
|
||||
return err
|
||||
}
|
||||
j, err := p.jsonFile(op.File, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if op.ListItem() {
|
||||
return j.removeFromList(op.Key, op.Value)
|
||||
}
|
||||
if existing, ok := j.read(op.Key); ok && bundle.Sha256([]byte(canonical(existing))) != old.Sha256 {
|
||||
p.finding(ManualChange, op.File+" "+pointer(op.Key), "soll entfernt werden, wurde aber von Hand geändert")
|
||||
}
|
||||
return j.remove(op.Key)
|
||||
}
|
||||
|
||||
func (p *planner) block(op bundle.Operation) error {
|
||||
d, err := p.load(op.File)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
text := string(d.content)
|
||||
if d.before == nil && *op.Content != "" {
|
||||
if op.Create != "" {
|
||||
raw, err := p.bundle.File(op.Create)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
text = string(raw)
|
||||
}
|
||||
p.created(op.File)
|
||||
}
|
||||
key := op.File + "#" + op.Block
|
||||
if existing, ok := readBlock(text, op.Block); ok {
|
||||
if oldSha := p.old.Common.Blocks[key]; oldSha != "" &&
|
||||
bundle.Sha256([]byte(existing)) != oldSha && existing != *op.Content {
|
||||
p.finding(ManualChange, key, "Block seit dem letzten sync von Hand geändert")
|
||||
}
|
||||
}
|
||||
// Leer heißt nicht löschen: ergebnis löscht nur Dateien, die Hugin angelegt hat.
|
||||
d.content = append([]byte{}, setBlock(text, op.Block, *op.Content)...)
|
||||
if *op.Content != "" {
|
||||
if p.next.Common.Blocks == nil {
|
||||
p.next.Common.Blocks = map[string]string{}
|
||||
}
|
||||
p.next.Common.Blocks[key] = bundle.Sha256([]byte(*op.Content))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *planner) result(opt Options) (*Plan, error) {
|
||||
plan := &Plan{Findings: p.findings, Write: map[string][]byte{}, Lock: p.next, options: opt}
|
||||
names := make([]string, 0, len(p.files))
|
||||
for rel := range p.files {
|
||||
names = append(names, rel)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, rel := range names {
|
||||
d := p.files[rel]
|
||||
if d.json != nil {
|
||||
d.content = d.json.bytes()
|
||||
if d.json.empty() && p.old.Created(rel) {
|
||||
d.content = nil
|
||||
}
|
||||
}
|
||||
if len(d.content) == 0 && p.old.Created(rel) {
|
||||
d.content = nil
|
||||
}
|
||||
switch {
|
||||
case d.content == nil && d.before != nil:
|
||||
plan.Delete = append(plan.Delete, rel)
|
||||
p.forgotten(rel)
|
||||
case d.content != nil && !bytes.Equal(d.content, d.before):
|
||||
plan.Write[rel] = d.content
|
||||
if d.before == nil {
|
||||
plan.New = append(plan.New, rel)
|
||||
}
|
||||
default:
|
||||
plan.unchanged++
|
||||
}
|
||||
}
|
||||
// Pakete ohne eigene Dateien und Schlüssel (nur entfernen) gehören nicht in die Lock
|
||||
for id, pk := range p.next.Packages {
|
||||
if pk.Version == "" {
|
||||
delete(p.next.Packages, id)
|
||||
}
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
func (p *planner) forgotten(rel string) {
|
||||
p.next.Common.Created = slices.DeleteFunc(p.next.Common.Created, func(s string) bool { return s == rel })
|
||||
}
|
||||
|
||||
// Run schreibt den Plan: erst alle neuen Inhalte in Hilfsdateien, dann
|
||||
// umbenennen, dann löschen, zuletzt hugin.lock.
|
||||
func (p *Plan) Run(repo string) error {
|
||||
return p.RunWithLock(repo, filepath.Join(repo, lock.Filename))
|
||||
}
|
||||
|
||||
// RunWithLock schreibt wie Run, die Lock-Datei aber nach lockPath
|
||||
// (hugin try führt eine eigene, damit hugin.lock unberührt bleibt).
|
||||
func (p *Plan) RunWithLock(repo, lockPath string) error {
|
||||
if p.Open() {
|
||||
return errors.New("offene Befunde; mit --overwrite setzt Hugin die Dateien zurück")
|
||||
}
|
||||
var helperFiles [][2]string
|
||||
cleanUp := func() {
|
||||
for _, h := range helperFiles {
|
||||
os.Remove(h[0])
|
||||
}
|
||||
}
|
||||
names := make([]string, 0, len(p.Write))
|
||||
for rel := range p.Write {
|
||||
names = append(names, rel)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, rel := range names {
|
||||
abs, err := paths.InRepo(repo, rel)
|
||||
if err != nil {
|
||||
cleanUp()
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
||||
cleanUp()
|
||||
return err
|
||||
}
|
||||
tmp := abs + ".hugin-new"
|
||||
if err := os.WriteFile(tmp, p.Write[rel], 0o644); err != nil {
|
||||
cleanUp()
|
||||
return err
|
||||
}
|
||||
helperFiles = append(helperFiles, [2]string{tmp, abs})
|
||||
}
|
||||
for _, h := range helperFiles {
|
||||
if err := os.Rename(h[0], h[1]); err != nil {
|
||||
cleanUp()
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, rel := range p.Delete {
|
||||
abs, err := paths.InRepo(repo, rel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(abs); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
removeEmptyFolders(repo, path.Dir(rel))
|
||||
}
|
||||
raw, err := p.Lock.Bytes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(lockPath, raw, 0o644)
|
||||
}
|
||||
|
||||
// removeEmptyFolders löscht rel und seine Eltern, solange sie leer sind.
|
||||
func removeEmptyFolders(repo, rel string) {
|
||||
for rel != "." && rel != "" && !strings.HasPrefix(rel, "..") {
|
||||
if err := os.Remove(filepath.Join(repo, filepath.FromSlash(rel))); err != nil {
|
||||
return
|
||||
}
|
||||
rel = path.Dir(rel)
|
||||
}
|
||||
}
|
||||
328
cli/internal/reconcile/reconcile_test.go
Normal file
328
cli/internal/reconcile/reconcile_test.go
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
package reconcile
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hugin/cli/internal/bundle"
|
||||
"hugin/cli/internal/lock"
|
||||
)
|
||||
|
||||
// Goldene Bundles, die die Server-Tests erzeugen (contract/examples/bundles).
|
||||
const examples = "../../../contract/examples/bundles"
|
||||
|
||||
func load(t *testing.T, name string) *bundle.Bundle {
|
||||
t.Helper()
|
||||
b, err := bundle.Read(filepath.Join(examples, name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func sync(t *testing.T, repo, name string, opt Options) *Plan {
|
||||
t.Helper()
|
||||
old, err := lock.Load(repo)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan, err := MakePlan(repo, load(t, name), old, opt)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !plan.Open() {
|
||||
if err := plan.Run(repo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
func read(t *testing.T, repo, rel string) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join(repo, rel))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func write(t *testing.T, repo, rel, content string) {
|
||||
t.Helper()
|
||||
abs := filepath.Join(repo, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(abs, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func exists(repo, rel string) bool {
|
||||
_, err := os.Stat(filepath.Join(repo, rel))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func jsonValue(t *testing.T, repo, rel string) map[string]any {
|
||||
t.Helper()
|
||||
var value map[string]any
|
||||
if err := json.Unmarshal([]byte(read(t, repo, rel)), &value); err != nil {
|
||||
t.Fatalf("%s: %v", rel, err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func TestFirstInstallInEmptyRepo(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
plan := sync(t, repo, "first-install", Options{})
|
||||
if len(plan.Findings) > 0 {
|
||||
t.Fatalf("Befunde: %v", plan.Findings)
|
||||
}
|
||||
for _, rel := range []string{
|
||||
".claude/skills/pub-munin-nutzen/SKILL.md",
|
||||
".claude/skills/pub-munin-nutzen/referenz/beispiele.md",
|
||||
".hugin/rules/pub-hausregeln.md",
|
||||
"CLAUDE.md", ".mcp.json", "opencode.json", "hugin.lock",
|
||||
} {
|
||||
if !exists(repo, rel) {
|
||||
t.Errorf("%s fehlt", rel)
|
||||
}
|
||||
}
|
||||
claudeMd := read(t, repo, "CLAUDE.md")
|
||||
if !strings.Contains(claudeMd, "<!-- hugin:begin") || !strings.Contains(claudeMd, "@.hugin/rules/pub-hausregeln.md\n<!-- hugin:end -->") {
|
||||
t.Errorf("CLAUDE.md ohne Block:\n%s", claudeMd)
|
||||
}
|
||||
mcp := jsonValue(t, repo, ".mcp.json")["mcpServers"].(map[string]any)["munin"].(map[string]any)
|
||||
if mcp["type"] != "http" {
|
||||
t.Errorf(".mcp.json: %v", mcp)
|
||||
}
|
||||
oc := jsonValue(t, repo, "opencode.json")
|
||||
if oc["instructions"].([]any)[0] != ".hugin/rules/*.md" {
|
||||
t.Errorf("opencode.json: %v", oc)
|
||||
}
|
||||
|
||||
// Ein zweiter sync mit demselben Bundle ändert nichts
|
||||
if plan := sync(t, repo, "first-install", Options{}); plan.Changed() {
|
||||
t.Errorf("zweiter sync ändert etwas: schreiben %v, löschen %v", plan.Write, plan.Delete)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCleansUp(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
sync(t, repo, "first-install", Options{})
|
||||
plan := sync(t, repo, "update", Options{})
|
||||
if len(plan.Findings) > 0 {
|
||||
t.Fatalf("Befunde: %v", plan.Findings)
|
||||
}
|
||||
for _, remove := range []string{
|
||||
".claude/skills/pub-munin-nutzen/referenz",
|
||||
".hugin/rules/pub-hausregeln.md",
|
||||
".hugin",
|
||||
"CLAUDE.md", // von Hugin angelegt und jetzt leer
|
||||
} {
|
||||
if exists(repo, remove) {
|
||||
t.Errorf("%s sollte entfernt sein", remove)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(read(t, repo, ".claude/skills/pub-munin-nutzen/SKILL.md"), "pub-munin-nutzen 1.5.0") {
|
||||
t.Error("SKILL.md nicht aktualisiert")
|
||||
}
|
||||
oc := jsonValue(t, repo, "opencode.json")
|
||||
if _, ok := oc["mcp"]; !ok {
|
||||
t.Error("MCP-Eintrag fehlt")
|
||||
}
|
||||
if _, ok := oc["instructions"]; ok {
|
||||
t.Errorf("leere instructions nicht entfernt: %v", oc["instructions"])
|
||||
}
|
||||
l, _ := lock.Load(repo)
|
||||
if _, ok := l.Packages["pub-hausregeln"]; ok {
|
||||
t.Error("pub-hausregeln noch in hugin.lock")
|
||||
}
|
||||
if l.Packages["pub-munin-nutzen"].Version != "1.5.0" {
|
||||
t.Errorf("Version in hugin.lock: %v", l.Packages["pub-munin-nutzen"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeamFilesArePreserved(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
write(t, repo, "CLAUDE.md", "# Unser Projekt\r\n\r\nWir nutzen Go.\r\n")
|
||||
write(t, repo, "opencode.json", "{\n // unser Modell\n \"model\": \"anthropic/claude-sonnet-5-5\",\n \"instructions\": [\"docs/stil.md\"]\n}\n")
|
||||
|
||||
sync(t, repo, "first-install", Options{})
|
||||
claudeMd := read(t, repo, "CLAUDE.md")
|
||||
if !strings.HasPrefix(claudeMd, "# Unser Projekt\r\n\r\nWir nutzen Go.\r\n\r\n<!-- hugin:begin") {
|
||||
t.Errorf("CLAUDE.md:\n%q", claudeMd)
|
||||
}
|
||||
if strings.Contains(strings.ReplaceAll(claudeMd, "\r\n", ""), "\n") {
|
||||
t.Error("CLAUDE.md: Zeilenenden gemischt")
|
||||
}
|
||||
oc := read(t, repo, "opencode.json")
|
||||
if !strings.Contains(oc, "// unser Modell") || !strings.Contains(oc, `"docs/stil.md"`) {
|
||||
t.Errorf("opencode.json verändert:\n%s", oc)
|
||||
}
|
||||
|
||||
sync(t, repo, "update", Options{})
|
||||
if got := read(t, repo, "CLAUDE.md"); got != "# Unser Projekt\r\n\r\nWir nutzen Go.\r\n" {
|
||||
t.Errorf("CLAUDE.md nach Abbestellen:\n%q", got)
|
||||
}
|
||||
oc = read(t, repo, "opencode.json")
|
||||
if !strings.Contains(oc, `"docs/stil.md"`) || strings.Contains(oc, ".hugin/rules") {
|
||||
t.Errorf("opencode.json nach Abbestellen:\n%s", oc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualChangeIsDetected(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
sync(t, repo, "first-install", Options{})
|
||||
skill := ".claude/skills/pub-munin-nutzen/SKILL.md"
|
||||
write(t, repo, skill, "von Hand\n")
|
||||
|
||||
plan := sync(t, repo, "update", Options{})
|
||||
if !plan.Open() || len(plan.Findings) != 1 || plan.Findings[0].Kind != ManualChange || plan.Findings[0].Location != skill {
|
||||
t.Fatalf("Befunde: %v", plan.Findings)
|
||||
}
|
||||
if read(t, repo, skill) != "von Hand\n" {
|
||||
t.Error("Datei trotz Befund geändert")
|
||||
}
|
||||
if !exists(repo, ".hugin/rules/pub-hausregeln.md") {
|
||||
t.Error("andere Dateien trotz Befund geändert")
|
||||
}
|
||||
|
||||
sync(t, repo, "update", Options{Overwrite: true})
|
||||
if !strings.Contains(read(t, repo, skill), "1.5.0") {
|
||||
t.Error("--overwrite setzt nicht zurück")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualChangeInBlock(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
sync(t, repo, "first-install", Options{})
|
||||
text := strings.Replace(read(t, repo, "CLAUDE.md"), "@.hugin", "@extra.md\n@.hugin", 1)
|
||||
write(t, repo, "CLAUDE.md", text)
|
||||
if plan := sync(t, repo, "first-install", Options{}); len(plan.Findings) != 1 || plan.Findings[0].Location != "CLAUDE.md#hugin" {
|
||||
t.Fatalf("Befunde: %v", plan.Findings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForeignMcpServerIsConflict(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
write(t, repo, ".mcp.json", `{"mcpServers": {"munin": {"type": "http", "url": "https://anders"}}}`)
|
||||
plan := sync(t, repo, "first-install", Options{})
|
||||
if len(plan.Findings) != 1 || plan.Findings[0].Kind != Conflict {
|
||||
t.Fatalf("Befunde: %v", plan.Findings)
|
||||
}
|
||||
if exists(repo, "hugin.lock") {
|
||||
t.Error("trotz Konflikt geschrieben")
|
||||
}
|
||||
}
|
||||
|
||||
func TestForeignFileIsConflict(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
write(t, repo, ".claude/skills/pub-munin-nutzen/SKILL.md", "eigener Skill\n")
|
||||
plan := sync(t, repo, "first-install", Options{})
|
||||
if len(plan.Findings) != 1 || plan.Findings[0].Kind != Conflict {
|
||||
t.Fatalf("Befunde: %v", plan.Findings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSymlinkInRepoIsRejected(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
if err := os.Symlink(outside, filepath.Join(repo, ".claude")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := MakePlan(repo, load(t, "first-install"), lock.New(), Options{}); err == nil {
|
||||
t.Fatal("Symlink nicht erkannt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockSet(t *testing.T) {
|
||||
tests := []struct{ name, text, content, expected string }{
|
||||
{"leere Datei", "", "a\n", beginMarker("hugin") + "\na\n" + endMarker("hugin") + "\n"},
|
||||
{"anhängen", "x\n", "a\n", "x\n\n" + beginMarker("hugin") + "\na\n" + endMarker("hugin") + "\n"},
|
||||
{"replace", "x\n\n" + beginMarker("hugin") + "\nalt\n" + endMarker("hugin") + "\ny\n", "a\nb\n",
|
||||
"x\n\n" + beginMarker("hugin") + "\na\nb\n" + endMarker("hugin") + "\ny\n"},
|
||||
{"remove", "x\n\n" + beginMarker("hugin") + "\nalt\n" + endMarker("hugin") + "\n", "", "x\n"},
|
||||
{"entfernen ohne Block", "x\n", "", "x\n"},
|
||||
{"nur Block entfernen", beginMarker("hugin") + "\nalt\n" + endMarker("hugin") + "\n", "", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := setBlock(tt.text, "hugin", tt.content); got != tt.expected {
|
||||
t.Errorf("\n got %q\nwant %q", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandsAndAgents(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
if plan := sync(t, repo, "commands-and-agents", Options{}); len(plan.Findings) > 0 {
|
||||
t.Fatalf("Befunde: %v", plan.Findings)
|
||||
}
|
||||
for _, rel := range []string{
|
||||
".claude/commands/pub-release-notiz.md", ".opencode/commands/pub-release-notiz.md",
|
||||
".claude/agents/pub-code-review.md", ".opencode/agents/pub-code-review.md",
|
||||
} {
|
||||
if !exists(repo, rel) {
|
||||
t.Errorf("%s fehlt", rel)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(read(t, repo, ".opencode/commands/pub-release-notiz.md"), "Version $1, gedacht für $2") {
|
||||
t.Error("Platzhalter für OpenCode nicht übersetzt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyBundleCleansUpEverything(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
write(t, repo, "opencode.json", "{\n \"model\": \"x\"\n}\n")
|
||||
sync(t, repo, "first-install", Options{})
|
||||
old, _ := lock.Load(repo)
|
||||
plan, err := MakePlan(repo, bundle.Empty(), old, Options{})
|
||||
if err != nil || len(plan.Findings) > 0 {
|
||||
t.Fatalf("%v %v", err, plan.Findings)
|
||||
}
|
||||
if err := plan.Run(repo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, remove := range []string{".claude", ".hugin", "CLAUDE.md", ".mcp.json"} {
|
||||
if exists(repo, remove) {
|
||||
t.Errorf("%s ist noch da", remove)
|
||||
}
|
||||
}
|
||||
if got := jsonValue(t, repo, "opencode.json"); len(got) != 1 || got["model"] != "x" {
|
||||
t.Errorf("opencode.json: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewOpencodeJsonHasSchemaAndIsCleanedUp(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
sync(t, repo, "first-install", Options{})
|
||||
if got := jsonValue(t, repo, "opencode.json")["$schema"]; got != OpenCodeSchema {
|
||||
t.Errorf("$schema in neuer opencode.json: %v", got)
|
||||
}
|
||||
// Von hugin angelegt und nur noch $schema übrig: wird wieder entfernt
|
||||
old, _ := lock.Load(repo)
|
||||
plan, err := MakePlan(repo, bundle.Empty(), old, Options{})
|
||||
if err != nil || len(plan.Findings) > 0 {
|
||||
t.Fatalf("%v %v", err, plan.Findings)
|
||||
}
|
||||
if err := plan.Run(repo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if exists(repo, "opencode.json") {
|
||||
t.Errorf("opencode.json ist noch da:\n%s", read(t, repo, "opencode.json"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExistingOpencodeJsonGetsNoSchema(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
write(t, repo, "opencode.json", "{\n \"model\": \"x\"\n}\n")
|
||||
sync(t, repo, "first-install", Options{})
|
||||
if _, ok := jsonValue(t, repo, "opencode.json")["$schema"]; ok {
|
||||
t.Error("hugin schreibt $schema nur in Dateien, die es selbst anlegt")
|
||||
}
|
||||
}
|
||||
58
cli/internal/repo/repo.go
Normal file
58
cli/internal/repo/repo.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// Package repo liefert die Repo-Kennung für die Nutzungsmeldung (→ KONZEPT.md 11.5).
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Id ist die vereinheitlichte Git-Remote-URL von origin, z. B.
|
||||
// git.intern/shop/api. Ohne Remote: der Pfad relativ zum Benutzerordner.
|
||||
func Id(repo string) string {
|
||||
ctx, abort := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer abort()
|
||||
from, err := exec.CommandContext(ctx, "git", "-C", repo, "remote", "get-url", "origin").Output()
|
||||
if err == nil {
|
||||
if k := Unify(strings.TrimSpace(string(from))); k != "" {
|
||||
return k
|
||||
}
|
||||
}
|
||||
abs, _ := filepath.Abs(repo)
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
if rel, err := filepath.Rel(home, abs); err == nil && !strings.HasPrefix(rel, "..") {
|
||||
return "~/" + filepath.ToSlash(rel)
|
||||
}
|
||||
}
|
||||
return filepath.ToSlash(abs)
|
||||
}
|
||||
|
||||
var scpForm = regexp.MustCompile(`^[^@/:]+@([^:/]+):(.+)$`) // git@host:pfad
|
||||
|
||||
// Unify entfernt Schema, Zugangsdaten, Port bei SSH und .git:
|
||||
// https://user:pw@git.intern/shop/api.git und git@git.intern:shop/api.git
|
||||
// werden beide zu git.intern/shop/api.
|
||||
func Unify(url string) string {
|
||||
if t := scpForm.FindStringSubmatch(url); t != nil {
|
||||
url = t[1] + "/" + t[2]
|
||||
} else {
|
||||
if i := strings.Index(url, "://"); i >= 0 {
|
||||
schema := url[:i]
|
||||
url = url[i+3:]
|
||||
if j := strings.Index(url, "@"); j >= 0 && j < strings.Index(url+"/", "/") {
|
||||
url = url[j+1:]
|
||||
}
|
||||
if schema == "ssh" {
|
||||
if host, rest, ok := strings.Cut(url, "/"); ok {
|
||||
url = strings.Split(host, ":")[0] + "/" + rest
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
url = strings.TrimSuffix(strings.TrimSuffix(url, "/"), ".git")
|
||||
return strings.ToLower(url[:strings.Index(url+"/", "/")]) + url[strings.Index(url+"/", "/"):]
|
||||
}
|
||||
17
cli/internal/repo/repo_test.go
Normal file
17
cli/internal/repo/repo_test.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package repo
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestUnify(t *testing.T) {
|
||||
for url, expected := range map[string]string{
|
||||
"https://git.intern/shop/api.git": "git.intern/shop/api",
|
||||
"https://user:geheim@Git.Intern/shop/api": "git.intern/shop/api",
|
||||
"git@git.intern:shop/api.git": "git.intern/shop/api",
|
||||
"ssh://git@git.intern:2222/shop/api.git": "git.intern/shop/api",
|
||||
"https://git.intern:8443/shop/api/": "git.intern:8443/shop/api",
|
||||
} {
|
||||
if got := Unify(url); got != expected {
|
||||
t.Errorf("Vereinheitlichen(%q) = %q, erwartet %q", url, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
38
cli/internal/tools/opencode_test.go
Normal file
38
cli/internal/tools/opencode_test.go
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeOpenCode legt ein Programm „opencode“ in einen eigenen PATH.
|
||||
func fakeOpenCode(t *testing.T, script string) {
|
||||
t.Helper()
|
||||
bin := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(bin, "opencode"), []byte("#!/bin/sh\n"+script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", bin)
|
||||
}
|
||||
|
||||
func TestCheckOpenCode(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
|
||||
fakeOpenCode(t, "exit 0\n")
|
||||
if msg := CheckOpenCode(repo); msg != "" {
|
||||
t.Errorf("gültige Konfiguration: %q", msg)
|
||||
}
|
||||
|
||||
fakeOpenCode(t, `printf '\033[91m\033[1mError: \033[0mConfiguration is invalid at %s/opencode.json\n↳ Missing key mcp.x.enabled\n' "$PWD" >&2; exit 1`+"\n")
|
||||
msg := CheckOpenCode(repo)
|
||||
want := "Configuration is invalid at " + repo + "/opencode.json\n↳ Missing key mcp.x.enabled"
|
||||
if resolved, _ := filepath.EvalSymlinks(repo); msg != want && msg != "Configuration is invalid at "+resolved+"/opencode.json\n↳ Missing key mcp.x.enabled" {
|
||||
t.Errorf("Meldung ohne Farbcodes erwartet:\n%q\nbekommen:\n%q", want, msg)
|
||||
}
|
||||
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
if msg := CheckOpenCode(repo); msg != "" {
|
||||
t.Errorf("ohne OpenCode wird nichts geprüft: %q", msg)
|
||||
}
|
||||
}
|
||||
115
cli/internal/tools/tools.go
Normal file
115
cli/internal/tools/tools.go
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
// Package werkzeuge erkennt Claude Code und OpenCode im Repo und auf dem
|
||||
// Rechner und prüft ihre Versionen gegen die Untergrenzen (→ KONZEPT.md 5.4, 5.5).
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Untergrenzen, solange der Server keine anderen liefert (→ KONZEPT.md 5.5).
|
||||
var Floors = map[string]string{
|
||||
"claude": "2.1.105",
|
||||
"opencode": "1.2.11",
|
||||
}
|
||||
|
||||
var hints = map[string][]string{
|
||||
"claude": {".claude", "CLAUDE.md", ".mcp.json"},
|
||||
"opencode": {".opencode", "opencode.json", "opencode.jsonc", "AGENTS.md"},
|
||||
}
|
||||
|
||||
// InRepo erkennt die Werkzeuge an vorhandenen Dateien. Ohne Hinweise: beide.
|
||||
func InRepo(repo string) []string {
|
||||
var found []string
|
||||
for _, name := range []string{"claude", "opencode"} {
|
||||
for _, file := range hints[name] {
|
||||
if _, err := os.Stat(filepath.Join(repo, file)); err == nil {
|
||||
found = append(found, name)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(found) == 0 {
|
||||
return []string{"claude", "opencode"}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
var semver = regexp.MustCompile(`\d+\.\d+\.\d+`)
|
||||
|
||||
// Version fragt das installierte Werkzeug nach seiner Version; leer, wenn es
|
||||
// nicht installiert ist oder nicht antwortet.
|
||||
func Version(name string) string {
|
||||
ctx, abort := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer abort()
|
||||
from, err := exec.CommandContext(ctx, name, "--version").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return semver.FindString(string(from))
|
||||
}
|
||||
|
||||
// Less vergleicht zwei Versionen a.b.c.
|
||||
func Less(a, b string) bool {
|
||||
pa, pb := parts(a), parts(b)
|
||||
for i := range 3 {
|
||||
if pa[i] != pb[i] {
|
||||
return pa[i] < pb[i]
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parts(v string) [3]int {
|
||||
var t [3]int
|
||||
for i, s := range strings.SplitN(v, ".", 3) {
|
||||
t[i], _ = strconv.Atoi(s)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// Target ist der Name im Bundle: claude, opencode@1 oder opencode@2.
|
||||
func Target(name, version string) string {
|
||||
if name != "opencode" {
|
||||
return name
|
||||
}
|
||||
if version != "" && !Less(version, "2.0.0") {
|
||||
return "opencode@2"
|
||||
}
|
||||
return "opencode@1"
|
||||
}
|
||||
|
||||
var ansi = regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
||||
|
||||
// CheckOpenCode lässt OpenCode die zusammengeführte Konfiguration im Repo laden. OpenCode
|
||||
// startet nicht, wenn opencode.json ein falsches Feld hat; das soll hugin melden, bevor es
|
||||
// jemand beim Start merkt. Leer bei Erfolg oder wenn OpenCode nicht installiert ist.
|
||||
// --pure: globale Plugins nicht mitladen (sie können eigene Fehler haben).
|
||||
func CheckOpenCode(repo string) string {
|
||||
if _, err := exec.LookPath("opencode"); err != nil {
|
||||
return ""
|
||||
}
|
||||
ctx, abort := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer abort()
|
||||
cmd := exec.CommandContext(ctx, "opencode", "debug", "config", "--pure")
|
||||
cmd.Dir = repo
|
||||
var stderr strings.Builder
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err == nil {
|
||||
return ""
|
||||
} else if ctx.Err() != nil {
|
||||
return "opencode debug config antwortet nicht (Zeitlimit 30 s)"
|
||||
}
|
||||
text := strings.TrimSpace(ansi.ReplaceAllString(stderr.String(), ""))
|
||||
text = strings.TrimPrefix(text, "Error: ")
|
||||
if text == "" {
|
||||
return "opencode debug config ist fehlgeschlagen"
|
||||
}
|
||||
return text
|
||||
}
|
||||
44
cli/internal/tools/tools_test.go
Normal file
44
cli/internal/tools/tools_test.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLess(t *testing.T) {
|
||||
tests := []struct {
|
||||
a, b string
|
||||
ok bool
|
||||
}{
|
||||
{"2.1.104", "2.1.105", true},
|
||||
{"2.1.105", "2.1.105", false},
|
||||
{"2.10.0", "2.9.9", false},
|
||||
{"1.2.9", "1.2.11", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if Less(tt.a, tt.b) != tt.ok {
|
||||
t.Errorf("Kleiner(%s, %s) != %v", tt.a, tt.b, tt.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTarget(t *testing.T) {
|
||||
for version, target := range map[string]string{"1.19.3": "opencode@1", "2.0.1": "opencode@2", "": "opencode@1"} {
|
||||
if got := Target("opencode", version); got != target {
|
||||
t.Errorf("Ziel(opencode, %q) = %s", version, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInRepo(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
if got := InRepo(repo); !slices.Equal(got, []string{"claude", "opencode"}) {
|
||||
t.Errorf("leeres Repo: %v", got)
|
||||
}
|
||||
os.WriteFile(filepath.Join(repo, "CLAUDE.md"), nil, 0o644)
|
||||
if got := InRepo(repo); !slices.Equal(got, []string{"claude"}) {
|
||||
t.Errorf("nur CLAUDE.md: %v", got)
|
||||
}
|
||||
}
|
||||
73
contract/bundle-request.schema.json
Normal file
73
contract/bundle-request.schema.json
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://hugin.intern/schema/bundle-request-1.json",
|
||||
"title": "Anfrage an POST /api/v1/bundle bzw. /api/v1/delivery/bundle",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"packages",
|
||||
"tools"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"packages": {
|
||||
"description": "Abos: <id>@<stabil|test|^major|exakte Version>. Ohne @ gilt @stabil.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9]+(-[a-z0-9]+)*(@(stabil|test|\\^\\d+|\\d+\\.\\d+\\.\\d+))?$"
|
||||
},
|
||||
"uniqueItems": true
|
||||
},
|
||||
"unsubscribed": {
|
||||
"description": "Pakete, die nicht geliefert werden sollen. Für Pflichtpakete wirkungslos.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9]+(-[a-z0-9]+)*$"
|
||||
},
|
||||
"uniqueItems": true
|
||||
},
|
||||
"tools": {
|
||||
"description": "Erkannte Werkzeuge mit installierter Version; leerer String, wenn die Version unbekannt ist.",
|
||||
"type": "object",
|
||||
"minProperties": 1,
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"claude": {
|
||||
"type": "string"
|
||||
},
|
||||
"opencode": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mandatory": {
|
||||
"description": "Pflichtpakete mitliefern.",
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"major": {
|
||||
"description": "Major-Sprünge bei @stabil erlauben (hugin sync --major).",
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"options": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"opencode_reads_claude": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"installed": {
|
||||
"description": "Aktuell installierte Versionen (aus hugin.lock). Daraus berechnet der Server die entfernen-Operationen.",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+$"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
# Verwaltet von Hugin (pub-munin-nutzen 1.4.0). Änderungen über hugin.intern.
|
||||
name: pub-munin-nutzen
|
||||
description: 'Wann und wie du Munin nutzt: vor neuer Arbeit recall, nach Erkenntnissen remember. Nutze diesen Skill immer, wenn du in einem Projekt mit Munin arbeitest.'
|
||||
license: intern
|
||||
---
|
||||
|
||||
# Munin nutzen
|
||||
|
||||
Munin ist das gemeinsame Gedächtnis der Teams.
|
||||
|
||||
1. Bevor du mit einer neuen Aufgabe beginnst, frage Munin mit `recall` nach
|
||||
bekannten Besonderheiten des Projekts.
|
||||
2. Wenn du etwas Neues und Dauerhaftes herausfindest, halte es mit `remember` fest.
|
||||
3. Mit `related` findest du verwandte Einträge.
|
||||
|
||||
Beispiele stehen in [referenz/beispiele.md](referenz/beispiele.md).
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
# Beispiele
|
||||
|
||||
- Gut: „Die Staging-DB wird jeden Sonntag zurückgesetzt.“
|
||||
- Schlecht: „Ich habe heute die Tests laufen lassen.“
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<!-- Verwaltet von Hugin (pub-hausregeln 1.0.0). Änderungen über hugin.intern. -->
|
||||
|
||||
# Hausregeln
|
||||
|
||||
- Schreibe keine Geheimnisse (Keys, Passwörter) in Dateien oder Commits.
|
||||
- Frage nach, bevor du etwas löschst, das du nicht selbst angelegt hast.
|
||||
57
contract/examples/bundles/claude-only/hugin-bundle.json
Normal file
57
contract/examples/bundles/claude-only/hugin-bundle.json
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
{
|
||||
"format": 1,
|
||||
"generated": "2026-10-18T12:00:00Z",
|
||||
"tools": [
|
||||
"claude"
|
||||
],
|
||||
"packages": [
|
||||
{
|
||||
"id": "pub-hausregeln",
|
||||
"version": "1.0.0",
|
||||
"checksum": "sha256:0e2983623cec5dfe509ffb5f62332a4ff54a79b2affe5c981165148cb09ce127",
|
||||
"kind": "rules",
|
||||
"risk": "text",
|
||||
"compatibility": "translated",
|
||||
"mandatory": false
|
||||
},
|
||||
{
|
||||
"id": "pub-munin-nutzen",
|
||||
"version": "1.4.0",
|
||||
"checksum": "sha256:9e50afeed46db30a6e651488caf175a82b763ce266943525e49a3c85cd05ca37",
|
||||
"kind": "skill",
|
||||
"risk": "text",
|
||||
"compatibility": "portable",
|
||||
"mandatory": false
|
||||
}
|
||||
],
|
||||
"operations": [
|
||||
{
|
||||
"op": "file",
|
||||
"package": "pub-munin-nutzen",
|
||||
"path": ".claude/skills/pub-munin-nutzen/SKILL.md",
|
||||
"source": "files/.claude/skills/pub-munin-nutzen/SKILL.md",
|
||||
"sha256": "sha256:d3ad99d5e6f43d532917f1f57589259cf4812d292e1104f087d56820d5fbbe26"
|
||||
},
|
||||
{
|
||||
"op": "file",
|
||||
"package": "pub-munin-nutzen",
|
||||
"path": ".claude/skills/pub-munin-nutzen/referenz/beispiele.md",
|
||||
"source": "files/.claude/skills/pub-munin-nutzen/referenz/beispiele.md",
|
||||
"sha256": "sha256:2746734c5c36045173fadbed75377a65817c8ca6fccb4e487773fc0ae75ec58a"
|
||||
},
|
||||
{
|
||||
"op": "file",
|
||||
"package": "pub-hausregeln",
|
||||
"path": ".hugin/rules/pub-hausregeln.md",
|
||||
"source": "files/.hugin/rules/pub-hausregeln.md",
|
||||
"sha256": "sha256:670a7621fa4b561ed580452fde7881d551d3c9cb83ae6adc581cb2e1b61c1bfd"
|
||||
},
|
||||
{
|
||||
"op": "block",
|
||||
"file": "CLAUDE.md",
|
||||
"block": "hugin",
|
||||
"content": "@.hugin/rules/pub-hausregeln.md\n"
|
||||
}
|
||||
],
|
||||
"hints": []
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
# Verwaltet von Hugin (pub-code-review 1.0.0). Änderungen über hugin.intern.
|
||||
name: pub-code-review
|
||||
description: Prüft Änderungen auf Fehler und Verständlichkeit. Nach größeren Änderungen einsetzen, bevor ein Pull Request entsteht.
|
||||
tools: Read, Grep, Glob
|
||||
model: opus
|
||||
effort: high
|
||||
---
|
||||
|
||||
Du prüfst Änderungen an Code. Du änderst nichts, du schreibst nur Befunde.
|
||||
|
||||
Für jeden Befund: Datei und Zeile, was falsch ist, ein konkreter Vorschlag.
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
# Verwaltet von Hugin (pub-release-notiz 1.0.0). Änderungen über hugin.intern.
|
||||
description: Schreibt eine Release-Notiz für eine Version.
|
||||
argument-hint: '[version] [zielgruppe]'
|
||||
arguments:
|
||||
- version
|
||||
- zielgruppe
|
||||
---
|
||||
|
||||
Schreibe eine Release-Notiz für Version $version, gedacht für $zielgruppe.
|
||||
Lies dazu die Commits seit dem letzten Tag.
|
||||
|
||||
Weitere Wünsche: $ARGUMENTS
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
---
|
||||
# Verwaltet von Hugin (pub-code-review 1.0.0). Änderungen über hugin.intern.
|
||||
description: Prüft Änderungen auf Fehler und Verständlichkeit. Nach größeren Änderungen einsetzen, bevor ein Pull Request entsteht.
|
||||
mode: subagent
|
||||
permission:
|
||||
edit: deny
|
||||
bash: deny
|
||||
webfetch: deny
|
||||
websearch: deny
|
||||
model: anthropic/claude-opus-5-5
|
||||
temperature: 0.1
|
||||
---
|
||||
|
||||
Du prüfst Änderungen an Code. Du änderst nichts, du schreibst nur Befunde.
|
||||
|
||||
Für jeden Befund: Datei und Zeile, was falsch ist, ein konkreter Vorschlag.
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
# Verwaltet von Hugin (pub-release-notiz 1.0.0). Änderungen über hugin.intern.
|
||||
description: Schreibt eine Release-Notiz für eine Version.
|
||||
---
|
||||
|
||||
Schreibe eine Release-Notiz für Version $1, gedacht für $2.
|
||||
Lies dazu die Commits seit dem letzten Tag.
|
||||
|
||||
Weitere Wünsche: $ARGUMENTS
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
{
|
||||
"format": 1,
|
||||
"generated": "2026-10-18T12:00:00Z",
|
||||
"tools": [
|
||||
"claude",
|
||||
"opencode@1"
|
||||
],
|
||||
"packages": [
|
||||
{
|
||||
"id": "pub-code-review",
|
||||
"version": "1.0.0",
|
||||
"checksum": "sha256:0b8f1233bb379c53fa4b6edf63993c78b3f8bfd9a83f7a7ccc604b10ff3e5a29",
|
||||
"kind": "subagent",
|
||||
"risk": "text",
|
||||
"compatibility": "translated",
|
||||
"mandatory": false
|
||||
},
|
||||
{
|
||||
"id": "pub-release-notiz",
|
||||
"version": "1.0.0",
|
||||
"checksum": "sha256:22643f328cc562b525974a0eb815aec3c0ef8fa5c79e3997e0d3e356ab327b16",
|
||||
"kind": "command",
|
||||
"risk": "text",
|
||||
"compatibility": "translated",
|
||||
"mandatory": false
|
||||
}
|
||||
],
|
||||
"operations": [
|
||||
{
|
||||
"op": "file",
|
||||
"package": "pub-code-review",
|
||||
"path": ".claude/agents/pub-code-review.md",
|
||||
"source": "files/.claude/agents/pub-code-review.md",
|
||||
"sha256": "sha256:706a29ca50d481c7985ad5eb40e124d832b13124d56d42f2ed1d4f03111ae230"
|
||||
},
|
||||
{
|
||||
"op": "file",
|
||||
"package": "pub-release-notiz",
|
||||
"path": ".claude/commands/pub-release-notiz.md",
|
||||
"source": "files/.claude/commands/pub-release-notiz.md",
|
||||
"sha256": "sha256:11fea449fecf436eb7753f2769e7f1fed45ebcd95c77bb5b8cd15d755bec5d08"
|
||||
},
|
||||
{
|
||||
"op": "file",
|
||||
"package": "pub-code-review",
|
||||
"path": ".opencode/agents/pub-code-review.md",
|
||||
"source": "files/.opencode/agents/pub-code-review.md",
|
||||
"sha256": "sha256:96cab45a44f56db9fb3584323b14f5ddef4d2eb3f811806f95444d650b43ef3b"
|
||||
},
|
||||
{
|
||||
"op": "file",
|
||||
"package": "pub-release-notiz",
|
||||
"path": ".opencode/commands/pub-release-notiz.md",
|
||||
"source": "files/.opencode/commands/pub-release-notiz.md",
|
||||
"sha256": "sha256:01579f65b6407f1f89fa7728cc7f44b8157b147378717e3031c0898ea673e3bd"
|
||||
}
|
||||
],
|
||||
"hints": []
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
# Verwaltet von Hugin (pub-munin-nutzen 1.4.0). Änderungen über hugin.intern.
|
||||
name: pub-munin-nutzen
|
||||
description: 'Wann und wie du Munin nutzt: vor neuer Arbeit recall, nach Erkenntnissen remember. Nutze diesen Skill immer, wenn du in einem Projekt mit Munin arbeitest.'
|
||||
license: intern
|
||||
---
|
||||
|
||||
# Munin nutzen
|
||||
|
||||
Munin ist das gemeinsame Gedächtnis der Teams.
|
||||
|
||||
1. Bevor du mit einer neuen Aufgabe beginnst, frage Munin mit `recall` nach
|
||||
bekannten Besonderheiten des Projekts.
|
||||
2. Wenn du etwas Neues und Dauerhaftes herausfindest, halte es mit `remember` fest.
|
||||
3. Mit `related` findest du verwandte Einträge.
|
||||
|
||||
Beispiele stehen in [referenz/beispiele.md](referenz/beispiele.md).
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
# Beispiele
|
||||
|
||||
- Gut: „Die Staging-DB wird jeden Sonntag zurückgesetzt.“
|
||||
- Schlecht: „Ich habe heute die Tests laufen lassen.“
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<!-- Verwaltet von Hugin (pub-hausregeln 1.0.0). Änderungen über hugin.intern. -->
|
||||
|
||||
# Hausregeln
|
||||
|
||||
- Schreibe keine Geheimnisse (Keys, Passwörter) in Dateien oder Commits.
|
||||
- Frage nach, bevor du etwas löschst, das du nicht selbst angelegt hast.
|
||||
115
contract/examples/bundles/first-install/hugin-bundle.json
Normal file
115
contract/examples/bundles/first-install/hugin-bundle.json
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
{
|
||||
"format": 1,
|
||||
"generated": "2026-10-18T12:00:00Z",
|
||||
"tools": [
|
||||
"claude",
|
||||
"opencode@1"
|
||||
],
|
||||
"packages": [
|
||||
{
|
||||
"id": "pub-hausregeln",
|
||||
"version": "1.0.0",
|
||||
"checksum": "sha256:0e2983623cec5dfe509ffb5f62332a4ff54a79b2affe5c981165148cb09ce127",
|
||||
"kind": "rules",
|
||||
"risk": "text",
|
||||
"compatibility": "translated",
|
||||
"mandatory": false
|
||||
},
|
||||
{
|
||||
"id": "pub-munin-anbindung",
|
||||
"version": "1.0.0",
|
||||
"checksum": "sha256:9c3bb85f6f4bc4596303f8dacf00a7ad2db9e2ef813e889ea7fca42db23f34a2",
|
||||
"kind": "mcp",
|
||||
"risk": "tools",
|
||||
"compatibility": "translated",
|
||||
"mandatory": true
|
||||
},
|
||||
{
|
||||
"id": "pub-munin-nutzen",
|
||||
"version": "1.4.0",
|
||||
"checksum": "sha256:9e50afeed46db30a6e651488caf175a82b763ce266943525e49a3c85cd05ca37",
|
||||
"kind": "skill",
|
||||
"risk": "text",
|
||||
"compatibility": "portable",
|
||||
"mandatory": false
|
||||
}
|
||||
],
|
||||
"operations": [
|
||||
{
|
||||
"op": "file",
|
||||
"package": "pub-munin-nutzen",
|
||||
"path": ".claude/skills/pub-munin-nutzen/SKILL.md",
|
||||
"source": "files/.claude/skills/pub-munin-nutzen/SKILL.md",
|
||||
"sha256": "sha256:d3ad99d5e6f43d532917f1f57589259cf4812d292e1104f087d56820d5fbbe26"
|
||||
},
|
||||
{
|
||||
"op": "file",
|
||||
"package": "pub-munin-nutzen",
|
||||
"path": ".claude/skills/pub-munin-nutzen/referenz/beispiele.md",
|
||||
"source": "files/.claude/skills/pub-munin-nutzen/referenz/beispiele.md",
|
||||
"sha256": "sha256:2746734c5c36045173fadbed75377a65817c8ca6fccb4e487773fc0ae75ec58a"
|
||||
},
|
||||
{
|
||||
"op": "file",
|
||||
"package": "pub-hausregeln",
|
||||
"path": ".hugin/rules/pub-hausregeln.md",
|
||||
"source": "files/.hugin/rules/pub-hausregeln.md",
|
||||
"sha256": "sha256:670a7621fa4b561ed580452fde7881d551d3c9cb83ae6adc581cb2e1b61c1bfd"
|
||||
},
|
||||
{
|
||||
"op": "json",
|
||||
"package": "pub-munin-anbindung",
|
||||
"file": ".mcp.json",
|
||||
"key": [
|
||||
"mcpServers",
|
||||
"munin"
|
||||
],
|
||||
"value": {
|
||||
"type": "http",
|
||||
"url": "https://munin.intern/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${MUNIN_KEY}"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "json",
|
||||
"file": "opencode.json",
|
||||
"key": [
|
||||
"instructions"
|
||||
],
|
||||
"mode": "list_item",
|
||||
"value": ".hugin/rules/*.md"
|
||||
},
|
||||
{
|
||||
"op": "json",
|
||||
"package": "pub-munin-anbindung",
|
||||
"file": "opencode.json",
|
||||
"key": [
|
||||
"mcp",
|
||||
"munin"
|
||||
],
|
||||
"value": {
|
||||
"type": "remote",
|
||||
"url": "https://munin.intern/mcp",
|
||||
"enabled": true,
|
||||
"headers": {
|
||||
"Authorization": "Bearer {env:MUNIN_KEY}"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "block",
|
||||
"file": "CLAUDE.md",
|
||||
"block": "hugin",
|
||||
"content": "@.hugin/rules/pub-hausregeln.md\n"
|
||||
}
|
||||
],
|
||||
"hints": [
|
||||
{
|
||||
"kind": "env_variable",
|
||||
"name": "MUNIN_KEY",
|
||||
"package": "pub-munin-anbindung"
|
||||
}
|
||||
]
|
||||
}
|
||||
86
contract/examples/bundles/local-mcp/hugin-bundle.json
Normal file
86
contract/examples/bundles/local-mcp/hugin-bundle.json
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
{
|
||||
"format": 1,
|
||||
"generated": "2026-10-18T12:00:00Z",
|
||||
"tools": [
|
||||
"claude",
|
||||
"opencode@1"
|
||||
],
|
||||
"packages": [
|
||||
{
|
||||
"id": "pub-zeit-lokal",
|
||||
"version": "1.0.0",
|
||||
"checksum": "sha256:29b47ccd830b4a4815746972bdcca964e23adc4e935e82ff7877ccb50ceb4584",
|
||||
"kind": "mcp",
|
||||
"risk": "scripts",
|
||||
"compatibility": "translated",
|
||||
"mandatory": false
|
||||
}
|
||||
],
|
||||
"operations": [
|
||||
{
|
||||
"op": "json",
|
||||
"package": "pub-zeit-lokal",
|
||||
"file": ".mcp.json",
|
||||
"key": [
|
||||
"mcpServers",
|
||||
"zeit"
|
||||
],
|
||||
"value": {
|
||||
"type": "stdio",
|
||||
"command": "hugin",
|
||||
"args": [
|
||||
"mcp",
|
||||
"run",
|
||||
"pub-zeit-lokal"
|
||||
],
|
||||
"env": {
|
||||
"ZEIT_ZONE_HINWEIS": "${ZEIT_ZONE_HINWEIS}"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "json",
|
||||
"package": "pub-zeit-lokal",
|
||||
"file": "opencode.json",
|
||||
"key": [
|
||||
"mcp",
|
||||
"zeit"
|
||||
],
|
||||
"value": {
|
||||
"type": "local",
|
||||
"command": [
|
||||
"hugin",
|
||||
"mcp",
|
||||
"run",
|
||||
"pub-zeit-lokal"
|
||||
],
|
||||
"enabled": true,
|
||||
"environment": {
|
||||
"ZEIT_ZONE_HINWEIS": "{env:ZEIT_ZONE_HINWEIS}"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "install",
|
||||
"package": "pub-zeit-lokal",
|
||||
"version": "1.0.0",
|
||||
"checksum": "sha256:29b47ccd830b4a4815746972bdcca964e23adc4e935e82ff7877ccb50ceb4584",
|
||||
"runtime": "python",
|
||||
"entry": "zeit_server",
|
||||
"files": [
|
||||
{
|
||||
"path": "zeit_server.py",
|
||||
"source": "local/pub-zeit-lokal/1.0.0/zeit_server.py",
|
||||
"sha256": "sha256:45e3d0952541a5f887a3734492b150c68c75fbf27afcd173fe8153ae845323cc"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"hints": [
|
||||
{
|
||||
"kind": "env_variable",
|
||||
"name": "ZEIT_ZONE_HINWEIS",
|
||||
"package": "pub-zeit-lokal"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
"""Minimaler MCP-Server über stdio, nur mit der Standardbibliothek.
|
||||
|
||||
Ein JSON-Objekt je Zeile. Ausgabe als Bytes, damit Windows kein \r einfügt.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
TOOL = {
|
||||
"name": "zeit",
|
||||
"description": "Liefert Datum und Uhrzeit dieses Rechners.",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
}
|
||||
|
||||
|
||||
def answer(message):
|
||||
method = message.get("method")
|
||||
if method == "initialize":
|
||||
return {"protocolVersion": message["params"]["protocolVersion"],
|
||||
"capabilities": {"tools": {}}, "serverInfo": {"name": "zeit", "version": "1.0.0"}}
|
||||
if method == "tools/list":
|
||||
return {"tools": [TOOL]}
|
||||
if method == "tools/call":
|
||||
hint = os.environ.get("ZEIT_ZONE_HINWEIS", "")
|
||||
text = f"{datetime.now().astimezone():%d.%m.%Y %H:%M:%S %Z} UHR-4711" + (f" ({hint})" if hint else "")
|
||||
return {"content": [{"type": "text", "text": text}]}
|
||||
if method == "ping":
|
||||
return {}
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
for line in sys.stdin:
|
||||
if not line.strip():
|
||||
continue
|
||||
message = json.loads(line)
|
||||
if "id" not in message:
|
||||
continue # Benachrichtigungen brauchen keine Antwort
|
||||
result = answer(message)
|
||||
if result is None:
|
||||
reply = {"jsonrpc": "2.0", "id": message["id"],
|
||||
"error": {"code": -32601, "message": f"unbekannt: {message.get('method')}"}}
|
||||
else:
|
||||
reply = {"jsonrpc": "2.0", "id": message["id"], "result": result}
|
||||
sys.stdout.buffer.write(json.dumps(reply).encode("utf-8") + b"\n")
|
||||
sys.stdout.buffer.flush()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
// Verwaltet von Hugin (pub-specht-plugin 1.0.0). Änderungen über hugin.intern.
|
||||
export * from "./pub-specht-plugin/index.ts"
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { tool } from "@opencode-ai/plugin"
|
||||
import { marke } from "./lib/marke.ts"
|
||||
|
||||
// OpenCode ruft jede exportierte Funktion beim Start auf
|
||||
export const SpechtPlugin = async () => {
|
||||
return {
|
||||
tool: {
|
||||
specht_marke: tool({
|
||||
description: "Liefert die Erkennungsmarke des Specht-Plugins",
|
||||
args: {},
|
||||
async execute() {
|
||||
return marke
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export const marke = "SPECHT-5127"
|
||||
43
contract/examples/bundles/opencode-plugin/hugin-bundle.json
Normal file
43
contract/examples/bundles/opencode-plugin/hugin-bundle.json
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"format": 1,
|
||||
"generated": "2026-10-18T12:00:00Z",
|
||||
"tools": [
|
||||
"claude",
|
||||
"opencode@1"
|
||||
],
|
||||
"packages": [
|
||||
{
|
||||
"id": "pub-specht-plugin",
|
||||
"version": "1.0.0",
|
||||
"checksum": "sha256:fafb3e308847f4b491685ba223350e75c79aa2b6a7d7fab21189a695b0d47dcb",
|
||||
"kind": "plugin",
|
||||
"risk": "permissions",
|
||||
"compatibility": "opencode-only",
|
||||
"mandatory": false
|
||||
}
|
||||
],
|
||||
"operations": [
|
||||
{
|
||||
"op": "file",
|
||||
"package": "pub-specht-plugin",
|
||||
"path": ".opencode/plugins/pub-specht-plugin.ts",
|
||||
"source": "files/.opencode/plugins/pub-specht-plugin.ts",
|
||||
"sha256": "sha256:b053f6c9332c7a3777ae4b6aae3303216853da473225b72d3edc66c1045e1c1e"
|
||||
},
|
||||
{
|
||||
"op": "file",
|
||||
"package": "pub-specht-plugin",
|
||||
"path": ".opencode/plugins/pub-specht-plugin/index.ts",
|
||||
"source": "files/.opencode/plugins/pub-specht-plugin/index.ts",
|
||||
"sha256": "sha256:c33979fe526c3b22b625bdc37f9e51bce083b9045ef6d6ab852e294295ecb8f8"
|
||||
},
|
||||
{
|
||||
"op": "file",
|
||||
"package": "pub-specht-plugin",
|
||||
"path": ".opencode/plugins/pub-specht-plugin/lib/marke.ts",
|
||||
"source": "files/.opencode/plugins/pub-specht-plugin/lib/marke.ts",
|
||||
"sha256": "sha256:53e52e0666b133f9ec0597554abc8da3a80899e0f3abae405f47c4fa7da7a06d"
|
||||
}
|
||||
],
|
||||
"hints": []
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
---
|
||||
# Verwaltet von Hugin (pub-munin-nutzen 1.5.0). Änderungen über hugin.intern.
|
||||
name: pub-munin-nutzen
|
||||
description: 'Wann und wie du Munin nutzt: vor neuer Arbeit recall, nach Erkenntnissen remember. Nutze diesen Skill immer, wenn du in einem Projekt mit Munin arbeitest.'
|
||||
license: intern
|
||||
---
|
||||
|
||||
# Munin nutzen
|
||||
|
||||
Munin ist das gemeinsame Gedächtnis der Teams.
|
||||
|
||||
1. Bevor du mit einer neuen Aufgabe beginnst, frage Munin mit `recall` nach
|
||||
bekannten Besonderheiten des Projekts.
|
||||
2. Wenn du etwas Neues und Dauerhaftes herausfindest, halte es mit `remember` fest.
|
||||
3. Mit `related` findest du verwandte Einträge.
|
||||
|
||||
Gut: „Die Staging-DB wird jeden Sonntag zurückgesetzt.“
|
||||
Schlecht: „Ich habe heute die Tests laufen lassen.“
|
||||
102
contract/examples/bundles/update/hugin-bundle.json
Normal file
102
contract/examples/bundles/update/hugin-bundle.json
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
{
|
||||
"format": 1,
|
||||
"generated": "2026-10-18T12:00:00Z",
|
||||
"tools": [
|
||||
"claude",
|
||||
"opencode@1"
|
||||
],
|
||||
"packages": [
|
||||
{
|
||||
"id": "pub-munin-anbindung",
|
||||
"version": "1.0.0",
|
||||
"checksum": "sha256:9c3bb85f6f4bc4596303f8dacf00a7ad2db9e2ef813e889ea7fca42db23f34a2",
|
||||
"kind": "mcp",
|
||||
"risk": "tools",
|
||||
"compatibility": "translated",
|
||||
"mandatory": true
|
||||
},
|
||||
{
|
||||
"id": "pub-munin-nutzen",
|
||||
"version": "1.5.0",
|
||||
"checksum": "sha256:8a573c430c18265cb033dfdfba7daeb1d6366c7703ef0c00cb323554563a3f93",
|
||||
"kind": "skill",
|
||||
"risk": "text",
|
||||
"compatibility": "portable",
|
||||
"mandatory": false
|
||||
}
|
||||
],
|
||||
"operations": [
|
||||
{
|
||||
"op": "remove",
|
||||
"package": "pub-munin-nutzen",
|
||||
"path": ".claude/skills/pub-munin-nutzen/referenz/beispiele.md"
|
||||
},
|
||||
{
|
||||
"op": "remove",
|
||||
"package": "pub-hausregeln",
|
||||
"path": ".hugin/rules/pub-hausregeln.md"
|
||||
},
|
||||
{
|
||||
"op": "json_remove",
|
||||
"file": "opencode.json",
|
||||
"key": [
|
||||
"instructions"
|
||||
],
|
||||
"mode": "list_item",
|
||||
"value": ".hugin/rules/*.md"
|
||||
},
|
||||
{
|
||||
"op": "file",
|
||||
"package": "pub-munin-nutzen",
|
||||
"path": ".claude/skills/pub-munin-nutzen/SKILL.md",
|
||||
"source": "files/.claude/skills/pub-munin-nutzen/SKILL.md",
|
||||
"sha256": "sha256:50430ecef44ca4944aed97dd02b086e987a8f52b588cfafa95347fef154b78f1"
|
||||
},
|
||||
{
|
||||
"op": "json",
|
||||
"package": "pub-munin-anbindung",
|
||||
"file": ".mcp.json",
|
||||
"key": [
|
||||
"mcpServers",
|
||||
"munin"
|
||||
],
|
||||
"value": {
|
||||
"type": "http",
|
||||
"url": "https://munin.intern/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${MUNIN_KEY}"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "json",
|
||||
"package": "pub-munin-anbindung",
|
||||
"file": "opencode.json",
|
||||
"key": [
|
||||
"mcp",
|
||||
"munin"
|
||||
],
|
||||
"value": {
|
||||
"type": "remote",
|
||||
"url": "https://munin.intern/mcp",
|
||||
"enabled": true,
|
||||
"headers": {
|
||||
"Authorization": "Bearer {env:MUNIN_KEY}"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "block",
|
||||
"file": "CLAUDE.md",
|
||||
"block": "hugin",
|
||||
"content": ""
|
||||
}
|
||||
],
|
||||
"hints": [
|
||||
{
|
||||
"kind": "env_variable",
|
||||
"name": "MUNIN_KEY",
|
||||
"package": "pub-munin-anbindung"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
contract/examples/packages/pub-code-review/1.0.0/AGENT.md
vendored
Normal file
3
contract/examples/packages/pub-code-review/1.0.0/AGENT.md
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
Du prüfst Änderungen an Code. Du änderst nichts, du schreibst nur Befunde.
|
||||
|
||||
Für jeden Befund: Datei und Zeile, was falsch ist, ein konkreter Vorschlag.
|
||||
15
contract/examples/packages/pub-code-review/1.0.0/hugin.yaml
vendored
Normal file
15
contract/examples/packages/pub-code-review/1.0.0/hugin.yaml
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
id: pub-code-review
|
||||
kind: subagent
|
||||
version: 1.0.0
|
||||
owner: plattform
|
||||
scope: public
|
||||
description: >
|
||||
Prüft Änderungen auf Fehler und Verständlichkeit. Nach größeren Änderungen
|
||||
einsetzen, bevor ein Pull Request entsteht.
|
||||
capability: read
|
||||
model: thorough
|
||||
extra:
|
||||
claude:
|
||||
effort: high
|
||||
opencode:
|
||||
temperature: 0.1
|
||||
4
contract/examples/packages/pub-hausregeln/1.0.0/RULES.md
vendored
Normal file
4
contract/examples/packages/pub-hausregeln/1.0.0/RULES.md
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Hausregeln
|
||||
|
||||
- Schreibe keine Geheimnisse (Keys, Passwörter) in Dateien oder Commits.
|
||||
- Frage nach, bevor du etwas löschst, das du nicht selbst angelegt hast.
|
||||
6
contract/examples/packages/pub-hausregeln/1.0.0/hugin.yaml
vendored
Normal file
6
contract/examples/packages/pub-hausregeln/1.0.0/hugin.yaml
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
id: pub-hausregeln
|
||||
kind: rules
|
||||
version: 1.0.0
|
||||
owner: plattform
|
||||
scope: public
|
||||
description: Grundregeln für alle Agenten im Haus.
|
||||
11
contract/examples/packages/pub-munin-anbindung/1.0.0/hugin.yaml
vendored
Normal file
11
contract/examples/packages/pub-munin-anbindung/1.0.0/hugin.yaml
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
id: pub-munin-anbindung
|
||||
kind: mcp
|
||||
version: 1.0.0
|
||||
owner: plattform
|
||||
scope: public
|
||||
description: Bindet Munin als MCP-Server an.
|
||||
server:
|
||||
name: munin
|
||||
url: https://munin.intern/mcp
|
||||
headers:
|
||||
Authorization: "Bearer {{env:MUNIN_KEY}}"
|
||||
10
contract/examples/packages/pub-munin-nutzen/1.4.0/SKILL.md
vendored
Normal file
10
contract/examples/packages/pub-munin-nutzen/1.4.0/SKILL.md
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Munin nutzen
|
||||
|
||||
Munin ist das gemeinsame Gedächtnis der Teams.
|
||||
|
||||
1. Bevor du mit einer neuen Aufgabe beginnst, frage Munin mit `recall` nach
|
||||
bekannten Besonderheiten des Projekts.
|
||||
2. Wenn du etwas Neues und Dauerhaftes herausfindest, halte es mit `remember` fest.
|
||||
3. Mit `related` findest du verwandte Einträge.
|
||||
|
||||
Beispiele stehen in [referenz/beispiele.md](referenz/beispiele.md).
|
||||
11
contract/examples/packages/pub-munin-nutzen/1.4.0/hugin.yaml
vendored
Normal file
11
contract/examples/packages/pub-munin-nutzen/1.4.0/hugin.yaml
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
id: pub-munin-nutzen
|
||||
kind: skill
|
||||
version: 1.4.0
|
||||
owner: plattform
|
||||
scope: public
|
||||
description: >
|
||||
Wann und wie du Munin nutzt: vor neuer Arbeit recall, nach Erkenntnissen
|
||||
remember. Nutze diesen Skill immer, wenn du in einem Projekt mit Munin arbeitest.
|
||||
license: intern
|
||||
changes: >
|
||||
Hinweis auf das neue Werkzeug „related“ ergänzt.
|
||||
4
contract/examples/packages/pub-munin-nutzen/1.4.0/referenz/beispiele.md
vendored
Normal file
4
contract/examples/packages/pub-munin-nutzen/1.4.0/referenz/beispiele.md
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Beispiele
|
||||
|
||||
- Gut: „Die Staging-DB wird jeden Sonntag zurückgesetzt.“
|
||||
- Schlecht: „Ich habe heute die Tests laufen lassen.“
|
||||
11
contract/examples/packages/pub-munin-nutzen/1.5.0/SKILL.md
vendored
Normal file
11
contract/examples/packages/pub-munin-nutzen/1.5.0/SKILL.md
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# Munin nutzen
|
||||
|
||||
Munin ist das gemeinsame Gedächtnis der Teams.
|
||||
|
||||
1. Bevor du mit einer neuen Aufgabe beginnst, frage Munin mit `recall` nach
|
||||
bekannten Besonderheiten des Projekts.
|
||||
2. Wenn du etwas Neues und Dauerhaftes herausfindest, halte es mit `remember` fest.
|
||||
3. Mit `related` findest du verwandte Einträge.
|
||||
|
||||
Gut: „Die Staging-DB wird jeden Sonntag zurückgesetzt.“
|
||||
Schlecht: „Ich habe heute die Tests laufen lassen.“
|
||||
11
contract/examples/packages/pub-munin-nutzen/1.5.0/hugin.yaml
vendored
Normal file
11
contract/examples/packages/pub-munin-nutzen/1.5.0/hugin.yaml
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
id: pub-munin-nutzen
|
||||
kind: skill
|
||||
version: 1.5.0
|
||||
owner: plattform
|
||||
scope: public
|
||||
description: >
|
||||
Wann und wie du Munin nutzt: vor neuer Arbeit recall, nach Erkenntnissen
|
||||
remember. Nutze diesen Skill immer, wenn du in einem Projekt mit Munin arbeitest.
|
||||
license: intern
|
||||
changes: >
|
||||
Beispiele direkt in den Skill übernommen; referenz/ entfällt.
|
||||
4
contract/examples/packages/pub-release-notiz/1.0.0/COMMAND.md
vendored
Normal file
4
contract/examples/packages/pub-release-notiz/1.0.0/COMMAND.md
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
Schreibe eine Release-Notiz für Version {{version}}, gedacht für {{zielgruppe}}.
|
||||
Lies dazu die Commits seit dem letzten Tag.
|
||||
|
||||
Weitere Wünsche: $ARGUMENTS
|
||||
7
contract/examples/packages/pub-release-notiz/1.0.0/hugin.yaml
vendored
Normal file
7
contract/examples/packages/pub-release-notiz/1.0.0/hugin.yaml
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
id: pub-release-notiz
|
||||
kind: command
|
||||
version: 1.0.0
|
||||
owner: plattform
|
||||
scope: public
|
||||
description: Schreibt eine Release-Notiz für eine Version.
|
||||
arguments: [version, zielgruppe]
|
||||
9
contract/examples/packages/pub-specht-plugin/1.0.0/hugin.yaml
vendored
Normal file
9
contract/examples/packages/pub-specht-plugin/1.0.0/hugin.yaml
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
id: pub-specht-plugin
|
||||
kind: plugin
|
||||
version: 1.0.0
|
||||
owner: kern
|
||||
scope: public
|
||||
targets: [opencode]
|
||||
description: >
|
||||
OpenCode-Plugin mit dem Werkzeug specht_marke, das eine feste Erkennungsmarke
|
||||
liefert. Beispiel für ein Plugin, das Hugin verteilt.
|
||||
17
contract/examples/packages/pub-specht-plugin/1.0.0/index.ts
vendored
Normal file
17
contract/examples/packages/pub-specht-plugin/1.0.0/index.ts
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { tool } from "@opencode-ai/plugin"
|
||||
import { marke } from "./lib/marke.ts"
|
||||
|
||||
// OpenCode ruft jede exportierte Funktion beim Start auf
|
||||
export const SpechtPlugin = async () => {
|
||||
return {
|
||||
tool: {
|
||||
specht_marke: tool({
|
||||
description: "Liefert die Erkennungsmarke des Specht-Plugins",
|
||||
args: {},
|
||||
async execute() {
|
||||
return marke
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
1
contract/examples/packages/pub-specht-plugin/1.0.0/lib/marke.ts
vendored
Normal file
1
contract/examples/packages/pub-specht-plugin/1.0.0/lib/marke.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const marke = "SPECHT-5127"
|
||||
14
contract/examples/packages/pub-zeit-lokal/1.0.0/hugin.yaml
vendored
Normal file
14
contract/examples/packages/pub-zeit-lokal/1.0.0/hugin.yaml
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
id: pub-zeit-lokal
|
||||
kind: mcp
|
||||
version: 1.0.0
|
||||
owner: kern
|
||||
scope: public
|
||||
description: >
|
||||
Lokaler MCP-Server mit dem Werkzeug zeit, das Datum und Uhrzeit des Rechners
|
||||
liefert. Beispiel für einen Server, den Hugin samt Code verteilt.
|
||||
server:
|
||||
name: zeit
|
||||
local:
|
||||
runtime: python
|
||||
entry: zeit_server
|
||||
env: [ZEIT_ZONE_HINWEIS]
|
||||
52
contract/examples/packages/pub-zeit-lokal/1.0.0/zeit_server.py
vendored
Normal file
52
contract/examples/packages/pub-zeit-lokal/1.0.0/zeit_server.py
vendored
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"""Minimaler MCP-Server über stdio, nur mit der Standardbibliothek.
|
||||
|
||||
Ein JSON-Objekt je Zeile. Ausgabe als Bytes, damit Windows kein \r einfügt.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
TOOL = {
|
||||
"name": "zeit",
|
||||
"description": "Liefert Datum und Uhrzeit dieses Rechners.",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
}
|
||||
|
||||
|
||||
def answer(message):
|
||||
method = message.get("method")
|
||||
if method == "initialize":
|
||||
return {"protocolVersion": message["params"]["protocolVersion"],
|
||||
"capabilities": {"tools": {}}, "serverInfo": {"name": "zeit", "version": "1.0.0"}}
|
||||
if method == "tools/list":
|
||||
return {"tools": [TOOL]}
|
||||
if method == "tools/call":
|
||||
hint = os.environ.get("ZEIT_ZONE_HINWEIS", "")
|
||||
text = f"{datetime.now().astimezone():%d.%m.%Y %H:%M:%S %Z} UHR-4711" + (f" ({hint})" if hint else "")
|
||||
return {"content": [{"type": "text", "text": text}]}
|
||||
if method == "ping":
|
||||
return {}
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
for line in sys.stdin:
|
||||
if not line.strip():
|
||||
continue
|
||||
message = json.loads(line)
|
||||
if "id" not in message:
|
||||
continue # Benachrichtigungen brauchen keine Antwort
|
||||
result = answer(message)
|
||||
if result is None:
|
||||
reply = {"jsonrpc": "2.0", "id": message["id"],
|
||||
"error": {"code": -32601, "message": f"unbekannt: {message.get('method')}"}}
|
||||
else:
|
||||
reply = {"jsonrpc": "2.0", "id": message["id"], "result": result}
|
||||
sys.stdout.buffer.write(json.dumps(reply).encode("utf-8") + b"\n")
|
||||
sys.stdout.buffer.flush()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
29
contract/examples/scenarios.yaml
Normal file
29
contract/examples/scenarios.yaml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Szenarien für goldene Bundles. Server erzeugt sie (pytest --update),
|
||||
# CLI wendet sie in seinen Tests an. Pfade relativ zu packages/.
|
||||
- name: first-install
|
||||
tools: [claude, opencode@1]
|
||||
packages: [pub-munin-nutzen/1.4.0, pub-hausregeln/1.0.0, pub-munin-anbindung/1.0.0]
|
||||
mandatory: [pub-munin-anbindung]
|
||||
|
||||
- name: update
|
||||
tools: [claude, opencode@1]
|
||||
packages: [pub-munin-nutzen/1.5.0, pub-munin-anbindung/1.0.0]
|
||||
mandatory: [pub-munin-anbindung]
|
||||
installed: [pub-munin-nutzen/1.4.0, pub-hausregeln/1.0.0, pub-munin-anbindung/1.0.0]
|
||||
|
||||
- name: claude-only
|
||||
tools: [claude]
|
||||
packages: [pub-munin-nutzen/1.4.0, pub-hausregeln/1.0.0]
|
||||
|
||||
- name: commands-and-agents
|
||||
tools: [claude, opencode@1]
|
||||
packages: [pub-release-notiz/1.0.0, pub-code-review/1.0.0]
|
||||
|
||||
- name: local-mcp
|
||||
tools: [claude, opencode@1]
|
||||
packages: [pub-zeit-lokal/1.0.0]
|
||||
|
||||
# Plugin nur für OpenCode: Claude bekommt nichts
|
||||
- name: opencode-plugin
|
||||
tools: [claude, opencode@1]
|
||||
packages: [pub-specht-plugin/1.0.0]
|
||||
473
contract/hugin-bundle.schema.json
Normal file
473
contract/hugin-bundle.schema.json
Normal file
|
|
@ -0,0 +1,473 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://hugin.intern/schema/hugin-bundle-1.json",
|
||||
"title": "hugin-bundle.json",
|
||||
"description": "Installationsanleitung in einem Hugin-Bundle (ZIP). Beschreibt den vollständigen Soll-Zustand der angefragten Pakete im Repo. Clients wenden die Operationen der Reihe nach an.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"format",
|
||||
"generated",
|
||||
"tools",
|
||||
"packages",
|
||||
"operations",
|
||||
"hints"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"format": {
|
||||
"const": 1
|
||||
},
|
||||
"generated": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"tools": {
|
||||
"description": "Für welche Werkzeuge übersetzt wurde, mit Hauptversion.",
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"enum": [
|
||||
"claude",
|
||||
"opencode@1",
|
||||
"opencode@2"
|
||||
]
|
||||
},
|
||||
"uniqueItems": true
|
||||
},
|
||||
"packages": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/package"
|
||||
}
|
||||
},
|
||||
"operations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/op_file"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/op_block"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/op_json"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/op_remove"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/op_json_remove"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/op_install"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"hints": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/hint"
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"package_id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
|
||||
"minLength": 1,
|
||||
"maxLength": 64
|
||||
},
|
||||
"checksum": {
|
||||
"type": "string",
|
||||
"pattern": "^sha256:[0-9a-f]{64}$"
|
||||
},
|
||||
"repo_path": {
|
||||
"description": "Pfad relativ zur Repo-Wurzel, mit /, ohne führenden /. Segmente '.' und '..' sind verboten; Clients prüfen das zusätzlich selbst (das Muster kommt ohne Lookahead aus, damit es auch mit RE2 funktioniert).",
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[^/\\\\:*?\"<>|][^\\\\:*?\"<>|]*$"
|
||||
},
|
||||
"zip_path": {
|
||||
"description": "Pfad einer Datei im ZIP, immer unter files/. Segmente '..' sind verboten.",
|
||||
"type": "string",
|
||||
"pattern": "^files/[^\\\\]+$"
|
||||
},
|
||||
"json_path": {
|
||||
"description": "Schlüsselpfad in einer JSON-Datei, als Liste von Schlüsseln (Punkte in Namen sind erlaubt).",
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"package": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"version",
|
||||
"checksum",
|
||||
"kind",
|
||||
"risk",
|
||||
"compatibility",
|
||||
"mandatory"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/$defs/package_id"
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+$"
|
||||
},
|
||||
"checksum": {
|
||||
"$ref": "#/$defs/checksum"
|
||||
},
|
||||
"kind": {
|
||||
"enum": [
|
||||
"skill",
|
||||
"rules",
|
||||
"command",
|
||||
"subagent",
|
||||
"mcp",
|
||||
"plugin",
|
||||
"hook"
|
||||
]
|
||||
},
|
||||
"risk": {
|
||||
"enum": [
|
||||
"text",
|
||||
"tools",
|
||||
"scripts",
|
||||
"permissions"
|
||||
]
|
||||
},
|
||||
"compatibility": {
|
||||
"enum": [
|
||||
"portable",
|
||||
"translated",
|
||||
"claude-only",
|
||||
"opencode-only"
|
||||
]
|
||||
},
|
||||
"mandatory": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"op_file": {
|
||||
"description": "Datei anlegen oder ersetzen. Inhalt liegt im ZIP unter quelle.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"op",
|
||||
"package",
|
||||
"path",
|
||||
"source",
|
||||
"sha256"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"op": {
|
||||
"const": "file"
|
||||
},
|
||||
"package": {
|
||||
"$ref": "#/$defs/package_id"
|
||||
},
|
||||
"path": {
|
||||
"$ref": "#/$defs/repo_path"
|
||||
},
|
||||
"source": {
|
||||
"$ref": "#/$defs/zip_path"
|
||||
},
|
||||
"sha256": {
|
||||
"$ref": "#/$defs/checksum"
|
||||
}
|
||||
}
|
||||
},
|
||||
"op_block": {
|
||||
"description": "Verwalteten Block in einer Textdatei setzen. Der Block steht zwischen den Zeilen '<!-- {block}:anfang' … und '<!-- {block}:ende -->'. Leerer inhalt entfernt den Block. Fehlt die Datei, wird sie aus anlegen erzeugt (falls gesetzt), sonst nur mit dem Block.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"op",
|
||||
"file",
|
||||
"block",
|
||||
"content"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"op": {
|
||||
"const": "block"
|
||||
},
|
||||
"file": {
|
||||
"$ref": "#/$defs/repo_path"
|
||||
},
|
||||
"block": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]*$"
|
||||
},
|
||||
"content": {
|
||||
"type": "string"
|
||||
},
|
||||
"create": {
|
||||
"$ref": "#/$defs/zip_path"
|
||||
}
|
||||
}
|
||||
},
|
||||
"op_json": {
|
||||
"description": "Schlüssel in einer JSON- oder JSONC-Datei setzen. modus 'setzen' ersetzt den Wert; 'listeneintrag' stellt sicher, dass wert in der Liste unter schluessel vorkommt. Andere Inhalte, Reihenfolge und Kommentare bleiben erhalten. Fehlt die Datei, wird sie aus anlegen erzeugt (falls gesetzt), sonst als leeres Objekt.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"op",
|
||||
"file",
|
||||
"key",
|
||||
"value"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"op": {
|
||||
"const": "json"
|
||||
},
|
||||
"package": {
|
||||
"$ref": "#/$defs/package_id",
|
||||
"description": "Fehlt bei gemeinsamen Einträgen, die Hugin für mehrere Pakete pflegt (z. B. instructions in opencode.json)."
|
||||
},
|
||||
"file": {
|
||||
"$ref": "#/$defs/repo_path"
|
||||
},
|
||||
"key": {
|
||||
"$ref": "#/$defs/json_path"
|
||||
},
|
||||
"mode": {
|
||||
"enum": [
|
||||
"set",
|
||||
"list_item"
|
||||
],
|
||||
"default": "setzen"
|
||||
},
|
||||
"value": {},
|
||||
"create": {
|
||||
"$ref": "#/$defs/zip_path"
|
||||
}
|
||||
}
|
||||
},
|
||||
"op_remove": {
|
||||
"description": "Datei oder Ordner entfernen, den eine frühere Version angelegt hat. Fehlt das Ziel, ist nichts zu tun.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"op",
|
||||
"package",
|
||||
"path"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"op": {
|
||||
"const": "remove"
|
||||
},
|
||||
"package": {
|
||||
"$ref": "#/$defs/package_id"
|
||||
},
|
||||
"path": {
|
||||
"$ref": "#/$defs/repo_path"
|
||||
}
|
||||
}
|
||||
},
|
||||
"op_json_remove": {
|
||||
"description": "Schlüssel entfernen ('setzen') bzw. Eintrag aus der Liste entfernen ('listeneintrag').",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"op",
|
||||
"file",
|
||||
"key"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"op": {
|
||||
"const": "json_remove"
|
||||
},
|
||||
"package": {
|
||||
"$ref": "#/$defs/package_id",
|
||||
"description": "Fehlt bei gemeinsamen Einträgen, die Hugin für mehrere Pakete pflegt (z. B. instructions in opencode.json)."
|
||||
},
|
||||
"file": {
|
||||
"$ref": "#/$defs/repo_path"
|
||||
},
|
||||
"key": {
|
||||
"$ref": "#/$defs/json_path"
|
||||
},
|
||||
"mode": {
|
||||
"enum": [
|
||||
"set",
|
||||
"list_item"
|
||||
],
|
||||
"default": "setzen"
|
||||
},
|
||||
"value": {}
|
||||
}
|
||||
},
|
||||
"hint": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind"
|
||||
],
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "env_variable"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Z_][A-Z0-9_]*$"
|
||||
},
|
||||
"package": {
|
||||
"$ref": "#/$defs/package_id"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"name",
|
||||
"package"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "min_version"
|
||||
},
|
||||
"tool": {
|
||||
"enum": [
|
||||
"claude",
|
||||
"opencode"
|
||||
]
|
||||
},
|
||||
"version": {
|
||||
"type": "string"
|
||||
},
|
||||
"package": {
|
||||
"$ref": "#/$defs/package_id"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"tool",
|
||||
"version"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "deprecated"
|
||||
},
|
||||
"package": {
|
||||
"$ref": "#/$defs/package_id"
|
||||
},
|
||||
"successor": {
|
||||
"$ref": "#/$defs/package_id"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"package"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "major_available"
|
||||
},
|
||||
"package": {
|
||||
"$ref": "#/$defs/package_id"
|
||||
},
|
||||
"version": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"package",
|
||||
"version"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"local_path": {
|
||||
"description": "Pfad einer Datei im ZIP für lokale Server, immer unter local/<package>/<version>/. Segmente '..' sind verboten.",
|
||||
"type": "string",
|
||||
"pattern": "^local/[^\\\\]+$"
|
||||
},
|
||||
"op_install": {
|
||||
"description": "Lokalen MCP-Server außerhalb des Repos installieren (→ KONZEPT.md 20, Stufe 5b). Ablage: <HUGIN_MCP_HOME oder ~/.local/share/mcp>/<package>/<version>/. Gestartet wird mit hugin mcp run <package>; Clients ohne diesen Befehl überspringen die Operation mit einer Warnung.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"op",
|
||||
"package",
|
||||
"version",
|
||||
"checksum",
|
||||
"runtime",
|
||||
"entry",
|
||||
"files"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"op": {
|
||||
"const": "install"
|
||||
},
|
||||
"package": {
|
||||
"$ref": "#/$defs/package_id"
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+$"
|
||||
},
|
||||
"checksum": {
|
||||
"$ref": "#/$defs/checksum"
|
||||
},
|
||||
"runtime": {
|
||||
"enum": [
|
||||
"python"
|
||||
]
|
||||
},
|
||||
"entry": {
|
||||
"description": "python: Modul (python -m) oder Skript im Paket (.py)",
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"files": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"path",
|
||||
"source",
|
||||
"sha256"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"path": {
|
||||
"$ref": "#/$defs/repo_path"
|
||||
},
|
||||
"source": {
|
||||
"$ref": "#/$defs/local_path"
|
||||
},
|
||||
"sha256": {
|
||||
"$ref": "#/$defs/checksum"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"python": {
|
||||
"description": "kleinste nötige Python-Version, z. B. 3.10",
|
||||
"type": "string",
|
||||
"pattern": "^3\\.\\d{1,2}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
35
docker-compose.yml
Normal file
35
docker-compose.yml
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# PostgreSQL zum Entwickeln und Testen, dauerhaft (Daten im Volume hugin-postgres).
|
||||
#
|
||||
# docker compose up -d # starten
|
||||
# docker compose down # anhalten, Daten bleiben
|
||||
# docker compose down -v # anhalten und Daten löschen
|
||||
#
|
||||
# Datenbanken: hugin (für den Server, z. B. scripts/demo.sh) und die Verwaltungs-
|
||||
# datenbank postgres (für die Tests; jeder Test legt dort eine eigene an und
|
||||
# räumt sie wieder ab). Zugang nur von diesem Rechner (127.0.0.1:55432).
|
||||
#
|
||||
# HUGIN_DATABASE_URL=postgresql+psycopg://hugin:hugin@127.0.0.1:55432/hugin scripts/demo.sh
|
||||
# scripts/postgres-test.sh --compose
|
||||
|
||||
name: hugin
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17.6
|
||||
environment:
|
||||
POSTGRES_USER: hugin
|
||||
POSTGRES_PASSWORD: hugin
|
||||
POSTGRES_DB: hugin
|
||||
ports:
|
||||
- "127.0.0.1:55432:5432"
|
||||
volumes:
|
||||
- hugin-postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U hugin -d hugin"]
|
||||
interval: 2s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
hugin-postgres:
|
||||
31
scripts/build-cli.sh
Executable file
31
scripts/build-cli.sh
Executable file
|
|
@ -0,0 +1,31 @@
|
|||
#!/usr/bin/env bash
|
||||
# Baut das CLI für alle unterstützten Systeme (→ KONZEPT.md 11, 18).
|
||||
#
|
||||
# scripts/build-cli.sh 0.2.0 # nach cli/dist/0.2.0/
|
||||
# scripts/build-cli.sh 0.2.0 /srv/hugin-downloads # zusätzlich in den Download-Ordner (HUGIN_DOWNLOAD_DIR)
|
||||
#
|
||||
# Ergebnis: hugin-<os>-<arch>[.exe] und SHA256SUMS. Ohne CGO, also ohne
|
||||
# Abhängigkeiten auf dem Zielrechner.
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:?Version angeben, z. B. scripts/build-cli.sh 0.2.0}"
|
||||
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "Version im Format x.y.z"; exit 2; }
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
TARGET="$ROOT/cli/dist/$VERSION"
|
||||
rm -rf "$TARGET" && mkdir -p "$TARGET"
|
||||
|
||||
for platform in darwin/arm64 darwin/amd64 linux/amd64 linux/arm64 windows/amd64; do
|
||||
os="${platform%/*}"; arch="${platform#*/}"
|
||||
file="hugin-$os-$arch"; [[ "$os" == windows ]] && file+=".exe"
|
||||
(cd "$ROOT/cli" && CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" \
|
||||
go build -trimpath -ldflags "-s -w -X main.Version=$VERSION" -o "$TARGET/$file" ./cmd/hugin)
|
||||
echo " $file"
|
||||
done
|
||||
(cd "$TARGET" && shasum -a 256 hugin-* > SHA256SUMS)
|
||||
echo "Gebaut: $TARGET"
|
||||
|
||||
if [[ -n "${2:-}" ]]; then
|
||||
mkdir -p "$2/$VERSION"
|
||||
cp "$TARGET"/* "$2/$VERSION/"
|
||||
echo "Kopiert nach $2/$VERSION (für die Seite „Programm hugin“)"
|
||||
fi
|
||||
27
scripts/csp-test.sh
Executable file
27
scripts/csp-test.sh
Executable file
|
|
@ -0,0 +1,27 @@
|
|||
#!/usr/bin/env bash
|
||||
# Prüft das CSS-Framework unter der strengen CSP von Hugin (→ KONZEPT.md 16.4):
|
||||
# zählt CSP-Verstöße der Demo und klickt alle data-action-Aktionen durch.
|
||||
# Braucht Google Chrome (headless).
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
CH="${CHROME:-/Applications/Google Chrome.app/Contents/MacOS/Google Chrome}"
|
||||
PORT="${PORT:-8813}"
|
||||
PROFILE="$(mktemp -d)"
|
||||
python3 -I "$ROOT/scripts/csp/server.py" "$ROOT/static/css-framework/${FOLDER:-.}" "$PORT" 1 "$ROOT/scripts/csp/actions.js" &
|
||||
SERVER=$!
|
||||
# Rückgabewert des Tests bewahren; wait auf den beendeten Server würde ihn überschreiben
|
||||
trap 'rc=$?; kill $SERVER 2>/dev/null; wait $SERVER 2>/dev/null || true; rm -rf "$PROFILE"; exit $rc' EXIT
|
||||
sleep 1
|
||||
# Chrome endet nach dem Zeitbudget mit 143; bewertet wird nur die ausgegebene Seite
|
||||
"$CH" --headless=new --disable-gpu --user-data-dir="$PROFILE" --virtual-time-budget=6000 \
|
||||
--dump-dom "http://127.0.0.1:$PORT/index.html" >"$PROFILE/page.html" 2>/dev/null || true
|
||||
python3 -I -c '
|
||||
import html, re, sys
|
||||
t = sys.stdin.read()
|
||||
failed = 0
|
||||
for part in ("actions", "csp-result"):
|
||||
m = re.search(r"<pre id=\"" + part + r"\">(.*?)</pre>", t, re.S)
|
||||
text = html.unescape(m.group(1)) if m else part + ": kein Ergebnis"
|
||||
print(text)
|
||||
failed += (not m) or ("FEHL" in text) or (part == "csp-result" and not text.startswith("VIOLATIONS 0"))
|
||||
sys.exit(1 if failed else 0)' <"$PROFILE/page.html"
|
||||
22
scripts/csp/actions.js
Normal file
22
scripts/csp/actions.js
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
window.addEventListener('load', function () { setTimeout(function () {
|
||||
var r = [];
|
||||
function check(name, f) { try { r.push((f() ? 'OK ' : 'FEHLT') + ' ' + name); } catch (e) { r.push('FEHLER ' + name + ': ' + e); } }
|
||||
function click(el) { el.dispatchEvent(new MouseEvent('click', { bubbles: true })); }
|
||||
var q = function (s) { return document.querySelector(s); };
|
||||
check('modal-open', function () { var b = q('[data-action="modal-open"]'); click(b); return q('#' + b.dataset.target).classList.contains('open') && document.body.classList.contains('modal-open'); });
|
||||
check('modal-close', function () { var m = q('.modal-backdrop.open'); click(m.querySelector('[data-action="modal-close"]')); return !m.classList.contains('open') && !document.body.classList.contains('modal-open'); });
|
||||
check('modal-backdrop', function () { var b = q('[data-action="modal-open"]'); click(b); var m = q('#' + b.dataset.target); click(m); return !m.classList.contains('open'); });
|
||||
check('tab', function () { var t = document.querySelectorAll('[data-action="tab"]')[1]; click(t); return t.classList.contains('active') && q('#' + t.dataset.target).classList.contains('active'); });
|
||||
check('dropdown', function () { var d = q('[data-action="dropdown"]'); click(d); return q('#' + d.dataset.target).classList.contains('open'); });
|
||||
check('dropdown schließt bei Klick daneben', function () { click(document.body); return !q('.dropdown.open'); });
|
||||
check('nav-dropdown', function () { var d = q('[data-action="nav-dropdown"]'); click(d); return d.closest('li').classList.contains('open'); });
|
||||
check('toggle-class', function () { var e = q('[data-action="toggle-class"]'); var before = e.classList.contains(e.dataset.class); click(e); return e.classList.contains(e.dataset.class) !== before; });
|
||||
check('sort', function () { var th = q('[data-action="sort"]'); click(th); return th.dataset.sort === 'asc'; });
|
||||
check('toast + XSS-fest', function () { showToast('<img src=x>', 'success'); var t = q('.toast:last-child'); return t && t.querySelector('.toast-title').textContent === '<img src=x>' && !t.querySelector('img') && t.querySelector('.toast-icon-success'); });
|
||||
check('demo-toast', function () { var n = document.querySelectorAll('.toast').length; click(q('[data-action="demo-toast"]')); return document.querySelectorAll('.toast').length === n + 1; });
|
||||
check('toast-close', function () { var t = q('.toast'); click(t.querySelector('[data-action="toast-close"]')); return t.classList.contains('is-leaving'); });
|
||||
check('theme-toggle', function () { var before = document.documentElement.dataset.theme; click(q('[data-action="theme-toggle"]')); return document.documentElement.dataset.theme !== before && getComputedStyle(q('#icon-sun')).display !== getComputedStyle(q('#icon-moon')).display; });
|
||||
check('alert-dismiss', function () { var a = q('[data-action="alert-dismiss"]'); click(a); return a.closest('.alert').classList.contains('is-dismissing'); });
|
||||
check('data-progress', function () { var b = q('[data-progress="80"]'); return b.style.width === '80%'; });
|
||||
var m = document.createElement('pre'); m.id = 'actions'; m.textContent = r.join('\n'); document.body.appendChild(m);
|
||||
}, 300); });
|
||||
28
scripts/csp/server.py
Normal file
28
scripts/csp/server.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# Testserver für scripts/csp-test.sh: liefert Dateien mit strenger CSP aus und
|
||||
# schiebt Prüfskripte ein (Zählen von CSP-Verstößen, Klicktest der Aktionen).
|
||||
import http.server, sys, functools, os
|
||||
CSP = ("default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; "
|
||||
"connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'")
|
||||
TRIAL = b"""window.__csp=[];document.addEventListener('securitypolicyviolation',function(e){
|
||||
window.__csp.push(e.violatedDirective+' '+(e.sample||'').slice(0,40)+' @'+e.lineNumber);});
|
||||
window.addEventListener('load',function(){setTimeout(function(){var m=document.createElement('pre');
|
||||
m.id='csp-result';m.textContent='VIOLATIONS '+window.__csp.length+'\\n'+window.__csp.join('\\n');
|
||||
document.body.appendChild(m);},500);});"""
|
||||
class H(http.server.SimpleHTTPRequestHandler):
|
||||
def end_headers(self):
|
||||
if self.server.csp: self.send_header("Content-Security-Policy", CSP)
|
||||
super().end_headers()
|
||||
def do_GET(self):
|
||||
if self.path == "/__actions.js":
|
||||
self.send_response(200); self.send_header("Content-Type","text/javascript"); self.end_headers(); self.wfile.write(open(sys.argv[4],"rb").read()); return
|
||||
if self.path == "/__probe.js":
|
||||
self.send_response(200); self.send_header("Content-Type","text/javascript"); self.end_headers(); self.wfile.write(TRIAL); return
|
||||
if self.path.endswith(".html"):
|
||||
raw = open(os.path.join(self.server.folder, self.path.lstrip("/")), "rb").read()
|
||||
raw = raw.replace(b"<head>", b'<head><script src="/__probe.js"></script>', 1).replace(b"</body>", b'<script src="/__actions.js"></script></body>', 1)
|
||||
self.send_response(200); self.send_header("Content-Type","text/html; charset=utf-8"); self.end_headers(); self.wfile.write(raw); return
|
||||
super().do_GET()
|
||||
def log_message(self, *a): pass
|
||||
folder, port, csp = sys.argv[1], int(sys.argv[2]), sys.argv[3] == "1"
|
||||
s = http.server.ThreadingHTTPServer(("127.0.0.1", port), functools.partial(H, directory=folder)); s.csp = csp; s.folder = folder
|
||||
s.serve_forever()
|
||||
203
scripts/csp/web_actions.py
Normal file
203
scripts/csp/web_actions.py
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
"""Weboberfläche im echten Chrome bedienen: Live-Suche, Tabs, Kopieren, Rückfragen, Formulare.
|
||||
|
||||
Ergänzt web_browser.py (CSP und Bildschirmfotos) um das, was nur mit JavaScript im Browser
|
||||
prüfbar ist: htmx-Trigger, hugin.js und die Aktionen des Frameworks auf den echten Seiten.
|
||||
|
||||
uv run --project server --with websockets --with httpx python scripts/csp/web_actions.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import websockets
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from web_browser import ADMIN, CHROME, ROOT, cdp, cookie_for, create_data, free_port # noqa: E402
|
||||
|
||||
|
||||
async def js(ws, expression):
|
||||
result = await cdp(ws, "Runtime.evaluate", expression=expression, returnByValue=True, awaitPromise=True)
|
||||
return result["result"].get("value")
|
||||
|
||||
|
||||
async def press(ws, key, code, key_code):
|
||||
for kind in ("keyDown", "keyUp"):
|
||||
await cdp(ws, "Input.dispatchKeyEvent", type=kind, key=key, code=code, windowsVirtualKeyCode=key_code)
|
||||
|
||||
|
||||
async def run(port_chrome: int, url: str, cookies: dict[str, str]) -> bool:
|
||||
target = httpx.put(f"http://127.0.0.1:{port_chrome}/json/new?about:blank").json()
|
||||
results = []
|
||||
|
||||
def check(name, ok):
|
||||
results.append(bool(ok))
|
||||
print(("OK " if ok else "FEHLT ") + name)
|
||||
|
||||
async with websockets.connect(target["webSocketDebuggerUrl"], max_size=50_000_000) as ws:
|
||||
await cdp(ws, "Page.enable")
|
||||
await cdp(ws, "Network.enable")
|
||||
|
||||
async def go(path, who):
|
||||
await cdp(ws, "Network.clearBrowserCookies")
|
||||
await cdp(ws, "Network.setCookie", name="hugin_session", value=cookies[who], url=url)
|
||||
await cdp(ws, "Page.navigate", url=url + path)
|
||||
await asyncio.sleep(1.2)
|
||||
|
||||
# Topbar: Abmelden bleibt sichtbar, auch unten auf langen Seiten und auf dem Handy
|
||||
logout_visible = ("(function () { var b = [...document.querySelectorAll('button')]"
|
||||
".find(b => b.textContent.trim() == 'Abmelden'); var r = b.getBoundingClientRect();"
|
||||
"return r.width > 0 && r.top >= 0 && r.bottom <= innerHeight && r.left >= 0"
|
||||
" && r.right <= document.documentElement.clientWidth; })()")
|
||||
await cdp(ws, "Emulation.setDeviceMetricsOverride", width=1280, height=800, deviceScaleFactor=1, mobile=False)
|
||||
await go("/manage?tenant=shop", "Admin")
|
||||
await js(ws, "scrollTo(0, document.body.scrollHeight)")
|
||||
await asyncio.sleep(0.3)
|
||||
check("Topbar: Abmelden unten auf langer Seite sichtbar", await js(ws, logout_visible))
|
||||
await cdp(ws, "Emulation.setDeviceMetricsOverride", width=390, height=844, deviceScaleFactor=1, mobile=True)
|
||||
await go("/packages/pub-munin-nutzen/versions/1.4.0", "Shop CI")
|
||||
check("Topbar: auf dem Handy nichts abgeschnitten", await js(ws, logout_visible))
|
||||
await js(ws, "document.querySelector('[data-action=sidebar-open]').click()")
|
||||
await asyncio.sleep(0.5)
|
||||
check("Topbar: Menü öffnet die Sidebar",
|
||||
await js(ws, "document.getElementById('sidebar').getBoundingClientRect().left >= 0"))
|
||||
await cdp(ws, "Emulation.clearDeviceMetricsOverride")
|
||||
|
||||
# Katalog: Live-Suche über htmx
|
||||
await go("/catalog", "Shop CI")
|
||||
await js(ws, "var i = document.getElementById('search'); i.value = 'haus';"
|
||||
"i.dispatchEvent(new Event('input', {bubbles: true}))")
|
||||
await asyncio.sleep(1.2)
|
||||
text = await js(ws, "document.getElementById('catalog-list').textContent")
|
||||
check("Katalog: Live-Suche", "pub-hausregeln" in text and "pub-munin-nutzen" not in text)
|
||||
check("Katalog: Suche steht in der URL", "q=haus" in await js(ws, "location.href"))
|
||||
|
||||
# Versionsseite: zurück zum ersten Tab
|
||||
await go("/packages/pub-munin-nutzen/versions/1.4.0", "Shop CI")
|
||||
await js(ws, "var t = document.querySelectorAll('[data-action=tab]'); t[1].click(); t[0].click()")
|
||||
check("Version: Tab zurück zu Inhalt",
|
||||
await js(ws, "document.getElementById('tab-content').classList.contains('active')"))
|
||||
await js(ws, "document.getElementById('tabbtn-content').focus()")
|
||||
await press(ws, "ArrowRight", "ArrowRight", 39)
|
||||
check("Version: Pfeiltaste wechselt Tab samt aria-selected",
|
||||
await js(ws, "document.activeElement.id == 'tabbtn-diff' && "
|
||||
"document.getElementById('tabbtn-diff').getAttribute('aria-selected') == 'true' && "
|
||||
"document.getElementById('tabbtn-content').getAttribute('aria-selected') == 'false' && "
|
||||
"document.getElementById('tab-diff').classList.contains('active')"))
|
||||
await press(ws, "Home", "Home", 36)
|
||||
check("Version: Pos1 springt zum ersten Tab",
|
||||
await js(ws, "document.getElementById('tab-content').classList.contains('active')"))
|
||||
|
||||
# Paketseite: Kopieren, erst ohne Fokus (kein Zugriff auf die Zwischenablage), dann mit
|
||||
await go("/packages/pub-munin-nutzen", "Shop CI")
|
||||
await js(ws, "document.querySelector('[data-copy=subscribe]').click()")
|
||||
await asyncio.sleep(0.6)
|
||||
check("Kopieren ohne Zugriff: Text markiert, Hinweis auf Tastenkürzel",
|
||||
"Markiert" in await js(ws, "document.getElementById('toast-container').textContent")
|
||||
and "pub-munin-nutzen" in await js(ws, "getSelection().toString()"))
|
||||
await cdp(ws, "Emulation.setFocusEmulationEnabled", enabled=True)
|
||||
await cdp(ws, "Browser.grantPermissions", origin=url,
|
||||
permissions=["clipboardReadWrite", "clipboardSanitizedWrite"])
|
||||
await js(ws, "document.querySelector('[data-copy=subscribe]').click()")
|
||||
await asyncio.sleep(0.6)
|
||||
check("Kopieren: Toast", "Kopiert" in await js(ws, "document.getElementById('toast-container').textContent"))
|
||||
check("Kopieren: Abo in der Zwischenablage",
|
||||
await js(ws, "navigator.clipboard.readText()") == "packages:\n - pub-munin-nutzen")
|
||||
|
||||
# Verwaltung: Rückfrage und Felder je Key-Art
|
||||
await go("/manage?tenant=shop", "Admin")
|
||||
await js(ws, "window.__asked = []; window.confirm = function (q) { __asked.push(q); return false; }")
|
||||
await js(ws, "[...document.querySelectorAll('button')].find(b => b.textContent.trim() == 'Widerrufen').click()")
|
||||
await asyncio.sleep(0.8)
|
||||
asked = await js(ws, "JSON.stringify(window.__asked || null)")
|
||||
check("Verwaltung: Rückfrage vor Widerrufen, Abbrechen bleibt auf der Seite",
|
||||
asked and "widerrufen?" in asked and await js(ws, "Array.isArray(window.__asked)"))
|
||||
|
||||
def fields():
|
||||
return js(ws, "JSON.stringify(['key-project', 'key-projects'].map(id => {"
|
||||
"var f = document.getElementById(id); return [f.disabled, f.closest('[data-when]').hidden]; }))")
|
||||
|
||||
check("Key-Art Projekt: nur Feld Projekt", await fields() == "[[false,false],[true,true]]")
|
||||
await js(ws, "var s = document.getElementById('key-kind'); s.value = 'integration';"
|
||||
"s.dispatchEvent(new Event('change', {bubbles: true}))")
|
||||
check("Key-Art Integration: nur Projektauswahl", await fields() == "[[true,true],[false,false]]")
|
||||
await js(ws, "var s = document.getElementById('key-kind'); s.value = 'tenant';"
|
||||
"s.dispatchEvent(new Event('change', {bubbles: true}))")
|
||||
check("Key-Art Tenant: keins von beiden", await fields() == "[[true,true],[true,true]]")
|
||||
check("Ausgeblendetes Feld ist wirklich unsichtbar",
|
||||
await js(ws, "document.getElementById('key-project').offsetParent === null"))
|
||||
|
||||
# Audit: Live-Filter über htmx
|
||||
await go("/audit", "Admin")
|
||||
await js(ws, "var s = document.getElementById('action'); s.value = 'key.';"
|
||||
"s.dispatchEvent(new Event('change', {bubbles: true}))")
|
||||
await asyncio.sleep(1.2)
|
||||
actions = await js(ws, "[...document.querySelectorAll('#audit-list tbody tr td:nth-child(4) code')]"
|
||||
".map(c => c.textContent)")
|
||||
check("Audit: Live-Filter", actions and all(x.startswith("key.") for x in actions))
|
||||
|
||||
# Prüfung: Zurückgeben verlangt einen Kommentar, Freigeben nicht
|
||||
await go("/packages/pub-munin-nutzen/versions/1.5.0", "Prüfer")
|
||||
await js(ws, "document.querySelector('[data-target=decide]').click()")
|
||||
await js(ws, "document.querySelector('button[value=return]').click()")
|
||||
await asyncio.sleep(0.8)
|
||||
check("Prüfung: Zurückgeben ohne Kommentar bleibt im Dialog",
|
||||
await js(ws, "location.pathname.endsWith('/versions/1.5.0') && "
|
||||
"document.getElementById('comment').validity.valueMissing"))
|
||||
await js(ws, "document.querySelector('button[value=approve]').click()")
|
||||
await asyncio.sleep(1.2)
|
||||
check("Prüfung: Freigeben ohne Kommentar geht", await js(ws, "location.pathname") == "/reviews")
|
||||
# Editor zuletzt: Nach der Änderung hält die Warnung beim Verlassen jede weitere Navigation auf
|
||||
# Editor: Tab rückt ein, Esc + Tab verlässt das Feld, Warnung bei ungespeicherten Änderungen
|
||||
await go("/packages/pub-munin-nutzen/versions/1.4.0/edit", "Autorin")
|
||||
check("Editor: ohne Änderung keine Warnung beim Verlassen",
|
||||
not await js(ws, "var e = new Event('beforeunload', {cancelable: true}); dispatchEvent(e); e.defaultPrevented"))
|
||||
await js(ws, "var t = document.querySelector('textarea[name=manifest]'); t.focus(); t.setSelectionRange(0, 0)")
|
||||
await press(ws, "Tab", "Tab", 9)
|
||||
check("Editor: Tab rückt ein",
|
||||
await js(ws, "document.querySelector('textarea[name=manifest]').value.startsWith(' id:')"))
|
||||
check("Editor: nach Änderung Warnung beim Verlassen",
|
||||
await js(ws, "var e = new Event('beforeunload', {cancelable: true}); dispatchEvent(e); e.defaultPrevented"))
|
||||
await press(ws, "Escape", "Escape", 27)
|
||||
await press(ws, "Tab", "Tab", 9)
|
||||
check("Editor: Esc und Tab springt weiter",
|
||||
await js(ws, "document.activeElement.name") != "manifest")
|
||||
|
||||
return all(results)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
work = Path(tempfile.mkdtemp())
|
||||
port, port_chrome = free_port(), free_port()
|
||||
url = f"http://127.0.0.1:{port}"
|
||||
env = os.environ | {"HUGIN_DATABASE_URL": f"sqlite:///{work}/hugin.db", "HUGIN_ADMIN_TOKEN": ADMIN}
|
||||
server_folder = ROOT / "server"
|
||||
subprocess.run(["uv", "run", "-q", "hugin-server", "migrate"], cwd=server_folder, env=env, check=True)
|
||||
server = subprocess.Popen(["uv", "run", "-q", "hugin-server", "run", "--host", "127.0.0.1", "--port", str(port)],
|
||||
cwd=server_folder, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
chrome = subprocess.Popen([CHROME, "--headless=new", "--disable-gpu", f"--remote-debugging-port={port_chrome}",
|
||||
f"--user-data-dir={work}/chrome", "about:blank"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
try:
|
||||
for _ in range(100):
|
||||
try:
|
||||
httpx.get(f"{url}/readyz").raise_for_status()
|
||||
httpx.get(f"http://127.0.0.1:{port_chrome}/json/version").raise_for_status()
|
||||
break
|
||||
except httpx.HTTPError:
|
||||
time.sleep(0.2)
|
||||
keys = create_data(url)
|
||||
cookies = {name: cookie_for(url, key) for name, key in keys.items()}
|
||||
sys.exit(0 if asyncio.run(run(port_chrome, url, cookies)) else 1)
|
||||
finally:
|
||||
server.terminate()
|
||||
chrome.terminate()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
200
scripts/csp/web_browser.py
Normal file
200
scripts/csp/web_browser.py
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
"""Weboberfläche im echten Chrome prüfen: CSP-Verstöße zählen, Bildschirmfotos machen.
|
||||
|
||||
Startet einen Hugin-Server mit Testdaten, meldet sich mit verschiedenen Keys an
|
||||
und ruft jede Seite über das DevTools-Protokoll auf. Ein vor dem Laden
|
||||
eingefügtes Skript (CDP, nicht von der CSP betroffen) zählt Verstöße.
|
||||
|
||||
uv run --with websockets --with httpx python scripts/csp/web_browser.py <ausgabeordner>
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import websockets
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
CHROME = os.environ.get("CHROME", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome")
|
||||
ADMIN = "web-test-admin"
|
||||
COUNTER = """window.__csp = []; document.addEventListener('securitypolicyviolation',
|
||||
e => window.__csp.push(e.violatedDirective + ' ' + (e.sample || '').slice(0, 40)));"""
|
||||
|
||||
|
||||
def free_port() -> int:
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def zip_folder(folder: Path) -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w") as z:
|
||||
for d in sorted(folder.rglob("*")):
|
||||
if d.is_file():
|
||||
z.write(d, d.relative_to(folder).as_posix())
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def create_data(url: str) -> dict[str, str]:
|
||||
a = {"Authorization": f"Bearer {ADMIN}"}
|
||||
c = httpx.Client(base_url=url)
|
||||
keys = {}
|
||||
for tenant, project in [("plattform", "kern"), ("shop", "api")]:
|
||||
c.post("/api/v1/admin/tenants", headers=a, json={"slug": tenant, "name": tenant.title()}).raise_for_status()
|
||||
c.post(f"/api/v1/admin/tenants/{tenant}/projects", headers=a, json={"slug": project, "name": project})
|
||||
for name, tenant, project, roles in [("Autorin", "plattform", "kern", ["pub_author"]),
|
||||
("Prüfer", "plattform", "kern", ["pub_reviewer"]),
|
||||
("Shop CI", "shop", "api", [])]:
|
||||
response = c.post(f"/api/v1/admin/tenants/{tenant}/keys", headers=a,
|
||||
json={"kind": "project", "name": name, "project": project, "roles": roles})
|
||||
keys[name] = response.json()["key"]
|
||||
packages = ROOT / "contract/examples/packages"
|
||||
for name in ["pub-munin-nutzen/1.4.0", "pub-hausregeln/1.0.0", "pub-munin-anbindung/1.0.0",
|
||||
"pub-munin-nutzen/1.5.0"]:
|
||||
c.post("/api/v1/drafts", headers={"Authorization": f"Bearer {keys['Autorin']}",
|
||||
"Content-Type": "application/zip"},
|
||||
content=zip_folder(packages / name)).raise_for_status()
|
||||
package, number = name.split("/")
|
||||
base = f"/api/v1/packages/{package}/versions/{number}"
|
||||
c.post(f"{base}/submit", headers={"Authorization": f"Bearer {keys['Autorin']}"}).raise_for_status()
|
||||
if name != "pub-munin-nutzen/1.5.0": # 1.5.0 bleibt in Prüfung
|
||||
c.post(f"{base}/approve", headers={"Authorization": f"Bearer {keys['Prüfer']}"},
|
||||
json={"comment": "passt"}).raise_for_status()
|
||||
c.post("/api/v1/usage", headers={"Authorization": f"Bearer {keys['Shop CI']}"},
|
||||
json={"repo": "git.intern/shop/api", "tools": {"claude": "2.1.302"},
|
||||
"installed": {"pub-munin-nutzen": "1.4.0", "pub-hausregeln": "1.0.0"}})
|
||||
keys["Admin"] = ADMIN
|
||||
return keys
|
||||
|
||||
|
||||
def cookie_for(url: str, key: str) -> str:
|
||||
response = httpx.post(f"{url}/login", data={"key": key, "next": ""})
|
||||
return response.cookies["hugin_session"]
|
||||
|
||||
|
||||
async def cdp(ws, method, **params):
|
||||
cdp.n += 1
|
||||
await ws.send(json.dumps({"id": cdp.n, "method": method, "params": params}))
|
||||
while True:
|
||||
response = json.loads(await ws.recv())
|
||||
if response.get("id") == cdp.n:
|
||||
return response.get("result", {})
|
||||
|
||||
|
||||
cdp.n = 0
|
||||
|
||||
|
||||
async def validate_pages(port_chrome: int, url: str, pages, output: Path, dunkel: bool) -> int:
|
||||
target = httpx.put(f"http://127.0.0.1:{port_chrome}/json/new?about:blank").json()
|
||||
error = 0
|
||||
async with websockets.connect(target["webSocketDebuggerUrl"], max_size=50_000_000) as ws:
|
||||
await cdp(ws, "Page.enable")
|
||||
await cdp(ws, "Network.enable")
|
||||
await cdp(ws, "Page.addScriptToEvaluateOnNewDocument", source=COUNTER)
|
||||
await cdp(ws, "Emulation.setDeviceMetricsOverride", width=1280, height=900, deviceScaleFactor=1, mobile=False)
|
||||
if dunkel:
|
||||
await cdp(ws, "Emulation.setEmulatedMedia", features=[{"name": "prefers-color-scheme", "value": "dark"}])
|
||||
for name, path, cookie in pages:
|
||||
await cdp(ws, "Network.clearBrowserCookies")
|
||||
if cookie:
|
||||
await cdp(ws, "Network.setCookie", name="hugin_session", value=cookie, url=url)
|
||||
await cdp(ws, "Page.navigate", url=url + path)
|
||||
await asyncio.sleep(1.2)
|
||||
result = await cdp(ws, "Runtime.evaluate", expression="JSON.stringify(window.__csp)", returnByValue=True)
|
||||
verstoesse = json.loads(result["result"]["value"])
|
||||
error += len(verstoesse)
|
||||
hoehe = (await cdp(ws, "Runtime.evaluate", expression="document.documentElement.scrollHeight",
|
||||
returnByValue=True))["result"]["value"]
|
||||
image = await cdp(ws, "Page.captureScreenshot", format="png", captureBeyondViewport=True,
|
||||
clip={"x": 0, "y": 0, "width": 1280, "height": min(hoehe, 4000), "scale": 1})
|
||||
file = output / f"{'dunkel-' if dunkel else ''}{name}.png"
|
||||
file.write_bytes(base64.b64decode(image["data"]))
|
||||
print(f"{'✓' if not verstoesse else '✗'} {name:<12} {path:<45} Verstöße: {len(verstoesse)} {verstoesse[:3]}")
|
||||
return error
|
||||
|
||||
|
||||
async def gegenprobe(port_chrome: int, url: str, cookie: str) -> bool:
|
||||
"""Fügt ein style-Attribut ein; die Messung muss den Verstoß sehen."""
|
||||
target = httpx.put(f"http://127.0.0.1:{port_chrome}/json/new?about:blank").json()
|
||||
async with websockets.connect(target["webSocketDebuggerUrl"]) as ws:
|
||||
await cdp(ws, "Page.enable")
|
||||
await cdp(ws, "Network.enable")
|
||||
await cdp(ws, "Page.addScriptToEvaluateOnNewDocument", source=COUNTER)
|
||||
await cdp(ws, "Network.setCookie", name="hugin_session", value=cookie, url=url)
|
||||
await cdp(ws, "Page.navigate", url=url + "/help")
|
||||
await asyncio.sleep(1)
|
||||
await cdp(ws, "Runtime.evaluate", expression="document.body.insertAdjacentHTML('beforeend', "
|
||||
"'<div style=\\'color:red\\'>x</div>')")
|
||||
await asyncio.sleep(0.5)
|
||||
anzahl = (await cdp(ws, "Runtime.evaluate", expression="window.__csp.length", returnByValue=True))
|
||||
return anzahl["result"]["value"] > 0
|
||||
|
||||
|
||||
def main() -> None:
|
||||
output = Path(sys.argv[1])
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
arbeit = Path(tempfile.mkdtemp())
|
||||
port, port_chrome = free_port(), free_port()
|
||||
url = f"http://127.0.0.1:{port}"
|
||||
env = os.environ | {"HUGIN_DATABASE_URL": f"sqlite:///{arbeit}/hugin.db", "HUGIN_ADMIN_TOKEN": ADMIN}
|
||||
server_folder = ROOT / "server"
|
||||
subprocess.run(["uv", "run", "-q", "hugin-server", "migrate"], cwd=server_folder, env=env, check=True)
|
||||
server = subprocess.Popen(["uv", "run", "-q", "hugin-server", "run", "--host", "127.0.0.1", "--port", str(port)],
|
||||
cwd=server_folder, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
chrome = subprocess.Popen([CHROME, "--headless=new", "--disable-gpu", f"--remote-debugging-port={port_chrome}",
|
||||
f"--user-data-dir={arbeit}/chrome", "about:blank"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
try:
|
||||
for _ in range(100):
|
||||
try:
|
||||
httpx.get(f"{url}/readyz").raise_for_status()
|
||||
httpx.get(f"http://127.0.0.1:{port_chrome}/json/version").raise_for_status()
|
||||
break
|
||||
except httpx.HTTPError:
|
||||
time.sleep(0.2)
|
||||
keys = create_data(url)
|
||||
c = {name: cookie_for(url, key) for name, key in keys.items()}
|
||||
pages = [
|
||||
("login", "/login", None),
|
||||
("catalog", "/catalog", c["Shop CI"]),
|
||||
("package", "/packages/pub-munin-nutzen", c["Shop CI"]),
|
||||
("version", "/packages/pub-munin-nutzen/versions/1.4.0", c["Shop CI"]),
|
||||
("pruefung", "/packages/pub-munin-nutzen/versions/1.5.0", c["Prüfer"]),
|
||||
("reviews", "/reviews", c["Prüfer"]),
|
||||
("upload", "/upload", c["Autorin"]),
|
||||
("project", "/project", c["Shop CI"]),
|
||||
("manage", "/manage?tenant=shop", c["Admin"]),
|
||||
("overview", "/overview", c["Shop CI"]),
|
||||
("system", "/system", c["Admin"]),
|
||||
("audit", "/audit", c["Admin"]),
|
||||
("new", "/new?kind=skill&id=pub-neu", c["Autorin"]),
|
||||
("editor", "/packages/pub-munin-nutzen/versions/1.4.0/edit", c["Autorin"]),
|
||||
("download", "/download", c["Shop CI"]),
|
||||
("help", "/help/package-format", c["Shop CI"]),
|
||||
("hilfe-ohne", "/help/teams", None),
|
||||
]
|
||||
if not asyncio.run(gegenprobe(port_chrome, url, c["Shop CI"])):
|
||||
print("Gegenprobe gescheitert: ein absichtlicher Verstoß wurde nicht gezählt")
|
||||
sys.exit(2)
|
||||
print("Gegenprobe ok: absichtlicher Verstoß wird gezählt")
|
||||
error = asyncio.run(validate_pages(port_chrome, url, pages, output, dunkel=False))
|
||||
error += asyncio.run(validate_pages(port_chrome, url, pages[1:4], output, dunkel=True))
|
||||
print("Verstöße gesamt:", error)
|
||||
sys.exit(1 if error else 0)
|
||||
finally:
|
||||
server.terminate()
|
||||
chrome.terminate()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
107
scripts/demo.sh
Executable file
107
scripts/demo.sh
Executable file
|
|
@ -0,0 +1,107 @@
|
|||
#!/usr/bin/env bash
|
||||
# Startet Hugin lokal mit Beispieldaten zum Ausprobieren.
|
||||
#
|
||||
# scripts/demo.sh # Server auf http://127.0.0.1:8080, Daten in .demo/
|
||||
# scripts/demo.sh --neu # Beispieldaten verwerfen und neu anlegen
|
||||
# HOST=0.0.0.0 scripts/demo.sh # auch von anderen Rechnern erreichbar (z. B. Windows-Test)
|
||||
# HUGIN_DATABASE_URL=postgresql+psycopg://hugin:hugin@127.0.0.1:55432/hugin scripts/demo.sh
|
||||
# # mit der PostgreSQL aus docker-compose.yml
|
||||
#
|
||||
# Beim ersten Start legt das Skript Tenants, Projekte, Personen und Keys an,
|
||||
# veröffentlicht die Beispielpakete und schreibt alle Keys nach .demo/keys.txt.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
DEMO="$ROOT/.demo"
|
||||
PORT="${PORT:-8080}"
|
||||
URL="http://127.0.0.1:$PORT"
|
||||
mkdir -p "$DEMO"
|
||||
if [[ -n "${HUGIN_DATABASE_URL:-}" ]]; then
|
||||
# Eigene Datenbank (z. B. PostgreSQL): die Keys gehören zu dieser Datenbank
|
||||
KEYS="$DEMO/keys-$(printf '%s' "$HUGIN_DATABASE_URL" | shasum | cut -c1-8).txt"
|
||||
if [[ "${1:-}" == "--neu" ]]; then
|
||||
echo "--neu mit eigener Datenbank: Daten dort selbst löschen, z. B. docker compose down -v"; exit 2
|
||||
fi
|
||||
else
|
||||
# Nur die SQLite-Demo verwerfen; Keys anderer Datenbanken (keys-*.txt) bleiben
|
||||
[[ "${1:-}" == "--neu" ]] && rm -f "$DEMO"/hugin.db* "$DEMO/keys.txt" "$DEMO/server.log"
|
||||
export HUGIN_DATABASE_URL="sqlite:///$DEMO/hugin.db"
|
||||
KEYS="$DEMO/keys.txt"
|
||||
fi
|
||||
export HUGIN_ADMIN_TOKEN="${HUGIN_ADMIN_TOKEN:-demo-admin}"
|
||||
ADMIN=(-H "Authorization: Bearer $HUGIN_ADMIN_TOKEN")
|
||||
|
||||
if ! (cd "$ROOT/server" && uv run -q hugin-server migrate 2>"$DEMO/migrate.log"); then
|
||||
tail -1 "$DEMO/migrate.log"
|
||||
echo "Migration gescheitert; die Datenbank stammt vermutlich von einem älteren Stand (ganzer Fehler: $DEMO/migrate.log)."
|
||||
echo "Neu anlegen: bei SQLite 'scripts/demo.sh --neu',"
|
||||
echo "bei PostgreSQL aus docker-compose.yml 'docker compose down -v && docker compose up -d'."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -f "$KEYS" ]]; then
|
||||
echo "Beispieldaten vorhanden (.demo/). Keys: $KEYS"
|
||||
else
|
||||
(cd "$ROOT/server" && exec uv run -q hugin-server run --host "${HOST:-127.0.0.1}" --port "$PORT") >"$DEMO/server.log" 2>&1 &
|
||||
SERVER=$!
|
||||
trap 'kill $SERVER 2>/dev/null; wait $SERVER 2>/dev/null || true' EXIT
|
||||
for _ in $(seq 60); do curl -fsS "$URL/readyz" >/dev/null 2>&1 && break; sleep 0.25; done
|
||||
if curl -fsS "${ADMIN[@]}" "$URL/api/v1/admin/tenants" 2>/dev/null | grep -q '"plattform"'; then
|
||||
echo "Die Datenbank enthält schon Demo-Daten, aber die Keys dazu fehlen ($KEYS)."
|
||||
echo "Neu anlegen: bei PostgreSQL aus docker-compose.yml 'docker compose down -v && docker compose up -d',"
|
||||
echo "bei SQLite 'scripts/demo.sh --neu'."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
api() { curl -fsS -H "Content-Type: application/json" "$@"; }
|
||||
field() { python3 -c "import json,sys; print(json.load(sys.stdin)['$1'])"; }
|
||||
for t in plattform:kern shop:api shop:web; do
|
||||
api "${ADMIN[@]}" "$URL/api/v1/admin/tenants" -d "{\"slug\":\"${t%%:*}\",\"name\":\"${t%%:*}\"}" >/dev/null 2>&1 || true
|
||||
api "${ADMIN[@]}" "$URL/api/v1/admin/tenants/${t%%:*}/projects" -d "{\"slug\":\"${t##*:}\",\"name\":\"${t##*:}\"}" >/dev/null
|
||||
done
|
||||
person() { api "${ADMIN[@]}" "$URL/api/v1/admin/tenants/$1/people" -d "$2" | field key; }
|
||||
key() { api "${ADMIN[@]}" "$URL/api/v1/admin/tenants/$1/keys" -d "$2" | field key; }
|
||||
ANNA=$(person plattform '{"name":"Anna Autorin","mail":"anna@firma.de","project":"kern","roles":["pub_author"]}')
|
||||
BERT=$(person plattform '{"name":"Bert Prüfer","mail":"bert@firma.de","roles":["pub_reviewer"]}')
|
||||
CARLA=$(person plattform '{"name":"Carla Admin","mail":"carla@firma.de","roles":["pub_reviewer","tenant_admin"]}')
|
||||
SHOP_ADMIN=$(key shop '{"kind":"tenant","name":"Shop-Admin"}')
|
||||
SHOP_CI=$(key shop '{"kind":"project","name":"Shop CI","project":"api"}')
|
||||
SHOP_INT=$(key shop '{"kind":"integration","name":"Softwareverteilung"}')
|
||||
|
||||
publish() {
|
||||
local zip="$DEMO/package.zip"
|
||||
rm -f "$zip"; (cd "$ROOT/contract/examples/packages/$1" && zip -qr "$zip" .)
|
||||
curl -fsS -H "Authorization: Bearer $ANNA" -H "Content-Type: application/zip" --data-binary "@$zip" "$URL/api/v1/drafts" >/dev/null
|
||||
local base="$URL/api/v1/packages/${1%/*}/versions/${1#*/}"
|
||||
api -H "Authorization: Bearer $ANNA" -X POST "$base/submit" >/dev/null
|
||||
[[ "${2:-}" == "open" ]] || api -H "Authorization: Bearer $BERT" "$base/approve" -d '{"comment":"Demo"}' >/dev/null
|
||||
}
|
||||
for p in pub-munin-nutzen/1.4.0 pub-hausregeln/1.0.0 pub-munin-anbindung/1.0.0 pub-release-notiz/1.0.0 pub-code-review/1.0.0; do
|
||||
publish "$p"
|
||||
done
|
||||
publish pub-munin-nutzen/1.5.0 open # liegt bei den Prüfungen
|
||||
curl -fsS "${ADMIN[@]}" -X PUT -H "Content-Type: application/json" "$URL/api/v1/admin/mandatory/pub-munin-anbindung" -d '{"tenant":null}'
|
||||
api -H "Authorization: Bearer $SHOP_CI" "$URL/api/v1/usage" \
|
||||
-d '{"repo":"git.intern/shop/api","tools":{"claude":"2.1.302","opencode":"1.15.4"},"installed":{"pub-munin-nutzen":"1.4.0","pub-hausregeln":"1.0.0"}}' >/dev/null
|
||||
|
||||
cat > "$KEYS" <<EOF
|
||||
Hugin-Demo — Keys zum Anmelden unter $URL
|
||||
(nur für diese lokale Demo; .demo/ löschen oder scripts/demo.sh --neu)
|
||||
|
||||
Plattform-Admin (alles, System) $HUGIN_ADMIN_TOKEN
|
||||
Anna Autorin (pub_author, Person) $ANNA
|
||||
Bert Prüfer (pub_reviewer, Person) $BERT
|
||||
Carla Admin (Prüferin + Admin, Person) $CARLA
|
||||
Shop-Admin (Tenant shop) $SHOP_ADMIN
|
||||
Shop CI (Projekt shop/api, lesen) $SHOP_CI
|
||||
Softwareverteilung (Integration shop) $SHOP_INT
|
||||
EOF
|
||||
kill $SERVER 2>/dev/null; wait $SERVER 2>/dev/null || true
|
||||
trap - EXIT
|
||||
fi
|
||||
|
||||
cat "$KEYS"
|
||||
echo
|
||||
echo "Weboberfläche: $URL API-Doku: $URL/api/docs MCP: $URL/mcp"
|
||||
echo "Beenden mit Strg+C."
|
||||
cd "$ROOT/server" && exec uv run -q hugin-server run --host "${HOST:-127.0.0.1}" --port "$PORT"
|
||||
125
scripts/integration-test.ps1
Normal file
125
scripts/integration-test.ps1
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
<#
|
||||
.SYNOPSIS
|
||||
Prüft hugin-install.ps1 unter Windows PowerShell 5.1 gegen einen laufenden Hugin.
|
||||
|
||||
.DESCRIPTION
|
||||
Gegenstück zu scripts/integration-test.sh für Windows. Braucht einen Hugin mit
|
||||
den Beispielpaketen, z. B. scripts/demo.sh auf einem anderen Rechner:
|
||||
|
||||
HOST=0.0.0.0 scripts/demo.sh # auf dem Mac/Linux-Rechner
|
||||
|
||||
Dann unter Windows (PowerShell 5.1, nicht pwsh):
|
||||
|
||||
powershell -ExecutionPolicy Bypass -File integration-test.ps1 `
|
||||
-Server http://<rechner>:8080 -Key <Key "Softwareverteilung" aus .demo/keys.txt>
|
||||
|
||||
Ändert nichts außerhalb eines Temp-Ordners. Rückgabewert 0, wenn alles passt.
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Server,
|
||||
[Parameter(Mandatory = $true)][string]$Key,
|
||||
# Nur zum Vorabtest auf anderen Systemen, z. B. -PowerShell pwsh; unter Windows weglassen
|
||||
[string]$PowerShell = 'powershell.exe'
|
||||
)
|
||||
|
||||
Set-StrictMode -Version 2
|
||||
$ErrorActionPreference = 'Stop'
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
$Utf8 = New-Object System.Text.UTF8Encoding($false)
|
||||
$failures = 0
|
||||
|
||||
function Check([string]$Name, [bool]$Ok, [string]$Info = '') {
|
||||
if ($Ok) { Write-Host "OK $Name" } else { Write-Host "FEHLER $Name $Info" -ForegroundColor Red; $script:failures++ }
|
||||
}
|
||||
|
||||
Write-Host "PowerShell $($PSVersionTable.PSVersion) ($($PSVersionTable.PSEdition))"
|
||||
if ($PSVersionTable.PSVersion.Major -ne 5) { Write-Warning 'Dieser Test ist für Windows PowerShell 5.1 gedacht.' }
|
||||
|
||||
$work = Join-Path ([IO.Path]::GetTempPath()) ("hugin-test-" + [Guid]::NewGuid().ToString('N').Substring(0, 8))
|
||||
$repo = Join-Path $work 'repo'
|
||||
New-Item -ItemType Directory -Path $repo -Force | Out-Null
|
||||
try {
|
||||
# Skript vom Server laden, wie eine Softwareverteilung es tun würde
|
||||
$script = Join-Path $work 'hugin-install.ps1'
|
||||
Invoke-WebRequest -UseBasicParsing -Uri ($Server.TrimEnd('/') + '/api/v1/delivery/client.ps1') `
|
||||
-Headers @{ Authorization = "Bearer $Key" } -OutFile $script
|
||||
$raw = [IO.File]::ReadAllBytes($script)
|
||||
Check 'client.ps1 mit BOM (für PowerShell 5.1)' ($raw[0] -eq 0xEF -and $raw[1] -eq 0xBB -and $raw[2] -eq 0xBF)
|
||||
|
||||
[IO.File]::WriteAllText((Join-Path $repo 'CLAUDE.md'), "# Werk`r`n`r`nUnser Projekt.`r`n", $Utf8)
|
||||
[IO.File]::WriteAllText((Join-Path $repo 'opencode.json'), "{`n `"model`": `"anthropic/claude-sonnet-5-5`"`n}`n", $Utf8)
|
||||
|
||||
function Run([string[]]$More) {
|
||||
$output = & $PowerShell -NoProfile -ExecutionPolicy Bypass -File $script -Server $Server -Key $Key `
|
||||
-Repo $repo -Tools 'claude=2.1.302,opencode=1.19.3' -Client 'windows-test' @More 2>&1 | Out-String
|
||||
return @{ Text = $output; Rc = $LASTEXITCODE }
|
||||
}
|
||||
|
||||
$first = Run @('-Packages', 'pub-munin-nutzen,pub-hausregeln,pub-code-review')
|
||||
Check 'Erstinstallation' ($first.Rc -eq 0) $first.Text
|
||||
foreach ($rel in '.claude\skills\pub-munin-nutzen\SKILL.md', '.claude\agents\pub-code-review.md',
|
||||
'.opencode\agents\pub-code-review.md', '.hugin\rules\pub-hausregeln.md', '.mcp.json', 'hugin.lock.json') {
|
||||
Check "Datei $rel" (Test-Path -LiteralPath (Join-Path $repo $rel))
|
||||
}
|
||||
$skill = [IO.File]::ReadAllBytes((Join-Path $repo '.claude\skills\pub-munin-nutzen\SKILL.md'))
|
||||
Check 'SKILL.md ohne BOM' (-not ($skill[0] -eq 0xEF -and $skill[1] -eq 0xBB))
|
||||
Check 'SKILL.md mit LF, ohne CRLF' (-not $Utf8.GetString($skill).Contains("`r`n"))
|
||||
$claude = [IO.File]::ReadAllText((Join-Path $repo 'CLAUDE.md'), $Utf8)
|
||||
Check 'CLAUDE.md behält Inhalt und CRLF' ($claude.StartsWith("# Werk`r`n`r`nUnser Projekt.`r`n`r`n<!-- hugin:begin") -and -not ($claude -replace "`r`n", '').Contains("`n"))
|
||||
foreach ($json in 'opencode.json', '.mcp.json', 'hugin.lock.json') {
|
||||
$text = [IO.File]::ReadAllText((Join-Path $repo $json), $Utf8)
|
||||
$valid = $true; try { $null = $text | ConvertFrom-Json } catch { $valid = $false }
|
||||
Check "$json gültiges JSON ohne BOM" ($valid -and -not $text.StartsWith([char]0xFEFF))
|
||||
}
|
||||
$oc = [IO.File]::ReadAllText((Join-Path $repo 'opencode.json'), $Utf8) | ConvertFrom-Json
|
||||
Check 'opencode.json: eigenes Modell bleibt, MCP und instructions ergänzt' (
|
||||
$oc.model -eq 'anthropic/claude-sonnet-5-5' -and $oc.mcp.munin.type -eq 'remote' -and @($oc.instructions) -contains '.hugin/rules/*.md')
|
||||
# Eine Liste mit einem Eintrag muss eine Liste bleiben (Eigenheit von ConvertTo-Json in 5.1)
|
||||
Check 'instructions bleibt eine Liste' ([IO.File]::ReadAllText((Join-Path $repo 'opencode.json'), $Utf8) -match '"instructions":\s*\[')
|
||||
|
||||
$second = Run @('-Packages', 'pub-munin-nutzen,pub-hausregeln,pub-code-review')
|
||||
Check 'Zweiter Lauf ändert nichts' ($second.Rc -eq 0 -and $second.Text -match 'Dateien: 0 schreiben, 0 löschen') $second.Text
|
||||
|
||||
$dryRun = Run @('-Packages', 'pub-munin-nutzen', '-DryRun')
|
||||
Check 'Trockenlauf schreibt nichts' ($dryRun.Text -match 'Trockenlauf' -and (Test-Path (Join-Path $repo '.claude\agents\pub-code-review.md')))
|
||||
|
||||
$unsubscribed = Run @('-Packages', 'pub-munin-nutzen')
|
||||
Check 'Abbestellen räumt auf' ($unsubscribed.Rc -eq 0 -and -not (Test-Path (Join-Path $repo '.claude\agents\pub-code-review.md')) -and
|
||||
-not ([IO.File]::ReadAllText((Join-Path $repo 'CLAUDE.md'), $Utf8).Contains('hugin:begin'))) $unsubscribed.Text
|
||||
Check 'CLAUDE.md wieder wie vorher' ([IO.File]::ReadAllText((Join-Path $repo 'CLAUDE.md'), $Utf8) -eq "# Werk`r`n`r`nUnser Projekt.`r`n")
|
||||
|
||||
[IO.File]::AppendAllText((Join-Path $repo '.claude\skills\pub-munin-nutzen\SKILL.md'), "von Hand`n", $Utf8)
|
||||
$manual = Run @('-Packages', 'pub-munin-nutzen,pub-hausregeln')
|
||||
Check 'Handänderung wird erkannt und nichts geschrieben' ($manual.Rc -ne 0 -and $manual.Text -match 'von Hand' -and
|
||||
-not (Test-Path (Join-Path $repo '.hugin\rules\pub-hausregeln.md'))) $manual.Text
|
||||
$overwritten = Run @('-Packages', 'pub-munin-nutzen,pub-hausregeln', '-Overwrite')
|
||||
Check '-Overwrite setzt zurück' ($overwritten.Rc -eq 0 -and (Test-Path (Join-Path $repo '.hugin\rules\pub-hausregeln.md')))
|
||||
|
||||
$repo2 = Join-Path $work 'repo2'; New-Item -ItemType Directory -Path $repo2 | Out-Null
|
||||
[IO.File]::WriteAllText((Join-Path $repo2 'opencode.json'), "{`n // eigenes`n `"model`": `"x`"`n}`n", $Utf8)
|
||||
$jsonc = & $PowerShell -NoProfile -ExecutionPolicy Bypass -File $script -Server $Server -Key $Key -Repo $repo2 `
|
||||
-Tools 'opencode=1.19.3' -Packages pub-hausregeln 2>&1 | Out-String
|
||||
Check 'JSON mit Kommentaren bleibt unangetastet' ($LASTEXITCODE -ne 0 -and $jsonc -match 'Kommentare' -and
|
||||
[IO.File]::ReadAllText((Join-Path $repo2 'opencode.json'), $Utf8).Contains('// eigenes'))
|
||||
|
||||
# Eine Junction im Repo darf nicht nach draußen führen (z. B. bei Ausführung als Admin)
|
||||
$repo3 = Join-Path $work 'repo3'; $outside = Join-Path $work 'outside'
|
||||
New-Item -ItemType Directory -Path $repo3, $outside | Out-Null
|
||||
if ($PSVersionTable.PSEdition -eq 'Desktop' -or $IsWindows) {
|
||||
New-Item -ItemType Junction -Path (Join-Path $repo3 '.opencode') -Target $outside | Out-Null
|
||||
} else {
|
||||
New-Item -ItemType SymbolicLink -Path (Join-Path $repo3 '.opencode') -Target $outside | Out-Null
|
||||
}
|
||||
$link = & $PowerShell -NoProfile -ExecutionPolicy Bypass -File $script -Server $Server -Key $Key -Repo $repo3 `
|
||||
-Tools 'opencode=1.19.3' -Packages pub-code-review 2>&1 | Out-String
|
||||
Check 'Junction nach draußen wird abgelehnt' ($LASTEXITCODE -ne 0 -and $link -match 'Junction' -and
|
||||
@(Get-ChildItem -LiteralPath $outside -Force).Count -eq 0) $link
|
||||
# Junction vor dem Aufräumen lösen, damit Remove-Item nicht dem Link folgt
|
||||
(Get-Item -LiteralPath (Join-Path $repo3 '.opencode') -Force).Delete()
|
||||
} finally {
|
||||
Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
if ($failures -eq 0) { Write-Host "`nAlles ok." -ForegroundColor Green; exit 0 }
|
||||
Write-Host "`n$failures Prüfung(en) fehlgeschlagen." -ForegroundColor Red
|
||||
exit 1
|
||||
137
scripts/integration-test.sh
Executable file
137
scripts/integration-test.sh
Executable file
|
|
@ -0,0 +1,137 @@
|
|||
#!/usr/bin/env bash
|
||||
# Auslieferung für Integrationen (→ KONZEPT.md 14.7) Ende zu Ende:
|
||||
# Server starten, Integrations-Key anlegen, Pakete veröffentlichen und
|
||||
# hugin-install.ps1 gegen ein Test-Repo laufen lassen.
|
||||
#
|
||||
# PWSH=/pfad/zu/pwsh scripts/integration-test.sh
|
||||
#
|
||||
# Hinweis: Läuft hier mit PowerShell 7. Ziel ist Windows PowerShell 5.1; dort
|
||||
# einmal von Hand prüfen (siehe README, „Integration testen“).
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
PWSH="${PWSH:-pwsh}"
|
||||
WORK="$(mktemp -d)"
|
||||
PORT="${PORT:-8766}"
|
||||
URL="http://127.0.0.1:$PORT"
|
||||
export HUGIN_DATABASE_URL="sqlite:///$WORK/hugin.db"
|
||||
export HUGIN_ADMIN_TOKEN="integration-admin"
|
||||
ADMIN=(-H "Authorization: Bearer $HUGIN_ADMIN_TOKEN")
|
||||
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
[[ -n "${SERVER:-}" ]] && kill "$SERVER" 2>/dev/null && wait "$SERVER" 2>/dev/null || true
|
||||
if [[ -z "${KEEP:-}" ]]; then rm -rf "$WORK"; else echo "Arbeitsordner: $WORK"; fi
|
||||
exit $rc
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
api() { curl -fsS -H "Content-Type: application/json" "$@"; }
|
||||
key() { python3 -c 'import json,sys; print(json.load(sys.stdin)["key"])'; }
|
||||
|
||||
(cd "$ROOT/server" && uv run -q hugin-server migrate)
|
||||
(cd "$ROOT/server" && exec uv run -q hugin-server run --host 127.0.0.1 --port "$PORT") >"$WORK/server.log" 2>&1 &
|
||||
SERVER=$!
|
||||
for _ in $(seq 50); do curl -fsS "$URL/readyz" >/dev/null 2>&1 && break; sleep 0.2; done
|
||||
|
||||
api "${ADMIN[@]}" "$URL/api/v1/admin/tenants" -d '{"slug":"plattform","name":"Plattform"}' >/dev/null
|
||||
api "${ADMIN[@]}" "$URL/api/v1/admin/tenants/plattform/projects" -d '{"slug":"kern","name":"Kern"}' >/dev/null
|
||||
api "${ADMIN[@]}" "$URL/api/v1/admin/tenants" -d '{"slug":"werk","name":"Werk"}' >/dev/null
|
||||
AUTHOR=$(api "${ADMIN[@]}" "$URL/api/v1/admin/tenants/plattform/keys" -d '{"kind":"project","name":"autor","project":"kern","roles":["pub_author"]}' | key)
|
||||
REVIEWER=$(api "${ADMIN[@]}" "$URL/api/v1/admin/tenants/plattform/keys" -d '{"kind":"project","name":"pruefer","project":"kern","roles":["pub_reviewer"]}' | key)
|
||||
INTEGRATION=$(api "${ADMIN[@]}" "$URL/api/v1/admin/tenants/werk/keys" -d '{"kind":"integration","name":"Softwareverteilung"}' | key)
|
||||
|
||||
publish() {
|
||||
local zip="$WORK/package.zip"
|
||||
rm -f "$zip"; (cd "$ROOT/contract/examples/packages/$1" && zip -qr "$zip" .)
|
||||
curl -fsS -H "Authorization: Bearer $AUTHOR" -H "Content-Type: application/zip" --data-binary "@$zip" "$URL/api/v1/drafts" >/dev/null
|
||||
local base="$URL/api/v1/packages/${1%/*}/versions/${1#*/}"
|
||||
api -H "Authorization: Bearer $AUTHOR" -X POST "$base/submit" >/dev/null
|
||||
api -H "Authorization: Bearer $REVIEWER" "$base/approve" -d '{}' >/dev/null
|
||||
}
|
||||
for p in pub-munin-nutzen/1.4.0 pub-hausregeln/1.0.0 pub-munin-anbindung/1.0.0 pub-code-review/1.0.0; do publish "$p"; done
|
||||
curl -fsS "${ADMIN[@]}" -X PUT -H "Content-Type: application/json" "$URL/api/v1/admin/mandatory/pub-munin-anbindung" -d '{"tenant":null}'
|
||||
|
||||
echo "== Status und Übersicht"
|
||||
curl -fsS -H "Authorization: Bearer $INTEGRATION" "$URL/api/v1/delivery/status"; echo
|
||||
curl -fsS -H "Authorization: Bearer $INTEGRATION" "$URL/api/v1/delivery/packages" | python3 -c 'import json,sys; print([(p["id"], p["channels"]) for p in json.load(sys.stdin)])'
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $AUTHOR" "$URL/api/v1/delivery/status")
|
||||
[[ $code == 403 ]] || { echo "Projekt-Key hätte abgelehnt werden müssen ($code)"; exit 1; }
|
||||
|
||||
echo "== Skript laden und ausführen"
|
||||
curl -fsS -H "Authorization: Bearer $INTEGRATION" "$URL/api/v1/delivery/client.ps1" -o "$WORK/hugin-install.ps1"
|
||||
REPO="$WORK/repo"; mkdir -p "$REPO"
|
||||
printf '# Werk\r\n\r\nUnser Projekt.\r\n' > "$REPO/CLAUDE.md"
|
||||
printf '{\n "model": "anthropic/claude-sonnet-5-5"\n}\n' > "$REPO/opencode.json"
|
||||
run() {
|
||||
HUGIN_KEY="$INTEGRATION" "$PWSH" -NoProfile -NonInteractive -File "$WORK/hugin-install.ps1" \
|
||||
-Server "$URL" -Repo "$REPO" -Tools 'claude=2.1.302,opencode=1.19.3' -Client werk-pc-01 "$@"
|
||||
}
|
||||
run -Packages pub-munin-nutzen,pub-hausregeln,pub-code-review
|
||||
|
||||
check() { grep -q -- "$2" "$REPO/$1" || { echo "$1 enthält nicht: $2"; cat "$REPO/$1"; exit 1; }; }
|
||||
check .claude/skills/pub-munin-nutzen/SKILL.md "pub-munin-nutzen 1.4.0"
|
||||
check .claude/agents/pub-code-review.md "tools: Read, Grep, Glob"
|
||||
check .opencode/agents/pub-code-review.md "webfetch: deny"
|
||||
check CLAUDE.md "@.hugin/rules/pub-hausregeln.md"
|
||||
check .mcp.json '"type": *"http"'
|
||||
check opencode.json '.hugin/rules/\*.md'
|
||||
python3 - "$REPO" <<'PY'
|
||||
import json, sys, pathlib
|
||||
repo = pathlib.Path(sys.argv[1])
|
||||
claude = (repo / "CLAUDE.md").read_bytes()
|
||||
assert claude.startswith(b"# Werk\r\n\r\nUnser Projekt.\r\n\r\n<!-- hugin:begin"), claude[:80]
|
||||
assert b"\n" not in claude.replace(b"\r\n", b""), "Zeilenenden gemischt"
|
||||
for f in ["opencode.json", ".mcp.json", "hugin.lock.json"]:
|
||||
roh = (repo / f).read_bytes()
|
||||
assert not roh.startswith(b"\xef\xbb\xbf"), f + " mit BOM"
|
||||
json.loads(roh)
|
||||
oc = json.loads((repo / "opencode.json").read_text())
|
||||
assert oc["model"] == "anthropic/claude-sonnet-5-5" and oc["mcp"]["munin"]["type"] == "remote", oc
|
||||
skill = (repo / ".claude/skills/pub-munin-nutzen/SKILL.md").read_bytes()
|
||||
assert b"\r\n" not in skill and not skill.startswith(b"\xef\xbb\xbf")
|
||||
print("Inhalte ok")
|
||||
PY
|
||||
|
||||
echo "== Zweiter Lauf ändert nichts"
|
||||
run -Packages pub-munin-nutzen,pub-hausregeln,pub-code-review | tee "$WORK/lauf2.txt"
|
||||
grep -q "Dateien: 0 schreiben, 0 löschen" "$WORK/lauf2.txt" || { echo "zweiter Lauf hätte nichts ändern dürfen"; exit 1; }
|
||||
|
||||
echo "== Aktualisierung und Abbestellen"
|
||||
publish pub-munin-nutzen/1.5.0
|
||||
run -Packages pub-munin-nutzen
|
||||
check .claude/skills/pub-munin-nutzen/SKILL.md "pub-munin-nutzen 1.5.0"
|
||||
[[ ! -e "$REPO/.claude/skills/pub-munin-nutzen/referenz" ]] || { echo "referenz/ nicht entfernt"; exit 1; }
|
||||
[[ ! -e "$REPO/.claude/agents/pub-code-review.md" ]] || { echo "Subagent nicht entfernt"; exit 1; }
|
||||
grep -q "hugin:begin" "$REPO/CLAUDE.md" && { echo "Block nicht entfernt"; exit 1; }
|
||||
cmp -s <(printf '# Werk\r\n\r\nUnser Projekt.\r\n') "$REPO/CLAUDE.md" || { echo "CLAUDE.md nicht wie vorher"; exit 1; }
|
||||
python3 -c "import json,sys; oc=json.load(open('$REPO/opencode.json')); assert 'instructions' not in oc and 'munin' in oc['mcp'], oc"
|
||||
|
||||
echo "== Handänderung wird erkannt"
|
||||
echo "von Hand" >> "$REPO/.claude/skills/pub-munin-nutzen/SKILL.md"
|
||||
publish pub-hausregeln/1.0.0 2>/dev/null || true
|
||||
set +e; run -Packages pub-munin-nutzen,pub-hausregeln > "$WORK/lauf4.txt" 2>&1; RC=$?; set -e
|
||||
[[ $RC -ne 0 ]] && grep -q "von Hand geändert" "$WORK/lauf4.txt" || { cat "$WORK/lauf4.txt"; echo "Handänderung nicht erkannt"; exit 1; }
|
||||
[[ ! -e "$REPO/.hugin/rules/pub-hausregeln.md" ]] || { echo "trotz Befund geschrieben"; exit 1; }
|
||||
run -Packages pub-munin-nutzen,pub-hausregeln -Overwrite >/dev/null
|
||||
check .hugin/rules/pub-hausregeln.md "Hausregeln"
|
||||
|
||||
echo "== JSON mit Kommentaren wird nicht angefasst"
|
||||
REPO2="$WORK/repo2"; mkdir -p "$REPO2"
|
||||
printf '{\n // eigenes\n "model": "x"\n}\n' > "$REPO2/opencode.json"
|
||||
set +e; HUGIN_KEY="$INTEGRATION" "$PWSH" -NoProfile -NonInteractive -File "$WORK/hugin-install.ps1" \
|
||||
-Server "$URL" -Repo "$REPO2" -Tools 'opencode=1.19.3' -Packages pub-hausregeln > "$WORK/lauf5.txt" 2>&1; RC=$?; set -e
|
||||
[[ $RC -ne 0 ]] && grep -q "Kommentare" "$WORK/lauf5.txt" || { cat "$WORK/lauf5.txt"; echo "JSONC nicht erkannt"; exit 1; }
|
||||
grep -q "// eigenes" "$REPO2/opencode.json" || { echo "opencode.json verändert"; exit 1; }
|
||||
|
||||
echo "== Kein Schreiben über Links nach draußen"
|
||||
REPO3="$WORK/repo3"; OUTSIDE="$WORK/draussen"; mkdir -p "$REPO3" "$OUTSIDE"
|
||||
ln -s "$OUTSIDE" "$REPO3/.opencode"
|
||||
set +e; HUGIN_KEY="$INTEGRATION" "$PWSH" -NoProfile -NonInteractive -File "$WORK/hugin-install.ps1" \
|
||||
-Server "$URL" -Repo "$REPO3" -Tools 'opencode=1.19.3' -Packages pub-code-review > "$WORK/lauf6.txt" 2>&1; RC=$?; set -e
|
||||
[[ $RC -ne 0 ]] && grep -q "Link oder eine Junction" "$WORK/lauf6.txt" || { cat "$WORK/lauf6.txt"; echo "Link nicht erkannt"; exit 1; }
|
||||
[[ -z "$(ls -A "$OUTSIDE")" ]] || { echo "außerhalb des Repos geschrieben"; ls -R "$OUTSIDE"; exit 1; }
|
||||
|
||||
echo "== Nutzung"
|
||||
sqlite3 "$WORK/hugin.db" "select repo, package_id, version, tools from hugin_usage order by package_id" 2>/dev/null || true
|
||||
echo "Integrationstest ok."
|
||||
42
scripts/postgres-test.sh
Executable file
42
scripts/postgres-test.sh
Executable file
|
|
@ -0,0 +1,42 @@
|
|||
#!/usr/bin/env bash
|
||||
# Server-Tests gegen PostgreSQL in Docker (→ KONZEPT.md 14.2: SQLite oder PostgreSQL).
|
||||
#
|
||||
# scripts/postgres-test.sh # ganze Testsuite
|
||||
# scripts/postgres-test.sh -k bundle # weitere Argumente gehen an pytest
|
||||
# POSTGRES_IMAGE=postgres:16.10 scripts/postgres-test.sh
|
||||
# scripts/postgres-test.sh --compose # die dauerhafte PostgreSQL aus docker-compose.yml nutzen
|
||||
#
|
||||
# Startet einen Wegwerf-Container (Daten im Speicher), führt Migrationen und
|
||||
# Tests aus, je Test eine eigene Datenbank, und entfernt den Container danach.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
if [[ "${1:-}" == "--compose" ]]; then
|
||||
shift
|
||||
(cd "$ROOT" && docker compose up -d --wait postgres >/dev/null)
|
||||
echo "PostgreSQL aus docker-compose.yml (127.0.0.1:55432)"
|
||||
cd "$ROOT/server"
|
||||
exec env HUGIN_TEST_POSTGRES="postgresql+psycopg://hugin:hugin@127.0.0.1:55432/postgres" \
|
||||
uv run pytest -q -p no:warnings "$@"
|
||||
fi
|
||||
|
||||
IMAGE="${POSTGRES_IMAGE:-postgres:17.6}"
|
||||
NAME="hugin-postgres-test-$$"
|
||||
PASSWORD="hugin-test"
|
||||
|
||||
docker run -d --rm --name "$NAME" -e POSTGRES_PASSWORD="$PASSWORD" -e POSTGRES_USER=hugin \
|
||||
--tmpfs /var/lib/postgresql/data -p 127.0.0.1::5432 "$IMAGE" >/dev/null
|
||||
trap 'rc=$?; docker rm -f "$NAME" >/dev/null 2>&1 || true; exit $rc' EXIT
|
||||
PORT="$(docker port "$NAME" 5432/tcp | head -1 | sed 's/.*://')"
|
||||
|
||||
for _ in $(seq 60); do
|
||||
docker exec "$NAME" pg_isready -U hugin -q 2>/dev/null && break
|
||||
sleep 0.5
|
||||
done
|
||||
docker exec "$NAME" pg_isready -U hugin -q || { echo "PostgreSQL startet nicht"; exit 1; }
|
||||
echo "PostgreSQL $(docker exec "$NAME" postgres --version | awk '{print $3}') auf Port $PORT"
|
||||
|
||||
cd "$ROOT/server"
|
||||
HUGIN_TEST_POSTGRES="postgresql+psycopg://hugin:$PASSWORD@127.0.0.1:$PORT/postgres" \
|
||||
uv run pytest -q -p no:warnings "$@"
|
||||
124
scripts/smoke-test-local.sh
Executable file
124
scripts/smoke-test-local.sh
Executable file
|
|
@ -0,0 +1,124 @@
|
|||
#!/usr/bin/env bash
|
||||
# Rauchtest ohne echte Werkzeuge: Server starten, Beispielpakete veröffentlichen,
|
||||
# mit dem CLI in ein Test-Repo abgleichen (→ KONZEPT.md 17).
|
||||
#
|
||||
# scripts/smoke-test-local.sh # Arbeitsordner wird danach gelöscht
|
||||
# KEEP=1 scripts/smoke-test-local.sh # Arbeitsordner bleibt zum Ansehen
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
WORK="$(mktemp -d)"
|
||||
PORT="${PORT:-8765}"
|
||||
URL="http://127.0.0.1:$PORT"
|
||||
export HUGIN_DATABASE_URL="sqlite:///$WORK/hugin.db"
|
||||
export HUGIN_ADMIN_TOKEN="rauchtest-admin"
|
||||
export HUGIN_CLI_MIN_VERSION="0.1.0"
|
||||
export HUGIN_DOWNLOAD_DIR="$WORK/downloads"
|
||||
mkdir -p "$HUGIN_DOWNLOAD_DIR/0.2.0" && touch "$HUGIN_DOWNLOAD_DIR/0.2.0/hugin-linux-amd64"
|
||||
ADMIN=(-H "Authorization: Bearer $HUGIN_ADMIN_TOKEN")
|
||||
|
||||
cleanup() {
|
||||
[[ -n "${SERVER:-}" ]] && kill "$SERVER" 2>/dev/null || true
|
||||
if [[ -z "${KEEP:-}" ]]; then rm -rf "$WORK"; else echo "Arbeitsordner: $WORK"; fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
api() { curl -fsS -H "Content-Type: application/json" "$@"; }
|
||||
key() { python3 -c 'import json,sys; print(json.load(sys.stdin)["key"])'; }
|
||||
|
||||
echo "== Server"
|
||||
(cd "$ROOT/server" && uv run -q hugin-server migrate)
|
||||
(cd "$ROOT/server" && exec uv run -q hugin-server run --host 127.0.0.1 --port "$PORT") >"$WORK/server.log" 2>&1 &
|
||||
SERVER=$!
|
||||
for _ in $(seq 50); do curl -fsS "$URL/readyz" >/dev/null 2>&1 && break; sleep 0.2; done
|
||||
curl -fsS "$URL/readyz"; echo
|
||||
|
||||
echo "== Tenants, Projekte, Keys"
|
||||
api "${ADMIN[@]}" "$URL/api/v1/admin/tenants" -d '{"slug":"plattform","name":"Plattform"}' >/dev/null
|
||||
api "${ADMIN[@]}" "$URL/api/v1/admin/tenants/plattform/projects" -d '{"slug":"kern","name":"Kern"}' >/dev/null
|
||||
api "${ADMIN[@]}" "$URL/api/v1/admin/tenants" -d '{"slug":"shop","name":"Shop"}' >/dev/null
|
||||
api "${ADMIN[@]}" "$URL/api/v1/admin/tenants/shop/projects" -d '{"slug":"web","name":"Web"}' >/dev/null
|
||||
AUTHOR=$(api "${ADMIN[@]}" "$URL/api/v1/admin/tenants/plattform/keys" -d '{"kind":"project","name":"autor","project":"kern","roles":["pub_author"]}' | key)
|
||||
REVIEWER=$(api "${ADMIN[@]}" "$URL/api/v1/admin/tenants/plattform/keys" -d '{"kind":"project","name":"pruefer","project":"kern","roles":["pub_reviewer"]}' | key)
|
||||
READER=$(api "${ADMIN[@]}" "$URL/api/v1/admin/tenants/shop/keys" -d '{"kind":"project","name":"web","project":"web"}' | key)
|
||||
|
||||
publish() {
|
||||
local folder="$ROOT/contract/examples/packages/$1" zip="$WORK/package.zip"
|
||||
rm -f "$zip"; (cd "$folder" && zip -qr "$zip" .)
|
||||
curl -fsS -H "Authorization: Bearer $AUTHOR" -H "Content-Type: application/zip" --data-binary "@$zip" "$URL/api/v1/drafts" >/dev/null
|
||||
local base="$URL/api/v1/packages/${1%/*}/versions/${1#*/}"
|
||||
api -H "Authorization: Bearer $AUTHOR" -X POST "$base/submit" >/dev/null
|
||||
api -H "Authorization: Bearer $REVIEWER" "$base/approve" -d '{"comment":"Rauchtest"}' >/dev/null
|
||||
echo " veröffentlicht: $1"
|
||||
}
|
||||
|
||||
echo "== Pakete"
|
||||
publish pub-munin-nutzen/1.4.0
|
||||
publish pub-hausregeln/1.0.0
|
||||
publish pub-munin-anbindung/1.0.0
|
||||
curl -fsS "${ADMIN[@]}" -X PUT -H "Content-Type: application/json" "$URL/api/v1/admin/mandatory/pub-munin-anbindung" -d '{"tenant":null}'
|
||||
|
||||
echo "== CLI"
|
||||
(cd "$ROOT/cli" && go build -o "$WORK/hugin" ./cmd/hugin)
|
||||
REPO="$WORK/repo"; mkdir -p "$REPO"; cd "$REPO"; git init -q
|
||||
printf '# Web\n\nUnser Projekt.\n' > CLAUDE.md
|
||||
export HUGIN_KEY="$READER"
|
||||
"$WORK/hugin" init --server "$URL" --project web >/dev/null
|
||||
sed -i.alt 's/^packages: \[\]/packages:\n - pub-munin-nutzen\n - pub-hausregeln/' .hugin.yaml && rm -f .hugin.yaml.alt
|
||||
"$WORK/hugin" sync
|
||||
"$WORK/hugin" check
|
||||
|
||||
echo "== Aktualisierung"
|
||||
publish pub-munin-nutzen/1.5.0
|
||||
set +e; "$WORK/hugin" check; RC=$?; set -e
|
||||
[[ $RC -eq 1 ]] || { echo "check hätte die neue Version melden müssen (rc=$RC)"; exit 1; }
|
||||
"$WORK/hugin" sync
|
||||
"$WORK/hugin" status
|
||||
|
||||
echo "== Autoren: new, validate, try, upload"
|
||||
export HUGIN_SERVER="$URL"
|
||||
cd "$WORK"
|
||||
HUGIN_KEY="$AUTHOR" "$WORK/hugin" new command pub-rauch-befehl
|
||||
cat > pub-rauch-befehl/hugin.yaml <<'YAML'
|
||||
id: pub-rauch-befehl
|
||||
kind: command
|
||||
version: 1.0.0
|
||||
owner: kern
|
||||
scope: public
|
||||
description: Rauchtest für Befehle.
|
||||
arguments: [ziel]
|
||||
YAML
|
||||
printf 'Prüfe {{ziel}} und fasse zusammen. $ARGUMENTS\n' > pub-rauch-befehl/COMMAND.md
|
||||
HUGIN_KEY="$AUTHOR" "$WORK/hugin" validate pub-rauch-befehl
|
||||
cd "$REPO"
|
||||
cp hugin.lock "$WORK/lock-vorher"
|
||||
HUGIN_KEY="$AUTHOR" "$WORK/hugin" try "$WORK/pub-rauch-befehl"
|
||||
grep -q 'Prüfe $ziel' .claude/commands/pub-rauch-befehl.md
|
||||
cmp -s hugin.lock "$WORK/lock-vorher" || { echo "try hat hugin.lock verändert"; exit 1; }
|
||||
"$WORK/hugin" try --remove
|
||||
[[ ! -e .claude/commands/pub-rauch-befehl.md ]] || { echo "try --remove hat nicht aufgeräumt"; exit 1; }
|
||||
cd "$WORK"
|
||||
set +e; printf 'Nimm $1\n' > pub-rauch-befehl/COMMAND.md
|
||||
HUGIN_KEY="$AUTHOR" "$WORK/hugin" upload pub-rauch-befehl; RC=$?; set -e
|
||||
[[ $RC -eq 1 ]] || { echo "Linter hätte \$1 ablehnen müssen (rc=$RC)"; exit 1; }
|
||||
printf 'Prüfe {{ziel}} und fasse zusammen.\n' > pub-rauch-befehl/COMMAND.md
|
||||
HUGIN_KEY="$AUTHOR" "$WORK/hugin" upload --submit pub-rauch-befehl
|
||||
api -H "Authorization: Bearer $REVIEWER" "$URL/api/v1/packages/pub-rauch-befehl/versions/1.0.0/approve" -d '{}' >/dev/null
|
||||
cd "$REPO"
|
||||
sed -i.alt 's/^ - pub-hausregeln$/ - pub-hausregeln\n - pub-rauch-befehl/' .hugin.yaml && rm -f .hugin.yaml.alt
|
||||
"$WORK/hugin" sync
|
||||
grep -q 'Prüfe $ziel' .claude/commands/pub-rauch-befehl.md
|
||||
|
||||
echo "== suche und Versionsprüfung des CLI"
|
||||
"$WORK/hugin" search munin | tee "$WORK/search.txt"
|
||||
grep -q "pub-munin-nutzen" "$WORK/search.txt" || { echo "search findet pub-munin-nutzen nicht"; exit 1; }
|
||||
(cd "$ROOT/cli" && go build -ldflags "-X main.Version=0.0.9" -o "$WORK/hugin-alt" ./cmd/hugin)
|
||||
set +e; "$WORK/hugin-alt" check > "$WORK/check-alt.txt" 2>&1; RC=$?; set -e
|
||||
[[ $RC -eq 1 ]] && grep -q "älter als die nötige Version 0.1.0" "$WORK/check-alt.txt" || { cat "$WORK/check-alt.txt"; echo "altes CLI nicht gemeldet"; exit 1; }
|
||||
(cd "$ROOT/cli" && go build -ldflags "-X main.Version=0.1.0" -o "$WORK/hugin-010" ./cmd/hugin)
|
||||
"$WORK/hugin-010" check > "$WORK/check-010.txt" 2>&1 || true
|
||||
grep -q "hugin 0.2.0 ist verfügbar" "$WORK/check-010.txt" || { cat "$WORK/check-010.txt"; echo "Hinweis auf neue Version fehlt"; exit 1; }
|
||||
|
||||
echo "== Nutzung"
|
||||
sqlite3 "$WORK/hugin.db" "select repo, package_id, version, tools from hugin_usage order by package_id" 2>/dev/null || true
|
||||
echo "Rauchtest ok."
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue