Une machine, une question binaire : le chemin que le MENU emprunte aboutit-il sur un système déclaratif. La commande d'installation est celle du menu, prise telle quelle — un test qui installerait par ses propres soins prouverait SON chemin, pas celui du produit, et c'est là que les pannes se cachaient. Le verdict est l'ÉTAT de la machine, non un code de retour : « nixos-rebuild » rend 4 quand une unité n'a pas redémarré alors que le système est activé. Il vit dans long_test/ parce qu'il crée une VM et prend des heures ; le lanceur unitaire doit rester lançable en secondes. --- EN --- One machine, one binary question: does the path the MENU takes succeed on a declarative system. The install command is the menu's, taken as is — a test installing by its own means would prove ITS path, not the product's, and that is where the failures hid. The verdict is the machine's STATE, not an exit code: "nixos-rebuild" returns 4 when a unit failed to restart while the system is active. It lives in long_test/ because it creates a VM and takes hours; the unit runner must stay runnable in seconds. Assisted-by: Claude Opus 5
179 lines
6.5 KiB
Python
179 lines
6.5 KiB
Python
#!/usr/bin/env python3
|
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
"""Poser un paquet, une seule fois pour tout le CLI.
|
|
|
|
Quatre familles de gestionnaires, et trois choses qui changent de l'une à
|
|
l'autre : la COMMANDE, le NOM du paquet, et le BINAIRE qu'il pose. Chaque
|
|
appelant qui refaisait ce travail chez lui en couvrait un sous-ensemble
|
|
différent — d'où un CLI où l'on pouvait installer virt-viewer sous openSUSE
|
|
mais pas un navigateur ni lm-sensors.
|
|
|
|
Deux règles que la composante tient à la place des appelants :
|
|
|
|
- l'ID de /etc/os-release décide AVANT le PATH. Une machine peut porter
|
|
plusieurs gestionnaires — un poste Debian avec dnf installé pour construire
|
|
un paquet — et c'est l'ID qui dit lequel gouverne le système ;
|
|
- la commande s'affiche AVANT la question. On approuve ce qu'on a lu, pas un
|
|
« (y/N) » posé à l'aveugle.
|
|
"""
|
|
|
|
import shlex
|
|
import shutil
|
|
|
|
from script.todo.todo_i18n import t
|
|
|
|
# Les familles, dans l'ordre où le PATH est interrogé quand l'ID de la
|
|
# distribution est inconnu.
|
|
FAMILIES = ("apt-get", "dnf", "pacman", "zypper")
|
|
|
|
# Le préfixe de commande de chaque famille ; les paquets s'ajoutent au bout.
|
|
# Tous non interactifs : un menu qui rend la main à un prompt apt bloque.
|
|
_INSTALL = {
|
|
"apt-get": ("sudo", "apt-get", "install", "-y"),
|
|
"dnf": ("sudo", "dnf", "install", "-y"),
|
|
"pacman": ("sudo", "pacman", "-S", "--needed", "--noconfirm"),
|
|
"zypper": ("sudo", "zypper", "--non-interactive", "install"),
|
|
}
|
|
|
|
# ID de /etc/os-release -> famille. Les dérivées sont nommées explicitement :
|
|
# ID_LIKE existe mais manque ou ment sur assez de distributions pour qu'on ne
|
|
# s'y fie pas seul, et le repli par le PATH couvre ce qui n'est pas listé.
|
|
_OS_ID_FAMILY = {
|
|
"ubuntu": "apt-get",
|
|
"debian": "apt-get",
|
|
"linuxmint": "apt-get",
|
|
"pop": "apt-get",
|
|
"raspbian": "apt-get",
|
|
"fedora": "dnf",
|
|
"rhel": "dnf",
|
|
"centos": "dnf",
|
|
"almalinux": "dnf",
|
|
"rocky": "dnf",
|
|
"arch": "pacman",
|
|
"manjaro": "pacman",
|
|
"endeavouros": "pacman",
|
|
"opensuse": "zypper",
|
|
"opensuse-leap": "zypper",
|
|
"opensuse-tumbleweed": "zypper",
|
|
"sles": "zypper",
|
|
}
|
|
|
|
|
|
def os_id() -> str:
|
|
"""L'ID de la distribution hôte, ou '' si /etc/os-release est illisible."""
|
|
try:
|
|
with open("/etc/os-release", encoding="utf-8") as fh:
|
|
for line in fh:
|
|
if line.startswith("ID="):
|
|
return line.split("=", 1)[1].strip().strip('"').lower()
|
|
except OSError:
|
|
pass
|
|
return ""
|
|
|
|
|
|
def family() -> str | None:
|
|
"""La famille qui gouverne cette machine, ou None si aucune n'est connue.
|
|
|
|
L'ID de la distribution d'abord, le PATH ensuite : sur une machine qui
|
|
porte deux gestionnaires, l'ID dit lequel possède le système.
|
|
"""
|
|
connue = _OS_ID_FAMILY.get(os_id())
|
|
if connue and shutil.which(connue):
|
|
return connue
|
|
for candidate in FAMILIES:
|
|
if shutil.which(candidate):
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def conseil_sans_gestionnaire() -> str:
|
|
"""Ce qu'on peut faire là où aucune famille impérative ne gouverne, ou "".
|
|
|
|
« Aucun gestionnaire connu » est VRAI sur NixOS et n'y mène nulle part :
|
|
rien ne s'y installe par une commande, tout s'y déclare. Le dire seul
|
|
ferme la porte ; nommer les deux gestes qui marchent l'ouvre — le shell
|
|
jetable pour essayer, la déclaration pour ce qui reste.
|
|
|
|
Vide ailleurs : sur une machine dont on ne sait rien, inventer un conseil
|
|
vaut moins que de se taire.
|
|
"""
|
|
if os_id() != "nixos":
|
|
return ""
|
|
return t(
|
|
"NixOS: nix-shell -p <package> for one shell,"
|
|
" or declare it in /etc/nixos/configuration.nix"
|
|
)
|
|
|
|
|
|
def install_command(paquets, famille=None) -> list | None:
|
|
"""La commande d'installation, en liste d'arguments. None si personne.
|
|
|
|
`paquets` est une liste de noms, ou un dict {famille: liste} quand le
|
|
paquet ne porte pas le même nom partout (lm-sensors chez Debian,
|
|
lm_sensors chez Fedora et Arch).
|
|
"""
|
|
famille = famille or family()
|
|
if not famille:
|
|
return None
|
|
if isinstance(paquets, dict):
|
|
paquets = paquets.get(famille) or []
|
|
paquets = [paquets] if isinstance(paquets, str) else list(paquets)
|
|
if not paquets:
|
|
return None
|
|
return list(_INSTALL[famille]) + paquets
|
|
|
|
|
|
def resolve(binaires, commun=None, par_famille=None, famille=None):
|
|
"""(paquets, inconnus) pour les binaires demandés.
|
|
|
|
Le nom du binaire n'est presque jamais celui du paquet : `commun` donne la
|
|
correspondance quand elle vaut pour les quatre familles, `par_famille` la
|
|
corrige là où elle diverge. Les paquets sortent dédoublonnés et dans
|
|
l'ordre demandé — trois binaires d'un même paquet ne le demandent qu'une
|
|
fois. `inconnus` liste ce pour quoi aucun paquet n'est déclaré : à dire,
|
|
jamais à deviner.
|
|
"""
|
|
famille = famille or family()
|
|
surcharge = (par_famille or {}).get(famille, {})
|
|
commun = commun or {}
|
|
paquets, inconnus = [], []
|
|
for binaire in binaires:
|
|
paquet = surcharge.get(binaire) or commun.get(binaire)
|
|
if not paquet:
|
|
inconnus.append(binaire)
|
|
elif paquet not in paquets:
|
|
paquets.append(paquet)
|
|
return paquets, inconnus
|
|
|
|
|
|
def show_and_ask(cmd, question, is_yes, prefix=" ") -> bool:
|
|
"""Afficher la commande, PUIS demander. True si l'opérateur accepte.
|
|
|
|
L'ordre est le fond de l'affaire : une question posée avant la commande
|
|
fait approuver à l'aveugle. Centralisé ici pour qu'aucun appelant ne
|
|
puisse l'inverser.
|
|
"""
|
|
lisible = cmd if isinstance(cmd, str) else shlex.join(cmd)
|
|
print(f"{prefix}{t('Will execute:')} {lisible}")
|
|
return bool(is_yes(input(question)))
|
|
|
|
|
|
def ask_and_install(execute, cmd, question, is_yes, prefix=" "):
|
|
"""Proposer puis lancer. Rend le code de sortie, ou None si rien n'a été
|
|
lancé — refus de l'opérateur, ou aucune commande à proposer.
|
|
|
|
`execute` est le lanceur du CLI : exec_command_live REND le code de sortie
|
|
et ne lève rien, donc l'appelant doit tester ce qui revient.
|
|
"""
|
|
if not cmd:
|
|
print(f"{prefix}⚠ {t('no known package manager here.')}")
|
|
conseil = conseil_sans_gestionnaire()
|
|
if conseil:
|
|
print(f"{prefix} {conseil}")
|
|
return None
|
|
if not show_and_ask(cmd, question, is_yes, prefix=prefix):
|
|
print(t("Nothing to do."))
|
|
return None
|
|
lisible = cmd if isinstance(cmd, str) else shlex.join(cmd)
|
|
return execute.exec_command_live(lisible, source_erplibre=False)
|