hugin/cli/internal/importer/importer_test.go
2026-10-27 08:33:21 +01:00

332 lines
12 KiB
Go

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")
}
}