From 1aabc0f9c7d91c6cf929ef2b2afd70eba65f514c Mon Sep 17 00:00:00 2001 From: nathaninline Date: Fri, 7 Aug 2026 17:57:51 +0200 Subject: [PATCH] Installateurs Unix fusionnes, staticcheck sans avertissement - sys_install_unix.go porte le parcours commun (arborescence, lien du binaire, /etc/default, chown) ; Linux et macOS ne gardent que leur declaration de services derriere installServices/uninstallServices (395 -> 339 lignes, et surtout une seule copie a corriger) - l'installateur macOS pose desormais les DEUX services (il n'en posait qu'un) - API bbolt depreciee remplacee, message d'erreur duplique factorise - staticcheck.conf : ST1005 ecartee, avec la raison (messages francais montres a l'utilisateur). Le projet est a zero avertissement. --- internal/ajean/chat_conversation.go | 8 +- internal/ajean/store.go | 4 +- internal/ajean/sys_install_darwin.go | 156 +++++---------------- internal/ajean/sys_install_linux.go | 195 ++++++--------------------- internal/ajean/sys_install_unix.go | 153 +++++++++++++++++++++ staticcheck.conf | 9 ++ 6 files changed, 242 insertions(+), 283 deletions(-) create mode 100644 internal/ajean/sys_install_unix.go create mode 100644 staticcheck.conf diff --git a/internal/ajean/chat_conversation.go b/internal/ajean/chat_conversation.go index c84dc6f..24e0a32 100644 --- a/internal/ajean/chat_conversation.go +++ b/internal/ajean/chat_conversation.go @@ -208,12 +208,16 @@ func (c *Conversation) state() map[string]any { // ErrBusy : une génération est déjà en cours (un seul tour à la fois). var ErrBusy = fmt.Errorf("génération en cours") +// errModelLoading est renvoyée telle quelle à l'utilisateur, dans le chat : ce +// n'est pas un défaut mais une attente, et le message doit le dire. +var errModelLoading = fmt.Errorf("⏳ Le modèle est encore en train de charger — réessaie dans quelques secondes.") + // StartTurn ajoute le message utilisateur et lance la génération EN ARRIÈRE-PLAN // (context.Background, détaché de toute connexion HTTP). Renvoie ErrBusy si un // tour est déjà en cours, ou une erreur si le modèle n'est pas prêt. func (c *Conversation) StartTurn(text string, caps Caps, temperature float64) error { if !healthCheck() { - return fmt.Errorf("⏳ Le modèle est encore en train de charger — réessaie dans quelques secondes.") + return errModelLoading } c.mu.Lock() if c.Generating { @@ -440,7 +444,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa // progression. Renvoie ErrBusy si un tour est déjà en cours. func (c *Conversation) CompactNow() error { if !healthCheck() { - return fmt.Errorf("⏳ Le modèle est encore en train de charger — réessaie dans quelques secondes.") + return errModelLoading } c.mu.Lock() if c.Generating { diff --git a/internal/ajean/store.go b/internal/ajean/store.go index f020a86..65dcf68 100644 --- a/internal/ajean/store.go +++ b/internal/ajean/store.go @@ -2,6 +2,7 @@ package ajean import ( "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -9,6 +10,7 @@ import ( "time" bolt "go.etcd.io/bbolt" + bolterrors "go.etcd.io/bbolt/errors" ) // store.go — l'unique endroit où AJEAN écrit son état. @@ -176,7 +178,7 @@ func allKV(bucket string) map[string]string { func replaceKV(bucket string, m map[string]string) error { return withDB(func(d *bolt.DB) error { return d.Update(func(tx *bolt.Tx) error { - if err := tx.DeleteBucket([]byte(bucket)); err != nil && err != bolt.ErrBucketNotFound { + if err := tx.DeleteBucket([]byte(bucket)); err != nil && !errors.Is(err, bolterrors.ErrBucketNotFound) { return err } b, err := tx.CreateBucket([]byte(bucket)) diff --git a/internal/ajean/sys_install_darwin.go b/internal/ajean/sys_install_darwin.go index 2fdce9b..a555121 100644 --- a/internal/ajean/sys_install_darwin.go +++ b/internal/ajean/sys_install_darwin.go @@ -6,18 +6,17 @@ 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/ajean », cf. issue #4). -// Implémentation prudente ; à valider sur une machine Apple. +// sys_install_darwin.go — la part launchd de l'installation. Le parcours commun +// (dossiers, lien du binaire, /etc/default, chown) vit dans sys_install_unix.go. +// +// ⚠️ NON TESTÉ sur un vrai Mac. Le support macOS a été ajouté parce que le code +// systemd tournait par erreur et échouait sur « systemctl: command not found ». +// Implémentation prudente, à valider sur une machine Apple. -// launchdPlistTemplate — champs formatés : Label, chemin du binaire, UserName, +// launchdPlistTemplate — champs : Label, binaire, argument, UserName, // WorkingDirectory, AJEAN_HOME, StandardOutPath, StandardErrorPath. // KeepAlive/SuccessfulExit=false ≈ Restart=on-failure ; RunAtLoad relance au // boot une fois chargé avec `-w`. @@ -29,7 +28,7 @@ const launchdPlistTemplate = ` ProgramArguments %s - serve + %s UserName%s WorkingDirectory%s @@ -44,127 +43,34 @@ const launchdPlistTemplate = ` ` -func cmdInstall(args []string) error { - if os.Geteuid() != 0 { - return fmt.Errorf("ajean install doit être exécuté en root (sudo ajean install)") - } - targetUser := os.Getenv("SUDO_USER") - if targetUser == "" { - targetUser = "root" - } - for _, a := range args { - if strings.HasPrefix(a, "--user=") { - targetUser = strings.TrimPrefix(a, "--user=") +// unitArgs associe chaque service à la sous-commande qu'il exécute. +func unitArgs() map[string]string { + return map[string]string{serviceName(): "serve", uiUnitName: "web"} +} + +func installServices(targetUser, ajeanHome string) error { + exe := installedExePath() + for name, arg := range unitArgs() { + logPath := filepath.Join(ajeanHome, name+".log") + plist := fmt.Sprintf(launchdPlistTemplate, + launchdLabel(name), exe, arg, targetUser, ajeanHome, ajeanHome, logPath, logPath) + path := launchdPlistPath(name) + if err := os.WriteFile(path, []byte(plist), 0o644); err != nil { + return err } + fmt.Printf(" %s %s\n", green("✓"), path) } - u, err := user.Lookup(targetUser) - if err != nil { - return fmt.Errorf("utilisateur '%s' introuvable: %w", targetUser, err) - } - ajeanHome := defaultAjeanHome() - if v := os.Getenv("AJEAN_HOME"); v != "" { - ajeanHome = v - } - svc := serviceName() - - fmt.Printf("Installation pour utilisateur %s\n", cyan(targetUser)) - fmt.Printf(" AJEAN_HOME = %s\n", ajeanHome) - fmt.Printf(" service = %s (launchd)\n", svc) - - // 1. Arborescence + configuration de départ - if err := provisionDataDir(); err != nil { - return err - } - - // 2. Symlink idempotent vers /usr/local/bin/ajean — 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 := installedExePath() - 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) - } - - // 3. /etc/default/ajean pour que les invocations CLI résolvent AJEAN_HOME. - _ = os.MkdirAll("/etc/default", 0o755) - defaults := fmt.Sprintf("# Généré par ajean install — racine des données\nAJEAN_HOME=%s\n", ajeanHome) - if err := os.WriteFile("/etc/default/ajean", []byte(defaults), 0o644); err != nil { - return err - } - fmt.Printf(" %s /etc/default/ajean\n", green("✓")) - - // 4. LaunchDaemon plist (le log va sous AJEAN_HOME, accessible au user cible) - logPath := filepath.Join(ajeanHome, svc+".log") - plist := fmt.Sprintf(launchdPlistTemplate, launchdLabel(svc), target, targetUser, ajeanHome, ajeanHome, logPath, logPath) - plistPath := launchdPlistPath(svc) - if err := os.WriteFile(plistPath, []byte(plist), 0o644); err != nil { - return err - } - fmt.Printf(" %s %s\n", green("✓"), plistPath) - - // 5. chown AJEAN_HOME au user cible - chown(ajeanHome, 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+" ajean edit")) - fmt.Printf(" (renseigne BIN, MODEL, etc.)\n") - fmt.Printf(" 2. démarre le service: %s\n", bold("sudo ajean start")) - fmt.Printf(" 3. UI web : %s\n", bold("sudo -u "+targetUser+" ajean web")) return nil } -func cmdUninstall(args []string) error { - if os.Geteuid() != 0 { - return fmt.Errorf("ajean 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/ajean", - installedExePath(), - } { - if err := os.Remove(p); err == nil { - fmt.Printf(" %s %s\n", green("✓"), p) +// uninstallServices décharge et efface les deux daemons. L'interface passe +// AVANT le moteur : elle sait le redémarrer, l'ordre inverse pourrait le +// relancer sous nos pieds. +func uninstallServices() { + for _, name := range []string{uiUnitName, serviceName()} { + _ = exec.Command("launchctl", "unload", "-w", launchdPlistPath(name)).Run() + if err := os.Remove(launchdPlistPath(name)); err == nil { + fmt.Printf(" %s %s\n", green("✓"), launchdPlistPath(name)) } } - // On ne supprime jamais AJEAN_HOME automatiquement (modèles, presets). - fmt.Println(dim("(données utilisateur conservées — supprime $AJEAN_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/internal/ajean/sys_install_linux.go b/internal/ajean/sys_install_linux.go index 3d69cf1..9df5dc0 100644 --- a/internal/ajean/sys_install_linux.go +++ b/internal/ajean/sys_install_linux.go @@ -6,41 +6,22 @@ import ( "fmt" "os" "os/exec" - "os/user" - "path/filepath" - "strings" ) +// sys_install_linux.go — la part systemd de l'installation. Le parcours commun +// (dossiers, lien du binaire, /etc/default, chown) vit dans sys_install_unix.go. + // sudoersTemplate autorise l'utilisateur à piloter UNE unité sans mot de passe. -// Posé pour les deux unités : l'interface, qui tourne sans privilèges, doit -// pouvoir redémarrer le moteur (bascule de preset) comme elle-même (nouveau -// jeton de liaison). -const sudoersTemplate = `# Permet à %s de piloter l'unité %s sans mot de passe (posé par ajean install). -%s ALL=(root) NOPASSWD: /bin/systemctl start %s, /bin/systemctl stop %s, /bin/systemctl restart %s, /bin/systemctl enable %s, /bin/systemctl disable %s +// Posé pour les deux : l'interface, qui tourne sans privilèges, doit pouvoir +// redémarrer le moteur (bascule de preset) comme elle-même (nouveau jeton). +const sudoersTemplate = `# Permet à %[1]s de piloter l'unité %[2]s sans mot de passe (posé par ajean install). +%[1]s ALL=(root) NOPASSWD: /bin/systemctl start %[2]s, /bin/systemctl stop %[2]s, /bin/systemctl restart %[2]s, /bin/systemctl enable %[2]s, /bin/systemctl disable %[2]s ` -// uiUnitTemplate — le service d'interface : UI web locale, tunnel du relais et -// endpoint OpenAI, servis par un seul process (donc une seule conversation). -// Champs formatés : User, WorkingDirectory, ExecStart. -const uiUnitTemplate = `[Unit] -Description=AJEAN interface web + accès distant -After=network-online.target %s.service -Wants=network-online.target - -[Service] -Type=simple -User=%s -WorkingDirectory=%s -ExecStart=%s -Restart=always -RestartSec=5 - -[Install] -WantedBy=multi-user.target -` - -const serviceUnitTemplate = `[Unit] -Description=AJEAN llama.cpp server +// engineUnitTemplate — le moteur : exec llama-server, supervisé directement par +// systemd. Champs : User, WorkingDirectory, ExecStart. +const engineUnitTemplate = `[Unit] +Description=AJEAN — moteur llama.cpp After=network.target [Service] @@ -62,81 +43,31 @@ CPUAccounting=yes WantedBy=multi-user.target ` -func cmdInstall(args []string) error { - if os.Geteuid() != 0 { - return fmt.Errorf("ajean install doit être exécuté en root (sudo ajean 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) - } - ajeanHome := defaultAjeanHome() - if v := os.Getenv("AJEAN_HOME"); v != "" { - ajeanHome = v - } - svc := serviceName() +// uiUnitTemplate — l'interface : UI web locale, tunnel du relais et endpoint +// OpenAI, servis par un SEUL process (donc une seule conversation). +// Champs : unité du moteur (dépendance), User, WorkingDirectory, ExecStart. +const uiUnitTemplate = `[Unit] +Description=AJEAN — interface web + accès distant +After=network-online.target %s.service +Wants=network-online.target - fmt.Printf("Installation pour utilisateur %s\n", cyan(targetUser)) - fmt.Printf(" AJEAN_HOME = %s\n", ajeanHome) - fmt.Printf(" service = %s\n", svc) +[Service] +Type=simple +User=%s +WorkingDirectory=%s +ExecStart=%s +Restart=always +RestartSec=5 - // 1. Arborescence + configuration de départ - if err := provisionDataDir(); err != nil { - return err - } - fmt.Printf(" %s arborescence prête\n", green("✓")) +[Install] +WantedBy=multi-user.target +` - // 2. Symlink current binary to /usr/local/bin/ajean - self, err := os.Executable() - if err != nil { - return err - } - if rp, e := filepath.EvalSymlinks(self); e == nil { - self = rp - } - target := installedExePath() - // Garde-fou (issue #5) : si on tourne DÉJÀ depuis la cible (l'utilisateur a - // posé le binaire dans /usr/local/bin/ajean puis lancé `sudo ajean install`), - // ne surtout pas Remove+Symlink sur soi-même — ça effacerait le vrai binaire - // et créerait /usr/local/bin/ajean -> /usr/local/bin/ajean (« Too many levels - // of symbolic links »). On compare les chemins réels (résolus). - 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 { - // fall back to copy if symlink fails (e.g. cross-fs) - if data, err := os.ReadFile(self); err == nil { - _ = os.WriteFile(target, data, 0o755) - } - } - fmt.Printf(" %s %s -> %s\n", green("✓"), target, self) - } - - // 3. /etc/default/ajean, pour que les invocations root résolvent AJEAN_HOME. - defaults := fmt.Sprintf("# Généré par ajean install — racine des données\nAJEAN_HOME=%s\n", ajeanHome) - if err := os.WriteFile("/etc/default/ajean", []byte(defaults), 0o644); err != nil { - return err - } - fmt.Printf(" %s /etc/default/ajean\n", green("✓")) - - // 4. Les deux unités : le moteur (exec llama-server) et l'interface. +func installServices(targetUser, ajeanHome string) error { + svc, exe := serviceName(), installedExePath() units := map[string]string{ - svc: fmt.Sprintf(serviceUnitTemplate, targetUser, ajeanHome, installedExePath()+" serve"), - uiUnitName: fmt.Sprintf(uiUnitTemplate, svc, targetUser, ajeanHome, installedExePath()+" web"), + svc: fmt.Sprintf(engineUnitTemplate, targetUser, ajeanHome, exe+" serve"), + uiUnitName: fmt.Sprintf(uiUnitTemplate, svc, targetUser, ajeanHome, exe+" web"), } for name, body := range units { path := "/etc/systemd/system/" + name + ".service" @@ -144,49 +75,23 @@ func cmdInstall(args []string) error { return err } fmt.Printf(" %s %s\n", green("✓"), path) - } - // 5. Sudoers : une règle par unité, pour que l'interface (sans privilèges) - // puisse redémarrer le moteur ET elle-même. - for _, name := range []string{svc, uiUnitName} { - sudoers := fmt.Sprintf(sudoersTemplate, targetUser, name, targetUser, name, name, name, name, name) - path := "/etc/sudoers.d/ajean-" + name - if err := os.WriteFile(path, []byte(sudoers), 0o440); err != nil { + sudoers := fmt.Sprintf(sudoersTemplate, targetUser, name) + sudoersPath := "/etc/sudoers.d/ajean-" + name + if err := os.WriteFile(sudoersPath, []byte(sudoers), 0o440); err != nil { return err } - fmt.Printf(" %s %s\n", green("✓"), path) + fmt.Printf(" %s %s\n", green("✓"), sudoersPath) } - - // 6. chown AJEAN_HOME contents to target user - chown(ajeanHome, u) - - // 7. systemd reload _ = exec.Command("systemctl", "daemon-reload").Run() - - 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+" ajean edit")) - fmt.Printf(" (renseigne BIN, MODEL, etc.)\n") - fmt.Printf(" 2. démarre le service: %s\n", bold("sudo -u "+targetUser+" ajean start")) - fmt.Printf(" 3. UI web : %s\n", bold("sudo -u "+targetUser+" ajean web")) return nil } -func cmdUninstall(args []string) error { - if os.Geteuid() != 0 { - return fmt.Errorf("ajean uninstall doit être exécuté en root") - } +// uninstallServices arrête, désactive et efface les deux unités. L'interface +// passe AVANT le moteur : elle sait le redémarrer, l'ordre inverse pourrait le +// relancer sous nos pieds. +func uninstallServices() { svc := serviceName() - keepData := false - for _, a := range args { - if a == "--purge" { - keepData = false - } - if a == "--keep-data" { - keepData = true - } - } for _, name := range []string{uiUnitName, svc} { _ = exec.Command("systemctl", "stop", name).Run() _ = exec.Command("systemctl", "disable", name).Run() @@ -196,30 +101,10 @@ func cmdUninstall(args []string) error { "/etc/systemd/system/" + uiUnitName + ".service", "/etc/sudoers.d/ajean-" + svc, "/etc/sudoers.d/ajean-" + uiUnitName, - "/etc/default/ajean", - installedExePath(), } { if err := os.Remove(p); err == nil { fmt.Printf(" %s %s\n", green("✓"), p) } } _ = exec.Command("systemctl", "daemon-reload").Run() - if !keepData { - fmt.Println(dim("(données utilisateur conservées — supprime $AJEAN_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/internal/ajean/sys_install_unix.go b/internal/ajean/sys_install_unix.go new file mode 100644 index 0000000..c02c7be --- /dev/null +++ b/internal/ajean/sys_install_unix.go @@ -0,0 +1,153 @@ +//go:build !windows + +package ajean + +import ( + "fmt" + "os" + "os/user" + "path/filepath" + "strings" +) + +// sys_install_unix.go — installation et désinstallation, partie commune à Linux +// et macOS. Les deux plateformes font exactement la même chose, à un détail +// près : la façon de déclarer les services (systemd d'un côté, launchd de +// l'autre). Ce détail vit dans sys_install_linux.go / sys_install_darwin.go, +// derrière deux fonctions : installServices et uninstallServices. +// +// Avant, chaque plateforme avait sa copie intégrale du parcours : deux fichiers +// identiques à 60 %, donc toute correction à faire deux fois. Comme le support +// macOS n'est pas testé sur une vraie machine, c'est précisément là qu'une +// divergence passait inaperçue. + +func cmdInstall(args []string) error { + if os.Geteuid() != 0 { + return fmt.Errorf("ajean install doit être exécuté en root (sudo ajean 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) + } + ajeanHome := defaultAjeanHome() + if v := os.Getenv("AJEAN_HOME"); v != "" { + ajeanHome = v + } + + fmt.Printf("Installation pour utilisateur %s\n", cyan(targetUser)) + fmt.Printf(" AJEAN_HOME = %s\n", ajeanHome) + fmt.Printf(" services = %s + %s\n", serviceName(), uiUnitName) + + // 1. Arborescence + configuration de départ. + if err := provisionDataDir(); err != nil { + return err + } + fmt.Printf(" %s arborescence prête\n", green("✓")) + + // 2. Lien vers /usr/local/bin/ajean. + if err := linkInstalledExe(); err != nil { + return err + } + + // 3. /etc/default/ajean, pour que les invocations root résolvent AJEAN_HOME. + if err := os.MkdirAll("/etc/default", 0o755); err != nil { + return err + } + defaults := fmt.Sprintf("# Généré par ajean install — racine des données\nAJEAN_HOME=%s\n", ajeanHome) + if err := os.WriteFile("/etc/default/ajean", []byte(defaults), 0o644); err != nil { + return err + } + fmt.Printf(" %s /etc/default/ajean\n", green("✓")) + + // 4. Déclaration des services (systemd ou launchd selon la plateforme). + if err := installServices(targetUser, ajeanHome); err != nil { + return err + } + + // 5. AJEAN_HOME appartient à l'utilisateur cible : les deux services et les + // commandes qu'il tape doivent pouvoir écrire dans la base. + chown(ajeanHome, u) + + fmt.Println() + fmt.Printf("%s installation terminée.\n", green("[ok]")) + fmt.Printf("\nProchaines étapes :\n") + fmt.Printf(" 1. édite la config : %s (renseigne BIN, MODEL)\n", bold("sudo -u "+targetUser+" ajean edit")) + fmt.Printf(" 2. démarre le moteur : %s\n", bold("sudo ajean start")) + fmt.Printf(" 3. démarre l'interface: %s\n", bold("sudo ajean ui start")) + return nil +} + +// linkInstalledExe pose /usr/local/bin/ajean sur le binaire courant. +// +// Garde-fou (issue #5) : si on tourne DÉJÀ depuis la cible — l'utilisateur a +// posé le binaire là puis lancé `sudo ajean install` — il ne faut surtout pas +// Remove+Symlink sur soi-même : ça effacerait le vrai binaire et créerait un +// lien vers lui-même (« Too many levels of symbolic links »). On compare les +// chemins réels, résolus. +func linkInstalledExe() error { + self, err := os.Executable() + if err != nil { + return err + } + if rp, e := filepath.EvalSymlinks(self); e == nil { + self = rp + } + target := installedExePath() + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + if tp, e := filepath.EvalSymlinks(target); e == nil && tp == self { + fmt.Printf(" %s %s est déjà le binaire installé (aucun lien à créer)\n", green("✓"), target) + return nil + } + _ = os.Remove(target) + if err := os.Symlink(self, target); err != nil { + // Repli sur une copie si le lien échoue (volumes différents). + data, rerr := os.ReadFile(self) + if rerr != nil { + return rerr + } + if werr := os.WriteFile(target, data, 0o755); werr != nil { + return werr + } + } + fmt.Printf(" %s %s -> %s\n", green("✓"), target, self) + return nil +} + +func cmdUninstall(args []string) error { + if os.Geteuid() != 0 { + return fmt.Errorf("ajean uninstall doit être exécuté en root") + } + uninstallServices() + for _, p := range []string{"/etc/default/ajean", installedExePath()} { + if err := os.Remove(p); err == nil { + fmt.Printf(" %s %s\n", green("✓"), p) + } + } + fmt.Println(dim("(données utilisateur conservées — supprime $AJEAN_HOME manuellement si tu veux purger)")) + fmt.Println(green("[ok]") + " désinstallé") + return nil +} + +// chown donne récursivement path à l'utilisateur/groupe indiqué. +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/staticcheck.conf b/staticcheck.conf new file mode 100644 index 0000000..9b77ab6 --- /dev/null +++ b/staticcheck.conf @@ -0,0 +1,9 @@ +# ST1005 exige des messages d'erreur en minuscule et sans ponctuation finale. +# La règle suppose des erreurs anglaises destinées à être enchâssées dans une +# phrase plus longue. Ici, une bonne partie des erreurs sont des messages +# FRANÇAIS affichés tels quels à l'utilisateur, dans le chat ou dans le +# terminal (« ⏳ Le modèle est encore en train de charger — réessaie dans +# quelques secondes. »). Les rendre conformes reviendrait à dégrader le produit +# pour satisfaire l'outil. On désactive donc la règle, plutôt que de laisser +# traîner des avertissements permanents qu'on apprendrait à ignorer. +checks = ["inherit", "-ST1005"]