v0.7.1 : ne plus afficher l'echec de migration a chaque commande

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.
This commit is contained in:
nathaninline committed 2026-08-04 16:40:22 +02:00
1 parent 2b20158e7c
commit b4d2d5ed02
9 files changed
+140 -42

No files matched your search

+1 -1
View File
@@ -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() {
+17 -8
View File
@@ -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)"))
}
+47 -5
View File
@@ -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)
+55
View File
@@ -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)
}
}
+3
View File
@@ -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")