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 b6574cb..dba2a3f 100644 Binary files a/cmd/ajean/resource_windows_amd64.syso and b/cmd/ajean/resource_windows_amd64.syso differ diff --git a/cmd/ajean/resource_windows_arm64.syso b/cmd/ajean/resource_windows_arm64.syso index d783374..e0a00b9 100644 Binary files a/cmd/ajean/resource_windows_arm64.syso and b/cmd/ajean/resource_windows_arm64.syso differ 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")