diff --git a/internal/ajean/sys_migrate.go b/internal/ajean/sys_migrate.go index dfb7d7c..73cf727 100644 --- a/internal/ajean/sys_migrate.go +++ b/internal/ajean/sys_migrate.go @@ -129,13 +129,22 @@ func configFilesToRewrite(home string) []string { // 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. +// Trois écritures d'un même chemin doivent être couvertes, sans quoi on croit +// avoir tout réécrit alors qu'il reste des références mortes : +// +// C:\ProgramData\jean forme native, dans config.env +// C:/ProgramData/jean Windows accepte les deux séparateurs dans une valeur +// C:\\ProgramData\\jean forme JSON, où l'antislash est échappé (model_dirs.json, +// mcp.json, webprefs.json sont écrits par json.Marshal) +// +// 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)}) + // Forme JSON : chaque antislash est doublé. + variants = append(variants, [2]string{jsonEscapePath(oldHome), jsonEscapePath(newHome)}) } for _, path := range configFilesToRewrite(newHome) { b, err := os.ReadFile(path) @@ -163,6 +172,18 @@ func rewriteHomeReferences(oldHome, newHome string) { adoptLegacyStateFiles(newHome) } +// jsonEscapePath renvoie l'écriture d'un chemin telle qu'elle apparaît DANS un +// fichier JSON : sous Windows, json.Marshal double chaque antislash. Le +// remplacement littéral doit donc chercher cette forme-là, sinon model_dirs.json +// et mcp.json gardent des chemins morts. +// +// Attention en modifiant : "\\" est UN antislash et "\\\\" en est deux. Une +// version antérieure utilisait des littéraux bruts et remplaçait en fait un +// antislash par lui-même, ce qui laissait passer le remplacement natif et +// produisait un JSON invalide (« invalid character 'U' in string escape code »), +// donc une liste de dossiers de modèles perdue. Couvert par un test. +func jsonEscapePath(p string) string { return strings.ReplaceAll(p, "\\", "\\\\") } + // 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 diff --git a/internal/ajean/sys_migrate_test.go b/internal/ajean/sys_migrate_test.go index ec1501f..da4b885 100644 --- a/internal/ajean/sys_migrate_test.go +++ b/internal/ajean/sys_migrate_test.go @@ -1,6 +1,7 @@ package ajean import ( + "encoding/json" "os" "path/filepath" "runtime" @@ -172,3 +173,46 @@ func TestMigrateHomeAdoptsServiceStateFiles(t *testing.T) { t.Fatalf("PID perdu: %q", b) } } + +// Les fichiers JSON (model_dirs.json, mcp.json, webprefs.json) sont ecrits par +// json.Marshal : sous Windows chaque antislash y est DOUBLE. Chercher la forme +// native ne les trouve donc pas, et on croirait avoir tout reecrit en laissant +// des references mortes. Constate en bac a sable. +func TestMigrateHomeRewritesJSONEscapedPaths(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("l'echappement JSON des separateurs ne concerne que Windows") + } + root := t.TempDir() + legacy := filepath.Join(root, "jean") + target := filepath.Join(root, "ajean") + mkHome(t, legacy) + + esc := jsonEscapePath + body := `{"dirs":["` + esc(filepath.Join(legacy, "models")) + `"]}` + if err := os.WriteFile(filepath.Join(legacy, "model_dirs.json"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + + migrateHome(target, legacy) + + b, err := os.ReadFile(filepath.Join(target, "model_dirs.json")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(b), esc(legacy)) { + t.Errorf("reference JSON a l'ancien dossier non reecrite:\n%s", b) + } + if !strings.Contains(string(b), esc(target)) { + t.Errorf("le JSON ne pointe pas vers le nouveau dossier:\n%s", b) + } + // Le fichier doit rester du JSON valide apres reecriture. + var parsed struct { + Dirs []string `json:"dirs"` + } + if err := json.Unmarshal(b, &parsed); err != nil { + t.Fatalf("JSON casse par la reecriture: %v\n%s", err, b) + } + if len(parsed.Dirs) != 1 || !strings.HasPrefix(parsed.Dirs[0], target) { + t.Fatalf("chemin decode inattendu: %+v", parsed.Dirs) + } +}