Renommage AJEAN (2/3) : services, binaire et releases

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-<os>-<arch> puis en copient un
jeu jean-<os>-<arch> 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.
This commit is contained in:
nathaninline committed 2026-08-04 14:07:44 +02:00
1 parent 110458c133
commit 3720b90173
11 files changed
+275 -99

No files matched your search

+12 -12
View File
@@ -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' \
'<?xml version="1.0" encoding="UTF-8"?>' \
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">' \
'<plist version="1.0"><dict>' \
'<key>CFBundleName</key><string>Jean</string>' \
'<key>CFBundleDisplayName</key><string>Jean</string>' \
'<key>CFBundleExecutable</key><string>jean</string>' \
'<key>CFBundleIdentifier</key><string>link.ajean.jean</string>' \
'<key>CFBundleIconFile</key><string>jean</string>' \
'<key>CFBundleName</key><string>AJEAN</string>' \
'<key>CFBundleDisplayName</key><string>AJEAN</string>' \
'<key>CFBundleExecutable</key><string>ajean</string>' \
'<key>CFBundleIdentifier</key><string>link.ajean.app</string>' \
'<key>CFBundleIconFile</key><string>ajean</string>' \
'<key>CFBundlePackageType</key><string>APPL</string>' \
'<key>LSMinimumSystemVersion</key><string>11.0</string>' \
'<key>LSUIElement</key><true/>' \
'<key>NSHighResolutionCapable</key><true/>' \
'</dict></plist>' > "$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:
+34 -18
View File
@@ -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' \
'<?xml version="1.0" encoding="UTF-8"?>' \
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">' \
'<plist version="1.0"><dict>' \
'<key>CFBundleName</key><string>Jean</string>' \
'<key>CFBundleDisplayName</key><string>Jean</string>' \
'<key>CFBundleExecutable</key><string>jean</string>' \
'<key>CFBundleIdentifier</key><string>link.ajean.jean</string>' \
'<key>CFBundleIconFile</key><string>jean</string>' \
'<key>CFBundleName</key><string>AJEAN</string>' \
'<key>CFBundleDisplayName</key><string>AJEAN</string>' \
'<key>CFBundleExecutable</key><string>ajean</string>' \
'<key>CFBundleIdentifier</key><string>link.ajean.app</string>' \
'<key>CFBundleIconFile</key><string>ajean</string>' \
'<key>CFBundlePackageType</key><string>APPL</string>' \
"<key>CFBundleShortVersionString</key><string>$v</string>" \
"<key>CFBundleVersion</key><string>$v</string>" \
@@ -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-<os>-<arch> » : 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
+44 -15
View File
@@ -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<ext> » en reprenant au
// passage le « jean-link<ext> » 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
}
+13 -1
View File
@@ -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()
+17 -14
View File
@@ -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)
+35 -16
View File
@@ -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)
+37 -14
View File
@@ -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,
+13 -5
View File
@@ -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 {
+19 -2
View File
@@ -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).
+49
View File
@@ -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
}
+2 -2
View File
@@ -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 {