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). # 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). # Sert à tester le bundle avant de décider d'une vraie release (release.yml).
name: mac-build name: mac-build
@@ -13,38 +13,38 @@ jobs:
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: with:
go-version-file: go.mod go-version-file: go.mod
- name: build + bundle Jean.app (arm64) - name: build + bundle AJEAN.app (arm64)
run: | run: |
mkdir -p dist mkdir -p dist
CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 \ 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 mkdir -p /tmp/jean.iconset
sips -s format png 'cmd/jean/icon.ico' --out /tmp/icon.png >/dev/null 2>&1 || true 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 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 sips -z $s $s /tmp/icon.png --out "/tmp/jean.iconset/icon_${s}x${s}.png" >/dev/null 2>&1 || true
done done
iconutil -c icns /tmp/jean.iconset -o /tmp/jean.icns >/dev/null 2>&1 || true 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" mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
cp dist/jean-darwin-arm64 "$app/Contents/MacOS/jean" cp dist/ajean-darwin-arm64 "$app/Contents/MacOS/ajean"
chmod +x "$app/Contents/MacOS/jean" chmod +x "$app/Contents/MacOS/ajean"
cp /tmp/jean.icns "$app/Contents/Resources/jean.icns" 2>/dev/null || true cp /tmp/jean.icns "$app/Contents/Resources/jean.icns" 2>/dev/null || true
printf '%s\n' \ printf '%s\n' \
'<?xml version="1.0" encoding="UTF-8"?>' \ '<?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">' \ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">' \
'<plist version="1.0"><dict>' \ '<plist version="1.0"><dict>' \
'<key>CFBundleName</key><string>Jean</string>' \ '<key>CFBundleName</key><string>AJEAN</string>' \
'<key>CFBundleDisplayName</key><string>Jean</string>' \ '<key>CFBundleDisplayName</key><string>AJEAN</string>' \
'<key>CFBundleExecutable</key><string>jean</string>' \ '<key>CFBundleExecutable</key><string>ajean</string>' \
'<key>CFBundleIdentifier</key><string>link.ajean.jean</string>' \ '<key>CFBundleIdentifier</key><string>link.ajean.app</string>' \
'<key>CFBundleIconFile</key><string>jean</string>' \ '<key>CFBundleIconFile</key><string>ajean</string>' \
'<key>CFBundlePackageType</key><string>APPL</string>' \ '<key>CFBundlePackageType</key><string>APPL</string>' \
'<key>LSMinimumSystemVersion</key><string>11.0</string>' \ '<key>LSMinimumSystemVersion</key><string>11.0</string>' \
'<key>LSUIElement</key><true/>' \ '<key>LSUIElement</key><true/>' \
'<key>NSHighResolutionCapable</key><true/>' \ '<key>NSHighResolutionCapable</key><true/>' \
'</dict></plist>' > "$app/Contents/Info.plist" '</dict></plist>' > "$app/Contents/Info.plist"
codesign --force --deep -s - "$app" || true 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 ls -l dist
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
with: with:
+34 -18
View File
@@ -29,11 +29,11 @@ jobs:
mkdir -p dist mkdir -p dist
# arm64 = natif ; amd64 = cross avec le SDK universel (-arch x86_64). # arm64 = natif ; amd64 = cross avec le SDK universel (-arch x86_64).
CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 \ 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_ENABLED=1 GOOS=darwin GOARCH=amd64 \
CGO_CFLAGS="-arch x86_64" CGO_LDFLAGS="-arch x86_64" \ CGO_CFLAGS="-arch x86_64" CGO_LDFLAGS="-arch x86_64" \
go build -trimpath -ldflags="-s -w" -o dist/jean-darwin-amd64 ./cmd/jean go build -trimpath -ldflags="-s -w" -o dist/ajean-darwin-amd64 ./cmd/ajean
file dist/jean-darwin-* file dist/ajean-darwin-*
- name: bundles Jean.app - name: bundles Jean.app
run: | run: |
# Un binaire nu double-cliqué ouvrirait le Terminal. On publie donc # 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. # 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. # 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 # 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. # le .ico, dont l'échec silencieux donnait une app sans icône.
mkdir -p /tmp/jean.iconset mkdir -p /tmp/jean.iconset
for s in 16 32 128 256 512; do 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 done
iconutil -c icns /tmp/jean.iconset -o /tmp/jean.icns iconutil -c icns /tmp/jean.iconset -o /tmp/jean.icns
v="${GITHUB_REF_NAME#v}" v="${GITHUB_REF_NAME#v}"
for arch in amd64 arm64; do 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" mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
cp "dist/jean-darwin-$arch" "$app/Contents/MacOS/jean" cp "dist/ajean-darwin-$arch" "$app/Contents/MacOS/ajean"
chmod +x "$app/Contents/MacOS/jean" chmod +x "$app/Contents/MacOS/ajean"
cp /tmp/jean.icns "$app/Contents/Resources/jean.icns" 2>/dev/null || true cp /tmp/jean.icns "$app/Contents/Resources/ajean.icns" 2>/dev/null || true
printf '%s\n' \ printf '%s\n' \
'<?xml version="1.0" encoding="UTF-8"?>' \ '<?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">' \ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">' \
'<plist version="1.0"><dict>' \ '<plist version="1.0"><dict>' \
'<key>CFBundleName</key><string>Jean</string>' \ '<key>CFBundleName</key><string>AJEAN</string>' \
'<key>CFBundleDisplayName</key><string>Jean</string>' \ '<key>CFBundleDisplayName</key><string>AJEAN</string>' \
'<key>CFBundleExecutable</key><string>jean</string>' \ '<key>CFBundleExecutable</key><string>ajean</string>' \
'<key>CFBundleIdentifier</key><string>link.ajean.jean</string>' \ '<key>CFBundleIdentifier</key><string>link.ajean.app</string>' \
'<key>CFBundleIconFile</key><string>jean</string>' \ '<key>CFBundleIconFile</key><string>ajean</string>' \
'<key>CFBundlePackageType</key><string>APPL</string>' \ '<key>CFBundlePackageType</key><string>APPL</string>' \
"<key>CFBundleShortVersionString</key><string>$v</string>" \ "<key>CFBundleShortVersionString</key><string>$v</string>" \
"<key>CFBundleVersion</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 # Signature ad-hoc : sans elle, macOS refuse purement et simplement
# de lancer un binaire arm64 non signé (pas juste un avertissement). # de lancer un binaire arm64 non signé (pas juste un avertissement).
codesign --force --deep -s - "$app" || true 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" rm -rf "dist/mac-$arch"
done done
ls -l dist ls -l dist
@@ -102,16 +106,28 @@ jobs:
# (l'app se rattache à la console du terminal parent si lancée en CLI). # (l'app se rattache à la console du terminal parent si lancée en CLI).
[ "$os" = windows ] && { ext=".exe"; ldflags="$ldflags -H=windowsgui"; } [ "$os" = windows ] && { ext=".exe"; ldflags="$ldflags -H=windowsgui"; }
GOOS=$os GOARCH=$arch CGO_ENABLED=0 \ 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 done
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v4
with: with:
name: macos name: macos
path: dist 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 - name: SHA256SUMS
run: | run: |
chmod +x dist/jean-darwin-* chmod +x dist/ajean-darwin-* dist/jean-darwin-*
(cd dist && sha256sum jean-* Jean-macos-*.zip > SHA256SUMS) (cd dist && sha256sum ajean-* jean-* *.zip > SHA256SUMS)
ls -l dist && cat dist/SHA256SUMS ls -l dist && cat dist/SHA256SUMS
- name: publier la release - name: publier la release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
+44 -15
View File
@@ -93,8 +93,16 @@ func relayURL() string {
return defaultRelayURL return defaultRelayURL
} }
// linkServiceName est l'unité systemd qui exécute le worker « jean link --foreground ». // linkUnitName / legacyLinkUnitName : l'unité systemd qui exécute le worker
const linkServiceName = "jean-link" // « 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 { func cmdLink(args []string) error {
sub := "" 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()) fmt.Printf("%s token enregistré (%s…), relais: %s\n", green("[ok]"), tok[:min(8, len(tok))], relayURL())
if linkServiceActive() { if linkServiceActive() {
fmt.Printf("%s service %s: actif\n", green("[ok]"), linkServiceName) fmt.Printf("%s service %s: actif\n", green("[ok]"), linkServiceName())
} else { } 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 return nil
case "logout": case "logout":
@@ -174,7 +182,7 @@ func startLink(force bool) error {
return err return err
} }
case linkServiceActive(): 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: default:
if err := linkServiceCtl("start"); err != nil { if err := linkServiceCtl("start"); err != nil {
return err return err
@@ -375,8 +383,29 @@ func serveLocalWebMux(mux *http.ServeMux) {
// linkPIDPath / linkLogPath : suivi du worker de lien hors systemd (macOS, // linkPIDPath / linkLogPath : suivi du worker de lien hors systemd (macOS,
// Windows), où Jean est une app de bureau lancée sans droits root. // Windows), où Jean est une app de bureau lancée sans droits root.
func linkPIDPath() string { return filepath.Join(AjeanHome(), linkServiceName+".pid") } func linkPIDPath() string { return adoptLegacyLinkFile(".pid") }
func linkLogPath() string { return filepath.Join(AjeanHome(), linkServiceName+".log") } 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 // 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) ; // 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 { if os.Geteuid() != 0 {
bin, pre = "sudo", []string{"-n", "systemctl"} 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 cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
if err := cmd.Run(); err != nil { 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 { switch action {
case "start", "restart": 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": 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 return nil
} }
@@ -434,7 +463,7 @@ func linkServiceActive() bool {
} }
return linkUserPID() > 0 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" return strings.TrimSpace(string(out)) == "active"
} }
@@ -467,12 +496,12 @@ func linkUserSvcCtl(action string) error {
} }
_ = os.Remove(linkPIDPath()) _ = os.Remove(linkPIDPath())
if action == "stop" { 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 return nil
} }
case "start": case "start":
if linkUserPID() > 0 { 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 return nil
} }
default: default:
@@ -516,7 +545,7 @@ func linkUserSvcCtl(action string) error {
_ = os.Remove(linkPIDPath()) _ = os.Remove(linkPIDPath())
return fmt.Errorf("le lien s'est arrêté aussitôt — voir %s", linkLogPath()) 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 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 legacyToolsFlag() string { return filepath.Join(AjeanHome(), ".tools_enabled") }
func apiKeyPath() string { return filepath.Join(AjeanHome(), ".api_key") } func apiKeyPath() string { return filepath.Join(AjeanHome(), ".api_key") }
func crawlKeyPath() string { return filepath.Join(AjeanHome(), ".crawl4ai_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 { func serviceName() string {
if n := os.Getenv("AJEAN_SERVICE"); n != "" {
return n
}
if n := os.Getenv("JEAN_SERVICE"); n != "" { if n := os.Getenv("JEAN_SERVICE"); n != "" {
return 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. // Color helpers (ANSI). Disabled when stdout is not a TTY.
var colorOn = isTerminal() var colorOn = isTerminal()
+17 -14
View File
@@ -81,25 +81,25 @@ func cmdInstall(args []string) error {
if err != nil { if err != nil {
return fmt.Errorf("utilisateur '%s' introuvable: %w", targetUser, err) return fmt.Errorf("utilisateur '%s' introuvable: %w", targetUser, err)
} }
jeanHome := defaultJeanHome() ajeanHome := defaultAjeanHome()
if v := os.Getenv("JEAN_HOME"); v != "" { if v := os.Getenv("JEAN_HOME"); v != "" {
jeanHome = v ajeanHome = v
} }
svc := serviceName() svc := serviceName()
fmt.Printf("Installation pour utilisateur %s\n", cyan(targetUser)) 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) fmt.Printf(" service = %s (launchd)\n", svc)
// 1. Répertoires // 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 { if err := os.MkdirAll(d, 0o755); err != nil {
return err return err
} }
} }
// 2. config.env si absent // 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) { if _, err := os.Stat(conf); os.IsNotExist(err) {
body := strings.ReplaceAll(configTemplate, "USER", targetUser) body := strings.ReplaceAll(configTemplate, "USER", targetUser)
if err := os.WriteFile(conf, []byte(body), 0o644); err != nil { 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) 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) _ = os.MkdirAll("/etc/default", 0o755)
defaults := fmt.Sprintf("# Generated by jean install — racine des configs/skills/SKILLS\nJEAN_HOME=%s\n", jeanHome) 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/jean", []byte(defaults), 0o644); err != nil { if err := os.WriteFile("/etc/default/ajean", []byte(defaults), 0o644); err != nil {
return err 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) // 5. LaunchDaemon plist (le log va sous JEAN_HOME, accessible au user cible)
logPath := filepath.Join(jeanHome, svc+".log") logPath := filepath.Join(ajeanHome, svc+".log")
plist := fmt.Sprintf(launchdPlistTemplate, launchdLabel(svc), targetUser, jeanHome, jeanHome, logPath, logPath) plist := fmt.Sprintf(launchdPlistTemplate, launchdLabel(svc), targetUser, ajeanHome, ajeanHome, logPath, logPath)
plistPath := launchdPlistPath(svc) plistPath := launchdPlistPath(svc)
if err := os.WriteFile(plistPath, []byte(plist), 0o644); err != nil { if err := os.WriteFile(plistPath, []byte(plist), 0o644); err != nil {
return err return err
@@ -155,7 +157,7 @@ func cmdInstall(args []string) error {
fmt.Printf(" %s %s\n", green("✓"), plistPath) fmt.Printf(" %s %s\n", green("✓"), plistPath)
// 6. chown JEAN_HOME au user cible // 6. chown JEAN_HOME au user cible
chown(jeanHome, u) chown(ajeanHome, u)
fmt.Println() fmt.Println()
fmt.Printf("%s installation terminée.\n", green("[ok]")) 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() _ = exec.Command("launchctl", "unload", "-w", launchdPlistPath(svc)).Run()
for _, p := range []string{ for _, p := range []string{
launchdPlistPath(svc), launchdPlistPath(svc),
"/etc/default/jean", "/etc/default/ajean",
"/usr/local/bin/jean", installedExePath(),
legacyExePath(),
} { } {
if err := os.Remove(p); err == nil { if err := os.Remove(p); err == nil {
fmt.Printf(" %s %s\n", green("✓"), p) fmt.Printf(" %s %s\n", green("✓"), p)
+35 -16
View File
@@ -76,25 +76,25 @@ func cmdInstall(args []string) error {
if err != nil { if err != nil {
return fmt.Errorf("utilisateur '%s' introuvable: %w", targetUser, err) return fmt.Errorf("utilisateur '%s' introuvable: %w", targetUser, err)
} }
jeanHome := defaultJeanHome() ajeanHome := defaultAjeanHome()
if v := os.Getenv("JEAN_HOME"); v != "" { if v := os.Getenv("JEAN_HOME"); v != "" {
jeanHome = v ajeanHome = v
} }
svc := serviceName() svc := serviceName()
fmt.Printf("Installation pour utilisateur %s\n", cyan(targetUser)) 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) fmt.Printf(" service = %s\n", svc)
// 1. Create directories // 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 { if err := os.MkdirAll(d, 0o755); err != nil {
return err return err
} }
} }
// 2. Drop a config.env if none exists // 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) { if _, err := os.Stat(conf); os.IsNotExist(err) {
body := strings.ReplaceAll(configTemplate, "USER", targetUser) body := strings.ReplaceAll(configTemplate, "USER", targetUser)
if err := os.WriteFile(conf, []byte(body), 0o644); err != nil { 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 { if rp, e := filepath.EvalSymlinks(self); e == nil {
self = rp self = rp
} }
target := "/usr/local/bin/jean" target := installedExePath()
// Garde-fou (issue #5) : si on tourne DÉJÀ depuis la cible (l'utilisateur a // 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`), // 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 // 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) fmt.Printf(" %s %s -> %s\n", green("✓"), target, self)
} }
// 4. Drop /etc/default/jean so root invocations resolve JEAN_HOME correctly. // 3 bis. Alias hérité : `jean` doit rester tapable. Des utilisateurs ont des
defaults := fmt.Sprintf("# Generated by jean install — racine des configs/skills/SKILLS\nJEAN_HOME=%s\n", jeanHome) // alias shell, des cron, des scripts de déploiement et des tutos qui appellent
if err := os.WriteFile("/etc/default/jean", []byte(defaults), 0o644); err != nil { // « 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 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) // 5. Write the systemd unit (ExecStart = `ajean serve`, no start.sh needed)
unit := fmt.Sprintf(serviceUnitTemplate, targetUser, jeanHome, "/usr/local/bin/jean serve") unit := fmt.Sprintf(serviceUnitTemplate, targetUser, ajeanHome, installedExePath()+" serve")
unitPath := "/etc/systemd/system/" + svc + ".service" unitPath := "/etc/systemd/system/" + svc + ".service"
if err := os.WriteFile(unitPath, []byte(unit), 0o644); err != nil { if err := os.WriteFile(unitPath, []byte(unit), 0o644); err != nil {
return err return err
@@ -151,14 +170,14 @@ func cmdInstall(args []string) error {
// 5. Sudoers drop-in // 5. Sudoers drop-in
sudoers := fmt.Sprintf(sudoersTemplate, targetUser, svc, targetUser, svc, svc, svc, svc, svc) 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 { if err := os.WriteFile(sudoersPath, []byte(sudoers), 0o440); err != nil {
return err return err
} }
fmt.Printf(" %s %s\n", green("✓"), sudoersPath) fmt.Printf(" %s %s\n", green("✓"), sudoersPath)
// 6. chown JEAN_HOME contents to target user // 6. chown JEAN_HOME contents to target user
chown(jeanHome, u) chown(ajeanHome, u)
// 7. systemd reload // 7. systemd reload
_ = exec.Command("systemctl", "daemon-reload").Run() _ = exec.Command("systemctl", "daemon-reload").Run()
@@ -191,9 +210,9 @@ func cmdUninstall(args []string) error {
_ = exec.Command("systemctl", "disable", svc).Run() _ = exec.Command("systemctl", "disable", svc).Run()
for _, p := range []string{ for _, p := range []string{
"/etc/systemd/system/" + svc + ".service", "/etc/systemd/system/" + svc + ".service",
"/etc/sudoers.d/jean-" + svc, "/etc/sudoers.d/ajean-" + svc,
"/etc/default/jean", "/etc/default/jean",
"/usr/local/bin/jean", installedExePath(),
} { } {
if err := os.Remove(p); err == nil { if err := os.Remove(p); err == nil {
fmt.Printf(" %s %s\n", green("✓"), p) fmt.Printf(" %s %s\n", green("✓"), p)
+37 -14
View File
@@ -97,14 +97,15 @@ func cmdInstall(args []string) error {
return nil 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 // 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, // 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 // 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 // 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. // et AJEAN démarrait depuis le fichier téléchargé, sans jamais s'installer.
func installSelf(binDir string) (string, error) { func installSelf(binDir string) (string, error) {
src, err := os.Executable() src, err := os.Executable()
@@ -112,32 +113,54 @@ func installSelf(binDir string) (string, error) {
return "", err return "", err
} }
src, _ = filepath.EvalSymlinks(src) src, _ = filepath.EvalSymlinks(src)
dst := filepath.Join(binDir, "jean.exe") dst := filepath.Join(binDir, "ajean.exe")
if strings.EqualFold(src, dst) { if strings.EqualFold(src, dst) {
installLegacyAlias(binDir, src)
return dst, nil return dst, nil
} }
if err := os.MkdirAll(binDir, 0o755); err != nil { if err := os.MkdirAll(binDir, 0o755); err != nil {
return "", err return "", err
} }
in, err := os.Open(src) if err := copyExe(src, dst); err != nil {
if err != nil {
return "", err 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() 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) out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
if err != nil { if err != nil {
return "", err return err
} }
if _, err := io.Copy(out, in); err != nil { if _, err := io.Copy(out, in); err != nil {
out.Close() out.Close()
return "", err return err
} }
if err := out.Close(); err != nil { return out.Close()
return "", err
}
return dst, nil
} }
// addToUserPath appends dir to the per-user PATH (HKCU\Environment) persistently, // 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. // 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, // (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. // faute d'équivalent, on utilise AJEAN_HOME\bin ajouté au PATH utilisateur.
func installedExePath() string { 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" { 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 { 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, // 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. // 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"). // launchdLabel dérive le label launchd du service (ex. "com.ajean.ajean"), en
func launchdLabel(svc string) string { return "com.jean." + svc } // 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 // launchdPlistPath : chemin du LaunchDaemon (domaine système, exécuté par root
// puis abaissé à l'utilisateur cible via la clé UserName du plist). // 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 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 // --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. // 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 { func restartHintText() string {