diff --git a/internal/ajean/backend_buildfilter_test.go b/internal/ajean/backend_buildfilter_test.go index e2a2739..491f147 100644 --- a/internal/ajean/backend_buildfilter_test.go +++ b/internal/ajean/backend_buildfilter_test.go @@ -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 { diff --git a/internal/ajean/backend_llamacpp.go b/internal/ajean/backend_llamacpp.go index 1eb70fd..25834fc 100644 --- a/internal/ajean/backend_llamacpp.go +++ b/internal/ajean/backend_llamacpp.go @@ -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. diff --git a/internal/ajean/backend_model_dirs_test.go b/internal/ajean/backend_model_dirs_test.go index 91cbb3a..fbad3f1 100644 --- a/internal/ajean/backend_model_dirs_test.go +++ b/internal/ajean/backend_model_dirs_test.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é") diff --git a/internal/ajean/backend_models_dest_test.go b/internal/ajean/backend_models_dest_test.go index 5493a98..ff45b90 100644 --- a/internal/ajean/backend_models_dest_test.go +++ b/internal/ajean/backend_models_dest_test.go @@ -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 { diff --git a/internal/ajean/backend_models_dl_test.go b/internal/ajean/backend_models_dl_test.go index 4c0cd1f..ad4a0d7 100644 --- a/internal/ajean/backend_models_dl_test.go +++ b/internal/ajean/backend_models_dl_test.go @@ -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} { diff --git a/internal/ajean/backend_prebuilt_test.go b/internal/ajean/backend_prebuilt_test.go index 326e35e..fb4a50e 100644 --- a/internal/ajean/backend_prebuilt_test.go +++ b/internal/ajean/backend_prebuilt_test.go @@ -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" { diff --git a/internal/ajean/backend_presets_test.go b/internal/ajean/backend_presets_test.go index b002666..385bd3a 100644 --- a/internal/ajean/backend_presets_test.go +++ b/internal/ajean/backend_presets_test.go @@ -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 { diff --git a/internal/ajean/chat_agent.go b/internal/ajean/chat_agent.go index 768d1fa..4eb6aba 100644 --- a/internal/ajean/chat_agent.go +++ b/internal/ajean/chat_agent.go @@ -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") diff --git a/internal/ajean/chat_memory.go b/internal/ajean/chat_memory.go index 248e07a..dead68e 100644 --- a/internal/ajean/chat_memory.go +++ b/internal/ajean/chat_memory.go @@ -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() diff --git a/internal/ajean/mcp_client_test.go b/internal/ajean/mcp_client_test.go index 682a9d5..c65207e 100644 --- a/internal/ajean/mcp_client_test.go +++ b/internal/ajean/mcp_client_test.go @@ -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", diff --git a/internal/ajean/relay_link.go b/internal/ajean/relay_link.go index 3edcc56..2fbaeaf 100644 --- a/internal/ajean/relay_link.go +++ b/internal/ajean/relay_link.go @@ -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() diff --git a/internal/ajean/run.go b/internal/ajean/run.go index 63019d8..da539f8 100644 --- a/internal/ajean/run.go +++ b/internal/ajean/run.go @@ -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 |url |key ] - 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 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 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) } diff --git a/internal/ajean/store.go b/internal/ajean/store.go index 13a671b..faf74f9 100644 --- a/internal/ajean/store.go +++ b/internal/ajean/store.go @@ -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 { diff --git a/internal/ajean/store_test.go b/internal/ajean/store_test.go new file mode 100644 index 0000000..2862036 --- /dev/null +++ b/internal/ajean/store_test.go @@ -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") + } +} diff --git a/internal/ajean/sys_install_darwin.go b/internal/ajean/sys_install_darwin.go index 96953a0..2fdce9b 100644 --- a/internal/ajean/sys_install_darwin.go +++ b/internal/ajean/sys_install_darwin.go @@ -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 diff --git a/internal/ajean/sys_install_linux.go b/internal/ajean/sys_install_linux.go index 022f2f5..f29b82c 100644 --- a/internal/ajean/sys_install_linux.go +++ b/internal/ajean/sys_install_linux.go @@ -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 { diff --git a/internal/ajean/sys_install_windows.go b/internal/ajean/sys_install_windows.go index a216c0a..765d886 100644 --- a/internal/ajean/sys_install_windows.go +++ b/internal/ajean/sys_install_windows.go @@ -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() diff --git a/internal/ajean/sys_update_asset_test.go b/internal/ajean/sys_update_asset_test.go index 13a88b0..8dbdd59 100644 --- a/internal/ajean/sys_update_asset_test.go +++ b/internal/ajean/sys_update_asset_test.go @@ -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) }