mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-13 18:28:03 +02:00
CLI elaguee et tests remis sur la base
- suppression des alias de commandes herites (skills, machine, tools, mem, upgrade...) - aide reecrite et regroupee - helper de test testHome + fermeture de la base (verrou bbolt sous Windows) - tests des dossiers de modeles alignes sur models/
This commit is contained in:
1 parent
6de356f649
commit
62ad4155a2
18 files changed
+202
-153
No files matched your search
@@ -12,7 +12,7 @@ func TestCompiledFile(t *testing.T) {
|
||||
`[123/456] Building CUDA object ggml/src/ggml-cuda/CMakeFiles/ggml-cuda.dir/acc.cu.o`: "acc.cu",
|
||||
// La ligne de commande nvcc géante ne doit PAS être prise pour un fichier.
|
||||
` C:\...\nvcc.exe -x cu ... -o ggml-cuda.dir\Release\acc.obj "C:\...\acc.cu"`: "",
|
||||
`Building Custom Rule C:/ProgramData/ajean/...`: "",
|
||||
`Building Custom Rule C:/ProgramData/ajean/...`: "",
|
||||
`-- UI: running npm install`: "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
|
||||
@@ -39,9 +39,9 @@ func cmdLlamacpp(args []string) error {
|
||||
switch sub {
|
||||
case "install":
|
||||
return llamacppInstall(args)
|
||||
case "update", "upgrade":
|
||||
case "update":
|
||||
return llamacppUpdate(args)
|
||||
case "status", "info", "":
|
||||
case "status", "":
|
||||
return llamacppStatus(args)
|
||||
case "prebuilt":
|
||||
// Binaires officiels précompilés (aucune compilation) — voir backend_prebuilt.go.
|
||||
|
||||
@@ -6,12 +6,11 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Un modèle posé hors de AJEAN_HOME (disque externe) doit être utilisable dès
|
||||
// Un modèle posé hors de models/ (disque externe) doit être utilisable dès
|
||||
// que son dossier est déclaré — et refusé tant qu'il ne l'est pas.
|
||||
func TestResolveModelPathExtraDir(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
testHome(t)
|
||||
ext := t.TempDir()
|
||||
t.Setenv("AJEAN_HOME", home)
|
||||
t.Setenv("AJEAN_MODEL_DIRS", "")
|
||||
|
||||
extModel := filepath.Join(ext, "gros.gguf")
|
||||
@@ -39,20 +38,22 @@ func TestResolveModelPathExtraDir(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// AJEAN_HOME garde la priorité et les non-.gguf restent refusés.
|
||||
// models/ garde la priorité et les non-.gguf restent refusés.
|
||||
func TestResolveModelPathHomeFirst(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
testHome(t)
|
||||
ext := t.TempDir()
|
||||
t.Setenv("AJEAN_HOME", home)
|
||||
t.Setenv("AJEAN_MODEL_DIRS", ext)
|
||||
for _, d := range []string{home, ext} {
|
||||
if err := os.MkdirAll(modelsDir(), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, d := range []string{modelsDir(), ext} {
|
||||
if err := os.WriteFile(filepath.Join(d, "m.gguf"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
got, err := resolveModelPath("m.gguf")
|
||||
if err != nil || got != filepath.Join(home, "m.gguf") {
|
||||
t.Fatalf("got %q (%v), attendu le fichier de AJEAN_HOME", got, err)
|
||||
if err != nil || got != filepath.Join(modelsDir(), "m.gguf") {
|
||||
t.Fatalf("got %q (%v), attendu le fichier de models/", got, err)
|
||||
}
|
||||
if _, err := resolveModelPath("/etc/passwd"); err == nil {
|
||||
t.Fatal("un fichier non-.gguf devrait être refusé")
|
||||
|
||||
@@ -12,17 +12,16 @@ import (
|
||||
)
|
||||
|
||||
func TestDownloadDestPathDir(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
testHome(t)
|
||||
extra := t.TempDir()
|
||||
t.Setenv("AJEAN_HOME", home)
|
||||
t.Setenv("AJEAN_MODEL_DIRS", extra)
|
||||
|
||||
// Dossier vide → AJEAN_HOME.
|
||||
// Dossier vide → models/.
|
||||
got, err := downloadDestPath("m.gguf", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := filepath.Join(home, "m.gguf"); got != want {
|
||||
if want := filepath.Join(modelsDir(), "m.gguf"); got != want {
|
||||
t.Fatalf("défaut = %s, attendu %s", got, want)
|
||||
}
|
||||
|
||||
@@ -76,8 +75,8 @@ func TestDiskFreeAndCheck(t *testing.T) {
|
||||
|
||||
// Les .part orphelins doivent aussi être nettoyés dans les dossiers ajoutés.
|
||||
func TestCleanStalePartFilesExtraDirs(t *testing.T) {
|
||||
home, extra := t.TempDir(), t.TempDir()
|
||||
t.Setenv("AJEAN_HOME", home)
|
||||
testHome(t)
|
||||
extra := t.TempDir()
|
||||
t.Setenv("AJEAN_MODEL_DIRS", extra)
|
||||
stale := filepath.Join(extra, "coupe.gguf.part")
|
||||
if err := os.WriteFile(stale, []byte("x"), 0o644); err != nil {
|
||||
|
||||
@@ -75,8 +75,11 @@ func TestRunDownloadCancelLeavesNothing(t *testing.T) {
|
||||
|
||||
// cleanStalePartFiles doit balayer les .part orphelins sans toucher aux .gguf.
|
||||
func TestCleanStalePartFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("AJEAN_HOME", dir)
|
||||
testHome(t)
|
||||
dir := modelsDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keep := filepath.Join(dir, "bon.gguf")
|
||||
stale := filepath.Join(dir, "coupe.gguf.part")
|
||||
for _, p := range []string{keep, stale} {
|
||||
|
||||
@@ -67,8 +67,7 @@ func TestExtractArchiveSymlink(t *testing.T) {
|
||||
// comme sur une machine mise à jour plusieurs fois.
|
||||
func fakePrebuilt(t *testing.T, version string, tags ...string) string {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
t.Setenv("AJEAN_HOME", home)
|
||||
home := testHome(t)
|
||||
dir := filepath.Join(home, "backends", "llama.cpp-prebuilt")
|
||||
name := "llama-server"
|
||||
if runtime.GOOS == "windows" {
|
||||
|
||||
@@ -6,6 +6,14 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// setConfig installe une configuration décrite au format des presets.
|
||||
func setConfig(t *testing.T, body string) {
|
||||
t.Helper()
|
||||
if err := WriteConfig(parseEnv(body)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPresetFingerprintIgnoresCosmetics vérifie que la détection du preset actif
|
||||
// ne dépend QUE de la config effective (ensemble KEY=VALUE), pas de la mise en
|
||||
// forme : ordre des lignes, commentaires (dont `# NAME=`), lignes vides, `export`,
|
||||
@@ -32,15 +40,14 @@ func TestPresetFingerprintIgnoresCosmetics(t *testing.T) {
|
||||
// suivante l'écrasait, llama.cpp revoyait les deux cartes et réétalait le modèle
|
||||
// sur la petite — pendant que `ajean gpu` réaffichait « auto ».
|
||||
func TestSwitchToPresetGardeLesReglagesMachine(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("AJEAN_HOME", home)
|
||||
home := testHome(t)
|
||||
write := func(p, body string) {
|
||||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// config.env courante : un preset + les réglages machine.
|
||||
write(confPath(), "MODEL=ancien.gguf\nCTX=4096\nCUDA_VISIBLE_DEVICES=1\nWEB_ENGINE=go\nMEM_MODE=always\n")
|
||||
// Configuration courante : un preset + les réglages machine.
|
||||
setConfig(t, "MODEL=ancien.gguf\nCTX=4096\nCUDA_VISIBLE_DEVICES=1\nWEB_ENGINE=go\nMEM_MODE=always\n")
|
||||
target := filepath.Join(home, "cible.env")
|
||||
write(target, "MODEL=nouveau.gguf\nCTX=8192\n")
|
||||
|
||||
@@ -66,14 +73,13 @@ func TestSwitchToPresetGardeLesReglagesMachine(t *testing.T) {
|
||||
// --tensor-split ait deux cartes. Écraser ça par une sélection mono-GPU faisait
|
||||
// mourir le chargement sur « cudaMalloc failed: out of memory ».
|
||||
func TestSwitchToPresetLaisseLePresetImposerSesGPU(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("AJEAN_HOME", home)
|
||||
home := testHome(t)
|
||||
write := func(p, body string) {
|
||||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write(confPath(), "MODEL=ancien.gguf\nCUDA_VISIBLE_DEVICES=1\nWEB_ENGINE=go\n")
|
||||
setConfig(t, "MODEL=ancien.gguf\nCUDA_VISIBLE_DEVICES=1\nWEB_ENGINE=go\n")
|
||||
deuxGPU := filepath.Join(home, "deux-gpu.env")
|
||||
write(deuxGPU, "MODEL=fable.gguf\nCUDA_VISIBLE_DEVICES=1,0\n")
|
||||
if err := applyPresetFile(deuxGPU); err != nil {
|
||||
|
||||
@@ -26,7 +26,7 @@ func cmdAgent(args []string) error {
|
||||
return err
|
||||
}
|
||||
fmt.Println(green("[ok]") + " mode agent désactivé")
|
||||
case "", "status", "list":
|
||||
case "", "status":
|
||||
state := dim("off")
|
||||
if agentEnabled() {
|
||||
state = green("on")
|
||||
|
||||
@@ -317,9 +317,9 @@ const (
|
||||
// actuel). Toute valeur inconnue retombe sur always.
|
||||
func memMode() MemMode {
|
||||
switch strings.ToLower(strings.TrimSpace(ReadConfig()["MEM_MODE"])) {
|
||||
case "off", "0", "false", "none", "no", "non":
|
||||
case "off":
|
||||
return MemOff
|
||||
case "ondemand", "on-demand", "demand", "manual", "manuel":
|
||||
case "ondemand":
|
||||
return MemOnDemand
|
||||
default: // "always", "auto", "" et inconnus
|
||||
return MemAlways
|
||||
@@ -343,7 +343,7 @@ func cmdMemory(args []string) error {
|
||||
MemAlways: "auto (l'IA cherche et sauve d'elle-même)",
|
||||
}
|
||||
switch sub {
|
||||
case "off", "none", "0", "false":
|
||||
case "off":
|
||||
if err := setMemMode(MemOff); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -351,11 +351,11 @@ func cmdMemory(args []string) error {
|
||||
if err := setMemMode(MemOnDemand); err != nil {
|
||||
return err
|
||||
}
|
||||
case "always", "auto", "on":
|
||||
case "always":
|
||||
if err := setMemMode(MemAlways); err != nil {
|
||||
return err
|
||||
}
|
||||
case "", "status", "list":
|
||||
case "", "status":
|
||||
m := memMode()
|
||||
fmt.Printf("%s mode: %s — %s\n", cyan("Mémoire"), bold(string(m)), label[m])
|
||||
pages := MemList()
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
// tentative de connexion : sans ça, connecter en parallèle multiplierait les
|
||||
// process stdio lancés pour un même serveur.
|
||||
func TestMCPEnsureDeduplicatesConcurrentConnects(t *testing.T) {
|
||||
t.Setenv("AJEAN_HOME", t.TempDir())
|
||||
testHome(t)
|
||||
t.Cleanup(mcpCloseAll)
|
||||
|
||||
// Commande inexistante : la connexion échoue, mais l'entrée du pool est bien
|
||||
@@ -53,7 +53,7 @@ func TestMCPEndToEnd(t *testing.T) {
|
||||
if _, err := exec.LookPath("npx"); err != nil {
|
||||
t.Skip("npx indisponible — test MCP e2e ignoré")
|
||||
}
|
||||
t.Setenv("AJEAN_HOME", t.TempDir())
|
||||
testHome(t)
|
||||
|
||||
cfg := MCPServerConfig{
|
||||
Command: "npx",
|
||||
|
||||
@@ -87,7 +87,7 @@ func cmdLink(args []string) error {
|
||||
// éviter de prendre un mot pour un token et d'écraser le vrai).
|
||||
printLinkHelp()
|
||||
return nil
|
||||
case "serve", "--foreground", "fg":
|
||||
case "serve":
|
||||
// Le worker réel (boucle de connexion au relais), pendant de `ajean serve`.
|
||||
// C'est ce que lance l'unité systemd ; il NE doit PAS rendre la main.
|
||||
return runLinkForeground()
|
||||
|
||||
+29
-45
@@ -66,15 +66,11 @@ func Main() {
|
||||
mustExit(cmdLink(args))
|
||||
case "oai":
|
||||
mustExit(cmdOAI(args))
|
||||
case "agent", "skills", "machine", "tools":
|
||||
// « mode agent » unifie l'ancien couple machine + skills : un seul
|
||||
// interrupteur active TOUS les outils de l'IA (shell + mémoire ; les
|
||||
// skills ont été fondus dans la mémoire). Les anciens noms restent
|
||||
// acceptés comme alias rétro-compatibles.
|
||||
case "agent":
|
||||
mustExit(cmdAgent(args))
|
||||
case "internet", "web-access":
|
||||
case "internet":
|
||||
mustExit(cmdInternet(args))
|
||||
case "memory", "mem":
|
||||
case "memory":
|
||||
mustExit(cmdMemory(args))
|
||||
case "serve":
|
||||
mustExit(cmdServe(args))
|
||||
@@ -82,15 +78,15 @@ func Main() {
|
||||
mustExit(cmdTest(args))
|
||||
case "bench":
|
||||
mustExit(cmdBench(args))
|
||||
case "llamacpp", "llama":
|
||||
case "llamacpp":
|
||||
mustExit(cmdLlamacpp(args))
|
||||
case "update", "upgrade", "self-update":
|
||||
case "update":
|
||||
mustExit(cmdUpdate(args))
|
||||
case "where", "paths":
|
||||
case "where":
|
||||
mustExit(cmdWhere(args))
|
||||
case restartArg:
|
||||
// Sous-commande interne, absente de l'aide : l'accompagnateur detache qui
|
||||
// attend la fermeture de l'app, migre, puis la relance.
|
||||
// Sous-commande interne, absente de l'aide : l'accompagnateur détaché qui
|
||||
// attend la fermeture de l'app puis la relance.
|
||||
mustExit(cmdRestartAfterUpdate(args))
|
||||
case "install":
|
||||
mustExit(cmdInstall(args))
|
||||
@@ -116,58 +112,46 @@ Application:
|
||||
app lance l'UI web + ouvre le navigateur (auto au double-clic du binaire)
|
||||
|
||||
Service:
|
||||
start | stop | restart gérer le service (systemd sous Linux, processus en arrière-plan sous Windows)
|
||||
start | stop | restart gérer le service
|
||||
status | logs état / logs en direct
|
||||
enable | disable auto-démarrage au boot
|
||||
edit éditer $AJEAN_HOME/config.env
|
||||
set-api-key [clé] protéger llama-server (clé Bearer); vide = générer, "" = retirer
|
||||
set-web-key [clé] protéger l'API de pilotage 'ajean web'; vide = générer, "" = retirer
|
||||
edit éditer la configuration dans $EDITOR
|
||||
switch [N] activer un preset de presets/ (interactif ou par numéro)
|
||||
test | bench [N] vérifier que l'IA répond / mesurer prefill + decode tok/s
|
||||
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)
|
||||
|
||||
Presets:
|
||||
switch [N] choisir un preset dans configs/ (interactif ou par numéro)
|
||||
set-api-key [clé] protéger llama-server (clé Bearer); vide = générer, "" = retirer
|
||||
set-web-key [clé] protéger l'API de pilotage; vide = générer, "" = retirer
|
||||
|
||||
Interaction:
|
||||
chat [system-prompt] chat terminal streamé
|
||||
web [PORT] UI web (défaut :8090) — chat + presets + mode agent
|
||||
agent [on|off|status] donne à l'IA ses outils (shell, fichiers, mémoire)
|
||||
memory [off|ondemand|always|status] mode mémoire de l'IA
|
||||
internet [on|off|status|engine <go|crawl4ai>|url <url>|key <clé>]
|
||||
accès web de l'IA (web_search/open/read/grep) — moteur intégré ou serveur Crawl4AI
|
||||
memory [off|ondemand|always|status] mode mémoire de l'IA (off / sur demande / auto)
|
||||
accès web de l'IA (moteur intégré ou serveur Crawl4AI)
|
||||
|
||||
Accès distant (ajean.link) :
|
||||
link <token> enregistre le token et démarre le lien au relais (token = 1re fois / pour le changer)
|
||||
link start | restart | stop démarre / redémarre / arrête le service de lien
|
||||
link code génère un code d'appairage (valable 10 min, à usage unique) pour le portail
|
||||
link <token> enregistre le token et démarre le lien au relais
|
||||
link start | restart | stop pilote le service de lien
|
||||
link code code d'appairage (10 min, usage unique) pour le portail
|
||||
link status | logout état du lien / oublier le token
|
||||
link (sans argument) affiche l'aide des sous-commandes link
|
||||
link serve exécute le worker au premier plan (utilisé par ajean-link.service ; pendant de 'ajean serve')
|
||||
|
||||
Mode agent:
|
||||
agent [on|off|status] active TOUS les outils de l'IA (shell complet + mémoire) — un seul interrupteur
|
||||
link serve worker au premier plan (lancé par ajean-link.service)
|
||||
|
||||
Backend llama.cpp :
|
||||
llamacpp install clone + compile llama.cpp (détecte CUDA/ROCm/Metal/CPU), pointe BIN dessus
|
||||
llamacpp update git pull + recompile le backend existant (arrête/redémarre le service)
|
||||
llamacpp install clone + compile llama.cpp (CUDA/ROCm/Metal/CPU), pointe BIN dessus
|
||||
llamacpp update git pull + recompile le backend existant
|
||||
llamacpp status commit courant, backend détecté, retard sur origin
|
||||
|
||||
Entrypoint (utilisé par ajean.service) :
|
||||
serve lit config.env et exec le binaire llama-server
|
||||
|
||||
Installation:
|
||||
where affiche où sont le binaire, la config, la mémoire et le dossier de travail de l'agent
|
||||
install installer (Linux: unité systemd, sudoers, dossiers ; Windows: dossiers + config)
|
||||
uninstall désinstaller
|
||||
update [--check] mettre à jour AJEAN depuis les releases GitHub (--check = signale sans installer)
|
||||
where affiche l'emplacement du binaire, de la base et des dossiers
|
||||
install | uninstall installer / désinstaller
|
||||
update [--check] mettre à jour depuis les releases GitHub
|
||||
serve entrypoint du service : exec llama-server (usage interne)
|
||||
|
||||
Env:
|
||||
AJEAN_HOME racine (défaut: /etc/ajean sur Linux/macOS, %%ProgramData%%\ajean sur Windows)
|
||||
AJEAN_HOME reste accepté (héritage)
|
||||
EDITOR éditeur pour 'ajean edit' (défaut: nano sur Unix, notepad sur Windows)
|
||||
|
||||
Config: $AJEAN_HOME/config.env
|
||||
AJEAN_HOME racine des données (défaut : /etc/ajean, %%ProgramData%%\ajean sous Windows)
|
||||
EDITOR éditeur pour 'ajean edit' (défaut : nano, notepad sous Windows)
|
||||
`, Version)
|
||||
}
|
||||
|
||||
|
||||
+49
-29
@@ -33,49 +33,69 @@ const (
|
||||
bkChat = "chat" // conversation partagée
|
||||
)
|
||||
|
||||
// Les connexions sont mises en cache PAR CHEMIN, et non dans un singleton.
|
||||
// $AJEAN_HOME peut désigner un autre dossier d'un appel à l'autre — c'est le cas
|
||||
// dans les tests, qui donnent à chacun son dossier temporaire. Un singleton
|
||||
// figerait la toute première base et tous les appels suivants écriraient dans le
|
||||
// mauvais fichier, sans erreur et sans bruit.
|
||||
var (
|
||||
dbOnce sync.Once
|
||||
dbConn *bolt.DB
|
||||
dbErr error
|
||||
dbMu sync.Mutex
|
||||
dbConns = map[string]*bolt.DB{}
|
||||
)
|
||||
|
||||
func dbPath() string { return filepath.Join(AjeanHome(), "ajean.db") }
|
||||
|
||||
// db ouvre la base à la première utilisation et la garde ouverte pour la durée
|
||||
// du process. bbolt prend un verrou exclusif sur le fichier : le délai d'attente
|
||||
// évite qu'un second process (l'app lancée deux fois) se bloque indéfiniment.
|
||||
// db ouvre la base (ou renvoie celle déjà ouverte pour ce chemin) et la garde
|
||||
// ouverte pour la durée du process. bbolt prend un verrou exclusif sur le
|
||||
// fichier : le délai d'attente évite qu'un second process (l'app lancée deux
|
||||
// fois) se bloque indéfiniment.
|
||||
func db() (*bolt.DB, error) {
|
||||
dbOnce.Do(func() {
|
||||
if err := os.MkdirAll(AjeanHome(), 0o755); err != nil {
|
||||
dbErr = err
|
||||
return
|
||||
}
|
||||
d, err := bolt.Open(dbPath(), 0o600, &bolt.Options{Timeout: 3 * time.Second})
|
||||
if err != nil {
|
||||
dbErr = fmt.Errorf("base %s inaccessible : %w", dbPath(), err)
|
||||
return
|
||||
}
|
||||
err = d.Update(func(tx *bolt.Tx) error {
|
||||
for _, b := range []string{bkConfig, bkPrefs, bkState, bkChat} {
|
||||
if _, err := tx.CreateBucketIfNotExists([]byte(b)); err != nil {
|
||||
return err
|
||||
}
|
||||
path := dbPath()
|
||||
|
||||
dbMu.Lock()
|
||||
defer dbMu.Unlock()
|
||||
if d, ok := dbConns[path]; ok {
|
||||
return d, nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d, err := bolt.Open(path, 0o600, &bolt.Options{Timeout: 3 * time.Second})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("base %s inaccessible : %w", path, err)
|
||||
}
|
||||
err = d.Update(func(tx *bolt.Tx) error {
|
||||
for _, b := range []string{bkConfig, bkPrefs, bkState, bkChat} {
|
||||
if _, err := tx.CreateBucketIfNotExists([]byte(b)); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
d.Close()
|
||||
dbErr = err
|
||||
return
|
||||
}
|
||||
dbConn = d
|
||||
return nil
|
||||
})
|
||||
return dbConn, dbErr
|
||||
if err != nil {
|
||||
d.Close()
|
||||
return nil, err
|
||||
}
|
||||
dbConns[path] = d
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// getBytes lit une valeur. Une base inaccessible se comporte comme une base
|
||||
// vide : les appelants sont des lecteurs de réglages, aucun n'a de recours utile
|
||||
// face à une erreur d'E/S, et tous ont déjà un défaut.
|
||||
// closeDB ferme la base ouverte pour le dossier courant, s'il y en a une. bbolt
|
||||
// garde un verrou exclusif sur son fichier tant qu'il est ouvert : sans ça,
|
||||
// impossible de supprimer ou déplacer le dossier de données sous Windows.
|
||||
func closeDB() {
|
||||
path := dbPath()
|
||||
dbMu.Lock()
|
||||
defer dbMu.Unlock()
|
||||
if d, ok := dbConns[path]; ok {
|
||||
_ = d.Close()
|
||||
delete(dbConns, path)
|
||||
}
|
||||
}
|
||||
|
||||
func getBytes(bucket, key string) []byte {
|
||||
d, err := db()
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package ajean
|
||||
|
||||
import "testing"
|
||||
|
||||
// testHome donne au test un $AJEAN_HOME à lui et ferme la base à la fin.
|
||||
//
|
||||
// La fermeture n'est pas une politesse : bbolt garde un verrou exclusif sur son
|
||||
// fichier, et sous Windows le ménage de t.TempDir() échoue tant qu'il est ouvert.
|
||||
func testHome(t *testing.T) string {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
t.Setenv("AJEAN_HOME", home)
|
||||
t.Cleanup(closeDB)
|
||||
return home
|
||||
}
|
||||
|
||||
// La configuration écrite doit se relire à l'identique, et une clé vidée
|
||||
// disparaître au lieu de rester à "".
|
||||
func TestConfigAllerRetour(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := WriteConfig(map[string]string{"MODEL": "m.gguf", "CTX": "4096"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg := ReadConfig(); cfg["MODEL"] != "m.gguf" || cfg["CTX"] != "4096" {
|
||||
t.Fatalf("configuration relue incorrecte : %v", cfg)
|
||||
}
|
||||
if err := SetConfigKey("CTX", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := ReadConfig()["CTX"]; ok {
|
||||
t.Error("CTX vidée mais toujours présente")
|
||||
}
|
||||
}
|
||||
|
||||
// WriteConfig REMPLACE : aucune clé de l'ancienne configuration ne doit
|
||||
// survivre, sans quoi une bascule de preset laisserait des réglages fantômes.
|
||||
func TestWriteConfigRemplaceTout(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := WriteConfig(map[string]string{"A": "1", "B": "2"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := WriteConfig(map[string]string{"B": "3"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := ReadConfig()
|
||||
if _, ok := cfg["A"]; ok {
|
||||
t.Error("A a survécu au remplacement")
|
||||
}
|
||||
if cfg["B"] != "3" {
|
||||
t.Errorf("B = %q, attendu 3", cfg["B"])
|
||||
}
|
||||
}
|
||||
|
||||
// Une base absente se comporte comme une base vide : les lecteurs ont tous un
|
||||
// défaut, aucun ne doit paniquer.
|
||||
func TestLecturesSurBaseVide(t *testing.T) {
|
||||
testHome(t)
|
||||
if len(ReadConfig()) != 0 {
|
||||
t.Error("configuration non vide sur une base neuve")
|
||||
}
|
||||
if agentEnabled() || internetEnabled() {
|
||||
t.Error("interrupteurs actifs sur une base neuve")
|
||||
}
|
||||
if readAPIKey() != "" || readWebKey() != "" || readLinkToken() != "" {
|
||||
t.Error("clés non vides sur une base neuve")
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
// sur « systemctl: command not found » / « /etc/default/ajean », cf. issue #4).
|
||||
// Implémentation prudente ; à valider sur une machine Apple.
|
||||
|
||||
|
||||
// launchdPlistTemplate — champs formatés : Label, chemin du binaire, UserName,
|
||||
// WorkingDirectory, AJEAN_HOME, StandardOutPath, StandardErrorPath.
|
||||
// KeepAlive/SuccessfulExit=false ≈ Restart=on-failure ; RunAtLoad relance au
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
|
||||
const sudoersTemplate = `# Allow %s to manage the %s systemd unit without a password (installed by ajean).
|
||||
%s ALL=(root) NOPASSWD: /bin/systemctl start %s, /bin/systemctl stop %s, /bin/systemctl restart %s, /bin/systemctl enable %s, /bin/systemctl disable %s
|
||||
`
|
||||
@@ -110,7 +109,7 @@ func cmdInstall(args []string) error {
|
||||
}
|
||||
fmt.Printf(" %s /etc/default/ajean\n", green("✓"))
|
||||
|
||||
// 5. Write the systemd unit (ExecStart = `ajean serve`, no start.sh needed)
|
||||
// 4. Write the systemd unit (ExecStart = `ajean serve`, no start.sh needed)
|
||||
unit := fmt.Sprintf(serviceUnitTemplate, targetUser, ajeanHome, installedExePath()+" serve")
|
||||
unitPath := "/etc/systemd/system/" + svc + ".service"
|
||||
if err := os.WriteFile(unitPath, []byte(unit), 0o644); err != nil {
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
// service itself is managed by the PID-file supervisor in sys_service_windows.go
|
||||
// (ajean start / stop / status), which needs no admin rights.
|
||||
|
||||
|
||||
func cmdInstall(args []string) error {
|
||||
ajeanHome := AjeanHome()
|
||||
|
||||
|
||||
@@ -15,48 +15,21 @@ func relWith(names ...string) *ghRelease {
|
||||
return rel
|
||||
}
|
||||
|
||||
// Une release de transition publie les deux noms : on doit préférer ajean-*.
|
||||
func TestPickAssetPrefersAjean(t *testing.T) {
|
||||
names := updateAssetNames()
|
||||
got, url, _ := pickAsset(relWith(names[1], names[0]))
|
||||
if got != names[0] {
|
||||
t.Fatalf("got %q, attendu la préférence %q", got, names[0])
|
||||
// L'asset de la plateforme est trouvé au milieu des autres, et le nom renvoyé
|
||||
// est bien celui qui servira à vérifier le SHA-256.
|
||||
func TestPickAssetTrouveSaPlateforme(t *testing.T) {
|
||||
want := updateAssetName()
|
||||
got, url, size := pickAsset(relWith("SHA256SUMS", "ajean-plan9-386", want))
|
||||
if got != want {
|
||||
t.Fatalf("got %q, attendu %q", got, want)
|
||||
}
|
||||
if url == "" {
|
||||
t.Fatal("URL vide")
|
||||
}
|
||||
}
|
||||
|
||||
// Release future, purgée des anciens noms : ajean-* seul suffit.
|
||||
func TestPickAssetAjeanOnly(t *testing.T) {
|
||||
names := updateAssetNames()
|
||||
if got, _, _ := pickAsset(relWith(names[0])); got != names[0] {
|
||||
t.Fatalf("got %q, attendu %q", got, names[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Ancienne release, antérieure au renommage : le repli sur ajean-* doit marcher,
|
||||
// sinon un client neuf ne pourrait pas s'installer depuis une release existante.
|
||||
func TestPickAssetFallsBackToLegacy(t *testing.T) {
|
||||
names := updateAssetNames()
|
||||
got, _, _ := pickAsset(relWith(names[1]))
|
||||
if got != names[1] {
|
||||
t.Fatalf("got %q, attendu le repli %q", got, names[1])
|
||||
}
|
||||
}
|
||||
|
||||
// Le nom renvoyé sert à vérifier le SHA-256 : il doit être celui réellement
|
||||
// choisi, pas le nom préféré, sinon la vérification échoue sur un repli.
|
||||
func TestPickAssetReturnsCheckedName(t *testing.T) {
|
||||
names := updateAssetNames()
|
||||
got, url, size := pickAsset(relWith(names[1]))
|
||||
if got != names[1] || url != "https://example.invalid/"+names[1] || size != 42 {
|
||||
t.Fatalf("incohérence nom/URL/taille: %q %q %d", got, url, size)
|
||||
if url != "https://example.invalid/"+want || size != 42 {
|
||||
t.Fatalf("incohérence URL/taille : %q %d", url, size)
|
||||
}
|
||||
}
|
||||
|
||||
// Aucun asset pour cette plateforme → pas d'URL, l'appelant doit pouvoir le voir.
|
||||
func TestPickAssetNoMatch(t *testing.T) {
|
||||
func TestPickAssetSansCorrespondance(t *testing.T) {
|
||||
if _, url, _ := pickAsset(relWith("ajean-plan9-386", "SHA256SUMS")); url != "" {
|
||||
t.Fatalf("URL inattendue: %q", url)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user