From 3720b9017365cecf4b24e7c15b6bfb7756611069 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Tue, 4 Aug 2026 14:07:44 +0200 Subject: [PATCH] Renommage AJEAN (2/3) : services, binaire et releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Suite du renommage. Principe directeur : le code s'adapte a ce qui est DEJA installe sur la machine, plutot que d'exiger une migration privilegiee qui pourrait echouer et laisser un serveur sans service. Services (sys_unitname.go). Une mise a jour remplace le binaire, pas les unites systemd : un serveur deja installe tourne encore sous jean.service et jean-link.service. serviceName() et linkServiceName() resolvent donc le nom a l'usage — l'unite qui existe reellement — au lieu de supposer "ajean*". Sans ca, le premier systemctl restart apres MAJ echouerait, et sur un serveur distant ca veut dire l'acces coupe sans terminal pour reparer. Meme logique pour le label launchd, ou le prefixe com.jean. est conserve tant que c'est lui qui est charge. Le fichier PID du worker de lien est repris au passage (jean-link.pid -> ajean-link.pid) : c'est lui qui dit si un worker tourne, et sans reprise la premiere version renommee en aurait demarre un second, donnant deux tunnels concurrents vers le relais. Binaire. installedExePath vise desormais ajean(.exe), et l'installation pose un alias "jean" a cote — lien symbolique sous Unix, copie sous Windows ou les liens demandent des droits qu'on n'a pas toujours. La commande d'hier reste tapable : on ne controle ni les alias shell, ni les cron, ni les scripts de deploiement, ni les tutos deja ecrits. /etc/default/ajean est ecrit avec AJEAN_HOME ET JEAN_HOME, et l'ancien /etc/default/jean est laisse en place plutot que supprime : des scripts d'utilisateurs le sourcent. Releases. Les workflows produisent ajean-- puis en copient un jeu jean-- identique, une fois les binaires macOS recuperes. Les deux jeux sont dans SHA256SUMS. Ces copies sont indispensables : le parc installe ne sait chercher que jean-*, il ne pourrait donc meme pas atteindre la version qui apprend a lire ajean-*. A retirer quand le parc aura bascule. Le bundle devient AJEAN.app, avec le zip publie sous les deux noms pour ne pas casser les liens de telechargement deja diffuses. La detection du mode application se basant sur /Contents/MacOS et non sur le nom du bundle, elle n'est pas affectee. --- .github/workflows/mac-build.yml | 24 +++++------ .github/workflows/release.yml | 52 +++++++++++++++-------- internal/ajean/relay_link.go | 59 ++++++++++++++++++++------- internal/ajean/run.go | 14 ++++++- internal/ajean/sys_install_darwin.go | 31 +++++++------- internal/ajean/sys_install_linux.go | 51 +++++++++++++++-------- internal/ajean/sys_install_windows.go | 51 ++++++++++++++++------- internal/ajean/sys_paths.go | 18 +++++--- internal/ajean/sys_service_darwin.go | 21 +++++++++- internal/ajean/sys_unitname.go | 49 ++++++++++++++++++++++ internal/ajean/sys_update.go | 4 +- 11 files changed, 275 insertions(+), 99 deletions(-) create mode 100644 internal/ajean/sys_unitname.go diff --git a/.github/workflows/mac-build.yml b/.github/workflows/mac-build.yml index caee71d..52fb76a 100644 --- a/.github/workflows/mac-build.yml +++ b/.github/workflows/mac-build.yml @@ -1,4 +1,4 @@ -# Build macOS à la demande, SANS rien publier : produit Jean.app en artifact +# Build macOS à la demande, SANS rien publier : produit AJEAN.app en artifact # téléchargeable depuis l'onglet Actions (visible seulement par le dépôt). # Sert à tester le bundle avant de décider d'une vraie release (release.yml). name: mac-build @@ -13,38 +13,38 @@ jobs: - uses: actions/setup-go@v5 with: go-version-file: go.mod - - name: build + bundle Jean.app (arm64) + - name: build + bundle AJEAN.app (arm64) run: | mkdir -p dist CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 \ - go build -trimpath -ldflags="-s -w" -o dist/jean-darwin-arm64 ./cmd/jean + go build -trimpath -ldflags="-s -w" -o dist/ajean-darwin-arm64 ./cmd/ajean mkdir -p /tmp/jean.iconset sips -s format png 'cmd/jean/icon.ico' --out /tmp/icon.png >/dev/null 2>&1 || true for s in 16 32 128 256 512; do sips -z $s $s /tmp/icon.png --out "/tmp/jean.iconset/icon_${s}x${s}.png" >/dev/null 2>&1 || true done iconutil -c icns /tmp/jean.iconset -o /tmp/jean.icns >/dev/null 2>&1 || true - app="dist/Jean.app" + app="dist/AJEAN.app" mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" - cp dist/jean-darwin-arm64 "$app/Contents/MacOS/jean" - chmod +x "$app/Contents/MacOS/jean" + cp dist/ajean-darwin-arm64 "$app/Contents/MacOS/ajean" + chmod +x "$app/Contents/MacOS/ajean" cp /tmp/jean.icns "$app/Contents/Resources/jean.icns" 2>/dev/null || true printf '%s\n' \ '' \ '' \ '' \ - 'CFBundleNameJean' \ - 'CFBundleDisplayNameJean' \ - 'CFBundleExecutablejean' \ - 'CFBundleIdentifierlink.ajean.jean' \ - 'CFBundleIconFilejean' \ + 'CFBundleNameAJEAN' \ + 'CFBundleDisplayNameAJEAN' \ + 'CFBundleExecutableajean' \ + 'CFBundleIdentifierlink.ajean.app' \ + 'CFBundleIconFileajean' \ 'CFBundlePackageTypeAPPL' \ 'LSMinimumSystemVersion11.0' \ 'LSUIElement' \ 'NSHighResolutionCapable' \ '' > "$app/Contents/Info.plist" codesign --force --deep -s - "$app" || true - (cd dist && zip -qry Jean-macos-arm64.zip Jean.app && rm -rf Jean.app) + (cd dist && zip -qry AJEAN-macos-arm64.zip AJEAN.app && rm -rf AJEAN.app) ls -l dist - uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0b09f1f..c98b884 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,11 +29,11 @@ jobs: mkdir -p dist # arm64 = natif ; amd64 = cross avec le SDK universel (-arch x86_64). CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 \ - go build -trimpath -ldflags="-s -w" -o dist/jean-darwin-arm64 ./cmd/jean + go build -trimpath -ldflags="-s -w" -o dist/ajean-darwin-arm64 ./cmd/ajean CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 \ CGO_CFLAGS="-arch x86_64" CGO_LDFLAGS="-arch x86_64" \ - go build -trimpath -ldflags="-s -w" -o dist/jean-darwin-amd64 ./cmd/jean - file dist/jean-darwin-* + go build -trimpath -ldflags="-s -w" -o dist/ajean-darwin-amd64 ./cmd/ajean + file dist/ajean-darwin-* - name: bundles Jean.app run: | # Un binaire nu double-cliqué ouvrirait le Terminal. On publie donc @@ -43,29 +43,29 @@ jobs: # la barre de menus), comme le double-clic sur jean.exe sous Windows. # LSUIElement : app de barre de menus, aucune icône dans le Dock. # L'icône part du PNG 1024 généré avec le reste de la marque - # (cmd/jean/icon.png, voir tools/gen-icon) : plus de conversion depuis + # (cmd/ajean/icon.png, voir tools/gen-icon) : plus de conversion depuis # le .ico, dont l'échec silencieux donnait une app sans icône. mkdir -p /tmp/jean.iconset for s in 16 32 128 256 512; do - sips -z $s $s 'cmd/jean/icon.png' --out "/tmp/jean.iconset/icon_${s}x${s}.png" >/dev/null + sips -z $s $s 'cmd/ajean/icon.png' --out "/tmp/jean.iconset/icon_${s}x${s}.png" >/dev/null done iconutil -c icns /tmp/jean.iconset -o /tmp/jean.icns v="${GITHUB_REF_NAME#v}" for arch in amd64 arm64; do - app="dist/mac-$arch/Jean.app" + app="dist/mac-$arch/AJEAN.app" mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" - cp "dist/jean-darwin-$arch" "$app/Contents/MacOS/jean" - chmod +x "$app/Contents/MacOS/jean" - cp /tmp/jean.icns "$app/Contents/Resources/jean.icns" 2>/dev/null || true + cp "dist/ajean-darwin-$arch" "$app/Contents/MacOS/ajean" + chmod +x "$app/Contents/MacOS/ajean" + cp /tmp/jean.icns "$app/Contents/Resources/ajean.icns" 2>/dev/null || true printf '%s\n' \ '' \ '' \ '' \ - 'CFBundleNameJean' \ - 'CFBundleDisplayNameJean' \ - 'CFBundleExecutablejean' \ - 'CFBundleIdentifierlink.ajean.jean' \ - 'CFBundleIconFilejean' \ + 'CFBundleNameAJEAN' \ + 'CFBundleDisplayNameAJEAN' \ + 'CFBundleExecutableajean' \ + 'CFBundleIdentifierlink.ajean.app' \ + 'CFBundleIconFileajean' \ 'CFBundlePackageTypeAPPL' \ "CFBundleShortVersionString$v" \ "CFBundleVersion$v" \ @@ -76,7 +76,11 @@ jobs: # Signature ad-hoc : sans elle, macOS refuse purement et simplement # de lancer un binaire arm64 non signé (pas juste un avertissement). codesign --force --deep -s - "$app" || true - (cd "dist/mac-$arch" && zip -qry "../Jean-macos-$arch.zip" Jean.app) + (cd "dist/mac-$arch" && zip -qry "../AJEAN-macos-$arch.zip" AJEAN.app) + # Nom hérité conservé : les liens de téléchargement déjà publiés + # (site, mails, messages) pointent dessus et doivent continuer de + # fonctionner. Même contenu, deux noms. + cp "dist/AJEAN-macos-$arch.zip" "dist/Jean-macos-$arch.zip" rm -rf "dist/mac-$arch" done ls -l dist @@ -102,16 +106,28 @@ jobs: # (l'app se rattache à la console du terminal parent si lancée en CLI). [ "$os" = windows ] && { ext=".exe"; ldflags="$ldflags -H=windowsgui"; } GOOS=$os GOARCH=$arch CGO_ENABLED=0 \ - go build -trimpath -ldflags="$ldflags" -o "dist/jean-$os-$arch$ext" ./cmd/jean + go build -trimpath -ldflags="$ldflags" -o "dist/ajean-$os-$arch$ext" ./cmd/ajean done - uses: actions/download-artifact@v4 with: name: macos path: dist + - name: assets hérités jean-* (transition) + run: | + # Le parc déjà installé ne sait chercher QUE « jean-- » : sans + # ces copies, aucun des utilisateurs actuels ne pourrait se mettre à + # jour — pas même vers la version qui apprend à lire « ajean-* ». + # Après cette étape SEULEMENT, donc une fois les binaires macOS + # récupérés, les deux jeux de noms sont complets. + # À supprimer quand le parc aura basculé (quelques versions). + for f in dist/ajean-*; do + cp "$f" "dist/jean-${f#dist/ajean-}" + done + ls -l dist - name: SHA256SUMS run: | - chmod +x dist/jean-darwin-* - (cd dist && sha256sum jean-* Jean-macos-*.zip > SHA256SUMS) + chmod +x dist/ajean-darwin-* dist/jean-darwin-* + (cd dist && sha256sum ajean-* jean-* *.zip > SHA256SUMS) ls -l dist && cat dist/SHA256SUMS - name: publier la release uses: softprops/action-gh-release@v2 diff --git a/internal/ajean/relay_link.go b/internal/ajean/relay_link.go index 2b02f0e..1b2580b 100644 --- a/internal/ajean/relay_link.go +++ b/internal/ajean/relay_link.go @@ -93,8 +93,16 @@ func relayURL() string { return defaultRelayURL } -// linkServiceName est l'unité systemd qui exécute le worker « jean link --foreground ». -const linkServiceName = "jean-link" +// linkUnitName / legacyLinkUnitName : l'unité systemd qui exécute le worker +// « ajean link --foreground », et son nom d'avant le renommage. +const ( + linkUnitName = "ajean-link" + legacyLinkUnitName = "jean-link" +) + +// linkServiceName renvoie l'unité de lien réellement installée sur la machine +// (voir sys_unitname.go : un serveur mis à jour tourne encore sous jean-link). +func linkServiceName() string { return resolveUnitName(linkUnitName, legacyLinkUnitName) } func cmdLink(args []string) error { sub := "" @@ -121,9 +129,9 @@ func cmdLink(args []string) error { } fmt.Printf("%s token enregistré (%s…), relais: %s\n", green("[ok]"), tok[:min(8, len(tok))], relayURL()) if linkServiceActive() { - fmt.Printf("%s service %s: actif\n", green("[ok]"), linkServiceName) + fmt.Printf("%s service %s: actif\n", green("[ok]"), linkServiceName()) } else { - fmt.Printf("%s service %s: arrêté (jean link pour démarrer)\n", yellow("[info]"), linkServiceName) + fmt.Printf("%s service %s: arrêté (jean link pour démarrer)\n", yellow("[info]"), linkServiceName()) } return nil case "logout": @@ -174,7 +182,7 @@ func startLink(force bool) error { return err } case linkServiceActive(): - fmt.Printf("%s service %s déjà en cours — « jean link restart » pour le relancer\n", yellow("[info]"), linkServiceName) + fmt.Printf("%s service %s déjà en cours — « jean link restart » pour le relancer\n", yellow("[info]"), linkServiceName()) default: if err := linkServiceCtl("start"); err != nil { return err @@ -375,8 +383,29 @@ func serveLocalWebMux(mux *http.ServeMux) { // linkPIDPath / linkLogPath : suivi du worker de lien hors systemd (macOS, // Windows), où Jean est une app de bureau lancée sans droits root. -func linkPIDPath() string { return filepath.Join(AjeanHome(), linkServiceName+".pid") } -func linkLogPath() string { return filepath.Join(AjeanHome(), linkServiceName+".log") } +func linkPIDPath() string { return adoptLegacyLinkFile(".pid") } +func linkLogPath() string { return adoptLegacyLinkFile(".log") } + +// adoptLegacyLinkFile renvoie le chemin « ajean-link » en reprenant au +// passage le « jean-link » laissé par une version antérieure. +// +// Reprendre le fichier PID n'est pas cosmétique : c'est LUI qui dit si un worker +// tourne déjà. Sans cette reprise, la première version renommée ne verrait plus +// le worker en cours, en démarrerait un second, et la machine se retrouverait +// avec deux tunnels concurrents vers le relais. Idempotent, silencieux en cas +// d'échec (on retombe simplement sur le nouveau nom). +func adoptLegacyLinkFile(ext string) string { + newPath := filepath.Join(AjeanHome(), linkUnitName+ext) + if _, err := os.Stat(newPath); err == nil { + return newPath + } + legacy := filepath.Join(AjeanHome(), legacyLinkUnitName+ext) + if _, err := os.Stat(legacy); err != nil { + return newPath + } + _ = os.Rename(legacy, newPath) + return newPath +} // linkServiceCtl pilote le worker de lien (start/stop/restart). Sous Linux c'est // l'unité systemd jean-link (avec sudo non interactif si on n'est pas root) ; @@ -411,16 +440,16 @@ func linkServiceCtl(action string) error { if os.Geteuid() != 0 { bin, pre = "sudo", []string{"-n", "systemctl"} } - cmd := exec.Command(bin, append(pre, action, linkServiceName)...) + cmd := exec.Command(bin, append(pre, action, linkServiceName())...) cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr if err := cmd.Run(); err != nil { - return fmt.Errorf("systemctl %s %s: %w", action, linkServiceName, err) + return fmt.Errorf("systemctl %s %s: %w", action, linkServiceName(), err) } switch action { case "start", "restart": - fmt.Printf("%s service %s %s\n", green("[ok]"), linkServiceName, action+"é") + fmt.Printf("%s service %s %s\n", green("[ok]"), linkServiceName(), action+"é") case "stop": - fmt.Printf("%s service %s arrêté\n", green("[ok]"), linkServiceName) + fmt.Printf("%s service %s arrêté\n", green("[ok]"), linkServiceName()) } return nil } @@ -434,7 +463,7 @@ func linkServiceActive() bool { } return linkUserPID() > 0 } - out, _ := exec.Command("systemctl", "is-active", linkServiceName).Output() + out, _ := exec.Command("systemctl", "is-active", linkServiceName()).Output() return strings.TrimSpace(string(out)) == "active" } @@ -467,12 +496,12 @@ func linkUserSvcCtl(action string) error { } _ = os.Remove(linkPIDPath()) if action == "stop" { - fmt.Printf("%s service %s arrêté\n", green("[ok]"), linkServiceName) + fmt.Printf("%s service %s arrêté\n", green("[ok]"), linkServiceName()) return nil } case "start": if linkUserPID() > 0 { - fmt.Printf("%s service %s déjà en cours\n", yellow("[info]"), linkServiceName) + fmt.Printf("%s service %s déjà en cours\n", yellow("[info]"), linkServiceName()) return nil } default: @@ -516,7 +545,7 @@ func linkUserSvcCtl(action string) error { _ = os.Remove(linkPIDPath()) return fmt.Errorf("le lien s'est arrêté aussitôt — voir %s", linkLogPath()) } - fmt.Printf("%s service %s démarré (PID %d)\n", green("[ok]"), linkServiceName, pid) + fmt.Printf("%s service %s démarré (PID %d)\n", green("[ok]"), linkServiceName(), pid) return nil } diff --git a/internal/ajean/run.go b/internal/ajean/run.go index b526c94..cfc0f97 100644 --- a/internal/ajean/run.go +++ b/internal/ajean/run.go @@ -234,13 +234,25 @@ func legacySkillsFlag() string { return filepath.Join(skillsDir(), ".enabled") } func legacyToolsFlag() string { return filepath.Join(AjeanHome(), ".tools_enabled") } func apiKeyPath() string { return filepath.Join(AjeanHome(), ".api_key") } func crawlKeyPath() string { return filepath.Join(AjeanHome(), ".crawl4ai_key") } +// serviceName est le nom de l'unité qui exécute llama-server. AJEAN_SERVICE et +// JEAN_SERVICE sont tous deux honorés : le second est posé à la main sur des +// machines qu'on ne voit pas, le renommage ne doit pas les casser. func serviceName() string { + if n := os.Getenv("AJEAN_SERVICE"); n != "" { + return n + } if n := os.Getenv("JEAN_SERVICE"); n != "" { return n } - return "jean" + // Pas de nom imposé : on prend l'unité réellement installée. Un serveur + // simplement mis à jour tourne encore sous « jean.service » — voir + // sys_unitname.go. + return resolveUnitName("ajean", legacyServiceName()) } +// legacyServiceName est le nom d'avant le renommage. +func legacyServiceName() string { return "jean" } + // Color helpers (ANSI). Disabled when stdout is not a TTY. var colorOn = isTerminal() diff --git a/internal/ajean/sys_install_darwin.go b/internal/ajean/sys_install_darwin.go index 56f55d8..404e83d 100644 --- a/internal/ajean/sys_install_darwin.go +++ b/internal/ajean/sys_install_darwin.go @@ -81,25 +81,25 @@ func cmdInstall(args []string) error { if err != nil { return fmt.Errorf("utilisateur '%s' introuvable: %w", targetUser, err) } - jeanHome := defaultJeanHome() + ajeanHome := defaultAjeanHome() if v := os.Getenv("JEAN_HOME"); v != "" { - jeanHome = v + ajeanHome = v } svc := serviceName() fmt.Printf("Installation pour utilisateur %s\n", cyan(targetUser)) - fmt.Printf(" JEAN_HOME = %s\n", jeanHome) + fmt.Printf(" JEAN_HOME = %s\n", ajeanHome) fmt.Printf(" service = %s (launchd)\n", svc) // 1. Répertoires - for _, d := range []string{jeanHome, filepath.Join(jeanHome, "configs"), filepath.Join(jeanHome, "SKILLS")} { + for _, d := range []string{ajeanHome, filepath.Join(ajeanHome, "configs"), filepath.Join(ajeanHome, "SKILLS")} { if err := os.MkdirAll(d, 0o755); err != nil { return err } } // 2. config.env si absent - conf := filepath.Join(jeanHome, "config.env") + conf := filepath.Join(ajeanHome, "config.env") if _, err := os.Stat(conf); os.IsNotExist(err) { body := strings.ReplaceAll(configTemplate, "USER", targetUser) if err := os.WriteFile(conf, []byte(body), 0o644); err != nil { @@ -137,17 +137,19 @@ func cmdInstall(args []string) error { fmt.Printf(" %s %s -> %s\n", green("✓"), target, self) } - // 4. /etc/default/jean pour que les invocations CLI résolvent JEAN_HOME. + // 4. /etc/default/ajean pour que les invocations CLI résolvent AJEAN_HOME. + // Les deux clés sont écrites : des scripts d'utilisateurs sourcent ce fichier + // et lisent encore $JEAN_HOME. _ = os.MkdirAll("/etc/default", 0o755) - defaults := fmt.Sprintf("# Generated by jean install — racine des configs/skills/SKILLS\nJEAN_HOME=%s\n", jeanHome) - if err := os.WriteFile("/etc/default/jean", []byte(defaults), 0o644); err != nil { + defaults := fmt.Sprintf("# Generated by ajean install — racine des configs/memoire/presets\nAJEAN_HOME=%s\nJEAN_HOME=%s\n", ajeanHome, ajeanHome) + if err := os.WriteFile("/etc/default/ajean", []byte(defaults), 0o644); err != nil { return err } - fmt.Printf(" %s /etc/default/jean\n", green("✓")) + fmt.Printf(" %s /etc/default/ajean\n", green("✓")) // 5. LaunchDaemon plist (le log va sous JEAN_HOME, accessible au user cible) - logPath := filepath.Join(jeanHome, svc+".log") - plist := fmt.Sprintf(launchdPlistTemplate, launchdLabel(svc), targetUser, jeanHome, jeanHome, logPath, logPath) + logPath := filepath.Join(ajeanHome, svc+".log") + plist := fmt.Sprintf(launchdPlistTemplate, launchdLabel(svc), targetUser, ajeanHome, ajeanHome, logPath, logPath) plistPath := launchdPlistPath(svc) if err := os.WriteFile(plistPath, []byte(plist), 0o644); err != nil { return err @@ -155,7 +157,7 @@ func cmdInstall(args []string) error { fmt.Printf(" %s %s\n", green("✓"), plistPath) // 6. chown JEAN_HOME au user cible - chown(jeanHome, u) + chown(ajeanHome, u) fmt.Println() fmt.Printf("%s installation terminée.\n", green("[ok]")) @@ -175,8 +177,9 @@ func cmdUninstall(args []string) error { _ = exec.Command("launchctl", "unload", "-w", launchdPlistPath(svc)).Run() for _, p := range []string{ launchdPlistPath(svc), - "/etc/default/jean", - "/usr/local/bin/jean", + "/etc/default/ajean", + installedExePath(), + legacyExePath(), } { if err := os.Remove(p); err == nil { fmt.Printf(" %s %s\n", green("✓"), p) diff --git a/internal/ajean/sys_install_linux.go b/internal/ajean/sys_install_linux.go index d53fedc..2d3f0b0 100644 --- a/internal/ajean/sys_install_linux.go +++ b/internal/ajean/sys_install_linux.go @@ -76,25 +76,25 @@ func cmdInstall(args []string) error { if err != nil { return fmt.Errorf("utilisateur '%s' introuvable: %w", targetUser, err) } - jeanHome := defaultJeanHome() + ajeanHome := defaultAjeanHome() if v := os.Getenv("JEAN_HOME"); v != "" { - jeanHome = v + ajeanHome = v } svc := serviceName() fmt.Printf("Installation pour utilisateur %s\n", cyan(targetUser)) - fmt.Printf(" JEAN_HOME = %s\n", jeanHome) + fmt.Printf(" JEAN_HOME = %s\n", ajeanHome) fmt.Printf(" service = %s\n", svc) // 1. Create directories - for _, d := range []string{jeanHome, filepath.Join(jeanHome, "configs"), filepath.Join(jeanHome, "SKILLS")} { + for _, d := range []string{ajeanHome, filepath.Join(ajeanHome, "configs"), filepath.Join(ajeanHome, "SKILLS")} { if err := os.MkdirAll(d, 0o755); err != nil { return err } } // 2. Drop a config.env if none exists - conf := filepath.Join(jeanHome, "config.env") + conf := filepath.Join(ajeanHome, "config.env") if _, err := os.Stat(conf); os.IsNotExist(err) { body := strings.ReplaceAll(configTemplate, "USER", targetUser) if err := os.WriteFile(conf, []byte(body), 0o644); err != nil { @@ -111,7 +111,7 @@ func cmdInstall(args []string) error { if rp, e := filepath.EvalSymlinks(self); e == nil { self = rp } - target := "/usr/local/bin/jean" + target := installedExePath() // Garde-fou (issue #5) : si on tourne DÉJÀ depuis la cible (l'utilisateur a // posé le binaire dans /usr/local/bin/jean puis lancé `sudo jean install`), // ne surtout pas Remove+Symlink sur soi-même — ça effacerait le vrai binaire @@ -134,15 +134,34 @@ func cmdInstall(args []string) error { fmt.Printf(" %s %s -> %s\n", green("✓"), target, self) } - // 4. Drop /etc/default/jean so root invocations resolve JEAN_HOME correctly. - defaults := fmt.Sprintf("# Generated by jean install — racine des configs/skills/SKILLS\nJEAN_HOME=%s\n", jeanHome) - if err := os.WriteFile("/etc/default/jean", []byte(defaults), 0o644); err != nil { + // 3 bis. Alias hérité : `jean` doit rester tapable. Des utilisateurs ont des + // alias shell, des cron, des scripts de déploiement et des tutos qui appellent + // « jean » — on ne contrôle rien de tout ça, et un binaire renommé qui casse + // la commande d'hier est une régression pour eux. Le lien pointe sur le + // nouveau nom, donc il suit les mises à jour sans entretien. + if alias := legacyExePath(); alias != target { + if rp, e := filepath.EvalSymlinks(alias); e != nil || rp != self { + _ = os.Remove(alias) + if err := os.Symlink(target, alias); err != nil { + fmt.Printf(" %s alias %s non créé (%v) — « ajean » reste disponible\n", yellow("[info]"), alias, err) + } else { + fmt.Printf(" %s %s -> %s (alias hérité)\n", green("✓"), alias, target) + } + } + } + + // 4. Drop /etc/default/ajean so root invocations resolve AJEAN_HOME correctly. + // On écrit les DEUX clés : des scripts d'utilisateurs sourcent ce fichier et + // lisent $JEAN_HOME. L'ancien /etc/default/jean est laissé en place — le + // supprimer casserait ces mêmes scripts, et AjeanHome() sait déjà le lire. + defaults := fmt.Sprintf("# Generated by ajean install — racine des configs/memoire/presets\nAJEAN_HOME=%s\nJEAN_HOME=%s\n", ajeanHome, ajeanHome) + if err := os.WriteFile("/etc/default/ajean", []byte(defaults), 0o644); err != nil { return err } - fmt.Printf(" %s /etc/default/jean\n", green("✓")) + fmt.Printf(" %s /etc/default/ajean\n", green("✓")) - // 5. Write the systemd unit (ExecStart = `jean serve`, no start.sh needed) - unit := fmt.Sprintf(serviceUnitTemplate, targetUser, jeanHome, "/usr/local/bin/jean serve") + // 5. Write the systemd unit (ExecStart = `ajean serve`, no start.sh needed) + unit := fmt.Sprintf(serviceUnitTemplate, targetUser, ajeanHome, installedExePath()+" serve") unitPath := "/etc/systemd/system/" + svc + ".service" if err := os.WriteFile(unitPath, []byte(unit), 0o644); err != nil { return err @@ -151,14 +170,14 @@ func cmdInstall(args []string) error { // 5. Sudoers drop-in sudoers := fmt.Sprintf(sudoersTemplate, targetUser, svc, targetUser, svc, svc, svc, svc, svc) - sudoersPath := "/etc/sudoers.d/jean-" + svc + sudoersPath := "/etc/sudoers.d/ajean-" + svc if err := os.WriteFile(sudoersPath, []byte(sudoers), 0o440); err != nil { return err } fmt.Printf(" %s %s\n", green("✓"), sudoersPath) // 6. chown JEAN_HOME contents to target user - chown(jeanHome, u) + chown(ajeanHome, u) // 7. systemd reload _ = exec.Command("systemctl", "daemon-reload").Run() @@ -191,9 +210,9 @@ func cmdUninstall(args []string) error { _ = exec.Command("systemctl", "disable", svc).Run() for _, p := range []string{ "/etc/systemd/system/" + svc + ".service", - "/etc/sudoers.d/jean-" + svc, + "/etc/sudoers.d/ajean-" + svc, "/etc/default/jean", - "/usr/local/bin/jean", + installedExePath(), } { if err := os.Remove(p); err == nil { fmt.Printf(" %s %s\n", green("✓"), p) diff --git a/internal/ajean/sys_install_windows.go b/internal/ajean/sys_install_windows.go index 9aae2dc..4cfd47e 100644 --- a/internal/ajean/sys_install_windows.go +++ b/internal/ajean/sys_install_windows.go @@ -97,14 +97,15 @@ func cmdInstall(args []string) error { return nil } -// installSelf copies the currently running executable into binDir as jean.exe +// installSelf copies the currently running executable into binDir as ajean.exe // and returns the destination path. If the running exe already lives there -// (re-install), it's a no-op. +// (re-install), it's a no-op. Une copie « jean.exe » est posée à côté pour que +// l'ancienne commande reste tapable (voir installLegacyAlias). // // binDir est créé ici, et pas seulement par l'appelant : sur une machine vierge, // le premier lancement passait par appFirstRun, qui ne créait que le dossier de // données et pas son sous-dossier bin. L'utilisateur recevait alors « open -// C:\ProgramData\jean\bin\jean.exe: The system cannot find the path specified » +// C:\ProgramData\ajean\bin\ajean.exe: The system cannot find the path specified » // et AJEAN démarrait depuis le fichier téléchargé, sans jamais s'installer. func installSelf(binDir string) (string, error) { src, err := os.Executable() @@ -112,32 +113,54 @@ func installSelf(binDir string) (string, error) { return "", err } src, _ = filepath.EvalSymlinks(src) - dst := filepath.Join(binDir, "jean.exe") + dst := filepath.Join(binDir, "ajean.exe") if strings.EqualFold(src, dst) { + installLegacyAlias(binDir, src) return dst, nil } if err := os.MkdirAll(binDir, 0o755); err != nil { return "", err } - in, err := os.Open(src) - if err != nil { + if err := copyExe(src, dst); err != nil { return "", err } + installLegacyAlias(binDir, dst) + return dst, nil +} + +// installLegacyAlias pose une copie « jean.exe » à côté de « ajean.exe ». +// +// Une copie et pas un lien : créer un lien symbolique sous Windows demande des +// droits particuliers qu'on n'a pas toujours, et un binaire de quelques dizaines +// de Mo dupliqué est un prix dérisoire pour ne casser ni les raccourcis du menu +// Démarrer, ni les tâches planifiées, ni les scripts que les utilisateurs ont +// écrits eux-mêmes. Best-effort : un échec ne compromet pas l'installation, +// « ajean » reste disponible. +func installLegacyAlias(binDir, src string) { + alias := filepath.Join(binDir, "jean.exe") + if strings.EqualFold(src, alias) { + return + } + // Échoue si un « jean.exe » est en cours d'exécution — sans gravité, il s'agit + // alors déjà d'une version d'AJEAN, et la copie repassera au prochain lancement. + _ = copyExe(src, alias) +} + +func copyExe(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } defer in.Close() - // Can't overwrite a running exe, but jean.exe in binDir isn't the one we're - // running (src != dst here), so a plain create is fine. out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755) if err != nil { - return "", err + return err } if _, err := io.Copy(out, in); err != nil { out.Close() - return "", err + return err } - if err := out.Close(); err != nil { - return "", err - } - return dst, nil + return out.Close() } // addToUserPath appends dir to the per-user PATH (HKCU\Environment) persistently, diff --git a/internal/ajean/sys_paths.go b/internal/ajean/sys_paths.go index a7ec1e8..7ef148d 100644 --- a/internal/ajean/sys_paths.go +++ b/internal/ajean/sys_paths.go @@ -26,14 +26,22 @@ type ajeanPaths struct { } // installedExePath est l'emplacement canonique du binaire après installation. -// Il diffère par plateforme : sous Unix les installateurs posent /usr/local/bin/jean +// Il diffère par plateforme : sous Unix les installateurs posent /usr/local/bin/ajean // (c'est ce que référencent les unités systemd et le plist launchd) ; sous Windows, -// faute d'équivalent, on utilise JEAN_HOME\bin ajouté au PATH utilisateur. -func installedExePath() string { +// faute d'équivalent, on utilise AJEAN_HOME\bin ajouté au PATH utilisateur. +func installedExePath() string { return exePathNamed("ajean") } + +// legacyExePath est l'emplacement d'avant le renommage. L'installation continue +// d'y poser un alias : `jean` reste tapable indéfiniment. Ça ne coûte qu'un lien +// et ça préserve les habitudes, les scripts cron, les alias shell et les tutos +// déjà écrits par les utilisateurs — dont on ne contrôle aucun. +func legacyExePath() string { return exePathNamed("jean") } + +func exePathNamed(name string) string { if runtime.GOOS == "windows" { - return filepath.Join(AjeanHome(), "bin", "jean.exe") + return filepath.Join(AjeanHome(), "bin", name+".exe") } - return "/usr/local/bin/jean" + return "/usr/local/bin/" + name } func currentPaths() ajeanPaths { diff --git a/internal/ajean/sys_service_darwin.go b/internal/ajean/sys_service_darwin.go index 4a167f5..c277e46 100644 --- a/internal/ajean/sys_service_darwin.go +++ b/internal/ajean/sys_service_darwin.go @@ -18,8 +18,25 @@ import ( // le support macOS était absent (le code systemd/Linux était utilisé par erreur, // cf. issue #4). Implémentation prudente basée sur launchctl load/unload/list. -// launchdLabel dérive le label launchd du service (ex. "com.jean.jean"). -func launchdLabel(svc string) string { return "com.jean." + svc } +// launchdLabel dérive le label launchd du service (ex. "com.ajean.ajean"), en +// conservant le préfixe hérité « com.jean. » tant que c'est LUI qui est +// réellement installé : le plist d'un daemon déjà chargé ne se renomme pas tout +// seul, et viser le mauvais label reviendrait à ne plus voir le service. +func launchdLabel(svc string) string { + modern := "com.ajean." + svc + if _, err := os.Stat("/Library/LaunchDaemons/" + modern + ".plist"); err == nil { + return modern + } + if legacy := "com.jean." + svc; fileExists("/Library/LaunchDaemons/" + legacy + ".plist") { + return legacy + } + return modern +} + +func fileExists(p string) bool { + _, err := os.Stat(p) + return err == nil +} // launchdPlistPath : chemin du LaunchDaemon (domaine système, exécuté par root // puis abaissé à l'utilisateur cible via la clé UserName du plist). diff --git a/internal/ajean/sys_unitname.go b/internal/ajean/sys_unitname.go new file mode 100644 index 0000000..711e81f --- /dev/null +++ b/internal/ajean/sys_unitname.go @@ -0,0 +1,49 @@ +package ajean + +import "os" + +// Nom des unités système, pendant et après le renommage jean → ajean. +// +// Le problème : une mise à jour remplace le BINAIRE, pas les unités systemd. +// Un serveur déjà installé continue donc de tourner sous « jean.service » et +// « jean-link.service », qui pointent sur /usr/local/bin/jean. Si le binaire +// renommé partait du principe que les unités s'appellent désormais « ajean* », +// le premier `systemctl restart` échouerait — et sur un serveur distant, ça veut +// dire l'accès coupé, sans terminal pour réparer. +// +// La solution retenue n'est PAS de migrer les unités au démarrage (il faudrait +// root, et un échec laisserait la machine sans service). C'est de résoudre le +// nom à l'usage : on utilise l'unité qui existe réellement. Une machine ancienne +// reste sur ses unités « jean* » et fonctionne ; une machine fraîchement +// installée — ou réinstallée via `ajean install` — utilise les « ajean* ». +// Aucune fenêtre pendant laquelle plus rien ne tourne. + +// unitExists dit si un service de ce nom est installé sur la machine : unité +// systemd sous Linux, daemon launchd sous macOS (repéré par son plist, sous +// l'un ou l'autre des deux préfixes de label). Toujours faux sous Windows, où +// le « service » est un simple processus détaché suivi par un fichier PID. +func unitExists(name string) bool { + paths := []string{ + "/etc/systemd/system/" + name + ".service", + "/Library/LaunchDaemons/com.ajean." + name + ".plist", + "/Library/LaunchDaemons/com.jean." + name + ".plist", + } + for _, p := range paths { + if _, err := os.Stat(p); err == nil { + return true + } + } + return false +} + +// resolveUnitName renvoie le nom d'unité à utiliser : le nouveau s'il est +// installé, sinon l'ancien s'il l'est, sinon le nouveau (installation à venir). +func resolveUnitName(modern, legacy string) string { + if unitExists(modern) { + return modern + } + if unitExists(legacy) { + return legacy + } + return modern +} diff --git a/internal/ajean/sys_update.go b/internal/ajean/sys_update.go index f91e5c0..9717199 100644 --- a/internal/ajean/sys_update.go +++ b/internal/ajean/sys_update.go @@ -420,9 +420,9 @@ func restartAfterUpdate() (bool, string) { time.Sleep(1500 * time.Millisecond) // laisser la réponse HTTP atteindre le client // --no-block : on enregistre le job puis on rend la main ; systemd exécute le // stop/start même si ce process (et le client systemctl) sont tués entre-temps. - _ = exec.Command("systemctl", "--no-block", "restart", linkServiceName).Run() + _ = exec.Command("systemctl", "--no-block", "restart", linkServiceName()).Run() }() - return true, "Service " + linkServiceName + " redémarré automatiquement — la page va se reconnecter seule (le modèle n'est pas rechargé)." + return true, "Service " + linkServiceName() + " redémarré automatiquement — la page va se reconnecter seule (le modèle n'est pas rechargé)." } func restartHintText() string {