Correction Windows

This commit is contained in:
nathaninline authored and GitHub committed 2026-06-09 09:35:07 +02:00
1 parent a40c50c400
commit ca0a315260
14 files changed
+973 -87

No files matched your search

+26 -3
View File
@@ -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 .
+158
View File
@@ -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
View File
@@ -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
})
}
+96
View File
@@ -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
}
-5
View File
@@ -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...)
+10 -9
View File
@@ -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.
+39
View File
@@ -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)
}
+55
View File
@@ -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)
}
+14 -9
View File
@@ -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
View File
@@ -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",
+72
View File
@@ -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"
}
+203
View File
@@ -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"
}
+5 -4
View File
@@ -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
+88 -1
View File
@@ -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) {