338 lines
10 KiB
Go
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)
|
|
}
|