Renommage AJEAN (1/3) : module, chemins de donnees et auto-MAJ

Socle du renommage jean -> AJEAN, sans aucun impact visible pour le parc
installe. Trois briques :

- module github.com/nathaninline/ajean, cmd/ajean, internal/ajean,
  package ajean. Purement interne.

- AjeanHome() remplace JeanHome(). Les defauts deviennent
  %ProgramData%\ajean et /etc/ajean, avec migration du dossier existant
  au premier lancement. La migration est un os.Rename dans le dossier
  parent de l'ancien chemin : intra-volume, donc atomique et instantane
  meme avec des .gguf de plusieurs dizaines de Go. Si le rename echoue
  (handle ouvert sous Windows, droits, volume en lecture seule) on
  CONTINUE sur l'ancien chemin et on retentera : jamais de copie, jamais
  de suppression, le pire cas est "rien n'a bouge".
  $AJEAN_HOME et $JEAN_HOME restent tous deux honores, ainsi que
  /etc/default/ajean et /etc/default/jean : un chemin impose a la main
  est un ordre, on ne le migre pas.

- l'auto-MAJ accepte desormais ajean-<os>-<arch> ET jean-<os>-<arch>,
  dans cet ordre. Indispensable dans les deux sens : les binaires deja
  installes ne connaissent que jean-*, et ce binaire-ci doit pouvoir
  s'installer depuis une release anterieure au renommage.

Couvert par des tests, dont le repli quand un handle ouvert bloque le
rename sous Windows.
This commit is contained in:
nathaninline committed 2026-08-04 13:59:25 +02:00
1 parent c1d58b19f2
commit 110458c133
104 files changed
+514 -226

No files matched your search

+2 -2
View File
@@ -41,7 +41,7 @@ Aucune dépendance à l'exécution, aucun flag CMake à retenir, aucun conteneur
### 1. Le binaire
```bash
curl -L -o jean https://github.com/nathaninline/jean/releases/latest/download/jean-linux-amd64
curl -L -o jean https://github.com/nathaninline/ajean/releases/latest/download/jean-linux-amd64
chmod +x jean
sudo mv jean /usr/local/bin/jean
```
@@ -264,7 +264,7 @@ Chaque appel `/api/*` présente alors `Authorization: Bearer <clé>` :
Go 1.25+. AJEAN est écrit à 100 % en Go, l'interface est embarquée via `go:embed` :
```bash
git clone https://github.com/nathaninline/jean.git
git clone https://github.com/nathaninline/ajean.git
cd jean
CGO_ENABLED=0 go build -o jean ./cmd/jean
```
File renamed without changes.
File renamed without changes.
+4 -4
View File
@@ -1,4 +1,4 @@
// Point d'entrée du binaire jean. Tout le code vit dans internal/jean ; ce
// Point d'entrée du binaire ajean. Tout le code vit dans internal/jean ; ce
// dossier ne porte que le main() et les ressources Windows (.syso, icône,
// versioninfo) qui doivent résider dans le dossier du package main.
//
@@ -7,11 +7,11 @@
// les .syso après avoir bumpé la version : `go generate ./...`
// (nécessite : go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@latest)
//
//go:generate go run github.com/nathaninline/jean/tools/gen-icon icon.ico
//go:generate go run github.com/nathaninline/ajean/tools/gen-icon icon.ico
//go:generate goversioninfo -64 -icon=icon.ico -o resource_windows_amd64.syso versioninfo.json
//go:generate goversioninfo -64 -arm -icon=icon.ico -o resource_windows_arm64.syso versioninfo.json
package main
import "github.com/nathaninline/jean/internal/jean"
import "github.com/nathaninline/ajean/internal/ajean"
func main() { jean.Main() }
func main() { ajean.Main() }
File renamed without changes.
File renamed without changes.
@@ -26,7 +26,7 @@
"OriginalFilename": "jean.exe",
"ProductName": "AJEAN",
"ProductVersion": "0.6.15",
"Comments": "https://github.com/nathaninline/jean — projet open source (MIT)"
"Comments": "https://github.com/nathaninline/ajean — projet open source (MIT)"
},
"VarFileInfo": {
"Translation": {
+1 -1
View File
@@ -1,4 +1,4 @@
module github.com/nathaninline/jean
module github.com/nathaninline/ajean
go 1.25.0
@@ -1,6 +1,6 @@
// backend_build.go — machinerie de compilation de llama.cpp : détection du
// plan de build (CUDA/ROCm/Metal/Vulkan/CPU), cmake, suivi de progression, logs.
package jean
package ajean
import (
"bufio"
@@ -1,4 +1,4 @@
package jean
package ajean
import "testing"
@@ -1,4 +1,4 @@
package jean
package ajean
// backend_catalog.go — récupère la liste de modèles curatée servie par ajean.link et la
// combine avec les infos matérielles locales, pour que l'écran d'accueil (à
@@ -1,4 +1,4 @@
package jean
package ajean
import (
"bufio"
@@ -1,4 +1,4 @@
package jean
package ajean
import (
"bufio"
@@ -1,4 +1,4 @@
package jean
package ajean
import (
"fmt"
@@ -82,7 +82,7 @@ func llamacppRepoDir() string {
}
func defaultRepoDir() string {
return filepath.Join(JeanHome(), "backends", "llama.cpp")
return filepath.Join(AjeanHome(), "backends", "llama.cpp")
}
// findRepoRoot walks up from a binary path (…/build/bin/llama-server) looking
@@ -510,7 +510,7 @@ func sanitizeBackendName(name string) string {
// dossier backends (../, chemin absolu…).
func backendDir(name string) (string, error) {
name = sanitizeBackendName(name)
root, err := filepath.Abs(filepath.Join(JeanHome(), "backends"))
root, err := filepath.Abs(filepath.Join(AjeanHome(), "backends"))
if err != nil {
return "", err
}
@@ -1,4 +1,4 @@
package jean
package ajean
import (
"encoding/json"
@@ -15,7 +15,7 @@ import (
// (disque dur externe, second SSD, montage réseau…). La liste est persistée
// dans $JEAN_HOME/model_dirs.json — PAS dans config.env, que le changement de
// preset réécrit — et peut aussi venir de $JEAN_MODEL_DIRS.
func modelDirsPath() string { return filepath.Join(JeanHome(), "model_dirs.json") }
func modelDirsPath() string { return filepath.Join(AjeanHome(), "model_dirs.json") }
// modelDirsListSep sépare les dossiers dans $JEAN_MODEL_DIRS (':' sous Unix,
// ';' sous Windows, comme le PATH).
@@ -39,7 +39,7 @@ func extraModelDirs() []string {
if v := strings.TrimSpace(os.Getenv("JEAN_MODEL_DIRS")); v != "" {
raw = append(raw, strings.Split(v, modelDirsListSep())...)
}
return dedupDirs(raw, JeanHome())
return dedupDirs(raw, AjeanHome())
}
// dedupDirs nettoie une liste de chemins : vides retirés, chemins absolus
@@ -82,17 +82,17 @@ func normDir(p string) string {
// modelDirs renvoie tous les dossiers où chercher un .gguf : JEAN_HOME d'abord
// (il reste la destination des téléchargements), puis les dossiers ajoutés.
func modelDirs() []string {
return append([]string{JeanHome()}, extraModelDirs()...)
return append([]string{AjeanHome()}, extraModelDirs()...)
}
// saveExtraModelDirs écrit la liste des dossiers supplémentaires sur disque.
func saveExtraModelDirs(dirs []string) error {
clean := dedupDirs(dirs, JeanHome())
clean := dedupDirs(dirs, AjeanHome())
b, err := json.MarshalIndent(clean, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(JeanHome(), 0o755); err != nil {
if err := os.MkdirAll(AjeanHome(), 0o755); err != nil {
return err
}
return os.WriteFile(modelDirsPath(), append(b, '\n'), 0o644)
@@ -147,7 +147,7 @@ func resolveModelPath(name string) (string, error) {
return p, nil
}
}
return filepath.Join(JeanHome(), base), nil // introuvable : l'appelant décidera
return filepath.Join(AjeanHome(), base), nil // introuvable : l'appelant décidera
}
// modelDirCount compte les .gguf lisibles d'un dossier (-1 si illisible).
@@ -211,7 +211,7 @@ func handleModelDirs(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, map[string]any{"ok": true})
return
}
out := []map[string]any{{"path": JeanHome(), "home": true, "count": modelDirCount(JeanHome()), "exists": true}}
out := []map[string]any{{"path": AjeanHome(), "home": true, "count": modelDirCount(AjeanHome()), "exists": true}}
for _, d := range extraModelDirs() {
n := modelDirCount(d)
out = append(out, map[string]any{"path": d, "home": false, "count": n, "exists": n >= 0})
@@ -1,4 +1,4 @@
package jean
package ajean
import (
"os"
@@ -1,4 +1,4 @@
package jean
package ajean
import (
"context"
@@ -97,7 +97,7 @@ func downloadDestPath(name string) (string, error) {
if !strings.HasSuffix(strings.ToLower(base), ".gguf") {
return "", fmt.Errorf("seuls les fichiers .gguf sont acceptés")
}
return filepath.Join(JeanHome(), base), nil
return filepath.Join(AjeanHome(), base), nil
}
// modelFromPresetContent extracts the MODEL= value from a preset's config.env
@@ -600,7 +600,7 @@ func handleModelDownloadCancel(w http.ResponseWriter, r *http.Request) {
// (its state lived in memory), so the partial file would otherwise sit there
// forever eating disk.
func cleanStalePartFiles() {
matches, err := filepath.Glob(filepath.Join(JeanHome(), "*.gguf.part"))
matches, err := filepath.Glob(filepath.Join(AjeanHome(), "*.gguf.part"))
if err != nil {
return
}
@@ -1,4 +1,4 @@
package jean
package ajean
import (
"bytes"
@@ -8,7 +8,7 @@
// CUDA officiel pour Linux (on retombe sur Vulkan — la compilation locale
// reste la voie CUDA sous Linux). La compilation locale reste indispensable
// pour les forks (ex. PrismML).
package jean
package ajean
import (
"archive/tar"
@@ -37,7 +37,7 @@ type ghAsset struct {
}
func prebuiltDir() string {
return filepath.Join(JeanHome(), "backends", "llama.cpp-prebuilt")
return filepath.Join(AjeanHome(), "backends", "llama.cpp-prebuilt")
}
// prebuiltVersion lit le marqueur VERSION du dossier prebuilt : "tag cudaVer"
@@ -1,4 +1,4 @@
package jean
package ajean
import (
"archive/tar"
@@ -1,4 +1,4 @@
package jean
package ajean
import (
"bufio"
@@ -1,4 +1,4 @@
package jean
package ajean
import "testing"
@@ -1,4 +1,4 @@
package jean
package ajean
import (
"fmt"
@@ -34,7 +34,7 @@ func cmdServe(args []string) error {
return fmt.Errorf("modèle introuvable : %s", model)
}
if !filepath.IsAbs(bin) {
bin = filepath.Join(JeanHome(), bin)
bin = filepath.Join(AjeanHome(), bin)
}
// Make sure llama-server can find its bundled shared libraries (the .so/.dll
@@ -102,7 +102,7 @@ func cmdServe(args []string) error {
// Working dir = JEAN_HOME so relative paths in EXTRA_ARGS (e.g. --mmproj
// mmproj-F16.gguf) still resolve.
_ = os.Chdir(JeanHome())
_ = os.Chdir(AjeanHome())
fmt.Fprintf(os.Stderr, "[jean serve] %s model=%s port=%s\n",
bin, filepath.Base(model), get("PORT", "8080"))
@@ -1,4 +1,4 @@
package jean
package ajean
import (
"fmt"
@@ -27,7 +27,7 @@ func agentEnabled() bool {
}
func setAgentEnabled(on bool) error {
_ = os.MkdirAll(JeanHome(), 0o755)
_ = os.MkdirAll(AjeanHome(), 0o755)
// On nettoie systématiquement les anciens drapeaux pour ne pas garder un
// état fantôme « à moitié activé » hérité de l'ancien modèle à deux toggles.
_ = os.Remove(legacyToolsFlag())
@@ -1,4 +1,4 @@
package jean
package ajean
import (
"bufio"
@@ -1,4 +1,4 @@
package jean
package ajean
import (
"bytes"
@@ -1,4 +1,4 @@
package jean
package ajean
import (
"strings"
@@ -1,4 +1,4 @@
package jean
package ajean
import (
"context"
@@ -65,10 +65,10 @@ var conv = func() *Conversation {
return c
}()
// convPath = fichier de persistance sur la box jean (JeanHome = /etc/jean en
// convPath = fichier de persistance sur la box jean (AjeanHome = /etc/jean en
// prod). En clair : la box déchiffre déjà pour lancer le modèle, le relais reste
// aveugle — le persister ici ne change rien à la posture E2E.
func convPath() string { return filepath.Join(JeanHome(), "conversation.json") }
func convPath() string { return filepath.Join(AjeanHome(), "conversation.json") }
// LoadConversation recharge l'état persisté au démarrage du process. Sans fichier
// (première fois) on part d'une conversation vide.
@@ -94,7 +94,7 @@ func (c *Conversation) persist() {
if err != nil {
return
}
_ = os.MkdirAll(JeanHome(), 0o755)
_ = os.MkdirAll(AjeanHome(), 0o755)
tmp := convPath() + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return
@@ -1,4 +1,4 @@
package jean
package ajean
import (
"context"
@@ -1,4 +1,4 @@
package jean
package ajean
// chat_diff.go — petit diff ligne à ligne pour l'interface : quand l'IA modifie
// un fichier (outil `edit`) ou une page de mémoire (`mem_edit`, `mem_add`), on
@@ -1,4 +1,4 @@
package jean
package ajean
import (
"bytes"
@@ -74,7 +74,7 @@ func writeCrawlKey(key string) error {
}
return nil
}
_ = os.MkdirAll(JeanHome(), 0o755)
_ = os.MkdirAll(AjeanHome(), 0o755)
return os.WriteFile(crawlKeyPath(), []byte(key+"\n"), 0o600)
}
@@ -97,7 +97,7 @@ func internetEnabled() bool {
}
func setInternetEnabled(on bool) error {
_ = os.MkdirAll(JeanHome(), 0o755)
_ = os.MkdirAll(AjeanHome(), 0o755)
if on {
f, err := os.Create(internetFlag())
if err != nil {
@@ -1,6 +1,6 @@
// chat_internet_tools.go — les 4 outils web exposés au modèle (web_search,
// web_open, web_read, web_grep) + la sous-commande `jean internet`.
package jean
package ajean
import (
"fmt"
@@ -1,4 +1,4 @@
package jean
package ajean
import (
"errors"
@@ -1,4 +1,4 @@
package jean
package ajean
import (
"context"
@@ -1,4 +1,4 @@
package jean
package ajean
import (
"os"
@@ -1,4 +1,4 @@
package jean
package ajean
import (
"os"
@@ -26,7 +26,7 @@ var (
)
// agentWorkspace renvoie le dossier de travail de l'agent, créé au besoin. Il
// essaie plusieurs emplacements car JeanHome() vaut %ProgramData%\jean sous
// essaie plusieurs emplacements car AjeanHome() vaut %ProgramData%\jean sous
// Windows, qui n'est pas inscriptible par un utilisateur non administrateur.
func agentWorkspace() string {
workspaceOnce.Do(func() {
@@ -60,7 +60,7 @@ func workspaceCandidates() []string {
if v := strings.TrimSpace(os.Getenv(workspaceEnv)); v != "" {
c = append(c, v)
}
c = append(c, filepath.Join(JeanHome(), "workspace"))
c = append(c, filepath.Join(AjeanHome(), "workspace"))
if home, err := os.UserHomeDir(); err == nil && home != "" {
c = append(c, filepath.Join(home, "jean", "workspace"))
}
@@ -1,4 +1,4 @@
package jean
package ajean
import (
"path/filepath"
@@ -1,4 +1,4 @@
package jean
package ajean
// cli_app.go — expérience « application » de Jean.
//
@@ -15,4 +15,4 @@
//
// Les suffixes _windows/_linux/_darwin/_unix/_other portent les contraintes de
// compilation par OS. ui/index.html est GÉNÉRÉ depuis ui/src/ (voir ui/assemble.ps1).
package jean
package ajean
@@ -1,4 +1,4 @@
package jean
package ajean
import (
"bytes"
@@ -138,7 +138,7 @@ func runBench(nPrompt, nPredict int) (*benchResult, error) {
// lastBenchPath stores the most recent benchmark result so the web UI can show
// it without re-running. Lives in JEAN_HOME so it survives restarts.
func lastBenchPath() string { return filepath.Join(JeanHome(), ".last_bench.json") }
func lastBenchPath() string { return filepath.Join(AjeanHome(), ".last_bench.json") }
// savedBench is a benchResult plus the model it was run against and a timestamp.
type savedBench struct {
@@ -170,7 +170,7 @@ func loadLastBench() *savedBench {
// benchStorePath holds per-preset benchmark results so the UI can show each
// preset's measured performance. Keyed by preset name.
func benchStorePath() string { return filepath.Join(JeanHome(), ".bench_presets.json") }
func benchStorePath() string { return filepath.Join(AjeanHome(), ".bench_presets.json") }
// loadBenchStore returns the per-preset bench map (empty if none/unreadable).
func loadBenchStore() map[string]savedBench {
@@ -1,4 +1,4 @@
package jean
package ajean
import (
"bufio"
@@ -1,4 +1,4 @@
package jean
package ajean
import (
"bufio"
@@ -74,7 +74,7 @@ func runOAIFront(rawLn net.Listener, tlsCfg *tls.Config) error {
// oaiPublicPath est le drapeau qui active l'accès OpenAI public (piloté par l'UI,
// lu en direct → activable/coupable sans redémarrer le service de lien).
func oaiPublicPath() string { return filepath.Join(JeanHome(), ".oai_public") }
func oaiPublicPath() string { return filepath.Join(AjeanHome(), ".oai_public") }
// oaiPublicEnabled indique si l'accès OpenAI public est activé pour cette machine.
func oaiPublicEnabled() bool {
@@ -101,7 +101,7 @@ func setOAIPublic(on bool) error {
// $JEAN_HOME/certs et ne quitte jamais la machine. Toujours construite ; c'est le
// démux (oaiPublicEnabled, lu en direct) qui décide de router ou non le trafic.
func oaiTLSConfig() *tls.Config {
certmagic.Default.Storage = &certmagic.FileStorage{Path: filepath.Join(JeanHome(), "certs")}
certmagic.Default.Storage = &certmagic.FileStorage{Path: filepath.Join(AjeanHome(), "certs")}
certmagic.DefaultACME.Agreed = true
certmagic.DefaultACME.Email = strings.TrimSpace(os.Getenv("JEAN_ACME_EMAIL"))
certmagic.DefaultACME.DisableHTTPChallenge = true // pas de :80 accessible (CGNAT) → TLS-ALPN uniquement
@@ -1,4 +1,4 @@
package jean
package ajean
import (
"context"
@@ -1,4 +1,4 @@
package jean
package ajean
import (
"context"
@@ -1,4 +1,4 @@
package jean
package ajean
import (
"os/exec"
@@ -1,4 +1,4 @@
package jean
package ajean
import (
"encoding/json"
@@ -149,7 +149,7 @@ func loadMCPConfigLocked() (map[string]MCPServerConfig, error) {
// saveMCPConfigLocked écrit atomiquement mcp.json (write temp + rename).
func saveMCPConfigLocked(servers map[string]MCPServerConfig) error {
_ = os.MkdirAll(JeanHome(), 0o755)
_ = os.MkdirAll(AjeanHome(), 0o755)
f := mcpConfigFile{MCPServers: servers}
b, err := json.MarshalIndent(f, "", " ")
if err != nil {
@@ -1,4 +1,4 @@
package jean
package ajean
// relay_e2e.go — chiffrement bout-en-bout du chat entre le NAVIGATEUR et CET agent, de
// sorte que le relais (ajean.link) ne voie que de l'opaque : « boîte noire ».
@@ -32,7 +32,7 @@ import (
"sync"
)
func e2eKeyPath() string { return filepath.Join(JeanHome(), ".e2e_key") }
func e2eKeyPath() string { return filepath.Join(AjeanHome(), ".e2e_key") }
var (
e2eOnce sync.Once
@@ -55,7 +55,7 @@ func e2ePrivateKey() (*ecdh.PrivateKey, error) {
e2eErr = err
return
}
_ = os.MkdirAll(JeanHome(), 0o755)
_ = os.MkdirAll(AjeanHome(), 0o755)
if err := os.WriteFile(e2eKeyPath(), []byte(hex.EncodeToString(k.Bytes())+"\n"), 0o600); err != nil {
e2eErr = err
return
@@ -1,4 +1,4 @@
package jean
package ajean
// relay_e2eauth.go — authentification MUTUELLE + anti-rejeu du canal chiffré.
//
@@ -44,7 +44,7 @@ var (
authSet = map[string]bool{}
)
func authUsersPath() string { return filepath.Join(JeanHome(), ".authorized_users") }
func authUsersPath() string { return filepath.Join(AjeanHome(), ".authorized_users") }
func loadAuthUsers() {
authOnce.Do(func() {
@@ -88,7 +88,7 @@ func authorizeUser(uPubHex string) error {
return nil
}
authSet[uPubHex] = true
_ = os.MkdirAll(JeanHome(), 0o755)
_ = os.MkdirAll(AjeanHome(), 0o755)
f, err := os.OpenFile(authUsersPath(), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return err
@@ -102,7 +102,7 @@ func authorizeUser(uPubHex string) error {
//
// Générés par « jean link code » (ou affichés par « jean link »), ils sont
// partagés avec le worker « jean link --foreground » (systemd, autre process) via
// un fichier JeanHome/.pair_codes — sinon les deux process ne s'accorderaient pas.
// un fichier AjeanHome/.pair_codes — sinon les deux process ne s'accorderaient pas.
// Stockés HACHÉS (SHA-256) : le fichier ne révèle aucun code. Chaque code expire
// au bout de 10 min et est consommé (retiré) au premier appairage réussi.
@@ -113,7 +113,7 @@ type pairEntry struct {
Exp int64 `json:"e"` // expiration (unix ms)
}
func pairCodesPath() string { return filepath.Join(JeanHome(), ".pair_codes") }
func pairCodesPath() string { return filepath.Join(AjeanHome(), ".pair_codes") }
func hashPairCode(code string) string {
h := sha256.Sum256([]byte(strings.ToUpper(strings.TrimSpace(code))))
@@ -141,7 +141,7 @@ func loadPairEntries() []pairEntry {
}
func savePairEntries(es []pairEntry) error {
if err := os.MkdirAll(JeanHome(), 0o755); err != nil {
if err := os.MkdirAll(AjeanHome(), 0o755); err != nil {
return err
}
b, _ := json.Marshal(es)
@@ -1,4 +1,4 @@
package jean
package ajean
import (
"strings"
@@ -1,4 +1,4 @@
package jean
package ajean
// relay_link.go — `jean link <token>` : connecte ce serveur Jean au relais public
// (ajean.link) par une connexion SORTANTE persistante, pour qu'un utilisateur
@@ -42,8 +42,8 @@ import (
// $JEAN_LINK_URL (utile pour tester contre un relais local).
const defaultRelayURL = "wss://ajean.link/agent"
func linkTokenPath() string { return filepath.Join(JeanHome(), ".link_token") }
func linkMachinePath() string { return filepath.Join(JeanHome(), ".link_machine") }
func linkTokenPath() string { return filepath.Join(AjeanHome(), ".link_token") }
func linkMachinePath() string { return filepath.Join(AjeanHome(), ".link_machine") }
// machineID returns a stable per-machine identifier, creating one on first use.
// Permet au relais de regrouper les connexions d'une même machine sous le compte.
@@ -56,7 +56,7 @@ func machineID() string {
buf := make([]byte, 8)
_, _ = rand.Read(buf)
id := hex.EncodeToString(buf)
_ = os.MkdirAll(JeanHome(), 0o755)
_ = os.MkdirAll(AjeanHome(), 0o755)
_ = os.WriteFile(linkMachinePath(), []byte(id+"\n"), 0o600)
return id
}
@@ -71,7 +71,7 @@ func readLinkToken() string {
}
func saveLinkToken(tok string) error {
if err := os.MkdirAll(JeanHome(), 0o755); err != nil {
if err := os.MkdirAll(AjeanHome(), 0o755); err != nil {
return err
}
return os.WriteFile(linkTokenPath(), []byte(tok+"\n"), 0o600)
@@ -140,7 +140,7 @@ func cmdLink(args []string) error {
case "code":
code, err := newPairCode()
if err != nil {
return fmt.Errorf("génération du code (droits sur %s ?): %w", JeanHome(), err)
return fmt.Errorf("génération du code (droits sur %s ?): %w", AjeanHome(), err)
}
fmt.Printf("%s code d'appairage (valable 10 min, à usage unique) :\n %s\n", green("[link]"), bold(code))
return nil
@@ -209,7 +209,7 @@ func linkPrintIdentity() error {
}
code, err := newPairCode()
if err != nil {
fmt.Printf("%s code d'appairage indisponible (droits sur %s ?): %v\n", yellow("[link]"), JeanHome(), err)
fmt.Printf("%s code d'appairage indisponible (droits sur %s ?): %v\n", yellow("[link]"), AjeanHome(), err)
fmt.Printf(" Réessaie : sudo jean link code\n")
return nil
}
@@ -375,8 +375,8 @@ func serveLocalWebMux(mux *http.ServeMux) {
// linkPIDPath / linkLogPath : suivi du worker de lien hors systemd (macOS,
// Windows), où Jean est une app de bureau lancée sans droits root.
func linkPIDPath() string { return filepath.Join(JeanHome(), linkServiceName+".pid") }
func linkLogPath() string { return filepath.Join(JeanHome(), linkServiceName+".log") }
func linkPIDPath() string { return filepath.Join(AjeanHome(), linkServiceName+".pid") }
func linkLogPath() string { return filepath.Join(AjeanHome(), linkServiceName+".log") }
// linkServiceCtl pilote le worker de lien (start/stop/restart). Sous Linux c'est
// l'unité systemd jean-link (avec sudo non interactif si on n'est pas root) ;
@@ -486,7 +486,7 @@ func linkUserSvcCtl(action string) error {
if p, err := filepath.EvalSymlinks(self); err == nil {
self = p
}
if err := os.MkdirAll(JeanHome(), 0o755); err != nil {
if err := os.MkdirAll(AjeanHome(), 0o755); err != nil {
return err
}
logf, err := os.OpenFile(linkLogPath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
+40 -30
View File
@@ -1,7 +1,7 @@
// jean — single-binary LLM server manager + web UI for llama.cpp deployments.
// Point d'entrée réel : Main(), appelé par cmd/jean (les métadonnées Windows
// .syso/go:generate vivent là-bas, dans le dossier du package main).
package jean
package ajean
import (
"fmt"
@@ -175,55 +175,65 @@ func mustExit(err error) {
}
}
// JeanHome resolves the JEAN data directory.
// Precedence: $JEAN_HOME → /etc/default/jean (unix only) → defaultJeanHome().
// defaultJeanHome() is platform-specific (see sys_platform_unix.go / sys_platform_windows.go).
func JeanHome() string {
// AjeanHome resolves the AJEAN data directory.
// Precedence: $AJEAN_HOME → $JEAN_HOME (héritage) → /etc/default/ajean puis
// /etc/default/jean (unix only) → migratedDefaultHome().
//
// Les deux noms de variable sont acceptés indéfiniment : ils sont posés À LA MAIN
// dans les config des utilisateurs et dans des scripts qu'on ne voit pas. Une
// variable explicite est un ordre — on ne migre RIEN quand il y en a une, on obéit.
func AjeanHome() string {
if h := os.Getenv("AJEAN_HOME"); h != "" {
return h
}
if h := os.Getenv("JEAN_HOME"); h != "" {
return h
}
if h := readEtcDefault(); h != "" {
return h
}
return defaultJeanHome()
return migratedDefaultHome()
}
// readEtcDefault parses /etc/default/jean for JEAN_HOME=<path>. Quiet on errors.
// readEtcDefault parses /etc/default/ajean (puis /etc/default/jean, hérité) for
// AJEAN_HOME=<path> ou JEAN_HOME=<path>. Quiet on errors.
func readEtcDefault() string {
b, err := os.ReadFile("/etc/default/jean")
if err != nil {
return ""
}
for _, line := range strings.Split(string(b), "\n") {
s := strings.TrimSpace(line)
if s == "" || strings.HasPrefix(s, "#") {
for _, path := range []string{"/etc/default/ajean", "/etc/default/jean"} {
b, err := os.ReadFile(path)
if err != nil {
continue
}
s = strings.TrimPrefix(s, "export ")
if eq := strings.IndexByte(s, '='); eq > 0 {
k := strings.TrimSpace(s[:eq])
v := strings.Trim(strings.TrimSpace(s[eq+1:]), "\"'")
if k == "JEAN_HOME" {
return v
for _, line := range strings.Split(string(b), "\n") {
s := strings.TrimSpace(line)
if s == "" || strings.HasPrefix(s, "#") {
continue
}
s = strings.TrimPrefix(s, "export ")
if eq := strings.IndexByte(s, '='); eq > 0 {
k := strings.TrimSpace(s[:eq])
v := strings.Trim(strings.TrimSpace(s[eq+1:]), "\"'")
if k == "AJEAN_HOME" || k == "JEAN_HOME" {
return v
}
}
}
}
return ""
}
func confPath() string { return filepath.Join(JeanHome(), "config.env") }
func presetsDir() string { return filepath.Join(JeanHome(), "configs") }
func skillsDir() string { return filepath.Join(JeanHome(), "SKILLS") }
func memoryDir() string { return filepath.Join(JeanHome(), "MEMORY") }
func agentFlag() string { return filepath.Join(JeanHome(), ".agent_enabled") }
func internetFlag() string { return filepath.Join(JeanHome(), ".internet_enabled") }
func mcpConfigPath() string { return filepath.Join(JeanHome(), "mcp.json") }
func confPath() string { return filepath.Join(AjeanHome(), "config.env") }
func presetsDir() string { return filepath.Join(AjeanHome(), "configs") }
func skillsDir() string { return filepath.Join(AjeanHome(), "SKILLS") }
func memoryDir() string { return filepath.Join(AjeanHome(), "MEMORY") }
func agentFlag() string { return filepath.Join(AjeanHome(), ".agent_enabled") }
func internetFlag() string { return filepath.Join(AjeanHome(), ".internet_enabled") }
func mcpConfigPath() string { return filepath.Join(AjeanHome(), "mcp.json") }
// Anciens drapeaux séparés, conservés pour la migration vers le mode agent unifié.
func legacySkillsFlag() string { return filepath.Join(skillsDir(), ".enabled") }
func legacyToolsFlag() string { return filepath.Join(JeanHome(), ".tools_enabled") }
func apiKeyPath() string { return filepath.Join(JeanHome(), ".api_key") }
func crawlKeyPath() string { return filepath.Join(JeanHome(), ".crawl4ai_key") }
func legacyToolsFlag() string { return filepath.Join(AjeanHome(), ".tools_enabled") }
func apiKeyPath() string { return filepath.Join(AjeanHome(), ".api_key") }
func crawlKeyPath() string { return filepath.Join(AjeanHome(), ".crawl4ai_key") }
func serviceName() string {
if n := os.Getenv("JEAN_SERVICE"); n != "" {
return n
@@ -1,4 +1,4 @@
package jean
package ajean
// sys_brand_icon.go — l'icône de la marque AJEAN, rendue à la volée.
//
@@ -1,6 +1,6 @@
//go:build darwin
package jean
package ajean
// sys_console_darwin.go — détection du lancement « par clic » sous macOS.
//
@@ -1,6 +1,6 @@
//go:build !windows && !darwin
package jean
package ajean
// setupConsole : sous Linux, le process a toujours une vraie console/tty
// standard (stdout hérité). Rien à faire → on signale simplement « on a une
@@ -1,6 +1,6 @@
//go:build windows
package jean
package ajean
// sys_console_windows.go — gestion de la console sous Windows.
//
@@ -1,6 +1,6 @@
//go:build !windows
package jean
package ajean
import "os"
@@ -1,6 +1,6 @@
//go:build windows
package jean
package ajean
import (
"fmt"
@@ -113,7 +113,7 @@ func appFirstRun() bool {
"Installer AJEAN sur cet ordinateur ?\n\n"+
"• le programme sera copié dans :\n "+target+"\n"+
"• un raccourci sera ajouté au menu Démarrer et au Bureau\n"+
"• vos réglages et modèles seront rangés dans :\n "+JeanHome()+"\n\n"+
"• vos réglages et modèles seront rangés dans :\n "+AjeanHome()+"\n\n"+
"Vous pourrez ensuite supprimer le fichier que vous venez de télécharger.\n\n"+
"Répondre Non démarre AJEAN sans rien installer. La question reviendra\n"+
"au prochain lancement de ce fichier.",
@@ -376,7 +376,7 @@ type vsFixedFileInfo struct {
// Extrait de cmdInstall pour être réutilisable au premier lancement, sans
// embarquer l'installation du binaire ni les messages de console.
func provisionDataDir() error {
jeanHome := JeanHome()
jeanHome := AjeanHome()
for _, d := range []string{jeanHome, filepath.Join(jeanHome, "configs"), filepath.Join(jeanHome, "SKILLS")} {
if err := os.MkdirAll(d, 0o755); err != nil {
return err
@@ -1,6 +1,6 @@
//go:build windows
package jean
package ajean
import (
"os"
@@ -1,6 +1,6 @@
//go:build darwin
package jean
package ajean
import (
"fmt"
@@ -1,6 +1,6 @@
//go:build linux
package jean
package ajean
import (
"fmt"
@@ -1,6 +1,6 @@
//go:build windows
package jean
package ajean
import (
"fmt"
@@ -38,7 +38,7 @@ EXTRA_ARGS=""
`
func cmdInstall(args []string) error {
jeanHome := JeanHome()
jeanHome := AjeanHome()
fmt.Printf("Installation (Windows)\n")
fmt.Printf(" JEAN_HOME = %s\n", jeanHome)
@@ -203,7 +203,7 @@ func cmdUninstall(args []string) error {
_ = svcStop(false)
// Pull JEAN_HOME\bin off the user PATH (best-effort).
binDir := filepath.Join(JeanHome(), "bin")
binDir := filepath.Join(AjeanHome(), "bin")
if removed, err := removeFromUserPath(binDir); err == nil && removed {
fmt.Printf(" %s %s retiré du PATH utilisateur\n", green("✓"), binDir)
}
@@ -214,7 +214,7 @@ func cmdUninstall(args []string) error {
}
if !keepData {
jeanHome := JeanHome()
jeanHome := AjeanHome()
if err := os.RemoveAll(jeanHome); err != nil {
return fmt.Errorf("suppression de %s: %w", jeanHome, err)
}
+80
View File
@@ -0,0 +1,80 @@
package ajean
import (
"fmt"
"os"
"path/filepath"
"sync"
)
// Migration du dossier de données jean → ajean.
//
// C'est le seul endroit du renommage qui peut faire PERDRE des données : le
// dossier contient la config, les clés, la mémoire, les conversations, et
// surtout des .gguf qui pèsent des dizaines de gigaoctets. Trois règles en
// découlent, et elles expliquent tout le code ci-dessous :
//
// 1. On déplace par os.Rename DANS LE DOSSIER PARENT de l'ancien. Un rename
// intra-volume est atomique et instantané — il ne recopie pas les 40 Go, et
// il n'existe aucun instant où les données seraient à moitié quelque part.
// Choisir le parent de l'ancien chemin (et pas le défaut de la plateforme)
// garantit qu'on reste sur le même volume, donc que le rename est bien un
// rename et non un copy+delete déguisé.
//
// 2. Si le rename échoue — service en cours qui tient des handles sous Windows,
// droits insuffisants, volume monté en lecture seule — on CONTINUE SUR
// L'ANCIEN CHEMIN. Une machine qui n'a pas migré marche exactement comme
// avant ; une machine qui a perdu ses modèles, non. La migration est donc
// retentée à chaque démarrage jusqu'à ce qu'elle passe.
//
// 3. Aucune suppression, jamais. Le pire cas est « rien n'a bougé ».
var (
homeOnce sync.Once
homePath string
)
// migratedDefaultHome renvoie le dossier de données par défaut, en migrant
// l'ancien dossier « jean » vers « ajean » à la première résolution du process.
// Le résultat est mis en cache : deux appels ne doivent jamais désigner deux
// dossiers différents, sinon une moitié du programme écrirait à côté de l'autre.
//
// N'est PAS appelé quand $AJEAN_HOME/$JEAN_HOME ou /etc/default/* imposent un
// chemin : un choix explicite de l'utilisateur ne se migre pas.
func migratedDefaultHome() string {
homeOnce.Do(func() { homePath = resolveDefaultHome() })
return homePath
}
func resolveDefaultHome() string {
return migrateHome(defaultAjeanHome(), legacyDefaultHome())
}
// migrateHome contient toute la logique de migration, isolée des chemins réels
// de la plateforme pour être testable telle quelle. Renvoie le dossier à
// utiliser — le nouveau si la migration a réussi ou n'était pas nécessaire,
// l'ancien si elle a échoué.
func migrateHome(target, legacy string) string {
if isDir(target) {
return target // déjà migré (ou installation neuve déjà faite)
}
if !isDir(legacy) {
return target // installation neuve : rien à migrer
}
// Même parent que l'ancien dossier ⇒ même volume ⇒ rename atomique.
sibling := filepath.Join(filepath.Dir(legacy), filepath.Base(target))
if isDir(sibling) {
return sibling
}
if err := os.Rename(legacy, sibling); err != nil {
// Cas le plus courant sous Windows : un service AJEAN tourne encore et
// tient un handle dans le dossier. On reste sur l'ancien chemin — tout
// fonctionne — et on retentera au prochain démarrage.
fmt.Fprintf(os.Stderr, "[info] dossier de données pas encore migré vers %s (%v) — on continue sur %s\n",
sibling, err, legacy)
return legacy
}
fmt.Fprintf(os.Stderr, "[ok] dossier de données migré : %s → %s\n", legacy, sibling)
return sibling
}
+102
View File
@@ -0,0 +1,102 @@
package ajean
import (
"os"
"path/filepath"
"runtime"
"testing"
)
// mkHome crée un dossier de données factice contenant un fichier témoin, pour
// vérifier que la migration déplace bien le CONTENU et pas seulement le dossier.
func mkHome(t *testing.T, dir string) {
t.Helper()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "config.env"), []byte("MODEL=x.gguf\n"), 0o644); err != nil {
t.Fatal(err)
}
}
// Cas nominal : seul l'ancien dossier existe → il est migré, contenu compris.
func TestMigrateHomeMovesLegacy(t *testing.T) {
root := t.TempDir()
legacy := filepath.Join(root, "jean")
target := filepath.Join(root, "ajean")
mkHome(t, legacy)
if got := migrateHome(target, legacy); got != target {
t.Fatalf("got %q, attendu le nouveau chemin %q", got, target)
}
if b, err := os.ReadFile(filepath.Join(target, "config.env")); err != nil || string(b) != "MODEL=x.gguf\n" {
t.Fatalf("contenu non migré: %q (%v)", b, err)
}
if isDir(legacy) {
t.Fatalf("l'ancien dossier existe encore après un rename réussi")
}
}
// Installation neuve : rien à migrer, on renvoie le nouveau chemin sans rien créer.
func TestMigrateHomeFreshInstall(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "ajean")
if got := migrateHome(target, filepath.Join(root, "jean")); got != target {
t.Fatalf("got %q, attendu %q", got, target)
}
}
// Déjà migré : le nouveau dossier existe → on ne retouche à rien, même si un
// vieux dossier « jean » traîne encore (utilisateur qui l'a recréé, restauration
// de sauvegarde…). Écraser serait perdre les données courantes.
func TestMigrateHomeKeepsExistingTarget(t *testing.T) {
root := t.TempDir()
legacy := filepath.Join(root, "jean")
target := filepath.Join(root, "ajean")
mkHome(t, legacy)
mkHome(t, target)
if err := os.WriteFile(filepath.Join(target, "config.env"), []byte("MODEL=courant.gguf\n"), 0o644); err != nil {
t.Fatal(err)
}
if got := migrateHome(target, legacy); got != target {
t.Fatalf("got %q, attendu %q", got, target)
}
b, _ := os.ReadFile(filepath.Join(target, "config.env"))
if string(b) != "MODEL=courant.gguf\n" {
t.Fatalf("le dossier courant a été écrasé par l'ancien: %q", b)
}
if !isDir(legacy) {
t.Fatalf("l'ancien dossier a été supprimé — la migration ne doit JAMAIS supprimer")
}
}
// Rename impossible → on doit retomber sur l'ancien chemin, intact. C'est la
// garantie « au pire, rien n'a bougé », celle qui protège les .gguf.
//
// On reproduit le scénario réel : un service AJEAN tourne encore et tient un
// handle ouvert dans le dossier de données, ce qui fait échouer le rename du
// dossier sous Windows. Unix, lui, renomme sans broncher un dossier dont des
// fichiers sont ouverts — il n'y a donc rien à simuler là-bas.
func TestMigrateHomeFallsBackWhenRenameFails(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("un rename de dossier réussit avec des fichiers ouverts hors Windows")
}
root := t.TempDir()
legacy := filepath.Join(root, "jean")
target := filepath.Join(root, "ajean")
mkHome(t, legacy)
held, err := os.Open(filepath.Join(legacy, "config.env"))
if err != nil {
t.Fatal(err)
}
defer held.Close()
if got := migrateHome(target, legacy); got != legacy {
t.Fatalf("got %q, attendu un repli sur l'ancien chemin %q", got, legacy)
}
if b, err := os.ReadFile(filepath.Join(legacy, "config.env")); err != nil || string(b) != "MODEL=x.gguf\n" {
t.Fatalf("l'ancien dossier a été abîmé par une migration ratée: %q (%v)", b, err)
}
}
@@ -1,4 +1,4 @@
package jean
package ajean
import (
"fmt"
@@ -15,7 +15,7 @@ import (
// modèles et les fichiers que l'agent écrit. On centralise donc la réponse ici,
// et on l'expose à la fois en ligne de commande (`jean where`) et dans l'UI.
type jeanPaths struct {
type ajeanPaths struct {
Home string `json:"home"` // JEAN_HOME : racine des données
Config string `json:"config"` // config.env
Exe string `json:"exe"` // binaire en cours d'exécution
@@ -31,18 +31,18 @@ type jeanPaths struct {
// faute d'équivalent, on utilise JEAN_HOME\bin ajouté au PATH utilisateur.
func installedExePath() string {
if runtime.GOOS == "windows" {
return filepath.Join(JeanHome(), "bin", "jean.exe")
return filepath.Join(AjeanHome(), "bin", "jean.exe")
}
return "/usr/local/bin/jean"
}
func currentPaths() jeanPaths {
func currentPaths() ajeanPaths {
exe, _ := os.Executable()
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
exe = resolved
}
return jeanPaths{
Home: JeanHome(),
return ajeanPaths{
Home: AjeanHome(),
Config: confPath(),
Exe: exe,
Installed: installedExePath(),
@@ -1,6 +1,6 @@
//go:build unix
package jean
package ajean
import (
"context"
@@ -16,25 +16,30 @@ import (
"time"
)
// defaultJeanHome is the data root when neither $JEAN_HOME nor /etc/default/jean
// override it.
// defaultAjeanHome is the data root when neither $AJEAN_HOME/$JEAN_HOME nor
// /etc/default/* override it. legacyDefaultHome est son équivalent d'avant le
// renommage, migré au premier lancement (voir sys_migrate.go).
//
// Linux : /etc/jean (machine de prod, service systemd lancé par root).
// macOS : /etc/jean n'est PAS écrivable par une app lancée depuis le Finder
// (« mkdir /etc/jean: permission denied »), et un Mac est une machine de bureau,
// Linux : /etc/ajean (machine de prod, service systemd lancé par root).
// macOS : /etc/ajean n'est PAS écrivable par une app lancée depuis le Finder
// (« mkdir /etc/ajean: permission denied »), et un Mac est une machine de bureau,
// pas un serveur — on retombe donc sur le dossier utilisateur standard, sauf si
// un `sudo jean install` a déjà créé /etc/jean à notre nom.
func defaultJeanHome() string {
// un `sudo ajean install` a déjà créé /etc/ajean à notre nom.
func defaultAjeanHome() string { return unixHome("ajean") }
func legacyDefaultHome() string { return unixHome("jean") }
func unixHome(name string) string {
if runtime.GOOS != "darwin" || os.Geteuid() == 0 {
return "/etc/jean"
return "/etc/" + name
}
if isWritableDir("/etc/jean") {
return "/etc/jean" // installé par `sudo jean install` puis chown à l'utilisateur
if isWritableDir("/etc/" + name) {
return "/etc/" + name // installé par `sudo ajean install` puis chown à l'utilisateur
}
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, "Library", "Application Support", "jean")
return filepath.Join(home, "Library", "Application Support", name)
}
return "/etc/jean"
return "/etc/" + name
}
// isWritableDir teste qu'un dossier existe ET qu'on peut y écrire (le seul test
@@ -1,6 +1,6 @@
//go:build windows
package jean
package ajean
import (
"context"
@@ -40,19 +40,26 @@ func init() {
}
}
// 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 {
// homeRoot is the parent directory of the data root: %ProgramData% (machine-wide,
// the closest analogue to /etc), falling back to %LOCALAPPDATA% for unprivileged
// setups. Ancien et nouveau dossier partagent ce parent, ce qui garantit que la
// migration jean → ajean est un rename intra-volume (voir sys_migrate.go).
func homeRoot() string {
if pd := os.Getenv("ProgramData"); pd != "" {
return filepath.Join(pd, "jean")
return pd
}
if la := os.Getenv("LOCALAPPDATA"); la != "" {
return filepath.Join(la, "jean")
return la
}
return filepath.Join(os.TempDir(), "jean")
return os.TempDir()
}
// defaultAjeanHome is the data root when no AJEAN_HOME/JEAN_HOME is set.
func defaultAjeanHome() string { return filepath.Join(homeRoot(), "ajean") }
// legacyDefaultHome is the pre-rename data root, migrated on first run.
func legacyDefaultHome() string { return filepath.Join(homeRoot(), "jean") }
// defaultEditor is used by `jean edit` when $EDITOR is unset.
func defaultEditor() string { return "notepad" }
@@ -1,4 +1,4 @@
package jean
package ajean
import (
"fmt"
@@ -1,6 +1,6 @@
//go:build darwin
package jean
package ajean
import (
"fmt"
@@ -29,7 +29,7 @@ func launchdPlistPath(svc string) string {
// launchdLogPath : sortie standard/erreur du service, sous JEAN_HOME (accessible
// en écriture par l'utilisateur du service après le chown de l'installation).
func launchdLogPath() string { return filepath.Join(JeanHome(), serviceName()+".log") }
func launchdLogPath() string { return filepath.Join(AjeanHome(), serviceName()+".log") }
// serviceAction mappe start/stop/restart/enable/disable sur launchctl. `load -w`
// (re)active le service ET le rend persistant au boot ; `unload -w` le désactive.
@@ -133,8 +133,8 @@ func serviceIsActive() bool {
// la fermeture de Jean.app, et permet de tuer tout le groupe (llama-server
// enfant compris) d'un seul signal.
func pidFilePath() string { return filepath.Join(JeanHome(), serviceName()+".pid") }
func logFilePath() string { return filepath.Join(JeanHome(), serviceName()+".log") }
func pidFilePath() string { return filepath.Join(AjeanHome(), serviceName()+".pid") }
func logFilePath() string { return filepath.Join(AjeanHome(), serviceName()+".log") }
func userSvcAction(action string) error {
switch action {
@@ -173,7 +173,7 @@ func userSvcStart() error {
if err != nil {
return err
}
if err := os.MkdirAll(JeanHome(), 0o755); err != nil {
if err := os.MkdirAll(AjeanHome(), 0o755); err != nil {
return err
}
logf, err := os.OpenFile(logFilePath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
@@ -187,7 +187,7 @@ func userSvcStart() error {
// Même répertoire de travail que sous systemd/launchd : les chemins relatifs
// de config.env (MODEL=…gguf) se résolvent depuis JEAN_HOME, pas depuis « / »
// que nous hérite le Finder.
cmd.Dir = JeanHome()
cmd.Dir = AjeanHome()
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
if err := cmd.Start(); err != nil {
return fmt.Errorf("démarrage de 'jean serve': %w", err)
@@ -1,6 +1,6 @@
//go:build linux
package jean
package ajean
import (
"fmt"
@@ -1,6 +1,6 @@
//go:build windows
package jean
package ajean
import (
"fmt"
@@ -26,8 +26,8 @@ const (
createNoWindow = 0x08000000 // CREATE_NO_WINDOW (aucune console pour l'enfant)
)
func pidFilePath() string { return filepath.Join(JeanHome(), serviceName()+".pid") }
func logFilePath() string { return filepath.Join(JeanHome(), serviceName()+".log") }
func pidFilePath() string { return filepath.Join(AjeanHome(), serviceName()+".pid") }
func logFilePath() string { return filepath.Join(AjeanHome(), serviceName()+".log") }
func serviceAction(action string) error {
switch action {
@@ -59,7 +59,7 @@ func svcStart() error {
if err != nil {
return err
}
if err := os.MkdirAll(JeanHome(), 0o755); err != nil {
if err := os.MkdirAll(AjeanHome(), 0o755); err != nil {
return err
}
logf, err := os.OpenFile(logFilePath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
@@ -71,7 +71,7 @@ func svcStart() error {
cmd := exec.Command(self, "serve")
cmd.Stdout = logf
cmd.Stderr = logf
cmd.Dir = JeanHome() // les chemins relatifs de config.env se résolvent depuis JEAN_HOME
cmd.Dir = AjeanHome() // les chemins relatifs de config.env se résolvent depuis JEAN_HOME
// createNoWindow + HideWindow : le service enfant (`jean serve`) ne doit JAMAIS
// faire clignoter de console noire quand Jean est lancé en mode app (double-clic).
cmd.SysProcAttr = &syscall.SysProcAttr{
@@ -1,6 +1,6 @@
//go:build !windows
package jean
package ajean
// sys_splash_other.go — pas de fenêtre de démarrage hors Windows (no-op).
@@ -1,6 +1,6 @@
//go:build windows
package jean
package ajean
// sys_splash_windows.go — écran de démarrage « Lancement de Jean… ».
//
@@ -1,6 +1,6 @@
//go:build darwin
package jean
package ajean
// sys_tray_darwin.go — icône de Jean dans la barre de menus macOS (en haut à
// droite, à côté de l'horloge), pendant exact du system tray Windows : quand on
@@ -1,6 +1,6 @@
//go:build !windows && !darwin
package jean
package ajean
// sys_tray_other.go — pas d'icône de zone de notification sous Linux (serveur
// sans session graphique la plupart du temps). On se contente d'ouvrir l'UI dans
@@ -1,6 +1,6 @@
//go:build windows
package jean
package ajean
// sys_tray_windows.go — icône de Jean dans la zone de notification Windows (system
// tray). Quand on lance l'app (double-clic ou `jean app`), l'UI web s'ouvre dans
@@ -1,4 +1,4 @@
package jean
package ajean
import "os"
@@ -1,4 +1,4 @@
package jean
package ajean
import (
"crypto/sha256"
@@ -22,7 +22,10 @@ import (
// l'OS/arch courant, remplace le binaire en place, puis affiche quoi redémarrer.
// AUCUN redémarrage de service automatique (choix volontaire, plus sûr).
const updateRepo = "nathaninline/jean"
// Le dépôt a été renommé jean → ajean ; GitHub redirige les anciennes URL et
// l'API REST, donc les binaires déjà installés qui pointent encore sur
// « nathaninline/jean » continuent de trouver les releases.
const updateRepo = "nathaninline/ajean"
type ghRelease struct {
TagName string `json:"tag_name"`
@@ -34,14 +37,39 @@ type ghRelease struct {
} `json:"assets"`
}
// updateAssetName reconstruit le nom d'asset attendu pour la plateforme courante,
// suivant la convention des releases : jean-<os>-<arch>[.exe].
func updateAssetName() string {
n := "jean-" + runtime.GOOS + "-" + runtime.GOARCH
// updateAssetNames reconstruit les noms d'asset acceptables pour la plateforme
// courante, par ordre de préférence : ajean-<os>-<arch>[.exe] puis, en repli,
// l'ancien nom jean-<os>-<arch>[.exe].
//
// Le repli n'est pas de la coquetterie : pendant la transition, les releases
// publient les DEUX noms (mêmes binaires) pour que le parc déjà installé — qui
// ne connaît que « jean-* » — continue de se mettre à jour. Ce binaire-ci sait
// lire les deux, il fonctionne donc aussi bien avec une release de transition
// qu'avec une release future qui n'aura plus que « ajean-* ».
func updateAssetNames() []string {
suffix := runtime.GOOS + "-" + runtime.GOARCH
if runtime.GOOS == "windows" {
n += ".exe"
suffix += ".exe"
}
return n
return []string{"ajean-" + suffix, "jean-" + suffix}
}
// updateAssetName renvoie le nom d'asset préféré (usage : messages à l'écran).
func updateAssetName() string { return updateAssetNames()[0] }
// pickAsset choisit dans la release le premier asset correspondant à la
// plateforme, en respectant l'ordre de préférence. Renvoie son nom, son URL et
// sa taille — le nom retourné est celui qui servira à vérifier le SHA-256, qui
// doit donc être celui réellement téléchargé, pas celui qu'on préférait.
func pickAsset(rel *ghRelease) (name, url string, size int64) {
for _, want := range updateAssetNames() {
for _, a := range rel.Assets {
if a.Name == want {
return a.Name, a.BrowserDownloadURL, a.Size
}
}
}
return "", "", 0
}
// ensureV préfixe "v" si absent, pour comparer via golang.org/x/mod/semver.
@@ -56,7 +84,7 @@ func ensureV(s string) string {
func fetchLatestRelease() (*ghRelease, error) {
req, _ := http.NewRequest("GET", "https://api.github.com/repos/"+updateRepo+"/releases/latest", nil)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "jean-update")
req.Header.Set("User-Agent", "ajean-update")
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
if err != nil {
return nil, err
@@ -114,17 +142,10 @@ func applyUpdate() (string, error) {
return Version, fmt.Errorf("déjà à jour (%s)", Version)
}
want := updateAssetName()
var url string
var size int64
for _, a := range rel.Assets {
if a.Name == want {
url, size = a.BrowserDownloadURL, a.Size
break
}
}
want, url, size := pickAsset(rel)
if url == "" {
return "", fmt.Errorf("aucun binaire %q dans la release %s (os/arch %s/%s)", want, latest, runtime.GOOS, runtime.GOARCH)
return "", fmt.Errorf("aucun binaire %s dans la release %s (os/arch %s/%s)",
strings.Join(updateAssetNames(), " ni "), latest, runtime.GOOS, runtime.GOARCH)
}
exe, err := os.Executable()
@@ -136,12 +157,12 @@ func applyUpdate() (string, error) {
}
// Contrôle des droits AVANT de télécharger : sans ça, l'échec survient sur le
// os.Create du fichier temporaire et l'utilisateur reçoit un « open
// /usr/local/bin/.jean-update.tmp: permission denied » incompréhensible,
// /usr/local/bin/.ajean-update.tmp: permission denied » incompréhensible,
// notamment depuis le bouton de l'UI web (jean web lancé sans sudo).
if err := checkUpdateWritable(exe); err != nil {
return "", err
}
tmp := filepath.Join(filepath.Dir(exe), ".jean-update.tmp")
tmp := filepath.Join(filepath.Dir(exe), ".ajean-update.tmp")
if err := downloadTo(url, tmp); err != nil {
os.Remove(tmp)
@@ -190,7 +211,7 @@ func updatePermissionError(exe string) error {
// pas : root, groupes, ACL, montage en lecture seule…).
func checkUpdateWritable(exe string) error {
dir := filepath.Dir(exe)
probe, err := os.CreateTemp(dir, ".jean-update-probe-*")
probe, err := os.CreateTemp(dir, ".ajean-update-probe-*")
if err != nil {
if os.IsPermission(err) {
return updatePermissionError(exe)
@@ -237,7 +258,7 @@ func cmdUpdate(args []string) error {
func downloadTo(url, dst string) error {
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("User-Agent", "jean-update")
req.Header.Set("User-Agent", "ajean-update")
req.Header.Set("Accept", "application/octet-stream")
resp, err := (&http.Client{Timeout: 5 * time.Minute}).Do(req)
if err != nil {
@@ -274,7 +295,7 @@ func verifyChecksum(rel *ghRelease, assetName, path string) error {
return nil
}
req, _ := http.NewRequest("GET", sumsURL, nil)
req.Header.Set("User-Agent", "jean-update")
req.Header.Set("User-Agent", "ajean-update")
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
if err != nil {
return fmt.Errorf("téléchargement des sommes de contrôle : %w", err)
+63
View File
@@ -0,0 +1,63 @@
package ajean
import "testing"
// relWith fabrique une release ne contenant que les assets nommés.
func relWith(names ...string) *ghRelease {
rel := &ghRelease{TagName: "v9.9.9"}
for _, n := range names {
rel.Assets = append(rel.Assets, struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
Size int64 `json:"size"`
}{Name: n, BrowserDownloadURL: "https://example.invalid/" + n, Size: 42})
}
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])
}
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 jean-* 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)
}
}
// Aucun asset pour cette plateforme → pas d'URL, l'appelant doit pouvoir le voir.
func TestPickAssetNoMatch(t *testing.T) {
if _, url, _ := pickAsset(relWith("ajean-plan9-386", "SHA256SUMS")); url != "" {
t.Fatalf("URL inattendue: %q", url)
}
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,6 +1,6 @@
// web_api.go — handlers REST /api/* (statut, config, presets, modèles,
// mémoire, agent, clés, bench…) du serveur web local.
package jean
package ajean
import (
"encoding/json"
@@ -169,7 +169,7 @@ func handleBackends(w http.ResponseWriter, r *http.Request) {
// (essaie les sous-chemins des différents générateurs CMake). Renvoie
// [{name, path}] — un par dossier de backend contenant un binaire.
func listBackendBins() []map[string]any {
root := JeanHome() + "/backends"
root := AjeanHome() + "/backends"
entries, err := os.ReadDir(root)
if err != nil {
return []map[string]any{}
@@ -269,7 +269,7 @@ func handleLlamacppUninstallCustom(w http.ResponseWriter, r *http.Request) {
func handleModels(w http.ResponseWriter, r *http.Request) {
out := []map[string]any{}
seen := map[string]bool{}
home := JeanHome()
home := AjeanHome()
for _, dir := range modelDirs() {
entries, err := os.ReadDir(dir)
if err != nil {
@@ -1,4 +1,4 @@
package jean
package ajean
import (
"crypto/rand"
@@ -22,7 +22,7 @@ import (
// requête (pas de cache) pour qu'un changement de clé prenne effet sans
// redémarrer le serveur web.
func webKeyPath() string { return filepath.Join(JeanHome(), ".web_key") }
func webKeyPath() string { return filepath.Join(AjeanHome(), ".web_key") }
// readWebKey returns the trimmed contents of $JEAN_HOME/.web_key, or "" if the
// file is absent/empty.
@@ -98,7 +98,7 @@ func cmdSetWebKey(args []string) error {
fmt.Printf("%s clé de pilotage supprimée — l'API web n'est plus protégée\n", yellow("[info]"))
return nil
}
if err := os.MkdirAll(JeanHome(), 0o755); err != nil {
if err := os.MkdirAll(AjeanHome(), 0o755); err != nil {
return err
}
if err := os.WriteFile(webKeyPath(), []byte(key+"\n"), 0o600); err != nil {
@@ -1,6 +1,6 @@
// web_chat.go — endpoints de chat du serveur web local : envoi/stop/reset,
// flux SSE d'abonnement à la conversation serveur (voir chat_conversation.go).
package jean
package ajean
import (
"context"
@@ -1,4 +1,4 @@
package jean
package ajean
// web_link_api.go — API locale pilotant l'accès distant (ajean.link) depuis l'UI
// web de Jean, pour éviter le terminal. Le panneau « Accès distant » ouvre une
@@ -8,7 +8,7 @@
// POST /api/llamacpp/install → job d'installation {force?}
// POST /api/llamacpp/update → job de mise à jour {clean?}
// GET /api/llamacpp/job?from= → progression du job (phase, lignes, erreur)
package jean
package ajean
import (
"encoding/json"
@@ -166,7 +166,7 @@ func handleLlamacpp(w http.ResponseWriter, r *http.Request) {
"bin": pbBin,
"in_use": pbBin != "" && samePath(pbBin, cfgBin),
}
out["backends_dir"] = filepath.Join(JeanHome(), "backends")
out["backends_dir"] = filepath.Join(AjeanHome(), "backends")
out["job"] = lcJobSnapshot(0, false)
sendJSON(w, 200, out)
}
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff. Show more