mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-20 05:28:15 +02:00
macOS: support launchd (install/start/stop/logs) au lieu du code systemd hérité (#4) — non testé sur Mac. Isole le code Linux dans *_linux.go, ajoute *_darwin.go
This commit is contained in:
1 parent
2862a2f27a
commit
deb05f50ea
5 files changed
+309
-3
No files matched your search
@@ -0,0 +1,202 @@
|
|||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"os/user"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// install_darwin.go — installation macOS via launchd (LaunchDaemon), équivalent
|
||||||
|
// de 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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
//go:build unix
|
//go:build linux
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
+2
-1
@@ -10,7 +10,8 @@ import (
|
|||||||
|
|
||||||
// service.go holds the platform-neutral pieces of service management. The
|
// service.go holds the platform-neutral pieces of service management. The
|
||||||
// actual start/stop/restart/status/logs implementation is platform-specific:
|
// actual start/stop/restart/status/logs implementation is platform-specific:
|
||||||
// - service_unix.go → systemd (systemctl/journalctl)
|
// - service_linux.go → systemd (systemctl/journalctl)
|
||||||
|
// - service_darwin.go → launchd (launchctl)
|
||||||
// - service_windows.go → PID-file background process supervisor
|
// - service_windows.go → PID-file background process supervisor
|
||||||
//
|
//
|
||||||
// editConfig and showVram live here because they work the same everywhere.
|
// editConfig and showVram live here because they work the same everywhere.
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// service_darwin.go — gestion du service via launchd (LaunchDaemon), équivalent
|
||||||
|
// macOS de service_linux.go (systemd). ⚠️ NON TESTÉ sur un vrai Mac : jusqu'ici
|
||||||
|
// 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 }
|
||||||
|
|
||||||
|
// launchdPlistPath : chemin du LaunchDaemon (domaine système, exécuté par root
|
||||||
|
// puis abaissé à l'utilisateur cible via la clé UserName du plist).
|
||||||
|
func launchdPlistPath(svc string) string {
|
||||||
|
return "/Library/LaunchDaemons/" + launchdLabel(svc) + ".plist"
|
||||||
|
}
|
||||||
|
|
||||||
|
// launchdLogPath : sortie standard/erreur du service, sous JEAN_HOME (accessible
|
||||||
|
// en écriture par l'utilisateur du service après le chown de l'installation).
|
||||||
|
func launchdLogPath() string { return filepath.Join(JeanHome(), serviceName()+".log") }
|
||||||
|
|
||||||
|
// serviceAction mappe start/stop/restart/enable/disable sur launchctl. `load -w`
|
||||||
|
// (re)active le service ET le rend persistant au boot ; `unload -w` le désactive.
|
||||||
|
func serviceAction(action string) error {
|
||||||
|
svc := serviceName()
|
||||||
|
plist := launchdPlistPath(svc)
|
||||||
|
run := func(args ...string) error {
|
||||||
|
bin, a := "launchctl", args
|
||||||
|
if os.Geteuid() != 0 {
|
||||||
|
bin, a = "sudo", append([]string{"-n", "launchctl"}, args...)
|
||||||
|
}
|
||||||
|
cmd := exec.Command(bin, a...)
|
||||||
|
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
switch action {
|
||||||
|
case "start", "enable":
|
||||||
|
if err := run("load", "-w", plist); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return checkStarted(svc)
|
||||||
|
case "stop", "disable":
|
||||||
|
if err := run("unload", "-w", plist); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println(green("[ok]") + " arrêté")
|
||||||
|
return nil
|
||||||
|
case "restart":
|
||||||
|
_ = run("unload", plist) // best-effort : peut ne pas être chargé
|
||||||
|
if err := run("load", "-w", plist); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return checkStarted(svc)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("action inconnue: %s", action)
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkStarted(svc string) error {
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
if serviceIsActive() {
|
||||||
|
fmt.Printf("%s %s: actif\n", green("[ok]"), svc)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Printf("%s %s: non démarré — derniers logs :\n", red("[ERREUR]"), svc)
|
||||||
|
fmt.Println("------------------------------------------------")
|
||||||
|
if b, err := os.ReadFile(launchdLogPath()); err == nil {
|
||||||
|
lines := strings.Split(strings.TrimRight(string(b), "\n"), "\n")
|
||||||
|
if len(lines) > 20 {
|
||||||
|
lines = lines[len(lines)-20:]
|
||||||
|
}
|
||||||
|
fmt.Println(strings.Join(lines, "\n"))
|
||||||
|
}
|
||||||
|
fmt.Println("------------------------------------------------")
|
||||||
|
fmt.Printf("→ jean logs pour plus de détails\n→ jean edit pour corriger config.env\n")
|
||||||
|
return fmt.Errorf("service %s non démarré", svc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func serviceLogs() error {
|
||||||
|
// tail -f du fichier de log défini dans le plist (StandardOut/ErrorPath).
|
||||||
|
cmd := exec.Command("tail", "-n", "80", "-f", launchdLogPath())
|
||||||
|
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// serviceIsActive : le service est chargé ET possède un PID courant. `launchctl
|
||||||
|
// list <label>` renvoie un dict incluant la clé "PID" uniquement quand il tourne.
|
||||||
|
func serviceIsActive() bool {
|
||||||
|
out, err := exec.Command("launchctl", "list", launchdLabel(serviceName())).Output()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(string(out), "\"PID\"")
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
//go:build unix
|
//go:build linux
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
Reference in new issue
Block a user