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 {