diff --git a/install_darwin.go b/install_darwin.go new file mode 100644 index 0000000..a9ed84a --- /dev/null +++ b/install_darwin.go @@ -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 = ` + + + + Label%s + ProgramArguments + + /usr/local/bin/jean + serve + + UserName%s + WorkingDirectory%s + EnvironmentVariables + JEAN_HOME%s + RunAtLoad + KeepAlive + SuccessfulExit + StandardOutPath%s + StandardErrorPath%s + + +` + +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 + }) +} diff --git a/install_unix.go b/install_linux.go similarity index 99% rename from install_unix.go rename to install_linux.go index e4a8119..9f9a5d2 100644 --- a/install_unix.go +++ b/install_linux.go @@ -1,4 +1,4 @@ -//go:build unix +//go:build linux package main diff --git a/service.go b/service.go index 33ca02a..1f5cd7b 100644 --- a/service.go +++ b/service.go @@ -10,7 +10,8 @@ import ( // service.go holds the platform-neutral pieces of service management. The // 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 // // editConfig and showVram live here because they work the same everywhere. diff --git a/service_darwin.go b/service_darwin.go new file mode 100644 index 0000000..f9994e6 --- /dev/null +++ b/service_darwin.go @@ -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