diff --git a/internal/ajean/sys_migrate.go b/internal/ajean/sys_migrate.go index d56389d..dfb7d7c 100644 --- a/internal/ajean/sys_migrate.go +++ b/internal/ajean/sys_migrate.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "sync" ) @@ -96,5 +97,86 @@ func migrateHome(target, legacy string) string { return legacy } fmt.Fprintf(os.Stderr, "[ok] dossier de données migré : %s → %s\n", legacy, sibling) + rewriteHomeReferences(legacy, sibling) return sibling } + +// configFilesToRewrite liste les fichiers de configuration susceptibles de +// contenir un chemin ABSOLU vers le dossier de données. Volontairement restreint +// à des fichiers texte, petits et écrits par nous : on ne réécrit pas à l'aveugle +// les 51 000 fichiers d'un dossier de données. +func configFilesToRewrite(home string) []string { + files := []string{ + filepath.Join(home, "config.env"), + filepath.Join(home, "model_dirs.json"), + filepath.Join(home, "mcp.json"), + filepath.Join(home, "webprefs.json"), + } + // Les presets sont des config.env alternatives : ils portent le même BIN. + presets, _ := filepath.Glob(filepath.Join(home, "configs", "*")) + return append(files, presets...) +} + +// rewriteHomeReferences réécrit les chemins absolus pointant vers l'ANCIEN +// dossier de données dans les fichiers de configuration. +// +// Sans ça, la migration casse l'installation qu'elle est censée préserver : +// `config.env` contient typiquement +// +// BIN=C:\ProgramData\jean\backends\llama.cpp\build\bin\Release\llama-server.exe +// +// c'est-à-dire un chemin absolu VERS le dossier qu'on vient de renommer. Le +// dossier a bougé, la ligne pointe dans le vide, et llama-server ne démarre plus. +// Ça concerne tous ceux qui ont fait un `llamacpp install`, donc le cas nominal. +// +// On couvre les deux écritures de séparateur, Windows acceptant indifféremment +// « \ » et « / » dans une même valeur. Best-effort par fichier : un fichier +// illisible est sauté sans compromettre les autres. +func rewriteHomeReferences(oldHome, newHome string) { + variants := [][2]string{{oldHome, newHome}} + if slash := filepath.ToSlash(oldHome); slash != oldHome { + variants = append(variants, [2]string{slash, filepath.ToSlash(newHome)}) + } + for _, path := range configFilesToRewrite(newHome) { + b, err := os.ReadFile(path) + if err != nil { + continue + } + out := string(b) + for _, v := range variants { + out = strings.ReplaceAll(out, v[0], v[1]) + } + if out == string(b) { + continue + } + fi, err := os.Stat(path) + mode := os.FileMode(0o644) + if err == nil { + mode = fi.Mode() + } + if err := os.WriteFile(path, []byte(out), mode); err != nil { + fmt.Fprintf(os.Stderr, "[warn] %s non réécrit (%v) — vérifie les chemins absolus qu'il contient\n", path, err) + continue + } + fmt.Fprintf(os.Stderr, "[ok] chemins mis à jour dans %s\n", filepath.Base(path)) + } + adoptLegacyStateFiles(newHome) +} + +// adoptLegacyStateFiles reprend les fichiers d'état nommés d'après le service +// (jean.pid / jean.log → ajean.pid / ajean.log). Le fichier PID dit si le +// service tourne : ne pas le reprendre reviendrait à croire qu'il est arrêté et +// à en démarrer un second. +func adoptLegacyStateFiles(home string) { + for _, ext := range []string{".pid", ".log"} { + from := filepath.Join(home, legacyServiceName()+ext) + to := filepath.Join(home, "ajean"+ext) + if _, err := os.Stat(to); err == nil { + continue + } + if _, err := os.Stat(from); err != nil { + continue + } + _ = os.Rename(from, to) + } +} diff --git a/internal/ajean/sys_migrate_test.go b/internal/ajean/sys_migrate_test.go index 87f24f4..ec1501f 100644 --- a/internal/ajean/sys_migrate_test.go +++ b/internal/ajean/sys_migrate_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" ) @@ -100,3 +101,74 @@ func TestMigrateHomeFallsBackWhenRenameFails(t *testing.T) { t.Fatalf("l'ancien dossier a été abîmé par une migration ratée: %q (%v)", b, err) } } + +// Le cas qui a casse une vraie machine : config.env contient un chemin ABSOLU +// vers le dossier de donnees (BIN=\backends\...\llama-server.exe). Apres +// migration ce chemin doit suivre, sinon llama-server ne demarre plus — et ca +// concerne tous ceux qui ont fait un `llamacpp install`, donc le cas nominal. +func TestMigrateHomeRewritesAbsolutePaths(t *testing.T) { + root := t.TempDir() + legacy := filepath.Join(root, "jean") + target := filepath.Join(root, "ajean") + mkHome(t, legacy) + if err := os.MkdirAll(filepath.Join(legacy, "configs"), 0o755); err != nil { + t.Fatal(err) + } + + bin := filepath.Join(legacy, "backends", "llama.cpp", "build", "bin", "llama-server") + write := func(rel, body string) { + if err := os.WriteFile(filepath.Join(legacy, rel), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + write("config.env", "BIN="+bin+"\nMODEL=\"x.gguf\"\n") + // Les presets portent le meme BIN et doivent suivre aussi. + write(filepath.Join("configs", "gros.env"), "BIN="+bin+"\n") + // Ecriture avec des slashs : Windows accepte les deux formes. + write("model_dirs.json", `{"dirs":["`+filepath.ToSlash(legacy)+`/models"]}`) + + if got := migrateHome(target, legacy); got != target { + t.Fatalf("migration echouee: %q", got) + } + + for _, rel := range []string{"config.env", filepath.Join("configs", "gros.env"), "model_dirs.json"} { + b, err := os.ReadFile(filepath.Join(target, rel)) + if err != nil { + t.Fatalf("%s illisible: %v", rel, err) + } + if strings.Contains(string(b), legacy) || strings.Contains(string(b), filepath.ToSlash(legacy)) { + t.Errorf("%s reference encore l'ancien dossier:\n%s", rel, b) + } + if !strings.Contains(string(b), target) && !strings.Contains(string(b), filepath.ToSlash(target)) { + t.Errorf("%s ne pointe pas vers le nouveau dossier:\n%s", rel, b) + } + } + + // Le BIN reecrit doit designer un chemin reellement atteignable. + newBin := filepath.Join(target, "backends", "llama.cpp", "build", "bin", "llama-server") + if b, _ := os.ReadFile(filepath.Join(target, "config.env")); !strings.Contains(string(b), newBin) { + t.Errorf("BIN ne pointe pas sur %s:\n%s", newBin, b) + } +} + +// Les fichiers d'etat du service (jean.pid/jean.log) doivent etre repris : le +// PID dit si le service tourne, l'ignorer ferait demarrer un second service. +func TestMigrateHomeAdoptsServiceStateFiles(t *testing.T) { + root := t.TempDir() + legacy := filepath.Join(root, "jean") + target := filepath.Join(root, "ajean") + mkHome(t, legacy) + if err := os.WriteFile(filepath.Join(legacy, "jean.pid"), []byte("4242\n"), 0o644); err != nil { + t.Fatal(err) + } + + migrateHome(target, legacy) + + b, err := os.ReadFile(filepath.Join(target, "ajean.pid")) + if err != nil { + t.Fatalf("ajean.pid absent apres migration: %v", err) + } + if strings.TrimSpace(string(b)) != "4242" { + t.Fatalf("PID perdu: %q", b) + } +} diff --git a/internal/ajean/sys_paths.go b/internal/ajean/sys_paths.go index 1a1264f..2eb99f4 100644 --- a/internal/ajean/sys_paths.go +++ b/internal/ajean/sys_paths.go @@ -16,7 +16,7 @@ import ( // et on l'expose à la fois en ligne de commande (`ajean where`) et dans l'UI. type ajeanPaths struct { - Home string `json:"home"` // JEAN_HOME : racine des données + Home string `json:"home"` // AJEAN_HOME : racine des données Config string `json:"config"` // config.env Exe string `json:"exe"` // binaire en cours d'exécution Installed string `json:"installed"` // binaire installé (peut différer de Exe) @@ -64,7 +64,7 @@ func cmdWhere(args []string) error { p := currentPaths() fmt.Printf("Emplacements AJEAN\n\n") for _, row := range [][2]string{ - {"données (JEAN_HOME)", p.Home}, + {"données (AJEAN_HOME)", p.Home}, {"configuration", p.Config}, {"binaire en cours", p.Exe}, {"binaire installé", p.Installed},