From 0b8f25fb4bb2586e6095e5ab71e9f1e56d651eee Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 31 Aug 2026 06:17:48 -0400 Subject: [PATCH 01/17] [ADD] script todo : installer les hooks git depuis le menu Git MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un commit-msg non installé, c'est le garde-fou de convention qui ne tourne jamais, et la pose restait une commande à recopier. git saute sans rien dire un hook privé du bit d'exécution : l'installation le remet. exec_command_live ne passe aucun cwd et ce dépôt porte 128 dépôts imbriqués : sans « git -C racine », un lancement depuis un addon y écrivait core.hooksPath et laissait la racine sans garde-fou. Et comme cette fonction retourne le code sans jamais lever, un « fatal: not in a git directory » annonçait « hooks installés ». Vérifié : 4114 tests verts ; le nouveau garde tombe sur un dispatch échangé comme sur une entrée sans branche. --- EN --- An uninstalled commit-msg is the convention guard never running, and installing it stayed a command to copy by hand. git skips a hook without the execution bit and says nothing: installing restores it. exec_command_live passes no cwd and this checkout holds 128 nested repositories: without the "git -C root", a launch from an addon wrote core.hooksPath there and left the root unguarded. And since that function returns the exit code without ever raising, a "fatal: not in a git directory" still announced "hooks installed". Checked: 4114 tests green; the new guard fails on a swapped dispatch as well as on an entry with no branch. Assisted-by: Claude Opus 5 --- script/todo/todo.py | 59 +++++++++++++++++++++++++++++++++++++++- script/todo/todo_i18n.py | 29 ++++++++++++++++++++ test/test_todo_menu.py | 23 ++++++++++++++++ 3 files changed, 110 insertions(+), 1 deletion(-) diff --git a/script/todo/todo.py b/script/todo/todo.py index aa1dd48..0755c1f 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -2524,11 +2524,21 @@ class TODO( if cmd_no_found: print(t("Command not found !")) + # Les hooks que le dépôt fournit. git saute silencieusement un hook qui + # ne porte pas le bit d'exécution, d'où la vérification à l'installation. + _GIT_HOOKS = ("commit-msg", "pre-commit") + _GIT_HOOKS_PATH = os.path.join("script", "git", "hooks") + def prompt_execute_git(self): print(f"🤖 {t('Git management tools!')}") choices = [ {"prompt_description": t("Local git server")}, {"prompt_description": t("Add a remote to a local repository")}, + { + "prompt_description": t( + "Install git hooks (commit-msg, pre-commit)" + ) + }, ] # Append config-driven entries @@ -2547,6 +2557,8 @@ class TODO( self.prompt_execute_git_local_server() elif status == "2": self._git_add_remote() + elif status == "3": + self._git_install_hooks() else: cmd_no_found = True try: @@ -2579,6 +2591,51 @@ class TODO( except Exception as e: print(f"{t('Error adding remote: ')}{e}") + def _git_install_hooks(self): + """Pointer core.hooksPath sur les hooks du dépôt. + + Le bit d'exécution fait partie de l'installation : sans lui git + ignore le hook sans rien dire, et le garde-fou du message de commit + passe inaperçu. + """ + racine = self._claude_context_root() + absolu = os.path.join(racine, self._GIT_HOOKS_PATH) + if not os.path.isdir(absolu): + print(f"{t('Hooks directory is missing: ')}{absolu}") + return + actuel = self._git_hooks_path(racine) + if actuel and actuel != self._GIT_HOOKS_PATH: + print(f"{t('Another hooks path is already set: ')}{actuel}") + if not self._is_yes(input(t("Replace it? (y/Y): "))): + print(t("Nothing to do.")) + return + for hook in self._GIT_HOOKS: + chemin = os.path.join(absolu, hook) + if os.path.isfile(chemin) and not os.access(chemin, os.X_OK): + os.chmod(chemin, os.stat(chemin).st_mode | 0o111) + print(f"{t('Execution bit added: ')}{hook}") + # « -C racine » et non le cwd : lancé depuis un dépôt imbriqué + # (odoo18.0/addons/…), git écrirait core.hooksPath là-bas et la + # racine resterait sans garde-fou, sans le moindre message. + cmd = ( + f"git -C {shlex.quote(racine)} config" + f" core.hooksPath {self._GIT_HOOKS_PATH}" + ) + print(f"{t('Will execute:')} {cmd}") + # exec_command_live RETOURNE le code de sortie, il ne lève rien : sans + # ce test, un « fatal: not in a git directory » annonçait quand même + # « Hooks git installés! ». Le rapport qui suit ne rattrape pas, il + # relit le bit d'exécution et non core.hooksPath. + status = self.execute.exec_command_live(cmd, source_erplibre=False) + if status: + print(f"{t('Error installing hooks: ')}{status}") + return + print(t("Git hooks installed!")) + for hook in self._GIT_HOOKS: + pose = os.access(os.path.join(absolu, hook), os.X_OK) + marque = t("hook installed") if pose else t("hook not installed") + print(f" {hook:<26} {marque}") + def prompt_execute_git_local_server(self): print(f"🤖 {t('Manage local git repository server!')}") choices = [ @@ -2879,7 +2936,7 @@ class TODO( ) if chemin_hooks: absolu = os.path.join(racine, chemin_hooks) - for hook in ("commit-msg", "pre-commit"): + for hook in self._GIT_HOOKS: pose = os.access(os.path.join(absolu, hook), os.X_OK) marque = ( t("hook installed") if pose else t("hook not installed") diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 21fdc67..1790373 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -883,6 +883,35 @@ TRANSLATIONS = { "fr": "Erreur lors de l'ajout du remote : ", "en": "Error adding remote: ", }, + # Git hooks + "Install git hooks (commit-msg, pre-commit)": { + "fr": "Installer les hooks git (commit-msg, pre-commit)", + "en": "Install git hooks (commit-msg, pre-commit)", + }, + "Hooks directory is missing: ": { + "fr": "Le répertoire des hooks est absent : ", + "en": "Hooks directory is missing: ", + }, + "Another hooks path is already set: ": { + "fr": "Un autre chemin de hooks est déjà configuré : ", + "en": "Another hooks path is already set: ", + }, + "Replace it? (y/Y): ": { + "fr": "Le remplacer? (y/Y) : ", + "en": "Replace it? (y/Y): ", + }, + "Execution bit added: ": { + "fr": "Bit d'exécution ajouté : ", + "en": "Execution bit added: ", + }, + "Git hooks installed!": { + "fr": "Hooks git installés!", + "en": "Git hooks installed!", + }, + "Error installing hooks: ": { + "fr": "Erreur lors de l'installation des hooks : ", + "en": "Error installing hooks: ", + }, # Git config vim "Configure git local editor to vim": { "fr": "Configuration git local par vim", diff --git a/test/test_todo_menu.py b/test/test_todo_menu.py index 5c38677..f6a5aa5 100644 --- a/test/test_todo_menu.py +++ b/test/test_todo_menu.py @@ -448,6 +448,29 @@ class TestProxmoxMenuNumbering(MenuCoherence, unittest.TestCase): } +class TestGitMenuNumbering(MenuCoherence, unittest.TestCase): + """Le menu Git, le seul dont todo.json suit des entrées codées en dur. + + Ses trois premières entrées sont écrites à la main, les suivantes viennent + de `git_from_makefile` et le repli générique les renumérote tout seul : + ajouter « Installer les hooks git » en [3] a poussé les deux entrées de + todo.json en [4] et [5] sans que rien ne le dise. Une quatrième entrée + codée en dur, oubliée dans le dispatch, ferait lancer à [4] la commande + du voisin sous le libellé attendu. + """ + + SOURCE = TODO_DIR / "todo.py" + ENTRY = "def prompt_execute_git(self):" + END = "def _git_install_hooks(self):" + MINIMUM = 2 + + EXPECTED = { + "Local git server": "prompt_execute_git_local_server", + "Add a remote to a local repository": "_git_add_remote", + "Install git hooks": "_git_install_hooks", + } + + class TestMenuLabels(unittest.TestCase): """Toute méthode de menu doit avoir son étiquette de fil d'Ariane. From 40a1e178f34ba61ada2652abdd884e8e7343f51a Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 31 Aug 2026 07:17:22 -0400 Subject: [PATCH 02/17] =?UTF-8?q?[FIX]=20qemu=20manage=20:=20ni=20adresse,?= =?UTF-8?q?=20ni=20nom=20de=20VM,=20ni=20r=C3=A9cit=20en=20commentaire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les commentaires suivent le dépôt en amont : une adresse ou un nom de machine qui s'y trouve devient public. Cinq adresses et deux noms de VM y étaient, plus cinq phrases racontant la séance où le défaut est apparu. Le mode de défaillance reste, au présent — « une VM renommée se voit attribuer la passerelle » — et l'incident part. Un relevé d'un seul jour part avec lui ; la structure durable reste. check_comment_hygiene.py passe de 5 identifiants et 5 à relire à zéro. Aucune ligne de code n'est touchée, seulement du texte de commentaire. --- EN --- Comments follow the repository upstream: an address or a machine name sitting in one becomes public. Five addresses and two VM names were there, plus five sentences telling the session where the fault appeared. The failure mode stays, in the present — "a renamed VM is given the gateway" — and the incident goes. A reading taken on one day goes with it; the durable structure stays. check_comment_hygiene.py goes from 5 identifiers and 5 to re-read down to zero. No line of code is touched, only comment prose. Assisted-by: Claude Opus 5 --- script/todo/qemu_manage.py | 46 +++++++++++++++++--------------------- 1 file changed, 20 insertions(+), 26 deletions(-) diff --git a/script/todo/qemu_manage.py b/script/todo/qemu_manage.py index 04f4694..30ba3bd 100644 --- a/script/todo/qemu_manage.py +++ b/script/todo/qemu_manage.py @@ -45,12 +45,11 @@ def parse_ssh_blocks(content) -> dict: def ssh_orphans(blocs, juge, prefixe="erplibre-"): """(gardées, orphelines) — chacune [(nom, raison)]. - Un ProxyJump valait preuve de vie À LUI SEUL : « écrite pour une VM - imbriquée, que virsh ne connaîtra jamais ». Le raisonnement oubliait que - le rebond, lui, peut avoir disparu. Vécu : la VM Proxmox locale effacée, - le nettoyage a retiré son entrée — correctement — et GARDÉ les trois - entrées qui rebondissaient par elle. Trois culs-de-sac, présentés comme - « mènent encore quelque part ». + Un ProxyJump ne vaut pas preuve de vie à lui seul : « écrite pour une VM + imbriquée, que virsh ne connaîtra jamais » oublie que le rebond, lui, peut + avoir disparu. Effacer la VM qui servait de rebond retire son entrée — + correctement — et laisse celles qui rebondissaient par elle : des + culs-de-sac présentés comme « mènent encore quelque part ». D'où le point fixe : retirer un parent peut orpheliner ses enfants, et ceux-ci peuvent en orpheliner d'autres. On tourne jusqu'à ce que plus @@ -887,11 +886,9 @@ class QemuManageMixin: """{chemin: domaine} — TOUT ce que les domaines référencent. L'autorité est libvirt, jamais le nom du fichier. Un domaine renommé - garde le nom de fichier d'avant : juger sur le nom faisait passer le - disque d'une VM EN MARCHE pour un orphelin. Rapporté sur - « erplibre-ubuntu-2404-MIGRATION », renommée depuis - « erplibre-ubuntu-2404 » : le nettoyage offrait ses trois fichiers — - disque de 63 Go, seed, nvram — au « rm -f ». + garde le nom de fichier d'avant : juger sur le nom fait passer le + disque d'une VM EN MARCHE pour un orphelin, et offre ses trois + fichiers — disque, seed, nvram — au « rm -f ». Les deux vues, persistante et vivante, pour la raison dite dans `_qemu_dumpxml`. @@ -2248,12 +2245,10 @@ class QemuManageMixin: """Adresses IPv4 de L'HÔTE, à écarter des candidates d'une VM. « virsh domifaddr --source arp » remonte la table ARP, où figurent les - passerelles des ponts libvirt (192.168.122.1, 192.168.123.1…). Une VM - n'a jamais l'adresse de son hôte : sans ce filtre, une VM RENOMMÉE — - dont le bail porte encore l'ancien nom d'hôte, donc sans - correspondance — se voyait attribuer la passerelle. Vécu sur - « erplibre-ubuntu-2404-MIGRATION », annoncée en 192.168.122.1 au lieu - de 192.168.123.170. + passerelles des ponts libvirt. Une VM n'a jamais l'adresse de son + hôte : sans ce filtre, une VM RENOMMÉE — dont le bail porte encore + l'ancien nom d'hôte, donc sans correspondance — se voit attribuer la + passerelle. """ try: res = subprocess.run( @@ -2304,9 +2299,9 @@ class QemuManageMixin: - agent : qemu-guest-agent DANS la VM (voit l'IP réelle même quand le bail dnsmasq est absent) ; - arp : table ARP de l'hôte (VM active sur le réseau). - On combine pour ne jamais rater une IP que le bail seul manquerait - (cas observé : 30 VM émulées, bail dnsmasq vide alors que la VM a une - IP).""" + On combine pour ne jamais rater une IP que le bail seul manquerait : + sous forte charge, le bail dnsmasq reste vide alors que la VM a bien + une adresse.""" ips = [] siennes = QemuManageMixin._qemu_host_addresses() for source in ("lease", "agent", "arp"): @@ -2396,8 +2391,8 @@ class QemuManageMixin: # Sans correspondance de nom d'hôte — le cas d'une VM RENOMMÉE, dont # le bail porte encore l'ancien nom — on prend la source la plus # sûre : le bail, puis l'agent, puis la table ARP. Celle-ci contient - # les passerelles des ponts, et « la dernière candidate » y tombait : - # la VM était annoncée en 192.168.122.1. + # les passerelles des ponts, où « la dernière candidate » tombe : la + # VM se voit alors annoncée avec l'adresse de sa passerelle. for source in ("lease", "agent", "arp"): if par_source.get(source): return par_source[source][-1] @@ -2549,10 +2544,9 @@ class QemuManageMixin: tourne dans la VM — install_proxmox.sh, les scripts d'installation, le Makefile — vient donc de là. - Vécu deux fois de suite. Un correctif de install_proxmox.sh, commité - ici, absent du distant : chaque VM déployée ensuite recevait l'ancien - script, et le défaut « revenait » alors qu'il était corrigé. Rien ne - le disait ; il a fallu comparer les deux versions à la main. + Un correctif commité ici mais pas poussé ne part donc pas : chaque VM + déployée ensuite reçoit l'ancien script, et le défaut « revient » + alors qu'il est corrigé. Rien ne le signale, d'où ce décompte. """ if not branche: return 0, [] From 87ad8c443934361b81e20ff8cbf851b6a1f8e58d Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 31 Aug 2026 07:18:13 -0400 Subject: [PATCH 03/17] [ADD] script todo : installer les outils manquants, les quatre familles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La réduction sûre listait les outils manquants et s'arrêtait là. Trois écritures séparées savaient installer, chacune un sous-ensemble différent : openSUSE posait virt-viewer, mais ni navigateur CLI ni lm-sensors. Le binaire n'est presque jamais le paquet : sgdisk vit dans « gdisk » chez Debian et Fedora, dans « gptfdisk » chez Arch et openSUSE. Deux règles passent aussi dans la composante : l'ID de la distribution décide avant le PATH, et la commande s'affiche avant la question. Vérifié : commandes identiques sur les trois familles déjà couvertes, 4139 tests verts. --- EN --- The safe shrink listed the missing tools and stopped there. Three separate writings knew how to install, each covering a different subset: openSUSE could put virt-viewer down, but neither a CLI browser nor lm-sensors. A binary is almost never the package: sgdisk lives in "gdisk" on Debian and Fedora, in "gptfdisk" on Arch and openSUSE. Two rules move into the component as well: the distribution ID decides before the PATH, and the command is shown before the question. Checked: commands identical on the three families already covered, and 4139 tests green. Assisted-by: Claude Opus 5 --- script/todo/qemu_access.py | 30 ++--- script/todo/qemu_install_monitor.py | 45 ++----- script/todo/qemu_manage.py | 53 ++++++++ script/todo/todo_i18n.py | 16 +++ script/todo/todo_install.py | 157 ++++++++++++++++++++++ script/todo/todo_telemetry.py | 48 +++---- test/test_qemu_shrink_tools.py | 196 +++++++++++++++++++++++++++ test/test_qemu_tunnel_menu.py | 15 ++- test/test_todo_install.py | 200 ++++++++++++++++++++++++++++ 9 files changed, 677 insertions(+), 83 deletions(-) create mode 100644 script/todo/todo_install.py create mode 100644 test/test_qemu_shrink_tools.py create mode 100644 test/test_todo_install.py diff --git a/script/todo/qemu_access.py b/script/todo/qemu_access.py index 645f38d..e0782f9 100644 --- a/script/todo/qemu_access.py +++ b/script/todo/qemu_access.py @@ -4,11 +4,13 @@ """Menu QEMU/KVM : atteindre une VM \u2014 SSH, tunnels, consoles, \u00e9mulateur.\n\nTout ce qui relie l'humain \u00e0 une machine d\u00e9j\u00e0 d\u00e9ploy\u00e9e : ~/.ssh/config et ses\nProxyJump, la d\u00e9couverte des VM imbriqu\u00e9es, les tunnels de bureau distant, la\nconsole s\u00e9rie et graphique (virt-viewer), et l'\u00e9mulateur Android d'une VM\ngraphique avec son tunnel adb.\n\nS\u00e9par\u00e9 du reste parce que c'est le seul bloc qui parle de R\u00c9SEAU et de\nsessions interactives, jamais de cr\u00e9ation ni de destruction de VM.""" import os +import shlex import shutil import socket import subprocess import time +from script.todo import todo_install from script.todo.todo_i18n import t @@ -597,34 +599,28 @@ class QemuAccessMixin: ) print(f' {t("To close it:")} pkill -f "{port}:localhost:{port}"') - # Un paquet, quatre familles. virt-viewer porte le même nom partout, ce qui - # est rare et bienvenu : seule la commande d'installation change. - _QEMU_VIRT_VIEWER_INSTALL = ( - ("apt-get", "sudo apt-get install -y virt-viewer"), - ("dnf", "sudo dnf install -y virt-viewer"), - ("pacman", "sudo pacman -S --needed --noconfirm virt-viewer"), - ("zypper", "sudo zypper --non-interactive install virt-viewer"), - ) - def _qemu_ensure_virt_viewer(self): """virt-viewer sur CETTE machine, installé s'il manque. Installé seulement là où il va SERVIR : sur un hyperviseur sans écran, poser un client graphique ne rendrait service à personne. C'est - l'appelant qui a vérifié l'affichage.""" + l'appelant qui a vérifié l'affichage, et c'est pourquoi celui-ci pose + sans demander — la seule installation du CLI dans ce cas. + + virt-viewer porte le même nom de paquet dans les quatre familles, ce + qui est rare : seule la commande change, et todo_install la connaît.""" if shutil.which("virt-viewer"): return True print(f"\n {t('virt-viewer is missing here; installing it.')}") - for tool, cmd in self._QEMU_VIRT_VIEWER_INSTALL: - if shutil.which(tool): - print(f" {t('Will execute:')} {cmd}") - self.execute.exec_command_live(cmd, source_erplibre=False) - break - else: + cmd = todo_install.install_command(["virt-viewer"]) + if not cmd: print(f" ⚠ {t('no known package manager here.')}") return False + lisible = shlex.join(cmd) + print(f" {t('Will execute:')} {lisible}") + self.execute.exec_command_live(lisible, source_erplibre=False) if shutil.which("virt-viewer"): - print(f" ✅ virt-viewer") + print(" ✅ virt-viewer") return True print(f" ⚠ {t('virt-viewer still missing after the install.')}") return False diff --git a/script/todo/qemu_install_monitor.py b/script/todo/qemu_install_monitor.py index 8c58ba5..2261f10 100644 --- a/script/todo/qemu_install_monitor.py +++ b/script/todo/qemu_install_monitor.py @@ -1089,17 +1089,6 @@ def cli_browser() -> str | None: return None -def _os_id() -> str: - """ID de la distribution hôte (/etc/os-release), ex. « ubuntu », « fedora ».""" - try: - for line in open("/etc/os-release", encoding="utf-8"): - if line.startswith("ID="): - return line.split("=", 1)[1].strip().strip('"').lower() - except OSError: - pass - return "" - - # Navigateurs CLI installables via apt/dnf/pacman (nom de paquet = binaire). # browsh/carbonyl ne sont pas dans les dépôts standard -> non proposés ici. INSTALLABLE_BROWSERS = ( @@ -1111,29 +1100,17 @@ INSTALLABLE_BROWSERS = ( def browser_install_command(browser="w3m") -> list | None: - """Commande d'installation du navigateur CLI `browser` adaptée à l'OS hôte : - apt (Ubuntu/Debian), dnf (Fedora), pacman (Arch). None si gestionnaire - inconnu.""" - apt = ["sudo", "apt-get", "install", "-y", browser] - dnf = ["sudo", "dnf", "install", "-y", browser] - pac = ["sudo", "pacman", "-S", "--needed", "--noconfirm", browser] - by_id = { - "ubuntu": apt, - "debian": apt, - "linuxmint": apt, - "fedora": dnf, - "arch": pac, - } - cmd = by_id.get(_os_id()) - if cmd: - return cmd - if shutil.which("apt-get"): - return apt - if shutil.which("dnf"): - return dnf - if shutil.which("pacman"): - return pac - return None + """Commande d'installation du navigateur CLI `browser`, ou None si aucun + gestionnaire de paquets connu. + + Les navigateurs proposés portent le même nom de paquet dans les quatre + familles ; todo_install choisit la commande. Cette écriture-ci ne + connaissait pas zypper, et openSUSE ne pouvait donc en installer aucun.""" + # Importé ici et non en tête : l'import de todo_i18n de ce module est + # protégé pour qu'il tourne en autonome, et todo_install en dépend. + from script.todo import todo_install + + return todo_install.install_command([browser]) def virsh_ip(name: str) -> str: diff --git a/script/todo/qemu_manage.py b/script/todo/qemu_manage.py index 30ba3bd..51f77aa 100644 --- a/script/todo/qemu_manage.py +++ b/script/todo/qemu_manage.py @@ -12,6 +12,7 @@ import shutil import subprocess import time +from script.todo import todo_install from script.todo.todo_i18n import t @@ -1305,6 +1306,52 @@ class QemuManageMixin: print(f"{t('Will execute:')} {cmd}") self.execute.exec_command_live(cmd, source_erplibre=False) + # Le nom du binaire n'est presque jamais celui du paquet. Ces cinq-là + # portent le même nom dans les quatre familles. + _SHRINK_PKG = { + "e2fsck": "e2fsprogs", + "resize2fs": "e2fsprogs", + "dumpe2fs": "e2fsprogs", + "partprobe": "parted", + "lsblk": "util-linux", + "blockdev": "util-linux", + } + # Les deux qui changent de famille en famille : sgdisk vit dans « gdisk » + # chez Debian et Fedora, dans « gptfdisk » chez Arch et openSUSE, et + # qemu-nbd porte quatre noms de paquet différents. + _SHRINK_PKG_FAMILY = { + "apt-get": {"sgdisk": "gdisk", "qemu-nbd": "qemu-utils"}, + "dnf": {"sgdisk": "gdisk", "qemu-nbd": "qemu-img"}, + "pacman": {"sgdisk": "gptfdisk", "qemu-nbd": "qemu-img"}, + "zypper": {"sgdisk": "gptfdisk", "qemu-nbd": "qemu-tools"}, + } + + def _qemu_install_shrink_tools(self, manquants): + """Poser les paquets qui fournissent les outils manquants. + + Rend la liste de ce qui manque ENCORE, relue sur le disque : vide si + tout est là. Un refus, un gestionnaire de paquets inconnu ou une + installation en échec la rendent non vide, et l'appelant renonce. + """ + paquets, inconnus = todo_install.resolve( + manquants, + commun=self._SHRINK_PKG, + par_famille=self._SHRINK_PKG_FAMILY, + ) + if inconnus: + print( + f" ⚠ {t('No package known here for:')} {', '.join(inconnus)}" + ) + status = todo_install.ask_and_install( + self.execute, + todo_install.install_command(paquets), + t("Install them? (y/N): "), + self._is_yes, + ) + if status: + print(f" {t('Error installing the tools: ')}{status}") + return [b for b in self._SHRINK_TOOLS if not shutil.which(b)] + def _qemu_safe_shrink(self, name, disk, new_gb): """Réduit le disque SANS casser l'OS, via qemu-nbd + resize2fs + sgdisk (sans libguestfs) : on réduit le FS (ext), puis la partition, @@ -1318,6 +1365,12 @@ class QemuManageMixin: print( f"{t('Missing tools for safe shrink:')} {', '.join(missing)}" ) + missing = self._qemu_install_shrink_tools(missing) + if missing: + print( + f"{t('Still missing, safe shrink cancelled:')}" + f" {', '.join(missing)}" + ) return False target = int(round(new_gb * (1 << 30))) # Sauvegarde OPTIONNELLE (défaut OUI) : permet de restaurer en cas diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 1790373..284cd9a 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -2813,6 +2813,22 @@ TRANSLATIONS = { "fr": "Outils manquants pour la réduction sûre :", "en": "Missing tools for safe shrink:", }, + "Install them? (y/N): ": { + "fr": "Les installer? (y/N) : ", + "en": "Install them? (y/N): ", + }, + "Error installing the tools: ": { + "fr": "Erreur lors de l'installation des outils : ", + "en": "Error installing the tools: ", + }, + "Still missing, safe shrink cancelled:": { + "fr": "Manquent toujours, réduction sûre annulée :", + "en": "Still missing, safe shrink cancelled:", + }, + "No package known here for:": { + "fr": "Aucun paquet connu ici pour :", + "en": "No package known here for:", + }, "Backing up the disk before shrinking…": { "fr": "Sauvegarde du disque avant réduction…", "en": "Backing up the disk before shrinking…", diff --git a/script/todo/todo_install.py b/script/todo/todo_install.py new file mode 100644 index 0000000..1023132 --- /dev/null +++ b/script/todo/todo_install.py @@ -0,0 +1,157 @@ +#!/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 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.')}") + 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) diff --git a/script/todo/todo_telemetry.py b/script/todo/todo_telemetry.py index d3f8492..940b5d3 100644 --- a/script/todo/todo_telemetry.py +++ b/script/todo/todo_telemetry.py @@ -530,37 +530,25 @@ def _command_columns(tree, paths): # --------------------------------------------------------------------------- # # Télémétrie SYSTÈME (vue F2) # --------------------------------------------------------------------------- # -def _os_id() -> str: - try: - for line in open("/etc/os-release", encoding="utf-8"): - if line.startswith("ID="): - return line.split("=", 1)[1].strip().strip('"').lower() - except OSError: - pass - return "" - - def sensors_install_command(): - """Commande d'installation de lm-sensors selon l'OS (nos 4 systèmes).""" - apt = ["sudo", "apt-get", "install", "-y", "lm-sensors"] - dnf = ["sudo", "dnf", "install", "-y", "lm_sensors"] - pac = ["sudo", "pacman", "-S", "--needed", "--noconfirm", "lm_sensors"] - cmd = { - "ubuntu": apt, - "debian": apt, - "linuxmint": apt, - "fedora": dnf, - "arch": pac, - }.get(_os_id()) - if cmd: - return cmd - if shutil.which("apt-get"): - return apt - if shutil.which("dnf"): - return dnf - if shutil.which("pacman"): - return pac - return None + """Commande d'installation de lm-sensors, ou None si aucun gestionnaire + de paquets connu. + + Le paquet change de nom : « lm-sensors » chez Debian, « lm_sensors » + ailleurs. Cette écriture-ci ne connaissait pas zypper, et openSUSE ne + pouvait donc pas l'installer.""" + # Importé ici et non en tête : l'import de todo_i18n de ce module est + # protégé pour qu'il tourne en autonome, et todo_install en dépend. + from script.todo import todo_install + + return todo_install.install_command( + { + "apt-get": ["lm-sensors"], + "dnf": ["lm_sensors"], + "pacman": ["lm_sensors"], + "zypper": ["sensors"], + } + ) def _first_int(path): diff --git a/test/test_qemu_shrink_tools.py b/test/test_qemu_shrink_tools.py new file mode 100644 index 0000000..364381e --- /dev/null +++ b/test/test_qemu_shrink_tools.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Les paquets qui fournissent les outils de la réduction sûre. + +La réduction d'un disque de VM refuse de partir sans huit binaires. Le nom du +binaire n'est presque jamais celui du paquet, et il change de famille en +famille : sgdisk vit dans « gdisk » chez Debian et Fedora, dans « gptfdisk » +chez Arch et openSUSE. Une table pareille se démode sans bruit — un trou n'y +fait rien planter, il fait juste proposer une installation qui n'installe pas +ce qui manque. + +Ce qui se vérifie ici sans VM et sans toucher au système : que chaque outil a +un paquet dans les quatre familles, que la commande construite est celle du +gestionnaire présent, que les paquets ne sont pas demandés deux fois, et qu'un +refus comme un échec laissent l'appelant renoncer plutôt que continuer sans +ses outils. +""" + +import io +import sys +import unittest +from contextlib import redirect_stdout +from unittest.mock import patch + +sys.argv = ["todo.py"] +from script.todo.todo import TODO # noqa: E402 +from script.todo import todo_install # noqa: E402 +from script.todo.todo_i18n import t # noqa: E402 + +FAMILIES = ("apt-get", "dnf", "pacman", "zypper") + + +class _Exec: + """Le lanceur de commandes, qui retient au lieu d'exécuter.""" + + def __init__(self, status=0): + self.ran = [] + self.status = status + + def exec_command_live(self, cmd, source_erplibre=False): + self.ran.append(cmd) + return self.status + + +class ShrinkToolsBase(unittest.TestCase): + def setUp(self): + self.todo = TODO.__new__(TODO) + self.todo.execute = _Exec() + + def _which(self, package_manager, missing, installed_after=()): + """shutil.which vu depuis qemu_manage : un seul gestionnaire de + paquets sur le PATH, et les outils manquants qui le restent tant que + l'installation n'a pas tourné.""" + done = self.todo.execute + + def which(binaire): + if binaire in FAMILIES: + return ( + f"/usr/bin/{binaire}" + if binaire == package_manager + else None + ) + if binaire in missing: + if done.ran and binaire in installed_after: + return f"/usr/bin/{binaire}" + return None + return f"/usr/bin/{binaire}" + + return which + + def _run(self, package_manager, missing, answer="y", installed_after=()): + which = self._which(package_manager, missing, installed_after) + buf = io.StringIO() + + # input() écrit son invite sur stdout comme le vrai : sans cela la + # question n'apparaît nulle part et l'ORDRE des deux ne se voit pas. + def demande(invite=""): + print(invite, end="") + return answer + + # `shutil` est UN seul objet module partagé : patcher son « which » + # par n'importe quel importateur le patche pour todo_install aussi, + # qui est le vrai lecteur du PATH depuis le refactor. + with patch("script.todo.qemu_manage.shutil.which", which), patch( + "builtins.input", demande + ), redirect_stdout(buf): + left = self.todo._qemu_install_shrink_tools(list(missing)) + return self.todo.execute.ran, left, buf.getvalue() + + +class TestPackageTable(ShrinkToolsBase): + def test_every_tool_has_a_package_in_every_family(self): + """Un trou ne plante pas : il propose une installation inutile.""" + for family in FAMILIES: + per_family = TODO._SHRINK_PKG_FAMILY[family] + for binaire in TODO._SHRINK_TOOLS: + paquet = per_family.get(binaire) or TODO._SHRINK_PKG.get( + binaire + ) + self.assertTrue( + paquet, + f"{family} : aucun paquet connu pour « {binaire} »", + ) + + def test_the_overrides_cover_exactly_the_known_families(self): + """Une famille connue de todo_install sans surcharge ici proposerait + « gdisk » à un Arch, qui ne l'a pas.""" + self.assertEqual(set(TODO._SHRINK_PKG_FAMILY), set(FAMILIES)) + self.assertEqual(set(todo_install.FAMILIES), set(FAMILIES)) + + def test_sgdisk_is_the_one_that_changes_name(self): + """Le cas qui a motivé la table, gardé explicitement.""" + noms = {f: TODO._SHRINK_PKG_FAMILY[f]["sgdisk"] for f in FAMILIES} + self.assertEqual(noms["apt-get"], "gdisk") + self.assertEqual(noms["dnf"], "gdisk") + self.assertEqual(noms["pacman"], "gptfdisk") + self.assertEqual(noms["zypper"], "gptfdisk") + + def test_no_family_override_repeats_the_common_table(self): + """Un doublon entre les deux tables est une divergence en attente.""" + for family in FAMILIES: + for binaire in TODO._SHRINK_PKG_FAMILY[family]: + self.assertNotIn( + binaire, + TODO._SHRINK_PKG, + f"{family} : « {binaire} » est dans les deux tables", + ) + + +class TestInstallCommand(ShrinkToolsBase): + def test_each_family_builds_its_own_command(self): + attendu = { + "apt-get": "sudo apt-get install -y gdisk", + "dnf": "sudo dnf install -y gdisk", + "pacman": "sudo pacman -S --needed --noconfirm gptfdisk", + "zypper": "sudo zypper --non-interactive install gptfdisk", + } + for family, cmd in attendu.items(): + self.setUp() + ran, left, _ = self._run( + family, ["sgdisk"], installed_after=("sgdisk",) + ) + self.assertEqual(ran, [cmd]) + self.assertEqual(left, []) + + def test_a_package_is_asked_for_once(self): + """e2fsck, resize2fs et dumpe2fs sortent du même paquet.""" + ran, _, _ = self._run( + "apt-get", + ["e2fsck", "resize2fs", "dumpe2fs", "sgdisk"], + installed_after=("e2fsck", "resize2fs", "dumpe2fs", "sgdisk"), + ) + self.assertEqual(ran, ["sudo apt-get install -y e2fsprogs gdisk"]) + + def test_the_command_is_shown_before_the_question(self): + """On approuve ce qu'on a lu : la commande passe AVANT la question. + + L'ordre est le fond de l'affaire, pas la simple présence des deux : + une question posée avant la commande fait approuver à l'aveugle. + """ + ran, _, out = self._run("apt-get", ["sgdisk"], answer="n") + commande = out.index("sudo apt-get install -y gdisk") + question = out.index(t("Install them? (y/N): ")) + self.assertLess(commande, question) + self.assertEqual(ran, []) + + +class TestGivingUp(ShrinkToolsBase): + def test_a_refusal_installs_nothing_and_keeps_the_list(self): + ran, left, _ = self._run("apt-get", ["sgdisk"], answer="n") + self.assertEqual(ran, []) + self.assertEqual(left, ["sgdisk"]) + + def test_an_unknown_package_manager_gives_up(self): + ran, left, _ = self._run("brew", ["sgdisk"]) + self.assertEqual(ran, []) + self.assertEqual(left, ["sgdisk"]) + + def test_the_list_is_re_read_from_disk_not_assumed(self): + """Une installation qui ne pose rien doit rester un échec.""" + ran, left, _ = self._run("apt-get", ["sgdisk"], installed_after=()) + self.assertEqual(len(ran), 1) + self.assertEqual(left, ["sgdisk"]) + + def test_a_failing_install_is_reported_and_not_swallowed(self): + """exec_command_live REND le code de sortie, il ne lève rien.""" + self.todo.execute = _Exec(status=100) + ran, left, out = self._run("apt-get", ["sgdisk"]) + self.assertEqual(len(ran), 1) + self.assertEqual(left, ["sgdisk"]) + self.assertIn("100", out) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_qemu_tunnel_menu.py b/test/test_qemu_tunnel_menu.py index 211a775..1677b6a 100644 --- a/test/test_qemu_tunnel_menu.py +++ b/test/test_qemu_tunnel_menu.py @@ -240,8 +240,19 @@ class TestEnsureVirtViewer(unittest.TestCase): self.assertEqual(1, len(self.ran)) def test_every_family_is_covered(self): - tools = [t for t, _c in TODO._QEMU_VIRT_VIEWER_INSTALL] - self.assertEqual(["apt-get", "dnf", "pacman", "zypper"], tools) + """La table des familles a quitté ce menu pour todo_install, qui la + partage avec les autres installations du CLI.""" + from script.todo import todo_install + + self.assertEqual( + ["apt-get", "dnf", "pacman", "zypper"], + list(todo_install.FAMILIES), + ) + for famille in todo_install.FAMILIES: + self.assertIn( + "virt-viewer", + todo_install.install_command(["virt-viewer"], famille=famille), + ) class TestTunnelMenuTargets(_MenuCase): diff --git a/test/test_todo_install.py b/test/test_todo_install.py new file mode 100644 index 0000000..8ec04e4 --- /dev/null +++ b/test/test_todo_install.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""La composante d'installation partagée : familles, noms, ordre de décision. + +Trois écritures séparées faisaient ce travail avant, chacune couvrant un +sous-ensemble différent : on pouvait installer virt-viewer sous openSUSE, mais +ni navigateur CLI ni lm-sensors. Ce qui se garde ici est donc surtout de la +COUVERTURE — qu'aucune famille ne retombe dans le trou — plus les deux règles +que la composante tient à la place des appelants : l'ID de la distribution +décide avant le PATH, et la commande s'affiche avant la question. + +Rien n'est installé : le PATH et /etc/os-release sont simulés. +""" + +import io +import sys +import unittest +from contextlib import redirect_stdout +from unittest.mock import mock_open, patch + +sys.argv = ["todo.py"] +from script.todo import todo_install # noqa: E402 + + +def _only(*present): + """shutil.which qui ne trouve que `present`.""" + return lambda b: f"/usr/bin/{b}" if b in present else None + + +class TestFamilyDetection(unittest.TestCase): + def test_every_family_has_a_non_interactive_command(self): + """Un menu qui rend la main à un prompt apt reste bloqué.""" + for famille in todo_install.FAMILIES: + cmd = todo_install.install_command(["p"], famille=famille) + self.assertEqual(cmd[0], "sudo") + self.assertEqual(cmd[-1], "p") + self.assertTrue( + {"-y", "--noconfirm", "--non-interactive"} & set(cmd), + f"{famille} : commande interactive {cmd}", + ) + + def test_the_distribution_id_decides_before_the_path(self): + """Une machine peut porter deux gestionnaires ; l'ID dit lequel + possède le système.""" + with patch( + "builtins.open", mock_open(read_data='ID="debian"\n') + ), patch( + "script.todo.todo_install.shutil.which", _only("apt-get", "dnf") + ): + self.assertEqual(todo_install.family(), "apt-get") + + def test_the_path_decides_when_the_id_is_unknown(self): + with patch( + "builtins.open", mock_open(read_data='ID="nonesuch"\n') + ), patch("script.todo.todo_install.shutil.which", _only("zypper")): + self.assertEqual(todo_install.family(), "zypper") + + def test_an_id_whose_manager_is_absent_falls_back(self): + """Un conteneur Debian minimal sans apt-get ne doit pas mener à une + commande apt-get qui n'existe pas.""" + with patch( + "builtins.open", mock_open(read_data='ID="debian"\n') + ), patch("script.todo.todo_install.shutil.which", _only("dnf")): + self.assertEqual(todo_install.family(), "dnf") + + def test_no_manager_is_none_not_a_guess(self): + with patch("builtins.open", mock_open(read_data="")), patch( + "script.todo.todo_install.shutil.which", _only() + ): + self.assertIsNone(todo_install.family()) + self.assertIsNone(todo_install.install_command(["p"])) + + def test_the_four_supported_families_are_all_mapped(self): + """Les plateformes annoncées par le dépôt doivent toutes tomber sur + une famille, sans quoi l'installation leur est fermée.""" + for os_id, attendu in ( + ("ubuntu", "apt-get"), + ("linuxmint", "apt-get"), + ("debian", "apt-get"), + ("almalinux", "dnf"), + ("rocky", "dnf"), + ("opensuse-leap", "zypper"), + ("opensuse-tumbleweed", "zypper"), + ("arch", "pacman"), + ): + with patch( + "builtins.open", mock_open(read_data=f'ID="{os_id}"\n') + ), patch( + "script.todo.todo_install.shutil.which", + _only(*todo_install.FAMILIES), + ): + self.assertEqual(todo_install.family(), attendu, os_id) + + +class TestPackageNames(unittest.TestCase): + def test_a_dict_picks_the_name_of_the_current_family(self): + """lm-sensors chez Debian, lm_sensors ailleurs.""" + noms = { + "apt-get": ["lm-sensors"], + "dnf": ["lm_sensors"], + "pacman": ["lm_sensors"], + "zypper": ["sensors"], + } + for famille, attendu in ( + ("apt-get", "lm-sensors"), + ("dnf", "lm_sensors"), + ("zypper", "sensors"), + ): + cmd = todo_install.install_command(noms, famille=famille) + self.assertEqual(cmd[-1], attendu) + + def test_a_family_absent_from_the_dict_installs_nothing(self): + """Mieux vaut rien proposer qu'un paquet qui n'existe pas ici.""" + self.assertIsNone( + todo_install.install_command({"apt-get": ["p"]}, famille="pacman") + ) + + def test_resolve_maps_binaries_and_deduplicates(self): + paquets, inconnus = todo_install.resolve( + ["e2fsck", "resize2fs", "sgdisk"], + commun={"e2fsck": "e2fsprogs", "resize2fs": "e2fsprogs"}, + par_famille={"pacman": {"sgdisk": "gptfdisk"}}, + famille="pacman", + ) + self.assertEqual(paquets, ["e2fsprogs", "gptfdisk"]) + self.assertEqual(inconnus, []) + + def test_resolve_says_what_it_cannot_map(self): + """À dire, jamais à deviner : un nom inventé installerait au hasard.""" + paquets, inconnus = todo_install.resolve( + ["sgdisk"], commun={}, par_famille={}, famille="apt-get" + ) + self.assertEqual(paquets, []) + self.assertEqual(inconnus, ["sgdisk"]) + + +class TestAskAndInstall(unittest.TestCase): + class _Exec: + def __init__(self, status=0): + self.ran, self.status = [], status + + def exec_command_live(self, cmd, source_erplibre=False): + self.ran.append(cmd) + return self.status + + def _play(self, cmd, answer, status=0): + ex = self._Exec(status) + buf = io.StringIO() + + def demande(invite=""): + print(invite, end="") + return answer + + with patch("builtins.input", demande), redirect_stdout(buf): + got = todo_install.ask_and_install( + ex, cmd, "Installer? (y/N): ", lambda a: a.strip() == "y" + ) + return ex.ran, got, buf.getvalue() + + def test_the_command_is_printed_before_the_question(self): + """Centralisé ici pour qu'aucun appelant ne puisse l'inverser.""" + ran, got, out = self._play( + ["sudo", "apt-get", "install", "-y", "p"], "n" + ) + self.assertLess( + out.index("sudo apt-get install -y p"), + out.index("Installer? (y/N): "), + ) + self.assertEqual(ran, []) + self.assertIsNone(got) + + def test_accepting_runs_it_and_gives_the_exit_code_back(self): + """exec_command_live REND le code, il ne lève rien : l'appelant doit + pouvoir le tester.""" + ran, got, _ = self._play(["sudo", "dnf", "install", "-y", "p"], "y") + self.assertEqual(ran, ["sudo dnf install -y p"]) + self.assertEqual(got, 0) + ran, got, _ = self._play( + ["sudo", "dnf", "install", "-y", "p"], "y", status=127 + ) + self.assertEqual(got, 127) + + def test_no_command_is_said_not_silently_skipped(self): + ran, got, out = self._play(None, "y") + self.assertEqual(ran, []) + self.assertIsNone(got) + self.assertTrue(out.strip()) + + def test_a_name_with_a_space_survives_the_display(self): + """shlex.join, pour que la commande affichée soit celle qui tourne.""" + ran, _, out = self._play( + ["sudo", "apt-get", "install", "-y", "a b"], "y" + ) + self.assertIn("'a b'", out) + self.assertIn("'a b'", ran[0]) + + +if __name__ == "__main__": + unittest.main() From 1cc74c83b4817559345e8eea6f21e571fb692f67 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 31 Aug 2026 07:51:19 -0400 Subject: [PATCH 04/17] =?UTF-8?q?[FIX]=20qemu=20shrink=20:=20place=20mesur?= =?UTF-8?q?=C3=A9e=20avant=20sauvegarde,=20=C3=A9tape=20annonc=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La sauvegarde double la place occupée et le défaut était OUI : sur un disque presque plein, une entrée vide lançait une copie qui s'arrête à mi-course et laisse un .bak tronqué. Les deux tailles passent donc avant la question, et le défaut bascule à NON quand la place manque. Le besoin annoncé est la taille ALLOUÉE : « cp --sparse=always » ne recopie pas les trous d'un qcow2. La sortie du gestionnaire de paquets enchaînait par ailleurs sur une question portant sur autre chose ; l'étape se referme d'une ligne. Vérifié : le défaut remis à OUI sans place, comme la taille apparente au lieu de l'allouée, font tomber les tests. 4144 verts. --- EN --- A backup doubles the space used and the default was YES: on a nearly full disk, an empty answer started a copy that stops midway and leaves a truncated .bak. Both sizes now come before the question, and the default flips to NO when the room is short. The need shown is the ALLOCATED size: "cp --sparse=always" does not copy the holes of a qcow2. The package manager output also ran straight into a question about something else; the step now closes with a line of its own. Checked: putting the default back to YES without room, like the apparent size instead of the allocated one, makes the tests fail. 4144 green. Assisted-by: Claude Opus 5 --- script/todo/qemu_manage.py | 57 +++++++++++++++++++++++-- script/todo/todo_i18n.py | 20 +++++++++ test/test_qemu_shrink_tools.py | 76 ++++++++++++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 4 deletions(-) diff --git a/script/todo/qemu_manage.py b/script/todo/qemu_manage.py index 51f77aa..56d47be 100644 --- a/script/todo/qemu_manage.py +++ b/script/todo/qemu_manage.py @@ -1350,7 +1350,58 @@ class QemuManageMixin: ) if status: print(f" {t('Error installing the tools: ')}{status}") - return [b for b in self._SHRINK_TOOLS if not shutil.which(b)] + reste = [b for b in self._SHRINK_TOOLS if not shutil.which(b)] + if not reste: + # Sans cette ligne, la sortie du gestionnaire de paquets est + # suivie directement de la question suivante, qui porte sur tout + # autre chose : rien ne dit que l'installation a abouti ni qu'on + # a changé d'étape. + print(f" ✅ {t('Tools installed; on with the shrink.')}") + return reste + + @staticmethod + def _qemu_backup_need_and_free(disk): + """(besoin, libre) en octets pour la copie de sauvegarde du disque. + + Le besoin est la taille ALLOUÉE et non la taille apparente : + « cp --sparse=always » ne recopie pas les trous d'un qcow2. C'est une + borne haute — « --reflink=auto » rend la copie presque gratuite sur + btrfs et XFS — mais rien ne garantit le reflink, et se tromper par + excès est le bon sens ici : une copie qui manque de place s'arrête à + mi-chemin et laisse un .bak tronqué. + """ + besoin = os.stat(disk).st_blocks * 512 + libre = shutil.disk_usage(os.path.dirname(disk) or ".").free + return besoin, libre + + def _qemu_ask_backup(self, disk): + """Proposer la sauvegarde du disque, chiffres en main. True si oui. + + Les deux tailles passent AVANT la question : une copie qui ne tient + pas s'arrête à mi-course et laisse un .bak tronqué sur un système de + fichiers désormais plein. Quand la place manque, le défaut bascule à + NON — une entrée distraite ne doit pas remplir le disque — sans pour + autant décider à la place de l'opérateur, qui peut insister. + """ + besoin, libre = self._qemu_backup_need_and_free(disk) + print( + f"\n{t('A backup doubles the space used:')}" + f" {self._human_size(besoin)} — {t('free here:')}" + f" {self._human_size(libre)}" + ) + if libre > besoin * 1.05: + return self._is_yes_default_yes( + input(t("Back up the disk before shrinking? (Y/n): ")) + ) + print(f"⚠ {t('Not enough free space for a full backup.')}") + return self._is_yes( + input( + t( + "Back up anyway, at the risk of filling the disk?" + " (y/N): " + ) + ) + ) def _qemu_safe_shrink(self, name, disk, new_gb): """Réduit le disque SANS casser l'OS, via qemu-nbd + resize2fs + @@ -1377,9 +1428,7 @@ class QemuManageMixin: # d'échec, et de tester la VM avant de la supprimer (proposé à la fin). self._shrink_backup = None bak = None - if self._is_yes_default_yes( - input(t("Back up the disk before shrinking? (Y/n): ")) - ): + if self._qemu_ask_backup(disk): bak = f"{disk}.bak" print(f"\n{t('Backing up the disk before shrinking…')}") if ( diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 284cd9a..b905c61 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -2829,6 +2829,26 @@ TRANSLATIONS = { "fr": "Aucun paquet connu ici pour :", "en": "No package known here for:", }, + "Tools installed; on with the shrink.": { + "fr": "Outils installés ; on passe à la réduction.", + "en": "Tools installed; on with the shrink.", + }, + "A backup doubles the space used:": { + "fr": "Une sauvegarde double la place occupée :", + "en": "A backup doubles the space used:", + }, + "free here:": { + "fr": "libre ici :", + "en": "free here:", + }, + "Not enough free space for a full backup.": { + "fr": "Pas assez de place libre pour une sauvegarde complète.", + "en": "Not enough free space for a full backup.", + }, + "Back up anyway, at the risk of filling the disk? (y/N): ": { + "fr": "Sauvegarder quand même, au risque de remplir le disque? (y/N) : ", + "en": "Back up anyway, at the risk of filling the disk? (y/N): ", + }, "Backing up the disk before shrinking…": { "fr": "Sauvegarde du disque avant réduction…", "en": "Backing up the disk before shrinking…", diff --git a/test/test_qemu_shrink_tools.py b/test/test_qemu_shrink_tools.py index 364381e..38c2cbc 100644 --- a/test/test_qemu_shrink_tools.py +++ b/test/test_qemu_shrink_tools.py @@ -18,6 +18,7 @@ ses outils. """ import io +import os import sys import unittest from contextlib import redirect_stdout @@ -192,5 +193,80 @@ class TestGivingUp(ShrinkToolsBase): self.assertIn("100", out) +class TestBackupSpace(unittest.TestCase): + """La sauvegarde avant réduction, et la place qu'elle demande. + + Elle doublait l'occupation sans rien annoncer : sur un disque presque + plein la copie s'arrête à mi-course et laisse un .bak tronqué, sur un + système de fichiers désormais saturé. Les deux chiffres passent donc + avant la question, et le défaut bascule quand la place manque — une + entrée distraite ne doit pas remplir le disque. + """ + + GIB = 1 << 30 + + def setUp(self): + self.todo = TODO.__new__(TODO) + + def test_the_need_is_the_allocated_size_not_the_apparent_one(self): + """« cp --sparse=always » ne recopie pas les trous d'un qcow2 : un + disque de 60 Go apparents mais 8 Go alloués ne demande que 8 Go.""" + faux = os.stat_result((0o644, 0, 0, 1, 0, 0, 60 * self.GIB, 0, 0, 0)) + # st_blocks n'est pas dans le tuple : on le pose à part. + with patch("script.todo.qemu_manage.os.stat") as stat, patch( + "script.todo.qemu_manage.shutil.disk_usage" + ) as du: + stat.return_value = type( + "S", (), {"st_blocks": 8 * self.GIB // 512} + )() + du.return_value = type("U", (), {"free": 99 * self.GIB})() + besoin, libre = TODO._qemu_backup_need_and_free("/x/d.qcow2") + self.assertEqual(besoin, 8 * self.GIB) + self.assertEqual(libre, 99 * self.GIB) + self.assertNotEqual(besoin, faux.st_size) + + def _decision(self, besoin, libre, answer): + """(question posée, sauvegarde retenue) — par le VRAI code. + + Ce helper appelle _qemu_ask_backup et ne réimplémente rien : une + copie de la logique dans le test aurait laissé passer un défaut + remis à OUI sans place, ce qui est précisément le défaut à garder. + """ + vu = [] + + def demande(invite=""): + vu.append(invite) + return answer + + with patch.object( + TODO, + "_qemu_backup_need_and_free", + staticmethod(lambda d: (besoin, libre)), + ), patch("builtins.input", demande), redirect_stdout(io.StringIO()): + retenu = self.todo._qemu_ask_backup("/x/d.qcow2") + return vu[-1], retenu + + def test_with_room_the_default_stays_yes(self): + question, retenu = self._decision(12 * self.GIB, 40 * self.GIB, "") + self.assertIn("(O/n", question) + self.assertTrue(retenu) + + def test_without_room_the_default_flips_to_no(self): + """Le cœur du correctif : entrée vide ne doit PAS remplir le disque.""" + question, retenu = self._decision(12 * self.GIB, 3 * self.GIB, "") + self.assertIn("(y/N", question) + self.assertFalse(retenu) + + def test_without_room_insisting_still_works(self): + """On informe, on ne décide pas à la place de l'opérateur.""" + _, retenu = self._decision(12 * self.GIB, 3 * self.GIB, "y") + self.assertTrue(retenu) + + def test_a_margin_guards_the_exactly_equal_case(self): + """Une place égale au besoin n'en laisse aucune : refusé.""" + _, retenu = self._decision(12 * self.GIB, 12 * self.GIB, "") + self.assertFalse(retenu) + + if __name__ == "__main__": unittest.main() From 3dcccf40d933dc982e0ab186aaaea67ce846534c Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 2 Sep 2026 03:53:40 -0400 Subject: [PATCH 05/17] =?UTF-8?q?[FIX]=20script=20todo=20:=20rtk=20hors=20?= =?UTF-8?q?du=20PATH,=20r=C3=A9sultat=20d'installation=20annonc=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un processus garde le PATH qu'il avait au démarrage : rtk installé dans ~/.local/bin pendant que TODO tourne échappe à shutil.which, et « rtk » nu sort en 127. Le menu le cherche donc aussi à l'emplacement de l'installateur et l'appelle par son chemin absolu, sans confondre un binaire hors PATH avec une absence. L'installation annonce son résultat : version et chemin, ou échec. Vérifié : 9 tests neufs, 117 au total. --- EN --- A process keeps the PATH it had at startup: rtk installed into ~/.local/bin while TODO runs escapes shutil.which, and a bare « rtk » exits 127. The menu therefore also looks at the installer's location and calls the binary by its absolute path, without mistaking a binary outside the PATH for a missing one. Installation now reports its outcome: version and path, or failure. Checked: 9 new tests, 117 in total. Assisted-by: Claude Opus 5 --- script/todo/todo.py | 136 ++++++++++++++++++++++++++++----------- script/todo/todo_i18n.py | 19 ++++++ test/test_todo_rtk.py | 132 +++++++++++++++++++++++++++++++++++++ 3 files changed, 249 insertions(+), 38 deletions(-) create mode 100644 test/test_todo_rtk.py diff --git a/script/todo/todo.py b/script/todo/todo.py index 0755c1f..1bcdc7b 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -4227,6 +4227,83 @@ class TODO( else: print(t("Command not found !")) + def rtk_locate(self): + """Localise l'exécutable rtk. + + Rend le couple (chemin, visible_dans_le_PATH). Le chemin vaut None + quand rtk est introuvable. Le second membre est faux quand le binaire + existe à l'emplacement où l'installateur le dépose sans que le PATH y + mène : un processus garde le PATH qu'il avait au démarrage, donc une + installation faite pendant que TODO tourne lui reste invisible tant + qu'il n'est pas relancé. + """ + rtk_path = shutil.which("rtk") + if rtk_path: + return rtk_path, True + # Emplacement par défaut de l'installateur (RTK_INSTALL_DIR le change). + fallback = os.path.expanduser("~/.local/bin/rtk") + if os.access(fallback, os.X_OK): + return fallback, False + return None, False + + def rtk_exec(self, args): + """Lance rtk par son chemin absolu, ou signale qu'il est absent. + + Le chemin absolu évite le code 127 d'un « rtk » nu quand le PATH du + processus ne mène pas à l'emplacement d'installation. + """ + rtk_path, _ = self.rtk_locate() + if rtk_path is None: + print(t("RTK is not installed. Use option 1 to install it.")) + return 1 + return self.execute.exec_command_live( + f"{shlex.quote(rtk_path)} {args}", + source_erplibre=False, + ) + + def rtk_version(self, rtk_path): + """Rend la version qu'annonce le binaire, « ? » s'il ne répond pas.""" + result = self.execute.exec_command_live( + f"{shlex.quote(rtk_path)} --version", + source_erplibre=False, + quiet=True, + return_status_and_output=True, + ) + if isinstance(result, tuple) and result[0] == 0: + return " ".join(result[1]).strip() + return "?" + + def rtk_report_path_warning(self): + """Dit comment rendre rtk appelable quand le PATH ne le porte pas.""" + print(t("rtk is not in the PATH of this process, restart TODO.")) + print(t("To make it permanent, add to your shell profile:")) + print(' export PATH="$HOME/.local/bin:$PATH"') + + def rtk_report_install(self, exit_code): + """Annonce le résultat de l'installation, PATH compris. + + Une installation réussie ne rend pas rtk appelable pour autant : le + binaire atterrit dans un répertoire que le PATH du processus courant + peut ignorer. Distinguer les deux cas évite de conclure à un échec + devant un « commande introuvable » qui ne tient qu'au PATH. + """ + if exit_code: + print(f"❌ {t('RTK installation failed, see the output above.')}") + return + rtk_path, in_path = self.rtk_locate() + if rtk_path is None: + print( + "❌" + f" {t('Installation ended without error, but no rtk binary was found.')}" + ) + return + print( + f"✅ {t('RTK is installed, version: ')}{self.rtk_version(rtk_path)}" + ) + print(f" {rtk_path}") + if not in_path: + self.rtk_report_path_warning() + def rtk_install(self): print(f"🤖 {t('Installation method:')}") choices = [ @@ -4244,64 +4321,47 @@ class TODO( if status == "0": return elif status == "1": - self.execute.exec_command_live( - "curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh", - source_erplibre=False, + command = ( + "curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/" + "refs/heads/master/install.sh | sh" ) elif status == "2": - self.execute.exec_command_live( - "brew install rtk", - source_erplibre=False, - ) + command = "brew install rtk" elif status == "3": - self.execute.exec_command_live( - "cargo install --git https://github.com/rtk-ai/rtk", - source_erplibre=False, - ) + command = "cargo install --git https://github.com/rtk-ai/rtk" else: print(t("Command not found !")) + return + exit_code = self.execute.exec_command_live( + command, + source_erplibre=False, + ) + self.rtk_report_install(exit_code) def rtk_check_version(self): - self.execute.exec_command_live( - "rtk --version", - source_erplibre=False, - ) + self.rtk_exec("--version") def rtk_show_gain(self): - self.execute.exec_command_live( - "rtk gain", - source_erplibre=False, - ) + self.rtk_exec("gain") def rtk_discover(self): - self.execute.exec_command_live( - "rtk discover", - source_erplibre=False, - ) + self.rtk_exec("discover") def rtk_init_global(self): - self.execute.exec_command_live( - "rtk init --global", - source_erplibre=False, - ) + self.rtk_exec("init --global") def rtk_check_status(self): - rtk_path = shutil.which("rtk") + rtk_path, in_path = self.rtk_locate() if rtk_path is None: print(t("RTK is not installed. Use option 1 to install it.")) return - result = self.execute.exec_command_live( - "rtk --version", - source_erplibre=False, - quiet=True, - return_status_and_output=True, + print( + f"{t('RTK is installed, version: ')}{self.rtk_version(rtk_path)}" ) - if isinstance(result, tuple) and result[0] == 0: - version_output = " ".join(result[1]).strip() - print(f"{t('RTK is installed, version: ')}{version_output}") - else: - print(f"{t('RTK is installed, version: ')}?") + print(f" {rtk_path}") + if not in_path: + self.rtk_report_path_warning() config_path = os.path.expanduser("~/.config/rtk/config.toml") if os.path.exists(config_path): diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index b905c61..4a21a71 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -367,6 +367,25 @@ TRANSLATIONS = { "fr": "RTK est installé, version : ", "en": "RTK is installed, version: ", }, + "RTK installation failed, see the output above.": { + "fr": "L'installation de RTK a échoué, voir la sortie ci-dessus.", + "en": "RTK installation failed, see the output above.", + }, + "Installation ended without error, but no rtk binary was found.": { + "fr": ( + "L'installation s'est terminée sans erreur, mais aucun binaire" + " rtk n'a été trouvé." + ), + "en": "Installation ended without error, but no rtk binary was found.", + }, + "rtk is not in the PATH of this process, restart TODO.": { + "fr": "rtk n'est pas dans le PATH de ce processus, relancer TODO.", + "en": "rtk is not in the PATH of this process, restart TODO.", + }, + "To make it permanent, add to your shell profile:": { + "fr": "Pour que ce soit permanent, ajouter au profil du shell :", + "en": "To make it permanent, add to your shell profile:", + }, "Global auto-rewrite hook: active": { "fr": "Hook auto-rewrite global : actif", "en": "Global auto-rewrite hook: active", diff --git a/test/test_todo_rtk.py b/test/test_todo_rtk.py new file mode 100644 index 0000000..56b6486 --- /dev/null +++ b/test/test_todo_rtk.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Le menu RTK : dit-il ce qui s'est réellement passé ? + +Deux modes de défaillance se ressemblent à l'écran et n'ont pas le même +remède. Le binaire peut être absent — l'installation a échoué. Il peut aussi +être posé sur le disque sans que le PATH du processus y mène : un processus +garde le PATH qu'il avait au démarrage, donc une installation faite pendant +que TODO tourne lui reste invisible jusqu'au redémarrage. Lancer « rtk » nu +rend alors 127, que rien ne distingue d'une absence. + +Ce test vérifie que les deux cas sont annoncés séparément, et que les +commandes passent par le chemin absolu du binaire plutôt que par le PATH. +""" + +import io +import os +import unittest +from contextlib import redirect_stdout +from unittest.mock import MagicMock, patch + +from script.todo.todo import TODO + +FALLBACK = os.path.expanduser("~/.local/bin/rtk") +PATH_HINT = 'export PATH="$HOME/.local/bin:$PATH"' + + +class TestRtkLocate(unittest.TestCase): + """rtk_locate distingue « dans le PATH », « posé ailleurs » et « absent ».""" + + def test_found_in_path(self): + with patch( + "script.todo.todo.shutil.which", return_value="/usr/bin/rtk" + ): + self.assertEqual(TODO().rtk_locate(), ("/usr/bin/rtk", True)) + + def test_found_outside_path(self): + with patch("script.todo.todo.shutil.which", return_value=None), patch( + "script.todo.todo.os.access", return_value=True + ): + self.assertEqual(TODO().rtk_locate(), (FALLBACK, False)) + + def test_absent(self): + with patch("script.todo.todo.shutil.which", return_value=None), patch( + "script.todo.todo.os.access", return_value=False + ): + self.assertEqual(TODO().rtk_locate(), (None, False)) + + +class TestRtkExec(unittest.TestCase): + """rtk_exec appelle le binaire par son chemin absolu, jamais « rtk » nu.""" + + def test_uses_absolute_path(self): + todo = TODO() + todo.execute = MagicMock() + todo.execute.exec_command_live.return_value = 0 + with patch("script.todo.todo.shutil.which", return_value=None), patch( + "script.todo.todo.os.access", return_value=True + ): + todo.rtk_exec("gain") + command = todo.execute.exec_command_live.call_args[0][0] + self.assertTrue(command.startswith(FALLBACK), command) + self.assertTrue(command.endswith(" gain"), command) + + def test_absent_runs_nothing(self): + todo = TODO() + todo.execute = MagicMock() + with patch("script.todo.todo.shutil.which", return_value=None), patch( + "script.todo.todo.os.access", return_value=False + ): + with redirect_stdout(io.StringIO()): + status = todo.rtk_exec("gain") + self.assertEqual(status, 1) + todo.execute.exec_command_live.assert_not_called() + + +class TestRtkReportInstall(unittest.TestCase): + """Le compte rendu d'installation nomme le résultat, sans le supposer.""" + + def report(self, todo, exit_code): + out = io.StringIO() + with redirect_stdout(out): + todo.rtk_report_install(exit_code) + return out.getvalue() + + def test_failure_is_not_announced_as_success(self): + todo = TODO() + todo.execute = MagicMock() + output = self.report(todo, 1) + self.assertIn("❌", output) + self.assertNotIn("✅", output) + todo.execute.exec_command_live.assert_not_called() + + def test_success_reports_version_and_path(self): + todo = TODO() + todo.execute = MagicMock() + todo.execute.exec_command_live.return_value = (0, ["rtk 0.47.0"]) + with patch( + "script.todo.todo.shutil.which", return_value="/usr/bin/rtk" + ): + output = self.report(todo, 0) + self.assertIn("✅", output) + self.assertIn("rtk 0.47.0", output) + self.assertIn("/usr/bin/rtk", output) + self.assertNotIn(PATH_HINT, output) + + def test_success_outside_path_tells_how_to_reach_it(self): + todo = TODO() + todo.execute = MagicMock() + todo.execute.exec_command_live.return_value = (0, ["rtk 0.47.0"]) + with patch("script.todo.todo.shutil.which", return_value=None), patch( + "script.todo.todo.os.access", return_value=True + ): + output = self.report(todo, 0) + self.assertIn("✅", output) + self.assertIn(PATH_HINT, output) + + def test_success_without_binary_is_not_a_success(self): + todo = TODO() + todo.execute = MagicMock() + with patch("script.todo.todo.shutil.which", return_value=None), patch( + "script.todo.todo.os.access", return_value=False + ): + output = self.report(todo, 0) + self.assertIn("❌", output) + self.assertNotIn("✅", output) + + +if __name__ == "__main__": + unittest.main() From 93ffb3b7e91ab44208021b22a46cef6416f7fafd Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 2 Sep 2026 04:01:10 -0400 Subject: [PATCH 06/17] [ADD] script todo : poser merge.conflictStyle=zdiff3 depuis le menu Git MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zdiff3 fait figurer la base commune dans les marqueurs de conflit et sort de la zone contestée les lignes que les deux côtés ont en commun : il reste moins à arbitrer à la main. Le style demande git 2.35, que toutes les plateformes supportées dépassent. La valeur est relue après écriture, « git config » ne rendant rien à l'écriture. L'entrée est déclarée dans TestGitMenuNumbering : le menu Git mêle entrées codées en dur et entrées de todo.json, qu'un rang de plus décale. Vérifié : 117 tests au vert. --- EN --- zdiff3 puts the merge base into the conflict markers and lifts out of the contested area the lines both sides share: less is left to arbitrate by hand. The style needs git 2.35, which every supported platform exceeds. The value is read back after writing, as « git config » returns nothing on write. The entry is declared in TestGitMenuNumbering: the Git menu mixes hard-coded entries with todo.json ones, which one more rank shifts. Checked: 117 tests green. Assisted-by: Claude Opus 5 --- script/todo/todo.py | 39 +++++++++++++++++++++++++++++++++++++++ script/todo/todo_i18n.py | 11 +++++++++++ test/test_todo_menu.py | 12 ++++++------ 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/script/todo/todo.py b/script/todo/todo.py index 1bcdc7b..c5e08d3 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -2539,6 +2539,11 @@ class TODO( "Install git hooks (commit-msg, pre-commit)" ) }, + { + "prompt_description": t( + "Set merge.conflictStyle to zdiff3 (global)" + ) + }, ] # Append config-driven entries @@ -2559,6 +2564,8 @@ class TODO( self._git_add_remote() elif status == "3": self._git_install_hooks() + elif status == "4": + self._git_set_conflict_style() else: cmd_no_found = True try: @@ -2636,6 +2643,38 @@ class TODO( marque = t("hook installed") if pose else t("hook not installed") print(f" {hook:<26} {marque}") + def _git_set_conflict_style(self): + """Poser merge.conflictStyle=zdiff3 dans la configuration globale. + + zdiff3 ajoute la base commune aux marqueurs de conflit et sort de la + zone contestée les lignes que les deux côtés ont en commun : il reste + moins à arbitrer à la main. Le style demande git 2.35, que toutes les + plateformes supportées dépassent. + + La valeur est relue après écriture : « git config » ne rend rien à + l'écriture, et une configuration globale en lecture seule échouerait + sans que le menu le sache. + """ + status = self.execute.exec_command_live( + "git config --global merge.conflictStyle zdiff3", + source_erplibre=False, + ) + if status: + print( + f"❌ {t('Failed to set merge.conflictStyle, see the output above.')}" + ) + return + result = self.execute.exec_command_live( + "git config --global --get merge.conflictStyle", + source_erplibre=False, + quiet=True, + return_status_and_output=True, + ) + value = ( + " ".join(result[1]).strip() if isinstance(result, tuple) else "" + ) + print(f"✅ merge.conflictStyle = {value}") + def prompt_execute_git_local_server(self): print(f"🤖 {t('Manage local git repository server!')}") choices = [ diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 4a21a71..88a3f96 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -903,6 +903,17 @@ TRANSLATIONS = { "en": "Error adding remote: ", }, # Git hooks + "Set merge.conflictStyle to zdiff3 (global)": { + "fr": "Configurer merge.conflictStyle en zdiff3 (global)", + "en": "Set merge.conflictStyle to zdiff3 (global)", + }, + "Failed to set merge.conflictStyle, see the output above.": { + "fr": ( + "Échec du réglage de merge.conflictStyle, voir la sortie" + " ci-dessus." + ), + "en": "Failed to set merge.conflictStyle, see the output above.", + }, "Install git hooks (commit-msg, pre-commit)": { "fr": "Installer les hooks git (commit-msg, pre-commit)", "en": "Install git hooks (commit-msg, pre-commit)", diff --git a/test/test_todo_menu.py b/test/test_todo_menu.py index f6a5aa5..b31912c 100644 --- a/test/test_todo_menu.py +++ b/test/test_todo_menu.py @@ -451,12 +451,11 @@ class TestProxmoxMenuNumbering(MenuCoherence, unittest.TestCase): class TestGitMenuNumbering(MenuCoherence, unittest.TestCase): """Le menu Git, le seul dont todo.json suit des entrées codées en dur. - Ses trois premières entrées sont écrites à la main, les suivantes viennent - de `git_from_makefile` et le repli générique les renumérote tout seul : - ajouter « Installer les hooks git » en [3] a poussé les deux entrées de - todo.json en [4] et [5] sans que rien ne le dise. Une quatrième entrée - codée en dur, oubliée dans le dispatch, ferait lancer à [4] la commande - du voisin sous le libellé attendu. + Ses premières entrées sont écrites à la main, les suivantes viennent de + `git_from_makefile` et le repli générique les renumérote tout seul : + ajouter une entrée codée en dur pousse celles de todo.json d'un rang sans + que rien ne le dise. Une entrée codée en dur oubliée dans le dispatch + ferait lancer la commande du voisin sous le libellé attendu. """ SOURCE = TODO_DIR / "todo.py" @@ -468,6 +467,7 @@ class TestGitMenuNumbering(MenuCoherence, unittest.TestCase): "Local git server": "prompt_execute_git_local_server", "Add a remote to a local repository": "_git_add_remote", "Install git hooks": "_git_install_hooks", + "Set merge.conflictStyle": "_git_set_conflict_style", } From d31ca9eab98917bf4f98d6a9234d73239691719e Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 2 Sep 2026 04:13:48 -0400 Subject: [PATCH 07/17] [UPD] qemu manage : nom de VM sans la version d'une publication continue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une distribution en publication continue n'a qu'une version, latest : le segment ne distingue aucune VM d'une autre et sort du nom. Une version nommée qui coexiste avec d'autres au catalogue y reste, tumbleweed comme les numérotées. Le nom se relit dans l'autre sens pour retrouver (distro, version) et filtrer les outils par distribution : une VM déjà déployée sous l'ancien nom n'y est plus résolue, la renommer suffit. Vérifié : catalogue rejoué sans collision, aller-retour du nom, 6 tests. --- EN --- A rolling-release distribution has a single version, latest: the segment tells no VM apart from another and leaves the name. A named version that coexists with others in the catalogue stays, tumbleweed as much as the numbered ones. The name is read back the other way to recover (distro, version) and filter tools by distribution: a VM already deployed under the old name no longer resolves there, renaming it is enough. Checked: catalogue replayed without collision, name round trip, 6 tests. Assisted-by: Claude Opus 5 --- script/todo/qemu_manage.py | 12 ++++- test/test_qemu_vm_name.py | 99 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 test/test_qemu_vm_name.py diff --git a/script/todo/qemu_manage.py b/script/todo/qemu_manage.py index 56d47be..53d95a7 100644 --- a/script/todo/qemu_manage.py +++ b/script/todo/qemu_manage.py @@ -2323,8 +2323,16 @@ class QemuManageMixin: """Nom de VM stable pour le parc, ex. erplibre-ubuntu-2404. Ajoute un suffixe d'architecture quand elle diffère de la native de l'hôte (ex. erplibre-ubuntu-2604-s390x sur un hôte amd64) pour éviter les collisions - de noms entre archis et rendre l'archi visible.""" - base = f"erplibre-{distro}-{version.replace('.', '')}" + de noms entre archis et rendre l'archi visible. + + La version « latest » ne figure pas dans le nom : une distribution en + publication continue n'en a qu'une, si bien que le segment ne + distingue aucune VM d'une autre. Une version nommée qui coexiste avec + d'autres au catalogue reste dans le nom, tumbleweed comprise.""" + if version == "latest": + base = f"erplibre-{distro}" + else: + base = f"erplibre-{distro}-{version.replace('.', '')}" if arch and arch != cls._native_arch(): base += f"-{arch}" return base diff --git a/test/test_qemu_vm_name.py b/test/test_qemu_vm_name.py new file mode 100644 index 0000000..5d53468 --- /dev/null +++ b/test/test_qemu_vm_name.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Le nom de VM du parc : que porte-t-il, et que retrouve-t-on dedans ? + +Le nom se lit dans les deux sens. `_qemu_infra_name` le fabrique à partir de +(distro, version, archi) ; `_qemu_vm_meta` remonte de ce nom à la version en +rejouant le catalogue. Les deux doivent rester en miroir : une règle ajoutée +d'un seul côté rend une VM introuvable dans son propre parc. + +Une distribution en publication continue n'a qu'une version, « latest », qui +ne distingue donc rien — elle sort du nom. Une version nommée qui coexiste +avec d'autres au catalogue, tumbleweed, reste. +""" + +import unittest +from unittest.mock import patch + +from script.todo.todo import TODO + + +class TestInfraName(unittest.TestCase): + """Ce que le nom porte selon la distribution et l'architecture.""" + + def setUp(self): + patcher = patch.object( + TODO, "_native_arch", staticmethod(lambda: "amd64") + ) + patcher.start() + self.addCleanup(patcher.stop) + + def test_rolling_release_drops_the_version(self): + self.assertEqual( + TODO._qemu_infra_name("arch", "latest", "amd64"), "erplibre-arch" + ) + + def test_rolling_release_keeps_the_foreign_arch(self): + self.assertEqual( + TODO._qemu_infra_name("arch", "latest", "arm64"), + "erplibre-arch-arm64", + ) + + def test_named_version_stays(self): + """tumbleweed coexiste avec Leap au catalogue : le nom doit trancher.""" + self.assertEqual( + TODO._qemu_infra_name("opensuse", "tumbleweed", "amd64"), + "erplibre-opensuse-tumbleweed", + ) + + def test_numbered_version_loses_only_its_dots(self): + self.assertEqual( + TODO._qemu_infra_name("ubuntu", "24.04", "amd64"), + "erplibre-ubuntu-2404", + ) + + def test_every_catalogue_entry_yields_a_distinct_name(self): + """Deux entrées du catalogue ne peuvent pas porter le même nom.""" + import importlib.util + from pathlib import Path + + path = ( + Path(__file__).resolve().parent.parent + / "script" + / "qemu" + / "deploy_qemu.py" + ) + spec = importlib.util.spec_from_file_location("deploy_qemu", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + + noms = [ + TODO._qemu_infra_name(distro, version, "amd64") + for distro, (versions, _default) in mod.DISTROS.items() + for version in versions + ] + self.assertEqual(len(noms), len(set(noms)), sorted(noms)) + + +class TestNameRoundTrip(unittest.TestCase): + """_qemu_vm_meta retrouve la version que _qemu_infra_name a effacée.""" + + def test_rolling_release_resolves_back_to_latest(self): + todo = TODO() + with patch.object( + TODO, "_qemu_vm_arch", lambda self, name: "amd64" + ), patch.object(TODO, "_native_arch", staticmethod(lambda: "amd64")): + + class Catalogue: + DISTROS = {"arch": (["latest"], "latest")} + + self.assertEqual( + todo._qemu_vm_meta("erplibre-arch", Catalogue()), + ("arch", "latest", "amd64"), + ) + + +if __name__ == "__main__": + unittest.main() From b32105288818d57243ca6ae8bd08b541e7604548 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 2 Sep 2026 04:43:25 -0400 Subject: [PATCH 08/17] =?UTF-8?q?[ADD]=20script=20todo=20:=20Starship=20de?= =?UTF-8?q?puis=20le=20menu=20Git,=20renomm=C3=A9=20Git=20et=20Shell?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Poser le binaire ne change pas le prompt : c'est la ligne d'initialisation dans le fichier du shell qui le fait, et les deux étapes échouent séparément. Le paquet vient de la distribution quand elle le connaît, de l'installateur amont sinon — starship n'est pas empaqueté partout. Le fichier du shell n'est demandé que devant plusieurs candidats, et la ligne ne s'écrit qu'une fois. L'entrée porte sa destination dans « method » : son rang suit le nombre d'entrées de todo.json, qu'un numéro codé en dur ignorerait. Vérifié : 18 tests. --- EN --- Laying down the binary does not change the prompt: the init line in the shell file does, and the two steps fail separately. The package comes from the distribution when it knows it, from the upstream installer otherwise — starship is not packaged everywhere. The shell file is only asked for when several candidates exist, and the line is written only once. The entry carries its destination in « method »: its rank follows the number of todo.json entries, which a hard-coded number would ignore. Checked: 18 tests. Assisted-by: Claude Opus 5 --- script/todo/todo.py | 154 +++++++++++++++++++++++- script/todo/todo_i18n.py | 49 +++++++- test/test_todo_menu.py | 39 +++++- test/test_todo_starship.py | 237 +++++++++++++++++++++++++++++++++++++ 4 files changed, 465 insertions(+), 14 deletions(-) create mode 100644 test/test_todo_starship.py diff --git a/script/todo/todo.py b/script/todo/todo.py index c5e08d3..9b9769f 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -26,7 +26,7 @@ sys.path.append(new_path) from script.config import config_file from script.execute import execute -from script.todo import todo_prefs +from script.todo import todo_install, todo_prefs from script.todo.database_manager import DatabaseManager from script.todo.longtest_menu import LongTestMenuMixin from script.todo.proxmox_menu import ProxmoxMenuMixin @@ -256,7 +256,7 @@ class TODO( [7] {t("Analyse - Odoo database analysis")} ── {t("Sources & documentation")} ── -[8] {t("Git - Git tools")} +[8] {t("Git - Git and shell tools")} [9] {t("Doc - Documentation search")} ── {t("AI & automation")} ── @@ -2530,7 +2530,7 @@ class TODO( _GIT_HOOKS_PATH = os.path.join("script", "git", "hooks") def prompt_execute_git(self): - print(f"🤖 {t('Git management tools!')}") + print(f"🤖 {t('Git and shell management tools!')}") choices = [ {"prompt_description": t("Local git server")}, {"prompt_description": t("Add a remote to a local repository")}, @@ -2551,6 +2551,17 @@ class TODO( if config_entries: choices.extend(config_entries) + # Starship ferme la liste : c'est un outil de shell, pas de git. Son + # rang dépend du nombre d'entrées venues de todo.json, donc « method » + # porte la destination dans l'entrée elle-même — un numéro codé en dur + # mènerait ailleurs dès qu'une entrée de configuration s'ajoute. + choices.append( + { + "prompt_description": t("Install Starship on Shell"), + "method": "_shell_install_starship", + } + ) + help_info = self.fill_help_info(choices) while True: @@ -2573,7 +2584,11 @@ class TODO( if 0 < int_cmd <= len(choices): cmd_no_found = False instance = choices[int_cmd - 1] - self.execute_from_configuration(instance) + method = instance.get("method") + if method: + getattr(self, method)() + else: + self.execute_from_configuration(instance) except ValueError: pass if cmd_no_found: @@ -2675,6 +2690,137 @@ class TODO( ) print(f"✅ merge.conflictStyle = {value}") + # Le shell -> (fichier de configuration, ligne d'initialisation). La ligne + # va en FIN de fichier : starship compose le prompt et doit passer après + # tout ce qui y touche. + _STARSHIP_SHELLS = { + "bash": ("~/.bashrc", 'eval "$(starship init bash)"'), + "zsh": ("~/.zshrc", 'eval "$(starship init zsh)"'), + "fish": ("~/.config/fish/config.fish", "starship init fish | source"), + } + + # L'installateur amont pose un binaire statique. Il sert de recours parce + # que le paquet manque d'une partie des dépôts des plateformes supportées. + _STARSHIP_UPSTREAM = "curl -sS https://starship.rs/install/install.sh | sh" + + @staticmethod + def _shell_name(): + """Le nom du shell de l'utilisateur d'après $SHELL, '' s'il est vide.""" + return os.path.basename(os.environ.get("SHELL", "")).strip() + + def _shell_install_starship(self): + """Poser starship, puis l'accrocher au shell de l'utilisateur. + + Deux étapes qui échouent séparément : le binaire, que le gestionnaire + de paquets de la distribution fournit quand il le connaît, et la ligne + d'initialisation dans le fichier de configuration du shell. Sans la + seconde, starship est installé et le prompt ne change pas. + """ + if shutil.which("starship") is None: + self._shell_install_starship_binary() + if shutil.which("starship") is None: + print( + f"❌ {t('starship is not installed, shell left untouched.')}" + ) + return + self._shell_hook_starship() + + def _shell_install_starship_binary(self): + """Poser le binaire : le paquet de la distribution, sinon l'amont. + + Un refus de l'opérateur arrête là. Un paquet inconnu ou une + installation en échec passent au recours amont, qui couvre les dépôts + où starship n'est pas empaqueté. + """ + cmd = todo_install.install_command(["starship"]) + if cmd: + status = todo_install.ask_and_install( + self.execute, + cmd, + t("Install starship? (y/N): "), + self._is_yes, + ) + if status is None: + return + if status == 0 and shutil.which("starship"): + return + print(f" {t('No starship package here, falling back upstream.')}") + todo_install.ask_and_install( + self.execute, + self._STARSHIP_UPSTREAM, + t("Run the upstream installer? (y/N): "), + self._is_yes, + ) + + def _shell_starship_present(self): + """Les shells dont le fichier de configuration existe déjà.""" + return [ + nom + for nom, (fichier, _ligne) in self._STARSHIP_SHELLS.items() + if os.path.exists(os.path.expanduser(fichier)) + ] + + def _shell_starship_target(self): + """Le shell à accrocher. Ne demande que devant un vrai choix. + + Aucun fichier de configuration présent : bash, sans question — la pose + le créera. Un seul présent : celui-là, il n'y a rien à choisir. Deux + ou trois : à l'opérateur de trancher, le sien proposé par défaut. + """ + presents = self._shell_starship_present() + if not presents: + return "bash" + if len(presents) == 1: + return presents[0] + courant = self._shell_name() + defaut = courant if courant in presents else presents[0] + print(f"\n{t('Which shell configuration?')}") + for i, nom in enumerate(presents, 1): + print(f" [{i}] {nom:<5} {self._STARSHIP_SHELLS[nom][0]}") + sel = input( + f"{t('Choice (number or name, default:')} {defaut}) : " + ).strip() + if not sel: + return defaut + if sel in presents: + return sel + try: + idx = int(sel) - 1 + if 0 <= idx < len(presents): + return presents[idx] + except ValueError: + pass + return defaut + + def _shell_hook_starship(self): + """Ajouter la ligne d'initialisation au fichier du shell choisi. + + La ligne n'est écrite qu'une fois : « starship init » cherché dans le + fichier couvre les trois shells, dont les lignes diffèrent. L'écriture + ne demande pas de confirmation — le choix du fichier, quand il y en a + un à faire, l'a déjà donnée. + """ + shell = self._shell_starship_target() + chemin, ligne = self._STARSHIP_SHELLS[shell] + chemin = os.path.expanduser(chemin) + contenu = "" + if os.path.exists(chemin): + with open(chemin, encoding="utf-8") as fh: + contenu = fh.read() + if "starship init" in contenu: + print(f"✅ {t('starship is already hooked into: ')}{chemin}") + return + os.makedirs(os.path.dirname(chemin), exist_ok=True) + with open(chemin, "a", encoding="utf-8") as fh: + # Un fichier qui ne finit pas par un saut de ligne collerait la + # ligne d'init à la dernière commande. + if contenu and not contenu.endswith("\n"): + fh.write("\n") + fh.write(f"{ligne}\n") + print(f"✅ {t('starship hooked into: ')}{chemin}") + print(f" {ligne}") + print(f" {t('Open a new shell to see it.')}") + def prompt_execute_git_local_server(self): print(f"🤖 {t('Manage local git repository server!')}") choices = [ diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 88a3f96..f57a3f1 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -821,13 +821,13 @@ TRANSLATIONS = { "en": "Module name is required!", }, # Git section - "Git - Git tools": { - "fr": "🌿 Git - Outils Git", - "en": "🌿 Git - Git tools", + "Git - Git and shell tools": { + "fr": "🌿 Git - Outils Git et Shell", + "en": "🌿 Git - Git and shell tools", }, - "Git management tools!": { - "fr": "Outils de gestion Git!", - "en": "Git management tools!", + "Git and shell management tools!": { + "fr": "Outils de gestion Git et Shell!", + "en": "Git and shell management tools!", }, "Local git server": { "fr": "Serveur git local", @@ -903,6 +903,43 @@ TRANSLATIONS = { "en": "Error adding remote: ", }, # Git hooks + # Starship + "Install Starship on Shell": { + "fr": "🚀 Installer Starship sur le shell", + "en": "🚀 Install Starship on Shell", + }, + "Install starship? (y/N): ": { + "fr": "Installer starship ? (o/N) : ", + "en": "Install starship? (y/N): ", + }, + "No starship package here, falling back upstream.": { + "fr": "Aucun paquet starship ici, recours à l'installateur amont.", + "en": "No starship package here, falling back upstream.", + }, + "Run the upstream installer? (y/N): ": { + "fr": "Lancer l'installateur amont ? (o/N) : ", + "en": "Run the upstream installer? (y/N): ", + }, + "starship is not installed, shell left untouched.": { + "fr": "starship n'est pas installé, le shell reste intact.", + "en": "starship is not installed, shell left untouched.", + }, + "Which shell configuration?": { + "fr": "Quel fichier de configuration de shell ?", + "en": "Which shell configuration?", + }, + "starship is already hooked into: ": { + "fr": "starship est déjà accroché à : ", + "en": "starship is already hooked into: ", + }, + "starship hooked into: ": { + "fr": "starship accroché à : ", + "en": "starship hooked into: ", + }, + "Open a new shell to see it.": { + "fr": "Ouvrir un nouveau shell pour le voir.", + "en": "Open a new shell to see it.", + }, "Set merge.conflictStyle to zdiff3 (global)": { "fr": "Configurer merge.conflictStyle en zdiff3 (global)", "en": "Set merge.conflictStyle to zdiff3 (global)", diff --git a/test/test_todo_menu.py b/test/test_todo_menu.py index b31912c..3adb286 100644 --- a/test/test_todo_menu.py +++ b/test/test_todo_menu.py @@ -148,6 +148,10 @@ class MenuCoherence: todo.py), ce socle sert DEUX menus : QEMU/KVM et Proxmox. Un troisième n'aura qu'à déclarer ses quatre attributs. + Une entrée peut aussi porter sa destination dans « method » plutôt que + dans un « elif status » numéroté. Elle échappe alors à la renumérotation + par construction, et EXPECTED la vérifie contre cette clé. + À déclarer par la sous-classe : SOURCE (le fichier), ENTRY (la ligne « def prompt_execute_… »), END (le membre suivant, qui borne la lecture) et EXPECTED (où mène chaque entrée, par le début de son libellé). @@ -170,18 +174,33 @@ class MenuCoherence: r'(?:el)?if status == "(\d+)":\s*\n(?:\s*#.*\n)*' r"\s*(?:status = )?self\.(\w+)\(" ) + # Une entrée qui porte sa destination dans « method » se dispatche seule, + # par le repli générique. Elle n'a pas de numéro dans le code, donc aucune + # renumérotation ne peut la désaligner : c'est le seul moyen de placer une + # entrée codée en dur APRÈS des entrées venues de la configuration, dont + # le nombre n'est pas connu à la lecture du source. + RE_SELF_DISPATCH = re.compile( + r'"prompt_description": t\(\s*\n?\s*"([^"]+)"\s*\)?,?\s*\n' + r'\s*"method": "(\w+)"' + ) def setUp(self): source = self.SOURCE.read_text(encoding="utf-8") start = source.index(self.ENTRY) end = source.index(self.END, start) self.body = source[start:end] + self.self_dispatch = dict(self.RE_SELF_DISPATCH.findall(self.body)) num = 0 self.shown = [] for kind, label in self.RE_ENTRY.findall(self.body): if kind == "prompt_description": num += 1 self.shown.append((num, label)) + self.numbered = [ + (n, label) + for n, label in self.shown + if label not in self.self_dispatch + ] self.dispatch = [ (int(n), m) for n, m in self.RE_DISPATCH_CALL.findall(self.body) ] @@ -189,7 +208,7 @@ class MenuCoherence: def test_the_menu_was_actually_parsed(self): """Sur une liste vide, tout test passe : mieux vaut tomber ici.""" self.assertGreater(len(self.shown), self.MINIMUM) - self.assertEqual(len(self.shown), len(self.dispatch)) + self.assertEqual(len(self.numbered), len(self.dispatch)) def test_numbering_is_contiguous_from_one(self): self.assertEqual( @@ -199,7 +218,7 @@ class MenuCoherence: def test_every_shown_entry_has_the_matching_dispatch(self): self.assertEqual( - [n for n, _ in self.shown], [n for n, _ in self.dispatch] + [n for n, _ in self.numbered], [n for n, _ in self.dispatch] ) def _key(self, label): @@ -218,10 +237,11 @@ class MenuCoherence: f"entrée [{num}] « {label} » absente d'EXPECTED :" " déclarez où elle mène", ) + atteint = self.self_dispatch.get(label, dct.get(num)) self.assertEqual( - dct.get(num), + atteint, self.EXPECTED[key], - f"[{num}] « {label} » mène à {dct.get(num)}" + f"[{num}] « {label} » mène à {atteint}" f" au lieu de {self.EXPECTED[key]}", ) @@ -229,6 +249,16 @@ class MenuCoherence: keys = {self._key(label) for _, label in self.shown} self.assertEqual(set(self.EXPECTED) - keys, set()) + def test_self_dispatched_entries_name_a_real_method(self): + """« method » est une chaîne : rien ne la relie au code sans ceci.""" + from script.todo.todo import TODO + + for label, method in self.self_dispatch.items(): + self.assertTrue( + hasattr(TODO, method), + f"« {label} » mène à {method}, qui n'existe pas", + ) + class TestLaParitéProxmox(unittest.TestCase): """Deux manques signalés par l'audit du découpage, comblés. @@ -468,6 +498,7 @@ class TestGitMenuNumbering(MenuCoherence, unittest.TestCase): "Add a remote to a local repository": "_git_add_remote", "Install git hooks": "_git_install_hooks", "Set merge.conflictStyle": "_git_set_conflict_style", + "Install Starship on Shell": "_shell_install_starship", } diff --git a/test/test_todo_starship.py b/test/test_todo_starship.py new file mode 100644 index 0000000..fecbda6 --- /dev/null +++ b/test/test_todo_starship.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Installer starship : deux étapes qui échouent séparément. + +Poser le binaire ne change pas le prompt ; c'est la ligne d'initialisation +dans le fichier du shell qui le fait. Un menu qui confond les deux annonce un +succès devant un prompt inchangé, ou réécrit la ligne à chaque passage. + +Le fichier à modifier ne se demande que devant un vrai choix : plusieurs +fichiers de configuration présents. Aucun, ou un seul, ne laisse rien à +trancher — une question posée là n'attend qu'une frappe pour rien. +""" + +import io +import os +import tempfile +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from unittest.mock import MagicMock, patch + +from script.todo.todo import TODO + +LIGNE_BASH = 'eval "$(starship init bash)"' +LIGNE_ZSH = 'eval "$(starship init zsh)"' + + +def refuse_input(*args, **kwargs): + raise AssertionError("aucune question ne devait être posée") + + +class TestShellName(unittest.TestCase): + """Le shell se lit dans $SHELL, dont seul le nom de base compte.""" + + def test_basename_of_shell(self): + with patch.dict(os.environ, {"SHELL": "/usr/bin/zsh"}): + self.assertEqual(TODO._shell_name(), "zsh") + + def test_empty_when_unset(self): + with patch.dict(os.environ, {"SHELL": ""}): + self.assertEqual(TODO._shell_name(), "") + + +class ShellFixture(unittest.TestCase): + """Un faux HOME où l'on pose les fichiers de configuration voulus.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + racine = Path(self.tmp.name) + self.fichiers = { + "bash": racine / "bashrc", + "zsh": racine / "zshrc", + "fish": racine / "fish" / "config.fish", + } + table = { + "bash": (str(self.fichiers["bash"]), LIGNE_BASH), + "zsh": (str(self.fichiers["zsh"]), LIGNE_ZSH), + "fish": ( + str(self.fichiers["fish"]), + "starship init fish | source", + ), + } + patcher = patch.dict(TODO._STARSHIP_SHELLS, table, clear=True) + patcher.start() + self.addCleanup(patcher.stop) + self.todo = TODO() + + def cree(self, *shells, contenu=""): + for shell in shells: + chemin = self.fichiers[shell] + chemin.parent.mkdir(parents=True, exist_ok=True) + chemin.write_text(contenu, encoding="utf-8") + + +class TestStarshipTarget(ShellFixture): + """Quel fichier, et quand la question se pose.""" + + def choisit(self, shell_env="/bin/bash", reponse=None): + entree = refuse_input if reponse is None else (lambda *a: reponse) + with patch.dict(os.environ, {"SHELL": shell_env}), patch( + "builtins.input", entree + ), redirect_stdout(io.StringIO()): + return self.todo._shell_starship_target() + + def test_no_file_means_bash_without_asking(self): + self.assertEqual(self.choisit(), "bash") + + def test_a_single_file_is_taken_without_asking(self): + self.cree("zsh") + self.assertEqual(self.choisit(), "zsh") + + def test_a_single_file_wins_over_the_current_shell(self): + """Le fichier présent l'emporte : $SHELL n'en a pas, lui.""" + self.cree("fish") + self.assertEqual(self.choisit(shell_env="/bin/bash"), "fish") + + def test_several_files_ask_and_default_to_the_current_shell(self): + self.cree("bash", "zsh") + self.assertEqual( + self.choisit(shell_env="/usr/bin/zsh", reponse=""), "zsh" + ) + + def test_several_files_answered_by_number(self): + self.cree("bash", "zsh") + self.assertEqual(self.choisit(reponse="2"), "zsh") + + def test_several_files_answered_by_name(self): + self.cree("bash", "zsh") + self.assertEqual(self.choisit(reponse="zsh"), "zsh") + + def test_a_nonsense_answer_falls_back_on_the_default(self): + self.cree("bash", "zsh") + self.assertEqual(self.choisit(reponse="42"), "bash") + + +class TestHookStarship(ShellFixture): + """L'écriture de la ligne, qui ne demande rien.""" + + def hook(self, shell_env="/bin/bash"): + out = io.StringIO() + with patch.dict(os.environ, {"SHELL": shell_env}), patch( + "builtins.input", refuse_input + ), redirect_stdout(out): + self.todo._shell_hook_starship() + return out.getvalue() + + def test_it_appends_the_line(self): + self.cree("bash", contenu="export EDITOR=vim\n") + self.hook() + self.assertEqual( + self.fichiers["bash"].read_text(encoding="utf-8"), + f"export EDITOR=vim\n{LIGNE_BASH}\n", + ) + + def test_a_file_without_a_final_newline_keeps_its_last_command(self): + self.cree("bash", contenu="export EDITOR=vim") + self.hook() + self.assertEqual( + self.fichiers["bash"].read_text(encoding="utf-8"), + f"export EDITOR=vim\n{LIGNE_BASH}\n", + ) + + def test_it_writes_only_once(self): + self.cree("bash", contenu=f"{LIGNE_BASH}\n") + sortie = self.hook() + self.assertEqual( + self.fichiers["bash"].read_text(encoding="utf-8"), + f"{LIGNE_BASH}\n", + ) + self.assertIn("✅", sortie) + + def test_no_file_at_all_creates_the_bash_one(self): + self.hook() + self.assertEqual( + self.fichiers["bash"].read_text(encoding="utf-8"), + f"{LIGNE_BASH}\n", + ) + + def test_a_missing_parent_directory_is_created(self): + """config.fish vit sous ~/.config/fish, que rien ne garantit.""" + self.todo._STARSHIP_SHELLS["bash"] = ( + str(self.fichiers["fish"]), + LIGNE_BASH, + ) + self.hook() + self.assertEqual( + self.fichiers["fish"].read_text(encoding="utf-8"), + f"{LIGNE_BASH}\n", + ) + + +class TestInstallStarship(unittest.TestCase): + """L'enchaînement des deux étapes, et ce qui l'arrête.""" + + def test_an_installed_binary_goes_straight_to_the_shell(self): + todo = TODO() + todo.execute = MagicMock() + with patch( + "script.todo.todo.shutil.which", return_value="/usr/bin/starship" + ), patch.object(TODO, "_shell_hook_starship") as hook, patch.object( + TODO, "_shell_install_starship_binary" + ) as poser: + todo._shell_install_starship() + poser.assert_not_called() + hook.assert_called_once() + + def test_a_missing_binary_leaves_the_shell_alone(self): + todo = TODO() + todo.execute = MagicMock() + out = io.StringIO() + with patch( + "script.todo.todo.shutil.which", return_value=None + ), patch.object(TODO, "_shell_hook_starship") as hook, patch.object( + TODO, "_shell_install_starship_binary" + ), redirect_stdout( + out + ): + todo._shell_install_starship() + hook.assert_not_called() + self.assertIn("❌", out.getvalue()) + + def test_a_refused_package_does_not_chain_to_upstream(self): + """Un refus est une décision : il n'appelle pas une seconde offre.""" + todo = TODO() + todo.execute = MagicMock() + appels = [] + with patch( + "script.todo.todo.todo_install.install_command", + return_value=["sudo", "pacman", "-S", "starship"], + ), patch( + "script.todo.todo.todo_install.ask_and_install", + side_effect=lambda *a, **k: appels.append(a[1]) or None, + ): + todo._shell_install_starship_binary() + self.assertEqual(len(appels), 1) + + def test_no_package_falls_back_upstream(self): + todo = TODO() + todo.execute = MagicMock() + appels = [] + with patch( + "script.todo.todo.todo_install.install_command", return_value=None + ), patch( + "script.todo.todo.todo_install.ask_and_install", + side_effect=lambda *a, **k: appels.append(a[1]) or 0, + ), redirect_stdout( + io.StringIO() + ): + todo._shell_install_starship_binary() + self.assertEqual(appels, [TODO._STARSHIP_UPSTREAM]) + + +if __name__ == "__main__": + unittest.main() From 371605b90ab3cac0afd399114593a4d206386556 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 2 Sep 2026 04:43:43 -0400 Subject: [PATCH 09/17] =?UTF-8?q?[IMP]=20script=20todo=20:=20une=20ic?= =?UTF-8?q?=C3=B4ne=20par=20entr=C3=A9e=20du=20menu=20Git=20et=20Shell?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sept entrées sans repère visuel se lisent une par une. L'icône entre dans la traduction, qui porte déjà le libellé affiché. Un glyphe à présentation texte occupe une colonne quand les autres en occupent deux, et décale la colonne des libellés : il prend deux espaces, comme 🖥 et ⚙ ailleurs dans le fichier. Rendu vérifié dans les deux langues. --- EN --- Seven entries with no visual marker are read one by one. The icon goes into the translation, which already carries the displayed label. A text-presentation glyph takes one column where the others take two, and shifts the label column: it gets two spaces, like 🖥 and ⚙ elsewhere in the file. Rendering checked in both languages. Assisted-by: Claude Opus 5 --- script/todo/todo_i18n.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index f57a3f1..089d6b8 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -830,8 +830,8 @@ TRANSLATIONS = { "en": "Git and shell management tools!", }, "Local git server": { - "fr": "Serveur git local", - "en": "Local git server", + "fr": "🗄 Serveur git local", + "en": "🗄 Local git server", }, "Manage local git repository server!": { "fr": "Gérer le serveur de dépôts git local!", @@ -879,8 +879,8 @@ TRANSLATIONS = { }, # Git remote add "Add a remote to a local repository": { - "fr": "Ajouter un remote vers un dépôt local", - "en": "Add a remote to a local repository", + "fr": "🔗 Ajouter un remote vers un dépôt local", + "en": "🔗 Add a remote to a local repository", }, "Remote name (default: localhost): ": { "fr": "Nom du remote (défaut: localhost) : ", @@ -941,8 +941,8 @@ TRANSLATIONS = { "en": "Open a new shell to see it.", }, "Set merge.conflictStyle to zdiff3 (global)": { - "fr": "Configurer merge.conflictStyle en zdiff3 (global)", - "en": "Set merge.conflictStyle to zdiff3 (global)", + "fr": "🔀 Configurer merge.conflictStyle en zdiff3 (global)", + "en": "🔀 Set merge.conflictStyle to zdiff3 (global)", }, "Failed to set merge.conflictStyle, see the output above.": { "fr": ( @@ -952,8 +952,8 @@ TRANSLATIONS = { "en": "Failed to set merge.conflictStyle, see the output above.", }, "Install git hooks (commit-msg, pre-commit)": { - "fr": "Installer les hooks git (commit-msg, pre-commit)", - "en": "Install git hooks (commit-msg, pre-commit)", + "fr": "🪝 Installer les hooks git (commit-msg, pre-commit)", + "en": "🪝 Install git hooks (commit-msg, pre-commit)", }, "Hooks directory is missing: ": { "fr": "Le répertoire des hooks est absent : ", @@ -981,12 +981,12 @@ TRANSLATIONS = { }, # Git config vim "Configure git local editor to vim": { - "fr": "Configuration git local par vim", - "en": "Configure git local editor to vim", + "fr": "📝 Configuration git local par vim", + "en": "📝 Configure git local editor to vim", }, "Generate git patch to /tmp": { - "fr": "Générer une patch git dans /tmp", - "en": "Generate git patch to /tmp", + "fr": "🩹 Générer une patch git dans /tmp", + "en": "🩹 Generate git patch to /tmp", }, "Git editor configured to vim successfully!": { "fr": "Éditeur git configuré sur vim avec succès!", From 8bdbf42a3103c6e2a21954e0494066cd74720cb6 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 2 Sep 2026 04:51:03 -0400 Subject: [PATCH 10/17] [ADD] script todo : installer Claude Code et opencode, PATH garanti MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ces installateurs posent leur binaire dans un répertoire du HOME que le PATH d'un shell ne porte pas toujours : sans la ligne d'export, le binaire est là et la commande reste introuvable. Le répertoire diffère d'un outil à l'autre, et l'un des deux écrit déjà sa propre ligne — la présence se teste donc sur le répertoire, pas sur la graphie, et rien n'est ajouté deux fois. Le PATH d'un processus est figé depuis son démarrage : un nouveau shell est nécessaire, ce que la sortie dit. Troisième écrivain dans le fichier de shell, starship compris, d'où l'écriture mise en commun. Vérifié : 31 tests. --- EN --- These installers put their binary in a HOME directory that a shell's PATH does not always carry: without the export line, the binary is there and the command stays not found. The directory differs from one tool to the other, and one of the two already writes its own line — presence is therefore tested on the directory, not on the spelling, and nothing is added twice. A process PATH is frozen since its start: a new shell is needed, which the output says. Third writer into the shell file, starship included, hence the shared write. Checked: 31 tests. Assisted-by: Claude Opus 5 --- script/todo/todo.py | 224 +++++++++++++----- script/todo/todo_i18n.py | 24 ++ test/test_todo_menu.py | 2 + ...o_starship.py => test_todo_shell_tools.py} | 223 ++++++++++++++--- 4 files changed, 372 insertions(+), 101 deletions(-) rename test/{test_todo_starship.py => test_todo_shell_tools.py} (50%) diff --git a/script/todo/todo.py b/script/todo/todo.py index 9b9769f..49ff113 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -2561,6 +2561,18 @@ class TODO( "method": "_shell_install_starship", } ) + choices.append( + { + "prompt_description": t("Install Claude Code"), + "method": "_shell_install_claude_code", + } + ) + choices.append( + { + "prompt_description": t("Install opencode"), + "method": "_shell_install_opencode", + } + ) help_info = self.fill_help_info(choices) @@ -2690,24 +2702,129 @@ class TODO( ) print(f"✅ merge.conflictStyle = {value}") - # Le shell -> (fichier de configuration, ligne d'initialisation). La ligne - # va en FIN de fichier : starship compose le prompt et doit passer après - # tout ce qui y touche. - _STARSHIP_SHELLS = { - "bash": ("~/.bashrc", 'eval "$(starship init bash)"'), - "zsh": ("~/.zshrc", 'eval "$(starship init zsh)"'), - "fish": ("~/.config/fish/config.fish", "starship init fish | source"), + # Le shell -> son fichier de configuration. + _SHELL_RC = { + "bash": "~/.bashrc", + "zsh": "~/.zshrc", + "fish": "~/.config/fish/config.fish", + } + + # Ce que chaque shell écrit pour lancer starship. La ligne va en FIN de + # fichier : starship compose le prompt et doit passer après tout ce qui y + # touche. + _STARSHIP_LINE = { + "bash": 'eval "$(starship init bash)"', + "zsh": 'eval "$(starship init zsh)"', + "fish": "starship init fish | source", } # L'installateur amont pose un binaire statique. Il sert de recours parce # que le paquet manque d'une partie des dépôts des plateformes supportées. _STARSHIP_UPSTREAM = "curl -sS https://starship.rs/install/install.sh | sh" + # Les assistants posés par un installateur amont : le nom du binaire mène + # à (commande, répertoire d'installation). Le répertoire sert à garantir + # le PATH — un binaire posé hors des chemins du shell reste introuvable. + _UPSTREAM_TOOLS = { + "claude": ( + "curl -fsSL https://claude.ai/install.sh | bash", + "~/.local/bin", + ), + "opencode": ( + "curl -fsSL https://opencode.ai/install | bash", + "~/.opencode/bin", + ), + } + @staticmethod def _shell_name(): """Le nom du shell de l'utilisateur d'après $SHELL, '' s'il est vide.""" return os.path.basename(os.environ.get("SHELL", "")).strip() + def _shell_rc_present(self): + """Les shells dont le fichier de configuration existe déjà.""" + return [ + nom + for nom, fichier in self._SHELL_RC.items() + if os.path.exists(os.path.expanduser(fichier)) + ] + + def _shell_rc_target(self): + """Le shell à modifier. Ne demande que devant un vrai choix. + + Aucun fichier de configuration présent : bash, sans question — l'appel + le créera. Un seul présent : celui-là, il n'y a rien à choisir. Deux ou + trois : à l'opérateur de trancher, le sien proposé par défaut. + """ + presents = self._shell_rc_present() + if not presents: + return "bash" + if len(presents) == 1: + return presents[0] + courant = self._shell_name() + defaut = courant if courant in presents else presents[0] + print(f"\n{t('Which shell configuration?')}") + for i, nom in enumerate(presents, 1): + print(f" [{i}] {nom:<5} {self._SHELL_RC[nom]}") + sel = input( + f"{t('Choice (number or name, default:')} {defaut}) : " + ).strip() + if not sel: + return defaut + if sel in presents: + return sel + try: + idx = int(sel) - 1 + if 0 <= idx < len(presents): + return presents[idx] + except ValueError: + pass + return defaut + + def _shell_rc_append(self, shell, ligne, marqueur): + """Ajouter la ligne au fichier du shell si le marqueur n'y est pas. + + Rend le chemin du fichier quand la ligne est écrite, None quand le + marqueur y était déjà. Le marqueur, et non la ligne entière, parce + qu'une variante écrite à la main ou par un installateur amont compte + autant : ce qui importe est que l'effet soit là, pas la graphie. + """ + chemin = os.path.expanduser(self._SHELL_RC[shell]) + contenu = "" + if os.path.exists(chemin): + with open(chemin, encoding="utf-8") as fh: + contenu = fh.read() + if marqueur in contenu: + return None + os.makedirs(os.path.dirname(chemin), exist_ok=True) + with open(chemin, "a", encoding="utf-8") as fh: + # Un fichier qui ne finit pas par un saut de ligne collerait la + # ligne ajoutée à la dernière commande. + if contenu and not contenu.endswith("\n"): + fh.write("\n") + fh.write(f"{ligne}\n") + return chemin + + def _shell_path_line(self, shell, repertoire): + """La ligne qui met un répertoire dans le PATH, selon le shell.""" + if shell == "fish": + return f"fish_add_path {repertoire}" + return f'export PATH="{repertoire}:$PATH"' + + def _shell_ensure_on_path(self, shell, repertoire): + """Garantir que le répertoire est dans le PATH du shell choisi. + + Ne fait rien si le répertoire y figure déjà, quelle que soit la + graphie — les installateurs amont écrivent souvent la ligne eux-mêmes. + """ + ligne = self._shell_path_line(shell, repertoire) + chemin = self._shell_rc_append(shell, ligne, repertoire) + if chemin is None: + print(f"✅ {t('Already on the PATH: ')}{repertoire}") + return + print(f"✅ {t('PATH line added to: ')}{chemin}") + print(f" {ligne}") + def _shell_install_starship(self): """Poser starship, puis l'accrocher au shell de l'utilisateur. @@ -2752,46 +2869,6 @@ class TODO( self._is_yes, ) - def _shell_starship_present(self): - """Les shells dont le fichier de configuration existe déjà.""" - return [ - nom - for nom, (fichier, _ligne) in self._STARSHIP_SHELLS.items() - if os.path.exists(os.path.expanduser(fichier)) - ] - - def _shell_starship_target(self): - """Le shell à accrocher. Ne demande que devant un vrai choix. - - Aucun fichier de configuration présent : bash, sans question — la pose - le créera. Un seul présent : celui-là, il n'y a rien à choisir. Deux - ou trois : à l'opérateur de trancher, le sien proposé par défaut. - """ - presents = self._shell_starship_present() - if not presents: - return "bash" - if len(presents) == 1: - return presents[0] - courant = self._shell_name() - defaut = courant if courant in presents else presents[0] - print(f"\n{t('Which shell configuration?')}") - for i, nom in enumerate(presents, 1): - print(f" [{i}] {nom:<5} {self._STARSHIP_SHELLS[nom][0]}") - sel = input( - f"{t('Choice (number or name, default:')} {defaut}) : " - ).strip() - if not sel: - return defaut - if sel in presents: - return sel - try: - idx = int(sel) - 1 - if 0 <= idx < len(presents): - return presents[idx] - except ValueError: - pass - return defaut - def _shell_hook_starship(self): """Ajouter la ligne d'initialisation au fichier du shell choisi. @@ -2800,27 +2877,48 @@ class TODO( ne demande pas de confirmation — le choix du fichier, quand il y en a un à faire, l'a déjà donnée. """ - shell = self._shell_starship_target() - chemin, ligne = self._STARSHIP_SHELLS[shell] - chemin = os.path.expanduser(chemin) - contenu = "" - if os.path.exists(chemin): - with open(chemin, encoding="utf-8") as fh: - contenu = fh.read() - if "starship init" in contenu: - print(f"✅ {t('starship is already hooked into: ')}{chemin}") + shell = self._shell_rc_target() + ligne = self._STARSHIP_LINE[shell] + chemin = self._shell_rc_append(shell, ligne, "starship init") + if chemin is None: + fichier = os.path.expanduser(self._SHELL_RC[shell]) + print(f"✅ {t('starship is already hooked into: ')}{fichier}") return - os.makedirs(os.path.dirname(chemin), exist_ok=True) - with open(chemin, "a", encoding="utf-8") as fh: - # Un fichier qui ne finit pas par un saut de ligne collerait la - # ligne d'init à la dernière commande. - if contenu and not contenu.endswith("\n"): - fh.write("\n") - fh.write(f"{ligne}\n") print(f"✅ {t('starship hooked into: ')}{chemin}") print(f" {ligne}") print(f" {t('Open a new shell to see it.')}") + def _shell_install_claude_code(self): + self._shell_install_upstream_tool("claude") + + def _shell_install_opencode(self): + self._shell_install_upstream_tool("opencode") + + def _shell_install_upstream_tool(self, binaire): + """Lancer l'installateur amont d'un assistant, puis garantir le PATH. + + Ces installateurs posent leur binaire dans un répertoire du HOME que + le PATH d'un shell ne porte pas toujours : sans la ligne d'export, le + binaire est là et la commande reste introuvable. Le PATH du processus + courant, lui, est figé depuis son démarrage — le menu ne verra pas le + binaire avant d'être relancé. + """ + commande, repertoire = self._UPSTREAM_TOOLS[binaire] + status = self.execute.exec_command_live( + commande, + source_erplibre=False, + ) + if status: + print(f"❌ {t('Installation failed, see the output above.')}") + return + self._shell_ensure_on_path(self._shell_rc_target(), repertoire) + pose = os.path.join(os.path.expanduser(repertoire), binaire) + if not os.path.exists(pose): + print(f"⚠ {t('Binary not found at: ')}{pose}") + return + print(f"✅ {binaire} : {pose}") + print(f" {t('Open a new shell to see it.')}") + def prompt_execute_git_local_server(self): print(f"🤖 {t('Manage local git repository server!')}") choices = [ diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 089d6b8..ec5e701 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -908,6 +908,30 @@ TRANSLATIONS = { "fr": "🚀 Installer Starship sur le shell", "en": "🚀 Install Starship on Shell", }, + "Install Claude Code": { + "fr": "🤖 Installer Claude Code", + "en": "🤖 Install Claude Code", + }, + "Install opencode": { + "fr": "🧠 Installer opencode", + "en": "🧠 Install opencode", + }, + "Installation failed, see the output above.": { + "fr": "L'installation a échoué, voir la sortie ci-dessus.", + "en": "Installation failed, see the output above.", + }, + "Already on the PATH: ": { + "fr": "Déjà dans le PATH : ", + "en": "Already on the PATH: ", + }, + "PATH line added to: ": { + "fr": "Ligne de PATH ajoutée à : ", + "en": "PATH line added to: ", + }, + "Binary not found at: ": { + "fr": "Binaire introuvable à : ", + "en": "Binary not found at: ", + }, "Install starship? (y/N): ": { "fr": "Installer starship ? (o/N) : ", "en": "Install starship? (y/N): ", diff --git a/test/test_todo_menu.py b/test/test_todo_menu.py index 3adb286..9066c7c 100644 --- a/test/test_todo_menu.py +++ b/test/test_todo_menu.py @@ -499,6 +499,8 @@ class TestGitMenuNumbering(MenuCoherence, unittest.TestCase): "Install git hooks": "_git_install_hooks", "Set merge.conflictStyle": "_git_set_conflict_style", "Install Starship on Shell": "_shell_install_starship", + "Install Claude Code": "_shell_install_claude_code", + "Install opencode": "_shell_install_opencode", } diff --git a/test/test_todo_starship.py b/test/test_todo_shell_tools.py similarity index 50% rename from test/test_todo_starship.py rename to test/test_todo_shell_tools.py index fecbda6..ae647f0 100644 --- a/test/test_todo_starship.py +++ b/test/test_todo_shell_tools.py @@ -2,14 +2,16 @@ # © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) -"""Installer starship : deux étapes qui échouent séparément. +"""Les outils de shell du menu Git et Shell : starship, Claude Code, opencode. -Poser le binaire ne change pas le prompt ; c'est la ligne d'initialisation -dans le fichier du shell qui le fait. Un menu qui confond les deux annonce un -succès devant un prompt inchangé, ou réécrit la ligne à chaque passage. +Trois installations, un même piège : poser le binaire ne suffit pas. Starship +ne change rien au prompt sans sa ligne d'initialisation ; les deux assistants +posent leur binaire dans un répertoire du HOME que le PATH ne porte pas +toujours. La seconde étape est celle qu'on oublie, et son absence ne se voit +qu'au prochain shell. -Le fichier à modifier ne se demande que devant un vrai choix : plusieurs -fichiers de configuration présents. Aucun, ou un seul, ne laisse rien à +Le fichier de configuration à modifier ne se demande que devant un vrai +choix : plusieurs fichiers présents. Aucun, ou un seul, ne laisse rien à trancher — une question posée là n'attend qu'une frappe pour rien. """ @@ -25,6 +27,7 @@ from script.todo.todo import TODO LIGNE_BASH = 'eval "$(starship init bash)"' LIGNE_ZSH = 'eval "$(starship init zsh)"' +LIGNE_FISH = "starship init fish | source" def refuse_input(*args, **kwargs): @@ -49,21 +52,17 @@ class ShellFixture(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) - racine = Path(self.tmp.name) + self.racine = Path(self.tmp.name) self.fichiers = { - "bash": racine / "bashrc", - "zsh": racine / "zshrc", - "fish": racine / "fish" / "config.fish", + "bash": self.racine / "bashrc", + "zsh": self.racine / "zshrc", + "fish": self.racine / "fish" / "config.fish", } - table = { - "bash": (str(self.fichiers["bash"]), LIGNE_BASH), - "zsh": (str(self.fichiers["zsh"]), LIGNE_ZSH), - "fish": ( - str(self.fichiers["fish"]), - "starship init fish | source", - ), - } - patcher = patch.dict(TODO._STARSHIP_SHELLS, table, clear=True) + patcher = patch.dict( + TODO._SHELL_RC, + {nom: str(p) for nom, p in self.fichiers.items()}, + clear=True, + ) patcher.start() self.addCleanup(patcher.stop) self.todo = TODO() @@ -75,7 +74,7 @@ class ShellFixture(unittest.TestCase): chemin.write_text(contenu, encoding="utf-8") -class TestStarshipTarget(ShellFixture): +class TestRcTarget(ShellFixture): """Quel fichier, et quand la question se pose.""" def choisit(self, shell_env="/bin/bash", reponse=None): @@ -83,7 +82,7 @@ class TestStarshipTarget(ShellFixture): with patch.dict(os.environ, {"SHELL": shell_env}), patch( "builtins.input", entree ), redirect_stdout(io.StringIO()): - return self.todo._shell_starship_target() + return self.todo._shell_rc_target() def test_no_file_means_bash_without_asking(self): self.assertEqual(self.choisit(), "bash") @@ -116,8 +115,71 @@ class TestStarshipTarget(ShellFixture): self.assertEqual(self.choisit(reponse="42"), "bash") +class TestRcAppend(ShellFixture): + """L'écriture d'une ligne dans le fichier du shell, une seule fois.""" + + def test_it_appends_and_returns_the_path(self): + self.cree("bash", contenu="export EDITOR=vim\n") + chemin = self.todo._shell_rc_append("bash", "LIGNE", "LIGNE") + self.assertEqual(chemin, str(self.fichiers["bash"])) + self.assertEqual( + self.fichiers["bash"].read_text(encoding="utf-8"), + "export EDITOR=vim\nLIGNE\n", + ) + + def test_a_present_marker_writes_nothing(self): + self.cree("bash", contenu="déjà LIGNE ici\n") + self.assertIsNone(self.todo._shell_rc_append("bash", "LIGNE", "LIGNE")) + self.assertEqual( + self.fichiers["bash"].read_text(encoding="utf-8"), + "déjà LIGNE ici\n", + ) + + def test_a_marker_written_otherwise_still_counts(self): + """Une variante à la main compte : c'est l'effet qui importe.""" + self.cree("bash", contenu='PATH="$HOME/.local/bin:$PATH"\n') + self.assertIsNone( + self.todo._shell_rc_append( + "bash", "export PATH=...", "/.local/bin" + ) + ) + + def test_a_file_without_a_final_newline_keeps_its_last_command(self): + self.cree("bash", contenu="export EDITOR=vim") + self.todo._shell_rc_append("bash", "LIGNE", "LIGNE") + self.assertEqual( + self.fichiers["bash"].read_text(encoding="utf-8"), + "export EDITOR=vim\nLIGNE\n", + ) + + def test_a_missing_parent_directory_is_created(self): + """config.fish vit sous ~/.config/fish, que rien ne garantit.""" + self.todo._shell_rc_append("fish", "LIGNE", "LIGNE") + self.assertEqual( + self.fichiers["fish"].read_text(encoding="utf-8"), "LIGNE\n" + ) + + +class TestPathLine(unittest.TestCase): + """La syntaxe du PATH n'est pas la même partout.""" + + def test_posix_shells_export(self): + todo = TODO() + for shell in ("bash", "zsh"): + self.assertEqual( + todo._shell_path_line(shell, "~/.local/bin"), + 'export PATH="~/.local/bin:$PATH"', + ) + + def test_fish_has_its_own_builtin(self): + self.assertEqual( + TODO()._shell_path_line("fish", "~/.local/bin"), + "fish_add_path ~/.local/bin", + ) + + class TestHookStarship(ShellFixture): - """L'écriture de la ligne, qui ne demande rien.""" + """L'écriture de la ligne d'initialisation, qui ne demande rien.""" def hook(self, shell_env="/bin/bash"): out = io.StringIO() @@ -135,12 +197,12 @@ class TestHookStarship(ShellFixture): f"export EDITOR=vim\n{LIGNE_BASH}\n", ) - def test_a_file_without_a_final_newline_keeps_its_last_command(self): - self.cree("bash", contenu="export EDITOR=vim") + def test_each_shell_gets_its_own_line(self): + self.cree("fish") self.hook() self.assertEqual( - self.fichiers["bash"].read_text(encoding="utf-8"), - f"export EDITOR=vim\n{LIGNE_BASH}\n", + self.fichiers["fish"].read_text(encoding="utf-8"), + f"{LIGNE_FISH}\n", ) def test_it_writes_only_once(self): @@ -159,18 +221,6 @@ class TestHookStarship(ShellFixture): f"{LIGNE_BASH}\n", ) - def test_a_missing_parent_directory_is_created(self): - """config.fish vit sous ~/.config/fish, que rien ne garantit.""" - self.todo._STARSHIP_SHELLS["bash"] = ( - str(self.fichiers["fish"]), - LIGNE_BASH, - ) - self.hook() - self.assertEqual( - self.fichiers["fish"].read_text(encoding="utf-8"), - f"{LIGNE_BASH}\n", - ) - class TestInstallStarship(unittest.TestCase): """L'enchaînement des deux étapes, et ce qui l'arrête.""" @@ -233,5 +283,102 @@ class TestInstallStarship(unittest.TestCase): self.assertEqual(appels, [TODO._STARSHIP_UPSTREAM]) +class TestUpstreamTools(ShellFixture): + """Claude Code et opencode : l'installateur, puis le PATH.""" + + def setUp(self): + super().setUp() + self.todo.execute = MagicMock() + # Chaque outil pose son binaire dans un répertoire du faux HOME. + self.repertoires = { + "claude": self.racine / "local" / "bin", + "opencode": self.racine / "opencode" / "bin", + } + patcher = patch.dict( + TODO._UPSTREAM_TOOLS, + { + nom: ( + TODO._UPSTREAM_TOOLS[nom][0], + str(chemin), + ) + for nom, chemin in self.repertoires.items() + }, + ) + patcher.start() + self.addCleanup(patcher.stop) + + def pose_le_binaire(self, outil): + chemin = self.repertoires[outil] + chemin.mkdir(parents=True, exist_ok=True) + (chemin / outil).write_text("", encoding="utf-8") + + def installe(self, outil, status=0): + self.todo.execute.exec_command_live.return_value = status + out = io.StringIO() + with patch.dict(os.environ, {"SHELL": "/bin/bash"}), patch( + "builtins.input", refuse_input + ), redirect_stdout(out): + self.todo._shell_install_upstream_tool(outil) + return out.getvalue() + + def test_the_documented_installers_are_the_ones_run(self): + self.assertEqual( + TODO._UPSTREAM_TOOLS["claude"][0], + "curl -fsSL https://claude.ai/install.sh | bash", + ) + self.assertEqual( + TODO._UPSTREAM_TOOLS["opencode"][0], + "curl -fsSL https://opencode.ai/install | bash", + ) + + def test_a_failed_install_touches_no_shell_file(self): + self.cree("bash", contenu="export EDITOR=vim\n") + sortie = self.installe("claude", status=1) + self.assertIn("❌", sortie) + self.assertEqual( + self.fichiers["bash"].read_text(encoding="utf-8"), + "export EDITOR=vim\n", + ) + + def test_a_success_puts_the_directory_on_the_path(self): + self.cree("bash") + self.pose_le_binaire("claude") + self.installe("claude") + contenu = self.fichiers["bash"].read_text(encoding="utf-8") + self.assertIn(str(self.repertoires["claude"]), contenu) + self.assertIn("export PATH=", contenu) + + def test_each_tool_gets_its_own_directory(self): + self.cree("bash") + self.pose_le_binaire("opencode") + self.installe("opencode") + contenu = self.fichiers["bash"].read_text(encoding="utf-8") + self.assertIn(str(self.repertoires["opencode"]), contenu) + self.assertNotIn(str(self.repertoires["claude"]), contenu) + + def test_a_directory_already_on_the_path_is_not_added_twice(self): + """L'installateur amont écrit souvent la ligne lui-même.""" + ligne = f'export PATH="{self.repertoires["opencode"]}:$PATH"\n' + self.cree("bash", contenu=ligne) + self.pose_le_binaire("opencode") + self.installe("opencode") + contenu = self.fichiers["bash"].read_text(encoding="utf-8") + self.assertEqual(contenu.count(str(self.repertoires["opencode"])), 1) + + def test_a_binary_that_did_not_land_is_said(self): + self.cree("bash") + sortie = self.installe("claude") + self.assertIn("⚠", sortie) + + def test_the_two_menu_entries_reach_the_shared_path(self): + for methode, outil in ( + ("_shell_install_claude_code", "claude"), + ("_shell_install_opencode", "opencode"), + ): + with patch.object(TODO, "_shell_install_upstream_tool") as partage: + getattr(self.todo, methode)() + partage.assert_called_once_with(outil) + + if __name__ == "__main__": unittest.main() From c32e7d87c9982218f25474e3bf3d608be2444612 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 2 Sep 2026 06:15:43 -0400 Subject: [PATCH 11/17] =?UTF-8?q?[ADD]=20script=20todo=20:=20d=C3=A9ployer?= =?UTF-8?q?=20/git=5Fprepare=5Fmerge=20depuis=20le=20menu=20Claude?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une fusion apporte plusieurs commits d'un coup, et rien ne guidait les deux écrits qu'elle demande : l'entrée de changelog et le message de merge. La commande déployée impose la source — CHANGELOG.base.md, les fichiers générés étant perdus au prochain doc_markdown — et le corps bilingue avec son trailer, que le garde-fou ne verra jamais, commit-msg ignorant tout message ouvrant sur « Merge ». Elle prépare, elle ne fusionne pas : le message part dans tasks/, non versionné. Vérifié : 45 tests du menu, et un déploiement dans un HOME jetable que l'écran de contexte relit « à jour » face à son gabarit. --- EN --- A merge lands several commits at once, and nothing guided the two pieces of writing it needs: the changelog entry and the merge message. The deployed command imposes the source — CHANGELOG.base.md, the generated files being lost at the next doc_markdown — and the bilingual body with its trailer, which the guard rail never sees, commit-msg skipping any message opening on « Merge ». It prepares, it does not merge: the message goes to tasks/, which is not versioned. Checked: 45 menu tests, and a deployment into a throwaway HOME that the context screen reads back as up to date against its template. Assisted-by: Claude Opus 5 --- ...plate_claude_commands_git_prepare_merge.md | 170 ++++++++++++++++++ script/todo/todo.py | 15 +- script/todo/todo_i18n.py | 4 + 3 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 conf/template_claude_commands_git_prepare_merge.md diff --git a/conf/template_claude_commands_git_prepare_merge.md b/conf/template_claude_commands_git_prepare_merge.md new file mode 100644 index 0000000..be801a8 --- /dev/null +++ b/conf/template_claude_commands_git_prepare_merge.md @@ -0,0 +1,170 @@ +--- +name: git_prepare_merge +description: "ERPLibre merge preparation: changelog entry, then the merge message for the current branch." +disable-model-invocation: true +allowed-tools: + - Bash(git status:*) + - Bash(git branch:*) + - Bash(git log:*) + - Bash(git diff:*) + - Bash(git merge-base:*) + - Bash(sed:*) + - Bash(make doc_markdown:*) + - Bash(python3:*) + - Read + - Edit + - Write +--- + +## Context + +- Current branch: !`git branch --show-current` +- Branch commits: !`git log --oneline $(git merge-base HEAD master)..HEAD` +- Files touched: !`git diff --stat $(git merge-base HEAD master)..HEAD` +- Working tree: !`git status --porcelain` +- Changelog head: !`sed -n '24,45p' CHANGELOG.base.md` + +## Task + +Prepare the merge of the CURRENT branch into its integration branch. Two +deliverables, in this order: the changelog entry, then the merge message. +Nothing is merged here — `/git_prepare_merge` prepares, the human merges. + +### 0. Read the branch + +`master` is production, `develop` is where the work lands. Take the target +from where the branch forked: `git merge-base HEAD develop` and +`git merge-base HEAD master`, the closer of the two names the target. + +Read the WHOLE branch before writing a word — `git log -p ..HEAD` for +the commits, `git diff ..HEAD` for the net result. A merge message +summarises what the branch delivers, which is rarely the concatenation of its +subjects: commits that undo each other cancel, and a fix to a feature added on +the same branch is part of the feature, not a separate line. + +Stop and say so, rather than inventing, when the branch is empty, when it is +already merged, or when the working tree carries changes not yet committed — +uncommitted work is not part of the merge and must not be described as if it +were. + +### 1. The changelog entry + +`CHANGELOG.base.md` at the repository root is the SOURCE. `CHANGELOG.md` and +`CHANGELOG.fr.md` are generated by mmg and every direct edit to them is lost +at the next `make doc_markdown` — never open them to write. + +The entry goes under `## [Unreleased]`, in the section that fits: Added / +Ajouté, Changed / Modifié, Fixed / Corrigé, Removed / Retiré, Security / +Sécurité. Create the pair of headings if the section does not exist yet, in +the file's own order. + +The file alternates language blocks with markers. Within one section the +English bullets sit under `` and the French translation under +``, in the SAME order: the two lists are read side by side, and a +bullet added to one language only leaves the other half wrong. Nothing goes +under `` but the version headings. + +What a bullet says: what the software now DOES or REFUSES, in the present, for +someone who was not on this branch. It is longer than a commit subject and +shorter than the commit body — the reader is choosing whether to upgrade, not +reviewing the diff. Keep the failure mode removed, the figure that bounds it, +the flag or the file a user has to know. Drop the internals nobody outside +calls. + +The two rules of `.claude/rules/04-code-conventions.md` hold here as +everywhere: nothing identifying — no customer, no real database, no host, no +address, no account path — and the code as the subject, never the session +that produced it. + +Regenerate afterwards, and stage the three files together, the generated pair +being what most readers actually open: + +```bash +make doc_markdown +git status --porcelain CHANGELOG.base.md CHANGELOG.md CHANGELOG.fr.md +``` + +### 2. The merge message + +Resolve `{MODEL}` exactly as `/commit` does — the trailer is required here +too, a merge message being as AI-assisted as any other. Run: + +```bash +python3 -c " +import glob, json, os, sys +sid = os.environ.get('CLAUDE_CODE_SESSION_ID', '') +hits = glob.glob(os.path.expanduser('~/.claude/projects/*/%s.jsonl' % sid)) if sid else [] +mid = '' +for path in hits[:1]: + with open(path) as fh: + for line in fh: + try: + m = json.loads(line).get('message', {}).get('model', '') + except Exception: + continue + if m and not m.startswith('<'): + mid = m +if not mid: + sys.exit('UNKNOWN') +mid = mid.removeprefix('claude-') +parts = [p for p in mid.split('-') if not (len(p) == 8 and p.isdigit())] +print('Claude %s %s' % (parts[0].capitalize(), '.'.join(parts[1:]))) +" +``` + +The shape, as this repository writes it: + +``` +Merge branch '' + +[TAG] scope: what the branch delivers, imperative, 72 characters maximum + + commits. Why the branch existed: the failure mode it removes, the +figure that bounds it, what was verified and how. Wrap at 80 characters. + +--- EN --- + +The same body, translated. + +Assisted-by: {MODEL} +``` + +The first line stays `Merge branch ''` — git writes it, tools read it, +and a merge whose first line says something else no longer looks like a merge +in `git log --oneline`. The tagged line beneath it is what a reader gets from +`--oneline` on the second row and from a release note, so it carries the same +duty as a commit subject: name the part of the system, then what is now +different about it. The evidence — the symptom, the quoted error, the +metaphor — belongs in the body. + +The body: the same budget as a commit, per language, and here a merge covers +several commits, so it is a SUMMARY and not a list. Open by stating how many +commits the branch carries, then say what they add up to. No bullet list, no +per-commit rundown: `git log ..HEAD` already gives that, and a body +repeating it teaches nothing. + +The bilingual rule holds — body, then the marker naming the language of what +FOLLOWS, then the translation — as does the ban on naming an AI in +`Co-authored-by:`. + +**The hook does not check this one.** `script/git/hooks/commit-msg` skips any +message beginning with `Merge `, along with `Revert `, `fixup!` and `squash!`. +Length, addresses and account paths pass unchallenged here, so the discipline +is entirely yours. + +### 3. Hand it over + +Write the message to `tasks/merge_message.txt` — `tasks/` is not versioned, +which is why the repository sends working material there — and print the two +commands the human runs, with `--no-ff` so the branch keeps a merge commit and +its history stays readable: + +```bash +git switch +git merge --no-ff -F tasks/merge_message.txt +``` + +Do not run them. Do not switch branch, do not merge, do not push: the merge is +the human's decision and the last chance to read the message before it is +permanent. Report, in a sentence each, the changelog section written to and +the number of commits summarised. diff --git a/script/todo/todo.py b/script/todo/todo.py index 49ff113..a924289 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -3050,6 +3050,11 @@ class TODO( print(f"🤖 {t('Deploy Claude Code commands!')}") choices = [ {"prompt_description": t("Commit - OCA/Odoo commit command")}, + { + "prompt_description": t( + "Git prepare merge - Git merge preparation command" + ) + }, { "prompt_description": t( "Todo Add Command - Add a command to todo.py menu" @@ -3071,11 +3076,16 @@ class TODO( personalize=True, ) elif status == "2": + self._setup_claude_command( + "git_prepare_merge", + "template_claude_commands_git_prepare_merge.md", + ) + elif status == "3": self._setup_claude_command( "todo_add_command", "template_claude_commands_todo_add_command.md", ) - elif status == "3": + elif status == "4": self._list_claude_commands() else: print(t("Command not found !")) @@ -3203,6 +3213,9 @@ class TODO( print(f"{t('Deployed commands'):<22} ~/.claude/commands/") gabarits = { "commit": "template_claude_commands_commit.md", + "git_prepare_merge": ( + "template_claude_commands_git_prepare_merge.md" + ), "todo_add_command": "template_claude_commands_todo_add_command.md", } for nom, gabarit in sorted(gabarits.items()): diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index ec5e701..318e53e 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -1176,6 +1176,10 @@ TRANSLATIONS = { "fr": "Commit - Commande de commit OCA/Odoo", "en": "Commit - OCA/Odoo commit command", }, + "Git prepare merge - Git merge preparation command": { + "fr": "Git prepare merge - Commande de préparation merge git", + "en": "Git prepare merge - Git merge preparation command", + }, "Todo Add Command - Add a command to todo.py menu": { "fr": "Todo Add Command - Ajouter une commande au menu todo.py", "en": "Todo Add Command - Add a command to todo.py menu", From 6847891c1b9df3f4ef73fae26c00b890bb32b6fc Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 2 Sep 2026 06:35:05 -0400 Subject: [PATCH 12/17] =?UTF-8?q?[ADD]=20script=20todo=20:=20g=C3=A9rer=20?= =?UTF-8?q?les=20plugins=20Claude=20Code=20et=20leur=20liste=20ERPLibre?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rien ne posait un plugin depuis TODO ; la CLI seule le faisait, hors du menu. L'installation passe par « -y » : la sortie de TODO est un tuyau, pas un terminal, et la CLI refuse sans lui toute installation qui exécute une commande déclarée par un marketplace. La liste préférée s'affiche donc AVANT la confirmation, seule occasion de la lire ; ses quatre plugins travaillent sur le poste, sans service tiers ni compte. La recherche lit les manifestes sur le disque, donc hors ligne. Vérifié : 8 tests neufs, dont la frontière de mot qui sépare deux noms dont l'un contient l'autre — une recherche naïve en rate trois. --- EN --- Nothing installed a plugin from TODO; the CLI alone did, outside the menu. Installing goes through « -y »: TODO's output is a pipe, not a terminal, and without it the CLI refuses any install that runs a command declared by a marketplace. The preferred list is therefore shown BEFORE the confirmation, the only chance to read it; its four plugins run on the workstation, with no third party and no account. Search reads the manifests from disk, hence offline. Checked: 8 new tests, among them the word boundary separating two names where one contains the other — a naive search misses three of them. Assisted-by: Claude Opus 5 --- script/todo/todo.py | 232 +++++++++++++++++++++++++++++++++++++++ script/todo/todo_i18n.py | 117 ++++++++++++++++++++ test/test_todo.py | 128 +++++++++++++++++++++ 3 files changed, 477 insertions(+) diff --git a/script/todo/todo.py b/script/todo/todo.py index a924289..5454edf 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -3027,6 +3027,11 @@ class TODO( ) }, {"prompt_description": t("Show the context given to Claude")}, + { + "prompt_description": t( + "Claude Code plugins - marketplaces and ERPLibre list" + ) + }, ] help_info = self.fill_help_info(choices) @@ -3043,6 +3048,8 @@ class TODO( self.prompt_execute_rtk() elif status == "4": self._show_claude_context() + elif status == "5": + self.prompt_execute_claude_plugins() else: print(t("Command not found !")) @@ -3334,6 +3341,231 @@ class TODO( except Exception as e: print(f"{t('Error adding automation: ')}{e}") + # Les plugins qu'ERPLibre pose par défaut, chacun avec la clé qui dit à + # quoi il sert. Tous viennent du marketplace officiel et travaillent sur + # le poste : aucun n'appelle un service tiers ni ne réclame de compte. + _CLAUDE_PREFERRED_PLUGINS = ( + ("superpowers", "brainstorming, subagent-driven development, TDD"), + ("pyright-lsp", "Python type checking and code intelligence"), + ("claude-security", "vulnerability scan run entirely in session"), + ( + "skill-creator", + "write, improve and evaluate the repository skills", + ), + ) + _CLAUDE_MARKETPLACES_DIR = "~/.claude/plugins/marketplaces" + + def prompt_execute_claude_plugins(self): + print(f"🤖 {t('Manage Claude Code plugins and marketplaces!')}") + choices = [ + {"section": t("Inventory")}, + {"prompt_description": t("List installed plugins")}, + {"prompt_description": t("List configured marketplaces")}, + {"prompt_description": t("Search a plugin in the marketplaces")}, + { + "prompt_description": t( + "Show a plugin detail and its token cost" + ) + }, + {"section": t("Install")}, + {"prompt_description": t("Install the ERPLibre preferred list")}, + {"prompt_description": t("Install a plugin by name")}, + {"prompt_description": t("Add a marketplace")}, + {"section": t("Maintenance")}, + { + "prompt_description": t( + "Update the marketplaces and the plugins" + ) + }, + {"prompt_description": t("Uninstall a plugin")}, + ] + help_info = self.fill_help_info(choices) + + while True: + status = click.prompt(help_info) + print() + if status == "0": + return False + elif status == "1": + self._claude_plugin_exec("list") + elif status == "2": + self._claude_plugin_exec("marketplace list") + elif status == "3": + self._claude_plugin_search() + elif status == "4": + self._claude_plugin_details() + elif status == "5": + self._claude_install_preferred_plugins() + elif status == "6": + self._claude_plugin_install_by_name() + elif status == "7": + self._claude_marketplace_add() + elif status == "8": + self._claude_plugin_update() + elif status == "9": + self._claude_plugin_uninstall() + else: + print(t("Command not found !")) + + def _claude_plugin_exec(self, args, quiet=False, capture=False): + """Lance « claude plugin », ou signale que claude est absent. + + Rend le code de sortie, ou le couple (code, lignes) quand capture est + vrai. Le code 1 sans sortie signale l'absence de l'exécutable : rien + n'a tourné, et l'appelant ne doit pas conclure à un échec de la + commande elle-même. + """ + claude = shutil.which("claude") + if claude is None: + print(t("The claude command is not in the PATH.")) + return (1, []) if capture else 1 + return self.execute.exec_command_live( + f"{shlex.quote(claude)} plugin {args}", + source_erplibre=False, + quiet=quiet, + return_status_and_output=capture, + ) + + def _claude_plugin_is_installed(self, name): + """Le plugin est-il déjà posé ? + + La liste est lue telle que la CLI l'écrit, et le nom y est cherché + comme un mot entier : « code-review » ne doit pas se reconnaître dans + « pr-review-toolkit ». Un doute rend faux, et l'installation qui suit + est de toute façon idempotente. + """ + result = self._claude_plugin_exec("list", quiet=True, capture=True) + if not isinstance(result, tuple) or result[0] != 0: + return False + motif = re.compile(rf"(? Date: Wed, 2 Sep 2026 06:35:57 -0400 Subject: [PATCH 13/17] =?UTF-8?q?[ADD]=20script=20todo=20:=20/todo=5Fplan?= =?UTF-8?q?=5Fmax,=20planifier=20avant=20d'ajouter=20une=20entr=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajouter une entrée commence par des décisions qu'aucun gabarit n'imposait : quel menu parent, motif A ou B, quoi faire en cas d'échec, ce qui est détruit, ce qui touche à une donnée client. La commande les demande avant d'écrire, planifie avec superpowers quand le plugin est là, et pose sa spécification dans tasks/, non versionné. Son frontmatter fixe l'effort à max, seule valeur de l'énumération ; ultracode est un épinglage de session que l'utilisateur tape, non un réglage de gabarit. Vérifié : les deux gabarits déployés dans un HOME jetable, relus « à jour » par l'écran de contexte. --- EN --- Adding an entry starts with decisions no template forced: which parent menu, pattern A or B, what to do on failure, what gets destroyed, what touches customer data. The command asks them before writing, plans with superpowers when the plugin is there, and lays its specification in tasks/, which is not versioned. Its frontmatter sets effort to max, the only value in the enumeration; ultracode is a session pin the user types, not a template setting. Checked: both templates deployed into a throwaway HOME, read back as up to date by the context screen. Assisted-by: Claude Opus 5 --- ...mplate_claude_commands_todo_add_command.md | 8 ++ .../template_claude_commands_todo_plan_max.md | 115 ++++++++++++++++++ script/todo/todo.py | 11 +- script/todo/todo_i18n.py | 9 +- 4 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 conf/template_claude_commands_todo_plan_max.md diff --git a/conf/template_claude_commands_todo_add_command.md b/conf/template_claude_commands_todo_add_command.md index dce3469..eceaec0 100644 --- a/conf/template_claude_commands_todo_add_command.md +++ b/conf/template_claude_commands_todo_add_command.md @@ -17,6 +17,14 @@ allowed-tools: - Current todo.json sections: !`python3 -c "import json; d=json.load(open('script/todo/todo.json')); print('\n'.join(d.keys()))"` - Current i18n keys count: !`grep -c fr.: script/todo/todo_i18n.py` +## Planning first + +`/todo_plan_max` plans one entry at maximum effort: it asks what forks the +design, uses the superpowers plugin when it is installed, and writes the +specification to `tasks/todo.md`. Both commands are deployed together by +`TODO › Execute › GPT code › Claude configs`. When such a specification +exists, implement it rather than re-deciding the design here. + ## Architecture Reference ### Files to modify diff --git a/conf/template_claude_commands_todo_plan_max.md b/conf/template_claude_commands_todo_plan_max.md new file mode 100644 index 0000000..3ed1aac --- /dev/null +++ b/conf/template_claude_commands_todo_plan_max.md @@ -0,0 +1,115 @@ +--- +name: todo_plan_max +description: "Plan a todo.py command at maximum effort: questions first, superpowers when installed, then the specification /todo_add_command implements." +disable-model-invocation: true +effort: max +allowed-tools: + - Bash(claude plugin list:*) + - Bash(claude plugin marketplace list:*) + - Bash(grep:*) + - Bash(sed:*) + - Bash(ls:*) + - Bash(git log:*) + - Read + - Glob + - Grep + - Write +--- + +## Context + +- Existing menus: !`grep -n "def prompt_execute" script/todo/todo.py` +- Menu sections in todo.json: !`python3 -c "import json;print(*json.load(open('script/todo/todo.json')),sep='\n')"` +- Installed plugins: !`claude plugin list` +- Last commits on the menu: !`git log --oneline -8 -- script/todo/` + +## Task + +Plan ONE command for the `script/todo/todo.py` menu, at the effort tier this +file pins. Planning is the whole job: this command produces a specification +and writes no menu code. `/todo_add_command` implements what comes out. + +### The effort tier + +The `effort: max` above applies to this invocation and to it alone. + +Two other levers exist, and they are the user's to pull, not yours. `/effort +ultracode` PINS ultracode for the rest of the session — every substantive task +then goes through the Workflow tool, and an interactive terminal releases the +pin with `/effort high`. The bare keyword `ultracode` in a typed prompt opts in +that ONE turn. Say which of the two would serve, and let the user type it; +never claim a pin that is not shown as on. + +Invoking this command is itself an explicit opt-in to multi-agent +orchestration, so the Workflow tool is available here without any further ask. +Reach for it when the plan genuinely has independent dimensions to explore in +parallel — several candidate designs, or a survey of how the existing menus +already solve the problem. A single obvious entry does not need one, and a +workflow spawned for it burns tokens the user is paying for. + +### 1. Ask before planning + +Ask with the question tool, never as prose the user has to answer in a +paragraph. Ask ONLY what changes the plan — a question whose every answer +leads to the same design is noise, and four is the ceiling per round. + +What usually forks the design, in this repository: + +- **Which menu.** Git, Code, Database, Config, Network, Process, Test, Update, + Run, Doc, Security — the parent decides who finds the entry. +- **Pattern A or B.** A hard-coded method when the entry prompts, branches or + reads state; a `todo.json` entry when it is one bash command or one make + target. Guessing wrong costs a rewrite, not an edit. +- **What it does on failure.** An entry that stops at the first error, one that + carries on and reports at the end, and one that asks before each step are + three different features wearing one name. +- **Whether it destroys anything.** A command that drops a database, deletes a + VM or overwrites a file needs a confirmation prompt and a name typed in full; + the repository already writes them that way. +- **Whether it touches customer data.** Only `private/` may hold it. If the + answer is yes, the plan says where the data lives and what never leaves it. + +Take the answers as given. When one contradicts what the code does, say so in +a sentence and plan what was asked for. + +### 2. Plan with superpowers when it is installed + +Read the context block above. When `superpowers` appears among the installed +plugins, use it: its brainstorming skill for the design, its subagent-driven +development and code-review skills for the shape of the work, its systematic +debugging skill when the entry wraps something that already misbehaves. + +When it is absent, say so in one line and plan without it — plan mode, the +repository's own conventions, and the menus already written. Do not install it +from here: `TODO › Execute › GPT code › Plugins Claude Code` carries the +ERPLibre list and the install is the user's decision, not a side effect of +asking for a plan. + +### 3. Read what already exists + +A menu of this size has almost always solved the problem next door. Before +designing anything, find the two or three closest entries and read them — +`grep -n "def prompt_execute" script/todo/todo.py` for the parents, then the +private methods under them. Copy the shape that is there: the same +confirmation prompt, the same `t()` keys, the same way of running a command. +An entry that behaves like its neighbours needs no explaining. + +### 4. What the plan contains + +Write it to `tasks/todo.md` — `tasks/` is not versioned, which is why the +convention sends working material there — as checkable items, and state: + +- the parent menu and the position of the entry in it; +- pattern A or B, and why the other was rejected; +- the exact i18n keys, with their French and English text, both mandatory; +- for pattern A, the method name and its signature; for pattern B, the + `todo.json` section and the command line; +- what the entry prints on success and on failure, and every confirmation it + asks for; +- how to verify it: the syntax checks, the unit test to add under `test/`, and + what to run by hand. A real machine goes to `long_test/`, never to `test/`, + which stays runnable in seconds. + +Then stop and hand it over. The plan is checked before code is written — that +is the whole point of planning at this tier. When it is approved, +`/todo_add_command` implements it. diff --git a/script/todo/todo.py b/script/todo/todo.py index 5454edf..db73f8a 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -3064,7 +3064,8 @@ class TODO( }, { "prompt_description": t( - "Todo Add Command - Add a command to todo.py menu" + "Todo Add Command + Plan Max - Plan and add a todo.py" + " command" ) }, {"prompt_description": t("Show installed custom commands")}, @@ -3088,6 +3089,13 @@ class TODO( "template_claude_commands_git_prepare_merge.md", ) elif status == "3": + # Les deux gabarits vont ensemble : /todo_plan_max produit la + # spécification que /todo_add_command implémente, et l'un sans + # l'autre laisse la moitié de la chaîne. + self._setup_claude_command( + "todo_plan_max", + "template_claude_commands_todo_plan_max.md", + ) self._setup_claude_command( "todo_add_command", "template_claude_commands_todo_add_command.md", @@ -3224,6 +3232,7 @@ class TODO( "template_claude_commands_git_prepare_merge.md" ), "todo_add_command": "template_claude_commands_todo_add_command.md", + "todo_plan_max": "template_claude_commands_todo_plan_max.md", } for nom, gabarit in sorted(gabarits.items()): etat = self._claude_command_state( diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index efe068b..ffd6013 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -1180,9 +1180,12 @@ TRANSLATIONS = { "fr": "Git prepare merge - Commande de préparation merge git", "en": "Git prepare merge - Git merge preparation command", }, - "Todo Add Command - Add a command to todo.py menu": { - "fr": "Todo Add Command - Ajouter une commande au menu todo.py", - "en": "Todo Add Command - Add a command to todo.py menu", + "Todo Add Command + Plan Max - Plan and add a todo.py command": { + "fr": ( + "Todo Add Command + Plan Max - Planifier et ajouter une" + " commande au menu todo.py" + ), + "en": "Todo Add Command + Plan Max - Plan and add a todo.py command", }, "Enter your full name: ": { "fr": "Entrez votre nom complet : ", From 2435f0f6d3f6e15bf8d169ceacd5408c9ad4c603 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 2 Sep 2026 07:16:23 -0400 Subject: [PATCH 14/17] =?UTF-8?q?[ADD]=20script=20todo=20:=20/todo=5Fgener?= =?UTF-8?q?ate=5Fcode,=20effort=20high=20et=20r=C3=A8gles=20du=20d=C3=A9p?= =?UTF-8?q?=C3=B4t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les règles à appliquer avant de coder sont éparpillées entre les configs, les hooks et les modules maison, et la documentation en contredit plusieurs : autopep8 est recommandé alors que le script sort en 1, aucun script oca-* n'est installé, la racine n'a ni .pre-commit-config.yaml ni .pylintrc, et flake8 comme pylint-odoo ne vivent que dans le venv Odoo, que rien ne lance. Le gabarit énonce ce que l'outillage impose, à effort high, l'épinglage ultracode restant celui de l'utilisateur. Vérifié : 146 règles relevées, 142 confirmées contre leur citation, 4 retirées. Deux gardes lient chaque entrée du menu à un gabarit présent qui déclare le bon nom. --- EN --- The rules to apply before coding are scattered across the configs, the hooks and the in-house modules, and the documentation contradicts several of them: autopep8 is recommended although the script exits 1, no oca-* script is installed, the root carries neither .pre-commit-config.yaml nor .pylintrc, and flake8 as well as pylint-odoo live only in the Odoo venv, which nothing invokes. The template states what the tooling enforces, at high effort, the ultracode pin remaining the user's own. Checked: 146 rules surveyed, 142 confirmed against their citation, 4 dropped. Two guards tie each menu entry to a template that exists and declares the right name. Assisted-by: Claude Opus 5 --- ...late_claude_commands_todo_generate_code.md | 228 ++++++++++++++++++ script/todo/todo.py | 14 ++ script/todo/todo_i18n.py | 6 + test/test_todo.py | 69 ++++++ 4 files changed, 317 insertions(+) create mode 100644 conf/template_claude_commands_todo_generate_code.md diff --git a/conf/template_claude_commands_todo_generate_code.md b/conf/template_claude_commands_todo_generate_code.md new file mode 100644 index 0000000..de236e9 --- /dev/null +++ b/conf/template_claude_commands_todo_generate_code.md @@ -0,0 +1,228 @@ +--- +name: todo_generate_code +description: "Write code in ERPLibre by the rules the repository actually enforces: Odoo/OCA module conventions first, then the real format and verify toolchain." +disable-model-invocation: true +effort: high +allowed-tools: + - Read + - Edit + - Write + - Grep + - Glob + - Bash(make format:*) + - Bash(make test_unit:*) + - Bash(make test_unit_file:*) + - Bash(./script/maintenance/:*) + - Bash(python3:*) + - Bash(git status:*) + - Bash(git diff:*) + - Bash(ls:*) + - Bash(cat:*) +--- + +## Context + +- Odoo series in this checkout: !`cat .odoo-version` +- Available venvs: !`ls -d .venv.* 2>/dev/null` +- Branch and pending work: !`git status --porcelain` +- Addons trees: !`ls -d odoo*/addons/*/ 2>/dev/null | head -12` + +## Task + +Write code in this repository by the rules it ENFORCES, which are not always +the rules it documents. Establish them first, then write. The sections below +were read off the configuration files and the in-house modules, not off the +prose; where a document disagrees with a tool, the tool wins and the +disagreement is named. + +### The effort tier + +The `effort: high` above applies to this invocation — "comprehensive +implementation with extensive testing", the tier for code that has to survive +review. + +A session can be pinned above it. `/effort ultracode` sets xhigh AND turns on +dynamic workflow orchestration, and a system-reminder then asks for a workflow +on every substantive task. That standing opt-in does not apply here: writing +one module correctly is one agent's job, and fanning it out multiplies both the +token cost and the ways the pieces disagree. + +So when a reminder says ultracode is on, say in one line that this command +works at high, and ask the user to type `/effort high` — that one command sets +the tier and clears the ultracode flag in the same move. A command's +frontmatter cannot release a session pin; only the user's own `/effort` can, +from an interactive terminal. Work solo either way. + +### 1. Before writing a line + +**Find the tree.** New modules go under `odoo/addons/_//` +— the version-prefixed tree is the real addons root, and the generator +rewrites any `addons/` it is handed into `odoo/addons/` +(`script/code_generator/new_project.py:144-146`). The version is in +`.odoo-version`; the venv carries BOTH versions in its name, so find it with +`ls -d .venv.odoo*` rather than composing it from memory. + +**Read the neighbours.** Two or three in-house modules under +`odoo/addons/ERPLibre_erplibre_addons/` show the conventions in force +better than any list. Copy their shape. + +**Bootstrap rather than hand-roll.** `script/code_generator/new_project.py -d + -m ` creates a module; `create_from_existing_module.py` +clones one. A module cloned from an existing one INHERITS its comments and +docstrings — reread them before committing, a client or database name travels +that way on its own. + +**Two rules bind every line you write**, and no tool checks either: +- Nothing identifying outside `private/` — no customer or third-party + organisation, no real database, host or VM name, no IP, e-mail or path + carrying an account name. Generalise to the class of situation instead. +- A comment says how the CODE works, in the present. A sentence whose subject + is an incident, a machine, a date or a person belongs in `tasks/`, which is + not versioned. + +**Never edit a generated file.** `FICHIER.md` and `FICHIER.fr.md` come from +`FICHIER.base.md` through mmg; an edit is lost at the next `make doc_markdown`. + +### 2. Odoo module conventions + +`__manifest__.py` carries at minimum `name`, `version`, `author`, `license`, +`category`, `summary`, `depends`, `data`, `installable`. `version` is +`.1.0.0` — `18.0.1.0.0` on an 18.0 checkout — and `license` is +`AGPL-3`, which every in-house module uses. + +The `data` list is ordered `security/`, then `data/`, then `wizards/`, then +`views/`, with `views/menu.xml` last. + +Non-Odoo Python requirements go in `external_dependencies: {"python": [...]}`, +and the import is guarded in the model with `try/except ImportError` logging at +debug level — a missing optional dependency must not break the registry. + +Layout: `models/`, `views/`, `security/`, `wizards/`, `data/`, `controllers/`, +`i18n/`, `report/`, `tests/`, `static/description/`. Ship +`static/description/icon.png`. + +Naming, one for one: +- `models/.py`, one model per file — + model `devops.workspace` lives in `models/devops_workspace.py`. +- `views/.xml` for a Model; a TransientModel's Python AND its XML + both live in `wizards/`. +- `ir.ui.view` ids: `_view_` (`_view_form`, + `_view_tree`, `_view_search`, `_view_kanban`). +- `ir.actions.act_window` ids: `__action_window`; + server actions: `__server_action`. +- `res.groups` go in `security/.xml` — named after the module, not + `security.xml` — and that file is listed BEFORE `ir.model.access.csv`. + +`security/ir.model.access.csv` carries exactly the header +`id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink`. +Split the rows by privilege level — a read-only row for the broad group, a +full-CRUD row for the administrative one — rather than one blanket row. + +Every Python file opens with the AGPL licence comment; hand-written models and +hooks also carry the shebang and copyright lines. A package `__init__.py` +starts with `# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)`, +a blank line, then imports. The root `__init__.py` imports subpackages on one +line (`from . import models, wizards`) and a hook by name +(`from .hooks import post_init_hook`); hooks live in a top-level `hooks.py` and +are declared under the matching manifest key. + +Models declare `_name`, `_description` and, when mixins are used, `_inherit`; +the class name is the CamelCase of the model. Odoo symbols come in one grouped +import — `from odoo import _, api, exceptions, fields, models`. + +Translations go in `i18n/` as `.pot` plus one `.po` per locale. + +### 3. Format — the toolchain that exists + +`make format` before committing. It formats only what git reports as +modified, added, renamed or untracked, dispatching each file by extension — +so it is cheap and safe to run repeatedly. `make format_all` sweeps whole +areas instead. + +What it runs underneath, and what to match when writing by hand: + +| Kind | Tool and settings | +|------|-------------------| +| Python | `isort --profile black -l 79`, then `black -l 79 --preview -t py37` | +| XML | prettier + `@prettier/plugin-xml`, tab-width 4, print-width 120 | +| XML under `data/` | same, print-width 999999999 — long data strings stay on one line | +| js, css, scss, html | prettier, tab-width 4, print-width 120, no bracket spacing | +| Shell | `shfmt -i 2 -ci -w` | + +black and isort run from `.venv.erplibre`, prettier from the repo-pinned +`./node_modules/.bin` — a globally installed prettier is a different version +and reformats differently. Nothing under `not_supported_files/` is formatted. + +**What NOT to run**, each verified absent from this checkout rather than +assumed: +- `./script/maintenance/autopep8.sh` — `oca-autopep8` is not installed; the + script exits 1. `doc/DEVELOPMENT.base.md` still recommends it; the document + is stale. +- Any `oca-*` console script (`oca-gen-addon-readme`, `oca-towncrier`, …) — + `script/OCA_maintainer-tools` is checked out but never pip-installed, and its + install block in `install_locally_dev.sh` is commented out. Run one as a + module from that directory if you truly need it. +- `pre-commit run` at the repository root — there is no root + `.pre-commit-config.yaml`. ERPLibre's own hooks are hand-written Python, + installed once per clone with + `git config core.hooksPath script/git/hooks`. + +**A vendored OCA addon** under `odoo/addons/OCA_*` carries its own +`.pre-commit-config.yaml`: run THAT from its directory instead of the ERPLibre +format scripts, so the patch matches what upstream will accept. + +### 4. Lint + +Neither linter runs on its own — no target, no hook, no CI invokes them, so +running them is a deliberate act: + +- flake8 lives in the Odoo venv, not `.venv.erplibre`. The root `.flake8` + applies: max-line-length 80, max-complexity 16, `select = C,E,F,W,B,B9`, + ignoring E203, E501 and W503 for black compatibility. +- pylint-odoo also lives in the Odoo venv: + `.venv.odoo<...>/bin/pylint --load-plugins=pylint_odoo --rcfile= `. + The `--rcfile` is not optional — the repository root ships no `.pylintrc`, + and the ones found under vendored trees belong to those projects. + +### 5. Verify + +Verification is entirely local: `.github/` holds no workflow, so nothing +catches a mistake after the fact. + +**Repository scripts** — `make test_unit` is the fast gate: no PostgreSQL, no +Odoo, no VM, a few seconds. While iterating on one file, `make test_unit_file +F=test/test_.py`. + +A new file in `test/` must declare at least one `test_*` function and end with +`if __name__ == "__main__": unittest.main()` as the LAST top-level statement — +the suite has its own test that enforces both, since the runner selects files +by the glob `test/test_*.py`. A test that creates a real machine, installs a +system or runs for hours goes in `long_test/` instead, and undoes itself with +`--detruire`. + +**An Odoo module** — drop the database, then run the module's tests: + +```bash +./odoo_bin.sh db --drop --database test_ +./test.sh -d test_ --db-filter test_ -i +``` + +`./test.sh` is `./run.sh` with `ODOO_MODE_TEST=true --workers 0`, which adds +`--test-enable --no-http --stop-after-init`. For coverage, bracket that with +`./.venv.erplibre/bin/coverage erase` before and a combine/report after, and +set `ODOO_MODE_COVERAGE=true` — coverage is switched on through the +environment, never a CLI flag. `make open_test_coverage` opens the report. + +To run ONE test file inside a module, install the module first, then pass +`--test-file=`. + +### 6. Then, and only then, commit + +`make format`, the tests above, then `/commit` — which resolves the model, +writes the `[TYPE] portée : sujet` subject under 72 characters, the bilingual +body under ten lines per language, and the `Assisted-by:` trailer. The +`commit-msg` hook REFUSES a message that breaks the mechanical part of that; +the `pre-commit` hook only reports comments worth rereading and never blocks. + +Stage by naming files. `git add -A` sweeps in `private/` and `tasks/`, which +are untracked on purpose. diff --git a/script/todo/todo.py b/script/todo/todo.py index db73f8a..512e509 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -3068,6 +3068,12 @@ class TODO( " command" ) }, + { + "prompt_description": t( + "Todo Generate Code - Code by the OCA rules at high" + " effort" + ) + }, {"prompt_description": t("Show installed custom commands")}, ] help_info = self.fill_help_info(choices) @@ -3101,6 +3107,11 @@ class TODO( "template_claude_commands_todo_add_command.md", ) elif status == "4": + self._setup_claude_command( + "todo_generate_code", + "template_claude_commands_todo_generate_code.md", + ) + elif status == "5": self._list_claude_commands() else: print(t("Command not found !")) @@ -3232,6 +3243,9 @@ class TODO( "template_claude_commands_git_prepare_merge.md" ), "todo_add_command": "template_claude_commands_todo_add_command.md", + "todo_generate_code": ( + "template_claude_commands_todo_generate_code.md" + ), "todo_plan_max": "template_claude_commands_todo_plan_max.md", } for nom, gabarit in sorted(gabarits.items()): diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index ffd6013..8325d41 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -1187,6 +1187,12 @@ TRANSLATIONS = { ), "en": "Todo Add Command + Plan Max - Plan and add a todo.py command", }, + "Todo Generate Code - Code by the OCA rules at high effort": { + "fr": ( + "Todo Generate Code - Coder selon les règles OCA, effort élevé" + ), + "en": "Todo Generate Code - Code by the OCA rules at high effort", + }, "Enter your full name: ": { "fr": "Entrez votre nom complet : ", "en": "Enter your full name: ", diff --git a/test/test_todo.py b/test/test_todo.py index 96e6c7c..9c9534e 100644 --- a/test/test_todo.py +++ b/test/test_todo.py @@ -485,6 +485,75 @@ class TestSetupClaudeCommit(unittest.TestCase): mock_makedirs.assert_called_once() +class TestClaudeCommandTemplates(unittest.TestCase): + """Chaque commande proposée par le menu doit avoir son gabarit. + + Un nom de gabarit fautif ne se voit qu'à l'exécution, au moment où le + déploiement échoue devant l'utilisateur : rien ne relie le littéral passé + à `_setup_claude_command` au fichier de `conf/`. + """ + + @staticmethod + def _deployed_templates(): + """Les gabarits nommés dans les appels à `_setup_claude_command`.""" + import ast + + source = Path("script/todo/todo.py").read_text(encoding="utf-8") + found = [] + for node in ast.walk(ast.parse(source)): + if not isinstance(node, ast.Call): + continue + attr = getattr(node.func, "attr", None) + if attr != "_setup_claude_command": + continue + # (nom_de_commande, nom_de_gabarit) : les deux sont des littéraux, + # sans quoi le test ne peut rien affirmer. + args = [ + a.value + for a in node.args + if isinstance(a, ast.Constant) and isinstance(a.value, str) + ] + if len(args) >= 2: + found.append(args[1]) + return found + + def test_every_menu_template_exists(self): + templates = self._deployed_templates() + self.assertGreaterEqual(len(templates), 4, templates) + for name in templates: + with self.subTest(template=name): + self.assertTrue( + os.path.isfile(os.path.join("conf", name)), + f"conf/{name} est nommé par le menu et n'existe pas", + ) + + def test_every_template_declares_its_own_name(self): + """Le `name:` du frontmatter donne le nom de la commande `/…` ; un + gabarit qui en déclare un autre déploie un fichier dont le contenu + parle d'une commande différente.""" + source = Path("script/todo/todo.py").read_text(encoding="utf-8") + import ast + + pairs = [] + for node in ast.walk(ast.parse(source)): + if not isinstance(node, ast.Call): + continue + if getattr(node.func, "attr", None) != "_setup_claude_command": + continue + args = [ + a.value + for a in node.args + if isinstance(a, ast.Constant) and isinstance(a.value, str) + ] + if len(args) >= 2: + pairs.append((args[0], args[1])) + self.assertTrue(pairs) + for command, template in pairs: + with self.subTest(command=command): + text = Path("conf", template).read_text(encoding="utf-8") + self.assertIn(f"name: {command}\n", text) + + class TestClaudePlugins(unittest.TestCase): """Le menu des plugins Claude Code. From 736ab3b676d69a7b426301a334a0da825d607c99 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 2 Sep 2026 07:35:28 -0400 Subject: [PATCH 15/17] =?UTF-8?q?[FIX]=20qemu=20setup-host=20:=20demander?= =?UTF-8?q?=20avant=20de=20red=C3=A9marrer=20l'h=C3=B4te?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accepter d'installer les paquets QEMU redémarrait la machine sans autre question : --assume-yes couvrait le gestionnaire de paquets, et la commande y ajoutait --reboot-if-needed, qui ne demandait rien. Une seule constante servait la VM qu'on vient de créer et le poste qui la crée. --reboot-if-needed PROPOSE désormais, sur /dev/tty pour rester visible quand la sortie est un tuyau, et vaut non par défaut ; un refus laisse les paquets posés et dit quoi faire. --assume-yes-reboot est le seul consentement muet, que seul le profil invité porte. Vérifié : 7 tests, rougis par deux mutations — assume_yes rouvrant la porte, le menu hôte reprenant le drapeau. --- EN --- Accepting the QEMU package install rebooted the machine with no further question: --assume-yes covered the package manager, and the command added --reboot-if-needed, which asked nothing. One constant served both the VM just created and the workstation creating it. --reboot-if-needed now OFFERS, on /dev/tty so it stays visible when output is a pipe, and defaults to no; a refusal leaves the packages in place and says what to do. --assume-yes-reboot is the only silent consent, carried by the guest profile alone. Checked: 7 tests, turned red by two mutations — assume_yes reopening the door, the host menu taking the flag back. Assisted-by: Claude Opus 5 --- script/qemu/deploy_qemu.py | 79 +++++++---- script/todo/qemu_install.py | 6 +- script/todo/qemu_menu.py | 9 +- test/test_qemu_setup_host_reboot.py | 196 ++++++++++++++++++++++++++++ 4 files changed, 263 insertions(+), 27 deletions(-) create mode 100644 test/test_qemu_setup_host_reboot.py diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index b874180..8955823 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -563,9 +563,7 @@ def default_image_name(distro: str, code: str, arch: str, version: str) -> str: # de cache fait qu'un déploiement Debian 13 et un Proxmox se # PARTAGENT le téléchargement (325 Mio) au lieu d'en faire deux. Sans # cette branche, le repli de fin nommait l'image « fedora-cloud-9 ». - return ( - f"debian-{PROXMOX_DEBIAN_BASE[version]}-genericcloud-{a}.qcow2" - ) + return f"debian-{PROXMOX_DEBIAN_BASE[version]}-genericcloud-{a}.qcow2" if distro == "arch": return f"arch-linux-{a}-cloudimg.qcow2" if distro == "opensuse": @@ -1072,6 +1070,7 @@ def setup_host( assume_yes: bool, no_install: bool, reboot_if_needed: bool = False, + assume_yes_reboot: bool = False, ) -> None: """Prépare l'hôte à faire tourner des VM : paquets, démon, groupe, réseau. @@ -1114,14 +1113,25 @@ def setup_host( # monte tout seul avec les modules du nouveau noyau. Un seul reboot # suffit donc à rendre l'hôte utilisable, sans repasser par ici. if reboot_if_needed: - print( - "\n↻ Redémarrage programmé (dans quelques secondes) : c'est la" - " SEULE façon de retrouver les modules du noyau.\n" - " Au retour, le réseau « default » démarrera seul" - " (autostart déjà actif)." + # Le consentement à installer des paquets ne vaut PAS consentement + # à redémarrer : assume_yes couvre pacman, jamais la machine de + # celui qui l'a tapé. Une provision sans personne devant l'écran + # passe --assume-yes-reboot, qui dit explicitement l'autre chose. + if assume_yes_reboot or prompt_yes_no( + "\n↻ Redémarrer MAINTENANT ? C'est la seule façon de" + " retrouver les modules du noyau. Au retour, le réseau" + " « default » démarrera seul (autostart déjà actif).", + default=False, + ): + print("\n↻ Redémarrage programmé (dans quelques secondes).") + schedule_reboot(runner) + return + sys.exit( + "Erreur : l'hôte n'est pas prêt, redémarrage refusé.\n" + f" {stale}\n" + " Redémarrez quand vous le voudrez, puis relancez" + " --setup-host." ) - schedule_reboot(runner) - return sys.exit(f"Erreur : l'hôte n'est pas prêt.\n {stale}") if not (ok and active): @@ -2468,18 +2478,23 @@ def _ip_taken(ip: str) -> bool: except (OSError, subprocess.SubprocessError): pass try: - if subprocess.run( - ["ping", "-c", "1", "-W", "1", ip], - capture_output=True, - timeout=5, - ).returncode == 0: + if ( + subprocess.run( + ["ping", "-c", "1", "-W", "1", ip], + capture_output=True, + timeout=5, + ).returncode + == 0 + ): return True except (OSError, subprocess.SubprocessError): pass return _ip_reachable(ip, port=22, timeout=1.5) -def static_net_plan(net: str | None, use_sudo: bool, name: str) -> dict[str, str] | None: +def static_net_plan( + net: str | None, use_sudo: bool, name: str +) -> dict[str, str] | None: """Adresse fixe libre pour une VM installée par debian-installer. L'initrd s390x ne contient QUE « netcfg-static » : le journal de d-i @@ -3019,9 +3034,11 @@ def virt_install( # VM que personne ne regarde, et il ne reste RIEN à lire ensuite — # exactement « l'installation a échoué, pas de sortie pertinente ». # Le fichier, lui, survit à l'arrêt du domaine. - f"pty,target_type={console_target},log.file={console_log}" - if installer - else f"pty,target_type={console_target}", + ( + f"pty,target_type={console_target},log.file={console_log}" + if installer + else f"pty,target_type={console_target}" + ), # Canal virtio de l'agent invité (org.qemu.guest_agent.0) : permet à # virsh de piloter la VM SANS réseau (ex. étendre le FS invité après # un redimensionnement de disque). Inoffensif si l'agent est absent. @@ -3495,8 +3512,15 @@ def build_parser() -> argparse.ArgumentParser: g_run.add_argument( "--reboot-if-needed", action="store_true", - help="Avec --setup-host : redémarre si le noyau a été mis à jour " - "depuis le démarrage (sinon libvirt ne peut pas créer virbr0).", + help="Avec --setup-host : PROPOSE un redémarrage si le noyau a été " + "mis à jour depuis le démarrage (sinon libvirt ne peut pas créer " + "virbr0). La question est posée sur /dev/tty et vaut non par défaut.", + ) + g_run.add_argument( + "--assume-yes-reboot", + action="store_true", + help="Redémarre sans poser la question. Réservé à une provision " + "sans personne devant l'écran ; --assume-yes ne l'implique pas.", ) g_run.add_argument( "--list-images", @@ -3555,6 +3579,7 @@ def main() -> None: args.assume_yes, args.no_install_deps, args.reboot_if_needed, + args.assume_yes_reboot, ) return @@ -3707,11 +3732,15 @@ def main() -> None: network_name(args.network), not args.dry_run, args.name ) if static: - print(f" Adresse fixe retenue : {static['ip']}" - f" (passerelle {static['gateway']})") + print( + f" Adresse fixe retenue : {static['ip']}" + f" (passerelle {static['gateway']})" + ) else: - print(" ⚠ Aucune adresse fixe déterminée : netcfg-static posera" - " la question à l'écran et l'installation s'arrêtera.") + print( + " ⚠ Aucune adresse fixe déterminée : netcfg-static posera" + " la question à l'écran et l'installation s'arrêtera." + ) build_installer_initrd( build_preseed(args, pw_hash, ssh_keys, static), initrd_src, diff --git a/script/todo/qemu_install.py b/script/todo/qemu_install.py index d65b00b..daafb07 100644 --- a/script/todo/qemu_install.py +++ b/script/todo/qemu_install.py @@ -19,9 +19,13 @@ class QemuInstallMixin: # Sans le groupe, virt-install retombe sur qemu:///session où « default » # n'existe pas : la VM échoue alors que tous les paquets sont installés. # L'ancien one-liner finissait par « || true » et masquait ses erreurs. + # Le redémarrage est consenti ICI et nulle part ailleurs : la VM vient + # d'être créée, personne ne la regarde, et le noyau fraîchement installé + # doit être chargé avant que libvirt puisse monter virbr0. Sur un poste de + # travail, la question se pose — voir _qemu_ensure_tools. _QEMU_QEMU_PKGS = ( "./script/qemu/deploy_qemu.py --setup-host --assume-yes" - " --reboot-if-needed" + " --reboot-if-needed --assume-yes-reboot" ) def _qemu_ask_prod(self): diff --git a/script/todo/qemu_menu.py b/script/todo/qemu_menu.py index 28db42f..a09e04f 100644 --- a/script/todo/qemu_menu.py +++ b/script/todo/qemu_menu.py @@ -253,7 +253,14 @@ class QemuMenuMixin: input(t("Install the QEMU/libvirt tools now? (Y/n): ")) ): return False - cmd = f"sudo {self._QEMU_QEMU_PKGS}" + # Sans --assume-yes-reboot : accepter d'installer des paquets n'est + # pas accepter de perdre ce qui tourne sur la machine. Quand le noyau + # a été remplacé depuis le démarrage, deploy_qemu.py pose la question + # sur /dev/tty, et un refus laisse l'hôte avec ses paquets posés. + cmd = ( + "sudo ./script/qemu/deploy_qemu.py --setup-host --assume-yes" + " --reboot-if-needed" + ) print(f"{t('Will execute:')} {cmd}") self.execute.exec_command_live(cmd, source_erplibre=False) if shutil.which("virsh"): diff --git a/test/test_qemu_setup_host_reboot.py b/test/test_qemu_setup_host_reboot.py new file mode 100644 index 0000000..f3727c0 --- /dev/null +++ b/test/test_qemu_setup_host_reboot.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Le redémarrage de l'hôte se demande, il ne se déduit pas. + +« --setup-host » installe des paquets, et sur une distribution à noyau +roulant il peut avoir besoin d'un redémarrage pour que libvirt monte virbr0. +Ces deux actes n'ont pas le même prix : le premier s'annule, le second emporte +tout ce qui tourne sur la machine. + +Ce que ces tests gardent : + +- « --assume-yes » couvre le gestionnaire de paquets, JAMAIS le redémarrage. +- « --reboot-if-needed » PROPOSE ; un refus laisse la machine debout et sort + en erreur, sans jamais programmer le redémarrage. +- « --assume-yes-reboot » est le seul consentement qui se passe de question, + et il est réservé à la provision d'une VM que personne ne regarde. +- La commande du menu hôte ne le porte pas ; celle du profil invité le porte. +""" + +import importlib.util +import io +import sys +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from unittest import mock + +sys.argv = ["todo.py"] + +RACINE = Path(__file__).resolve().parents[1] + + +def _deploy_qemu(): + """deploy_qemu.py chargé comme module, comme le fait todo.py.""" + path = RACINE / "script/qemu/deploy_qemu.py" + spec = importlib.util.spec_from_file_location("deploy_qemu", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +DQ = _deploy_qemu() + +# L'état exact qui déclenche la question : le noyau a été remplacé depuis le +# démarrage, donc le réseau « default » ne peut pas monter. +NOYAU_PERIME = "Le noyau en cours n'a plus ses modules." + + +class SetupHostReboot(unittest.TestCase): + def _lancer(self, reponse=False, **kwargs): + """setup_host sur un hôte au noyau périmé et au réseau inactif. + + Rend le triplet (redémarrages, questions, SystemExit ou None). Tout + ce qui touche au système est neutralisé : seul l'enchaînement des + décisions est sous test. « reponse » est ce que l'utilisateur répond + si la question lui est posée. + """ + runner = mock.MagicMock() + runner.dry_run = False + runner.use_sudo = False + reboots = [] + questions = [] + + def question(texte, default=True): + questions.append(texte) + return reponse + + with mock.patch.object(DQ, "ensure_tools"), mock.patch.object( + DQ, "ensure_libvirt_service" + ), mock.patch.object( + DQ, "ensure_libvirt_group", return_value=True + ), mock.patch.object( + DQ, "ensure_ssh_key" + ), mock.patch.object( + DQ, "ensure_network" + ), mock.patch.object( + DQ, "kernel_modules_stale", return_value=NOYAU_PERIME + ), mock.patch.object( + DQ, "libvirt_ready", return_value=True + ), mock.patch.object( + DQ, "network_state", return_value=(False, True) + ), mock.patch.object( + DQ, "schedule_reboot", side_effect=lambda r: reboots.append(r) + ), mock.patch.object( + DQ, "prompt_yes_no", side_effect=question + ): + with redirect_stdout(io.StringIO()): + try: + DQ.setup_host(runner, **kwargs) + sortie = None + except SystemExit as exc: + sortie = exc + return reboots, questions, sortie + + def test_assume_yes_alone_never_reboots(self): + """La régression même : accepter l'installation des paquets ne + redémarrait pas la machine, mais l'appelant, lui, ajoutait le drapeau + qui le faisait. Sans le drapeau, rien ne redémarre et rien n'est + demandé.""" + reboots, questions, sortie = self._lancer( + assume_yes=True, no_install=False, reboot_if_needed=False + ) + self.assertEqual(reboots, []) + self.assertEqual(questions, []) + self.assertIsInstance(sortie, SystemExit) + + def test_reboot_if_needed_asks_and_a_refusal_stops(self): + reboots, questions, sortie = self._lancer( + assume_yes=True, + no_install=False, + reboot_if_needed=True, + reponse=False, + ) + self.assertEqual(len(questions), 1, questions) + self.assertEqual(reboots, []) + self.assertIsInstance(sortie, SystemExit) + # Le refus doit se lire dans le message : un « pas prêt » sec laisse + # croire à une panne alors que la machine a obéi. + self.assertIn("refusé", str(sortie)) + + def test_reboot_if_needed_reboots_when_accepted(self): + reboots, questions, sortie = self._lancer( + assume_yes=True, + no_install=False, + reboot_if_needed=True, + reponse=True, + ) + self.assertEqual(len(questions), 1) + self.assertEqual(len(reboots), 1) + self.assertIsNone(sortie) + + def test_assume_yes_reboot_skips_the_question(self): + """La provision d'une VM neuve n'a personne pour répondre : sans ce + drapeau, la question tomberait sur un EOF et la VM resterait sur un + noyau sans modules.""" + reboots, questions, sortie = self._lancer( + assume_yes=True, + no_install=False, + reboot_if_needed=True, + assume_yes_reboot=True, + ) + self.assertEqual(questions, []) + self.assertEqual(len(reboots), 1) + self.assertIsNone(sortie) + + +class ConsentInTheCallers(unittest.TestCase): + """Le drapeau se lit dans les commandes que TODO fabrique.""" + + @staticmethod + def _commande_hote(): + """La chaîne assignée à « cmd » dans _qemu_ensure_tools. + + Lue par l'arbre syntaxique et non par le texte : un commentaire qui + NOMME le drapeau pour expliquer son absence est légitime, et une + recherche textuelle le prendrait pour la commande. + """ + import ast + + source = (RACINE / "script/todo/qemu_menu.py").read_text( + encoding="utf-8" + ) + for node in ast.walk(ast.parse(source)): + if ( + isinstance(node, ast.FunctionDef) + and node.name == "_qemu_ensure_tools" + ): + for stmt in ast.walk(node): + if ( + isinstance(stmt, ast.Assign) + and getattr(stmt.targets[0], "id", "") == "cmd" + ): + return ast.literal_eval(stmt.value) + raise AssertionError("cmd introuvable dans _qemu_ensure_tools") + + def test_the_host_menu_never_assumes_the_reboot(self): + cmd = self._commande_hote() + self.assertIn("--setup-host", cmd) + self.assertIn("--reboot-if-needed", cmd) + self.assertNotIn("--assume-yes-reboot", cmd) + + def test_the_guest_profile_carries_the_explicit_consent(self): + source = (RACINE / "script/todo/qemu_install.py").read_text( + encoding="utf-8" + ) + self.assertIn("--assume-yes-reboot", source) + + def test_the_flag_exists_in_the_parser(self): + parser = DQ.build_parser() + rendu = parser.format_help() + self.assertIn("--assume-yes-reboot", rendu) + + +if __name__ == "__main__": + unittest.main() From 29b12c8ca1b478b6e59b5f11a0278c9e052b8534 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 2 Sep 2026 08:04:05 -0400 Subject: [PATCH 16/17] =?UTF-8?q?[ADD]=20qemu=20arch=20:=20yay=20et=20bash?= =?UTF-8?q?-completion=20sur=20l'invit=C3=A9,=20guide=20au=20MOTD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une image cloud Arch est nue : ni bash-completion, ni accès à l'AUR. Les deux arrivent avec l'amorçage, sur la seule branche pacman. yay-bin plutôt que yay, dont le paquet source compile Go pour le même outil ; la construction reste sous l'utilisateur de la VM, makepkg refusant root. Le « || true » qui ferme le bloc porte : le groupe est le dernier membre de sa liste « || », donc set -e s'y applique et un sudo en échec emporterait l'installation entière. Le guide de connexion n'annonce yay que si une installation a eu lieu. Vérifié : 10 tests, dont « bash -n » sur la commande distante entière et la survie du bloc sous set -e avec un PATH vide. --- EN --- An Arch cloud image is bare: no bash-completion, no AUR access. Both come with the bootstrap, on the pacman branch alone. yay-bin rather than yay, whose source package compiles Go for the same tool; the build stays under the VM user, makepkg refusing root. The « || true » closing the block carries weight: the group is the last member of its « || » list, so set -e applies inside it and one failing sudo would take the whole install down. The login guide announces yay only when an install ran. Checked: 10 tests, among them « bash -n » over the whole remote command and the block surviving set -e with an empty PATH. Assisted-by: Claude Opus 5 --- script/qemu/deploy_qemu.py | 19 ++++ script/todo/qemu_deploy.py | 8 +- script/todo/qemu_install.py | 35 ++++++++ test/test_qemu_arch_yay.py | 154 ++++++++++++++++++++++++++++++++ test/test_qemu_desktop_tools.py | 7 +- 5 files changed, 220 insertions(+), 3 deletions(-) create mode 100644 test/test_qemu_arch_yay.py diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index 8955823..52a96a7 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -1759,6 +1759,19 @@ PKG_GUIDE: dict[str, tuple[tuple[str, str, str], ...]] = { } +# Assistant AUR posé sur l'invité Arch par l'amorçage d'installation. Les +# formes viennent du manuel de yay : il reprend les options de pacman, sauf +# « -Yc » qui lui est propre. Jamais sous sudo — yay appelle sudo lui-même +# pour la seule étape qui en a besoin, et le lancer en root fait échouer +# makepkg, qui refuse de construire sous cet utilisateur. +AUR_GUIDE: tuple[tuple[str, str, str], ...] = ( + ("yay -Syu", "mettre à jour dépôts + AUR", "upgrade repos and AUR"), + ("yay -S ", "installer depuis l'AUR", "install from the AUR"), + ("yay -Ss ", "chercher dans l'AUR", "search the AUR"), + ("yay -Yc", "retirer les orphelins", "remove orphans"), +) + + def zypper_guide(rolling: bool) -> tuple[tuple[str, str, str], ...]: """Aide-mémoire zypper. `rolling` : Tumbleweed plutôt que Leap. @@ -1965,6 +1978,12 @@ def build_motd( lang, narrow, ) + # yay arrive avec l'amorçage d'installation, pas avec l'image : une VM + # déployée sans installation n'annonce donc pas une commande absente. + # C'est la règle du bloc ERPLibre ci-dessous, appliquée au même signal. + if mgr == "pacman" and el_dir: + body.append("") + body += motd_block("AUR — yay", AUR_GUIDE, lang, narrow) if el_dir: body.append("") el_rows = erplibre_guide(el_dir, el_make, editor) diff --git a/script/todo/qemu_deploy.py b/script/todo/qemu_deploy.py index 9ea06f1..00ef384 100644 --- a/script/todo/qemu_deploy.py +++ b/script/todo/qemu_deploy.py @@ -186,8 +186,12 @@ class QemuDeployMixin: "{ sudo dnf clean all; sudo dnf install -y --refresh $PKGS; }; " "elif command -v pacman >/dev/null 2>&1; then " + self._qemu_pacman_prepare_cmd() - + "sudo pacman -S --needed --noconfirm $PKGS; " - "elif command -v zypper >/dev/null 2>&1; then " + # bash-completion n'est PAS dans une image cloud Arch, là où les + # images Debian et Fedora l'embarquent : sans lui, la tabulation + # ne complète que les noms de fichiers, pas les sous-commandes. + + "sudo pacman -S --needed --noconfirm $PKGS bash-completion; " + + self._qemu_yay_install_cmd() + + "elif command -v zypper >/dev/null 2>&1; then " # openSUSE : « --non-interactive » vaut le -y des autres, et # « --auto-agree-with-licenses », qui va APRÈS « install », # évite un blocage sur une licence à accepter. diff --git a/script/todo/qemu_install.py b/script/todo/qemu_install.py index daafb07..f4c419c 100644 --- a/script/todo/qemu_install.py +++ b/script/todo/qemu_install.py @@ -584,6 +584,41 @@ class QemuInstallMixin: "fi; " ) + @staticmethod + def _qemu_yay_install_cmd(): + """Pose yay, l'assistant AUR, sur un invité Arch. + + « yay-bin » et non « yay » : le paquet source compile son propre Go, + ce qui coûte plusieurs minutes et le compilateur avec ; le binaire + précompilé donne le même outil. + + makepkg REFUSE de tourner en root et sort en erreur ; le clonage et la + construction restent donc sous l'utilisateur de la VM, qui appelle + sudo pour la seule installation finale. Le NOPASSWD posé par + cloud-init rend ce sudo silencieux. + + yay est un bonus, pas une condition : le bloc se termine par « true » + pour qu'un AUR injoignable ne fasse pas échouer, sous « set -e », une + installation par ailleurs complète. + """ + return ( + "command -v yay >/dev/null 2>&1 || { " + "sudo pacman -S --needed --noconfirm base-devel git && " + "yd=$(mktemp -d) && " + "git clone --depth 1 https://aur.archlinux.org/yay-bin.git " + '"$yd" && ( cd "$yd" && makepkg -si --noconfirm ); ' + # « rm -rf » sur une variable vide rend 0 en silence sous -f : le + # nettoyage n'a donc pas besoin de savoir si le clonage a eu lieu. + 'rm -rf "$yd"; ' + # « || true » ferme le groupe ENTIER, et il porte. Le groupe est + # le DERNIER membre de la liste « || », donc set -e s'y applique + # et le premier sudo en échec emporterait toute l'installation. + # Un membre de plus l'y suspend, et rend le bloc inoffensif. + "} || true; " + "command -v yay >/dev/null 2>&1 " + '&& echo " yay installé" || echo " ⚠ yay non installé"; ' + ) + def _qemu_zypper_mirror_cmd(self): """Réécrit l'hôte des dépôts zypper vers un miroir plus proche.""" mirrors = " ".join(self._QEMU_ZYPPER_MIRRORS) diff --git a/test/test_qemu_arch_yay.py b/test/test_qemu_arch_yay.py new file mode 100644 index 0000000..37cc43a --- /dev/null +++ b/test/test_qemu_arch_yay.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""L'invité Arch : yay et bash-completion, puis leur trace dans l'accueil. + +Une image cloud Arch est nue : ni bash-completion, ni accès à l'AUR. Les deux +s'ajoutent à l'amorçage d'installation, sur la seule branche pacman. + +Ce que ces tests gardent : + +- makepkg REFUSE de construire en root : la construction ne passe jamais par + sudo, et un « sudo makepkg » repasserait le test au rouge. +- yay est un bonus : sous « set -e », son échec ne doit pas emporter une + installation par ailleurs complète. +- L'accueil de session n'annonce yay que sur une VM qui l'aura vraiment — + la règle déjà tenue par le bloc ERPLibre, appliquée au même signal. +""" + +import importlib.util +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +sys.argv = ["todo.py"] +from script.todo.todo import TODO # noqa: E402 + +RACINE = Path(__file__).resolve().parents[1] + + +def _deploy_qemu(): + """deploy_qemu.py chargé comme module, comme le fait todo.py.""" + path = RACINE / "script/qemu/deploy_qemu.py" + spec = importlib.util.spec_from_file_location("deploy_qemu", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +DQ = _deploy_qemu() + + +class ArchGuestBootstrap(unittest.TestCase): + def setUp(self): + self.todo = TODO.__new__(TODO) + self.cmd = self.todo._qemu_erplibre_remote_cmd( + "develop", None, False, False, "", "deb", () + ) + + def test_bash_completion_rides_the_pacman_branch(self): + self.assertIn("bash-completion", self.cmd) + # Sur la branche pacman et nulle part ailleurs : les images Debian et + # Fedora l'embarquent déjà, l'ajouter là serait du bruit. + pacman = self.cmd.index("command -v pacman") + zypper = self.cmd.index("command -v zypper", pacman) + self.assertIn("bash-completion", self.cmd[pacman:zypper]) + + def test_yay_is_installed_from_the_prebuilt_package(self): + """yay-bin plutôt que yay : le paquet source compile Go, et le + compilateur avec, pour le même outil.""" + self.assertIn("aur.archlinux.org/yay-bin.git", self.cmd) + + def test_makepkg_never_runs_under_sudo(self): + """makepkg sort en erreur sous root : « running makepkg as root is + not allowed ». La construction reste sous l'utilisateur de la VM.""" + yay = self.todo._qemu_yay_install_cmd() + self.assertIn("makepkg -si --noconfirm", yay) + self.assertNotIn("sudo makepkg", yay) + + def test_a_failing_aur_does_not_break_the_chain(self): + """Sous « set -e », un groupe qui échoue arrête tout. Le bloc doit + rendre 0 même sans réseau, sans quoi une VM par ailleurs installée + serait comptée en échec.""" + yay = self.todo._qemu_yay_install_cmd() + script = "set -e\nPATH=/nonexistent\n" + yay + "\necho SURVECU" + with tempfile.NamedTemporaryFile( + "w", suffix=".sh", delete=False + ) as fh: + fh.write(script) + chemin = fh.name + res = subprocess.run( + ["bash", chemin], capture_output=True, text=True, timeout=60 + ) + self.assertEqual(res.returncode, 0, res.stderr) + self.assertIn("SURVECU", res.stdout) + + def test_the_whole_remote_command_is_valid_shell(self): + """Une erreur de syntaxe ne se verrait qu'une fois la VM déployée.""" + res = subprocess.run( + ["bash", "-n"], + input=self.cmd, + capture_output=True, + text=True, + timeout=60, + ) + self.assertEqual(res.returncode, 0, res.stderr) + + +class AurInTheMotd(unittest.TestCase): + def test_arch_with_an_install_announces_yay(self): + motd = DQ.build_motd( + "arch", "latest", "amd64", "fr", "~/git/erplibre", "install", "vi" + ) + self.assertIn("AUR — yay", motd) + self.assertIn("yay -Syu", motd) + + def test_arch_without_an_install_announces_nothing(self): + motd = DQ.build_motd("arch", "latest", "amd64", "fr") + self.assertNotIn("yay", motd) + + def test_another_distro_never_gets_the_block(self): + for distro, version in ( + ("ubuntu", "24.04"), + ("debian", "13"), + ("fedora", "43"), + ("opensuse", "tumbleweed"), + ): + with self.subTest(distro=distro): + motd = DQ.build_motd( + distro, + version, + "amd64", + "fr", + "~/git/erplibre", + "install", + "vi", + ) + self.assertNotIn("yay", motd) + + def test_the_block_is_translated(self): + motd = DQ.build_motd( + "arch", "latest", "amd64", "en", "~/git/erplibre", "install", "vi" + ) + self.assertIn("search the AUR", motd) + self.assertNotIn("chercher dans l'AUR", motd) + + def test_the_block_stays_under_a_standard_terminal(self): + for lang in ("fr", "en"): + motd = DQ.build_motd( + "arch", + "latest", + "amd64", + lang, + "~/git/erplibre", + "install", + "vi", + ) + for line in motd.splitlines(): + self.assertLessEqual(len(line), 80, line) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_qemu_desktop_tools.py b/test/test_qemu_desktop_tools.py index 2e1e2d8..75cba88 100644 --- a/test/test_qemu_desktop_tools.py +++ b/test/test_qemu_desktop_tools.py @@ -182,7 +182,12 @@ class TestToolRemoteCommand(unittest.TestCase): script = self.todo._qemu_erplibre_remote_cmd( "develop", None, False, "gnome", "", "deb", ("pycharm",) ) - self.assertLess(script.index("PyCharm"), script.index("git clone")) + # « git clone --branch » et non « git clone » : l'amorçage Arch en + # pose un autre, celui de yay-bin, et il arrive plus tôt. Seul le + # clone du dépôt, qui seul porte une branche, est en jeu ici. + self.assertLess( + script.index("PyCharm"), script.index("git clone --branch") + ) self.assertLess( script.index("PyCharm"), script.index("make install_os") ) From d92797d38ac1786afffa2478128a3b7e568ab0e8 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 2 Sep 2026 08:04:15 -0400 Subject: [PATCH 17/17] =?UTF-8?q?[REF]=20claude=20:=20all=C3=A9ger=20le=20?= =?UTF-8?q?contexte,=20deux=20r=C3=A8gles=20passent=20en=20skills?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Environ 1 130 tokens étaient chargés à chaque session pour du contenu dérivable ou écrit deux fois : les deux tableaux d'index décrivaient des fichiers déjà chargés en entier, et la ligne de format de commit contredisait 04-code-conventions.md et le hook qui l'applique. Les règles du générateur et du déploiement ne servent qu'à une tâche : elles se chargent désormais à la demande, comme les deux skills déjà en place. L'interdit du générateur, lui, reste dans CLAUDE.md — une interdiction ne doit pas dépendre de l'ouverture d'une skill. Vérifié : aucun renvoi vers les fichiers déplacés, et les quatre skills déclarent un nom qui correspond à leur répertoire. --- EN --- About 1,130 tokens loaded every session for content that is derivable or written twice: the two index tables described files already loaded in full, and the commit-format line contradicted 04-code-conventions.md and the hook enforcing it. The code-generator and deployment rules serve one task each: they now load on demand, like the two skills already in place. The generator's prohibition stays in CLAUDE.md — a ban must not depend on a skill being opened. Checked: no reference points at the moved files, and all four skills declare a name matching their directory. Assisted-by: Claude Opus 5 --- .../erplibre-code-generator/SKILL.md} | 30 +++++++------------ .../erplibre-deployment/SKILL.md} | 9 +++++- CLAUDE.md | 30 ++----------------- 3 files changed, 21 insertions(+), 48 deletions(-) rename .claude/{rules/06-code-generator.md => skills/erplibre-code-generator/SKILL.md} (53%) rename .claude/{rules/08-deployment.md => skills/erplibre-deployment/SKILL.md} (84%) diff --git a/.claude/rules/06-code-generator.md b/.claude/skills/erplibre-code-generator/SKILL.md similarity index 53% rename from .claude/rules/06-code-generator.md rename to .claude/skills/erplibre-code-generator/SKILL.md index 3f30bf6..b7e49d5 100644 --- a/.claude/rules/06-code-generator.md +++ b/.claude/skills/erplibre-code-generator/SKILL.md @@ -1,4 +1,11 @@ -# Code Generator +--- +name: erplibre-code-generator +description: >- + Génération de modules Odoo dans ERPLibre : créer un module, cloner un + module existant, où vivent le moteur et les gabarits, et l'hygiène des + commentaires du code produit. À charger avant de générer ou de cloner + un module. +--- ERPLibre inclut un système de génération de modules Odoo : - `script/code_generator/new_project.py` — Créer un nouveau module @@ -20,26 +27,9 @@ ou de base y arrive tout seul. ## Le nettoyage au fur et à mesure -Rien ne se nettoie en une passe : on corrige les commentaires du fichier qu'on -touche, au moment où on le touche. Deux outils le rappellent. - -Le hook `pre-commit` liste ce qui est à relire dans les fichiers indexés, -SANS bloquer le commit : +La règle et ses deux garde-fous sont dans `.claude/rules/04-code-conventions.md`. Une invocation lui manque, utile +sur du code généré en masse : elle ne relève que les trouvailles. ```bash -git config core.hooksPath script/git/hooks # une fois par clone -``` - -L'outil se lance aussi à la main, sur un fichier, un répertoire ou l'index : - -```bash -python3 script/analyse/check_comment_hygiene.py script/todo/todo.py -python3 script/analyse/check_comment_hygiene.py --staged python3 script/analyse/check_comment_hygiene.py script --identifying-only ``` - -🔴 `identifiant` — adresse, courriel, chemin de compte : à retirer. -🟡 `récit` — témoignage, date, première personne : à RELIRE, l'outil ne -tranche pas. Un fait durable reste ; l'incident -où on l'a observé part. Codes de sortie : 0 rien, 1 des trouvailles, 2 l'outil -a échoué. diff --git a/.claude/rules/08-deployment.md b/.claude/skills/erplibre-deployment/SKILL.md similarity index 84% rename from .claude/rules/08-deployment.md rename to .claude/skills/erplibre-deployment/SKILL.md index e4ad023..22f6f01 100644 --- a/.claude/rules/08-deployment.md +++ b/.claude/skills/erplibre-deployment/SKILL.md @@ -1,4 +1,11 @@ -# Déploiement +--- +name: erplibre-deployment +description: >- + Déploiement ERPLibre : Docker, systemd, nginx, SSL, DNS, plateformes + supportées, et le choix de l'interpréteur Python (EL_PYTHON_PROVIDER) + comme du gestionnaire de paquets (EL_PIP_PROVIDER). À charger pour + déployer, installer ou changer de fournisseur Python. +--- - **Docker** : `docker-compose.yml` (PostgreSQL 18 + PostGIS 3.6) - **Systemd** : `script/systemd/` pour les services diff --git a/CLAUDE.md b/CLAUDE.md index f9994c0..5dbe05a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,9 @@ Version Odoo par défaut : **18.0** (support officiel ERPLibre 1.6.0) `ls -d .venv.odoo*` plutôt que de le composer de tête - Les scripts ERPLibre utilisent `.venv.erplibre/bin/python` - Le Makefile principal inclut des fragments depuis `conf/make.*.Makefile` +- Un module cloné depuis un module existant hérite de ses commentaires + et de ses docstrings : les relire avant de committer, un nom de client + ou de base y arrive tout seul - Les fichiers privés vont dans `private/`. C'est le SEUL endroit qui a le droit de porter une donnée de client — nom, base, machine, adresse, chiffres. Il peut être commité, mais seulement sur un dépôt privé : sur @@ -29,7 +32,6 @@ Version Odoo par défaut : **18.0** (support officiel ERPLibre 1.6.0) `.claude/rules/04-code-conventions.md` - La DB PostgreSQL par défaut est sur le port 5432, mot de passe admin : `admin` - Port Odoo par défaut : 8069, longpolling : 8072 -- Pour les commits : suivre le format `[TYPE] description` (ex: `[FIX]`, `[UPD]`, `[ADD]`, `[REM]`) - Pour la documentation : modifier les `.base.md`, jamais les `.md` ou `.fr.md` directement - Outil mmg disponible via `source .venv.erplibre/bin/activate && mmg` - Les tests qui créent de VRAIES machines vivent dans `long_test/` et non dans @@ -37,31 +39,5 @@ Version Odoo par défaut : **18.0** (support officiel ERPLibre 1.6.0) en quelques secondes, même sans virtualisation. Ils durent des heures et se défont par `--detruire` — voir `long_test/README.md` -## Core Principles - -- **Simplicity First**: Make every change as simple as possible. Impact minimal code. -- **No Laziness**: Find root causes. No temporary fixes. Senior developer standards. -- **Minimal Impact**: Changes should only touch what's necessary. Avoid introducing bugs. - -## Règles détaillées - -Les instructions détaillées sont dans `.claude/rules/` : - -| Fichier | Contenu | -|---------|---------| -| `01-versions.md` | Versions Odoo supportées, où lit-on la correspondance | -| `04-code-conventions.md` | Où sont les configs de format, conventions Git | -| `06-code-generator.md` | Génération de modules Odoo | -| `07-documentation.md` | Interdit : ne pas éditer les `.md` générés | -| `08-deployment.md` | Docker, systemd, nginx, SSL, DNS | -| `09-workflow.md` | Workflow orchestration + task management | - -Chargées à la demande (`.claude/skills/`) : - -| Skill | Contenu | -|-------|---------| -| `erplibre-commands` | Commandes make et scripts : versions, run, tests, DB, Docker, repo | -| `erplibre-doc-i18n` | Mode d'emploi mmg (`.base.md`) et i18n du CLI TODO | - L'arborescence et la liste des venvs ne sont plus documentées : `ls` et `ls -d .venv.*` en donnent l'état réel, la doc dérivait de la réalité.