mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 22:48:11 +02:00
Regression introduite par l'alias de la 0.7.0, signalee en usage : « Une version plus recente d'AJEAN est deja installee » a CHAQUE lancement, sans moyen d'en sortir. Deux defauts qui se renforcaient. installLegacyAlias utilisait copyExe, qui echoue quand jean.exe est en cours d'execution — Windows refuse d'ecraser un .exe actif. L'echec etait ignore volontairement (« sans gravite, la copie repassera au prochain lancement »), ce qui etait faux : l'alias restait fige indefiniment. replaceExe applique desormais la technique deja utilisee pour le binaire principal — renommer l'ancien, ecrire le nouveau, restaurer si la copie echoue. installSelf s'en sert aussi, pour la meme raison. ensureShortcuts ne touchait jamais un raccourci existant. Ceux crees avant la 0.7.0 visaient donc toujours jean.exe. Chaque lancement executait l'alias perime, qui voyait ajean.exe plus recent et le signalait — la boucle. Un raccourci existant est maintenant repointe s'il vise autre chose que le binaire courant. cleanupOldBinary balaie aussi le jean.exe.old que le nouveau remplacement peut laisser. Deux tests : remplacement d'un exe verrouille, et garantie que la cible ne disparait jamais si la copie echoue apres le renommage.
285 lines
10 KiB
Go
285 lines
10 KiB
Go
//go:build windows
|
|
|
|
package ajean
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// On Windows there's no systemd unit, sudoers, or /usr/local/bin to populate.
|
|
// `ajean install` simply provisions the data directory and a starter config; the
|
|
// service itself is managed by the PID-file supervisor in sys_service_windows.go
|
|
// (ajean start / stop / status), which needs no admin rights.
|
|
|
|
const configTemplate = `# Configuration JEAN — édite-moi puis: ajean restart
|
|
# ajean serve lit ce fichier et lance ton binaire llama.cpp.
|
|
|
|
# Chemins (utilise des chemins Windows ; les antislashs ou slashs marchent)
|
|
BIN="C:/llama.cpp/build/bin/Release/llama-server.exe"
|
|
MODEL="C:/models/your-model.gguf"
|
|
|
|
# Serveur
|
|
PORT="8080"
|
|
HOST="127.0.0.1"
|
|
|
|
# Inference
|
|
CTX="32768"
|
|
BATCH="2048"
|
|
UBATCH="512"
|
|
NGL="999"
|
|
|
|
# Args supplémentaires passés à llama-server
|
|
EXTRA_ARGS=""
|
|
`
|
|
|
|
func cmdInstall(args []string) error {
|
|
// Lancé en administrateur, `install` est le moment où la migration peut
|
|
// enfin aboutir sur un poste où l'utilisateur courant n'a pas les droits sur
|
|
// %ProgramData%. On la retente donc AVANT de résoudre le dossier : l'inverse
|
|
// laisserait la variable pointer sur l'ancien chemin et installerait à côté.
|
|
if retryHomeMigration() {
|
|
fmt.Printf("%s dossier de données migré vers %s\n", green("✓"), AjeanHome())
|
|
} else if notice := homeMigrationNotice(); notice != "" {
|
|
fmt.Printf("%s %s\n", yellow("[info]"), notice)
|
|
}
|
|
ajeanHome := AjeanHome()
|
|
|
|
fmt.Printf("Installation (Windows)\n")
|
|
fmt.Printf(" AJEAN_HOME = %s\n", ajeanHome)
|
|
fmt.Printf(" service = %s\n", serviceName())
|
|
|
|
// 1-2. Dossiers + config.env de départ (partagé avec le premier lancement de
|
|
// l'app, voir provisionDataDir dans sys_firstrun_windows.go).
|
|
conf := filepath.Join(ajeanHome, "config.env")
|
|
_, statErr := os.Stat(conf)
|
|
alreadyThere := statErr == nil
|
|
if err := provisionDataDir(); err != nil {
|
|
return err
|
|
}
|
|
if alreadyThere {
|
|
fmt.Printf(" %s config.env déjà présent — inchangé\n", dim("•"))
|
|
} else {
|
|
fmt.Printf(" %s écrit %s\n", green("✓"), conf)
|
|
}
|
|
|
|
// 3. Install the binary into JEAN_HOME\bin and put that dir on the user PATH,
|
|
// so `ajean` is callable from any shell (this is the Windows analogue of the
|
|
// /usr/local/bin symlink the Unix installer creates).
|
|
binDir := filepath.Join(ajeanHome, "bin")
|
|
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
onPath := false
|
|
if dst, err := installSelf(binDir); err != nil {
|
|
fmt.Printf(" %s copie du binaire impossible (%v) — ajoute-le au PATH à la main\n", dim("•"), err)
|
|
} else {
|
|
fmt.Printf(" %s binaire installé %s\n", green("✓"), dst)
|
|
added, err := addToUserPath(binDir)
|
|
switch {
|
|
case err != nil:
|
|
fmt.Printf(" %s mise à jour du PATH impossible (%v)\n", dim("•"), err)
|
|
case added:
|
|
fmt.Printf(" %s %s ajouté au PATH utilisateur\n", green("✓"), binDir)
|
|
onPath = true
|
|
default:
|
|
fmt.Printf(" %s %s déjà dans le PATH\n", dim("•"), binDir)
|
|
onPath = true
|
|
}
|
|
}
|
|
|
|
fmt.Println()
|
|
fmt.Printf("%s installation terminée.\n", green("[ok]"))
|
|
fmt.Printf("\nProchaines étapes :\n")
|
|
fmt.Printf(" 1. édite la config : %s (renseigne BIN, MODEL)\n", bold("ajean edit"))
|
|
fmt.Printf(" 2. démarre le service: %s\n", bold("ajean start"))
|
|
fmt.Printf(" 3. UI web : %s\n", bold("ajean web"))
|
|
if onPath {
|
|
fmt.Printf("\n%s ouvre un NOUVEAU terminal pour que 'jean' soit reconnu (le PATH n'est lu qu'au démarrage du shell).\n", dim("[info]"))
|
|
} else {
|
|
fmt.Printf("\n%s pour exécuter 'jean' depuis n'importe où, ajoute son dossier au PATH.\n", dim("[info]"))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// installSelf copies the currently running executable into binDir as ajean.exe
|
|
// and returns the destination path. If the running exe already lives there
|
|
// (re-install), it's a no-op. Une copie « jean.exe » est posée à côté pour que
|
|
// l'ancienne commande reste tapable (voir installLegacyAlias).
|
|
//
|
|
// binDir est créé ici, et pas seulement par l'appelant : sur une machine vierge,
|
|
// le premier lancement passait par appFirstRun, qui ne créait que le dossier de
|
|
// données et pas son sous-dossier bin. L'utilisateur recevait alors « open
|
|
// C:\ProgramData\ajean\bin\ajean.exe: The system cannot find the path specified »
|
|
// et AJEAN démarrait depuis le fichier téléchargé, sans jamais s'installer.
|
|
func installSelf(binDir string) (string, error) {
|
|
src, err := os.Executable()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
src, _ = filepath.EvalSymlinks(src)
|
|
dst := filepath.Join(binDir, "ajean.exe")
|
|
if strings.EqualFold(src, dst) {
|
|
installLegacyAlias(binDir, src)
|
|
return dst, nil
|
|
}
|
|
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
// replaceExe et non copyExe : ajean.exe peut etre en cours d'execution
|
|
// (service en tache de fond), auquel cas Windows refuse de l'ecraser.
|
|
if err := replaceExe(src, dst); err != nil {
|
|
return "", err
|
|
}
|
|
installLegacyAlias(binDir, dst)
|
|
return dst, nil
|
|
}
|
|
|
|
// installLegacyAlias pose une copie « jean.exe » à côté de « ajean.exe ».
|
|
//
|
|
// Une copie et pas un lien : créer un lien symbolique sous Windows demande des
|
|
// droits particuliers qu'on n'a pas toujours, et un binaire de quelques dizaines
|
|
// de Mo dupliqué est un prix dérisoire pour ne casser ni les raccourcis du menu
|
|
// Démarrer, ni les tâches planifiées, ni les scripts que les utilisateurs ont
|
|
// écrits eux-mêmes. Best-effort : un échec ne compromet pas l'installation,
|
|
// « ajean » reste disponible.
|
|
func installLegacyAlias(binDir, src string) {
|
|
alias := filepath.Join(binDir, "jean.exe")
|
|
if strings.EqualFold(src, alias) {
|
|
return
|
|
}
|
|
_ = replaceExe(src, alias)
|
|
}
|
|
|
|
// replaceExe copie src vers dst, y compris quand dst est un exécutable EN COURS.
|
|
//
|
|
// Windows refuse d'écraser un .exe en cours d'exécution, mais accepte de le
|
|
// RENOMMER : on décale l'ancien puis on écrit le nouveau à sa place. Sans ça,
|
|
// l'alias « jean.exe » restait figé sur une version périmée dès qu'il tournait
|
|
// au moment de la mise à jour — et comme les raccourcis existants le visent
|
|
// encore, chaque lancement relançait l'ancienne version, qui constatait qu'une
|
|
// plus récente était installée et le disait. À chaque fois. Constaté en usage.
|
|
func replaceExe(src, dst string) error {
|
|
if err := copyExe(src, dst); err == nil {
|
|
return nil
|
|
}
|
|
old := dst + ".old"
|
|
_ = os.Remove(old) // reliquat d'un remplacement précédent
|
|
if err := os.Rename(dst, old); err != nil {
|
|
return err // ni écrasable ni renommable : on laisse la place en l'état
|
|
}
|
|
if err := copyExe(src, dst); err != nil {
|
|
_ = os.Rename(old, dst) // rien ne doit disparaître
|
|
return err
|
|
}
|
|
_ = os.Remove(old) // échoue tant que l'ancien tourne ; nettoyé plus tard
|
|
return nil
|
|
}
|
|
|
|
func copyExe(src, dst string) error {
|
|
in, err := os.Open(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer in.Close()
|
|
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := io.Copy(out, in); err != nil {
|
|
out.Close()
|
|
return err
|
|
}
|
|
return out.Close()
|
|
}
|
|
|
|
// addToUserPath appends dir to the per-user PATH (HKCU\Environment) persistently,
|
|
// without admin rights and without setx's 1024-char truncation. Returns false if
|
|
// dir was already present. The change applies to newly launched shells.
|
|
func addToUserPath(dir string) (bool, error) {
|
|
// Read the *user* PATH (not the process PATH, which is User+Machine merged),
|
|
// edit it, and write it back, all via PowerShell's environment API which
|
|
// handles the registry REG_EXPAND_SZ type and the WM_SETTINGCHANGE broadcast.
|
|
ps := fmt.Sprintf(`$d=%s
|
|
$p=[Environment]::GetEnvironmentVariable('Path','User')
|
|
if (-not $p) { $p='' }
|
|
$parts=$p.Split(';') | Where-Object { $_ -ne '' }
|
|
if ($parts -contains $d) { Write-Output 'present'; exit 0 }
|
|
$new=(@($parts) + $d) -join ';'
|
|
[Environment]::SetEnvironmentVariable('Path',$new,'User')
|
|
Write-Output 'added'`, psQuote(dir))
|
|
cmd := hideCmd(exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", ps))
|
|
outBytes, err := cmd.CombinedOutput()
|
|
out := strings.TrimSpace(string(outBytes))
|
|
if err != nil {
|
|
return false, fmt.Errorf("%v: %s", err, out)
|
|
}
|
|
return strings.Contains(out, "added"), nil
|
|
}
|
|
|
|
// removeFromUserPath drops dir from the per-user PATH if present. Returns false
|
|
// if it wasn't there.
|
|
func removeFromUserPath(dir string) (bool, error) {
|
|
ps := fmt.Sprintf(`$d=%s
|
|
$p=[Environment]::GetEnvironmentVariable('Path','User')
|
|
if (-not $p) { Write-Output 'absent'; exit 0 }
|
|
$parts=$p.Split(';') | Where-Object { $_ -ne '' -and $_ -ne $d }
|
|
if (($p.Split(';') | Where-Object { $_ -eq $d }).Count -eq 0) { Write-Output 'absent'; exit 0 }
|
|
[Environment]::SetEnvironmentVariable('Path',($parts -join ';'),'User')
|
|
Write-Output 'removed'`, psQuote(dir))
|
|
cmd := hideCmd(exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", ps))
|
|
outBytes, err := cmd.CombinedOutput()
|
|
out := strings.TrimSpace(string(outBytes))
|
|
if err != nil {
|
|
return false, fmt.Errorf("%v: %s", err, out)
|
|
}
|
|
return strings.Contains(out, "removed"), nil
|
|
}
|
|
|
|
// psQuote wraps s in a PowerShell single-quoted string literal (doubling any
|
|
// embedded single quotes), safe against spaces and metacharacters in the path.
|
|
func psQuote(s string) string {
|
|
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
|
|
}
|
|
|
|
func cmdUninstall(args []string) error {
|
|
keepData := true
|
|
for _, a := range args {
|
|
if a == "--purge" {
|
|
keepData = false
|
|
}
|
|
if a == "--keep-data" {
|
|
keepData = true
|
|
}
|
|
}
|
|
// Stop the background server if it's running.
|
|
_ = svcStop(false)
|
|
|
|
// Pull JEAN_HOME\bin off the user PATH (best-effort).
|
|
binDir := filepath.Join(AjeanHome(), "bin")
|
|
if removed, err := removeFromUserPath(binDir); err == nil && removed {
|
|
fmt.Printf(" %s %s retiré du PATH utilisateur\n", green("✓"), binDir)
|
|
}
|
|
|
|
// Raccourcis posés à l'installation (menu Démarrer + Bureau).
|
|
if removeShortcuts() {
|
|
fmt.Printf(" %s raccourcis « AJEAN » supprimés\n", green("✓"))
|
|
}
|
|
|
|
if !keepData {
|
|
ajeanHome := AjeanHome()
|
|
if err := os.RemoveAll(ajeanHome); err != nil {
|
|
return fmt.Errorf("suppression de %s: %w", ajeanHome, err)
|
|
}
|
|
fmt.Printf(" %s %s supprimé\n", green("✓"), ajeanHome)
|
|
} else {
|
|
fmt.Println(dim("(données utilisateur conservées — relance avec --purge pour tout supprimer)"))
|
|
}
|
|
fmt.Println(green("[ok]") + " désinstallé")
|
|
return nil
|
|
}
|