Files
Loki/internal/ajean/sys_install_windows.go
T
nathaninline b4d2d5ed02 v0.7.1 : ne plus afficher l'echec de migration a chaque commande
Remonte par un utilisateur Windows non administrateur. Renommer
C:\ProgramData\jean exige d'ecrire dans C:\ProgramData, ce qu'un compte
standard ne peut pas faire : le rename echouait a chaque lancement et le
message technique s'affichait avant CHAQUE commande, y compris `ajean help`.

Rien n'etait casse — le repli sur l'ancien chemin fonctionne comme prevu et
aucune donnee n'est en jeu. Le defaut etait entierement dans l'affichage :
un detail interne presente comme une erreur, sur une situation permanente et
sans consequence.

La raison est desormais enregistree au lieu d'etre imprimee, et ressortie la
ou elle est actionnable : `ajean where`, qui parle justement d'emplacements,
avec la marche a suivre. `ajean install` en administrateur retente la
migration — c'est le seul moment ou les droits sont reunis — et le fait AVANT
de resoudre le dossier, sinon la variable resterait sur l'ancien chemin et on
installerait a cote.

La cause du rename est extraite du *os.LinkError : son texte repete les deux
chemins complets, ce qui donnait trois fois les memes chemins dans un message
qui les cite deja et noyait l'essentiel (« Acces refuse »).

Deux tests : l'echec reste silencieux mais enregistre, et aucun message
parasite quand la migration reussit. Verifie sur un cas reproduit — `help`
est propre, `where` affiche le conseil.
2026-08-04 16:40:22 +02:00

260 lines
9.1 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
}
if err := copyExe(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
}
// Échoue si un « jean.exe » est en cours d'exécution — sans gravité, il s'agit
// alors déjà d'une version d'AJEAN, et la copie repassera au prochain lancement.
_ = copyExe(src, alias)
}
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
}