hugin/cli/cmd/hugin/sync.go
2026-10-27 08:33:21 +01:00

338 lines
10 KiB
Go

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