mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Correction Windows
This commit is contained in:
1 parent
a40c50c400
commit
ca0a315260
14 files changed
+973
-87
No files matched your search
@@ -36,7 +36,7 @@ Prends un binaire pré-compilé depuis la page [Releases](../../releases), ou [c
|
||||
|
||||
```bash
|
||||
# exemple : Linux x86_64
|
||||
curl -L -o jean https://github.com/nathaninline/jean/releases/latest/download/jean-linux-amd64
|
||||
curl -L -o jean https://github.com/jean-llm/jean/releases/latest/download/jean-linux-amd64
|
||||
chmod +x jean
|
||||
sudo mv jean /usr/local/bin/jean
|
||||
```
|
||||
@@ -69,6 +69,27 @@ jean test # vérifie que le modèle répond
|
||||
jean web # http://<hôte>:8090
|
||||
```
|
||||
|
||||
## Windows
|
||||
|
||||
Jean fonctionne aussi sur Windows. Les différences avec Linux :
|
||||
|
||||
- **Pas de systemd.** `jean start` lance `jean serve` en arrière-plan (processus détaché), suivi via un fichier PID. `stop` / `restart` / `status` / `logs` agissent dessus — aucun droit administrateur requis. `enable` / `disable` (démarrage au boot) ne sont pas gérés : utilise une tâche planifiée ou `sc.exe` si tu en as besoin.
|
||||
- **`JEAN_HOME`** vaut par défaut `%ProgramData%\jean` (repli `%LOCALAPPDATA%\jean`). Surcharge avec la variable d'environnement `JEAN_HOME`.
|
||||
- **`jean install`** crée seulement le dossier de données et un `config.env` de départ (pas de sudoers ni de symlink). Ajoute toi-même le dossier de `jean.exe` au `PATH` pour l'appeler de partout.
|
||||
- **`jean edit`** ouvre `notepad` par défaut (surchargeable via `%EDITOR%`).
|
||||
- **`jean tools`** exécute les commandes via `cmd /C` (au lieu de `bash`).
|
||||
|
||||
```powershell
|
||||
# depuis PowerShell
|
||||
jean install # crée %ProgramData%\jean\config.env
|
||||
jean edit # règle BIN=...\llama-server.exe et MODEL=...\modele.gguf
|
||||
jean start
|
||||
jean status
|
||||
jean logs # suit %ProgramData%\jean\jean.log
|
||||
```
|
||||
|
||||
`jean llamacpp install` peut compiler llama.cpp si `git` et `cmake` sont présents ; sinon récupère un binaire `llama-server.exe` pré-compilé et pointe `BIN` dessus avec `jean edit`.
|
||||
|
||||
## Commandes
|
||||
|
||||
```
|
||||
@@ -79,6 +100,7 @@ Service :
|
||||
edit éditer $JEAN_HOME/config.env
|
||||
set-api-key [clé] protéger l'API (Bearer) ; vide = générer, "" = retirer
|
||||
vram utilisation GPU/VRAM (nvidia-smi)
|
||||
gpu [index…] liste les GPU / choisit le(s)quel(s) utiliser (gpu all = tous)
|
||||
test vérifie que le modèle répond (health + completion)
|
||||
bench [N] mesure prefill + decode tok/s
|
||||
|
||||
@@ -118,7 +140,7 @@ update [--ref=REF_GIT] [--clean] [--no-restart] [--force]
|
||||
|
||||
## Configuration
|
||||
|
||||
Tout vit sous **`$JEAN_HOME`** (défaut `/etc/jean`). Le service lit `config.env` :
|
||||
Tout vit sous **`$JEAN_HOME`** (défaut `/etc/jean` sur Linux/macOS, `%ProgramData%\jean` sur Windows). Le service lit `config.env` :
|
||||
|
||||
| Clé | Signification | Défaut |
|
||||
|-----|---------------|--------|
|
||||
@@ -129,6 +151,7 @@ Tout vit sous **`$JEAN_HOME`** (défaut `/etc/jean`). Le service lit `config.env
|
||||
| `NGL` | couches à déporter sur le GPU | `999` |
|
||||
| `BATCH` / `UBATCH` | batch / micro-batch | `2048` / `512` |
|
||||
| `THREADS` / `THREADS_BATCH` | threads CPU | `0` (auto) |
|
||||
| `CUDA_VISIBLE_DEVICES` | GPU à utiliser (réglé par `jean gpu`) | tous |
|
||||
| `KV_TYPE` (`_K`/`_V`) | quantization du cache KV | — |
|
||||
| `REASONING` | passthrough du mode raisonnement | — |
|
||||
| `EXTRA_ARGS` | ajouté tel quel à `llama-server` | — |
|
||||
@@ -148,7 +171,7 @@ La clé API (quand elle est définie avec `jean set-api-key`) est stockée dans
|
||||
Nécessite Go 1.22+. Jean est un binaire 100 % Go (l'UI web est embarquée via `go:embed`) :
|
||||
|
||||
```bash
|
||||
git clone https://github.com/nathaninline/jean.git
|
||||
git clone https://github.com/jean-llm/jean.git
|
||||
cd jean
|
||||
CGO_ENABLED=0 go build -o jean .
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// gpu.go — sélection du/des GPU utilisés par llama-server.
|
||||
//
|
||||
// jean gpu liste les GPU et montre la sélection courante
|
||||
// jean gpu 1 n'utilise que le GPU d'index 1
|
||||
// jean gpu 0 1 utilise les GPU 0 et 1
|
||||
// jean gpu all réinitialise (tous les GPU visibles)
|
||||
//
|
||||
// La sélection est stockée dans config.env sous CUDA_VISIBLE_DEVICES ; serve.go
|
||||
// l'exporte (avec CUDA_DEVICE_ORDER=PCI_BUS_ID pour que les index correspondent
|
||||
// à ceux affichés par nvidia-smi).
|
||||
|
||||
type gpuInfo struct {
|
||||
Index int
|
||||
Name string
|
||||
MemTotal string // en MiB
|
||||
MemUsed string
|
||||
Cap string // compute capability
|
||||
}
|
||||
|
||||
func cmdGPU(args []string) error {
|
||||
if len(args) == 0 || args[0] == "list" || args[0] == "ls" {
|
||||
return gpuList()
|
||||
}
|
||||
switch args[0] {
|
||||
case "all", "reset", "none", "auto":
|
||||
return gpuSet("")
|
||||
}
|
||||
// Sinon : une liste d'index (« 1 », « 0 1 », « 0,1 »).
|
||||
gpus, err := detectGPUs()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw := strings.Join(args, ",")
|
||||
var idx []string
|
||||
for _, tok := range strings.Split(raw, ",") {
|
||||
tok = strings.TrimSpace(tok)
|
||||
if tok == "" {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.Atoi(tok)
|
||||
if err != nil {
|
||||
return fmt.Errorf("index GPU invalide: %q (attendu un nombre)", tok)
|
||||
}
|
||||
if n < 0 || n >= len(gpus) {
|
||||
return fmt.Errorf("index GPU %d hors limites (0..%d) — voir « jean gpu »", n, len(gpus)-1)
|
||||
}
|
||||
idx = append(idx, strconv.Itoa(n))
|
||||
}
|
||||
if len(idx) == 0 {
|
||||
return fmt.Errorf("aucun index fourni")
|
||||
}
|
||||
return gpuSet(strings.Join(idx, ","))
|
||||
}
|
||||
|
||||
// gpuList affiche les GPU détectés et marque ceux actuellement sélectionnés.
|
||||
func gpuList() error {
|
||||
gpus, err := detectGPUs()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sel := ReadConfig()["CUDA_VISIBLE_DEVICES"]
|
||||
selected := map[int]bool{}
|
||||
if sel != "" {
|
||||
for _, t := range strings.Split(sel, ",") {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(t)); err == nil {
|
||||
selected[n] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
for _, g := range gpus {
|
||||
mark := " "
|
||||
line := fmt.Sprintf("[%d] %s — %s/%s MiB (cc %s)", g.Index, g.Name, g.MemUsed, g.MemTotal, g.Cap)
|
||||
active := sel == "" || selected[g.Index]
|
||||
if sel != "" && selected[g.Index] {
|
||||
mark = green("● ")
|
||||
line = green(line)
|
||||
} else if sel == "" {
|
||||
mark = dim("○ ")
|
||||
} else {
|
||||
mark = dim("○ ")
|
||||
line = dim(line)
|
||||
}
|
||||
_ = active
|
||||
fmt.Printf(" %s%s\n", mark, line)
|
||||
}
|
||||
fmt.Println()
|
||||
if sel == "" {
|
||||
fmt.Printf(" Sélection : %s (tous les GPU)\n", bold("auto"))
|
||||
} else {
|
||||
fmt.Printf(" Sélection : %s (CUDA_VISIBLE_DEVICES=%s)\n", bold(sel), sel)
|
||||
}
|
||||
fmt.Printf(" %s jean gpu <index…> pour choisir, jean gpu all pour réinitialiser\n", dim("→"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// gpuSet writes (or clears) CUDA_VISIBLE_DEVICES in config.env then offers a
|
||||
// restart so the change takes effect.
|
||||
func gpuSet(value string) error {
|
||||
if err := SetConfigKey("CUDA_VISIBLE_DEVICES", value); err != nil {
|
||||
return err
|
||||
}
|
||||
if value == "" {
|
||||
fmt.Printf("%s sélection GPU réinitialisée — tous les GPU seront visibles\n", green("[ok]"))
|
||||
} else {
|
||||
fmt.Printf("%s GPU sélectionné(s) : %s\n", green("[ok]"), bold(value))
|
||||
}
|
||||
fmt.Print(dim("[info] redémarrer le service pour appliquer ? [Y/n] "))
|
||||
sc := bufio.NewScanner(os.Stdin)
|
||||
if sc.Scan() && strings.HasPrefix(strings.ToLower(strings.TrimSpace(sc.Text())), "n") {
|
||||
fmt.Println(dim("[info] pense à lancer 'jean restart'"))
|
||||
return nil
|
||||
}
|
||||
return serviceAction("restart")
|
||||
}
|
||||
|
||||
// detectGPUs queries nvidia-smi for the list of NVIDIA GPUs.
|
||||
func detectGPUs() ([]gpuInfo, error) {
|
||||
if !hasTool("nvidia-smi") {
|
||||
return nil, fmt.Errorf("nvidia-smi introuvable — sélection GPU disponible uniquement sur NVIDIA")
|
||||
}
|
||||
out, err := exec.Command("nvidia-smi",
|
||||
"--query-gpu=index,name,memory.total,memory.used,compute_cap",
|
||||
"--format=csv,noheader,nounits").Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("nvidia-smi a échoué: %w", err)
|
||||
}
|
||||
var gpus []gpuInfo
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
parts := strings.Split(line, ",")
|
||||
if len(parts) < 5 {
|
||||
continue
|
||||
}
|
||||
for i := range parts {
|
||||
parts[i] = strings.TrimSpace(parts[i])
|
||||
}
|
||||
idx, _ := strconv.Atoi(parts[0])
|
||||
gpus = append(gpus, gpuInfo{
|
||||
Index: idx, Name: parts[1], MemTotal: parts[2], MemUsed: parts[3], Cap: parts[4],
|
||||
})
|
||||
}
|
||||
if len(gpus) == 0 {
|
||||
return nil, fmt.Errorf("aucun GPU NVIDIA détecté")
|
||||
}
|
||||
return gpus, nil
|
||||
}
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
//go:build unix
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const configTemplate = `# Configuration JEAN — édite-moi puis: jean restart
|
||||
# Le service systemd lit ce fichier et lance ton binaire llama.cpp.
|
||||
|
||||
# Chemins
|
||||
BIN="/usr/local/bin/llama-server"
|
||||
MODEL="/home/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=""
|
||||
`
|
||||
|
||||
const sudoersTemplate = `# Allow %s to manage the %s systemd unit without a password (installed by jean).
|
||||
%s ALL=(root) NOPASSWD: /bin/systemctl start %s, /bin/systemctl stop %s, /bin/systemctl restart %s, /bin/systemctl enable %s, /bin/systemctl disable %s
|
||||
`
|
||||
|
||||
const serviceUnitTemplate = `[Unit]
|
||||
Description=JEAN llama.cpp server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=%s
|
||||
WorkingDirectory=%s
|
||||
ExecStart=%s
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`
|
||||
|
||||
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\n", svc)
|
||||
|
||||
// 1. Create directories
|
||||
for _, d := range []string{jeanHome, filepath.Join(jeanHome, "configs"), filepath.Join(jeanHome, "SKILLS")} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Drop a config.env if none exists
|
||||
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 current binary to /usr/local/bin/jean
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := "/usr/local/bin/jean"
|
||||
_ = 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)
|
||||
|
||||
// 4. Drop /etc/default/jean so root invocations resolve JEAN_HOME correctly.
|
||||
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. Write the systemd unit (ExecStart = `jean serve`, no start.sh needed)
|
||||
unit := fmt.Sprintf(serviceUnitTemplate, targetUser, jeanHome, "/usr/local/bin/jean serve")
|
||||
unitPath := "/etc/systemd/system/" + svc + ".service"
|
||||
if err := os.WriteFile(unitPath, []byte(unit), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf(" %s %s\n", green("✓"), unitPath)
|
||||
|
||||
// 5. Sudoers drop-in
|
||||
sudoers := fmt.Sprintf(sudoersTemplate, targetUser, svc, targetUser, svc, svc, svc, svc, svc)
|
||||
sudoersPath := "/etc/sudoers.d/jean-" + svc
|
||||
if err := os.WriteFile(sudoersPath, []byte(sudoers), 0o440); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf(" %s %s\n", green("✓"), sudoersPath)
|
||||
|
||||
// 6. chown JEAN_HOME contents to target user
|
||||
chown(jeanHome, 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+" jean edit"))
|
||||
fmt.Printf(" (renseigne BIN, MODEL, etc.)\n")
|
||||
fmt.Printf(" 2. démarre le service: %s\n", bold("sudo -u "+targetUser+" 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()
|
||||
keepData := false
|
||||
for _, a := range args {
|
||||
if a == "--purge" {
|
||||
keepData = false
|
||||
}
|
||||
if a == "--keep-data" {
|
||||
keepData = true
|
||||
}
|
||||
}
|
||||
_ = exec.Command("systemctl", "stop", svc).Run()
|
||||
_ = exec.Command("systemctl", "disable", svc).Run()
|
||||
for _, p := range []string{
|
||||
"/etc/systemd/system/" + svc + ".service",
|
||||
"/etc/sudoers.d/jean-" + svc,
|
||||
"/etc/default/jean",
|
||||
"/usr/local/bin/jean",
|
||||
} {
|
||||
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 $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
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// On Windows there's no systemd unit, sudoers, or /usr/local/bin to populate.
|
||||
// `jean install` simply provisions the data directory and a starter config; the
|
||||
// service itself is managed by the PID-file supervisor in service_windows.go
|
||||
// (jean start / stop / status), which needs no admin rights.
|
||||
|
||||
const configTemplate = `# Configuration JEAN — édite-moi puis: jean restart
|
||||
# jean serve lit ce fichier et lance ton binaire llama.cpp.
|
||||
|
||||
# Chemins (utilise des chemins Windows ; les antislashs ou slashs marchent)
|
||||
BIN="C:/llama.cpp/build/bin/Release/llama-server.exe"
|
||||
MODEL="C:/models/your-model.gguf"
|
||||
|
||||
# Serveur
|
||||
PORT="8080"
|
||||
HOST="127.0.0.1"
|
||||
|
||||
# Inference
|
||||
CTX="32768"
|
||||
BATCH="2048"
|
||||
UBATCH="512"
|
||||
NGL="999"
|
||||
|
||||
# Args supplémentaires passés à llama-server
|
||||
EXTRA_ARGS=""
|
||||
`
|
||||
|
||||
func cmdInstall(args []string) error {
|
||||
jeanHome := JeanHome()
|
||||
|
||||
fmt.Printf("Installation (Windows)\n")
|
||||
fmt.Printf(" JEAN_HOME = %s\n", jeanHome)
|
||||
fmt.Printf(" service = %s\n", serviceName())
|
||||
|
||||
// 1. Create directories
|
||||
for _, d := range []string{jeanHome, filepath.Join(jeanHome, "configs"), filepath.Join(jeanHome, "SKILLS")} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Drop a config.env if none exists
|
||||
conf := filepath.Join(jeanHome, "config.env")
|
||||
if _, err := os.Stat(conf); os.IsNotExist(err) {
|
||||
if err := os.WriteFile(conf, []byte(configTemplate), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf(" %s écrit %s\n", green("✓"), conf)
|
||||
} else {
|
||||
fmt.Printf(" %s config.env déjà présent — inchangé\n", dim("•"))
|
||||
}
|
||||
|
||||
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("jean edit"))
|
||||
fmt.Printf(" 2. démarre le service: %s\n", bold("jean start"))
|
||||
fmt.Printf(" 3. UI web : %s\n", bold("jean web"))
|
||||
fmt.Printf("\n%s pour exécuter 'jean' depuis n'importe où, ajoute son dossier au PATH.\n", dim("[info]"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func cmdUninstall(args []string) error {
|
||||
keepData := true
|
||||
for _, a := range args {
|
||||
if a == "--purge" {
|
||||
keepData = false
|
||||
}
|
||||
if a == "--keep-data" {
|
||||
keepData = true
|
||||
}
|
||||
}
|
||||
// Stop the background server if it's running.
|
||||
_ = svcStop(false)
|
||||
|
||||
if !keepData {
|
||||
jeanHome := JeanHome()
|
||||
if err := os.RemoveAll(jeanHome); err != nil {
|
||||
return fmt.Errorf("suppression de %s: %w", jeanHome, err)
|
||||
}
|
||||
fmt.Printf(" %s %s supprimé\n", green("✓"), jeanHome)
|
||||
} else {
|
||||
fmt.Println(dim("(données utilisateur conservées — relance avec --purge pour tout supprimer)"))
|
||||
}
|
||||
fmt.Println(green("[ok]") + " désinstallé")
|
||||
return nil
|
||||
}
|
||||
@@ -522,11 +522,6 @@ func requireTools(tools ...string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func serviceIsActive() bool {
|
||||
out, _ := exec.Command("systemctl", "is-active", serviceName()).Output()
|
||||
return strings.TrimSpace(string(out)) == "active"
|
||||
}
|
||||
|
||||
// gitOutput runs a git command in `dir` and returns trimmed stdout (or "").
|
||||
func gitOutput(dir string, args ...string) string {
|
||||
cmd := exec.Command("git", args...)
|
||||
|
||||
@@ -28,6 +28,8 @@ func main() {
|
||||
mustExit(cmdSetAPIKey(args))
|
||||
case "vram":
|
||||
mustExit(showVram())
|
||||
case "gpu":
|
||||
mustExit(cmdGPU(args))
|
||||
case "switch":
|
||||
mustExit(cmdSwitch(args))
|
||||
case "chat":
|
||||
@@ -67,12 +69,13 @@ func printHelp() {
|
||||
Usage: jean <commande> [args]
|
||||
|
||||
Service:
|
||||
start | stop | restart gérer le service systemd
|
||||
start | stop | restart gérer le service (systemd sous Linux, processus en arrière-plan sous Windows)
|
||||
status | logs état / logs en direct
|
||||
enable | disable auto-démarrage au boot
|
||||
edit éditer $JEAN_HOME/config.env
|
||||
set-api-key [clé] protéger l'API (clé Bearer); vide = générer, "" = retirer
|
||||
vram utilisation GPU/VRAM (nvidia-smi)
|
||||
gpu [index…] liste les GPU / choisit le(s)quel(s) utiliser (gpu all = tous)
|
||||
test vérifie que l'IA répond (health + completion)
|
||||
bench [N] mesure prefill + decode tok/s (prompt 2000 tok, N=200 par défaut)
|
||||
|
||||
@@ -96,12 +99,12 @@ Entrypoint (utilisé par jean.service) :
|
||||
serve lit config.env et exec le binaire llama-server
|
||||
|
||||
Installation:
|
||||
install installer (systemd unit, sudoers, dossiers)
|
||||
install installer (Linux: unité systemd, sudoers, dossiers ; Windows: dossiers + config)
|
||||
uninstall désinstaller
|
||||
|
||||
Env:
|
||||
JEAN_HOME racine (défaut: $HOME/JEAN, ou /home/<USER>/JEAN si root)
|
||||
EDITOR éditeur pour 'jean edit' (défaut: nano)
|
||||
JEAN_HOME racine (défaut: /etc/jean sur Linux/macOS, %%ProgramData%%\jean sur Windows)
|
||||
EDITOR éditeur pour 'jean edit' (défaut: nano sur Unix, notepad sur Windows)
|
||||
|
||||
Config: $JEAN_HOME/config.env
|
||||
`, Version)
|
||||
@@ -114,11 +117,9 @@ func mustExit(err error) {
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultJeanHome is where everything lives unless overridden.
|
||||
const DefaultJeanHome = "/etc/jean"
|
||||
|
||||
// JeanHome resolves the JEAN data directory.
|
||||
// Precedence: $JEAN_HOME → /etc/default/jean → DefaultJeanHome.
|
||||
// Precedence: $JEAN_HOME → /etc/default/jean (unix only) → defaultJeanHome().
|
||||
// defaultJeanHome() is platform-specific (see platform_unix.go / platform_windows.go).
|
||||
func JeanHome() string {
|
||||
if h := os.Getenv("JEAN_HOME"); h != "" {
|
||||
return h
|
||||
@@ -126,7 +127,7 @@ func JeanHome() string {
|
||||
if h := readEtcDefault(); h != "" {
|
||||
return h
|
||||
}
|
||||
return DefaultJeanHome
|
||||
return defaultJeanHome()
|
||||
}
|
||||
|
||||
// readEtcDefault parses /etc/default/jean for JEAN_HOME=<path>. Quiet on errors.
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
//go:build unix
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// defaultJeanHome is the data root when neither $JEAN_HOME nor /etc/default/jean
|
||||
// override it.
|
||||
func defaultJeanHome() string { return "/etc/jean" }
|
||||
|
||||
// defaultEditor is used by `jean edit` when $EDITOR is unset.
|
||||
func defaultEditor() string { return "nano" }
|
||||
|
||||
// setLibraryPath ensures llama-server can load shared libs bundled next to the
|
||||
// binary by prepending dir to LD_LIBRARY_PATH.
|
||||
func setLibraryPath(dir string) {
|
||||
ld := dir
|
||||
if existing := os.Getenv("LD_LIBRARY_PATH"); existing != "" {
|
||||
ld = ld + ":" + existing
|
||||
}
|
||||
_ = os.Setenv("LD_LIBRARY_PATH", ld)
|
||||
}
|
||||
|
||||
// execServer replaces the current process with llama-server (so systemd
|
||||
// supervises llama-server directly, as the old start.sh did with `exec`).
|
||||
// args[0] must be the binary path.
|
||||
func execServer(bin string, args []string) error {
|
||||
return syscall.Exec(bin, args, os.Environ())
|
||||
}
|
||||
|
||||
// newShellCmd builds the command used by the run_shell tool.
|
||||
func newShellCmd(ctx context.Context, command string) *exec.Cmd {
|
||||
return exec.CommandContext(ctx, "/bin/bash", "-c", command)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// defaultJeanHome is the data root when $JEAN_HOME is unset. We use
|
||||
// %ProgramData%\jean (machine-wide, the closest analogue to /etc/jean), falling
|
||||
// back to %LOCALAPPDATA%\jean for unprivileged setups.
|
||||
func defaultJeanHome() string {
|
||||
if pd := os.Getenv("ProgramData"); pd != "" {
|
||||
return filepath.Join(pd, "jean")
|
||||
}
|
||||
if la := os.Getenv("LOCALAPPDATA"); la != "" {
|
||||
return filepath.Join(la, "jean")
|
||||
}
|
||||
return filepath.Join(os.TempDir(), "jean")
|
||||
}
|
||||
|
||||
// defaultEditor is used by `jean edit` when $EDITOR is unset.
|
||||
func defaultEditor() string { return "notepad" }
|
||||
|
||||
// setLibraryPath ensures llama-server can load DLLs bundled next to the binary.
|
||||
// Windows resolves dependent DLLs via PATH (and the binary's own directory), so
|
||||
// we prepend dir to PATH.
|
||||
func setLibraryPath(dir string) {
|
||||
p := dir
|
||||
if existing := os.Getenv("PATH"); existing != "" {
|
||||
p = dir + string(os.PathListSeparator) + existing
|
||||
}
|
||||
_ = os.Setenv("PATH", p)
|
||||
}
|
||||
|
||||
// execServer runs llama-server as a child process and waits for it. Windows has
|
||||
// no exec() that replaces the current image, so `jean serve` stays alive as the
|
||||
// parent (this is the detached process the service supervisor tracks).
|
||||
// args[0] is the binary path; the rest are its arguments.
|
||||
func execServer(bin string, args []string) error {
|
||||
cmd := exec.Command(bin, args[1:]...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Env = os.Environ()
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// newShellCmd builds the command used by the run_shell tool.
|
||||
func newShellCmd(ctx context.Context, command string) *exec.Cmd {
|
||||
return exec.CommandContext(ctx, "cmd", "/C", command)
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// cmdServe replaces the historic start.sh: read config.env, build the
|
||||
@@ -21,13 +20,18 @@ func cmdServe(args []string) error {
|
||||
return fmt.Errorf("MODEL non défini dans %s", confPath())
|
||||
}
|
||||
|
||||
// Resolve LD_LIBRARY_PATH the same way start.sh did so that custom llama.cpp
|
||||
// builds with bundled shared libs continue to load their .so neighbours.
|
||||
ld := filepath.Dir(bin)
|
||||
if existing := os.Getenv("LD_LIBRARY_PATH"); existing != "" {
|
||||
ld = ld + ":" + existing
|
||||
// Make sure llama-server can find its bundled shared libraries (the .so/.dll
|
||||
// neighbours of the binary). This is platform-specific: LD_LIBRARY_PATH on
|
||||
// Linux, PATH on Windows — handled inside execServer.
|
||||
setLibraryPath(filepath.Dir(bin))
|
||||
|
||||
// Sélection GPU (jean gpu) : on filtre les devices visibles par llama-server.
|
||||
// CUDA_DEVICE_ORDER=PCI_BUS_ID garantit que les index correspondent à ceux
|
||||
// affichés par nvidia-smi (sinon CUDA réordonne par "device le plus rapide").
|
||||
if v := cfg["CUDA_VISIBLE_DEVICES"]; v != "" {
|
||||
_ = os.Setenv("CUDA_VISIBLE_DEVICES", v)
|
||||
_ = os.Setenv("CUDA_DEVICE_ORDER", "PCI_BUS_ID")
|
||||
}
|
||||
_ = os.Setenv("LD_LIBRARY_PATH", ld)
|
||||
|
||||
get := func(key, fallback string) string {
|
||||
if v, ok := cfg[key]; ok && v != "" {
|
||||
@@ -80,6 +84,7 @@ func cmdServe(args []string) error {
|
||||
fmt.Fprintf(os.Stderr, "[jean serve] %s model=%s port=%s\n",
|
||||
bin, filepath.Base(model), get("PORT", "8080"))
|
||||
|
||||
// exec replaces this process — same as start.sh's `exec`.
|
||||
return syscall.Exec(bin, llmArgs, os.Environ())
|
||||
// Hand off to the llama-server process. On Unix this replaces the current
|
||||
// process (exec); on Windows it runs as a child and waits. See platform_*.go.
|
||||
return execServer(bin, llmArgs)
|
||||
}
|
||||
+9
-56
@@ -6,68 +6,19 @@ import (
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// serviceAction wraps `systemctl <action> <svc>` with passwordless sudo where
|
||||
// it makes sense, and prints a follow-up status check after start/restart.
|
||||
func serviceAction(action string) error {
|
||||
svc := serviceName()
|
||||
needsRoot := action == "start" || action == "stop" || action == "restart" || action == "enable" || action == "disable"
|
||||
args := []string{}
|
||||
bin := "systemctl"
|
||||
if needsRoot && os.Geteuid() != 0 {
|
||||
bin = "sudo"
|
||||
args = append(args, "-n", "systemctl")
|
||||
}
|
||||
args = append(args, action, svc)
|
||||
cmd := exec.Command(bin, args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
switch action {
|
||||
case "start", "restart":
|
||||
return checkStarted(svc)
|
||||
case "stop":
|
||||
fmt.Println(green("[ok]") + " arrêté")
|
||||
case "enable":
|
||||
fmt.Println(green("[ok]") + " démarrage auto activé")
|
||||
case "disable":
|
||||
fmt.Println(green("[ok]") + " démarrage auto désactivé")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkStarted(svc string) error {
|
||||
time.Sleep(2 * time.Second)
|
||||
out, _ := exec.Command("systemctl", "is-active", svc).Output()
|
||||
state := strings.TrimSpace(string(out))
|
||||
if state == "active" || state == "activating" {
|
||||
fmt.Printf("%s %s: %s\n", green("[ok]"), svc, state)
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("%s %s: %s — derniers logs :\n", red("[ERREUR]"), svc, state)
|
||||
fmt.Println("------------------------------------------------")
|
||||
logs, _ := exec.Command("journalctl", "-u", svc, "-n", "20", "--no-pager").Output()
|
||||
fmt.Print(string(logs))
|
||||
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 {
|
||||
cmd := exec.Command("journalctl", "-u", serviceName(), "-n", "80", "-f")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
// 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_windows.go → PID-file background process supervisor
|
||||
//
|
||||
// editConfig and showVram live here because they work the same everywhere.
|
||||
|
||||
func editConfig() error {
|
||||
editor := os.Getenv("EDITOR")
|
||||
if editor == "" {
|
||||
editor = "nano"
|
||||
editor = defaultEditor()
|
||||
}
|
||||
cmd := exec.Command(editor, confPath())
|
||||
cmd.Stdin = os.Stdin
|
||||
@@ -81,6 +32,8 @@ func editConfig() error {
|
||||
}
|
||||
|
||||
// showVram parses `nvidia-smi --query-gpu=...` and renders a colored bar.
|
||||
// nvidia-smi is available on both Linux and Windows when an NVIDIA driver is
|
||||
// installed, so this is platform-neutral.
|
||||
func showVram() error {
|
||||
out, err := exec.Command("nvidia-smi",
|
||||
"--query-gpu=name,memory.used,memory.total,utilization.gpu,temperature.gpu",
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
//go:build unix
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// serviceAction wraps `systemctl <action> <svc>` with passwordless sudo where
|
||||
// it makes sense, and prints a follow-up status check after start/restart.
|
||||
func serviceAction(action string) error {
|
||||
svc := serviceName()
|
||||
needsRoot := action == "start" || action == "stop" || action == "restart" || action == "enable" || action == "disable"
|
||||
args := []string{}
|
||||
bin := "systemctl"
|
||||
if needsRoot && os.Geteuid() != 0 {
|
||||
bin = "sudo"
|
||||
args = append(args, "-n", "systemctl")
|
||||
}
|
||||
args = append(args, action, svc)
|
||||
cmd := exec.Command(bin, args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
switch action {
|
||||
case "start", "restart":
|
||||
return checkStarted(svc)
|
||||
case "stop":
|
||||
fmt.Println(green("[ok]") + " arrêté")
|
||||
case "enable":
|
||||
fmt.Println(green("[ok]") + " démarrage auto activé")
|
||||
case "disable":
|
||||
fmt.Println(green("[ok]") + " démarrage auto désactivé")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkStarted(svc string) error {
|
||||
time.Sleep(2 * time.Second)
|
||||
out, _ := exec.Command("systemctl", "is-active", svc).Output()
|
||||
state := strings.TrimSpace(string(out))
|
||||
if state == "active" || state == "activating" {
|
||||
fmt.Printf("%s %s: %s\n", green("[ok]"), svc, state)
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("%s %s: %s — derniers logs :\n", red("[ERREUR]"), svc, state)
|
||||
fmt.Println("------------------------------------------------")
|
||||
logs, _ := exec.Command("journalctl", "-u", svc, "-n", "20", "--no-pager").Output()
|
||||
fmt.Print(string(logs))
|
||||
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 {
|
||||
cmd := exec.Command("journalctl", "-u", serviceName(), "-n", "80", "-f")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// serviceIsActive reports whether the systemd unit is currently running.
|
||||
func serviceIsActive() bool {
|
||||
out, _ := exec.Command("systemctl", "is-active", serviceName()).Output()
|
||||
return strings.TrimSpace(string(out)) == "active"
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// On Windows there is no systemd, so "the service" is a background copy of
|
||||
// `jean serve` that this process launches detached. We track it with a PID file
|
||||
// and stream its output to a log file under JEAN_HOME. This needs no admin
|
||||
// rights and no external tools beyond the always-present tasklist/taskkill.
|
||||
|
||||
const (
|
||||
createNewProcessGroup = 0x00000200 // CREATE_NEW_PROCESS_GROUP
|
||||
detachedProcess = 0x00000008 // DETACHED_PROCESS
|
||||
)
|
||||
|
||||
func pidFilePath() string { return filepath.Join(JeanHome(), serviceName()+".pid") }
|
||||
func logFilePath() string { return filepath.Join(JeanHome(), serviceName()+".log") }
|
||||
|
||||
func serviceAction(action string) error {
|
||||
switch action {
|
||||
case "start":
|
||||
return svcStart()
|
||||
case "stop":
|
||||
return svcStop(true)
|
||||
case "restart":
|
||||
_ = svcStop(false)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
return svcStart()
|
||||
case "status":
|
||||
return svcStatus()
|
||||
case "enable", "disable":
|
||||
fmt.Printf("%s '%s' n'est pas géré sur Windows (pas de service système).\n", yellow("[info]"), action)
|
||||
fmt.Printf(" Pour un démarrage au boot, crée une tâche planifiée ou un service via %s.\n", bold("sc.exe"))
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("action inconnue: %s", action)
|
||||
}
|
||||
}
|
||||
|
||||
func svcStart() error {
|
||||
if pid := readServicePID(); pid > 0 && processAlive(pid) {
|
||||
fmt.Printf("%s déjà démarré (PID %d)\n", yellow("[info]"), pid)
|
||||
return nil
|
||||
}
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(JeanHome(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
logf, err := os.OpenFile(logFilePath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ouverture du log %s: %w", logFilePath(), err)
|
||||
}
|
||||
defer logf.Close()
|
||||
|
||||
cmd := exec.Command(self, "serve")
|
||||
cmd.Stdout = logf
|
||||
cmd.Stderr = logf
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: createNewProcessGroup | detachedProcess}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("démarrage de 'jean serve': %w", err)
|
||||
}
|
||||
pid := cmd.Process.Pid
|
||||
if err := os.WriteFile(pidFilePath(), []byte(strconv.Itoa(pid)), 0o644); err != nil {
|
||||
return fmt.Errorf("écriture du PID: %w", err)
|
||||
}
|
||||
// Don't wait — let it run detached.
|
||||
_ = cmd.Process.Release()
|
||||
return checkStarted(pid)
|
||||
}
|
||||
|
||||
func checkStarted(pid int) error {
|
||||
time.Sleep(2 * time.Second)
|
||||
if processAlive(pid) {
|
||||
fmt.Printf("%s %s: démarré (PID %d)\n", green("[ok]"), serviceName(), pid)
|
||||
fmt.Printf(" logs: %s (jean logs pour suivre)\n", dim(logFilePath()))
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("%s %s: le processus s'est arrêté — derniers logs :\n", red("[ERREUR]"), serviceName())
|
||||
fmt.Println("------------------------------------------------")
|
||||
fmt.Print(tailFile(logFilePath(), 20))
|
||||
fmt.Println("------------------------------------------------")
|
||||
fmt.Printf("→ jean logs pour plus de détails\n→ jean edit pour corriger config.env\n")
|
||||
_ = os.Remove(pidFilePath())
|
||||
return fmt.Errorf("service %s non démarré", serviceName())
|
||||
}
|
||||
|
||||
func svcStop(verbose bool) error {
|
||||
pid := readServicePID()
|
||||
if pid <= 0 || !processAlive(pid) {
|
||||
_ = os.Remove(pidFilePath())
|
||||
if verbose {
|
||||
fmt.Println(yellow("[info]") + " aucun service en cours d'exécution")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// taskkill /T tue aussi le processus enfant llama-server.
|
||||
cmd := exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/T", "/F")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("arrêt du PID %d: %w\n%s", pid, err, string(out))
|
||||
}
|
||||
_ = os.Remove(pidFilePath())
|
||||
if verbose {
|
||||
fmt.Println(green("[ok]") + " arrêté")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func svcStatus() error {
|
||||
pid := readServicePID()
|
||||
if pid > 0 && processAlive(pid) {
|
||||
fmt.Printf("%s %s: actif (PID %d)\n", green("[ok]"), serviceName(), pid)
|
||||
} else {
|
||||
fmt.Printf("%s %s: arrêté\n", yellow("[info]"), serviceName())
|
||||
}
|
||||
fmt.Printf(" logs : %s\n", logFilePath())
|
||||
fmt.Printf(" config : %s\n", confPath())
|
||||
return nil
|
||||
}
|
||||
|
||||
func serviceLogs() error {
|
||||
path := logFilePath()
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("aucun log à %s (le service a-t-il déjà démarré ?): %w", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
// Print the tail, then follow appended bytes (poor man's tail -f).
|
||||
fmt.Print(tailFile(path, 80))
|
||||
if _, err := f.Seek(0, io.SeekEnd); err != nil {
|
||||
return err
|
||||
}
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := f.Read(buf)
|
||||
if n > 0 {
|
||||
os.Stdout.Write(buf[:n])
|
||||
}
|
||||
if err == io.EOF {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// serviceIsActive reports whether the background server is running.
|
||||
func serviceIsActive() bool {
|
||||
pid := readServicePID()
|
||||
return pid > 0 && processAlive(pid)
|
||||
}
|
||||
|
||||
func readServicePID() int {
|
||||
b, err := os.ReadFile(pidFilePath())
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
pid, _ := strconv.Atoi(strings.TrimSpace(string(b)))
|
||||
return pid
|
||||
}
|
||||
|
||||
// processAlive reports whether a PID is currently running, via tasklist (always
|
||||
// present on Windows, needs no special privileges).
|
||||
func processAlive(pid int) bool {
|
||||
out, err := exec.Command("tasklist", "/FI", "PID eq "+strconv.Itoa(pid), "/NH").Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
s := string(out)
|
||||
if strings.Contains(s, "No tasks") {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(s, strconv.Itoa(pid))
|
||||
}
|
||||
|
||||
// tailFile returns the last n lines of the file at path (best-effort).
|
||||
func tailFile(path string, n int) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
lines := strings.Split(strings.TrimRight(string(b), "\n"), "\n")
|
||||
if len(lines) > n {
|
||||
lines = lines[len(lines)-n:]
|
||||
}
|
||||
return strings.Join(lines, "\n") + "\n"
|
||||
}
|
||||
@@ -35,9 +35,10 @@ func setToolsEnabled(on bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// runShell executes a command via `bash -c` with a clamped timeout, returning
|
||||
// a single string formatted "exit: N\n\nstdout:\n...\n\nstderr:\n..." truncated
|
||||
// to keep tool output bounded.
|
||||
// runShell executes a command via the platform shell (bash -c on Unix, cmd /C
|
||||
// on Windows — see newShellCmd in platform_*.go) with a clamped timeout,
|
||||
// returning a single string formatted "exit: N\n\nstdout:\n...\n\nstderr:\n..."
|
||||
// truncated to keep tool output bounded.
|
||||
func runShell(command string, timeoutSec int) string {
|
||||
if timeoutSec <= 0 {
|
||||
timeoutSec = toolDefaultTimeout
|
||||
@@ -47,7 +48,7 @@ func runShell(command string, timeoutSec int) string {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "/bin/bash", "-c", command)
|
||||
cmd := newShellCmd(ctx, command)
|
||||
var stdout, stderr strings.Builder
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed ui/index.html ui/marked.min.js
|
||||
@@ -55,8 +59,91 @@ func cmdWeb(args []string) error {
|
||||
mux.HandleFunc("/api/bench", handleBench)
|
||||
mux.HandleFunc("/api/chat", handleChat)
|
||||
addr := fmt.Sprintf("0.0.0.0:%d", port)
|
||||
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
// Port occupé : on identifie le process qui le tient et on propose de
|
||||
// le terminer pour relancer à sa place.
|
||||
if !resolvePortConflict(port) {
|
||||
return err
|
||||
}
|
||||
if ln, err = net.Listen("tcp", addr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
fmt.Printf("[jean web] http://%s (Ctrl-C pour arrêter)\n", addr)
|
||||
return http.ListenAndServe(addr, mux)
|
||||
return http.Serve(ln, mux)
|
||||
}
|
||||
|
||||
// resolvePortConflict identifies the process listening on `port`, asks the user
|
||||
// whether to terminate it, and (on yes) kills it and waits for the port to free.
|
||||
// Returns true if the caller should retry binding.
|
||||
func resolvePortConflict(port int) bool {
|
||||
pid, name := pidOnPort(port)
|
||||
if pid == 0 {
|
||||
fmt.Printf("%s port %d déjà utilisé, mais le process n'a pas pu être identifié (essaie en root ?)\n", red("[err]"), port)
|
||||
return false
|
||||
}
|
||||
fmt.Printf("%s le port %d est déjà utilisé par %s (PID %d).\n", yellow("[!]"), port, bold(name), pid)
|
||||
fmt.Print(dim(" terminer ce process et relancer ? [Y/n] "))
|
||||
sc := bufio.NewScanner(os.Stdin)
|
||||
if sc.Scan() && strings.HasPrefix(strings.ToLower(strings.TrimSpace(sc.Text())), "n") {
|
||||
fmt.Println(dim(" annulé."))
|
||||
return false
|
||||
}
|
||||
// SIGTERM d'abord, puis SIGKILL si le port ne se libère pas.
|
||||
_ = exec.Command("kill", "-TERM", strconv.Itoa(pid)).Run()
|
||||
for i := 0; i < 15; i++ {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
if p, _ := pidOnPort(port); p == 0 {
|
||||
fmt.Printf("%s process %d terminé, redémarrage…\n", green("[ok]"), pid)
|
||||
return true
|
||||
}
|
||||
}
|
||||
_ = exec.Command("kill", "-KILL", strconv.Itoa(pid)).Run()
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
if p, _ := pidOnPort(port); p != 0 {
|
||||
fmt.Printf("%s impossible de libérer le port %d (PID %d toujours présent)\n", red("[err]"), port, p)
|
||||
return false
|
||||
}
|
||||
fmt.Printf("%s process %d terminé (forcé), redémarrage…\n", green("[ok]"), pid)
|
||||
return true
|
||||
}
|
||||
|
||||
// pidOnPort returns the PID and command name of the process listening on the
|
||||
// given TCP port, via `ss` (Linux) with an `lsof` fallback. Returns 0 if none
|
||||
// is found or if the tools can't see it (e.g. owned by another user).
|
||||
func pidOnPort(port int) (int, string) {
|
||||
redir := regexp.MustCompile(`pid=(\d+)`)
|
||||
if out, err := exec.Command("ss", "-ltnHp", fmt.Sprintf("sport = :%d", port)).Output(); err == nil {
|
||||
if m := redir.FindStringSubmatch(string(out)); m != nil {
|
||||
pid, _ := strconv.Atoi(m[1])
|
||||
return pid, processName(pid)
|
||||
}
|
||||
}
|
||||
if out, err := exec.Command("lsof", "-ti", fmt.Sprintf("tcp:%d", port), "-sTCP:LISTEN").Output(); err == nil {
|
||||
for _, line := range strings.Fields(string(out)) {
|
||||
if pid, err := strconv.Atoi(strings.TrimSpace(line)); err == nil {
|
||||
return pid, processName(pid)
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
// processName returns a short command name for a PID, or "?" if unknown.
|
||||
func processName(pid int) string {
|
||||
if b, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid)); err == nil {
|
||||
if n := strings.TrimSpace(string(b)); n != "" {
|
||||
return n
|
||||
}
|
||||
}
|
||||
if out, err := exec.Command("ps", "-o", "comm=", "-p", strconv.Itoa(pid)).Output(); err == nil {
|
||||
if n := strings.TrimSpace(string(out)); n != "" {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return "?"
|
||||
}
|
||||
|
||||
func handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
Reference in new issue
Block a user