Migration 0.7 automatique dans 'ajean install'

Un seul fichier, migrate_07.go, sans dependance entrante : le supprimer et
retirer les deux appels suffira le jour venu. Declenchee UNIQUEMENT par install
(root, delibere) et non au demarrage, contrairement a ce qui existait en 0.7 et
qui pouvait laisser une machine distante sans service.

Deplace configs/->presets/, MEMORY/->memory/, les .gguf->models/, reprend l'etat
en base, range les anciens fichiers dans avant-0.8/ et desactive les unites jean*
(sans quoi un second llama-server demarrait au boot suivant).

Trois tests : le cas nominal, l'installation neuve, et la machine deja migree a
la main. Ils ont trouve deux bugs reels de casse de fichiers (MEMORY/memory se
confondent sur Windows et macOS).
This commit is contained in:
nathaninline committed 2026-08-07 19:16:55 +02:00
1 parent 35edc631a9
commit 664b13753b
7 files changed
+452 -210

No files matched your search

+13 -26
View File
@@ -305,42 +305,29 @@ CGO_ENABLED=0 go build -o ajean ./cmd/ajean
- `cmd/ajean/` : point d'entrée + ressources Windows (icône, versioninfo). - `cmd/ajean/` : point d'entrée + ressources Windows (icône, versioninfo).
- `internal/ajean/` : tout le code, fichiers préfixés par domaine (`web_*`, `chat_*`, `llm_*`, `backend_*`, `relay_*`, `sys_*`, `mcp_*`) ; carte dans `doc.go`. - `internal/ajean/` : tout le code, fichiers préfixés par domaine (`web_*`, `chat_*`, `llm_*`, `backend_*`, `relay_*`, `sys_*`, `mcp_*`) ; carte dans `doc.go`.
- `internal/ajean/ui/` : interface web embarquée. **`index.html` est généré** : les sources vivent dans `ui/src/`. Pour modifier l'interface, éditer `ui/src/` puis lancer `go generate ./internal/ajean`. - `internal/ajean/ui/` : interface web embarquée. **`index.html` est généré** : les sources vivent dans `ui/src/`. Pour modifier l'interface, éditer `ui/src/` puis lancer `go generate ./internal/ajean`.
- `tools/` : outils hors binaire — `assemble-ui` (génère `index.html`), `gen-icon` (icônes Windows), `migrate-0.7` (reprise d'une installation 0.7.x, voir ci-dessous). - `tools/` : outils hors binaire — `assemble-ui` (génère `index.html`) et `gen-icon` (icônes Windows).
## Migrer depuis la 0.7.x ## Migrer depuis la 0.7.x
La 0.8 ne contient **aucun code de compatibilité** : elle ignore `config.env`, `configs/`, `MEMORY/` et les fichiers d'état de la 0.7. Sur une machine déjà installée, la reprise se fait une fois, à la main. Rien n'est supprimé : les anciens fichiers restent en place, et un retour arrière consiste à réactiver les anciennes unités. Une seule commande :
```bash ```bash
# 0. Sauvegarde (petits fichiers ; les .gguf ne risquent rien, ils ne bougent pas de volume) sudo ajean install
sudo tar czf /root/ajean-avant-0.8.tar.gz -C /etc/ajean --exclude=backends --exclude='*.gguf' --exclude='*.log' .
sudo systemctl stop jean-link jean # anciens noms de services
# 1. Nouvelle arborescence — on COPIE presets et mémoire, on DÉPLACE les modèles
sudo mkdir -p /etc/ajean/{presets,memory,models,bin}
sudo cp -a /etc/ajean/configs/. /etc/ajean/presets/
sudo cp -a /etc/ajean/MEMORY/. /etc/ajean/memory/
sudo mv /etc/ajean/*.gguf /etc/ajean/models/
# 2. Le binaire, puis les réglages dans la base
sudo install -m755 ajean /usr/local/bin/ajean
go run ./tools/migrate-0.7 /etc/ajean # ou le binaire pré-compilé de l'outil
sudo chown -R <utilisateur>:<utilisateur> /etc/ajean
# 3. Les deux services (remplacent jean.service et jean-link.service)
sudo systemctl disable --now jean jean-link
sudo ajean install # écrit ajean-engine + ajean-ui, sudoers, /etc/default
sudo systemctl enable --now ajean-engine ajean-ui
``` ```
`migrate-0.7` reprend la configuration, les préférences de l'interface, la conversation, les clés (API, pilotage, Crawl4AI), le jeton de liaison et l'identifiant machine, les interrupteurs agent/internet/OpenAI, les serveurs MCP et les benchmarks. Il ne touche ni aux presets, ni à la mémoire, ni aux modèles — ce sont des fichiers, ils ont juste changé de dossier. `install` détecte l'ancienne disposition et fait la reprise complète : il arrête et **désactive** les anciens services (`jean`, `jean-link`), déplace `configs/` vers `presets/`, `MEMORY/` vers `memory/` et les `.gguf` vers `models/`, reprend en base la configuration, les préférences, la conversation, les clés, le jeton de liaison, les interrupteurs, les serveurs MCP et les benchmarks — puis installe les deux nouvelles unités.
**`.e2e_key` ne doit pas être supprimé** : l'empreinte de chiffrement de bout en bout confirmée dans le portail en dépend. Il reste à sa place, la migration ne le touche pas. Désactiver les anciennes unités n'est pas un détail : une `jean.service` restée active relancerait un **second** `llama-server` au prochain démarrage, sur le même port et la même VRAM.
Vérification : `ajean where`, `ajean switch` (le preset actif doit être détecté), `ajean test`, puis `journalctl -u ajean-ui -n 20` doit afficher « lien établi ✓ » et l'empreinte E2E inchangée. Ce qui est garanti :
Si `ajean install` écrase une unité que vous aviez réglée à la main (priorité CPU, `CUDA_VISIBLE_DEVICES`, utilisateur dédié), rétablissez vos directives dans `/etc/systemd/system/ajean-engine.service` puis `systemctl daemon-reload`. - **Rien n'est supprimé.** Presets, mémoire et modèles sont *déplacés*, jamais copiés ni effacés. Les petits fichiers d'état sont lus puis rangés dans `avant-0.8/` — supprimez ce dossier quand tout va bien.
- **`.e2e_key` n'est pas touché**, donc l'empreinte de chiffrement confirmée dans le portail reste valable.
- **Une machine déjà migrée à la main** n'est pas écrasée : si `configs/` et `presets/` existent tous les deux, l'ancien est laissé tel quel et signalé.
Après coup : `ajean where`, `ajean switch` (le preset actif doit être détecté) et `ajean test`. Si vous aviez réglé l'unité du moteur à la main (priorité CPU, `CUDA_VISIBLE_DEVICES`, utilisateur dédié), `install` la réécrit : reportez vos directives dans `/etc/systemd/system/ajean-engine.service` puis `systemctl daemon-reload`.
Le code de cette reprise vit dans un fichier unique, `internal/ajean/migrate_07.go`, et sera supprimé d'ici une version ou deux.
## Licence ## Licence
+1 -1
View File
@@ -1,6 +1,6 @@
Le renommage est terminé. Plus rien ne s'appelle jean : ni le binaire, ni les services, ni les variables, ni les dossiers. Et le dossier de données, qui s'était couvert d'une douzaine de petits fichiers d'état, tient désormais dans six dossiers et une base. Le renommage est terminé. Plus rien ne s'appelle jean : ni le binaire, ni les services, ni les variables, ni les dossiers. Et le dossier de données, qui s'était couvert d'une douzaine de petits fichiers d'état, tient désormais dans six dossiers et une base.
**Cette version ne reprend pas les installations précédentes.** Il n'y a aucune migration : ni des dossiers, ni des unités système, ni des réglages. Réinstallez, puis retéléchargez vos modèles ou repointez-les avec *Dossiers de modèles*. **La reprise d'une installation 0.7 tient en une commande : `sudo ajean install`.** Il déplace les dossiers, reprend les réglages en base, désactive les anciens services et installe les nouveaux. Rien n'est supprimé : presets, mémoire et modèles sont déplacés, les anciens fichiers d'état rangés dans `avant-0.8/`. C'est le seul code de compatibilité de la version, isolé dans un fichier prévu pour être supprimé.
## Une base à la place des fichiers d'état ## Une base à la place des fichiers d'état
+274
View File
@@ -0,0 +1,274 @@
package ajean
// migrate_07.go — CODE TEMPORAIRE, à supprimer d'ici une version ou deux.
//
// Reprend une installation 0.7.x au moment de `ajean install`. Tout est ici, et
// rien d'autre n'en dépend : le jour où le parc aura basculé, supprimer ce
// fichier et les deux appels à migrateFrom07() (sys_install_unix.go et
// sys_install_windows.go) suffit. C'est la seule raison d'être de ce fichier —
// la 0.8 ne contient AUCUN autre code de compatibilité.
//
// POURQUOI ICI, ET PAS AU DÉMARRAGE. La version précédente migrait à chaque
// lancement. Sur un parc, ça veut dire une réécriture d'unités systemd à chaque
// boot, et un échec laisse une machine distante sans service et sans terminal
// pour réparer. `install` est le seul bon moment : root, délibéré, et il écrit
// déjà les unités.
//
// CE QU'ON NE DÉTRUIT PAS. Les presets, la mémoire et les modèles sont
// DÉPLACÉS, jamais copiés ni effacés. Les petits fichiers d'état sont lus puis
// rangés dans avant-0.8/ — ils ne servent plus, mais ils sont là si quelque
// chose a mal tourné.
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
)
// legacyUnits : les noms de services qu'a portés le produit avant la 0.8.
//
// Les désactiver n'est pas cosmétique. Une unité `jean.service` restée active
// relance un SECOND llama-server au prochain boot : deux moteurs sur le port
// 8080, la VRAM prise deux fois, et un démarrage qui échoue à moitié sans
// raison apparente.
var legacyUnits = []string{"jean", "jean-link", "ajean", "ajean-link"}
// hasEntry dit si home contient une entrée portant EXACTEMENT ce nom.
//
// os.Stat ne convient pas : sous Windows et macOS le système de fichiers est
// insensible à la casse, donc Stat("MEMORY") réussit sur un dossier nommé
// « memory ». Une installation 0.8 toute neuve passait ainsi pour une 0.7, et
// la migration se redéclenchait à chaque `install`. On compare donc les noms
// réels, tels que le système de fichiers les rapporte.
func hasEntry(home, name string) bool {
entries, err := os.ReadDir(home)
if err != nil {
return false
}
for _, e := range entries {
if e.Name() == name {
return true
}
}
return false
}
// sameDir dit si deux chemins désignent le MÊME dossier — ce qui arrive quand
// ils ne diffèrent que par la casse sur un système de fichiers qui l'ignore.
func sameDir(a, b string) bool {
ai, err := os.Stat(a)
if err != nil {
return false
}
bi, err := os.Stat(b)
if err != nil {
return false
}
return os.SameFile(ai, bi)
}
// needsMigration07 dit si le dossier de données est encore dans l'ancienne
// disposition. On se fie à ce qui ne peut PAS exister en 0.8.
func needsMigration07(home string) bool {
return hasEntry(home, "config.env") || hasEntry(home, "configs") || hasEntry(home, "MEMORY")
}
// migrateFrom07 reprend une installation 0.7.x. Sans effet si le dossier est
// déjà en 0.8. Doit être appelée AVANT provisionDataDir : elle déplace des
// dossiers entiers, ce qui suppose que la destination n'existe pas encore.
func migrateFrom07(home string) error {
if !needsMigration07(home) {
return nil
}
fmt.Printf("\n%s installation 0.7 détectée — reprise en cours\n", cyan("[migration]"))
// 1. Arrêter les anciens services : ils tiennent les fichiers qu'on déplace,
// et sous Windows un .gguf ouvert par llama-server refuse d'être renommé.
stopLegacyUnits()
// 2. Les dossiers. Un rename sur le même volume est instantané, même pour
// 130 Go de modèles, et atomique : jamais d'état à moitié déplacé.
for _, m := range []struct{ from, to string }{
{"configs", "presets"},
{"MEMORY", "memory"},
} {
if !hasEntry(home, m.from) { // nom exact : « MEMORY » n'est pas « memory »
continue
}
src, dst := filepath.Join(home, m.from), filepath.Join(home, m.to)
// « configs » et « presets » ne se confondent pas, mais « MEMORY » et
// « memory » si : sur un système insensible à la casse, ouvrir la
// destination ouvre en réalité la source. On ne considère donc la
// destination comme distincte que si c'est un AUTRE dossier.
if !sameDir(src, dst) {
// Destination déjà remplie : la machine a été migrée à la main et
// l'ancien dossier n'a pas été retiré. Écraser détruirait le travail
// fait, fusionner devinerait qui gagne. On laisse les deux et on le
// dit — c'est le seul cas où quelqu'un doit trancher.
if n, _ := os.ReadDir(dst); len(n) > 0 {
fmt.Printf(" %s %s/ ET %s/ existent tous les deux — %s/ laissé tel quel, à supprimer à la main\n",
yellow("[info]"), m.from, m.to, m.from)
continue
}
_ = os.Remove(dst) // destination vide créée par une install précédente
}
if err := os.Rename(src, dst); err != nil {
return fmt.Errorf("%s → %s : %w", m.from, m.to, err)
}
fmt.Printf(" %s %s/ → %s/\n", green("✓"), m.from, m.to)
}
if n, err := moveModels(home); err != nil {
return err
} else if n > 0 {
fmt.Printf(" %s %d modèle(s) → models/\n", green("✓"), n)
}
// 3. Les réglages, vers la base.
if err := importLegacyState(home); err != nil {
return err
}
fmt.Printf("%s reprise terminée — les anciens fichiers sont dans avant-0.8/\n\n", green("[ok]"))
return nil
}
// stopLegacyUnits est une variable pour que le test de migration n'aille pas
// invoquer systemctl sur la machine qui exécute les tests.
var stopLegacyUnits = stopLegacyUnitsReal
// stopLegacyUnitsReal arrête et désactive les services d'avant la 0.8,
// silencieux sur ceux qui n'existent pas (le cas courant).
func stopLegacyUnitsReal() {
for _, u := range legacyUnits {
switch runtime.GOOS {
case "linux":
_ = exec.Command("systemctl", "stop", u).Run()
_ = exec.Command("systemctl", "disable", u).Run()
case "darwin":
for _, label := range []string{"com.jean." + u, "com.ajean." + u} {
_ = exec.Command("launchctl", "unload", "-w", "/Library/LaunchDaemons/"+label+".plist").Run()
}
}
}
if runtime.GOOS == "linux" {
_ = exec.Command("systemctl", "daemon-reload").Run()
}
}
// moveModels range les .gguf de la racine dans models/. Renvoie le nombre
// déplacé. Un fichier qui résiste (encore ouvert) interrompt la migration : le
// laisser derrière donnerait une installation à laquelle il manque un modèle,
// sans le dire.
func moveModels(home string) (int, error) {
entries, err := os.ReadDir(home)
if err != nil {
return 0, err
}
dst := filepath.Join(home, "models")
n := 0
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasSuffix(strings.ToLower(name), ".gguf") {
continue
}
if n == 0 {
if err := os.MkdirAll(dst, 0o755); err != nil {
return 0, err
}
}
if err := os.Rename(filepath.Join(home, name), filepath.Join(dst, name)); err != nil {
return n, fmt.Errorf("déplacement de %s : %w", name, err)
}
n++
}
return n, nil
}
// importLegacyState recopie les anciens fichiers d'état dans la base, puis les
// range dans avant-0.8/. Les clés et les buckets sont ceux de store.go.
func importLegacyState(home string) error {
old := filepath.Join(home, "avant-0.8")
if err := os.MkdirAll(old, 0o755); err != nil {
return err
}
consumed := []string{}
read := func(name string) string {
b, err := os.ReadFile(filepath.Join(home, name))
if err != nil {
return ""
}
consumed = append(consumed, name)
return string(b)
}
// La configuration d'abord : sans elle le moteur ne démarre pas.
if cfg := parseEnv(read("config.env")); len(cfg) > 0 {
if err := WriteConfig(cfg); err != nil {
return err
}
fmt.Printf(" %s configuration (%d clés)\n", green("✓"), len(cfg))
}
if prefs := map[string]string{}; json.Unmarshal([]byte(read("webprefs.json")), &prefs) == nil && len(prefs) > 0 {
_ = replaceKV(bkPrefs, prefs)
}
if conv := read("conversation.json"); conv != "" {
_ = putBytes(bkChat, "conversation", []byte(conv))
fmt.Printf(" %s conversation\n", green("✓"))
}
// Clés et jetons : le contenu du fichier, débarrassé de sa fin de ligne.
for key, name := range map[string]string{
"api_key": ".api_key",
"web_key": ".web_key",
"crawl_key": ".crawl4ai_key",
"link_token": ".link_token",
"link_machine": ".link_machine",
} {
if v := strings.TrimSpace(read(name)); v != "" {
_ = putStr(bkState, key, v)
}
}
// Drapeaux : seule l'EXISTENCE du fichier comptait.
for key, name := range map[string]string{
"agent": ".agent_enabled",
"internet": ".internet_enabled",
"oai_public": ".oai_public",
} {
if _, err := os.Stat(filepath.Join(home, name)); err == nil {
_ = putBool(bkState, key, true)
consumed = append(consumed, name)
}
}
// Documents JSON repris tels quels.
for key, name := range map[string]string{
"model_dirs": "model_dirs.json",
"last_bench": ".last_bench.json",
"bench_presets": ".bench_presets.json",
"sysprompt": "sysprompt.txt",
} {
if v := read(name); v != "" {
if key == "sysprompt" {
_ = putStr(bkState, key, strings.TrimSpace(v))
} else {
_ = putBytes(bkState, key, []byte(v))
}
}
}
// mcp.json : on ne garde que la map interne, la clé « mcpServers » disparaît.
if v := read("mcp.json"); v != "" {
var f struct {
MCPServers json.RawMessage `json:"mcpServers"`
}
if json.Unmarshal([]byte(v), &f) == nil && len(f.MCPServers) > 0 {
_ = putBytes(bkState, "mcp", f.MCPServers)
}
}
for _, name := range consumed {
_ = os.Rename(filepath.Join(home, name), filepath.Join(old, name))
}
return nil
}
+150
View File
@@ -0,0 +1,150 @@
package ajean
import (
"os"
"path/filepath"
"strings"
"testing"
)
// Test de la reprise 0.7 → 0.8. À supprimer avec migrate_07.go.
//
// Ce code déplace les presets, la mémoire et des modèles de plusieurs dizaines
// de gigaoctets sur la machine de quelqu'un : il ne peut pas ne pas être testé.
// fake07Home fabrique un dossier de données dans l'ancienne disposition.
func fake07Home(t *testing.T) string {
t.Helper()
home := testHome(t)
stopLegacyUnits = func() {} // pas de systemctl pendant les tests
t.Cleanup(func() { stopLegacyUnits = stopLegacyUnitsReal })
write := func(rel, body string) {
p := filepath.Join(home, rel)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
write("config.env", "MODEL=\"m.gguf\"\nCTX=4096\nBIN=/usr/bin/llama-server\n")
write("configs/MON PRESET.env", "# NAME=MON PRESET\nMODEL=\"m.gguf\"\nCTX=8192\n")
write("MEMORY/user-preferences.md", "# Préférences\nfuseau: Europe/Paris\n")
write("m.gguf", "gguf")
write(".api_key", "sk-jean-secret\n")
write(".link_token", "jl_abcdef123456\n")
write(".agent_enabled", "")
write("webprefs.json", `{"theme":"dark"}`)
write("mcp.json", `{"mcpServers":{"fs":{"command":"npx","enabled":true}}}`)
write("sysprompt.txt", "Tu es AJEAN.\n")
write("conversation.json", `{"seq":42,"messages":[{"role":"user","content":"salut"}]}`)
return home
}
func TestMigration07(t *testing.T) {
home := fake07Home(t)
if !needsMigration07(home) {
t.Fatal("ancienne disposition non détectée")
}
if err := migrateFrom07(home); err != nil {
t.Fatal(err)
}
// Les dossiers ont bougé, pas disparu.
for _, c := range []struct{ path, want string }{
{filepath.Join(home, "presets", "MON PRESET.env"), "CTX=8192"},
{filepath.Join(home, "memory", "user-preferences.md"), "Europe/Paris"},
{filepath.Join(home, "models", "m.gguf"), "gguf"},
} {
b, err := os.ReadFile(c.path)
if err != nil {
t.Fatalf("%s absent : %v", c.path, err)
}
if !strings.Contains(string(b), c.want) {
t.Errorf("%s : contenu inattendu %q", c.path, string(b))
}
}
// hasEntry et non os.Stat : sur un système de fichiers insensible à la casse,
// Stat("MEMORY") réussit sur le dossier « memory » fraîchement créé.
for _, gone := range []string{"configs", "MEMORY", "m.gguf", "config.env"} {
if hasEntry(home, gone) {
t.Errorf("%s traîne encore à la racine", gone)
}
}
// Les réglages sont en base.
cfg := ReadConfig()
if cfg["MODEL"] != "m.gguf" || cfg["CTX"] != "4096" {
t.Errorf("configuration non reprise : %v", cfg)
}
if readAPIKey() != "sk-jean-secret" {
t.Errorf("clé API = %q", readAPIKey())
}
if readLinkToken() != "jl_abcdef123456" {
t.Errorf("jeton de liaison = %q", readLinkToken())
}
if !agentEnabled() {
t.Error("mode agent perdu")
}
if readSysPrompt() != "Tu es AJEAN." {
t.Errorf("prompt système = %q", readSysPrompt())
}
if loadWebPrefs()["theme"] != "dark" {
t.Error("préférences perdues")
}
if servers, _ := LoadMCPConfig(); servers["fs"].Command != "npx" {
t.Errorf("serveurs MCP perdus : %v", servers)
}
LoadConversation()
if conv.Seq != 42 {
t.Errorf("conversation perdue (seq %d)", conv.Seq)
}
// Les fichiers consommés sont rangés, pas détruits.
if _, err := os.Stat(filepath.Join(home, "avant-0.8", "config.env")); err != nil {
t.Error("config.env n'a pas été conservée dans avant-0.8/")
}
// Idempotence : relancer ne doit plus rien voir à migrer.
if needsMigration07(home) {
t.Error("la migration se redéclencherait au prochain install")
}
if err := migrateFrom07(home); err != nil {
t.Fatalf("second appel : %v", err)
}
}
// Une installation 0.8 neuve ne doit rien déclencher.
func TestMigration07IgnoreUneInstallationNeuve(t *testing.T) {
home := testHome(t)
if err := provisionDataDir(); err != nil {
t.Fatal(err)
}
if needsMigration07(home) {
t.Fatal("migration déclenchée sur une installation neuve")
}
}
// Machine migrée à la main : les nouveaux dossiers sont remplis ET les anciens
// traînent encore. L'install ne doit rien écraser, ni échouer.
func TestMigration07NEcrasePasUnDossierDejaRempli(t *testing.T) {
home := fake07Home(t)
precieux := filepath.Join(home, "presets", "DEJA LA.env")
if err := os.MkdirAll(filepath.Dir(precieux), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(precieux, []byte("CTX=1\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := migrateFrom07(home); err != nil {
t.Fatalf("la migration doit se poursuivre, pas échouer : %v", err)
}
b, err := os.ReadFile(precieux)
if err != nil || !strings.Contains(string(b), "CTX=1") {
t.Error("le preset déjà migré a été écrasé")
}
if !hasEntry(home, "configs") {
t.Error("l'ancien dossier a été supprimé alors qu'il fallait le laisser")
}
}
+7
View File
@@ -47,6 +47,13 @@ func cmdInstall(args []string) error {
fmt.Printf(" AJEAN_HOME = %s\n", ajeanHome) fmt.Printf(" AJEAN_HOME = %s\n", ajeanHome)
fmt.Printf(" services = %s + %s\n", serviceName(), uiUnitName) fmt.Printf(" services = %s + %s\n", serviceName(), uiUnitName)
// 0. Reprise d'une installation 0.7, si c'en est une. AVANT provisionDataDir :
// elle déplace des dossiers entiers, donc la destination ne doit pas
// encore exister. Code temporaire, voir migrate_07.go.
if err := migrateFrom07(ajeanHome); err != nil {
return fmt.Errorf("reprise de l'installation 0.7 : %w", err)
}
// 1. Arborescence + configuration de départ. // 1. Arborescence + configuration de départ.
if err := provisionDataDir(); err != nil { if err := provisionDataDir(); err != nil {
return err return err
+7
View File
@@ -23,6 +23,13 @@ func cmdInstall(args []string) error {
fmt.Printf(" AJEAN_HOME = %s\n", ajeanHome) fmt.Printf(" AJEAN_HOME = %s\n", ajeanHome)
fmt.Printf(" service = %s\n", serviceName()) fmt.Printf(" service = %s\n", serviceName())
// 0. Reprise d'une installation 0.7, si c'en est une. AVANT provisionDataDir :
// elle déplace des dossiers entiers, donc la destination ne doit pas
// encore exister. Code temporaire, voir migrate_07.go.
if err := migrateFrom07(ajeanHome); err != nil {
return fmt.Errorf("reprise de l'installation 0.7 : %w", err)
}
// 1. Arborescence + configuration de départ (partagé avec le premier // 1. Arborescence + configuration de départ (partagé avec le premier
// lancement de l'app, voir sys_datadir.go). // lancement de l'app, voir sys_datadir.go).
if err := provisionDataDir(); err != nil { if err := provisionDataDir(); err != nil {
-183
View File
@@ -1,183 +0,0 @@
// migrate-0.7 — reprend une installation AJEAN 0.7.x dans la base de la 0.8.
//
// USAGE UNIQUE, et volontairement HORS du binaire ajean. La 0.8 ne contient
// aucun code de compatibilité : c'est tout l'intérêt de la version. Mais une
// machine déjà installée ne doit pas pour autant perdre ses réglages, sa
// conversation et ses clés. D'où cet outil séparé, qu'on lance une fois puis
// qu'on oublie — le produit reste propre, la migration reste possible.
//
// go run ./tools/migrate-0.7 [$AJEAN_HOME] (défaut : /etc/ajean)
//
// Il lit l'ancienne disposition (config.env, webprefs.json, conversation.json,
// .api_key, .link_token…) et la recopie dans ajean.db, aux mêmes buckets et aux
// mêmes clés que internal/ajean/store.go.
//
// Il ne SUPPRIME rien : les anciens fichiers restent en place, intacts. Si la
// migration s'avère fausse, il suffit d'effacer ajean.db et de la relancer.
//
// Ce qu'il ne fait PAS, parce que ça se fait mieux au shell (voir le README) :
// déplacer configs/ vers presets/, MEMORY/ vers memory/, les .gguf vers
// models/, et remplacer les unités systemd.
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
bolt "go.etcd.io/bbolt"
)
const (
bkConfig = "config"
bkPrefs = "prefs"
bkState = "state"
bkChat = "chat"
)
var home = "/etc/ajean"
func main() {
if len(os.Args) > 1 {
home = os.Args[1]
}
db, err := bolt.Open(filepath.Join(home, "ajean.db"), 0o600, &bolt.Options{Timeout: 5 * time.Second})
must(err)
defer db.Close()
must(db.Update(func(tx *bolt.Tx) error {
for _, b := range []string{bkConfig, bkPrefs, bkState, bkChat} {
if _, err := tx.CreateBucketIfNotExists([]byte(b)); err != nil {
return err
}
}
return nil
}))
// Configuration du moteur.
if cfg := parseEnv(read("config.env")); len(cfg) > 0 {
must(putAll(db, bkConfig, cfg))
fmt.Printf("config : %d clés\n", len(cfg))
} else {
fmt.Println("config : ABSENTE — le service ne démarrera pas")
}
// Préférences de l'interface web.
var prefs map[string]string
if b := read("webprefs.json"); b != "" && json.Unmarshal([]byte(b), &prefs) == nil {
must(putAll(db, bkPrefs, prefs))
fmt.Printf("prefs : %d clés\n", len(prefs))
}
// Clés, jetons et interrupteurs.
for key, file := range map[string]string{
"api_key": ".api_key",
"web_key": ".web_key",
"crawl_key": ".crawl4ai_key",
"link_token": ".link_token",
"link_machine": ".link_machine",
} {
if v := strings.TrimSpace(read(file)); v != "" {
must(put(db, bkState, key, []byte(v)))
fmt.Printf("%-12s: repris\n", key)
}
}
// Les drapeaux étaient des fichiers dont seule l'EXISTENCE comptait.
for key, file := range map[string]string{
"agent": ".agent_enabled",
"internet": ".internet_enabled",
"oai_public": ".oai_public",
} {
if _, err := os.Stat(filepath.Join(home, file)); err == nil {
must(put(db, bkState, key, []byte("1")))
fmt.Printf("%-12s: actif\n", key)
}
}
// Serveurs MCP : on ne garde que la map interne, la clé « mcpServers » du
// fichier n'a plus de raison d'être.
if b := read("mcp.json"); b != "" {
var f struct {
MCPServers json.RawMessage `json:"mcpServers"`
}
if json.Unmarshal([]byte(b), &f) == nil && len(f.MCPServers) > 0 {
must(put(db, bkState, "mcp", f.MCPServers))
fmt.Println("mcp : repris")
}
}
// Benchmarks et dossiers de modèles : recopiés tels quels (même forme JSON).
for key, file := range map[string]string{
"last_bench": ".last_bench.json",
"bench_presets": ".bench_presets.json",
"model_dirs": "model_dirs.json",
} {
if b := read(file); b != "" {
must(put(db, bkState, key, []byte(b)))
fmt.Printf("%-12s: repris\n", key)
}
}
// Conversation partagée.
if b := read("conversation.json"); b != "" {
must(put(db, bkChat, "conversation", []byte(b)))
fmt.Printf("conversation: %d octets\n", len(b))
}
fmt.Println("\nTerminé — aucun ancien fichier n'a été supprimé.")
}
func read(name string) string {
b, err := os.ReadFile(filepath.Join(home, name))
if err != nil {
return ""
}
return string(b)
}
func put(db *bolt.DB, bucket, key string, val []byte) error {
return db.Update(func(tx *bolt.Tx) error {
return tx.Bucket([]byte(bucket)).Put([]byte(key), val)
})
}
func putAll(db *bolt.DB, bucket string, m map[string]string) error {
return db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(bucket))
for k, v := range m {
if v == "" {
continue
}
if err := b.Put([]byte(k), []byte(v)); err != nil {
return err
}
}
return nil
})
}
// parseEnv reprend à l'identique la lecture de backend_config.go.
func parseEnv(text string) map[string]string {
m := map[string]string{}
for _, line := range strings.Split(text, "\n") {
s := strings.TrimSpace(line)
if s == "" || strings.HasPrefix(s, "#") {
continue
}
k, v, ok := strings.Cut(strings.TrimPrefix(s, "export "), "=")
if !ok {
continue
}
m[strings.TrimSpace(k)] = strings.Trim(strings.TrimSpace(v), "\"")
}
return m
}
func must(err error) {
if err != nil {
fmt.Fprintln(os.Stderr, "migration:", err)
os.Exit(1)
}
}