[IMP] qemu deploy : pré-configurer la VM, et dire ce qui s'y installe

L'option des outils d'assistance posait trois installateurs amont, puis
laissait tout à retaper : hook global de rtk, zdiff3, hooks git du dépôt,
commandes Claude, activation du venv. Elle les pose, en deux temps parce que
hooks et gabarits VIVENT dans le dépôt ; le complément suit le clone, rend
toujours 0 — un confort ne fait pas échouer une VM — et sans clone la moitié
manquante est nommée. core.editor n'est posé qu'à défaut, l'hôte transmettant
déjà le sien. L'aide « ? » et F1 dit ce que chaque option installe, là où un
libellé de case porte trois des huit poses. Vérifié : 51 tests, les deux
écrans montés sans terminal, « bash -n » sur les fragments distants.

--- EN ---

The AI tools box posed three upstream installers, then left every setting to
retype: rtk's global hook, zdiff3, the checkout's git hooks, the Claude
commands, activating the venv. It poses them, in two phases because hooks and
templates LIVE in the checkout; the complement follows the clone, always
returns 0 — a comfort must not fail a VM — and with no clone the missing half
is named. core.editor is posed only where there is none, the host already
transmitting its own. The `?` and F1 help says what each option installs,
where a checkbox label carries three of this one's eight poses. Checked: 51
tests, both screens mounted headless, `bash -n` on the remote fragments.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-09-04 02:03:52 -04:00
parent a014e1eb30
commit 44dc08c06c
14 changed files with 1117 additions and 68 deletions

View file

@ -46,7 +46,8 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
- 3D acceleration for QEMU VMs, ticked at creation and settable afterwards, even on a VM with NO virtual screen — `auto` never grants one there, abstaining rather than adding a video device nobody asked for, while an off-screen render or an emulator inside the VM wants exactly that. A render node can exist while EGL refuses to start on it: QEMU then rejects the domain and the VM stays unusable until someone undoes the setting, so creation falls back to software rendering and an existing VM is offered the removal. Inside the guest the render node is `root:render` at 0660 and the account was not in it, so every GL application fell back to software rendering although VIRGL negotiation had succeeded, with nothing to say so; `render` and `video` are now declared BEFORE use, an unknown group name making cloud-init create no account at all — no password, no SSH key, a VM that boots unreachable
- A QEMU diagnostic report, written to one file to hand to someone who has no access to the machine: twenty-one read-only probes — host, hypervisor, GPU, tools present, storage — each time-bounded, since a command that hangs must not hold the report, and each section isolated, the file being written in one block at the end. It states the 3D condition of every VM from its PERSISTENT definition, where three values answer together and none alone: video type, `accel3d`, and the device libvirt pinned. That last one, an attribute added in libvirt 12.5.0 to keep the guest ABI stable across restarts, OUTRANKS `accel3d`: a VM first started without 3D keeps the non-GL device, and ticking the box afterwards writes an intent nothing applies. The report also offers the tools missing from it, showing the full command before asking, and the device list QEMU may open — libvirt adds the render node when the domain declares it, never a proprietary card's own nodes, which that stack opens too. It names the host, its paths and its addresses, and says so before it is shared
- Recovering files from the disk of a VM that no longer boots, libguestfs mounting its qcow2 without it. Every command carries `--ro`, and that is what changes the manoeuvre: opening the disk of a running machine for writing corrupts its filesystem. Partitions are listed, then the directories to copy out, the `copy-out` commands being shown rather than guessed at
- Development assistants installed INSIDE a VM at deployment: rtk, starship with its shell hook, and one agent — Claude Code or opencode. The box reveals the choice and the git identity, prefilled from the host, since that is what the VM already receives and an empty field would suggest none; what is typed wins, field by field. Every install is denied stdin and time-bounded: `|| true` covers failure, not WAITING, and an upstream installer asking a question would hang on a terminal-less SSH — hence `-y` for starship
- Development assistants installed INSIDE a VM at deployment, and the pre-configuration that makes them usable without retyping anything: rtk with its global auto-rewrite hook, starship with its shell hook, one agent — Claude Code or opencode —, tig, htop and vim, `merge.conflictStyle=zdiff3`, the checkout's git hooks, the five Claude commands of the repository, and `source .venv.erplibre/bin/activate` in the shell history, where the up arrow finds it. The box reveals the choice and the git identity, prefilled from the host, since that is what the VM already receives and an empty field would suggest none; what is typed wins, field by field. Every install is denied stdin and time-bounded: `|| true` covers failure, not WAITING, and an upstream installer asking a question would hang on a terminal-less SSH — hence `-y` for starship. The option works in TWO phases, and the second is named rather than silently dropped: hooks and command templates LIVE in the checkout, so a VM that installs nothing gets the installs and is told what the missing clone costs it. `core.editor` is only posed where there is none, the host's editor already travelling to the VM's `~/.gitconfig` — two authorities over one setting is one too many. Everything in that second phase returns 0: a pre-configuration is a comfort, and the install alone carries the VM's verdict
- An option's help at the deployment form, opened by `?` or F1 and closed by Esc: what each install setting POSES in the VM, listing every step rather than the two or three a checkbox label has room for — the AI tools box alone engages eight. The text lives in the same table as the boxes, so an option added without help shows an empty block instead of a wrong one, and both screens read it, the QEMU/KVM one and the Proxmox VE one
- A Git and Shell menu that installs what a checkout needs rather than printing a command to copy: the repository's git hooks, `merge.conflictStyle=zdiff3`, Starship, Claude Code, opencode, and the Claude Code plugins with an ERPLibre list. Three assistant commands are deployed from it — `/git_prepare_merge`, `/todo_plan_max`, `/todo_generate_code`. The missing tools of a safe shrink are installed across the four package families, where three separate pieces of code each knew a different subset. A binary posed in a HOME directory the shell's PATH does not always carry is found anyway, and the export line is written once
- A deployment blocked by an orphan disk — the qcow2 an interrupted creation leaves behind, which `deploy_qemu` then refuses to overwrite — is offered its deletion, size and path shown, before the creation fails after having made you wait
- A `pre-commit` hook lists the comments worth re-reading in the files being staged, and never blocks: over the repository's own sources, 373 files yield 463 signals, and a blocking check at that scale gets uninstalled the following week. The tool behind it, `script/analyse/check_comment_hygiene.py`, reports two families of unequal certainty — identifying data, an address, an e-mail or an account path, which is a finding; and narrative, a witness marker, an absolute date or the first person, which is a signal to RE-READ, since it cannot know whether the sentence states a durable fact. It reads comments and docstrings, `#` lines and shell trailing comments alike, skips vendored code, and falls back on a line scan when a source will not parse, an empty report otherwise declaring clean a file it never read. Exit codes follow the repository convention: 0 nothing to report, 1 findings, 2 the tool failed
@ -146,7 +147,8 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
- L'accélération 3D des VM QEMU, cochée à la création et réglable ensuite, même sur une VM SANS écran virtuel — « auto » ne l'accorde jamais là, s'abstenant plutôt que de poser un périphérique vidéo que personne n'a demandé, alors qu'un rendu hors écran ou un émulateur tournant dedans veut exactement cela. Un nœud de rendu peut exister sans qu'EGL y démarre : QEMU refuse alors le domaine et la VM reste inutilisable jusqu'à ce que quelqu'un défasse le réglage, d'où un repli sur le rendu logiciel à la création et le retrait proposé sur une VM existante. Dans l'invité, le nœud de rendu appartient à « root:render » en 0660 et le compte n'y était pas : toute application GL retombait sur le rendu logiciel alors que la négociation VIRGL avait réussi, sans que rien ne le signale ; « render » et « video » sont désormais déclarés AVANT usage, un nom de groupe inconnu faisant que cloud-init ne crée aucun compte — ni mot de passe, ni clé SSH, une VM qui démarre injoignable
- Un diagnostic QEMU, écrit dans un fichier unique à transmettre à quelqu'un qui n'a pas accès à la machine : vingt et une sondes en lecture — hôte, hyperviseur, GPU, outils présents, stockage — chacune bornée dans le temps, une commande qui pend ne devant pas retenir le rapport, et chaque section isolée, le fichier s'écrivant d'un bloc à la fin. Il dit l'état 3D de chaque VM d'après sa définition PERSISTANTE, où trois valeurs répondent ensemble et aucune seule : le type de vidéo, « accel3d », et le device figé par libvirt. Ce dernier, un attribut arrivé avec libvirt 12.5.0 pour tenir l'ABI de l'invité stable d'un démarrage à l'autre, L'EMPORTE sur « accel3d » : une VM démarrée une première fois sans 3D garde le device sans GL, et cocher la case ensuite écrit une intention que rien n'applique. Le rapport propose aussi les outils qui lui manquent, la commande complète affichée avant la question, et la liste des périphériques que QEMU peut ouvrir — libvirt y met le nœud de rendu quand le domaine le déclare, jamais les nœuds propres d'une carte propriétaire, que sa pile ouvre pourtant. Il porte le nom de l'hôte, ses chemins et ses adresses, et le dit avant qu'on l'envoie
- La récupération de fichiers dans le disque d'une VM qui ne démarre plus, libguestfs montant son qcow2 sans elle. Toute commande porte « --ro », et c'est ce qui change la manœuvre : ouvrir en écriture le disque d'une machine allumée corrompt son système de fichiers. Les partitions sont listées, puis les répertoires à extraire, les commandes « copy-out » étant montrées plutôt que devinées
- Les assistants de développement installés DANS une VM au déploiement : rtk, starship avec son accroche au shell, et un agent — Claude Code ou opencode. La case découvre le choix et l'identité git, pré-remplie avec celle de l'hôte, puisque c'est ce que la VM reçoit déjà et qu'un champ vide la ferait croire absente ; ce qui est saisi prime, champ par champ. Chaque pose est privée d'entrée standard et bornée dans le temps : « || true » couvre l'échec, pas l'ATTENTE, et un installateur amont qui pose une question resterait pendu sur un SSH sans terminal — d'où « -y » pour starship
- Les assistants de développement installés DANS une VM au déploiement, et la pré-configuration qui permet de s'en servir sans rien retaper : rtk et son hook global de réécriture, starship avec son accroche au shell, un agent — Claude Code ou opencode —, tig, htop et vim, « merge.conflictStyle=zdiff3 », les hooks git du dépôt, les cinq commandes Claude du dépôt, et « source .venv.erplibre/bin/activate » dans l'historique du shell, là où la flèche du haut le retrouve. La case découvre le choix et l'identité git, pré-remplie avec celle de l'hôte, puisque c'est ce que la VM reçoit déjà et qu'un champ vide la ferait croire absente ; ce qui est saisi prime, champ par champ. Chaque pose est privée d'entrée standard et bornée dans le temps : « || true » couvre l'échec, pas l'ATTENTE, et un installateur amont qui pose une question resterait pendu sur un SSH sans terminal — d'où « -y » pour starship. L'option travaille en DEUX temps, et le second est nommé plutôt que tu : les hooks et les gabarits de commandes VIVENT dans le dépôt, si bien qu'une VM qui n'installe rien reçoit les poses et s'entend dire ce que le clone absent lui coûte. « core.editor » n'est posé que là où il n'y en a pas, l'éditeur de l'hôte voyageant déjà jusqu'au ~/.gitconfig de la VM — deux autorités sur un même réglage en font une de trop. Tout ce second temps rend 0 : une pré-configuration est un confort, et l'installation seule porte le verdict de la VM
- L'aide d'une option au formulaire de déploiement, ouverte par « ? » ou F1 et fermée par Esc : ce que chaque réglage d'installation POSE dans la VM, toutes ses étapes plutôt que les deux ou trois que le libellé d'une case peut porter — celle des outils IA en engage huit à elle seule. Le texte vit dans la même table que les cases, si bien qu'une option ajoutée sans aide affiche un bloc vide plutôt qu'un texte faux, et les DEUX écrans le lisent, celui de QEMU/KVM comme celui de Proxmox VE
- Un menu Git et Shell qui installe ce dont un clone a besoin au lieu d'afficher une commande à recopier : les hooks git du dépôt, « merge.conflictStyle=zdiff3 », Starship, Claude Code, opencode, et les plugins Claude Code avec une liste ERPLibre. Trois commandes d'assistant s'y déploient — « /git_prepare_merge », « /todo_plan_max », « /todo_generate_code ». Les outils manquants d'une réduction sûre s'installent sur les quatre familles de paquets, là où trois écritures séparées en connaissaient chacune un sous-ensemble différent. Un binaire posé dans un répertoire du HOME que le PATH du shell ne porte pas toujours est trouvé quand même, et la ligne d'export est écrite une seule fois
- Un déploiement bloqué par un disque orphelin — le qcow2 qu'une création interrompue laisse et que « deploy_qemu » refuse ensuite d'écraser — se voit proposer son effacement, taille et chemin affichés, avant que la création n'échoue après avoir fait attendre
- Un hook `pre-commit` liste les commentaires à relire dans les fichiers qu'on indexe, et ne bloque jamais : sur les sources du dépôt, 373 fichiers rendent 463 signaux, et un contrôle bloquant à cette échelle se fait désinstaller la semaine suivante. L'outil qui le sert, `script/analyse/check_comment_hygiene.py`, rapporte deux familles de sûreté inégale — la donnée identifiante, adresse, courriel ou chemin de compte, qui est une trouvaille ; et le récit, marqueur de témoignage, date absolue ou première personne, qui est un signal à RELIRE, l'outil ne pouvant savoir si la phrase énonce un fait durable. Il lit les commentaires et les docstrings, les lignes `#` comme les commentaires shell de fin de ligne, écarte le code tiers, et se replie sur un balayage ligne à ligne quand un source ne se parse pas, un rapport vide déclarant sinon propre un fichier qu'il n'a jamais lu. Les codes de sortie suivent la convention du dépôt : 0 rien à signaler, 1 des trouvailles, 2 l'outil a échoué

View file

@ -20,7 +20,8 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
- L'accélération 3D des VM QEMU, cochée à la création et réglable ensuite, même sur une VM SANS écran virtuel — « auto » ne l'accorde jamais là, s'abstenant plutôt que de poser un périphérique vidéo que personne n'a demandé, alors qu'un rendu hors écran ou un émulateur tournant dedans veut exactement cela. Un nœud de rendu peut exister sans qu'EGL y démarre : QEMU refuse alors le domaine et la VM reste inutilisable jusqu'à ce que quelqu'un défasse le réglage, d'où un repli sur le rendu logiciel à la création et le retrait proposé sur une VM existante. Dans l'invité, le nœud de rendu appartient à « root:render » en 0660 et le compte n'y était pas : toute application GL retombait sur le rendu logiciel alors que la négociation VIRGL avait réussi, sans que rien ne le signale ; « render » et « video » sont désormais déclarés AVANT usage, un nom de groupe inconnu faisant que cloud-init ne crée aucun compte — ni mot de passe, ni clé SSH, une VM qui démarre injoignable
- Un diagnostic QEMU, écrit dans un fichier unique à transmettre à quelqu'un qui n'a pas accès à la machine : vingt et une sondes en lecture — hôte, hyperviseur, GPU, outils présents, stockage — chacune bornée dans le temps, une commande qui pend ne devant pas retenir le rapport, et chaque section isolée, le fichier s'écrivant d'un bloc à la fin. Il dit l'état 3D de chaque VM d'après sa définition PERSISTANTE, où trois valeurs répondent ensemble et aucune seule : le type de vidéo, « accel3d », et le device figé par libvirt. Ce dernier, un attribut arrivé avec libvirt 12.5.0 pour tenir l'ABI de l'invité stable d'un démarrage à l'autre, L'EMPORTE sur « accel3d » : une VM démarrée une première fois sans 3D garde le device sans GL, et cocher la case ensuite écrit une intention que rien n'applique. Le rapport propose aussi les outils qui lui manquent, la commande complète affichée avant la question, et la liste des périphériques que QEMU peut ouvrir — libvirt y met le nœud de rendu quand le domaine le déclare, jamais les nœuds propres d'une carte propriétaire, que sa pile ouvre pourtant. Il porte le nom de l'hôte, ses chemins et ses adresses, et le dit avant qu'on l'envoie
- La récupération de fichiers dans le disque d'une VM qui ne démarre plus, libguestfs montant son qcow2 sans elle. Toute commande porte « --ro », et c'est ce qui change la manœuvre : ouvrir en écriture le disque d'une machine allumée corrompt son système de fichiers. Les partitions sont listées, puis les répertoires à extraire, les commandes « copy-out » étant montrées plutôt que devinées
- Les assistants de développement installés DANS une VM au déploiement : rtk, starship avec son accroche au shell, et un agent — Claude Code ou opencode. La case découvre le choix et l'identité git, pré-remplie avec celle de l'hôte, puisque c'est ce que la VM reçoit déjà et qu'un champ vide la ferait croire absente ; ce qui est saisi prime, champ par champ. Chaque pose est privée d'entrée standard et bornée dans le temps : « || true » couvre l'échec, pas l'ATTENTE, et un installateur amont qui pose une question resterait pendu sur un SSH sans terminal — d'où « -y » pour starship
- Les assistants de développement installés DANS une VM au déploiement, et la pré-configuration qui permet de s'en servir sans rien retaper : rtk et son hook global de réécriture, starship avec son accroche au shell, un agent — Claude Code ou opencode —, tig, htop et vim, « merge.conflictStyle=zdiff3 », les hooks git du dépôt, les cinq commandes Claude du dépôt, et « source .venv.erplibre/bin/activate » dans l'historique du shell, là où la flèche du haut le retrouve. La case découvre le choix et l'identité git, pré-remplie avec celle de l'hôte, puisque c'est ce que la VM reçoit déjà et qu'un champ vide la ferait croire absente ; ce qui est saisi prime, champ par champ. Chaque pose est privée d'entrée standard et bornée dans le temps : « || true » couvre l'échec, pas l'ATTENTE, et un installateur amont qui pose une question resterait pendu sur un SSH sans terminal — d'où « -y » pour starship. L'option travaille en DEUX temps, et le second est nommé plutôt que tu : les hooks et les gabarits de commandes VIVENT dans le dépôt, si bien qu'une VM qui n'installe rien reçoit les poses et s'entend dire ce que le clone absent lui coûte. « core.editor » n'est posé que là où il n'y en a pas, l'éditeur de l'hôte voyageant déjà jusqu'au ~/.gitconfig de la VM — deux autorités sur un même réglage en font une de trop. Tout ce second temps rend 0 : une pré-configuration est un confort, et l'installation seule porte le verdict de la VM
- L'aide d'une option au formulaire de déploiement, ouverte par « ? » ou F1 et fermée par Esc : ce que chaque réglage d'installation POSE dans la VM, toutes ses étapes plutôt que les deux ou trois que le libellé d'une case peut porter — celle des outils IA en engage huit à elle seule. Le texte vit dans la même table que les cases, si bien qu'une option ajoutée sans aide affiche un bloc vide plutôt qu'un texte faux, et les DEUX écrans le lisent, celui de QEMU/KVM comme celui de Proxmox VE
- Un menu Git et Shell qui installe ce dont un clone a besoin au lieu d'afficher une commande à recopier : les hooks git du dépôt, « merge.conflictStyle=zdiff3 », Starship, Claude Code, opencode, et les plugins Claude Code avec une liste ERPLibre. Trois commandes d'assistant s'y déploient — « /git_prepare_merge », « /todo_plan_max », « /todo_generate_code ». Les outils manquants d'une réduction sûre s'installent sur les quatre familles de paquets, là où trois écritures séparées en connaissaient chacune un sous-ensemble différent. Un binaire posé dans un répertoire du HOME que le PATH du shell ne porte pas toujours est trouvé quand même, et la ligne d'export est écrite une seule fois
- Un déploiement bloqué par un disque orphelin — le qcow2 qu'une création interrompue laisse et que « deploy_qemu » refuse ensuite d'écraser — se voit proposer son effacement, taille et chemin affichés, avant que la création n'échoue après avoir fait attendre
- Un hook `pre-commit` liste les commentaires à relire dans les fichiers qu'on indexe, et ne bloque jamais : sur les sources du dépôt, 373 fichiers rendent 463 signaux, et un contrôle bloquant à cette échelle se fait désinstaller la semaine suivante. L'outil qui le sert, `script/analyse/check_comment_hygiene.py`, rapporte deux familles de sûreté inégale — la donnée identifiante, adresse, courriel ou chemin de compte, qui est une trouvaille ; et le récit, marqueur de témoignage, date absolue ou première personne, qui est un signal à RELIRE, l'outil ne pouvant savoir si la phrase énonce un fait durable. Il lit les commentaires et les docstrings, les lignes `#` comme les commentaires shell de fin de ligne, écarte le code tiers, et se replie sur un balayage ligne à ligne quand un source ne se parse pas, un rapport vide déclarant sinon propre un fichier qu'il n'a jamais lu. Les codes de sortie suivent la convention du dépôt : 0 rien à signaler, 1 des trouvailles, 2 l'outil a échoué

View file

@ -20,7 +20,8 @@ Recreating the virtual environment, use installation guide from tool `make`.
- 3D acceleration for QEMU VMs, ticked at creation and settable afterwards, even on a VM with NO virtual screen — `auto` never grants one there, abstaining rather than adding a video device nobody asked for, while an off-screen render or an emulator inside the VM wants exactly that. A render node can exist while EGL refuses to start on it: QEMU then rejects the domain and the VM stays unusable until someone undoes the setting, so creation falls back to software rendering and an existing VM is offered the removal. Inside the guest the render node is `root:render` at 0660 and the account was not in it, so every GL application fell back to software rendering although VIRGL negotiation had succeeded, with nothing to say so; `render` and `video` are now declared BEFORE use, an unknown group name making cloud-init create no account at all — no password, no SSH key, a VM that boots unreachable
- A QEMU diagnostic report, written to one file to hand to someone who has no access to the machine: twenty-one read-only probes — host, hypervisor, GPU, tools present, storage — each time-bounded, since a command that hangs must not hold the report, and each section isolated, the file being written in one block at the end. It states the 3D condition of every VM from its PERSISTENT definition, where three values answer together and none alone: video type, `accel3d`, and the device libvirt pinned. That last one, an attribute added in libvirt 12.5.0 to keep the guest ABI stable across restarts, OUTRANKS `accel3d`: a VM first started without 3D keeps the non-GL device, and ticking the box afterwards writes an intent nothing applies. The report also offers the tools missing from it, showing the full command before asking, and the device list QEMU may open — libvirt adds the render node when the domain declares it, never a proprietary card's own nodes, which that stack opens too. It names the host, its paths and its addresses, and says so before it is shared
- Recovering files from the disk of a VM that no longer boots, libguestfs mounting its qcow2 without it. Every command carries `--ro`, and that is what changes the manoeuvre: opening the disk of a running machine for writing corrupts its filesystem. Partitions are listed, then the directories to copy out, the `copy-out` commands being shown rather than guessed at
- Development assistants installed INSIDE a VM at deployment: rtk, starship with its shell hook, and one agent — Claude Code or opencode. The box reveals the choice and the git identity, prefilled from the host, since that is what the VM already receives and an empty field would suggest none; what is typed wins, field by field. Every install is denied stdin and time-bounded: `|| true` covers failure, not WAITING, and an upstream installer asking a question would hang on a terminal-less SSH — hence `-y` for starship
- Development assistants installed INSIDE a VM at deployment, and the pre-configuration that makes them usable without retyping anything: rtk with its global auto-rewrite hook, starship with its shell hook, one agent — Claude Code or opencode —, tig, htop and vim, `merge.conflictStyle=zdiff3`, the checkout's git hooks, the five Claude commands of the repository, and `source .venv.erplibre/bin/activate` in the shell history, where the up arrow finds it. The box reveals the choice and the git identity, prefilled from the host, since that is what the VM already receives and an empty field would suggest none; what is typed wins, field by field. Every install is denied stdin and time-bounded: `|| true` covers failure, not WAITING, and an upstream installer asking a question would hang on a terminal-less SSH — hence `-y` for starship. The option works in TWO phases, and the second is named rather than silently dropped: hooks and command templates LIVE in the checkout, so a VM that installs nothing gets the installs and is told what the missing clone costs it. `core.editor` is only posed where there is none, the host's editor already travelling to the VM's `~/.gitconfig` — two authorities over one setting is one too many. Everything in that second phase returns 0: a pre-configuration is a comfort, and the install alone carries the VM's verdict
- An option's help at the deployment form, opened by `?` or F1 and closed by Esc: what each install setting POSES in the VM, listing every step rather than the two or three a checkbox label has room for — the AI tools box alone engages eight. The text lives in the same table as the boxes, so an option added without help shows an empty block instead of a wrong one, and both screens read it, the QEMU/KVM one and the Proxmox VE one
- A Git and Shell menu that installs what a checkout needs rather than printing a command to copy: the repository's git hooks, `merge.conflictStyle=zdiff3`, Starship, Claude Code, opencode, and the Claude Code plugins with an ERPLibre list. Three assistant commands are deployed from it — `/git_prepare_merge`, `/todo_plan_max`, `/todo_generate_code`. The missing tools of a safe shrink are installed across the four package families, where three separate pieces of code each knew a different subset. A binary posed in a HOME directory the shell's PATH does not always carry is found anyway, and the export line is written once
- A deployment blocked by an orphan disk — the qcow2 an interrupted creation leaves behind, which `deploy_qemu` then refuses to overwrite — is offered its deletion, size and path shown, before the creation fails after having made you wait
- A `pre-commit` hook lists the comments worth re-reading in the files being staged, and never blocks: over the repository's own sources, 373 files yield 463 signals, and a blocking check at that scale gets uninstalled the following week. The tool behind it, `script/analyse/check_comment_hygiene.py`, reports two families of unequal certainty — identifying data, an address, an e-mail or an account path, which is a finding; and narrative, a witness marker, an absolute date or the first person, which is a signal to RE-READ, since it cannot know whether the sentence states a durable fact. It reads comments and docstrings, `#` lines and shell trailing comments alike, skips vendored code, and falls back on a line scan when a source will not parse, an empty report otherwise declaring clean a file it never read. Exit codes follow the repository convention: 0 nothing to report, 1 findings, 2 the tool failed

View file

@ -41,6 +41,7 @@ _TABLES = (
("timezones", list, "timezones"),
("timezone", str, "timezone"),
("vm_tools", list, "vm_tools"),
("tool_help", dict, "vm_tool_help"),
("tool_disk", dict, "vm_tool_disk"),
("tool_phases", dict, "vm_tool_phases"),
("tool_arches", dict, "vm_tool_arches"),
@ -53,6 +54,69 @@ _TABLES = (
)
# Ce que chaque réglage POSE, pour l'aide « ? ». Une clé de table en tête
# quand le bloc n'a de sens que si l'écran offre le réglage ; None quand il le
# porte toujours. Les textes disent l'effet dans la VM, pas la manœuvre à
# l'écran : le libellé de la case dit déjà où cliquer.
INSTALL_HELP = (
(
None,
# Le titre de la case, et non « ERPLibre » : elle commande TOUTE
# installation, l'hyperviseur Proxmox VE compris.
"Install software in the VM",
(
"clone of the checkout, then the make target of the profile",
"in ~/git/erplibre, or /opt/erplibre in production",
"unchecked: the VM stays a bare distribution",
"about +5 Go of disk",
),
),
(
None,
"Production (/opt, confined)",
(
"/opt/erplibre instead of ~/git/erplibre",
"Odoo registered as a systemd service",
"automatic updates left ON, unlike a dev VM",
),
),
(
"desktops",
"VM type",
(
"server: SSH only, no graphical session",
"graphical: the chosen desktop plus its remote access",
"an IDE needs a desktop, the build tools do not",
),
),
(
"app_stores",
"Application store",
(
"deb/rpm: packages of the distribution only",
"snap: snapd kept active where the distribution has it",
),
),
(
"timezones",
"Timezone",
(
"IANA name written by cloud-init at first boot",
"a misspelled name is IGNORED: the VM stays in UTC",
),
),
(
"mise_arches",
"Python interpreter",
(
"mise poses a precompiled CPython, in minutes",
"pyenv compiles it, which is much longer",
"mise publishes no binary for every architecture",
),
),
)
def extras_tables(ctx) -> dict:
"""Les tables lues du contexte, chacune ramenée à son type.
@ -187,7 +251,9 @@ class ExtrasMixin:
# Une case par outil, et non une liste déroulante : ils sont
# indépendants, et chacun se prend ou se laisse.
yield Static(
t("Development tools:"), id="t_tools", classes="grouptitle"
f"{t('Development tools:')} {t('(F1 or ? : details)')}",
id="t_tools",
classes="grouptitle",
)
for key, label, hint in tab["vm_tools"]:
gb = tab["tool_disk"].get(key, 0)
@ -236,6 +302,40 @@ class ExtrasMixin:
yield RadioButton(t("pyenv (compiles from source)"))
yield Static("", id="miswarn")
# ------------------------------------------------------------------ #
# L'aide
# ------------------------------------------------------------------ #
def extras_help_blocks(self) -> list:
"""[(titre, lignes)] : ce que chaque option de cet écran installe.
Les réglages communs d'abord, dans l'ordre du formulaire, puis un bloc
par outil OFFERT — un écran qui n'en propose pas n'a rien à en dire.
L'aide vient de la même table que les cases : une seule autorité, et
rien à synchroniser.
Un outil sans texte d'aide retombe sur son indice, qui tient en une
ligne : mieux vaut trop court que vide. Ce repli est un filet, pas une
place où se cacher — un test exige une aide pour chaque outil.
"""
tab = self._extras
blocs = []
for cle, titre, lignes in INSTALL_HELP:
if cle and not tab[cle]:
continue
blocs.append((t(titre), [t(x) for x in lignes]))
for key, label, hint in tab["vm_tools"]:
gb = tab["tool_disk"].get(key, 0)
lignes = list(tab["tool_help"].get(key) or [hint])
lignes.append(f"+{gb} Go")
blocs.append((label, lignes))
return blocs
def action_help(self) -> None:
"""Ouvre l'aide. Esc ou q la ferme, et rien n'a bougé."""
from script.todo.deploy_form_plan import help_screen
self.push_screen(help_screen()(self.extras_help_blocks()))
# ------------------------------------------------------------------ #
# Lire les widgets
# ------------------------------------------------------------------ #

View file

@ -483,6 +483,14 @@ PreviewScreen { align: center middle; }
}
#prevtitle { height: 1; color: $accent; text-style: bold; }
#prevbody { height: 1fr; overflow-y: auto; }
HelpScreen { align: center middle; }
#helpbox {
width: 84%; height: 80%; padding: 1 2;
border: thick $accent; background: $surface;
}
#helptitle { height: 1; color: $accent; text-style: bold; }
#helpbody { height: 1fr; }
.helphead { color: $accent; text-style: bold; }
RenameScreen { align: center middle; }
#renbox {
width: 60; height: auto; padding: 1 2;

View file

@ -50,7 +50,7 @@ _ECRANS = {}
def _build_screens():
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.screen import ModalScreen
from textual.widgets import Button, Input, Static
@ -109,7 +109,49 @@ def _build_screens():
def action_close(self) -> None:
self.dismiss()
return {"rename": RenameScreen, "preview": PreviewScreen}
class HelpScreen(ModalScreen):
"""Ce que chaque option d'installation pose VRAIMENT dans la VM.
`blocks` : [(titre, [lignes])], dans l'ordre du formulaire. Une case
cochée engage parfois huit poses, et son libellé n'en nomme que trois :
cet écran est le seul endroit où la liste complète est lisible AVANT
de déployer.
La fenêtre ne fait que LIRE : elle se ferme sans rien changer, ce qui
permet de l'ouvrir en plein remplissage du formulaire.
"""
BINDINGS = [
("escape", "close", t("Close")),
("q", "close", t("Close")),
]
def __init__(self, blocks, title=""):
super().__init__()
self._blocks = blocks
self._title = title or t("What each option installs")
def compose(self) -> ComposeResult:
with Vertical(id="helpbox"):
yield Static(
f" {self._title} ({t('Esc to close')})",
id="helptitle",
)
with VerticalScroll(id="helpbody"):
for titre, lignes in self._blocks:
yield Static(f"▸ {titre}", classes="helphead")
for ligne in lignes:
yield Static(f" {ligne}")
yield Static("")
def action_close(self) -> None:
self.dismiss()
return {
"rename": RenameScreen,
"preview": PreviewScreen,
"help": HelpScreen,
}
def rename_screen():
@ -126,6 +168,13 @@ def preview_screen():
return _ECRANS["preview"]
def help_screen():
"""La fenêtre d'aide, construite au premier appel."""
if not _ECRANS:
_ECRANS.update(_build_screens())
return _ECRANS["help"]
class PlanMixin:
"""Les gestes du plan, communs à tous les formulaires de déploiement."""

View file

@ -119,6 +119,7 @@ def run_proxmox_form(ctx, run_app: bool = True):
sur les invites textuelles. `run_app=False` rend l'instance sans la lancer
(tests headless)."""
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.widgets import (
Button,
@ -178,6 +179,12 @@ def run_proxmox_form(ctx, run_app: bool = True):
("f6", "select_all", t("All")),
("f7", "select_main", t("Main versions")),
("f8", "select_none", t("None")),
("f1", "help", t("Help")),
# « ? » aussi, parce que c'est la touche qu'on essaie d'abord.
# Cachée du pied de page : la même action deux fois s'y lirait
# comme deux aides. Un champ de saisie qui a le focus l'avale, et
# c'est pourquoi F1 existe à côté.
Binding("?", "help", t("Help"), show=False),
("escape", "cancel", t("Cancel")),
]
# Le socle porte la mise en page et les modales ; ne reste ici que ce

View file

@ -72,6 +72,12 @@ class QemuDeployMixin:
if deferred
else ""
)
# « aidev » n'est pas dans « deferred » : ses installations, elles,
# ont bien lieu. C'est sa MOITIÉ de pré-configuration qui reste
# dehors, les hooks et les gabarits vivant dans le dépôt.
if "aidev" in (tools or ()):
saute = t("no checkout: git hooks and Claude commands skipped")
note += f'echo " ⚠ {saute}"; '
return (
"set -e; "
+ self._qemu_cloud_init_wait()
@ -96,13 +102,13 @@ class QemuDeployMixin:
svc = self._qemu_odoo_service_cmd(prod).strip().rstrip(";")
final_cmd = f"{final_cmd} && {{ {svc}; }}"
# VM de DÉVELOPPEMENT uniquement : couper les mises à jour automatiques.
# Vécu sur erplibre-ubuntu-2404 : unattended-upgrades s'est déclenché en
# pleine migration Odoo 12->13 et a redémarré le cluster PostgreSQL
# (« received fast shutdown request » x3) -> OpenUpgrade a perdu sa
# connexion et la base intermédiaire est restée à moitié migrée. Effet
# secondaire bienvenu : les timers apt-daily ne tiennent plus le verrou
# apt pendant l'installation. En PROD on ne touche à rien : les
# correctifs de sécurité automatiques doivent rester actifs.
# unattended-upgrades REDÉMARRE le cluster PostgreSQL sous lui-même —
# « received fast shutdown request » — et une migration Odoo en cours y
# perd sa connexion : OpenUpgrade s'arrête et la base intermédiaire
# reste à moitié migrée. Effet secondaire bienvenu : les timers
# apt-daily ne tiennent plus le verrou apt pendant l'installation. En
# PROD on ne touche à rien : les correctifs de sécurité automatiques
# doivent rester actifs.
no_auto_upgrade = self._qemu_no_auto_upgrade(prod, app_store)
tools_cmd = self._qemu_tools_remote_cmd(
tools, prod, "before", ai_agent
@ -114,8 +120,7 @@ class QemuDeployMixin:
after_cmd = self._qemu_tools_remote_cmd(tools, prod, "after")
# APRÈS le make, et c'est mesuré : sur un dépôt cloné mais pas installé,
# PyCharm n'écrit AUCUN .idea — son configurateur d'interpréteur Python
# échoue faute de venv, et il renonce. « ⚠ pas de .idea », deux fois de
# suite sur erplibre-ubuntu-2604-gnome. Le même appel sur un dépôt
# échoue faute de venv, et il renonce. Le même appel sur un dépôt
# installé l'écrit en cinq minutes : erplibre.iml, misc.xml,
# modules.xml, vcs.xml.
#
@ -1179,9 +1184,9 @@ class QemuDeployMixin:
Le suivi d'installation tourne DÉTACHÉ, sans tty : il ne peut pas
répondre à une demande de mot de passe. « sudo -n » y échoue sur tout
hôte exigeant une authentification interactive (vécu sur erplibre01 avec
sudo-rs), et la VM devient alors introuvable dès que son bail DHCP
change. Le groupe libvirt est la seule voie qui n'exige ni root ni tty.
hôte exigeant une authentification interactive, et la VM devient alors
introuvable dès que son bail DHCP change. Le groupe libvirt est la
seule voie qui n'exige ni root ni tty.
Vérifié AVANT de créer quoi que ce soit : découvrir le problème après
vingt minutes d'installation coûte bien plus cher qu'une question ici.

View file

@ -85,6 +85,7 @@ def run_deploy_form(ctx, run_app: bool = True):
"""Formulaire de déploiement. Renvoie une spec, ou None si annulé.
`run_app=False` renvoie l'instance sans la lancer (tests headless)."""
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.widgets import (
Button,
@ -173,6 +174,12 @@ def run_deploy_form(ctx, run_app: bool = True):
("f6", "select_all", t("All")),
("f7", "select_main", t("Main versions")),
("f8", "select_none", t("None")),
("f1", "help", t("Help")),
# « ? » aussi, parce que c'est la touche qu'on essaie d'abord.
# Cachée du pied de page : la même action deux fois s'y lirait
# comme deux aides. Un champ de saisie qui a le focus l'avale, et
# c'est pourquoi F1 existe à côté.
Binding("?", "help", t("Help"), show=False),
("escape", "cancel", t("Cancel")),
]

View file

@ -761,6 +761,10 @@ class QemuInstallMixin:
_QEMU_VM_TOOLS = {
"pycharm": {
"label": "PyCharm",
"help": (
"JetBrains archive in /opt, launcher /usr/local/bin/pycharm",
"the checkout as project, .idea written like pycharm_configure",
),
"hint": "Python IDE, opens the ERPLibre checkout",
"disk_gb": 5,
"arches": ("amd64", "arm64"),
@ -771,6 +775,10 @@ class QemuInstallMixin:
},
"android": {
"label": "Android Studio",
"help": (
"Android Studio in /opt, desktop launcher and studio command",
"x86_64 only: Google publishes no other build",
),
"hint": "ERPLibre mobile development (x86_64 only)",
"disk_gb": 8,
"arches": ("amd64",),
@ -781,6 +789,11 @@ class QemuInstallMixin:
},
"gnome_ext": {
"label": "GNOME extensions",
"help": (
"the extension manager, plus the suggested extensions",
"distribution packages installed without being enabled",
"extensions named by UUID are enabled",
),
"hint": "suggested extensions + extension manager",
"disk_gb": 1,
"arches": (),
@ -802,6 +815,10 @@ class QemuInstallMixin:
# et sentencepiece clonés, node_modules, et les artefacts Gradle.
"mobile": {
"label": "ERPLibre mobile (build)",
"help": (
"Android SDK, then the debug APK and the Vitest run",
"a failed build marks the VM as failed",
),
"hint": "APK debug + Vitest, validates the VM",
"disk_gb": 12,
"arches": ("amd64",),
@ -827,6 +844,11 @@ class QemuInstallMixin:
# et les dépôts que l'utilisateur y poussera.
"forgejo": {
"label": "Forgejo (git forge)",
"help": (
"git forge on port 3000, SQLite database",
"system account, systemd service, admin account",
"posed by script/forgejo/install_forgejo.sh",
),
"hint": "self-hosted git forge on :3000, SQLite",
"disk_gb": 2,
"arches": ("amd64", "arm64"),
@ -838,23 +860,32 @@ class QemuInstallMixin:
# ni d'Odoo.
"phase": "after",
},
# L'émulateur n'a pas besoin de bureau DANS la VM : il s'affiche sur
# l'écran de qui s'y connecte, par « ssh -X ». Il a besoin, lui, de KVM
# dans la VM — donc de virtualisation imbriquée sur l'hôte, ce que le
# bloc vérifie et annonce plutôt que de laisser découvrir.
# Ni bureau ni famille de paquets : l'essentiel vient d'installateurs
# amont, qu'aucun dépôt de distribution ne porte, et les trois outils
# de terminal (tig, htop, vim) existent sous le même nom dans les
# quatre. Une VM serveur le prend donc aussi bien qu'une VM graphique
# — c'est en SSH qu'on s'en sert.
#
# Disque : ~1,5 Go d'image système, ~2 Go de données d'AVD, plus
# l'émulateur lui-même.
# Ni bureau ni famille de paquets : les quatre outils sont des
# installateurs amont, aucun n'est dans les dépôts des distributions
# supportées. Une VM serveur les prend donc aussi bien qu'une VM
# graphique — c'est en SSH qu'on s'en sert.
# L'outil travaille en DEUX temps : les installations avant le clone,
# puis ce qui a besoin du dépôt — hooks git et commandes Claude. La
# phase déclarée ici est la première ; la seconde est un complément
# gardé, ajouté par _qemu_after_remote_cmd.
#
# Disque : les binaires sont petits (rtk et starship sont statiques,
# l'agent est un bundle node) ; la marge couvre leurs caches.
"aidev": {
"label": "AI coding tools",
"hint": "rtk, starship, and one agent",
"help": (
"tig, htop and vim",
"rtk, plus its global auto-rewrite hook",
"starship, hooked into ~/.bashrc",
"the chosen agent: Claude Code or opencode",
"git: merge.conflictStyle zdiff3, core.editor vim if unset",
"git hooks of the checkout: commit-msg, pre-commit",
"the Claude commands: /commit, /git_prepare_merge, /todo_*",
"source .venv.erplibre/bin/activate in the shell history",
),
"hint": "rtk, starship, one agent, git and Claude ready",
"disk_gb": 2,
"arches": (),
"desktops": (),
@ -865,8 +896,20 @@ class QemuInstallMixin:
# ne répond pas.
"phase": "before",
},
# L'émulateur n'a pas besoin de bureau DANS la VM : il s'affiche sur
# l'écran de qui s'y connecte, par « ssh -X ». Il a besoin, lui, de KVM
# dans la VM — donc de virtualisation imbriquée sur l'hôte, ce que le
# bloc vérifie et annonce plutôt que de laisser découvrir.
#
# Disque : ~1,5 Go d'image système, ~2 Go de données d'AVD, plus
# l'émulateur lui-même.
"avd": {
"label": "Android emulator (Pixel)",
"help": (
"Android SDK, then a Pixel AVD named erplibre",
"software rendering: it opens over ssh -X",
"needs nested virtualization on the host",
),
"hint": "AVD viewable over ssh -X",
"disk_gb": 6,
"arches": ("amd64",),
@ -913,6 +956,13 @@ class QemuInstallMixin:
"mise_arches": self.QEMU_MISE_ARCHES,
"vm_tools": self._qemu_vm_tool_choices(),
"vm_tool_disk": {k: v["disk_gb"] for k, v in outils.items()},
# Ce que l'aide « ? » du formulaire affiche, outil par outil :
# une case cochée engage parfois huit poses, et son libellé n'en
# nomme que trois.
"vm_tool_help": {
k: tuple(t(x) for x in v.get("help", ()))
for k, v in outils.items()
},
"vm_tool_arches": {k: v["arches"] for k, v in outils.items()},
"vm_tool_desktops": {k: v["desktops"] for k, v in outils.items()},
# « after » = l'outil vit DANS le dépôt ERPLibre (compilation
@ -1189,24 +1239,45 @@ class QemuInstallMixin:
# réclame que le .iml et misc.xml.
_QEMU_PYCHARM_OPEN_TRIES = 60
def _qemu_xvfb_install_cmd(self):
"""Pose Xvfb avec le gestionnaire de paquets présent, sans bruit."""
x = self._QEMU_XVFB_PKG
def _qemu_pkg_install_cmd(self, paquets, quiet=True):
"""Pose des paquets avec le gestionnaire de paquets présent dans la VM.
`paquets` : une chaîne (ou une suite) quand le nom vaut pour les quatre
familles, ou {apt, dnf, zypper, pacman} quand il diverge de l'une à
l'autre. `quiet` renvoie la sortie au néant, pour ce qui n'a rien à
raconter.
Chaque branche rend 0, y compris celle qui échoue : les appelants
posent du CONFORT — un serveur X sans écran, des outils de terminal —
et aucun ne doit emporter l'installation d'ERPLibre. Le bloc rend 0
aussi sur une machine qui n'a aucun des quatre gestionnaires, ce qui
laisse « set -e » tranquille.
"""
if isinstance(paquets, dict):
noms = paquets
else:
liste = paquets if isinstance(paquets, str) else " ".join(paquets)
noms = dict.fromkeys(("apt", "dnf", "zypper", "pacman"), liste)
muet = " >/dev/null 2>&1" if quiet else ""
return (
"if command -v apt-get >/dev/null 2>&1; then "
"sudo DEBIAN_FRONTEND=noninteractive apt-get "
f"-o DPkg::Lock::Timeout=600 install -y {x['apt']} "
">/dev/null 2>&1 || true; "
f"-o DPkg::Lock::Timeout=600 install -y {noms['apt']}"
f"{muet} || true; "
"elif command -v dnf >/dev/null 2>&1; then "
f"sudo dnf install -y {x['dnf']} >/dev/null 2>&1 || true; "
f"sudo dnf install -y {noms['dnf']}{muet} || true; "
"elif command -v zypper >/dev/null 2>&1; then "
"sudo zypper --non-interactive install --auto-agree-with-licenses "
f"{x['zypper']} >/dev/null 2>&1 || true; "
f"{noms['zypper']}{muet} || true; "
"elif command -v pacman >/dev/null 2>&1; then "
f"sudo pacman -S --needed --noconfirm {x['pacman']} "
">/dev/null 2>&1 || true; fi; "
f"sudo pacman -S --needed --noconfirm {noms['pacman']}"
f"{muet} || true; fi; "
)
def _qemu_xvfb_install_cmd(self):
"""Pose Xvfb avec le gestionnaire de paquets présent, sans bruit."""
return self._qemu_pkg_install_cmd(self._QEMU_XVFB_PKG)
def _qemu_pycharm_project_cmd(self, prod=False):
"""Crée le .idea/ du dépôt en ouvrant PyCharm une fois, sans écran.
@ -2006,14 +2077,19 @@ class QemuInstallMixin:
émulateur créé avec succès effacerait le verdict de la compilation."""
picked = [
k
for k in ("forgejo", "mobile", "avd")
for k in ("aidev", "forgejo", "mobile", "avd")
if k in (tools or ()) and k in self._QEMU_VM_TOOLS
]
if not picked:
return ""
el_dir = self._qemu_install_dir(prod)
parts = []
# Forgejo d'abord : une minute, contre une heure pour le SDK et l'APK.
# La pré-configuration IA en tête : quelques secondes de copies, contre
# une minute pour Forgejo et une heure pour le SDK. Elle est aussi la
# seule à rendre toujours 0 — ce qui suit porte le verdict de la VM.
if "aidev" in picked:
parts.append(f"{{ {self._qemu_aidev_after_cmd(prod)}; }}")
# Forgejo ensuite : une minute, contre une heure pour le SDK et l'APK.
# Un échec rapide se voit tôt plutôt qu'après le long.
if "forgejo" in picked:
parts.append(f"{{ {self._qemu_forgejo_steps(el_dir)}; }}")
@ -2048,8 +2124,37 @@ class QemuInstallMixin:
"""Émulateur seul."""
return self._qemu_after_remote_cmd(("avd",), prod)
# Les outils de terminal qui accompagnent les assistants. Ceux-là SONT
# empaquetés, sous le même nom dans les quatre familles : rien à résoudre.
_QEMU_AIDEV_PKGS = "tig htop vim"
# La commande qu'une VM neuve rend à la première flèche du haut. Le chemin
# est relatif : le venv est à la racine du dépôt, et c'est de là qu'on
# l'active, en dev comme en production.
_QEMU_AIDEV_HISTORY = "source .venv.erplibre/bin/activate"
# Les commandes Claude déployées dans la VM : nom de la commande -> gabarit
# de conf/. La même liste qu'au menu de l'hôte. Les deux « todo_ » vont
# ensemble — /todo_plan_max produit la spécification que /todo_add_command
# implémente, et l'une sans l'autre laisse la moitié de la chaîne.
_QEMU_AIDEV_CLAUDE_CMDS = (
("commit", "template_claude_commands_commit.md"),
("git_prepare_merge", "template_claude_commands_git_prepare_merge.md"),
("todo_plan_max", "template_claude_commands_todo_plan_max.md"),
("todo_add_command", "template_claude_commands_todo_add_command.md"),
(
"todo_generate_code",
"template_claude_commands_todo_generate_code.md",
),
)
def _qemu_aidev_remote_cmd(self, agent=""):
"""rtk, starship et UN agent, posés dans la VM.
"""Les outils d'assistance et leur pré-configuration, dans la VM.
Trois installateurs amont — rtk, starship, UN agent —, trois paquets de
terminal, et les réglages qui font qu'on s'en sert sans rien retaper :
le hook global de rtk, l'accroche du prompt, le PATH, la configuration
git, et une entrée d'historique pour activer le venv.
Chaque pose est bornée dans le temps ET privée d'entrée standard. Le
contrat de la phase « before » veut qu'un outil ne fasse échouer ni
@ -2059,13 +2164,16 @@ class QemuInstallMixin:
« </dev/null » la lui fait rater tout de suite, « timeout » borne le
reste. C'est aussi pourquoi starship reçoit « -y ».
L'accroche du prompt et la ligne de PATH sont posées UNE fois :
sans le « grep » qui précède, chaque redéploiement d'une même VM
rallonge son ~/.bashrc d'une ligne identique.
Les lignes ajoutées à un fichier du HOME le sont UNE fois : sans le
« grep » qui précède, chaque redéploiement d'une même VM rallonge son
~/.bashrc — ou son historique — d'une ligne identique.
Le répertoire est écrit en « $HOME » et non en « ~ » : entre
guillemets, le tilde n'est pas étendu par le shell, et le PATH
porterait alors un chemin qui n'existe pas.
Ce que le dépôt seul peut donner — hooks git, commandes Claude — n'est
pas ici : voir _qemu_aidev_after_cmd.
"""
commande, repertoire = dev_tools.AGENTS.get(
agent or dev_tools.AGENT_DEFAUT,
@ -2074,6 +2182,13 @@ class QemuInstallMixin:
repertoire = repertoire.replace("~/", "$HOME/", 1)
prompt = dev_tools.STARSHIP_LINE["bash"]
path_line = f'export PATH="{repertoire}:$PATH"'
# rtk se pose dans ~/.local/bin, que le PATH d'un shell ne porte pas
# partout. La ligne de l'agent ne couvre ce répertoire que pour Claude
# Code ; avec opencode, qui s'installe ailleurs, rtk resterait
# introuvable. Les deux lignes se dédoublonnent d'elles-mêmes, le grep
# portant sur le répertoire.
local_bin = "$HOME/.local/bin"
local_line = f'export PATH="{local_bin}:$PATH"'
def pose(cmd, secondes):
return (
@ -2081,19 +2196,142 @@ class QemuInstallMixin:
" </dev/null || true; "
)
def une_fois(ligne, motif):
def une_fois(ligne, motif, fichier="~/.bashrc"):
return (
f"grep -qF {shlex.quote(motif)} ~/.bashrc 2>/dev/null"
f" || echo {shlex.quote(ligne)} >> ~/.bashrc; "
f"grep -qF {shlex.quote(motif)} {fichier} 2>/dev/null"
f" || echo {shlex.quote(ligne)} >> {fichier}; "
)
return (
f'echo "== {t("AI coding tools")} =="; '
+ self._qemu_pkg_install_cmd(self._QEMU_AIDEV_PKGS)
+ pose(dev_tools.RTK_UPSTREAM, 300)
# Par son chemin absolu, et non par « rtk » nu : le PATH de cette
# commande distante a été figé au démarrage du shell SSH, avant que
# l'installateur ne pose le binaire. Le code 127 qu'on obtiendrait
# sinon ne dirait pas que le hook n'a pas été écrit.
+ f'RTK="$(command -v rtk || echo "{local_bin}/rtk")"; '
+ '[ -x "$RTK" ] && timeout 60 "$RTK" init --global'
" </dev/null >/dev/null 2>&1 || true; "
+ pose(dev_tools.STARSHIP_UPSTREAM_YES, 300)
+ une_fois(prompt, "starship init bash")
+ pose(commande, 600)
+ une_fois(path_line, repertoire)
+ une_fois(local_line, local_bin)
# Claude Code s'installe DANS ~/.local/bin : la ligne serait la
# même, écrite deux fois dans le journal pour un seul effet.
+ (
une_fois(path_line, repertoire)
if repertoire != local_bin
else ""
)
+ self._qemu_aidev_git_cmd()
+ une_fois(
self._QEMU_AIDEV_HISTORY,
self._QEMU_AIDEV_HISTORY,
"~/.bash_history",
)
# bash crée son historique en 600 ; une redirection le crée selon
# l'umask, soit lisible par tous sur les images visées.
+ "chmod 600 ~/.bash_history 2>/dev/null || true; "
)
def _qemu_aidev_git_cmd(self):
"""Les deux réglages git globaux de la pré-configuration.
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.
L'éditeur, lui, n'est posé QUE s'il n'y en a pas : deploy_qemu.py
transmet celui de l'hôte dans le ~/.gitconfig de la VM et l'annonce
dans le guide de connexion, et l'écraser ici ferait deux autorités sur
un même réglage. vim est le repli, et son paquet vient d'être posé.
Le tout dans un « if » : la phase « before » d'une VM sans installation
ERPLibre n'a pas vu l'amorçage qui pose git, et « set -e » ferait
tomber le déploiement sur une commande introuvable.
"""
return (
"if command -v git >/dev/null 2>&1; then "
"git config --global merge.conflictStyle zdiff3 || true; "
"git config --global --get core.editor >/dev/null 2>&1 "
"|| git config --global core.editor vim || true; fi; "
)
def _qemu_aidev_after_cmd(self, prod=False):
"""La part de la pré-configuration qui a besoin du dépôt cloné.
Les hooks vivent dans le dépôt (`script/git/hooks`) et les gabarits des
commandes Claude dans `conf/` : avant le clone, ni l'un ni l'autre
n'existe. Le bit d'exécution fait partie de l'installation des hooks —
sans lui git les ignore SANS RIEN DIRE, et le garde-fou du message de
commit passe inaperçu.
Rendu SANS point-virgule final, comme les autres étapes de la phase
« après » : l'appelant enveloppe le bloc dans des accolades, et un
« ; ; » y est une erreur de syntaxe qui emporte tout le groupe.
Le bloc rend toujours 0. Une pré-configuration est un confort : c'est
l'installation qui porte le verdict de la VM, pas elle.
"""
el_dir = self._qemu_install_dir(prod)
hooks = self._GIT_HOOKS_PATH
cibles = " ".join(f"{el_dir}/{hooks}/{h}" for h in self._GIT_HOOKS)
etapes = [
f'echo "== {t("AI coding tools, pre-configuration")} =="',
# « git -C » et non le cwd : le dépôt porte des dépôts imbriqués
# (odoo18.0/addons/…), et core.hooksPath écrit dans l'un d'eux
# laisserait la racine sans garde-fou, sans le moindre message.
f"git -C {el_dir} config core.hooksPath {hooks} || true",
f"chmod +x {cibles} 2>/dev/null || true",
"mkdir -p ~/.claude/commands || true",
]
etapes += [
f"cp -f {el_dir}/conf/{gabarit}"
f" ~/.claude/commands/{nom}.md 2>/dev/null || true"
for nom, gabarit in self._QEMU_AIDEV_CLAUDE_CMDS
]
etapes.append(self._qemu_aidev_identity_cmd())
etapes.append(
'echo " ~/.claude/commands: '
"$(ls ~/.claude/commands/*.md 2>/dev/null | wc -l)"
f'/{len(self._QEMU_AIDEV_CLAUDE_CMDS)}"'
)
return "; ".join(etapes)
# Ce que le gabarit de /commit porte en exemple, et que l'identité de la
# VM remplace : une ligne « git -c user.name=… » qu'on recopie.
_QEMU_AIDEV_PLACEHOLDERS = (
("Your Name", "user.name"),
("your@email.com", "user.email"),
)
def _qemu_aidev_identity_cmd(self):
"""Substitue l'identité git de la VM dans la commande /commit.
En python3 et non en sed : le remplacement se fait alors sur du texte
littéral, là où un nom qui porterait « & » ou le séparateur choisi
changerait de sens dans un « s/// ». python3 est présent dans toutes
les images visées — cloud-init, qui les amorce, en dépend lui-même.
Une identité absente laisse le gabarit tel quel : l'exemple reste
lisible, et rien ne prétend une identité qu'on n'a pas.
"""
remplacements = "".join(
f'.replace("{marque}", g("{cle}") or "{marque}")'
for marque, cle in self._QEMU_AIDEV_PLACEHOLDERS
)
code = (
"import pathlib,subprocess as s;"
'g=lambda k:s.run(["git","config","--global","--get",k],'
"capture_output=True,text=True).stdout.strip();"
'p=pathlib.Path("~/.claude/commands/commit.md").expanduser();'
f"p.write_text(p.read_text(){remplacements})"
)
return (
"command -v python3 >/dev/null 2>&1 && "
f"python3 -c {shlex.quote(code)} 2>/dev/null || true"
)
def _qemu_tools_remote_cmd(
@ -2108,7 +2346,9 @@ class QemuInstallMixin:
elle dépend, et qui elle NE se garde PAS. C'est le contrat demandé : une
VM dont l'application ne compile pas doit être rouge."""
if phase == "after":
# Un seul bloc pour les deux options : voir _qemu_after_remote_cmd.
# Un seul bloc pour toutes les options : voir
# _qemu_after_remote_cmd. « aidev » y entre bien qu'il soit déclaré
# « before » — ses installations le sont, son complément non.
return self._qemu_after_remote_cmd(tools, prod)
blocks = {
# En tête : quelques secondes de curl, contre des minutes pour un

View file

@ -5015,9 +5015,205 @@ TRANSLATIONS = {
"fr": "Outils d'assistance IA",
"en": "AI coding tools",
},
"rtk, starship, and one agent": {
"fr": "rtk, starship, et un agent",
"en": "rtk, starship, and one agent",
"Python IDE, opens the ERPLibre checkout": {
"fr": "IDE Python, ouvre le dépôt ERPLibre",
"en": "Python IDE, opens the ERPLibre checkout",
},
"rtk, starship, one agent, git and Claude ready": {
"fr": "rtk, starship, un agent, git et Claude prêts",
"en": "rtk, starship, one agent, git and Claude ready",
},
"Help": {
"fr": "Aide",
"en": "Help",
},
"What each option installs": {
"fr": "Ce que chaque option installe",
"en": "What each option installs",
},
"(F1 or ? : details)": {
"fr": "(F1 ou ? : le détail)",
"en": "(F1 or ? : details)",
},
"VM type": {
"fr": "Type de VM",
"en": "VM type",
},
"Application store": {
"fr": "Magasin d'applications",
"en": "Application store",
},
"Python interpreter": {
"fr": "Interpréteur Python",
"en": "Python interpreter",
},
"clone of the checkout, then the make target of the profile": {
"fr": "clone du dépôt, puis la cible make du profil",
"en": "clone of the checkout, then the make target of the profile",
},
"in ~/git/erplibre, or /opt/erplibre in production": {
"fr": "dans ~/git/erplibre, ou /opt/erplibre en production",
"en": "in ~/git/erplibre, or /opt/erplibre in production",
},
"unchecked: the VM stays a bare distribution": {
"fr": "décochée : la VM reste une distribution nue",
"en": "unchecked: the VM stays a bare distribution",
},
"about +5 Go of disk": {
"fr": "environ +5 Go de disque",
"en": "about +5 Go of disk",
},
"/opt/erplibre instead of ~/git/erplibre": {
"fr": "/opt/erplibre au lieu de ~/git/erplibre",
"en": "/opt/erplibre instead of ~/git/erplibre",
},
"Odoo registered as a systemd service": {
"fr": "Odoo enregistré en service systemd",
"en": "Odoo registered as a systemd service",
},
"automatic updates left ON, unlike a dev VM": {
"fr": "mises à jour automatiques laissées ACTIVES, contrairement au dev",
"en": "automatic updates left ON, unlike a dev VM",
},
"server: SSH only, no graphical session": {
"fr": "serveur : SSH seul, sans session graphique",
"en": "server: SSH only, no graphical session",
},
"graphical: the chosen desktop plus its remote access": {
"fr": "graphique : le bureau choisi et son accès distant",
"en": "graphical: the chosen desktop plus its remote access",
},
"an IDE needs a desktop, the build tools do not": {
"fr": "un IDE demande un bureau, les outils de compilation non",
"en": "an IDE needs a desktop, the build tools do not",
},
"deb/rpm: packages of the distribution only": {
"fr": "deb/rpm : les paquets de la distribution seulement",
"en": "deb/rpm: packages of the distribution only",
},
"snap: snapd kept active where the distribution has it": {
"fr": "snap : snapd laissé actif là où la distribution l'a",
"en": "snap: snapd kept active where the distribution has it",
},
"IANA name written by cloud-init at first boot": {
"fr": "nom IANA écrit par cloud-init au premier démarrage",
"en": "IANA name written by cloud-init at first boot",
},
"a misspelled name is IGNORED: the VM stays in UTC": {
"fr": "un nom mal orthographié est IGNORÉ : la VM reste en UTC",
"en": "a misspelled name is IGNORED: the VM stays in UTC",
},
"mise poses a precompiled CPython, in minutes": {
"fr": "mise pose un CPython précompilé, en quelques minutes",
"en": "mise poses a precompiled CPython, in minutes",
},
"pyenv compiles it, which is much longer": {
"fr": "pyenv le compile, ce qui est bien plus long",
"en": "pyenv compiles it, which is much longer",
},
"mise publishes no binary for every architecture": {
"fr": "mise ne publie pas de binaire pour toutes les architectures",
"en": "mise publishes no binary for every architecture",
},
"JetBrains archive in /opt, launcher /usr/local/bin/pycharm": {
"fr": "archive JetBrains dans /opt, lanceur /usr/local/bin/pycharm",
"en": "JetBrains archive in /opt, launcher /usr/local/bin/pycharm",
},
"the checkout as project, .idea written like pycharm_configure": {
"fr": "le dépôt comme projet, .idea écrit comme pycharm_configure",
"en": "the checkout as project, .idea written like pycharm_configure",
},
"Android Studio in /opt, desktop launcher and studio command": {
"fr": "Android Studio dans /opt, lanceur de bureau et commande studio",
"en": "Android Studio in /opt, desktop launcher and studio command",
},
"x86_64 only: Google publishes no other build": {
"fr": "x86_64 seulement : Google n'en publie pas d'autre",
"en": "x86_64 only: Google publishes no other build",
},
"the extension manager, plus the suggested extensions": {
"fr": "le gestionnaire d'extensions, et les extensions suggérées",
"en": "the extension manager, plus the suggested extensions",
},
"distribution packages installed without being enabled": {
"fr": "les paquets de la distribution posés sans être activés",
"en": "distribution packages installed without being enabled",
},
"extensions named by UUID are enabled": {
"fr": "les extensions nommées par leur UUID sont activées",
"en": "extensions named by UUID are enabled",
},
"Android SDK, then the debug APK and the Vitest run": {
"fr": "le SDK Android, puis l'APK debug et la passe Vitest",
"en": "Android SDK, then the debug APK and the Vitest run",
},
"a failed build marks the VM as failed": {
"fr": "une compilation en échec fait échouer la VM",
"en": "a failed build marks the VM as failed",
},
"git forge on port 3000, SQLite database": {
"fr": "forge git sur le port 3000, base SQLite",
"en": "git forge on port 3000, SQLite database",
},
"system account, systemd service, admin account": {
"fr": "compte système, service systemd, compte administrateur",
"en": "system account, systemd service, admin account",
},
"posed by script/forgejo/install_forgejo.sh": {
"fr": "posé par script/forgejo/install_forgejo.sh",
"en": "posed by script/forgejo/install_forgejo.sh",
},
"tig, htop and vim": {
"fr": "tig, htop et vim",
"en": "tig, htop and vim",
},
"rtk, plus its global auto-rewrite hook": {
"fr": "rtk, et son hook global de réécriture",
"en": "rtk, plus its global auto-rewrite hook",
},
"starship, hooked into ~/.bashrc": {
"fr": "starship, accroché au ~/.bashrc",
"en": "starship, hooked into ~/.bashrc",
},
"the chosen agent: Claude Code or opencode": {
"fr": "l'agent choisi : Claude Code ou opencode",
"en": "the chosen agent: Claude Code or opencode",
},
"git: merge.conflictStyle zdiff3, core.editor vim if unset": {
"fr": "git : merge.conflictStyle zdiff3, core.editor vim s'il est vide",
"en": "git: merge.conflictStyle zdiff3, core.editor vim if unset",
},
"git hooks of the checkout: commit-msg, pre-commit": {
"fr": "les hooks git du dépôt : commit-msg, pre-commit",
"en": "git hooks of the checkout: commit-msg, pre-commit",
},
"the Claude commands: /commit, /git_prepare_merge, /todo_*": {
"fr": "les commandes Claude : /commit, /git_prepare_merge, /todo_*",
"en": "the Claude commands: /commit, /git_prepare_merge, /todo_*",
},
"source .venv.erplibre/bin/activate in the shell history": {
"fr": "source .venv.erplibre/bin/activate dans l'historique du shell",
"en": "source .venv.erplibre/bin/activate in the shell history",
},
"Android SDK, then a Pixel AVD named erplibre": {
"fr": "le SDK Android, puis un AVD Pixel nommé erplibre",
"en": "Android SDK, then a Pixel AVD named erplibre",
},
"software rendering: it opens over ssh -X": {
"fr": "rendu logiciel : il s'ouvre par ssh -X",
"en": "software rendering: it opens over ssh -X",
},
"needs nested virtualization on the host": {
"fr": "demande la virtualisation imbriquée sur l'hôte",
"en": "needs nested virtualization on the host",
},
"AI coding tools, pre-configuration": {
"fr": "Outils d'assistance IA, pré-configuration",
"en": "AI coding tools, pre-configuration",
},
"no checkout: git hooks and Claude commands skipped": {
"fr": "sans dépôt : hooks git et commandes Claude sautés",
"en": "no checkout: git hooks and Claude commands skipped",
},
"Name for git": {
"fr": "Nom pour git",

View file

@ -0,0 +1,230 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""L'aide « ? » du formulaire de déploiement.
Une case cochée engage parfois huit poses, et son libellé n'en nomme que
trois : « Outils d'assistance IA » installe aussi les hooks git du dépôt, les
commandes Claude et une entrée d'historique. Ce que le formulaire fait ne
peut pas se lire seulement dans le code de la commande distante — on décide
devant l'écran, une heure avant que le journal ne le montre.
Ce que ces tests gardent :
- chaque outil du catalogue a une aide, et un outil ajouté sans texte fait
tomber le test plutôt que d'afficher un bloc vide ;
- l'aide TRAVERSE le contexte : c'est le seul chemin jusqu'aux deux écrans,
et un réglage qui n'y entre pas n'existe pas ;
- les DEUX formulaires l'ouvrent — c'est en n'en servant qu'un que l'écran
Proxmox avait perdu la moitié des réglages ;
- la fenêtre se ferme sur Esc sans rien changer, et sans fermer l'écran qui
la porte.
"""
import asyncio
import importlib.util
import sys
import unittest
from pathlib import Path
sys.argv = ["todo.py"]
from script.todo.deploy_form_extras import ( # noqa: E402
INSTALL_HELP,
ExtrasMixin,
extras_tables,
)
from script.todo.todo import TODO # noqa: E402
try:
import textual # noqa: F401
TEXTUAL = True
except Exception: # pragma: no cover - dépend de l'environnement
TEXTUAL = False
def contexte():
todo = TODO.__new__(TODO)
mod = todo._qemu_import_module()
todo._qemu_list_domains = lambda: []
todo._qemu_branch_list = lambda: ["develop", "master"]
return todo._qemu_form_context(mod)
def contexte_proxmox():
"""Le contexte synthétique de l'écran Proxmox, plus le système invité.
Chargé PAR SON CHEMIN, comme le fait test_qemu_ai_tools de deploy_qemu :
« test » est aussi un paquet de la bibliothèque standard, et un import par
son nom y mènerait. Le contexte lui-même n'est pas recopié — une seconde
copie dériverait de celle que garde test_proxmox_form.
"""
chemin = Path(__file__).resolve().parent / "test_proxmox_form.py"
spec = importlib.util.spec_from_file_location("tpf_ctx", chemin)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
invite = TODO.__new__(TODO)._qemu_guest_context()
return {**invite, **mod.contexte()}
class Ecran(ExtrasMixin):
"""Le mixin seul, sans Textual : les blocs sont du texte, pas des widgets."""
def __init__(self, ctx):
self._extras = extras_tables(ctx)
class LeCatalogue(unittest.TestCase):
def test_every_tool_says_what_it_installs(self):
"""Un outil sans aide afficherait un bloc vide, ce qui se lit comme
« il ne pose rien »."""
for key, spec in TODO._QEMU_VM_TOOLS.items():
with self.subTest(outil=key):
self.assertTrue(spec.get("help"), key)
for ligne in spec["help"]:
self.assertTrue(ligne.strip())
def test_the_ai_tool_names_all_its_halves(self):
"""La case engage des installations ET une pré-configuration : celle
qui n'est pas nommée est celle qu'on croit absente."""
aide = " ".join(TODO._QEMU_VM_TOOLS["aidev"]["help"])
for morceau in (
"tig",
"rtk",
"starship",
"zdiff3",
"hooks",
"commit",
".venv.erplibre/bin/activate",
):
self.assertIn(morceau, aide)
def test_the_help_crosses_the_context(self):
"""Le contexte est le seul chemin jusqu'aux deux écrans."""
ctx = contexte()
aides = ctx["vm_tool_help"]
self.assertEqual(sorted(TODO._QEMU_VM_TOOLS), sorted(aides))
for key, lignes in aides.items():
self.assertTrue(lignes, key)
def test_the_common_options_point_at_real_tables(self):
"""Une condition sur une clé inexistante lèverait au premier appel de
l'aide, sur l'écran, devant l'utilisateur."""
tab = extras_tables(contexte())
for cle, titre, lignes in INSTALL_HELP:
with self.subTest(titre=titre):
if cle:
self.assertIn(cle, tab)
self.assertTrue(titre.strip())
self.assertTrue(lignes)
class LesBlocs(unittest.TestCase):
def test_it_covers_the_common_settings_and_every_offered_tool(self):
blocs = Ecran(contexte()).extras_help_blocks()
titres = [titre for titre, _lignes in blocs]
self.assertEqual(len(blocs), len(titres))
for _key, label, _hint in extras_tables(contexte())["vm_tools"]:
self.assertIn(label, titres)
# Les réglages communs viennent d'abord : on lit l'écran de haut en
# bas, et l'aide suit le même ordre.
self.assertLess(len(INSTALL_HELP), len(blocs))
def test_each_tool_block_ends_with_its_disk_cost(self):
"""Le disque est ce qui décide de cocher ou non sur une machine
étroite : il est dans le bloc, pas seulement dans le libellé."""
blocs = dict(Ecran(contexte()).extras_help_blocks())
for _key, label, _hint in extras_tables(contexte())["vm_tools"]:
self.assertRegex(blocs[label][-1], r"^\+\d+ Go$")
def test_a_screen_without_tools_says_nothing_about_them(self):
"""Chaque accès aux tables est gardé : un formulaire n'est pas tenu
d'offrir tous les réglages."""
blocs = Ecran({}).extras_help_blocks()
self.assertTrue(blocs)
self.assertEqual(len(INSTALL_HELP) - 4, len(blocs))
def ouvre_l_aide(forme="qemu", touche="f1", saisie=False):
"""Ce que l'écran montre : {ouvert, texte, ferme, depart, piles}.
Chaque forme apporte SON contexte : celui de l'autre écran lui manquerait
la moitié de ses clés, et l'écran ne se monterait pas.
"""
from script.todo.proxmox_deploy_form import run_proxmox_form
from script.todo.qemu_deploy_form import run_deploy_form
vu = {}
async def scenario():
app = (
run_deploy_form(contexte(), run_app=False)
if forme == "qemu"
else run_proxmox_form(contexte_proxmox(), run_app=False)
)
async with app.run_test(size=(200, 60)) as pilote:
await pilote.pause()
if saisie:
# Un champ de saisie avale les touches imprimables : c'est la
# situation où « ? » ne peut pas servir, et F1 doit.
from textual.widgets import Input
app.set_focus(app.query_one("#f_key", Input))
await pilote.pause()
depart = app.screen.__class__.__name__
await pilote.press(touche)
await pilote.pause()
vu["ouvert"] = app.screen.__class__.__name__
vu["texte"] = " ".join(
str(w.render()) for w in app.screen.query("Static")
)
await pilote.press("escape")
await pilote.pause()
vu["ferme"] = app.screen.__class__.__name__
vu["depart"] = depart
vu["piles"] = len(app.screen_stack)
asyncio.run(scenario())
return vu
@unittest.skipUnless(TEXTUAL, "Textual absent")
class LEcran(unittest.TestCase):
def test_f1_opens_it_on_both_forms(self):
"""C'est en ne servant qu'un des deux écrans que la moitié des
réglages avait dérivé."""
for forme in ("qemu", "proxmox"):
with self.subTest(forme=forme):
vu = ouvre_l_aide(forme)
self.assertEqual("HelpScreen", vu["ouvert"])
def test_the_question_mark_opens_it_too(self):
"""C'est la touche qu'on essaie d'abord, et le panneau l'a au montage
— aucun champ de saisie ne prend le focus en arrivant."""
vu = ouvre_l_aide("qemu", "?")
self.assertEqual("HelpScreen", vu["ouvert"])
def test_f1_is_the_one_that_works_from_a_text_field(self):
"""Un champ de saisie avale les touches imprimables : « ? » s'y écrit
au lieu d'ouvrir, et c'est pourquoi F1 existe à côté."""
self.assertEqual(
"HelpScreen", ouvre_l_aide("qemu", "f1", saisie=True)["ouvert"]
)
self.assertNotEqual(
"HelpScreen", ouvre_l_aide("qemu", "?", saisie=True)["ouvert"]
)
def test_escape_closes_it_and_leaves_the_form_standing(self):
"""Une aide qui fermerait le formulaire ferait perdre la saisie."""
vu = ouvre_l_aide("qemu")
self.assertEqual(vu["depart"], vu["ferme"])
self.assertEqual(1, vu["piles"])
def test_what_it_shows_is_what_the_option_installs(self):
vu = ouvre_l_aide("qemu")
for morceau in ("tig", "zdiff3", "hooks"):
self.assertIn(morceau, vu["texte"])
if __name__ == "__main__":
unittest.main()

View file

@ -1,11 +1,15 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Les outils d'assistance posés dans une VM au déploiement.
"""Les outils d'assistance et leur pré-configuration, dans une VM.
rtk, starship et UN agent — Claude Code ou opencode. Aucun n'est dans les
dépôts des distributions supportées : ce sont quatre installateurs amont,
donc quatre curl vers l'extérieur, lancés sur un SSH sans terminal.
rtk, starship et UN agent — Claude Code ou opencode — sont des installateurs
amont, donc autant de curl vers l'extérieur lancés sur un SSH sans terminal.
Autour d'eux : trois paquets de terminal, les réglages git, les hooks du
dépôt, les commandes Claude et une entrée d'historique.
L'outil travaille en DEUX temps, et c'est le partage que ces tests gardent en
premier : ce qui s'installe avant le clone, puis ce qui a besoin du dépôt.
Ce que ces tests gardent :
@ -13,8 +17,11 @@ Ce que ces tests gardent :
outils, et deux copies dérivent dès que l'amont en change une ;
- aucune pose ne peut PENDRE : « || true » couvre l'échec, pas l'attente
d'une réponse que personne ne donnera ;
- les lignes ajoutées au ~/.bashrc le sont UNE fois, sinon chaque
- toute ligne ajoutée à un fichier du HOME l'est UNE fois, sinon chaque
redéploiement d'une même VM le rallonge ;
- la pré-configuration rend TOUJOURS 0 : c'est l'installation qui porte le
verdict de la VM, pas un confort ;
- ce qui manque sans clone est NOMMÉ, jamais tu ;
- l'identité git saisie prime sur celle de l'hôte, champ par champ.
"""
@ -105,11 +112,17 @@ class LaCommandeDistante(unittest.TestCase):
def test_no_pose_can_hang(self):
"""« || true » couvre l'ÉCHEC, pas l'ATTENTE. Un installateur qui
pose une question resterait pendu sur un SSH sans terminal, et le
déploiement avec lui."""
déploiement avec lui.
L'invariant est vérifié pose par pose plutôt que par un compte : un
compte figé se contente d'être mis à jour quand une pose s'ajoute,
sans rien dire de la nouvelle."""
cmd = self._cmd("claude")
self.assertEqual(3, cmd.count("</dev/null"))
self.assertEqual(3, cmd.count("timeout "))
self.assertEqual(3, cmd.count("|| true"))
bornees = [x for x in cmd.split("; ") if "timeout " in x]
self.assertGreaterEqual(len(bornees), 4)
for morceau in bornees:
self.assertIn("</dev/null", morceau)
self.assertIn("|| true", morceau)
def test_starship_is_told_not_to_ask(self):
"""Sans « -y », son installateur attend une confirmation."""
@ -117,12 +130,81 @@ class LaCommandeDistante(unittest.TestCase):
def test_the_rc_lines_are_written_once(self):
"""Sans le « grep » qui précède, chaque redéploiement d'une même VM
rallonge son ~/.bashrc d'une ligne identique."""
rallonge son ~/.bashrc — ou son historique — d'une ligne identique.
Aucun ajout n'échappe à la règle : on compte les « >> » et non les
greps, pour qu'une ligne ajoutée sans garde fasse tomber le test."""
for agent in ("claude", "opencode"):
cmd = self._cmd(agent)
with self.subTest(agent=agent):
self.assertEqual(cmd.count(">> "), cmd.count("grep -qF"))
for morceau in cmd.split("; "):
if ">> " in morceau:
self.assertIn("grep -qF", morceau)
def test_the_local_bin_is_on_the_path_for_every_agent(self):
"""rtk se pose dans ~/.local/bin. La ligne de l'agent ne couvre ce
répertoire que pour Claude Code : avec opencode, qui s'installe
ailleurs, rtk resterait introuvable dans la VM."""
for agent in ("claude", "opencode"):
with self.subTest(agent=agent):
self.assertIn(
'export PATH="$HOME/.local/bin:$PATH"', self._cmd(agent)
)
def test_an_identical_path_line_is_not_written_twice(self):
"""Claude Code S'INSTALLE dans ~/.local/bin : deux lignes identiques
n'auraient qu'un effet, et deux fois la place dans le journal."""
cmd = self._cmd("claude")
self.assertEqual(2, cmd.count("grep -qF"))
for morceau in cmd.split(";"):
if ">> ~/.bashrc" in morceau:
self.assertIn("||", morceau)
self.assertEqual(1, cmd.count("$HOME/.local/bin:$PATH"))
def test_the_terminal_tools_are_posed(self):
"""tig, htop et vim portent le même nom dans les quatre familles :
aucune n'a de raison de les rater."""
cmd = self._cmd("claude")
for gestionnaire in ("apt-get", "dnf", "zypper", "pacman"):
self.assertIn(gestionnaire, cmd)
self.assertEqual(4, cmd.count("tig htop vim"))
def test_the_venv_activation_reaches_the_history(self):
"""La commande qu'on veut retrouver à la flèche du haut, dans le
fichier que bash relit, et une seule fois."""
cmd = self._cmd("claude")
ligne = "source .venv.erplibre/bin/activate"
self.assertIn(f"echo '{ligne}' >> ~/.bash_history", cmd)
self.assertIn(f"grep -qF '{ligne}' ~/.bash_history", cmd)
def test_the_history_keeps_the_rights_bash_gives_it(self):
"""Créé par une redirection, le fichier suit l'umask — lisible par
tous sur les images visées, là où bash le crée en 600."""
self.assertIn("chmod 600 ~/.bash_history", self._cmd("claude"))
def test_the_rtk_hook_is_called_by_absolute_path(self):
"""Le PATH de cette commande distante a été figé au démarrage du
shell SSH, avant que l'installateur ne pose le binaire : « rtk » nu
rendrait 127 sans dire que le hook n'a pas été écrit."""
cmd = self._cmd("claude")
self.assertIn('RTK="$(command -v rtk || echo "$HOME', cmd)
self.assertIn('"$RTK" init --global', cmd)
def test_git_is_configured_without_taking_the_editor_over(self):
"""zdiff3 est posé sans condition. L'éditeur, lui, ne l'est que s'il
n'y en a pas : deploy_qemu transmet celui de l'hôte, et deux
autorités sur un même réglage en font une de trop."""
cmd = self._cmd("claude")
self.assertIn("git config --global merge.conflictStyle zdiff3", cmd)
self.assertIn(
"git config --global --get core.editor >/dev/null 2>&1"
" || git config --global core.editor vim",
cmd,
)
def test_the_git_settings_survive_a_vm_without_git(self):
"""La phase « before » d'une VM sans installation ERPLibre n'a pas vu
l'amorçage qui pose git : hors d'un « if », « set -e » ferait tomber
le déploiement sur une commande introuvable."""
cmd = self._cmd("claude")
self.assertIn("if command -v git >/dev/null 2>&1; then", cmd)
def test_the_path_uses_home_not_a_tilde(self):
"""Entre guillemets, le tilde n'est pas étendu : le PATH porterait
@ -159,6 +241,111 @@ class LaCommandeDistante(unittest.TestCase):
self.assertIn("opencode.ai", cmd)
class LeComplementApresClone(unittest.TestCase):
"""Ce que la pré-configuration ne peut poser qu'une fois le dépôt là."""
def _cmd(self, prod=False):
return TODO.__new__(TODO)._qemu_aidev_after_cmd(prod)
def test_the_hooks_point_at_the_checkout(self):
"""« git -C » et non le cwd : le dépôt porte des dépôts imbriqués, et
core.hooksPath écrit dans l'un d'eux laisserait la racine sans
garde-fou, sans le moindre message."""
cmd = self._cmd()
self.assertIn(
"git -C $HOME/git/erplibre config core.hooksPath"
" script/git/hooks",
cmd,
)
def test_the_hooks_get_their_execution_bit(self):
"""git saute SANS RIEN DIRE un hook qui ne l'a pas, et le garde-fou du
message de commit passe alors inaperçu."""
cmd = self._cmd()
for hook in TODO._GIT_HOOKS:
self.assertIn(f"hooks/{hook}", cmd)
self.assertIn("chmod +x", cmd)
def test_every_claude_command_is_deployed(self):
"""Une commande absente de la liste est une commande que la VM n'aura
pas, et personne ne s'en aperçoit avant d'en avoir besoin."""
cmd = self._cmd()
for nom, gabarit in TODO._QEMU_AIDEV_CLAUDE_CMDS:
self.assertIn(f"conf/{gabarit}", cmd)
self.assertIn(f"~/.claude/commands/{nom}.md", cmd)
def test_the_two_todo_commands_travel_together(self):
"""/todo_plan_max produit la spécification que /todo_add_command
implémente : l'une sans l'autre laisse la moitié de la chaîne."""
noms = [nom for nom, _g in TODO._QEMU_AIDEV_CLAUDE_CMDS]
self.assertIn("todo_plan_max", noms)
self.assertIn("todo_add_command", noms)
def test_the_identity_is_substituted_literally(self):
"""En python3 et non en sed : un nom qui porterait « & » ou le
séparateur choisi changerait de sens dans un « s/// »."""
cmd = self._cmd()
self.assertIn("python3 -c", cmd)
for marque, cle in TODO._QEMU_AIDEV_PLACEHOLDERS:
self.assertIn(marque, cmd)
self.assertIn(cle, cmd)
def test_it_follows_production_to_opt(self):
cmd = self._cmd(True)
self.assertIn("/opt/erplibre/conf/", cmd)
self.assertNotIn("$HOME/git/erplibre", cmd)
def test_nothing_in_it_can_fail_the_vm(self):
"""Une pré-configuration est un confort. Chaque commande se garde, et
la dernière — un compte-rendu — rend 0 par construction."""
for prod in (False, True):
with self.subTest(prod=prod):
cmd = self._cmd(prod)
for morceau in cmd.split("; "):
if not morceau.strip() or morceau.startswith("echo "):
continue
self.assertIn("|| true", morceau)
def test_it_is_valid_shell(self):
for prod in (False, True):
with self.subTest(prod=prod):
fini = subprocess.run(
["bash", "-n"],
input=self._cmd(prod),
text=True,
capture_output=True,
)
self.assertEqual(0, fini.returncode, fini.stderr)
def test_it_leads_the_after_phase(self):
"""En tête : quelques secondes de copies, contre une minute pour
Forgejo et une heure pour le SDK Android."""
todo = TODO.__new__(TODO)
cmd = todo._qemu_after_remote_cmd(("aidev", "forgejo"), False)
self.assertLess(
cmd.index("core.hooksPath"), cmd.index("install_forgejo.sh")
)
def test_it_reaches_the_full_remote_command(self):
"""L'épreuve du bout en bout : sans cela, la moitié « dépôt » de la
case resterait un réglage sans effet."""
todo = TODO.__new__(TODO)
cmd = todo._qemu_erplibre_remote_cmd("develop", tools=("aidev",))
self.assertIn("core.hooksPath", cmd)
self.assertIn("~/.claude/commands/commit.md", cmd)
def test_a_vm_without_a_checkout_says_what_it_skips(self):
"""Écarter en silence laisse croire qu'une case cochée a été
honorée. Les installations, elles, ont bien lieu."""
todo = TODO.__new__(TODO)
cmd = todo._qemu_erplibre_remote_cmd(
"", tools=("aidev",), desktop="gnome"
)
self.assertIn("starship", cmd)
self.assertNotIn("core.hooksPath", cmd)
self.assertIn("⚠", cmd)
class LIdentiteGit(unittest.TestCase):
"""Le formulaire montre l'identité de l'hôte et permet de la changer."""

View file

@ -115,6 +115,19 @@ def releve(fabrique, ctx, gestes=None):
return vu
def liaison(entree):
"""(touche, action) d'une entrée de BINDINGS.
Textual en accepte DEUX formes : le tuple, et l'objet Binding quand la
liaison porte un réglage — « show=False » pour un raccourci qui doublerait
une ligne du pied de page. Lire les deux ici évite qu'un écran ait à
choisir sa forme pour plaire à un test.
"""
if isinstance(entree, tuple):
return entree[0], entree[1]
return entree.key, entree.action
@unittest.skipUnless(TEXTUAL, "Textual absent")
class TestLesDeuxEcrans(unittest.TestCase):
@classmethod
@ -188,8 +201,11 @@ class TestLesDeuxEcrans(unittest.TestCase):
("QEMU/KVM", run_deploy_form(todo._qemu_form_context(mod), False)),
("Proxmox", run_proxmox_form(contexte_proxmox(todo), False)),
):
paires = [liaison(b) for b in app.BINDINGS]
touches[nom] = {
b[0]: b[1] for b in app.BINDINGS if b[1].startswith("select_")
touche: action
for touche, action in paires
if action.startswith("select_")
}
self.assertEqual(touches["QEMU/KVM"], touches["Proxmox"])
self.assertIn("select_main", touches["Proxmox"].values())