From b4d2d5ed0265ce7037ee4701e2943045aa2a48ba Mon Sep 17 00:00:00 2001 From: nathaninline Date: Tue, 4 Aug 2026 16:40:22 +0200 Subject: [PATCH] v0.7.1 : ne plus afficher l'echec de migration a chaque commande MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remonte par un utilisateur Windows non administrateur. Renommer C:\ProgramData\jean exige d'ecrire dans C:\ProgramData, ce qu'un compte standard ne peut pas faire : le rename echouait a chaque lancement et le message technique s'affichait avant CHAQUE commande, y compris `ajean help`. Rien n'etait casse — le repli sur l'ancien chemin fonctionne comme prevu et aucune donnee n'est en jeu. Le defaut etait entierement dans l'affichage : un detail interne presente comme une erreur, sur une situation permanente et sans consequence. La raison est desormais enregistree au lieu d'etre imprimee, et ressortie la ou elle est actionnable : `ajean where`, qui parle justement d'emplacements, avec la marche a suivre. `ajean install` en administrateur retente la migration — c'est le seul moment ou les droits sont reunis — et le fait AVANT de resoudre le dossier, sinon la variable resterait sur l'ancien chemin et on installerait a cote. La cause du rename est extraite du *os.LinkError : son texte repete les deux chemins complets, ce qui donnait trois fois les memes chemins dans un message qui les cite deja et noyait l'essentiel (« Acces refuse »). Deux tests : l'echec reste silencieux mais enregistre, et aucun message parasite quand la migration reussit. Verifie sur un cas reproduit — `help` est propre, `where` affiche le conseil. --- RELEASE_NOTES.md | 39 +++++++----------- cmd/ajean/resource_windows_amd64.syso | Bin 7544 -> 7544 bytes cmd/ajean/resource_windows_arm64.syso | Bin 7544 -> 7544 bytes cmd/ajean/versioninfo.json | 6 +-- internal/ajean/run.go | 2 +- internal/ajean/sys_install_windows.go | 25 ++++++++---- internal/ajean/sys_migrate.go | 52 +++++++++++++++++++++--- internal/ajean/sys_migrate_test.go | 55 ++++++++++++++++++++++++++ internal/ajean/sys_paths.go | 3 ++ 9 files changed, 140 insertions(+), 42 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 1456e2c..1fb12f2 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,30 +1,23 @@ -Jean devient **AJEAN**, partout et plus seulement dans l'interface. Le dépôt, la commande, les fichiers, les dossiers et les services portent désormais le même nom. +## Correction -Cette version a été construite autour d'une contrainte simple : **rien de ce qui fonctionne aujourd'hui ne doit cesser de fonctionner**. Il n'y a rien à réinstaller et rien à reconfigurer. +**Un message d'erreur s'affichait avant chaque commande sur les postes Windows sans droits administrateur.** Il ressemblait à ceci, et revenait même pour un simple `ajean help` : -## La commande `jean` continue de marcher +``` +[info] dossier de données pas encore migré vers C:\ProgramData\ajean + (rename ... : Access is denied.) — on continue sur C:\ProgramData\jean +``` -`ajean` devient la commande principale, mais `jean` reste installée à côté et fait exactement la même chose. Les alias, les scripts, les tâches planifiées et les raccourcis existants ne bougent pas. +Rien n'était cassé. AJEAN fonctionnait normalement, sur son dossier habituel, sans aucune donnée en jeu. Mais renommer `C:\ProgramData\jean` demande le droit d'écrire dans `C:\ProgramData`, qu'un compte standard n'a pas : le renommage échouait à chaque lancement, et le message revenait indéfiniment. C'était un détail interne affiché comme une erreur. -De même, `JEAN_HOME` reste lu s'il a été défini à la main, au même titre que le nouveau `AJEAN_HOME`. Un emplacement choisi explicitement est un choix, pas un héritage à corriger. +Il ne s'affiche plus. La raison est conservée et ressortie là où elle sert à quelque chose — `ajean where`, qui montre justement les emplacements — avec ce qu'il faut faire pour aligner les noms si vous le souhaitez : -## Le dossier de données se renomme tout seul +``` +ajean where +``` -`%ProgramData%\jean` devient `%ProgramData%\ajean`, et `/etc/jean` devient `/etc/ajean` sous Linux et macOS. Vous n'avez aucune commande à lancer : la migration se fait pendant la mise à jour. +`ajean install` lancé en administrateur termine désormais la migration : c'est le seul moment où les droits nécessaires sont réunis. -Le déplacement est instantané, même avec 200 Go de modèles — rien n'est recopié, le dossier est renommé sur place. Tout ce qui pointait dedans suit : les chemins absolus de `config.env`, ceux de vos presets (dont le `--chat-template-file`), le fichier `/etc/default`, et le répertoire de travail de l'unité systemd. - -Votre clé d'accès distant est déplacée avec le reste : **aucun ré-appairage**, le tunnel se reconnecte seul. - -**Si vous aviez choisi vous-même l'emplacement** — un `JEAN_HOME` sur un autre disque, par exemple — il n'est pas touché. Un chemin défini à la main est une décision, pas un héritage à corriger. - -Et si le renommage ne peut pas aboutir, par exemple parce qu'AJEAN tourne encore en tâche de fond sous Windows, **il ne se passe rien du tout** : l'ancien dossier reste en place et continue de servir, la tentative sera reprise plus tard. Rien n'est jamais copié ni supprimé. - -## Les serveurs Linux ne perdent pas la main - -Les unités systemd **ne sont pas renommées**. Un serveur qui tourne sous `jean.service` continue sous `jean.service` : vos commandes `systemctl`, vos règles `sudoers` et vos scripts de supervision restent valables. Seuls les chemins qu'elles contiennent sont corrigés, et une copie de l'unité d'origine est conservée à côté. - -Une sauvegarde de la configuration, des presets et de la mémoire est déposée dans `/root` avant toute modification. En cas d'échec à n'importe quelle étape, la machine est remise dans son état initial et les services relancés. +Rien de tout cela n'est obligatoire. Un AJEAN qui continue d'utiliser `C:\ProgramData\jean` fonctionne exactement comme les autres, et vos données restent où elles sont. ## Mise à jour @@ -32,8 +25,4 @@ Une sauvegarde de la configuration, des presets et de la mémoire est déposée ajean update ``` -Les binaires restent publiés sous leurs deux noms, `ajean-*` et `jean-*`, le temps que le parc bascule. Les installations existantes se mettent donc à jour normalement, sans intervention. - -Sous Windows, télécharger `ajean-windows-amd64.exe` depuis cette page et le lancer fait le même travail : le fichier détecte l'installation existante et propose de la remplacer. - -L'icône de la barre de menus macOS, introduite en 0.6.10, n'a toujours pas été vérifiée sur une vraie machine. +Les binaires restent publiés sous leurs deux noms, `ajean-*` et `jean-*`, le temps que le parc bascule. diff --git a/cmd/ajean/resource_windows_amd64.syso b/cmd/ajean/resource_windows_amd64.syso index b6574cb8223c25e926128c1bdba7db9b80a4b6d3..dba2a3fd028eee1bbb8724b0fd478c6d770f197c 100644 GIT binary patch delta 35 ocmexi^}}idmn0`613Lolw@Kw+^j1d%*bdsxl!gj0FrbF9smFU delta 35 ncmexi^}}idmn0_x13Lp4Y!;T3WMVYftScSN$Y?ORQRX`Uk~Ih# diff --git a/cmd/ajean/resource_windows_arm64.syso b/cmd/ajean/resource_windows_arm64.syso index d783374518963503ebdc7afdc4165ef4fd291413..e0a00b97ae5ce091cb4f7db9b6db10b329e1905b 100644 GIT binary patch delta 35 ocmexi^}}idmn0`613Lolw@Kw+^j1d%*bdsxl!gj0FrbF9smFU delta 35 ncmexi^}}idmn0_x13Lp4Y!;T3WMVYftScSN$Y?ORQRX`Uk~Ih# diff --git a/cmd/ajean/versioninfo.json b/cmd/ajean/versioninfo.json index 8cd74b5..40e7a7b 100644 --- a/cmd/ajean/versioninfo.json +++ b/cmd/ajean/versioninfo.json @@ -3,13 +3,13 @@ "FileVersion": { "Major": 0, "Minor": 7, - "Patch": 0, + "Patch": 1, "Build": 0 }, "ProductVersion": { "Major": 0, "Minor": 7, - "Patch": 0, + "Patch": 1, "Build": 0 }, "FileFlagsMask": "3f", @@ -25,7 +25,7 @@ "LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.", "OriginalFilename": "ajean.exe", "ProductName": "AJEAN", - "ProductVersion": "0.7.0", + "ProductVersion": "0.7.1", "Comments": "https://github.com/nathaninline/ajean — projet open source (MIT)" }, "VarFileInfo": { diff --git a/internal/ajean/run.go b/internal/ajean/run.go index f340c55..70702da 100644 --- a/internal/ajean/run.go +++ b/internal/ajean/run.go @@ -10,7 +10,7 @@ import ( "strings" ) -const Version = "0.7.0" +const Version = "0.7.1" // Main est le vrai main() du binaire (cmd/ajean ne fait que l'appeler). func Main() { diff --git a/internal/ajean/sys_install_windows.go b/internal/ajean/sys_install_windows.go index 6d90438..efc72aa 100644 --- a/internal/ajean/sys_install_windows.go +++ b/internal/ajean/sys_install_windows.go @@ -38,15 +38,24 @@ EXTRA_ARGS="" ` func cmdInstall(args []string) error { - jeanHome := AjeanHome() + // Lancé en administrateur, `install` est le moment où la migration peut + // enfin aboutir sur un poste où l'utilisateur courant n'a pas les droits sur + // %ProgramData%. On la retente donc AVANT de résoudre le dossier : l'inverse + // laisserait la variable pointer sur l'ancien chemin et installerait à côté. + if retryHomeMigration() { + fmt.Printf("%s dossier de données migré vers %s\n", green("✓"), AjeanHome()) + } else if notice := homeMigrationNotice(); notice != "" { + fmt.Printf("%s %s\n", yellow("[info]"), notice) + } + ajeanHome := AjeanHome() fmt.Printf("Installation (Windows)\n") - fmt.Printf(" JEAN_HOME = %s\n", jeanHome) + fmt.Printf(" AJEAN_HOME = %s\n", ajeanHome) fmt.Printf(" service = %s\n", serviceName()) // 1-2. Dossiers + config.env de départ (partagé avec le premier lancement de // l'app, voir provisionDataDir dans sys_firstrun_windows.go). - conf := filepath.Join(jeanHome, "config.env") + conf := filepath.Join(ajeanHome, "config.env") _, statErr := os.Stat(conf) alreadyThere := statErr == nil if err := provisionDataDir(); err != nil { @@ -61,7 +70,7 @@ func cmdInstall(args []string) error { // 3. Install the binary into JEAN_HOME\bin and put that dir on the user PATH, // so `ajean` is callable from any shell (this is the Windows analogue of the // /usr/local/bin symlink the Unix installer creates). - binDir := filepath.Join(jeanHome, "bin") + binDir := filepath.Join(ajeanHome, "bin") if err := os.MkdirAll(binDir, 0o755); err != nil { return err } @@ -237,11 +246,11 @@ func cmdUninstall(args []string) error { } if !keepData { - jeanHome := AjeanHome() - if err := os.RemoveAll(jeanHome); err != nil { - return fmt.Errorf("suppression de %s: %w", jeanHome, err) + ajeanHome := AjeanHome() + if err := os.RemoveAll(ajeanHome); err != nil { + return fmt.Errorf("suppression de %s: %w", ajeanHome, err) } - fmt.Printf(" %s %s supprimé\n", green("✓"), jeanHome) + fmt.Printf(" %s %s supprimé\n", green("✓"), ajeanHome) } else { fmt.Println(dim("(données utilisateur conservées — relance avec --purge pour tout supprimer)")) } diff --git a/internal/ajean/sys_migrate.go b/internal/ajean/sys_migrate.go index 73cf727..39960d3 100644 --- a/internal/ajean/sys_migrate.go +++ b/internal/ajean/sys_migrate.go @@ -1,9 +1,11 @@ package ajean import ( + "errors" "fmt" "os" "path/filepath" + "runtime" "strings" "sync" ) @@ -33,8 +35,39 @@ import ( var ( homeOnce sync.Once homePath string + + // migrationDeferred retient pourquoi le dossier n'a pas pu être renommé, + // pour que seules les commandes qui parlent d'emplacements en fassent état. + migrationDeferred string ) +// homeMigrationNotice renvoie le message à montrer quand la migration attend +// encore, ou "" s'il n'y a rien à signaler. Renvoie aussi le conseil qui va +// avec : sous Windows, l'obstacle est presque toujours un manque de droits sur +// C:\ProgramData, et la réponse tient en une phrase. +func homeMigrationNotice() string { + if migrationDeferred == "" { + return "" + } + msg := migrationDeferred + "\n Tout fonctionne : AJEAN continue d'utiliser ce dossier." + if runtime.GOOS == "windows" { + return msg + "\n Pour aligner les noms, ferme AJEAN puis lance « ajean install » en administrateur." + } + return msg + "\n Pour aligner les noms : sudo ajean install." +} + +// renameCause extrait la cause réelle d'un échec de rename. os.Rename renvoie un +// *os.LinkError dont le texte répète les deux chemins complets — dans un message +// qui les cite déjà, ça donne trois fois la même chose et rend l'essentiel +// (« Accès refusé ») illisible. On ne garde que ce dernier. +func renameCause(err error) string { + var le *os.LinkError + if errors.As(err, &le) && le.Err != nil { + return le.Err.Error() + } + return err.Error() +} + // migratedDefaultHome renvoie le dossier de données par défaut, en migrant // l'ancien dossier « jean » vers « ajean » à la première résolution du process. // Le résultat est mis en cache : deux appels ne doivent jamais désigner deux @@ -89,11 +122,20 @@ func migrateHome(target, legacy string) string { return sibling } if err := os.Rename(legacy, sibling); err != nil { - // Cas le plus courant sous Windows : un service AJEAN tourne encore et - // tient un handle dans le dossier. On reste sur l'ancien chemin — tout - // fonctionne — et on retentera au prochain démarrage. - fmt.Fprintf(os.Stderr, "[info] dossier de données pas encore migré vers %s (%v) — on continue sur %s\n", - sibling, err, legacy) + // On reste sur l'ancien chemin — tout fonctionne — et on retentera plus + // tard. Deux causes courantes sous Windows : un service AJEAN qui tient + // encore un handle, ou, bien plus fréquent, un utilisateur non + // administrateur : renommer C:\ProgramData\jean exige d'écrire dans + // C:\ProgramData, ce qu'un compte standard ne peut pas faire. + // + // On ENREGISTRE la raison au lieu de l'afficher. L'afficher condamnait + // tout utilisateur non administrateur à voir un message d'erreur + // technique avant CHAQUE commande, y compris `ajean help`, pour une + // situation qui n'a aucune conséquence : son installation fonctionne. + // Le message n'a sa place que là où il est actionnable — `ajean where` + // et `ajean install`, voir homeMigrationNotice(). + migrationDeferred = fmt.Sprintf("dossier de données encore en %s — renommage en %s refusé (%s).", + legacy, filepath.Base(sibling), renameCause(err)) return legacy } fmt.Fprintf(os.Stderr, "[ok] dossier de données migré : %s → %s\n", legacy, sibling) diff --git a/internal/ajean/sys_migrate_test.go b/internal/ajean/sys_migrate_test.go index da4b885..73fa14b 100644 --- a/internal/ajean/sys_migrate_test.go +++ b/internal/ajean/sys_migrate_test.go @@ -216,3 +216,58 @@ func TestMigrateHomeRewritesJSONEscapedPaths(t *testing.T) { t.Fatalf("chemin decode inattendu: %+v", parsed.Dirs) } } + +// Cas d'un utilisateur Windows NON administrateur : il ne peut pas renommer +// C:\ProgramData\jean faute de droits sur C:\ProgramData. La migration echoue a +// chaque lancement, definitivement. +// +// Elle doit rester SILENCIEUSE. L'afficher condamnait cet utilisateur a voir un +// message d'erreur technique avant chaque commande, y compris `ajean help`, +// alors que son installation fonctionne parfaitement. La raison est enregistree +// et n'est ressortie que la ou elle est actionnable. +func TestMigrationFailureIsSilentButRecorded(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("reproduit un echec de rename propre a Windows") + } + migrationDeferred = "" + t.Cleanup(func() { migrationDeferred = "" }) + + root := t.TempDir() + legacy := filepath.Join(root, "jean") + mkHome(t, legacy) + held, err := os.Open(filepath.Join(legacy, "config.env")) + if err != nil { + t.Fatal(err) + } + defer held.Close() + + if got := migrateHome(filepath.Join(root, "ajean"), legacy); got != legacy { + t.Fatalf("attendu un repli sur %s, got %s", legacy, got) + } + if migrationDeferred == "" { + t.Fatal("la raison de l'echec n'a pas ete enregistree") + } + notice := homeMigrationNotice() + if !strings.Contains(notice, legacy) { + t.Errorf("le message ne dit pas quel dossier reste utilise:\n%s", notice) + } + if !strings.Contains(notice, "Tout fonctionne") { + t.Errorf("le message doit rassurer, pas alarmer:\n%s", notice) + } + if !strings.Contains(notice, "administrateur") { + t.Errorf("le message doit dire quoi faire:\n%s", notice) + } +} + +// Rien a signaler quand tout va bien : pas de message parasite. +func TestNoNoticeWhenMigrationSucceeds(t *testing.T) { + migrationDeferred = "" + t.Cleanup(func() { migrationDeferred = "" }) + root := t.TempDir() + legacy := filepath.Join(root, "jean") + mkHome(t, legacy) + migrateHome(filepath.Join(root, "ajean"), legacy) + if n := homeMigrationNotice(); n != "" { + t.Fatalf("message inattendu apres une migration reussie:\n%s", n) + } +} diff --git a/internal/ajean/sys_paths.go b/internal/ajean/sys_paths.go index 2eb99f4..48cf7c2 100644 --- a/internal/ajean/sys_paths.go +++ b/internal/ajean/sys_paths.go @@ -74,6 +74,9 @@ func cmdWhere(args []string) error { } { fmt.Printf(" %-20s %s\n", row[0], row[1]) } + if notice := homeMigrationNotice(); notice != "" { + fmt.Printf("\n%s %s\n", yellow("[info]"), notice) + } if p.Exe != p.Installed { fmt.Printf("\n%s tu exécutes une copie qui n'est PAS le binaire installé.\n", dim("[info]")) fmt.Printf(" Les mises à jour depuis l'application ne modifient que la copie lancée.\n")