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.
This commit is contained in:
nathaninline committed 2026-08-07 17:57:51 +02:00
1 parent 7ecd869da0
commit 1aabc0f9c7
6 files changed
+242 -283

No files matched your search

+6 -2
View File
@@ -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). // ErrBusy : une génération est déjà en cours (un seul tour à la fois).
var ErrBusy = fmt.Errorf("génération en cours") 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 // 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 // (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. // 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 { func (c *Conversation) StartTurn(text string, caps Caps, temperature float64) error {
if !healthCheck() { if !healthCheck() {
return fmt.Errorf("⏳ Le modèle est encore en train de charger — réessaie dans quelques secondes.") return errModelLoading
} }
c.mu.Lock() c.mu.Lock()
if c.Generating { 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. // progression. Renvoie ErrBusy si un tour est déjà en cours.
func (c *Conversation) CompactNow() error { func (c *Conversation) CompactNow() error {
if !healthCheck() { if !healthCheck() {
return fmt.Errorf("⏳ Le modèle est encore en train de charger — réessaie dans quelques secondes.") return errModelLoading
} }
c.mu.Lock() c.mu.Lock()
if c.Generating { if c.Generating {
+3 -1
View File
@@ -2,6 +2,7 @@ package ajean
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -9,6 +10,7 @@ import (
"time" "time"
bolt "go.etcd.io/bbolt" bolt "go.etcd.io/bbolt"
bolterrors "go.etcd.io/bbolt/errors"
) )
// store.go — l'unique endroit où AJEAN écrit son état. // 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 { func replaceKV(bucket string, m map[string]string) error {
return withDB(func(d *bolt.DB) error { return withDB(func(d *bolt.DB) error {
return d.Update(func(tx *bolt.Tx) 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 return err
} }
b, err := tx.CreateBucket([]byte(bucket)) b, err := tx.CreateBucket([]byte(bucket))
+31 -125
View File
@@ -6,18 +6,17 @@ import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"os/user"
"path/filepath" "path/filepath"
"strings"
) )
// sys_install_darwin.go — installation macOS via launchd (LaunchDaemon), équivalent // sys_install_darwin.go — la part launchd de l'installation. Le parcours commun
// de sys_install_linux.go (systemd). ⚠️ NON TESTÉ sur un vrai Mac : le support macOS // (dossiers, lien du binaire, /etc/default, chown) vit dans sys_install_unix.go.
// était totalement absent (le code systemd/Linux tournait par erreur et échouait //
// sur « systemctl: command not found » / « /etc/default/ajean », cf. issue #4). // ⚠️ NON TESTÉ sur un vrai Mac. Le support macOS a été ajouté parce que le code
// Implémentation prudente ; à valider sur une machine Apple. // 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. // WorkingDirectory, AJEAN_HOME, StandardOutPath, StandardErrorPath.
// KeepAlive/SuccessfulExit=false ≈ Restart=on-failure ; RunAtLoad relance au // KeepAlive/SuccessfulExit=false ≈ Restart=on-failure ; RunAtLoad relance au
// boot une fois chargé avec `-w`. // boot une fois chargé avec `-w`.
@@ -29,7 +28,7 @@ const launchdPlistTemplate = `<?xml version="1.0" encoding="UTF-8"?>
<key>ProgramArguments</key> <key>ProgramArguments</key>
<array> <array>
<string>%s</string> <string>%s</string>
<string>serve</string> <string>%s</string>
</array> </array>
<key>UserName</key><string>%s</string> <key>UserName</key><string>%s</string>
<key>WorkingDirectory</key><string>%s</string> <key>WorkingDirectory</key><string>%s</string>
@@ -44,127 +43,34 @@ const launchdPlistTemplate = `<?xml version="1.0" encoding="UTF-8"?>
</plist> </plist>
` `
func cmdInstall(args []string) error { // unitArgs associe chaque service à la sous-commande qu'il exécute.
if os.Geteuid() != 0 { func unitArgs() map[string]string {
return fmt.Errorf("ajean install doit être exécuté en root (sudo ajean install)") return map[string]string{serviceName(): "serve", uiUnitName: "web"}
} }
targetUser := os.Getenv("SUDO_USER")
if targetUser == "" { func installServices(targetUser, ajeanHome string) error {
targetUser = "root" exe := installedExePath()
} for name, arg := range unitArgs() {
for _, a := range args { logPath := filepath.Join(ajeanHome, name+".log")
if strings.HasPrefix(a, "--user=") { plist := fmt.Sprintf(launchdPlistTemplate,
targetUser = strings.TrimPrefix(a, "--user=") 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 return nil
} }
func cmdUninstall(args []string) error { // uninstallServices décharge et efface les deux daemons. L'interface passe
if os.Geteuid() != 0 { // AVANT le moteur : elle sait le redémarrer, l'ordre inverse pourrait le
return fmt.Errorf("ajean uninstall doit être exécuté en root") // relancer sous nos pieds.
} func uninstallServices() {
svc := serviceName() for _, name := range []string{uiUnitName, serviceName()} {
_ = exec.Command("launchctl", "unload", "-w", launchdPlistPath(svc)).Run() _ = exec.Command("launchctl", "unload", "-w", launchdPlistPath(name)).Run()
for _, p := range []string{ if err := os.Remove(launchdPlistPath(name)); err == nil {
launchdPlistPath(svc), fmt.Printf(" %s %s\n", green("✓"), launchdPlistPath(name))
"/etc/default/ajean",
installedExePath(),
} {
if err := os.Remove(p); err == nil {
fmt.Printf(" %s %s\n", green("✓"), p)
} }
} }
// 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
})
} }
+40 -155
View File
@@ -6,41 +6,22 @@ import (
"fmt" "fmt"
"os" "os"
"os/exec" "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. // 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 // Posé pour les deux : l'interface, qui tourne sans privilèges, doit pouvoir
// pouvoir redémarrer le moteur (bascule de preset) comme elle-même (nouveau // redémarrer le moteur (bascule de preset) comme elle-même (nouveau jeton).
// jeton de liaison). const sudoersTemplate = `# Permet à %[1]s de piloter l'unité %[2]s sans mot de passe (posé par ajean install).
const sudoersTemplate = `# Permet à %s de piloter l'unité %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
%s ALL=(root) NOPASSWD: /bin/systemctl start %s, /bin/systemctl stop %s, /bin/systemctl restart %s, /bin/systemctl enable %s, /bin/systemctl disable %s
` `
// uiUnitTemplate — le service d'interface : UI web locale, tunnel du relais et // engineUnitTemplate — le moteur : exec llama-server, supervisé directement par
// endpoint OpenAI, servis par un seul process (donc une seule conversation). // systemd. Champs : User, WorkingDirectory, ExecStart.
// Champs formatés : User, WorkingDirectory, ExecStart. const engineUnitTemplate = `[Unit]
const uiUnitTemplate = `[Unit] Description=AJEAN — moteur llama.cpp
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
After=network.target After=network.target
[Service] [Service]
@@ -62,81 +43,31 @@ CPUAccounting=yes
WantedBy=multi-user.target WantedBy=multi-user.target
` `
func cmdInstall(args []string) error { // uiUnitTemplate — l'interface : UI web locale, tunnel du relais et endpoint
if os.Geteuid() != 0 { // OpenAI, servis par un SEUL process (donc une seule conversation).
return fmt.Errorf("ajean install doit être exécuté en root (sudo ajean install)") // Champs : unité du moteur (dépendance), User, WorkingDirectory, ExecStart.
} const uiUnitTemplate = `[Unit]
targetUser := os.Getenv("SUDO_USER") Description=AJEAN — interface web + accès distant
if targetUser == "" { After=network-online.target %s.service
targetUser = "root" Wants=network-online.target
}
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()
fmt.Printf("Installation pour utilisateur %s\n", cyan(targetUser)) [Service]
fmt.Printf(" AJEAN_HOME = %s\n", ajeanHome) Type=simple
fmt.Printf(" service = %s\n", svc) User=%s
WorkingDirectory=%s
ExecStart=%s
Restart=always
RestartSec=5
// 1. Arborescence + configuration de départ [Install]
if err := provisionDataDir(); err != nil { WantedBy=multi-user.target
return err `
}
fmt.Printf(" %s arborescence prête\n", green("✓"))
// 2. Symlink current binary to /usr/local/bin/ajean func installServices(targetUser, ajeanHome string) error {
self, err := os.Executable() svc, exe := serviceName(), installedExePath()
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.
units := map[string]string{ units := map[string]string{
svc: fmt.Sprintf(serviceUnitTemplate, targetUser, ajeanHome, installedExePath()+" serve"), svc: fmt.Sprintf(engineUnitTemplate, targetUser, ajeanHome, exe+" serve"),
uiUnitName: fmt.Sprintf(uiUnitTemplate, svc, targetUser, ajeanHome, installedExePath()+" web"), uiUnitName: fmt.Sprintf(uiUnitTemplate, svc, targetUser, ajeanHome, exe+" web"),
} }
for name, body := range units { for name, body := range units {
path := "/etc/systemd/system/" + name + ".service" path := "/etc/systemd/system/" + name + ".service"
@@ -144,49 +75,23 @@ func cmdInstall(args []string) error {
return err return err
} }
fmt.Printf(" %s %s\n", green("✓"), path) fmt.Printf(" %s %s\n", green("✓"), path)
}
// 5. Sudoers : une règle par unité, pour que l'interface (sans privilèges) sudoers := fmt.Sprintf(sudoersTemplate, targetUser, name)
// puisse redémarrer le moteur ET elle-même. sudoersPath := "/etc/sudoers.d/ajean-" + name
for _, name := range []string{svc, uiUnitName} { if err := os.WriteFile(sudoersPath, []byte(sudoers), 0o440); err != nil {
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 {
return err 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() _ = 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 return nil
} }
func cmdUninstall(args []string) error { // uninstallServices arrête, désactive et efface les deux unités. L'interface
if os.Geteuid() != 0 { // passe AVANT le moteur : elle sait le redémarrer, l'ordre inverse pourrait le
return fmt.Errorf("ajean uninstall doit être exécuté en root") // relancer sous nos pieds.
} func uninstallServices() {
svc := serviceName() 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} { for _, name := range []string{uiUnitName, svc} {
_ = exec.Command("systemctl", "stop", name).Run() _ = exec.Command("systemctl", "stop", name).Run()
_ = exec.Command("systemctl", "disable", name).Run() _ = exec.Command("systemctl", "disable", name).Run()
@@ -196,30 +101,10 @@ func cmdUninstall(args []string) error {
"/etc/systemd/system/" + uiUnitName + ".service", "/etc/systemd/system/" + uiUnitName + ".service",
"/etc/sudoers.d/ajean-" + svc, "/etc/sudoers.d/ajean-" + svc,
"/etc/sudoers.d/ajean-" + uiUnitName, "/etc/sudoers.d/ajean-" + uiUnitName,
"/etc/default/ajean",
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)
} }
} }
_ = exec.Command("systemctl", "daemon-reload").Run() _ = 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
})
} }
+153
View File
@@ -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
})
}
+9
View File
@@ -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"]