diff --git a/go.mod b/go.mod index dd05e5f..4c88191 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/caddyserver/certmagic v0.25.4 github.com/coder/websocket v1.8.15 github.com/hashicorp/yamux v0.1.2 + golang.org/x/mod v0.35.0 ) require ( @@ -19,7 +20,6 @@ require ( go.uber.org/zap v1.27.1 // indirect go.uber.org/zap/exp v0.3.0 // indirect golang.org/x/crypto v0.50.0 // indirect - golang.org/x/mod v0.35.0 // indirect golang.org/x/net v0.53.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.43.0 // indirect diff --git a/main.go b/main.go index d20ed7a..83d235b 100644 --- a/main.go +++ b/main.go @@ -16,12 +16,14 @@ import ( "strings" ) -const Version = "0.3.1" +const Version = "0.3.2" func main() { // Migration one-shot des anciens skills (SKILLS//SKILL.md) vers la // nouvelle mémoire (MEMORY/.md). Idempotente, silencieuse si rien à faire. migrateSkillsToMemory() + // Nettoie un éventuel binaire .old laissé par une mise à jour Windows. + cleanupOldBinary() args := os.Args[1:] cmd := "help" @@ -71,6 +73,8 @@ func main() { mustExit(cmdBench(args)) case "llamacpp", "llama": mustExit(cmdLlamacpp(args)) + case "update", "upgrade", "self-update": + mustExit(cmdUpdate(args)) case "install": mustExit(cmdInstall(args)) case "uninstall": @@ -134,6 +138,7 @@ Entrypoint (utilisé par jean.service) : Installation: install installer (Linux: unité systemd, sudoers, dossiers ; Windows: dossiers + config) uninstall désinstaller + update [--check] mettre à jour jean depuis les releases GitHub (--check = signale sans installer) Env: JEAN_HOME racine (défaut: /etc/jean sur Linux/macOS, %%ProgramData%%\jean sur Windows) diff --git a/resource_windows_amd64.syso b/resource_windows_amd64.syso index 068efe8..478aa84 100644 Binary files a/resource_windows_amd64.syso and b/resource_windows_amd64.syso differ diff --git a/resource_windows_arm64.syso b/resource_windows_arm64.syso index 5cbaae2..9069474 100644 Binary files a/resource_windows_arm64.syso and b/resource_windows_arm64.syso differ diff --git a/update.go b/update.go new file mode 100644 index 0000000..e96f078 --- /dev/null +++ b/update.go @@ -0,0 +1,221 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "golang.org/x/mod/semver" +) + +// jean update — met à jour le binaire depuis les releases GitHub du projet. +// Périmètre minimal : compare la version, télécharge l'asset correspondant à +// 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" + +type ghRelease struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` + Assets []struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + Size int64 `json:"size"` + } `json:"assets"` +} + +// updateAssetName reconstruit le nom d'asset attendu pour la plateforme courante, +// suivant la convention des releases : jean--[.exe]. +func updateAssetName() string { + n := "jean-" + runtime.GOOS + "-" + runtime.GOARCH + if runtime.GOOS == "windows" { + n += ".exe" + } + return n +} + +// ensureV préfixe "v" si absent, pour comparer via golang.org/x/mod/semver. +func ensureV(s string) string { + s = strings.TrimSpace(s) + if s != "" && !strings.HasPrefix(s, "v") { + s = "v" + s + } + return s +} + +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") + resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return nil, fmt.Errorf("GitHub a répondu %s", resp.Status) + } + var rel ghRelease + if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { + return nil, err + } + return &rel, nil +} + +func cmdUpdate(args []string) error { + checkOnly := false + for _, a := range args { + if a == "--check" || a == "-check" || a == "check" { + checkOnly = true + } + } + fmt.Println("recherche de la dernière version…") + rel, err := fetchLatestRelease() + if err != nil { + return fmt.Errorf("impossible de contacter GitHub : %w", err) + } + latest := ensureV(rel.TagName) + cur := ensureV(Version) + if !semver.IsValid(latest) { + return fmt.Errorf("tag de release inattendu : %q", rel.TagName) + } + if semver.Compare(latest, cur) <= 0 { + fmt.Printf("jean est déjà à jour (%s).\n", Version) + return nil + } + fmt.Printf("nouvelle version disponible : %s (actuelle : %s)\n", strings.TrimPrefix(latest, "v"), Version) + fmt.Printf(" %s\n", rel.HTMLURL) + if checkOnly { + fmt.Println("lance 'jean update' pour l'installer.") + return nil + } + + want := updateAssetName() + var url string + var size int64 + for _, a := range rel.Assets { + if a.Name == want { + url, size = a.BrowserDownloadURL, a.Size + break + } + } + if url == "" { + return fmt.Errorf("aucun binaire %q dans la release %s (os/arch %s/%s)", want, latest, runtime.GOOS, runtime.GOARCH) + } + + exe, err := os.Executable() + if err != nil { + return err + } + if resolved, err := filepath.EvalSymlinks(exe); err == nil { + exe = resolved + } + dir := filepath.Dir(exe) + tmp := filepath.Join(dir, ".jean-update.tmp") + + fmt.Printf("téléchargement de %s (%.1f Mo)…\n", want, float64(size)/1e6) + if err := downloadTo(url, tmp); err != nil { + return fmt.Errorf("téléchargement : %w", err) + } + // Conserver les permissions du binaire existant (sinon 0755 par défaut). + mode := os.FileMode(0o755) + if fi, err := os.Stat(exe); err == nil { + mode = fi.Mode() + } + if err := os.Chmod(tmp, mode); err != nil { + os.Remove(tmp) + return err + } + if got := fileSize(tmp); size > 0 && got != size { + os.Remove(tmp) + return fmt.Errorf("taille inattendue (%d o reçus, %d attendus) — mise à jour annulée", got, size) + } + + if err := replaceBinary(exe, tmp); err != nil { + os.Remove(tmp) + if os.IsPermission(err) { + return fmt.Errorf("droits insuffisants pour écrire %s — relance avec privilèges (ex : sudo jean update)", exe) + } + return err + } + fmt.Printf("✓ jean mis à jour en %s\n", strings.TrimPrefix(latest, "v")) + printRestartHint() + return nil +} + +func downloadTo(url, dst string) error { + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("User-Agent", "jean-update") + req.Header.Set("Accept", "application/octet-stream") + resp, err := (&http.Client{Timeout: 5 * time.Minute}).Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return fmt.Errorf("GitHub a répondu %s", resp.Status) + } + f, err := os.Create(dst) + if err != nil { + return err + } + _, cErr := io.Copy(f, resp.Body) + if closeErr := f.Close(); cErr == nil { + cErr = closeErr + } + return cErr +} + +func fileSize(p string) int64 { + if fi, err := os.Stat(p); err == nil { + return fi.Size() + } + return -1 +} + +// replaceBinary échange le binaire en place. Sous Unix, rename() dans le même +// dossier est atomique et fonctionne même si l'ancien binaire tourne encore +// (l'inode ouvert reste valide). Sous Windows on ne peut pas écraser un .exe en +// cours : on renomme l'ancien en .old (supprimé au prochain lancement). +func replaceBinary(exe, tmp string) error { + if runtime.GOOS == "windows" { + old := exe + ".old" + _ = os.Remove(old) // nettoyage d'une éventuelle MAJ précédente + if err := os.Rename(exe, old); err != nil { + return err + } + if err := os.Rename(tmp, exe); err != nil { + _ = os.Rename(old, exe) // rollback + return err + } + _ = os.Remove(old) // échoue si l'exe tourne encore → nettoyé au prochain run + return nil + } + return os.Rename(tmp, exe) +} + +// cleanupOldBinary supprime silencieusement le .old laissé par une MAJ Windows +// précédente (le fichier n'était pas supprimable tant que l'exe tournait). +func cleanupOldBinary() { + if runtime.GOOS != "windows" { + return + } + if exe, err := os.Executable(); err == nil { + _ = os.Remove(exe + ".old") + } +} + +func printRestartHint() { + if runtime.GOOS == "windows" { + fmt.Println("redémarre les process jean en cours (jean web, etc.) pour appliquer la mise à jour.") + return + } + fmt.Println("pense à redémarrer les services : sudo systemctl restart jean jean-link (+ relancer 'jean web' si utilisé).") +} diff --git a/versioninfo.json b/versioninfo.json index 4b2f6a7..2b75489 100644 --- a/versioninfo.json +++ b/versioninfo.json @@ -3,13 +3,13 @@ "FileVersion": { "Major": 0, "Minor": 3, - "Patch": 1, + "Patch": 2, "Build": 0 }, "ProductVersion": { "Major": 0, "Minor": 3, - "Patch": 1, + "Patch": 2, "Build": 0 }, "FileFlagsMask": "3f", @@ -25,7 +25,7 @@ "LegalCopyright": "Copyright (c) 2026 Jean contributors. MIT License.", "OriginalFilename": "jean.exe", "ProductName": "Jean", - "ProductVersion": "0.3.1", + "ProductVersion": "0.3.2", "Comments": "https://github.com/nathaninline/jean — projet open source (MIT)" }, "VarFileInfo": {