Files
Loki/internal/ajean/sys_install_darwin.go
T
nathaninline 110458c133 Renommage AJEAN (1/3) : module, chemins de donnees et auto-MAJ
Socle du renommage jean -> AJEAN, sans aucun impact visible pour le parc
installe. Trois briques :

- module github.com/nathaninline/ajean, cmd/ajean, internal/ajean,
  package ajean. Purement interne.

- AjeanHome() remplace JeanHome(). Les defauts deviennent
  %ProgramData%\ajean et /etc/ajean, avec migration du dossier existant
  au premier lancement. La migration est un os.Rename dans le dossier
  parent de l'ancien chemin : intra-volume, donc atomique et instantane
  meme avec des .gguf de plusieurs dizaines de Go. Si le rename echoue
  (handle ouvert sous Windows, droits, volume en lecture seule) on
  CONTINUE sur l'ancien chemin et on retentera : jamais de copie, jamais
  de suppression, le pire cas est "rien n'a bouge".
  $AJEAN_HOME et $JEAN_HOME restent tous deux honores, ainsi que
  /etc/default/ajean et /etc/default/jean : un chemin impose a la main
  est un ordre, on ne le migre pas.

- l'auto-MAJ accepte desormais ajean-<os>-<arch> ET jean-<os>-<arch>,
  dans cet ordre. Indispensable dans les deux sens : les binaires deja
  installes ne connaissent que jean-*, et ce binaire-ci doit pouvoir
  s'installer depuis une release anterieure au renommage.

Couvert par des tests, dont le repli quand un handle ouvert bloque le
rename sous Windows.
2026-08-04 13:59:25 +02:00

203 lines
6.3 KiB
Go

//go:build darwin
package ajean
import (
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
"strings"
)
// sys_install_darwin.go — installation macOS via launchd (LaunchDaemon), équivalent
// de sys_install_linux.go (systemd). ⚠️ NON TESTÉ sur un vrai Mac : le support macOS
// était totalement absent (le code systemd/Linux tournait par erreur et échouait
// sur « systemctl: command not found » / « /etc/default/jean », cf. issue #4).
// Implémentation prudente ; à valider sur une machine Apple.
const configTemplate = `# Configuration JEAN — édite-moi puis: jean restart
# Le service launchd lit ce fichier et lance ton binaire llama.cpp.
# Chemins
BIN="/usr/local/bin/llama-server"
MODEL="/Users/USER/models/your-model.gguf"
# Serveur
PORT="8080"
HOST="0.0.0.0"
# Inference
CTX="32768"
BATCH="2048"
UBATCH="512"
NGL="999"
# Args supplémentaires passés à llama-server
EXTRA_ARGS=""
`
// launchdPlistTemplate — champs formatés : Label, UserName, WorkingDirectory,
// JEAN_HOME, StandardOutPath, StandardErrorPath. KeepAlive/SuccessfulExit=false
// ≈ Restart=on-failure ; RunAtLoad relance au boot une fois chargé avec `-w`.
const launchdPlistTemplate = `<?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>Label</key><string>%s</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/jean</string>
<string>serve</string>
</array>
<key>UserName</key><string>%s</string>
<key>WorkingDirectory</key><string>%s</string>
<key>EnvironmentVariables</key>
<dict><key>JEAN_HOME</key><string>%s</string></dict>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key>
<dict><key>SuccessfulExit</key><false/></dict>
<key>StandardOutPath</key><string>%s</string>
<key>StandardErrorPath</key><string>%s</string>
</dict>
</plist>
`
func cmdInstall(args []string) error {
if os.Geteuid() != 0 {
return fmt.Errorf("jean install doit être exécuté en root (sudo jean install)")
}
targetUser := os.Getenv("SUDO_USER")
if targetUser == "" {
targetUser = "root"
}
for _, a := range args {
if strings.HasPrefix(a, "--user=") {
targetUser = strings.TrimPrefix(a, "--user=")
}
}
u, err := user.Lookup(targetUser)
if err != nil {
return fmt.Errorf("utilisateur '%s' introuvable: %w", targetUser, err)
}
jeanHome := defaultJeanHome()
if v := os.Getenv("JEAN_HOME"); v != "" {
jeanHome = v
}
svc := serviceName()
fmt.Printf("Installation pour utilisateur %s\n", cyan(targetUser))
fmt.Printf(" JEAN_HOME = %s\n", jeanHome)
fmt.Printf(" service = %s (launchd)\n", svc)
// 1. Répertoires
for _, d := range []string{jeanHome, filepath.Join(jeanHome, "configs"), filepath.Join(jeanHome, "SKILLS")} {
if err := os.MkdirAll(d, 0o755); err != nil {
return err
}
}
// 2. config.env si absent
conf := filepath.Join(jeanHome, "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 {
return err
}
fmt.Printf(" %s écrit %s\n", green("✓"), conf)
}
// 3. Symlink idempotent vers /usr/local/bin/jean — même garde-fou que Linux
// (issue #5 : ne pas se lier sur soi-même si on tourne déjà depuis la cible).
self, err := os.Executable()
if err != nil {
return err
}
if rp, e := filepath.EvalSymlinks(self); e == nil {
self = rp
}
target := "/usr/local/bin/jean"
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
alreadyInPlace := false
if tp, e := filepath.EvalSymlinks(target); e == nil && tp == self {
alreadyInPlace = true
}
if alreadyInPlace {
fmt.Printf(" %s %s est déjà le binaire installé (aucun lien à créer)\n", green("✓"), target)
} else {
_ = os.Remove(target)
if err := os.Symlink(self, target); err != nil {
if data, err := os.ReadFile(self); err == nil {
_ = os.WriteFile(target, data, 0o755)
}
}
fmt.Printf(" %s %s -> %s\n", green("✓"), target, self)
}
// 4. /etc/default/jean pour que les invocations CLI résolvent 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 {
return err
}
fmt.Printf(" %s /etc/default/jean\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)
plistPath := launchdPlistPath(svc)
if err := os.WriteFile(plistPath, []byte(plist), 0o644); err != nil {
return err
}
fmt.Printf(" %s %s\n", green("✓"), plistPath)
// 6. chown JEAN_HOME au user cible
chown(jeanHome, u)
fmt.Println()
fmt.Printf("%s installation terminée.\n", green("[ok]"))
fmt.Printf("\nProchaines étapes :\n")
fmt.Printf(" 1. édite la config : %s\n", bold("sudo -u "+targetUser+" jean edit"))
fmt.Printf(" (renseigne BIN, MODEL, etc.)\n")
fmt.Printf(" 2. démarre le service: %s\n", bold("sudo jean start"))
fmt.Printf(" 3. UI web : %s\n", bold("sudo -u "+targetUser+" jean web"))
return nil
}
func cmdUninstall(args []string) error {
if os.Geteuid() != 0 {
return fmt.Errorf("jean uninstall doit être exécuté en root")
}
svc := serviceName()
_ = exec.Command("launchctl", "unload", "-w", launchdPlistPath(svc)).Run()
for _, p := range []string{
launchdPlistPath(svc),
"/etc/default/jean",
"/usr/local/bin/jean",
} {
if err := os.Remove(p); err == nil {
fmt.Printf(" %s %s\n", green("✓"), p)
}
}
// On ne supprime jamais JEAN_HOME automatiquement (modèles, configs).
fmt.Println(dim("(données utilisateur conservées — supprime $JEAN_HOME manuellement si tu veux purger)"))
fmt.Println(green("[ok]") + " désinstallé")
return nil
}
// chown recursively changes ownership of path to the given user/group.
func chown(path string, u *user.User) {
var uid, gid int
fmt.Sscanf(u.Uid, "%d", &uid)
fmt.Sscanf(u.Gid, "%d", &gid)
filepath.Walk(path, func(p string, info os.FileInfo, err error) error {
if err == nil {
_ = os.Chown(p, uid, gid)
}
return nil
})
}