#!/usr/bin/env bash # ============================================================================= # Installeur Neo pour Linux (x86_64, aarch64) et macOS 12+ (Apple Silicon, Intel) # # curl -fsSL https://dylan-perinetti.fr/install.sh | bash # curl -fsSL https://dylan-perinetti.fr/install.sh | bash -s -- [options] # # Options : --aide # # Tout le script est fait de fonctions ; seule la dernière ligne lance # l'installation. Un téléchargement interrompu n'exécute donc rien. # # Compatible bash 3.2 (celui de macOS) et outils BSD : pas de tableaux # associatifs, ${x,,}, mapfile, &>>… Les commandes propres à GNU (sed -i, # mv -T, ldd, flock) ne servent que sous Linux (repère « linux-seulement », # vérifié par tests/macos/lint-bash32.sh). # ============================================================================= if [ -z "${BASH_VERSION:-}" ]; then echo "L'installeur Neo doit être exécuté avec bash : curl -fsSL /install.sh | bash" >&2 exit 1 fi set -euo pipefail # ----------------------------------------------------------------------------- # Constantes # ----------------------------------------------------------------------------- constantes() { # Remplacé par publier.sh à partir de config/domaine. NEO_BASE_URL a priorité. DOMAINE_PUBLIE="dylan-perinetti.fr" # uv (gestionnaire Python d'Astral), version épinglée et empreintes vérifiées. UV_VERSION="0.12.23" UV_VERSION_MIN="0.12.0" # un uv déjà présent est réutilisé à partir de cette version UV_SHA256_X86_64="9167d72b3319674b6303c4cbe071854bba13ebdf3d76b1a7cbdc175471fb66d6" UV_SHA256_AARCH64="6524bd338177ed50d035d39354e12545e993bbeba2ecbddf0480c5b3a81d313f" UV_SHA256_X86_64_MACOS="960da44cb4b73685206ddd250b19e0a117fa41095710c1038f081f5cb613efb4" UV_SHA256_AARCH64_MACOS="50487ae565ccd96e499056b4674d438f4c53170202617b4c759defe0c6a1b544" MACOS_MIN=12 # uv et les roues Python demandent 11 (arm64) / 10.13 (Intel) # Python demandé par l'archive installée (choisir_python, après extraction) : # Neo 0.2+ porte .python-version (3.14 : uv.lock ne couvre que 3.14) ; les archives # 0.1.x (amont 0.17) n'en ont pas et demandent Python 3.11 à 3.13. PYTHON_GERE="3.14" # fourni par uv si le Python du système ne convient pas PYTHON_MIN="3.14" PYTHON_MAX="3.14" MARQUEUR_LANCEUR="# neo-prefixe:" MARQUEUR_RC="# installeur-neo" MARQUEUR_PREFIXE=".neo-prefixe" MARQUEUR_VERSION=".neo-installation" # Rapport de problème (après un échec, envoyé seulement avec accord explicite) : # fin de la sortie de l'installeur jointe, plafonnée (le service accepte 300 Ko). RAPPORT_LIGNES=300 RAPPORT_OCTETS=100000 # Espace libre exigé avant tout téléchargement (Mo de 1024 Ko). Réglable par les # experts : NEO_ESPACE_INSTALLATION_MO, NEO_ESPACE_CACHE_UV_MO, NEO_ESPACE_TEMP_MO # (0 = pas de contrôle). Mesure de Neo 0.2.2 (Debian 12, x86_64, --extra all) : # préfixe 0,67 Go (Python 3.14 0,11 ; uv 0,05 ; sources et bytecode 0,16 ; venv # 0,34), cache de uv 0,29 Go partagé avec le venv (liens durs, clones APFS) s'il # est sur le même disque ; pic du dossier temporaire 0,09 Go. Marge : environ ×3, # et le disque n'est pas laissé plein après l'installation. ESPACE_INSTALLATION_MO=2048 # disque du préfixe (cache de uv compris s'il y est aussi) ESPACE_CACHE_UV_MO=1024 # disque du cache de uv, s'il est ailleurs (copie, pas de lien) ESPACE_TEMP_MO=300 # disque du dossier temporaire de l'installation CODE_ESPACE=28 # code de sortie « espace disque insuffisant » (comme ENOSPC) } # ----------------------------------------------------------------------------- # Affichage # ----------------------------------------------------------------------------- init_affichage() { GRAS="" ATTENUE="" VERT="" JAUNE="" ROUGE="" RAZ="" if [ -t 1 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-dumb}" != "dumb" ]; then GRAS=$'\033[1m' ATTENUE=$'\033[2m' VERT=$'\033[32m' JAUNE=$'\033[33m' ROUGE=$'\033[31m' RAZ=$'\033[0m' fi } info() { printf '%s\n' "$*"; journaliser "$*"; } etape() { printf '\n%s→ %s%s\n' "$GRAS" "$*" "$RAZ"; journaliser "" "→ $*"; } ok() { printf ' %s✓%s %s\n' "$VERT" "$RAZ" "$*"; journaliser " ✓ $*"; } detail() { printf ' %s%s%s\n' "$ATTENUE" "$*" "$RAZ"; journaliser " $*"; } avert() { printf ' %s!%s %s\n' "$JAUNE" "$RAZ" "$*" >&2; journaliser " ! $*"; } echec() { printf '\n%s✗ %s%s\n' "$ROUGE" "$1" "$RAZ" >&2 ECHEC_MESSAGE=$1 journaliser "" "✗ $1" shift local ligne for ligne in "$@"; do printf ' %s\n' "$ligne" >&2; journaliser " $ligne"; done exit "${CODE_SORTIE_ECHEC:-1}" } # Copie des messages affichés, pour un éventuel rapport de problème (fichier # temporaire, supprimé à la fin ; rien n'est envoyé sans accord explicite). journaliser() { if [ -n "${JOURNAL_RAPPORT:-}" ]; then printf '%s\n' "$@" >>"$JOURNAL_RAPPORT" 2>/dev/null || true fi return 0 } # Ajoute au journal la fin d'un fichier de sortie (uv sync, neo --version…). joindre_au_journal() { # joindre_au_journal TITRE FICHIER LIGNES if [ -n "${JOURNAL_RAPPORT:-}" ] && [ -s "$2" ]; then { printf '%s\n' "--- $1 (fin) ---"; tail -n "$3" "$2"; printf '%s\n' "--- fin : $1 ---"; } \ >>"$JOURNAL_RAPPORT" 2>/dev/null || true fi return 0 } # Logo Neo (symbole « orbite ouverte » + « neo »), tiré de rebrand/logo_art.py. # Repères : t = trait (bleu moyen #5B8DEF), n = noyau (Lueur #7FD8FF), z = fin. # Affiché seulement sur un terminal UTF-8 d'au moins 70 colonnes. logo() { [ -t 1 ] || return 0 case "${LC_ALL:-${LC_CTYPE:-${LANG:-}}}" in *[Uu][Tt][Ff]-8* | *[Uu][Tt][Ff]8*) ;; *) return 0 ;; esac local cols cols=$(tput cols 2>/dev/null || echo "${COLUMNS:-80}") [ "${cols:-0}" -ge 70 ] 2>/dev/null || return 0 local trait="" noyau="" fin="" ligne if [ -n "$RAZ" ]; then case "${COLORTERM:-}" in truecolor | 24bit) trait=$'\033[38;2;91;141;239m' noyau=$'\033[38;2;127;216;255m' ;; *) trait=$'\033[38;5;69m' noyau=$'\033[38;5;117m' ;; esac fin="$RAZ" fi printf '\n' while IFS= read -r ligne; do ligne=${ligne//t/$trait} ligne=${ligne//n/$noyau} printf ' %s\n' "${ligne//z/$fin}" done <<'LOGO' n▄████▄z t▄▄▄z n████████z t▄▄▄z t▄██▀▀▀z n████████z t▀▀▀█▄▄z t▄██▀z n██████▀z t▀██▄z t██▄████z t▄█████▄▄z t▄██████▄z t▄█▀z n▀▀▀z t█▄z t██z t██z t▄█▀z t▀█▄z t▄█▀z t▀██z t██z t██z t██z t██z t█████████████z t██z t███▄z t██z t██z t██z t▀█▄z t▄z t▀█▄z t▄██z t▀███▄z t▀z t██z t▀█z t▀█████▀▀z t▀██████▀z t▀▀██▄▄▄z t▀▀▀██████▄z LOGO } # Chemin lisible : /home/x/.neo -> ~/.neo joli() { if [ -n "${HOME:-}" ] && [ "$HOME" != "/" ]; then # shellcheck disable=SC2088 case "$1" in "$HOME") printf '~'; return ;; "$HOME"/*) printf '~/%s' "${1#"$HOME"/}"; return ;; esac fi printf '%s' "$1" } # Citation sûre pour sh : abc'd -> 'abc'\''d' citer_sh() { # Remplacement passé par variable : bash 3.2 interprète autrement les # guillemets écrits directement dans ${s//…/…}. local s=$1 q="'\\''" printf "'%s'" "${s//\'/$q}" } aide() { local domaine="" [ "${DOMAINE_PUBLIE#@@}" = "$DOMAINE_PUBLIE" ] && domaine=$DOMAINE_PUBLIE sed -e "s||$domaine|g" -e "s||$CODE_ESPACE|" \ -e "s||$ESPACE_INSTALLATION_MO|" -e "s||$ESPACE_TEMP_MO|" \ -e "s||$ESPACE_CACHE_UV_MO|" <<'AIDE' Installeur Neo pour Linux (x86_64, aarch64) et macOS 12 ou plus récent (Apple Silicon ; Intel pas encore confirmé) Utilisation : curl -fsSL https:///install.sh | bash curl -fsSL https:///install.sh | bash -s -- [options] Options : --version X installer la version X (par défaut : la dernière publiée) --prefixe DIR dossier d’installation (défaut : ~/.local/share/neo, ou /opt/neo pour root) --sans-config ne pas lancer « neo setup » à la fin --migrate reprendre les réglages, la mémoire et les compétences d’un autre agent (OpenClaw, Claude Code, Codex…) avec « neo migrate auto », juste après l’installation --service installer la passerelle comme service utilisateur (neo gateway install) --complet installer aussi ripgrep, ffmpeg et Node.js avec le gestionnaire de paquets du système (macOS : affiche la commande Homebrew à lancer) --desinstaller retirer Neo (vos données ~/.neo sont conservées) --purger avec --desinstaller : supprimer aussi ~/.neo --rapport en cas d’échec, envoyer automatiquement le rapport de problème, sans question (choix enregistré) --sans-rapport ne jamais proposer ni envoyer de rapport (choix enregistré) --aide afficher cette aide Relancer l’installeur met Neo à jour. La version précédente est conservée pour un retour arrière (--version ), les plus anciennes sont supprimées. Passer à Neo depuis un autre agent : --migrate lance « neo migrate auto » après l’installation (sans terminal : sans question, avec --yes ; Neo reprend alors le seul agent de messagerie trouvé, plus Claude Code et Codex ; s’il en trouve plusieurs, il ne change rien et affiche les commandes à lancer). Les données de l’ancien agent sont copiées, jamais effacées ; son service est arrêté pour que deux agents ne répondent pas aux mêmes messages. « neo migrate undo » arrête Neo et remet l’ancien agent en route. Avec --service, la passerelle de Neo n’est installée que si la reprise a abouti. Rapports de problème : au début de l’installation, l’installeur demande une seule fois s’il peut envoyer automatiquement un rapport en cas d’échec, pour aider à améliorer Neo (système, étape, erreur et fin du journal, sans données personnelles : dossier personnel, nom d’utilisateur, e-mails, clés). Réponse par défaut : non. Le choix est enregistré dans ~/.neo/rapports.json (réglage rapports.automatique de Neo). Sans accord, un échec propose encore d’envoyer le rapport. Sans terminal, rien n’est envoyé (sauf --rapport) : le rapport est préparé dans un fichier, avec la commande pour l’envoyer. Espace disque : avant tout téléchargement, l’installeur vérifie l’espace libre sur les disques du dossier d’installation, du dossier temporaire et du cache de uv. S’il manque de la place, il s’arrête sans rien modifier (code de sortie ). Variables d’environnement : NEO_BASE_URL adresse de distribution (défaut : https://) NEO_HOME dossier de données (défaut : ~/.neo) Pour les experts, espace libre exigé en Mo (0 : pas de contrôle) : NEO_ESPACE_INSTALLATION_MO disque du dossier d’installation (défaut : ) NEO_ESPACE_TEMP_MO dossier temporaire (défaut : ) NEO_ESPACE_CACHE_UV_MO cache de uv, s’il est sur un autre disque (défaut : ) AIDE } # ----------------------------------------------------------------------------- # Arguments # ----------------------------------------------------------------------------- analyser_arguments() { OPT_VERSION="" OPT_PREFIXE="" OPT_SANS_CONFIG=0 OPT_SERVICE=0 OPT_COMPLET=0 OPT_DESINSTALLER=0 OPT_PURGER=0 OPT_RAPPORT="" OPT_MIGRER=0 while [ $# -gt 0 ]; do case "$1" in --version) [ $# -ge 2 ] && [ -n "$2" ] || echec "L’option --version attend un numéro de version (ex. --version 0.1.0)." OPT_VERSION=$2; shift 2 ;; --version=*) OPT_VERSION=${1#*=}; shift ;; --prefixe) [ $# -ge 2 ] && [ -n "$2" ] || echec "L’option --prefixe attend un dossier." OPT_PREFIXE=$2; shift 2 ;; --prefixe=*) OPT_PREFIXE=${1#*=}; shift ;; --sans-config) OPT_SANS_CONFIG=1; shift ;; --service) OPT_SERVICE=1; shift ;; --complet) OPT_COMPLET=1; shift ;; --migrate) OPT_MIGRER=1; shift ;; --desinstaller) OPT_DESINSTALLER=1; shift ;; --purger) OPT_PURGER=1; shift ;; --rapport|--sans-rapport) [ -n "$OPT_RAPPORT" ] && [ "$OPT_RAPPORT" != "$1" ] && echec "Les options --rapport et --sans-rapport s’excluent." OPT_RAPPORT=$1; shift ;; --aide|-h|--help) aide; exit 0 ;; *) echec "Option inconnue : $1" "Liste des options : bash install.sh --aide" ;; esac done if [ "$OPT_PURGER" = 1 ] && [ "$OPT_DESINSTALLER" = 0 ]; then echec "L’option --purger s’utilise avec --desinstaller." fi if [ "$OPT_MIGRER" = 1 ] && [ "$OPT_DESINSTALLER" = 1 ]; then echec "Les options --migrate et --desinstaller s’excluent." fi case "$OPT_RAPPORT" in --rapport) OPT_RAPPORT=oui ;; --sans-rapport) OPT_RAPPORT=non ;; esac if [ -n "$OPT_VERSION" ]; then OPT_VERSION=${OPT_VERSION#v} version_valide "$OPT_VERSION" || echec "Numéro de version invalide : $OPT_VERSION" fi } version_valide() { [[ $1 =~ ^[0-9]+(\.[0-9]+){1,3}([.+-]?[A-Za-z0-9]+)*$ ]] && [ ${#1} -le 40 ] } # a >= b (numéros pointés). Comparaison numérique champ par champ en bash pur : # « sort -V » n'existe pas partout (anciens macOS, BusyBox). version_ge() { local a=$1 b=$2 x y while [ -n "$a" ] || [ -n "$b" ]; do [ -z "$a" ] && return 1 # 0.12 < 0.12.0 (même ordre que le tri GNU par version) [ -z "$b" ] && return 0 x=${a%%.*} y=${b%%.*} if [ "$x" = "$a" ]; then a=""; else a=${a#*.}; fi if [ "$y" = "$b" ]; then b=""; else b=${b#*.}; fi x=${x%%[!0-9]*} y=${y%%[!0-9]*} x=${x:-0} y=${y:-0} [ "$((10#$x))" -gt "$((10#$y))" ] && return 0 [ "$((10#$x))" -lt "$((10#$y))" ] && return 1 done return 0 } # ----------------------------------------------------------------------------- # Plateforme # ----------------------------------------------------------------------------- detecter_plateforme() { local os machine os=$(uname -s 2>/dev/null || echo inconnu) case "$os" in Linux) SYSTEME="linux" ;; Darwin) SYSTEME="macos" ;; MINGW*|MSYS*|CYGWIN*) echec "Cet installeur est pour Linux et macOS." \ "Sous Windows, dans PowerShell : irm https://${DOMAINE_PUBLIE}/install.ps1 | iex" ;; *) echec "Système non pris en charge : $os." "Neo s’installe sur Linux et sur macOS $MACOS_MIN ou plus récent." ;; esac machine=$(uname -m 2>/dev/null || echo inconnue) case "$machine" in x86_64|amd64) ARCH="x86_64" ;; aarch64|arm64) ARCH="aarch64" ;; *) echec "Architecture non prise en charge : $machine." "Neo est disponible pour x86_64 et aarch64 (ARM 64 bits)." ;; esac ARCH_NOM=$ARCH if [ "$SYSTEME" = "macos" ]; then detecter_macos return 0 fi if ls /lib/ld-musl-* >/dev/null 2>&1 || { command -v ldd >/dev/null 2>&1 && ldd --version 2>&1 | grep -qi musl; }; then # linux-seulement echec "Les systèmes basés sur musl (Alpine…) ne sont pas pris en charge." \ "Neo nécessite une distribution utilisant la glibc (Debian, Ubuntu, Fedora, Arch, openSUSE…)." fi local infos="" id="" id_like="" nom="" if [ -r /etc/os-release ]; then # shellcheck disable=SC1091 infos=$( . /etc/os-release >/dev/null 2>&1; printf '%s|%s|%s' "${ID:-}" "${ID_LIKE:-}" "${PRETTY_NAME:-}" ) || infos="" fi IFS='|' read -r id id_like nom <<<"$infos" || true DISTRO_NOM=${nom:-Linux} case " $id $id_like " in *" debian "*|*" ubuntu "*) FAMILLE="debian" ;; *" fedora "*|*" rhel "*|*" centos "*) FAMILLE="fedora" ;; *" arch "*|*" archlinux "*) FAMILLE="arch" ;; *suse*) FAMILLE="suse" ;; *) FAMILLE="autre" ;; esac } detecter_macos() { local version majeure # Terminal lancé sous Rosetta sur un Mac Apple Silicon : uname -m répond # x86_64. On installe quand même la version native arm64. if [ "$ARCH" = "x86_64" ] && [ "$(sysctl -n hw.optional.arm64 2>/dev/null || true)" = "1" ]; then ARCH="aarch64" ROSETTA=1 fi if [ "$ARCH" = "aarch64" ]; then ARCH_NOM="Apple Silicon"; else ARCH_NOM="Intel"; fi version=$(sw_vers -productVersion 2>/dev/null || true) majeure=${version%%.*} case "$majeure" in ""|*[!0-9]*) echec "Version de macOS illisible (sw_vers -productVersion : « ${version} »)." ;; esac if [ "$majeure" -lt "$MACOS_MIN" ]; then echec "macOS $version n’est pas pris en charge." \ "Neo nécessite macOS $MACOS_MIN (Monterey) ou plus récent." fi DISTRO_NOM="macOS $version" FAMILLE="macos" } # Commande d'installation de paquets pour la famille détectée (texte affichable). commande_paquets() { local s="" [ "$EST_ROOT" = 1 ] || s="sudo " case "$FAMILLE" in debian) printf '%sapt-get update && %sapt-get install -y %s' "$s" "$s" "$*" ;; fedora) printf '%sdnf install -y %s' "$s" "$*" ;; arch) printf '%spacman -S --needed --noconfirm %s' "$s" "$*" ;; suse) printf '%szypper --non-interactive install %s' "$s" "$*" ;; *) printf 'installez avec le gestionnaire de paquets de votre système : %s' "$*" ;; esac } verifier_prerequis() { local manquants=() c if command -v curl >/dev/null 2>&1; then TELECHARGEUR="curl" elif command -v wget >/dev/null 2>&1; then TELECHARGEUR="wget" else manquants+=(curl) fi for c in tar gzip; do command -v "$c" >/dev/null 2>&1 || manquants+=("$c") done for c in mktemp sort grep sed head; do command -v "$c" >/dev/null 2>&1 || manquants+=(coreutils) done if ! command -v sha256sum >/dev/null 2>&1 && ! command -v shasum >/dev/null 2>&1 && ! command -v openssl >/dev/null 2>&1; then # repli manquants+=(coreutils) fi if [ ${#manquants[@]} -gt 0 ]; then local liste paquets liste=$(printf '%s\n' "${manquants[@]}" | sort -u | tr '\n' ' ') liste=${liste% } paquets=$liste [ "$FAMILLE" = "debian" ] && [[ " $liste " == *" curl "* ]] && paquets="$liste ca-certificates" echec "Outils manquants : $liste." "Installez-les puis relancez l’installeur :" " $(commande_paquets "$paquets")" fi } tty_disponible() { [ -z "${NEO_INSTALL_SANS_TTY:-}" ] && (exec /dev/tty) 2>/dev/null } # Question oui/non sur le terminal. $2 = défaut (o|n). Sans terminal : défaut non. demander() { local reponse="" invite="[o/N]" [ "${2:-n}" = "o" ] && invite="[O/n]" tty_disponible || return 1 printf ' %s %s ' "$1" "$invite" >/dev/tty # Fin de saisie (Ctrl-D, terminal fermé) : pas de consentement. IFS= read -r reponse /dev/tty; return 1; } case "$reponse" in "") [ "${2:-n}" = "o" ] ;; [oOyY]*) return 0 ;; *) return 1 ;; esac } # ----------------------------------------------------------------------------- # Réseau et empreintes # ----------------------------------------------------------------------------- telecharger() { local url=$1 dest=$2 rm -f "$dest" if [ "$TELECHARGEUR" = "curl" ]; then curl -fsSL --retry 3 --retry-delay 2 --connect-timeout 20 -o "$dest" "$url" /dev/null 2>&1; then # repli : shasum (macOS) ou openssl sortie=$(sha256sum "$1") # repli elif command -v shasum >/dev/null 2>&1; then sortie=$(shasum -a 256 "$1") else sortie=$(openssl dgst -sha256 -r "$1") fi sortie=${sortie%% *} printf '%s' "$sortie" | tr 'A-F' 'a-f' } # ----------------------------------------------------------------------------- # Emplacements # ----------------------------------------------------------------------------- determiner_emplacements() { if [ "$(id -u)" -eq 0 ] && [ "$SYSTEME" = "macos" ]; then echec "Sur macOS, lancez l’installeur sans sudo, depuis votre compte utilisateur." \ "Neo s’installe dans votre dossier personnel et n’a besoin d’aucun droit administrateur." fi if [ "$(id -u)" -eq 0 ]; then EST_ROOT=1 PREFIXE_DEFAUT="/opt/neo" BIN_DIR="/usr/local/bin" else EST_ROOT=0 [ -n "${HOME:-}" ] && [ -d "$HOME" ] || echec "La variable HOME n’est pas définie ou ne désigne pas un dossier." PREFIXE_DEFAUT="$HOME/.local/share/neo" BIN_DIR="$HOME/.local/bin" fi LANCEUR="$BIN_DIR/neo" NEO_HOME_EFFECTIF=${NEO_HOME:-${HOME:-/root}/.neo} if [ -n "$OPT_PREFIXE" ]; then PREFIXE=$OPT_PREFIXE # shellcheck disable=SC2088 case "$PREFIXE" in "~") PREFIXE=$HOME ;; "~/"*) PREFIXE="$HOME/${PREFIXE#"~/"}" ;; /*) ;; *) PREFIXE="$PWD/$PREFIXE" ;; esac elif PREFIXE=$(prefixe_du_lanceur "$LANCEUR"); then : # réinstallation : on garde le dossier choisi la première fois else PREFIXE=$PREFIXE_DEFAUT fi while [ "${PREFIXE%/}" != "$PREFIXE" ] && [ "$PREFIXE" != "/" ]; do PREFIXE=${PREFIXE%/}; done case "$PREFIXE" in *$'\n'*|*$'\r'*) echec "Le dossier d’installation ne doit pas contenir de retour à la ligne." ;; esac prefixe_sur "$PREFIXE" || echec "Dossier d’installation refusé : $PREFIXE" "Choisissez un sous-dossier dédié, par exemple --prefixe $PREFIXE_DEFAUT." } # Un préfixe sera supprimé à la désinstallation : on refuse les dossiers système. prefixe_sur() { case "$1" in ""|/|/bin|/boot|/dev|/etc|/home|/lib|/lib64|/media|/mnt|/opt|/proc|/root|/run|/sbin|/srv|/sys|/tmp|/usr|/usr/*bin|/usr/lib|/usr/local|/usr/share|/var|/var/lib|/var/tmp) return 1 ;; esac [ -n "${HOME:-}" ] && { [ "$1" = "$HOME" ] || [ "$1" = "$HOME/.local" ] || [ "$1" = "$HOME/.local/share" ] || [ "$1" = "$HOME/.neo" ]; } && return 1 [ "$1" = "$NEO_HOME_EFFECTIF" ] && return 1 if [ "$SYSTEME" = "macos" ]; then case "$1" in /Applications|/Library|/System|/Users|/Volumes|/cores|/private|/private/etc|/private/tmp|/private/var|/opt/homebrew|/usr/local/Homebrew) return 1 ;; esac [ -n "${HOME:-}" ] && { [ "$1" = "$HOME/Library" ] || [ "$1" = "$HOME/Applications" ]; } && return 1 fi return 0 } est_notre_lanceur() { [ -f "$1" ] && grep -q "^$MARQUEUR_LANCEUR " "$1" 2>/dev/null } prefixe_du_lanceur() { local ligne est_notre_lanceur "$1" || return 1 ligne=$(grep -m 1 "^$MARQUEUR_LANCEUR " "$1") || return 1 ligne=${ligne#"$MARQUEUR_LANCEUR "} [ -n "$ligne" ] && [ "${ligne#/}" != "$ligne" ] || return 1 printf '%s' "$ligne" } version_courante() { local cible [ -L "$PREFIXE/current" ] || return 1 cible=$(readlink "$PREFIXE/current") || return 1 printf '%s' "${cible##*/}" } version_saine() { local dossier="$PREFIXE/versions/$1" [ -f "$dossier/$MARQUEUR_VERSION" ] && [ -x "$dossier/venv/bin/neo" ] \ && "$dossier/venv/bin/python" -c 'import neo_cli' /dev/null 2>&1 } unite_service_presente() { if [ "$SYSTEME" = "macos" ]; then # Agent launchd écrit par « neo gateway install » (neo_cli/gateway.py). [ -n "${HOME:-}" ] && [ -f "$HOME/Library/LaunchAgents/ai.neo.gateway.plist" ] return fi [ -n "${HOME:-}" ] && [ -f "$HOME/.config/systemd/user/neo-gateway.service" ] && return 0 [ "$EST_ROOT" = 1 ] && [ -f /etc/systemd/system/neo-gateway.service ] } # ----------------------------------------------------------------------------- # Espace disque (avant tout téléchargement ou changement) # ----------------------------------------------------------------------------- # Seuil en Mo : variable d'environnement (experts), sinon valeur par défaut. seuil_espace() { # seuil_espace NOM_VARIABLE DEFAUT local v=${!1:-} case "$v" in "") v=$2 ;; *[!0-9]*|?????????*) avert "${1} ignorée (« ${v} » : nombre de Mo attendu) : ${2} Mo exigés." v=$2 ;; esac printf '%s' "$((10#$v))" } # Cache de uv, choisi comme uv le fait (sa configuration est ignorée : UV_NO_CONFIG) : # UV_CACHE_DIR, sinon $XDG_CACHE_HOME/uv, sinon ~/.cache/uv (Linux et macOS). dossier_cache_uv() { local d="" if [ -n "${UV_CACHE_DIR:-}" ]; then d=$UV_CACHE_DIR else case "${XDG_CACHE_HOME:-}" in /*) d="$XDG_CACHE_HOME/uv" ;; *) [ -n "${HOME:-}" ] && d="$HOME/.cache/uv" ;; esac fi [ -n "$d" ] || return 1 case "$d" in /*) ;; *) d="$PWD/$d" ;; esac printf '%s' "$d" } # Espace libre du disque qui contient $1, ou son premier parent existant (dossier # pas encore créé) : ESPACE_KO (Ko) et ESPACE_MONTAGE (point de montage). # « df -Pk » a le même format POSIX sous Linux (GNU, BusyBox) et macOS (BSD). Repère : # la colonne « Capacity » (n %), car le nom du volume peut contenir des espaces # (« map auto_home » sous macOS). df absent ou sortie illisible : code 1. espace_libre() { local d=$1 sortie ligne champs n i c ESPACE_KO="" ESPACE_MONTAGE="" case "$d" in /*) ;; *) d="$PWD/$d" ;; esac while [ ! -e "$d" ]; do d=${d%/*} [ -n "$d" ] || d=/ done command -v df >/dev/null 2>&1 || return 1 sortie=$(LC_ALL=C df -Pk "$d" 2>/dev/null &2 journaliser "$annonce" fi proposer_rapport "$rc" [ -n "${JOURNAL_RAPPORT:-}" ] && rm -f "$JOURNAL_RAPPORT" return "$rc" } # ----------------------------------------------------------------------------- # Rapport de problème (après un échec) # # Rien ne quitte la machine sans accord explicite : question sur le terminal # (défaut : non) ou option --rapport. Sans terminal, le rapport est seulement # préparé dans un fichier (droits 600), avec la commande pour l'envoyer. # Contenu : système, étape, erreur et fin de la sortie de l'installeur, nettoyés # des données personnelles (le service nettoie aussi de son côté). # ----------------------------------------------------------------------------- dossier_temp() { local d=${TMPDIR:-/tmp} while [ "${d%/}" != "$d" ]; do d=${d%/}; done printf '%s' "${d:-/tmp}" } # Données personnelles retirées (entrée standard -> sortie) : dossiers personnels, # nom d'utilisateur, identifiants dans les adresses, e-mails, clés et jetons. nettoyer_texte() { local q="'" dq='"' bs='\.' u vus=" " regles regles=( -e "s#(/home/|/Users/)[^/[:space:]${dq}${q}]+#\\1#g" -e 's#://[^/@[:space:]]+@#://@#g' -e 's#(^|[^A-Za-z0-9_])(sk|pk|rk)-[A-Za-z0-9_-]{16,}#\1#g' -e 's#(^|[^A-Za-z0-9_])(ghp|gho|ghu|ghs|ghr|github_pat)_[A-Za-z0-9_]{10,}#\1#g' -e 's#(^|[^A-Za-z0-9_])xox[abeprs]-[A-Za-z0-9-]{10,}#\1#g' -e 's#(^|[^A-Za-z0-9_])AKIA[0-9A-Z]{16}#\1#g' -e 's#([Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Tt][Oo][Kk][Ee][Nn]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Mm]ot[_ ]de[_ ]passe)([[:space:]]*[=:][[:space:]]*)[^[:space:]]+#\1\2#g' -e 's#([Bb]earer[[:space:]]+)[A-Za-z0-9._~+/=-]+#\1#g' -e 's#[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+##g' ) # Nom d'utilisateur, en mot entier (deux passes : occurrences rapprochées). # Les noms qui sont aussi des mots du journal (root, neo…) ne sont pas remplacés. for u in "$(id -un 2>/dev/null || true)" "${SUDO_USER:-}" "${USER:-}" "${LOGNAME:-}"; do case "$u" in ""|root|neo|uv|user|users|admin|test|essai|home|local|share|bin|python|tmp|linux|macos|*[!A-Za-z0-9._-]*) continue ;; esac [ "${#u}" -ge 3 ] || continue case "$vus" in *" $u "*) continue ;; esac vus="$vus$u " u=${u//./$bs} regles+=(-e "s#(^|[^A-Za-z0-9_])${u}([^A-Za-z0-9_]|\$)#\\1\\2#g") regles+=(-e "s#(^|[^A-Za-z0-9_])${u}([^A-Za-z0-9_]|\$)#\\1\\2#g") done LC_ALL=C sed -E "${regles[@]}" } # Texte (entrée standard) -> contenu d'une chaîne JSON : antislashs et guillemets # échappés, fins de ligne en \n, tabulations en espaces, autres contrôles retirés. # Sans awk (absent de certaines images) : sed réunit les lignes ($!N : le N de BSD # quitterait sans rien écrire sur la dernière ligne). json : sans fin de ligne finale. json_echapper() { LC_ALL=C tr '\t' ' ' | LC_ALL=C tr -d '\000-\010\013-\037\177' \ | LC_ALL=C sed -e ':a' -e '$!N' -e '$!ba' -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/\n/\\n/g' } json() { printf '%s' "$1" | json_echapper; } url_rapports() { local base=${BASE_URL:-} motif='^https?://[A-Za-z0-9._~:/?#@!$&'\''()*+,;=%-]+$' if [ -z "$base" ]; then if [ -n "${NEO_BASE_URL:-}" ]; then base=$NEO_BASE_URL elif [ "${DOMAINE_PUBLIE#@@}" = "$DOMAINE_PUBLIE" ]; then base="https://$DOMAINE_PUBLIE" else return 1 fi while [ "${base%/}" != "$base" ]; do base=${base%/}; done fi [[ $base =~ $motif ]] || return 1 printf '%s/api/rapports' "$base" } # Fin de la sortie de l'installeur, nettoyée et plafonnée. journal_rapport() { local texte octets # Arrêt avant toute installation (espace disque) : le journal n'apprendrait rien. [ "${RAPPORT_SANS_JOURNAL:-0}" = 1 ] && return 0 [ -n "${JOURNAL_RAPPORT:-}" ] && [ -s "$JOURNAL_RAPPORT" ] || return 0 texte=$(tail -n "$RAPPORT_LIGNES" "$JOURNAL_RAPPORT" | LC_ALL=C tr -d '\000-\010\013-\037\177' | nettoyer_texte) octets=$(printf '%s' "$texte" | wc -c | tr -d ' ') if [ "${octets:-0}" -gt "$RAPPORT_OCTETS" ]; then texte=$(printf '%s' "$texte" | tail -c "$RAPPORT_OCTETS" | sed '1d') # première ligne coupée fi printf '%s' "$texte" } # Champs du rapport (R_*), à partir de l'état de l'installeur au moment de l'échec. preparer_rapport() { local rc=$1 distro R_ETAPE=${ETAPE_RAPPORT:-} R_MESSAGE=${ECHEC_MESSAGE:-} if [ -z "$R_MESSAGE" ]; then # Arrêt sur une commande en échec (set -e), notée par le piège ERR. R_MESSAGE="Arrêt inattendu (code ${rc})" [ -n "${ERR_COMMANDE:-}" ] && R_MESSAGE="${R_MESSAGE} : $ERR_COMMANDE" [ -n "${ERR_FONCTION:-}" ] && R_ETAPE="${R_ETAPE:-inconnue} (${ERR_FONCTION})" fi R_ETAPE=$(printf '%.200s' "${R_ETAPE:-inconnue}" | nettoyer_texte) R_MESSAGE=$(printf '%.1000s' "$R_MESSAGE" | nettoyer_texte) R_VERSION=${VERSION:-${OPT_VERSION:-}} [ -n "$R_VERSION" ] || R_VERSION=inconnue R_OS=${SYSTEME:-} [ -n "$R_OS" ] || R_OS=$(uname -s 2>/dev/null | tr '[:upper:]' '[:lower:]') if [ "$R_OS" = "macos" ]; then R_OS_VERSION=$(sw_vers -productVersion 2>/dev/null || true) else R_OS_VERSION=$(uname -r 2>/dev/null || true) fi R_ARCH=${ARCH:-} [ -n "$R_ARCH" ] || R_ARCH=$(uname -m 2>/dev/null || true) distro=${DISTRO_NOM:-} [ -n "$distro" ] || distro=$(uname -s 2>/dev/null || true) R_DETAIL="${distro}, bash ${BASH_VERSION%%[(]*}" [ "${ROSETTA:-0}" = 1 ] && R_DETAIL="${R_DETAIL}, Rosetta" [ "${EST_ROOT:-0}" = 1 ] && R_DETAIL="${R_DETAIL}, root" R_DETAIL=$(printf '%s' "$R_DETAIL" | nettoyer_texte) R_RESUME="${distro}, ${R_ARCH}" R_JOURNAL=$(journal_rapport) } ecrire_rapport() { # ecrire_rapport FICHIER [CONTACT] { printf '{"source":"install.sh","consentement":true,"version":"%s",' "$(json "$R_VERSION")" printf '"systeme":{"os":"%s","version":"%s","arch":"%s","detail":"%s"},' \ "$(json "$R_OS")" "$(json "$R_OS_VERSION")" "$(json "$R_ARCH")" "$(json "$R_DETAIL")" printf '"etape":"%s","message":"%s",' "$(json "$R_ETAPE")" "$(json "$R_MESSAGE")" printf '"journal":"%s","contact":"%s"}' "$(json "$R_JOURNAL")" "$(json "${2:-}")" } >"$1" } # Messages du rapport : sur le terminal pendant le dialogue, sinon sur la sortie d'erreur. dire() { if [ "${R_TTY:-0}" = 1 ]; then printf '%s\n' "$*" >/dev/tty; else printf '%s\n' "$*" >&2; fi } commande_envoi() { # commande_envoi URL FICHIER : commande à copier pour un envoi plus tard local url=$1 f=$2 case "$f" in *[!A-Za-z0-9._/-]*) f=$(citer_sh "$f") ;; esac case "$url" in *[!A-Za-z0-9._~:/%-]*) url=$(citer_sh "$url") ;; esac if command -v curl >/dev/null 2>&1; then printf "curl -fsS -H 'Content-Type: application/json' --data-binary @%s %s" "$f" "$url" else printf "wget -qO- --header='Content-Type: application/json' --post-file=%s %s" "$f" "$url" fi } resumer_rapport() { # resumer_rapport URL : ce qui serait envoyé local octets lignes=0 hote=${1#*://} hote=${hote%%/*} octets=$(printf '%s' "$R_JOURNAL" | wc -c | tr -d ' ') [ -n "$R_JOURNAL" ] && lignes=$(printf '%s\n' "$R_JOURNAL" | wc -l | tr -d ' ') dire "" if [ "${R_AUTO:-0}" = 1 ]; then dire " Rapport de problème, envoyé avec votre accord (${RAPPORT_RAISON:-option --rapport}) :" else dire " Rapport de problème (rien n’est envoyé sans votre accord) :" fi dire " Système : $R_RESUME" dire " Étape  : $R_ETAPE" dire " Erreur  : $R_MESSAGE" if [ -n "$R_JOURNAL" ]; then dire " Journal : ${lignes} lignes de l’installation ($(( (${octets:-0} + 1023) / 1024 )) Ko), sans données personnelles" else dire " Journal : non joint" fi dire " Envoi à : $hote" [ "${R_AUTO:-0}" = 1 ] || dire " Contenu exact, lisible avant de répondre : $R_FICHIER" } envoyer_rapport() { # envoyer_rapport URL : envoie $R_FICHIER, supprimé une fois reçu local url=$1 reponse code="" id="" reponse=$(mktemp "$(dossier_temp)/neo-rapport-reponse.XXXXXX" 2>/dev/null) || reponse=/dev/null dire " Envoi du rapport…" if command -v curl >/dev/null 2>&1; then code=$(curl -sS --connect-timeout 10 --max-time 20 -o "$reponse" -w '%{http_code}' \ -H 'Content-Type: application/json' --data-binary "@$R_FICHIER" "$url" /dev/null) || code="" elif wget -q -O "$reponse" --header='Content-Type: application/json' --post-file="$R_FICHIER" \ --timeout=20 --tries=1 "$url" /dev/null 2>&1; then code=201 # wget ne donne pas le code : réponse 2xx fi if [ "$reponse" != /dev/null ]; then id=$(sed -n 's/.*"id"[[:space:]]*:[[:space:]]*"\(r-[A-Za-z0-9._-]*\)".*/\1/p' "$reponse" 2>/dev/null | head -n 1) rm -f "$reponse" fi if [ "$code" = 201 ] || { [ "$code" = 200 ] && [ -n "$id" ]; }; then rm -f "$R_FICHIER" if [ "${R_AUTO:-0}" = 1 ]; then if [ -n "$id" ]; then dire " Rapport envoyé (${id}). Merci."; else dire " Rapport envoyé. Merci."; fi elif [ -n "$id" ]; then dire " Merci : rapport envoyé (${id})." else dire " Merci : rapport envoyé." fi return 0 fi case "$code" in 429) dire " Le service des rapports reçoit trop d’envois en ce moment : réessayez dans quelques minutes." ;; 413) dire " Le rapport est trop volumineux pour le service des rapports." ;; *) dire " Le rapport n’a pas pu être envoyé (service injoignable ou réponse ${code:-absente})." ;; esac dire " Il est conservé pour un envoi plus tard :" dire " $(commande_envoi "$url" "$R_FICHIER")" return 1 } # Ctrl+C pendant le dialogue : rien n'est envoyé, rien ne reste. abandonner_rapport() { [ -n "${R_FICHIER:-}" ] && rm -f "$R_FICHIER" [ -n "${JOURNAL_RAPPORT:-}" ] && rm -f "$JOURNAL_RAPPORT" printf '\n' >&2 exit 130 } # Choix « rapport automatique » : posé une fois, enregistré dans le dossier de # données de Neo (repris par le réglage rapports.automatique de Neo). fichier_choix_rapport() { printf '%s/rapports.json' "${NEO_HOME_EFFECTIF:-${NEO_HOME:-${HOME:-/root}/.neo}}" } lire_choix_rapport() { # RAPPORT_AUTO=oui|non si un choix est enregistré local f contenu f=$(fichier_choix_rapport) [ -f "$f" ] || return 0 contenu=$(head -c 4096 "$f" 2>/dev/null | tr -d ' \t\r\n') || return 0 case "$contenu" in *'"automatique":true'*) RAPPORT_AUTO=oui RAPPORT_RAISON="choix enregistré dans $(joli "$f")" ;; *'"automatique":false'*) RAPPORT_AUTO=non RAPPORT_RAISON="" ;; esac return 0 } ecrire_choix_rapport() { # ecrire_choix_rapport oui|non : seulement après une réponse ou une option local f dossier temp valeur=false [ "$1" = "oui" ] && valeur=true # Installation par sudo : le dossier de données serait celui d'un autre compte. [ "${EST_ROOT:-0}" = 1 ] && [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ] && return 1 f=$(fichier_choix_rapport) dossier=${f%/*} if [ ! -d "$dossier" ]; then (umask 077; mkdir -p "$dossier") 2>/dev/null || return 1 fi temp="$dossier/.rapports.json.$$" if printf '{"automatique": %s, "decide_le": "%s", "source": "installateur"}\n' "$valeur" \ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >"$temp" 2>/dev/null && mv -f "$temp" "$f" 2>/dev/null; then return 0 fi rm -f "$temp" 2>/dev/null return 1 } # Au début de l'installation, avant tout travail : option, choix enregistré, ou # question (une seule fois, réponse par défaut : non). choisir_rapport_automatique() { local f reponse ou="" f=$(fichier_choix_rapport) if [ -n "${OPT_RAPPORT:-}" ]; then ecrire_choix_rapport "$OPT_RAPPORT" && ou=", choix enregistré dans $(joli "$f")" if [ "$OPT_RAPPORT" = "oui" ]; then detail "Rapports de problème : envoi automatique en cas d’échec (option --rapport${ou})." else detail "Rapports de problème : aucun (option --sans-rapport${ou})." fi return 0 fi if [ "${RAPPORT_AUTO:-}" = "oui" ]; then detail "Rapports de problème : envoi automatique en cas d’échec (${RAPPORT_RAISON})." return 0 fi [ -z "${RAPPORT_AUTO:-}" ] && tty_disponible || return 0 printf ' %s [o/N] ' "En cas de problème pendant l’installation, envoyer automatiquement un rapport (sans données personnelles) pour aider à améliorer Neo ?" >/dev/tty # Fin de saisie (Ctrl-D) : pas de réponse, rien n'est enregistré. IFS= read -r reponse /dev/tty; return 0; } case "$reponse" in [oOyY]*) RAPPORT_AUTO=oui RAPPORT_RAISON="accord donné au début de l’installation" ;; *) RAPPORT_AUTO=non ;; esac ecrire_choix_rapport "$RAPPORT_AUTO" && ou=" (choix enregistré dans $(joli "$f"))" if [ "$RAPPORT_AUTO" = "oui" ]; then detail "Merci. En cas de problème, un rapport sera envoyé automatiquement${ou}." else detail "Aucun rapport automatique : en cas de problème, l’installeur vous demandera${ou}." fi } # Appelé par nettoyer après un échec. proposer_rapport() { local rc=$1 url contact="" R_FICHIER="" R_TTY=0 R_AUTO=0 [ "${RAPPORT_ACTIF:-0}" = 1 ] && [ "${OPT_RAPPORT:-}" != "non" ] || return 0 case "$rc" in 0|130|143) return 0 ;; esac # réussite ou interruption volontaire url=$(url_rapports) || return 0 command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1 || return 0 R_FICHIER=$(mktemp "$(dossier_temp)/neo-rapport.XXXXXX" 2>/dev/null) || return 0 trap abandonner_rapport INT TERM chmod 600 "$R_FICHIER" 2>/dev/null preparer_rapport "$rc" ecrire_rapport "$R_FICHIER" || { rm -f "$R_FICHIER"; return 0; } tty_disponible && R_TTY=1 # Accord déjà donné (option --rapport, ou réponse « o » au début / choix enregistré, # avec un terminal) : envoi sans seconde question. Sinon, dernière chance. if [ "${OPT_RAPPORT:-}" = "oui" ] || { [ "$R_TTY" = 1 ] && [ "${RAPPORT_AUTO:-}" = "oui" ]; }; then R_AUTO=1 resumer_rapport "$url" envoyer_rapport "$url" elif [ "$R_TTY" = 1 ]; then resumer_rapport "$url" if demander "Envoyer ce rapport pour aider à améliorer Neo ?" n; then printf ' %s ' "Une adresse pour vous répondre (facultatif) :" >/dev/tty IFS= read -r contact /dev/tty; } contact=$(printf '%s' "$contact" | LC_ALL=C tr -d '\000-\037\177' \ | LC_ALL=C sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' | cut -c 1-200) ecrire_rapport "$R_FICHIER" "$contact" envoyer_rapport "$url" else rm -f "$R_FICHIER" dire " Rapport non envoyé : rien n’a quitté votre machine." fi else dire "" dire " Rapport de problème préparé, rien n’a été envoyé. Pour nous l’envoyer après l’avoir relu :" dire " $(commande_envoi "$url" "$R_FICHIER")" fi R_FICHIER="" return 0 } # ----------------------------------------------------------------------------- # uv # ----------------------------------------------------------------------------- assurer_uv() { local candidat v UV="" if [ -x "$PREFIXE/bin/uv" ]; then v=$("$PREFIXE/bin/uv" --version 2>/dev/null /dev/null); then v=$("$candidat" --version 2>/dev/null /dev/null || true fi mv -f "$PREFIXE/bin/.uv.tmp.$$" "$PREFIXE/bin/uv" UV="$PREFIXE/bin/uv" ok "uv $UV_VERSION installé dans $(joli "$PREFIXE/bin") (empreinte vérifiée)" } # Environnement neutre pour uv : pas de configuration ni de Python hérités. preparer_env_uv() { unset UV_PYTHON VIRTUAL_ENV UV_PROJECT_ENVIRONMENT UV_SYSTEM_PYTHON UV_FROZEN \ UV_LOCKED UV_NO_SYNC UV_PYTHON_PREFERENCE CONDA_PREFIX PYTHONHOME PYTHONPATH export UV_NO_CONFIG=1 export UV_PYTHON_DOWNLOADS=automatic export UV_PYTHON_INSTALL_DIR="$PREFIXE/python" export UV_NO_PROGRESS=1 } # Python demandé par l'archive extraite dans $1 : PYTHON_GERE, PYTHON_MIN, PYTHON_MAX. choisir_python() { local v="" [ -f "$1/.python-version" ] && v=$(head -n 1 "$1/.python-version" | tr -d ' \t\r') case "$v" in 3.1[4-9]|3.[2-9][0-9]) PYTHON_GERE=$v PYTHON_MIN=$v PYTHON_MAX=$v ;; *) # Neo 0.1.x (amont 0.17) : requires-python >=3.11,<3.14 PYTHON_GERE="3.12" PYTHON_MIN="3.11" PYTHON_MAX="3.13" ;; esac } python_systeme() { # linux-seulement (sous macOS, /usr/bin/python3 ouvrirait l’installation de Xcode) local c chemin for c in python3.14 python3.13 python3.12 python3.11 python3; do chemin=$(command -v "$c" 2>/dev/null) || continue if "$chemin" -c 'import sys lo, hi = (tuple(int(x) for x in a.split(".")) for a in sys.argv[1:3]) sys.exit(0 if lo <= sys.version_info[:2] <= hi else 1)' "$PYTHON_MIN" "$PYTHON_MAX" \ /dev/null 2>&1; then printf '%s' "$chemin" return 0 fi done return 1 } # uv sync dans $1 (dossier de version). $2 = python (chemin ou version gérée). synchroniser() { local dossier=$1 python=$2 journal=$3 shift 3 local args=(sync --project "$dossier" --no-dev "$@") # --frozen : le verrou publié est installé tel quel. Sa cohérence avec pyproject.toml # est vérifiée au build (uv lock --check) et l'archive par son empreinte. --locked # le revalidait avec l'uv de l'installeur : depuis l'amont 0.22 (exclude-newer # relatif), uv 0.12 le croit périmé et le recalcule (git requis, réseau, échec). [ -f "$dossier/uv.lock" ] && args+=(--frozen) # Bytecode précompilé pour tous (root ou non) : sans lui, le premier lancement # coûte plusieurs secondes de plus (compilation à la volée de milliers de modules). args+=(--compile-bytecode) if [ "${python#/}" = "$python" ]; then UV_PYTHON_PREFERENCE=only-managed UV_PROJECT_ENVIRONMENT="$dossier/venv" \ "$UV" "${args[@]}" --python "$python" >"$journal" 2>&1 else UV_PROJECT_ENVIRONMENT="$dossier/venv" \ "$UV" "${args[@]}" --python "$python" >"$journal" 2>&1 fi } # ----------------------------------------------------------------------------- # Version et archive # ----------------------------------------------------------------------------- determiner_base_url() { if [ -n "${NEO_BASE_URL:-}" ]; then BASE_URL=$NEO_BASE_URL elif [ "${DOMAINE_PUBLIE#@@}" = "$DOMAINE_PUBLIE" ]; then BASE_URL="https://$DOMAINE_PUBLIE" else echec "Cet installeur n’a pas été publié (domaine non renseigné)." \ "Utilisez : curl -fsSL https:///install.sh | bash" \ "ou définissez NEO_BASE_URL=https://." fi while [ "${BASE_URL%/}" != "$BASE_URL" ]; do BASE_URL=${BASE_URL%/}; done # Regex dans une variable : même sens sous bash 3.2 et 5.x. local motif='^https?://[A-Za-z0-9._~:/?#@!$&'\''()*+,;=%-]+$' [[ $BASE_URL =~ $motif ]] \ || echec "Adresse de distribution invalide : $BASE_URL" case "$BASE_URL" in https://*) ;; *) avert "Adresse de distribution non chiffrée ($BASE_URL) : l’empreinte protège l’archive, pas l’installeur." ;; esac } determiner_version() { if [ -n "$OPT_VERSION" ]; then VERSION=$OPT_VERSION return 0 fi telecharger "$BASE_URL/releases/latest.txt" "$TMPD/latest.txt" \ || echec "Impossible de connaître la dernière version de Neo." \ "Adresse : $BASE_URL/releases/latest.txt" \ "Vérifiez la connexion réseau, ou indiquez une version avec --version X." VERSION=$(head -c 64 "$TMPD/latest.txt" | tr -d ' \t\r\n') VERSION=${VERSION#v} version_valide "$VERSION" || echec "Le fichier latest.txt publié est invalide." "Contenu lu : « ${VERSION} »" } telecharger_et_verifier() { local nom="neo-$VERSION.tar.gz" ligne attendu nom_annonce reel entree ARCHIVE="$TMPD/$nom" detail "Téléchargement de ${nom}…" telecharger "$BASE_URL/releases/$nom" "$ARCHIVE" \ || echec "Téléchargement impossible : $BASE_URL/releases/$nom" \ "Vérifiez que la version $VERSION existe et que le réseau est disponible." telecharger "$BASE_URL/releases/$nom.sha256" "$TMPD/$nom.sha256" \ || echec "Empreinte introuvable : $BASE_URL/releases/$nom.sha256" \ "Par sécurité, Neo n’est pas installé sans empreinte vérifiée." ligne=$(head -n 1 "$TMPD/$nom.sha256" | tr -d '\r') attendu=${ligne%%[[:space:]]*} attendu=$(printf '%s' "$attendu" | tr 'A-F' 'a-f') nom_annonce=${ligne##*[[:space:]]} nom_annonce=${nom_annonce#\*} [[ $attendu =~ ^[0-9a-f]{64}$ ]] || echec "Fichier d’empreinte illisible : $nom.sha256" if [ "$nom_annonce" != "$attendu" ] && [ "$nom_annonce" != "$nom" ]; then echec "Le fichier d’empreinte concerne « ${nom_annonce} », pas « ${nom} »." fi reel=$(empreinte "$ARCHIVE") if [ "$reel" != "$attendu" ]; then echec "Empreinte SHA-256 incorrecte : l’archive est corrompue ou a été modifiée." \ "Attendue : $attendu" \ "Obtenue  : $reel" fi ok "Archive $nom téléchargée, empreinte SHA-256 vérifiée" # Contenu attendu : un seul dossier neo-X.Y.Z/, aucun chemin absolu ni « .. ». tar -tzf "$ARCHIVE" >"$TMPD/contenu" 2>/dev/null || echec "Archive illisible : $nom" while IFS= read -r entree; do case "$entree" in "neo-$VERSION"|"neo-$VERSION/"*) ;; *) echec "Contenu d’archive inattendu : $entree" ;; esac case "/$entree/" in */../*) echec "Chemin refusé dans l’archive : $entree" ;; esac done <"$TMPD/contenu" grep -qx "neo-$VERSION/pyproject.toml" "$TMPD/contenu" || echec "Archive incomplète : pyproject.toml absent." } # Construit $PREFIXE/versions/$VERSION (archive vérifiée + environnement Python). construire() { local dossier="$PREFIXE/versions/$VERSION" journal="$TMPD/uv.log" python="" premiere : >"$journal" if [ -e "$dossier" ]; then if [ "$VERSION" = "$COURANTE" ]; then # Réinstallation de la version active : l'ancienne copie est gardée # de côté et restaurée si quoi que ce soit échoue. SAUVEGARDE="$PREFIXE/versions/.$VERSION.ancienne.$$" SAUVEGARDE_CIBLE=$dossier mv "$dossier" "$SAUVEGARDE" else rm -rf "$dossier" # reste d'une installation interrompue fi fi EN_CONSTRUCTION=$dossier mkdir -p "$dossier" tar -xzf "$ARCHIVE" -C "$dossier" --strip-components=1 --no-same-owner # Marque d'origine lue par Neo (neo_cli/config.py, detect_install_method) : # sans elle, une installation par archive serait prise pour un « pip install ». printf 'neo-installeur\n' >"$dossier/.install_method" ok "Fichiers extraits dans $(joli "$dossier")" choisir_python "$dossier" # Essais successifs : Python du système (s'il convient) puis Python géré # par uv ; modules optionnels [all] puis installation de base. local essais=() essai mode reussi=0 if [ "$SYSTEME" = "macos" ]; then # Jamais /usr/bin/python3 : sans les outils de développement Apple, il # ouvre une fenêtre d’installation. Le Python géré par uv ne dépend ni # de Xcode ni de Homebrew. detail "Python $PYTHON_GERE fourni par uv (indépendant du Python de macOS et de Homebrew)" essais=("$PYTHON_GERE|all" "$PYTHON_GERE|base") elif python=$(python_systeme); then detail "Python du système : $python" essais=("$python|all" "$PYTHON_GERE|all" "$PYTHON_GERE|base") else if [ "$PYTHON_MIN" = "$PYTHON_MAX" ]; then detail "Python $PYTHON_GERE fourni par uv (le Python du système n’est pas une version $PYTHON_MIN)" else detail "Python $PYTHON_GERE fourni par uv (le Python du système n’est pas une version $PYTHON_MIN à $PYTHON_MAX)" fi essais=("$PYTHON_GERE|all" "$PYTHON_GERE|base") fi detail "Installation des dépendances (une à trois minutes la première fois)…" for essai in "${essais[@]}"; do python=${essai%|*} mode=${essai##*|} rm -rf "$dossier/venv" if [ "$mode" = "all" ]; then synchroniser "$dossier" "$python" "$journal" --extra all && { reussi=1; break; } else synchroniser "$dossier" "$python" "$journal" && { reussi=1; break; } fi if [ "$essai" != "${essais[${#essais[@]}-1]}" ]; then avert "Échec de l’installation des dépendances (Python $python, $mode) : nouvel essai." fi done if [ "$reussi" = 0 ]; then # Journal conservé hors du dossier d'installation (supprimé si l'installation échoue). local copie copie=$(mktemp "${TMPDIR:-/tmp}/neo-erreur-installation.XXXXXX") && cp -f "$journal" "$copie" || copie=$journal printf '\n' >&2 tail -n 25 "$journal" | sed 's/^/ /' >&2 joindre_au_journal "journal de uv sync" "$journal" 150 echec "L’installation des dépendances Python a échoué." \ "Journal complet : $copie" fi [ "$mode" = "all" ] || avert "Modules optionnels non installés : Neo les installera à la demande." [ -x "$dossier/venv/bin/neo" ] || echec "La commande neo n’a pas été créée dans l’environnement Python." # Précompilation des sources, pour tous : premier lancement plus rapide, et une # installation partagée (root) reste utilisable en lecture seule par les autres. detail "Précompilation des sources Python…" "$dossier/venv/bin/python" -m compileall -q -j 0 -x '/(venv|tests|node_modules)/' "$dossier" \ >"$journal" 2>&1 || true # Contrôle : la commande démarre (dans un NEO_HOME jetable, ~/.neo n'est pas touché). ETAPE_RAPPORT="contrôle de Neo (neo --version)" if ! NEO_HOME="$TMPD/neo-home-controle" NEO_NO_UPDATE_CHECK=1 \ "$dossier/venv/bin/neo" --version "$TMPD/controle.log" 2>&1; then tail -n 15 "$TMPD/controle.log" | sed 's/^/ /' >&2 joindre_au_journal "neo --version" "$TMPD/controle.log" 60 echec "Neo est installé mais ne démarre pas (neo --version a échoué)." fi premiere=$(head -n 1 "$TMPD/controle.log") ok "Environnement Python prêt (${premiere:-neo})" printf 'version=%s\ninstalle_le=%s\nsource=%s\n' "$VERSION" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ "$BASE_URL/releases/neo-$VERSION.tar.gz" >"$dossier/$MARQUEUR_VERSION" EN_CONSTRUCTION="" } # Renomme le lien $1 en $2 sans suivre $2 (lien vers un dossier) : atomique. remplacer_lien() { if [ "$SYSTEME" = "macos" ]; then mv -h -f "$1" "$2" # BSD : -h = ne pas entrer dans le dossier pointé else mv -T -f "$1" "$2" # linux-seulement (GNU) fi } # Bascule atomique du lien « current », mémorise la version précédente. basculer() { if [ -e "$PREFIXE/current" ] && [ ! -L "$PREFIXE/current" ]; then echec "$(joli "$PREFIXE/current") existe et n’est pas un lien : supprimez-le puis relancez." fi if [ -n "$COURANTE" ] && [ "$COURANTE" != "$VERSION" ] && [ -d "$PREFIXE/versions/$COURANTE" ]; then LIEN_TEMP="$PREFIXE/.precedente.tmp.$$" ln -s "versions/$COURANTE" "$LIEN_TEMP" remplacer_lien "$LIEN_TEMP" "$PREFIXE/precedente" fi LIEN_TEMP="$PREFIXE/.current.tmp.$$" ln -s "versions/$VERSION" "$LIEN_TEMP" remplacer_lien "$LIEN_TEMP" "$PREFIXE/current" LIEN_TEMP="" if [ -n "${SAUVEGARDE:-}" ]; then rm -rf "$SAUVEGARDE" SAUVEGARDE="" fi } # Garde la version active et la précédente ; supprime les autres. elaguer() { local garder_a="$VERSION" garder_b="" e nom if [ -L "$PREFIXE/precedente" ]; then garder_b=$(readlink "$PREFIXE/precedente") garder_b=${garder_b##*/} if [ ! -d "$PREFIXE/versions/$garder_b" ] || [ "$garder_b" = "$garder_a" ]; then rm -f "$PREFIXE/precedente" garder_b="" fi fi shopt -s nullglob dotglob for e in "$PREFIXE/versions"/*; do nom=${e##*/} [ "$nom" = "$garder_a" ] && continue [ -n "$garder_b" ] && [ "$nom" = "$garder_b" ] && continue rm -rf "$e" [[ $nom == .* ]] || detail "Ancienne version supprimée : $nom" done shopt -u nullglob dotglob PRECEDENTE=$garder_b } ecrire_lanceur() { local temp mkdir -p "$BIN_DIR" if { [ -e "$LANCEUR" ] || [ -L "$LANCEUR" ]; } && ! est_notre_lanceur "$LANCEUR"; then mv -f "$LANCEUR" "$LANCEUR.avant-neo" avert "Une commande « neo » existait déjà : elle a été renommée $(joli "$LANCEUR.avant-neo")." fi temp="$BIN_DIR/.neo.tmp.$$" { printf '#!/bin/sh\n' printf '%s\n' "# Lanceur Neo, écrit par l’installeur Neo. Ne pas modifier :" printf '# il est régénéré à chaque mise à jour.\n' printf '%s %s\n' "$MARQUEUR_LANCEUR" "$PREFIXE" # shellcheck disable=SC2016 printf 'exec %s "$@"\n' "$(citer_sh "$PREFIXE/current/venv/bin/neo")" } >"$temp" chmod 755 "$temp" mv -f "$temp" "$LANCEUR" } chemin_contient() { case ":${PATH:-}:" in *":$1:"*|*":$1/:"*) return 0 ;; esac return 1 } fichier_rc() { if [ "$SYSTEME" = "macos" ]; then # Terminal et iTerm ouvrent des shells de connexion : ~/.zprofile (zsh) # et le premier de ~/.bash_profile, ~/.bash_login, ~/.profile (bash). # Créer ~/.bash_profile masquerait un ~/.profile existant. local sh=${SHELL:-/bin/zsh} case "${sh##*/}" in zsh) printf '%s' "${ZDOTDIR:-$HOME}/.zprofile" ;; bash) if [ -f "$HOME/.bash_profile" ]; then printf '%s' "$HOME/.bash_profile" elif [ -f "$HOME/.bash_login" ]; then printf '%s' "$HOME/.bash_login" elif [ -f "$HOME/.profile" ]; then printf '%s' "$HOME/.profile" else printf '%s' "$HOME/.bash_profile" fi ;; fish) printf '%s' "$HOME/.config/fish/config.fish" ;; *) printf '%s' "$HOME/.profile" ;; esac return 0 fi case "${SHELL##*/}" in zsh) printf '%s' "${ZDOTDIR:-$HOME}/.zshrc" ;; bash) printf '%s' "$HOME/.bashrc" ;; fish) printf '%s' "$HOME/.config/fish/config.fish" ;; *) printf '%s' "$HOME/.profile" ;; esac } proposer_path() { local rc ligne valeur chemin_contient "$BIN_DIR" && return 0 rc=$(fichier_rc) if [ "$BIN_DIR" = "$HOME/.local/bin" ]; then # shellcheck disable=SC2016 valeur='$HOME/.local/bin' else valeur=$BIN_DIR fi if [ "${rc##*/}" = "config.fish" ]; then ligne="fish_add_path -g \"$valeur\" $MARQUEUR_RC" else ligne="export PATH=\"$valeur:\$PATH\" $MARQUEUR_RC" fi PATH_A_RECHARGER=1 if [ -f "$rc" ] && grep -q "$MARQUEUR_RC\$" "$rc" 2>/dev/null; then detail "$(joli "$BIN_DIR") est déjà ajouté au PATH dans $(joli "$rc") (nouveau terminal nécessaire)." return 0 fi info "" info " $(joli "$BIN_DIR") n’est pas dans votre PATH. Ligne à ajouter à $(joli "$rc") :" info " ${ligne% "$MARQUEUR_RC"}" if demander "Ajouter cette ligne à $(joli "$rc") ?" o; then mkdir -p "$(dirname "$rc")" printf '\n%s\n' "$ligne" >>"$rc" ok "Ligne ajoutée à $(joli "$rc")" else detail "Aucun fichier modifié." fi } # ----------------------------------------------------------------------------- # Options : --complet, --service # ----------------------------------------------------------------------------- installer_complements() { # linux-seulement local paquets=() cmd etape "Outils complémentaires (--complet)" command -v rg >/dev/null 2>&1 || paquets+=(ripgrep) if ! command -v ffmpeg >/dev/null 2>&1; then if [ "$FAMILLE" = "fedora" ]; then paquets+=(ffmpeg-free); else paquets+=(ffmpeg); fi fi if ! command -v node >/dev/null 2>&1; then case "$FAMILLE" in debian|arch) paquets+=(nodejs npm) ;; suse) paquets+=(nodejs-default npm-default) ;; *) paquets+=(nodejs) ;; esac fi if [ ${#paquets[@]} -eq 0 ]; then ok "ripgrep, ffmpeg et Node.js sont déjà présents" return 0 fi cmd=$(commande_paquets "${paquets[*]}") if [ "$FAMILLE" = "autre" ]; then avert "Distribution non reconnue : $cmd" return 0 fi if [ "$EST_ROOT" = 0 ]; then info " sudo va servir uniquement à installer ces paquets système :" info " $cmd" info " Neo lui-même ne garde aucun droit administrateur." if ! command -v sudo >/dev/null 2>&1; then avert "sudo est introuvable. Exécutez cette commande en tant que root, puis relancez Neo." return 0 fi if ! sudo -n true 2>/dev/null; then if ! tty_disponible; then avert "sudo demande un mot de passe et aucun terminal n’est disponible : exécutez la commande ci-dessus vous-même." return 0 fi if ! demander "Continuer avec sudo ?" o; then detail "Paquets non installés." return 0 fi fi else info " Paquets système à installer : ${paquets[*]}" info " $cmd" fi if executer_paquets "${paquets[@]}"; then ok "Installés : ${paquets[*]}" else avert "L’installation de certains paquets a échoué. Neo fonctionne sans eux ; commande à relancer :" avert " $cmd" fi } executer_paquets() { # linux-seulement local s=() entree=/dev/null journal="$TMPD/paquets.log" if [ "$EST_ROOT" = 0 ]; then s=(sudo) tty_disponible && entree=/dev/tty fi detail "Installation des paquets (quelques minutes selon la connexion)…" : >"$journal" if ! case "$FAMILLE" in debian) "${s[@]}" env DEBIAN_FRONTEND=noninteractive apt-get update -qq <"$entree" >>"$journal" 2>&1 \ && "${s[@]}" env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a \ apt-get install -y -qq "$@" <"$entree" >>"$journal" 2>&1 ;; fedora) if command -v dnf >/dev/null 2>&1; then "${s[@]}" dnf install -y "$@" <"$entree" >>"$journal" 2>&1 else "${s[@]}" yum install -y "$@" <"$entree" >>"$journal" 2>&1; fi ;; arch) "${s[@]}" pacman -S --needed --noconfirm "$@" <"$entree" >>"$journal" 2>&1 ;; suse) "${s[@]}" zypper --non-interactive install "$@" <"$entree" >>"$journal" 2>&1 ;; *) false ;; esac; then tail -n 15 "$journal" | sed 's/^/ /' >&2 joindre_au_journal "paquets système" "$journal" 30 return 1 fi } installer_service() { etape "Service de passerelle (--service)" if ! "$LANCEUR" gateway install --help /dev/null 2>&1; then avert "Cette version de Neo ne propose pas « neo gateway install »." return 0 fi if "$LANCEUR" gateway install /dev/null || true) "$LANCEUR" gateway install "$TMPD/passerelle.log" 2>&1 || true apres=$(cksum <"$plist" 2>/dev/null || true) if [ -n "$apres" ] && [ "$apres" != "$avant" ]; then ok "Agent launchd mis à jour, passerelle relancée sur la version $VERSION" elif "$LANCEUR" gateway restart /dev/null 2>&1 || outils+=(ripgrep) command -v ffmpeg >/dev/null 2>&1 || outils+=(ffmpeg) command -v node >/dev/null 2>&1 || outils+=(node) if [ ${#outils[@]} -eq 0 ]; then ok "ripgrep, ffmpeg et Node.js sont déjà présents" return 0 fi if command -v brew >/dev/null 2>&1; then info " Outils absents : ${outils[*]}. Pour les installer avec Homebrew (sans sudo) :" info " brew install ${outils[*]}" else info " Outils absents : ${outils[*]}. Homebrew n’est pas installé : ajoutez-les vous-même si besoin." fi detail "Neo fonctionne sans eux ; rien n’a été installé." } # ----------------------------------------------------------------------------- # Passage à Neo depuis un autre agent (neo migrate) # ----------------------------------------------------------------------------- # Installation faite avec sudo : chaque utilisateur configure Neo, et reprend son # ancien agent, depuis son propre compte. installe_par_sudo() { [ "$EST_ROOT" = 1 ] && [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ] } # --migrate : « neo migrate auto » juste après l'installation, avant « neo setup » (qui # n'a plus rien à demander si la reprise a écrit la configuration). Avec un terminal, Neo # montre l'aperçu et demande confirmation ; sans terminal, --yes applique sans question # et n'écrase jamais une configuration Neo existante. Jamais fatal : Neo est installé. migrer_apres_installation() { etape "Reprise d’un autre agent (neo migrate auto)" if installe_par_sudo; then avert "Installation avec sudo : chaque utilisateur lancera « neo migrate auto » depuis son compte." return 0 fi # Retour arrière vers une version sans la commande : simple avertissement. if ! "$LANCEUR" migrate auto --help /dev/null 2>&1; then avert "Cette version de Neo ne propose pas « neo migrate auto »." return 0 fi local rc=0 # Ctrl+C quitte la reprise, pas l'installeur (même règle que pour neo setup). trap ':' INT if tty_disponible; then "$LANCEUR" migrate auto /dev/tty 2>&1 || rc=$? else "$LANCEUR" migrate auto --yes "$TMPD/autre-agent.txt" 2>/dev/null || return 0 IFS= read -r INDICE_MIGRATION <"$TMPD/autre-agent.txt" || true } afficher_indice_migration() { [ -n "$INDICE_MIGRATION" ] || return 0 info "$INDICE_MIGRATION" info "Pour reprendre ses réglages : neo migrate auto" info "" } # ----------------------------------------------------------------------------- # Installation # ----------------------------------------------------------------------------- installer() { logo etape "Installation de Neo sur $DISTRO_NOM ($ARCH_NOM)" [ "${ROSETTA:-0}" = 1 ] && detail "Terminal lancé sous Rosetta : installation native Apple Silicon." detail "Dossier : $(joli "$PREFIXE") Commande : $(joli "$LANCEUR")" [ -n "$ESPACE_RESUME" ] && detail "$ESPACE_RESUME" [ "$FAMILLE" = "autre" ] && avert "Distribution non reconnue : l’installation continue, les paquets système éventuels seront à installer vous-même." NEO_HOME_EXISTAIT=0 [ -d "$NEO_HOME_EFFECTIF" ] && NEO_HOME_EXISTAIT=1 choisir_rapport_automatique # peut créer le dossier de données (choix enregistré) ETAPE_RAPPORT="préparation du dossier d’installation" [ -d "$PREFIXE" ] || PREFIXE_CREE=1 mkdir -p "$PREFIXE/versions" 2>/dev/null \ || echec "Impossible de créer $(joli "$PREFIXE")." "Choisissez un dossier accessible en écriture avec --prefixe DIR." [ -w "$PREFIXE" ] || echec "$(joli "$PREFIXE") n’est pas accessible en écriture." [ -f "$PREFIXE/$MARQUEUR_PREFIXE" ] || printf '%s\n' "Dossier géré par l’installeur Neo." >"$PREFIXE/$MARQUEUR_PREFIXE" if command -v flock >/dev/null 2>&1; then # repli : flock absent de macOS exec 9>"$PREFIXE/.verrou" flock -n 9 || echec "Une autre installation de Neo est en cours dans $(joli "$PREFIXE")." # repli fi COURANTE=$(version_courante) || COURANTE="" INSTALLATION_COMMENCEE=1 if [ -n "$OPT_PREFIXE" ] && ANCIEN=$(prefixe_du_lanceur "$LANCEUR") && [ "$ANCIEN" != "$PREFIXE" ]; then avert "La commande neo pointait vers $(joli "$ANCIEN") : elle pointera désormais vers $(joli "$PREFIXE")." avert "Pour retirer l’ancienne copie : bash install.sh --desinstaller --prefixe $(joli "$ANCIEN")" fi ETAPE_RAPPORT="version à installer" determiner_base_url determiner_version if [ -n "$COURANTE" ] && [ "$COURANTE" != "$VERSION" ] && version_saine "$VERSION"; then ok "Neo $VERSION est déjà présent dans $(joli "$PREFIXE/versions") : activation sans téléchargement" else if [ "$COURANTE" = "$VERSION" ]; then detail "Neo $VERSION est déjà la version active : réinstallation." elif [ -n "$COURANTE" ]; then detail "Mise à jour : $COURANTE → $VERSION" fi ETAPE_RAPPORT="téléchargement de Neo" telecharger_et_verifier preparer_env_uv ETAPE_RAPPORT="uv (gestionnaire Python)" assurer_uv ETAPE_RAPPORT="dépendances Python (uv sync)" construire fi ETAPE_RAPPORT="activation de la version" ANNONCE_ECHEC=0 basculer ecrire_lanceur elaguer ok "Commande installée : $(joli "$LANCEUR")" [ -n "$PRECEDENTE" ] && detail "Version précédente conservée pour un retour arrière : $PRECEDENTE" ETAPE_RAPPORT="finalisation (PATH, passerelle, configuration)" PATH_A_RECHARGER=0 [ "$EST_ROOT" = 0 ] && proposer_path hash -r 2>/dev/null || true local autre autre=$(command -v neo 2>/dev/null || true) if [ -n "$autre" ] && [ "$autre" != "$LANCEUR" ] && chemin_contient "$BIN_DIR"; then avert "Une autre commande « neo » passe avant celle-ci dans votre PATH : $autre" fi if unite_service_presente; then etape "Redémarrage de la passerelle" if [ "$SYSTEME" = "macos" ]; then redemarrer_passerelle_macos elif "$LANCEUR" gateway restart /dev/tty 2>&1; then configure=1 else avert "La configuration n’a pas abouti. Vous pourrez la reprendre avec : neo setup" fi trap 'exit 130' INT fi fi [ "$OPT_SERVICE" = 1 ] && installer_service_apres_migration printf '\n%sNeo %s est installé.%s\n' "$GRAS" "$VERSION" "$RAZ" if [ "$SYSTEME" = "macos" ] && [ "$ARCH" = "x86_64" ]; then info " ${JAUNE}!${RAZ} Mac Intel : installation pas encore confirmée sur une vraie machine. Signalez tout problème." fi detail "Programme : $(joli "$PREFIXE/versions/$VERSION")" if [ "$NEO_HOME_EXISTAIT" = 1 ]; then detail "Données  : $(joli "$NEO_HOME_EFFECTIF") (conservées)" elif [ -d "$NEO_HOME_EFFECTIF" ]; then detail "Données  : $(joli "$NEO_HOME_EFFECTIF")" else detail "Données  : $(joli "$NEO_HOME_EFFECTIF") (créé au premier lancement)" fi if [ "$PATH_A_RECHARGER" = 1 ]; then info "" info " Ouvrez un nouveau terminal, ou exécutez : export PATH=\"$BIN_DIR:\$PATH\"" fi info "" if [ "$configure" = 1 ]; then info "Pour commencer :" info " neo discuter avec Neo" info " neo setup modifier la configuration" else info "Prochaines étapes :" info " neo setup choisir le modèle et renseigner vos clés" info " neo discuter avec Neo" fi if [ "$OPT_SERVICE" = 0 ] || [ "$SERVICE_SAUTE" = 1 ]; then info " neo gateway install lancer la passerelle de messagerie en service" fi info "" afficher_indice_migration } # ----------------------------------------------------------------------------- # Désinstallation # ----------------------------------------------------------------------------- # Retire les lignes marquées sans « sed -i » (syntaxe différente sous BSD) ; # le fichier garde son inode, ses droits et un éventuel lien symbolique. retirer_ligne_path() { local temp temp=$(mktemp "$TMPD/rc.XXXXXX") || return 1 if sed "/$MARQUEUR_RC\$/d" "$1" >"$temp"; then cat "$temp" >"$1" fi rm -f "$temp" } desinstaller() { local trouve=0 f etape "Désinstallation de Neo" if [ -d "$PREFIXE" ] && { [ -f "$PREFIXE/$MARQUEUR_PREFIXE" ] || [ -d "$PREFIXE/versions" ]; }; then trouve=1 fi est_notre_lanceur "$LANCEUR" && trouve=1 if [ "$trouve" = 1 ]; then if unite_service_presente && [ -x "$LANCEUR" ]; then if "$LANCEUR" gateway uninstall /dev/null 2>&1; then ok "Service de passerelle retiré" else avert "Le service de passerelle n’a pas pu être retiré (neo gateway uninstall)." fi fi if est_notre_lanceur "$LANCEUR"; then rm -f "$LANCEUR" ok "Commande retirée : $(joli "$LANCEUR")" if [ -e "$LANCEUR.avant-neo" ]; then mv -f "$LANCEUR.avant-neo" "$LANCEUR" detail "Ancienne commande « neo » restaurée." fi fi if [ -d "$PREFIXE" ] && { [ -f "$PREFIXE/$MARQUEUR_PREFIXE" ] || [ -d "$PREFIXE/versions" ]; }; then rm -rf "$PREFIXE" ok "Programme retiré : $(joli "$PREFIXE")" fi if [ -n "${HOME:-}" ]; then local rcs=("$HOME/.bashrc" "$HOME/.zshrc" "${ZDOTDIR:-$HOME}/.zshrc" "$HOME/.profile" "$HOME/.config/fish/config.fish") if [ "$SYSTEME" = "macos" ]; then rcs+=("$HOME/.zprofile" "${ZDOTDIR:-$HOME}/.zprofile" "$HOME/.bash_profile" "$HOME/.bash_login") fi for f in "${rcs[@]}"; do if [ -f "$f" ] && grep -q "$MARQUEUR_RC\$" "$f" 2>/dev/null; then if [ "$SYSTEME" = "macos" ]; then retirer_ligne_path "$f" else sed -i "/$MARQUEUR_RC\$/d" "$f" # linux-seulement (GNU) fi ok "Ligne PATH retirée de $(joli "$f")" fi done fi else info " Aucune installation de Neo trouvée dans $(joli "$PREFIXE")." fi if [ "$OPT_PURGER" = 1 ]; then if [ -d "$NEO_HOME_EFFECTIF" ]; then case "$NEO_HOME_EFFECTIF" in ""|/|"${HOME:-/}"|"${HOME:-/}/") echec "Dossier de données refusé : $NEO_HOME_EFFECTIF" ;; esac rm -rf "$NEO_HOME_EFFECTIF" ok "Données supprimées : $(joli "$NEO_HOME_EFFECTIF")" fi if [ "$EST_ROOT" = 1 ]; then detail "Les dossiers ~/.neo des autres utilisateurs n’ont pas été touchés." fi elif [ -d "$NEO_HOME_EFFECTIF" ]; then info "" info " Vos données sont conservées dans $(joli "$NEO_HOME_EFFECTIF")." info " Pour les supprimer aussi : relancez avec --desinstaller --purger" fi printf '\n%sNeo est désinstallé.%s\n' "$GRAS" "$RAZ" } # ----------------------------------------------------------------------------- main() { constantes init_affichage analyser_arguments "$@" umask 022 EN_CONSTRUCTION="" SAUVEGARDE="" SAUVEGARDE_CIBLE="" LIEN_TEMP="" TMPD="" INSTALLATION_COMMENCEE=0 ANNONCE_ECHEC=1 PRECEDENTE="" PATH_A_RECHARGER=0 PREFIXE_CREE=0 COURANTE="" ANCIEN="" SYSTEME="" ARCH_NOM="" ROSETTA=0 # Rapport de problème : copie des messages, étape en cours, commande en échec. ECHEC_MESSAGE="" ETAPE_RAPPORT="détection du système" ERR_COMMANDE="" ERR_FONCTION="" RAPPORT_AUTO="" RAPPORT_RAISON="" RAPPORT_SANS_JOURNAL=0 ESPACE_RESUME="" INDICE_MIGRATION="" NEO_HOME_EXISTAIT=0 MIGRATION_RC="" SERVICE_SAUTE=0 case "$OPT_RAPPORT" in oui) RAPPORT_AUTO=oui RAPPORT_RAISON="option --rapport" ;; non) RAPPORT_AUTO=non ;; *) lire_choix_rapport ;; esac JOURNAL_RAPPORT=$(mktemp "$(dossier_temp)/neo-journal.XXXXXX" 2>/dev/null) || JOURNAL_RAPPORT="" RAPPORT_ACTIF=1 trap nettoyer EXIT trap 'exit 130' INT trap 'exit 143' TERM # Commande qui arrête l'installeur (set -e) : notée pour le rapport, rien d'autre. set -E trap 'ERR_COMMANDE=$BASH_COMMAND ERR_FONCTION=${FUNCNAME[0]:-}' ERR detecter_plateforme ETAPE_RAPPORT="emplacements" determiner_emplacements ETAPE_RAPPORT="outils requis" verifier_prerequis if [ "$OPT_DESINSTALLER" = 0 ]; then ETAPE_RAPPORT="espace disque" verifier_espace # avant le dossier temporaire : rien n'est encore écrit fi ETAPE_RAPPORT="dossier temporaire" TMPD=$(mktemp -d "${TMPDIR:-/tmp}/neo-installation.XXXXXX") if [ "$OPT_DESINSTALLER" = 1 ]; then ETAPE_RAPPORT="désinstallation" desinstaller else installer fi } main "$@"