Commit graph

737 commits

Author SHA1 Message Date
c4d8536509 [FIX] script todo: ouvrir PyCharm après l'installation, pas avant
« ⚠ pas de .idea » à chaque installation : PyCharm ouvrait un dépôt cloné mais
pas installé, son configurateur d'interpréteur Python échouait faute de venv, et
il renonçait avant d'écrire quoi que ce soit. Le même appel sur un dépôt
installé écrit erplibre.iml, misc.xml, modules.xml et vcs.xml en cinq minutes —
mesuré sur la VM.

L'ouverture passe donc après le make, et « make pycharm_configure » la suit :
pycharm_update() s'était déjà exécuté pendant l'installation, quand il n'y avait
rien à configurer. Le groupe rend toujours 0 — un bonus ne rougit pas une VM —
et la phase mobile, qui porte le verdict, reste après lui.

--- EN ---

"⚠ no .idea" on every install: PyCharm was opening a repo that was cloned but
not installed, its Python interpreter configurator failed for lack of a venv,
and it gave up before writing anything. The same call on an installed repo
writes erplibre.iml, misc.xml, modules.xml and vcs.xml in five minutes —
measured on the VM.

The open therefore moves after the make, with "make pycharm_configure" behind
it: pycharm_update() had already run during the install, when there was nothing
to configure. The group always returns 0 — a bonus does not redden a VM — and
the mobile phase, which carries the verdict, still comes after it.

Assisted-by: Claude Opus 5
2026-08-23 02:07:42 -04:00
72b19568af [IMP] script todo: ne pas retélécharger un IDE déjà installé
Rejouer une installation est le cas normal — une qui est morte, un outil ajouté
après coup — et le téléchargement en est la partie longue : environ cinq
minutes pour PyCharm, autant pour Android Studio, à chaque fois. Les deux
étapes vérifient donc /opt avant de sortir curl.

Mesuré sur erplibre-ubuntu-2604-gnome, IDE déjà posés : les deux étapes passent
de dix minutes à 0,094 s au total. Le reste rejoue quand même — lanceur, alias,
raccourci de bureau — il est idempotent et bon marché. Un test vérifie que la
garde n'avale pas l'échec du cas où il faut bel et bien télécharger.

--- EN ---

Replaying an install is the normal case — one that died, a tool added later —
and the download is the long part: about five minutes for PyCharm, as much for
Android Studio, every time. Both steps now check /opt before reaching for curl.

Measured on erplibre-ubuntu-2604-gnome with both IDEs already in place: the two
steps drop from ten minutes to 0.094 s total. The rest still replays — launcher,
alias, desktop entry — it is idempotent and cheap. A test checks the guard does
not swallow the failure of the case where downloading is actually needed.

Assisted-by: Claude Opus 5
2026-08-23 02:07:42 -04:00
b4310665fc [ADD] script todo: dire depuis quand un journal d'installation est muet
Le sablier ne distingue pas une installation qui travaille d'une qui est morte :
le marqueur de sortie manque dans les deux cas. Une session ssh emportée, et le
tableau de bord a affiché « ⏳ » pendant 54 minutes sans que rien ne cloche à
l'œil.

La colonne d'état porte maintenant le silence du journal — « ⏳ silence 48min ».
C'est un chiffre, pas un verdict. Le seuil de dix minutes vient d'une mesure :
le téléchargement d'Android Studio tient ~5 min sans une ligne, et l'étape
« APK debug » davantage, son détail partant dans le journal de la VM. Plus bas,
chaque installation deviendrait une alerte, et l'alerte cesserait d'être lue.

--- EN ---

The hourglass does not tell a working install from a dead one: the exit marker
is missing in both cases. An ssh session was reaped, and the dashboard showed
"⏳" for 54 minutes with nothing looking wrong.

The state column now carries the log's silence — "⏳ silent 48min". It is a
figure, not a verdict. The ten-minute threshold comes from a measurement: the
Android Studio download holds ~5 min without a line, and the "debug APK" step
longer, its detail going to the VM's own log. Any lower and every install would
become an alert, and the alert would stop being read.

Assisted-by: Claude Opus 5
2026-08-23 02:07:42 -04:00
087ce3effb [FIX] script todo: viser l'IDE par son nom, pas par la ligne de commande
Le filet de fermeture de PyCharm cherchait « /opt/pycharm » dans les lignes de
commande. Or le script d'installation est passé en argument à ssh, et il
contient ce chemin : le pkill a tué la session ssh qui portait une installation
en cours. Elle est morte sans marqueur de sortie, et le tableau de bord a
montré un sablier pendant 54 minutes. Exécuter la suite de tests suffisait à
déclencher le défaut, puisqu'un test rejoue l'étape.

Le filet vise désormais les NOMS de processus, bornés au compte courant.
Mesuré dans la VM : par nom, 3 processus réels et aucun faux ; par ligne de
commande, 4 — le ssh compris. Un test plante un témoin nommé « sleep » dont la
ligne contient le chemin de l'IDE, et les tests bouchonnent pgrep et pkill.

--- EN ---

PyCharm's closing net looked for "/opt/pycharm" in command lines. But the
install script is passed to ssh as an argument, and it contains that path: the
pkill killed the ssh session carrying a running install. It died with no exit
marker, and the dashboard showed an hourglass for 54 minutes. Running the test
suite was enough to trigger it, since one test replays the step.

The net now targets process NAMES, scoped to the current account. Measured in
the VM: by name, 3 real processes and no false ones; by command line, 4 — the
ssh included. A test plants a witness named "sleep" whose command line holds
the IDE path, and the tests stub pgrep and pkill.

Assisted-by: Claude Opus 5
2026-08-23 02:07:42 -04:00
84335e1b47 [ADD] script todo: afficher la RAM de l'hôte dans le suivi d'installation
La barre montrait le CPU et le disque, jamais la mémoire. C'est pourtant la
seule des trois dont l'épuisement ne se voit nulle part ailleurs : une
compilation mobile s'est fait tuer par le noyau sur une VM de 12 Go sans swap
pendant que la barre affichait une charge tranquille et du disque de reste.

Lue dans /proc/meminfo, sans dépendance — ce suivi tourne sur l'hyperviseur,
donc sous Linux, d'où viennent déjà getloadavg et libvirt. « MemAvailable »
plutôt que « MemFree », presque nul dès que le cache travaille. Le swap
n'occupe la barre que s'il existe, et alors même à zéro : une machine qui
commence à échanger explique une lenteur. Au passage, « charge » et « libre »
s'affichaient en français dans une session anglaise.

--- EN ---

The bar showed CPU and disk, never memory. Yet memory is the one of the three
whose exhaustion shows up nowhere else: a mobile build was killed by the kernel
on a 12 GB VM with no swap while the bar displayed a quiet load and disk to
spare.

Read from /proc/meminfo, no dependency — this monitor runs on the hypervisor,
so on Linux, where getloadavg and libvirt already come from. "MemAvailable"
rather than "MemFree", near zero as soon as the cache is working. Swap takes
room in the bar only when it exists, and then even at zero: a machine starting
to swap explains a slowdown. Along the way, "load" and "free" were showing in
French in an English session.

Assisted-by: Claude Opus 5
2026-08-23 02:07:42 -04:00
aa94e460ec [FIX] script todo: ne plus empaqueter les dépôts du manifeste dans l'APK
La compilation butait sur « Too many zip entries 123678 (MAX=65535) » : un APK
est un ZIP, et le dépôt mobile verse 122 684 fichiers d'assets pour 337 qui
sont l'application. Le levier existe et il est documenté chez lui
(doc/SERVICES.md) : ERPLIBRE_MANIFEST_PATH, ici pointé sur un manifeste vide —
« ces dépôts-là : aucun ». Le plugin l'annonce, « 0 repos ».

Mesuré sur erplibre-ubuntu-2604-gnome : dist passe de 123 019 fichiers à 336,
l'APK sort à 59 Mo et 2 472 entrées, et la phase mobile entière rend 0, tests
Vitest compris — 75 fichiers, 1938 tests. Qui veut les dépôts pose la variable
lui-même : elle est respectée. Mesure d'attente, à retirer quand ils tiendront
sous le plafond du ZIP.

--- EN ---

The build hit "Too many zip entries 123678 (MAX=65535)": an APK is a ZIP, and
the mobile repo pours 122,684 asset files in for 337 that are the application.
The lever exists and that repo documents it (doc/SERVICES.md):
ERPLIBRE_MANIFEST_PATH, pointed here at an empty manifest — "those repos:
none". The plugin says so itself, "0 repos".

Measured on erplibre-ubuntu-2604-gnome: dist drops from 123,019 files to 336,
the APK comes out at 59 MB with 2,472 entries, and the whole mobile phase
returns 0, Vitest included — 75 files, 1938 tests. Set the variable yourself
and the repos come back. A stopgap, to drop once they fit under the ZIP
ceiling.

Assisted-by: Claude Opus 5
2026-08-23 02:07:42 -04:00
9e69cfa49c [UPD] qemu doc: énumérer les causes que le diagnostic sait nommer
La liste s'arrêtait aux quatre premières et laissait croire que le reste
tombait dans « aucun motif connu ». Les deux pannes rencontrées cette semaine
y manquaient : un démon Gradle tué par le noyau, et un APK refusé pour ses
122 684 fichiers d'assets alors qu'un ZIP tient 65535 entrées.

La seconde n'a pas de correctif de notre côté, et la doc le dit : elle
appartient au dépôt mobile.

--- EN ---

The list stopped at the first four, implying the rest fell into "no known
pattern". Both failures met this week were missing from it: a Gradle daemon
killed by the kernel, and an APK refused for its 122,684 asset files when a ZIP
holds 65535 entries.

The second one has no fix on our side, and the doc says so: it belongs to the
mobile repository.

Assisted-by: Claude Opus 5
2026-08-23 02:07:42 -04:00
b19713e38f [FIX] script todo: ne plus laisser PyCharm manger la compilation mobile
Sur erplibre-ubuntu-2604-gnome, l'APK s'est fait tuer par le noyau. La cause
est ici : « $! » désigne xvfb-run, un script, et le tuer n'atteint ni PyCharm
ni Xvfb — l'IDE tournait encore 45 minutes après son étape, avec 1,9 Go, quand
Gradle a demandé ses 6,8 Go sur 12. C'est le GROUPE qu'on tue maintenant, et
plus rien ne survit : mesuré, 2 Go rendus.

Le .idea n'était jamais écrit non plus : 122 684 des 123 021 fichiers d'assets
du dépôt mobile épuisaient les 65 536 watches inotify. Relevées à 524288, le
projet se crée. Restent 4 Go de swap avant de compiler, et un diagnostic qui
nomme la mémoire — avec le compte de l'oom-killer — puis la limite ZIP de
65 535 entrées, sur laquelle la compilation bute désormais, en amont.

--- EN ---

On erplibre-ubuntu-2604-gnome the APK was killed by the kernel. The cause is
here: "$!" is xvfb-run, a script, and killing it reaches neither PyCharm nor
Xvfb — the IDE was still running 45 minutes after its step, holding 1.9 GB,
when Gradle asked for its 6.8 GB out of 12. The GROUP is killed now, and
nothing survives it: 2 GB given back, measured.

The .idea was never written either: 122,684 of the mobile repo's 123,021 asset
files exhausted the 65,536 inotify watches. Raised to 524288, the project gets
created. Also 4 GB of swap before building, and a diagnostic naming memory —
with the oom-killer count — then the 65,535-entry ZIP limit the build now hits,
upstream of us.

Assisted-by: Claude Opus 5
2026-08-23 02:07:42 -04:00
6177b1a6a9 [FIX] script todo: résumer les erreurs au lieu de chercher « error »
Le volet « d » et le compteur du tableau de bord cherchaient la sous-chaîne
« error ». Le journal de l'installation qui vient d'échouer — APK tué par le
noyau sur erplibre-ubuntu-2604-gnome — n'en contient AUCUNE : 0 ligne sur
8765, mesuré. Le volet annonçait « aucune erreur détectée » sur une machine
morte, et le tableau de bord 0 erreur.

Comptent désormais les marqueurs qui ne disent jamais « error » : « ⚠ ÉCHEC »,
« FAILURE », une trace Python, un « fatal: » de git, une mort par mémoire. Le
volet ouvre sur un résumé — l'étape en échec et son diagnostic, les signaux
durs, puis les répétitions comptées par forme. Sur ce journal : 1 étape nommée,
2 signaux, là où il n'affichait rien.

--- EN ---

The "d" pane and the dashboard counter looked for the "error" substring. The
log of the install that just failed — APK killed by the kernel on
erplibre-ubuntu-2604-gnome — contains NONE: 0 lines out of 8765, measured. The
pane said "no error detected" about a dead machine, and the dashboard 0 errors.

Markers that never say "error" now count: "⚠ ÉCHEC", "FAILURE", a Python
traceback, a git "fatal:", a death by memory. The pane opens on a summary — the
failed step with its diagnostic, the hard signals, then repeats counted by
shape. On that log: 1 named step and 2 signals, where it showed nothing.

Assisted-by: Claude Opus 5
2026-08-23 02:07:42 -04:00
8f5a439148 [FIX] script todo: ne plus annoncer un émulateur qui n'a pas démarré
Trois défauts vus en conduisant le menu sur de vraies VM. « setsid » détache,
donc son code de retour vaut 0 même quand rien ne se lance : le menu disait
« Démarré » sur une VM sans SDK, dont le journal disait « not found ». Le
démarrage attend maintenant de VOIR le processus, et à défaut cite le journal.

Une sonde préalable lit binaire et AVD d'un coup : une VM déployée sans cocher
l'outil est le cas normal, pas une panne. Vérifié sur deux VM réelles — outillée
(True), migration (« aucun binaire emulator »), sans rien y démarrer.

Et tout ce qui n'était pas « 2 » démarrait l'émulateur : un « n » de travers
suffisait. Au passage, « Choice » n'était traduit dans aucun des trois menus
qui l'affichent.

--- EN ---

Three defects found while driving the menu against real VMs. setsid detaches,
so its exit code is 0 even when nothing launches: the menu said "Started" on a
VM with no SDK, whose log said "not found". Starting now waits to SEE the
process, and quotes the log when it never appears.

A prior probe reads binary and AVD in one go: a VM deployed without ticking the
tool is the normal case, not a failure. Verified on two real VMs — tooled
(True), migration ("no emulator binary") — without starting anything there.

And anything that was not "2" started the emulator; a stray "n" was enough.
Along the way, "Choice" was untranslated in all three menus showing it.

Assisted-by: Claude Opus 5
2026-08-23 02:07:42 -04:00
58f5b9e8da [FIX] qemu doc: donner une commande d'émulateur qui fonctionne
Les deux commandes documentées échouent. « emulator » sans chemin absolu rend
« command not found », parce qu'un `ssh hôte 'commande'` ne lit ni ~/.profile
ni ~/.bashrc — l'erreur a été rencontrée telle quelle. Et le rendu annoncé,
« swiftshader_indirect », n'existe plus : l'émulateur répond « Selected GPU
option is not valid » et le code pose « swangle » depuis un moment.

La doc pointe maintenant d'abord le menu de todo.py, qui démarre l'émulateur
sans fenêtre et donne le tunnel adb : scrcpy reçoit du H.264 encodé par
l'appareil, là où `ssh -X` fait traverser chaque image en pixels bruts.

--- EN ---

Both documented commands fail. Bare `emulator` gives "command not found",
because `ssh host 'command'` reads neither ~/.profile nor ~/.bashrc — the
error was hit exactly like that. And the advertised renderer,
`swiftshader_indirect`, no longer exists: the emulator answers "Selected GPU
option is not valid", and the code has been setting `swangle` for a while.

The doc now points at todo.py's menu first, which starts the emulator without
a window and hands over the adb tunnel: scrcpy receives H.264 encoded by the
device, where `ssh -X` ships every frame as raw pixels.

Assisted-by: Claude Opus 5
2026-08-23 02:07:42 -04:00
91ff4388e7 [FIX] script todo: rendre la console de l'hyperviseur atteignable
Le choix 3 du menu tunnel levait « _qemu_console_tunnel() takes 1 positional
argument but 3 were given » : la fonction lit name et src sans les recevoir.
flake8 le disait déjà (F821 sur name et src), personne ne l'écoutait.

Ce menu n'avait aucun test, et c'est ce qui a laissé passer l'appel. Ses
quatre choix sont donc parcourus pour de vrai, jusqu'à la commande imprimée,
console comprise. Le défaut remis en place fait bien tomber les tests.

--- EN ---

Choice 3 of the tunnel menu raised "_qemu_console_tunnel() takes 1 positional
argument but 3 were given": the function reads name and src without taking
them. flake8 already said so (F821 on name and src); nobody listened.

That menu had no test at all, which is how the call shipped. All four of its
choices are now walked for real, down to the printed command, the console
included. Putting the defect back does make the tests fail.

Assisted-by: Claude Opus 5
2026-08-23 02:07:42 -04:00
13e8b1bd2d [ADD] script todo: lancer l'émulateur Android et son tunnel adb
Le tunnel n'était que du texte à recopier, et le démarrage restait manuel :
un second émulateur sur le même AVD tue le premier (« Running multiple
emulators »), rencontré deux fois. Le menu le détecte donc avant de lancer.

La fenêtre décide du reste. Sans elle, todo.py démarre à distance, détaché
par setsid dans le groupe kvm, et enchaîne sur scrcpy ; avec elle, la
commande doit partir du poste qui possède l'affichage, pas d'ici.

Mesuré : l'émulateur n'écoute que sur le 127.0.0.1 de la VM — l'hyperviseur
est refusé sur IP:5555 — d'où le -J qui met la VM en dernier saut. Vérifié
sur erplibre-mobile-proof : poignée de main adb CNXN rendant
sdk_gphone64_x86_64, enveloppe détachée survivant au ssh ; 30 tests.

--- EN ---

The tunnel was only text to copy, and starting the emulator stayed manual:
a second emulator on the same AVD kills the first ("Running multiple
emulators"), hit twice. The menu now detects it before starting.

The window decides the rest. Without one, todo.py starts it remotely,
detached by setsid in the kvm group, and chains into scrcpy; with one, the
command must run from the workstation that owns the display, not from here.

Measured: the emulator only listens on the VM's 127.0.0.1 — the hypervisor
is refused on IP:5555 — hence the -J putting the VM last. Verified on
erplibre-mobile-proof: an adb CNXN handshake returning sdk_gphone64_x86_64,
a detached wrapper outliving the ssh; 30 tests.

Assisted-by: Claude Opus 5
2026-08-23 02:07:42 -04:00
7a65568098 [ADD] todo qemu: offer the adb tunnel for scrcpy, X11-free
X11 carries raw pixels: 0.62 megapixel per frame even after shrinking the
screen, rendered in software. scrcpy receives an H.264 stream encoded BY the
device, so the emulator runs with no window at all — no X11 anywhere. The
tunnel menu gained a fourth kind, reusing the target list it already reads from
~/.ssh/config, ProxyJump included: the hard part was already solved there.

It forwards 5555, the emulator's own adb port, not 5037: tunnelling the adb
server would force the user to kill the one on their workstation, which holds
the same port. Verified through the tunnel from the hypervisor: an adb CNXN
handshake answers device::ro.product.name=sdk_gphone64_x86, exactly what adb
connect does. ss -ltn in the VM confirms 5554, 5555 and 5037 all listen on
127.0.0.1.

--- FR ---

X11 transporte des pixels bruts : 0,62 mégapixel par image même après réduction
de l'écran, en rendu logiciel. scrcpy reçoit un flux H.264 encodé PAR
l'appareil, si bien que l'émulateur tourne sans aucune fenêtre — plus de X11
nulle part. Le menu de tunnel gagne un quatrième type, qui réutilise la liste de
cibles qu'il lit déjà dans ~/.ssh/config, ProxyJump compris : le plus dur y
était déjà résolu.

Il redirige 5555, le port adb de l'émulateur lui-même, et non 5037 : tunneliser
le serveur adb obligerait à tuer celui du poste, qui occupe le même port.
Vérifié à travers le tunnel depuis l'hyperviseur : une poignée de main adb CNXN
répond device::ro.product.name=sdk_gphone64_x86, ce que fait exactement adb
connect. « ss -ltn » dans la VM confirme que 5554, 5555 et 5037 écoutent tous
sur 127.0.0.1.

Assisted-by: Claude Opus 5
2026-08-23 02:07:42 -04:00
1dcd19b02f [FIX] todo qemu: name a GPU mode the emulator still accepts
Reported from a workstation: "Selected GPU option 'swiftshader_indirect' is
not valid, switching to auto", then "Your GPU drivers may have a bug". That
mode no longer exists in emulator 37.1; it fell back to swangle by itself, so
it worked while printing two errors that read like a fault. emulator -help-gpu
lists exactly four modes: auto, host, swiftshader, swangle. The AVD now asks
for swangle. Checked on the VM: zero GPU errors where there were two,
vulkan:swiftshader gles:swangle selected outright, boot in 55 s.

--- FR ---

Remonté depuis un poste : « Selected GPU option 'swiftshader_indirect' is not
valid, switching to auto », puis « Your GPU drivers may have a bug ». Ce mode
n'existe plus dans l'émulateur 37.1 ; il retombait de lui-même sur swangle,
donc cela fonctionnait en affichant deux erreurs qui se lisent comme une panne.
« emulator -help-gpu » énumère exactement quatre modes : auto, host,
swiftshader, swangle. L'AVD demande désormais swangle. Vérifié sur la VM : zéro
erreur GPU là où il y en avait deux, vulkan:swiftshader gles:swangle choisis
d'emblée, boot en 55 s.

Assisted-by: Claude Opus 5
2026-08-23 02:07:42 -04:00
ef57e1a097 [IMP] todo qemu: shrink the emulator screen so a remote display keeps up
"It launches but it is too slow." The Pixel profile gives 1080x2400 — 2.6
megapixels pushed frame by frame through SSH, in software rendering. The AVD
now declares 540x1140 at 240 dpi: 0.62 megapixel, 4.2 times less. Measured on
the VM after the change: wm size reports 540x1140, and a full-screen capture
drops from 220 KB to 49 KB. Android handles the density and the app does not
notice; whoever wants the real size overrides it with -skin 1080x2400.

The printed command also gained -XC over -X, X11 compression being free on a
remote display, and -no-boot-anim. Checked: ca.erplibre.home relaunched at the
new size, MainActivity resumed.

--- FR ---

« Ça se lance mais c'est trop lent. » Le profil Pixel donne 1080x2400, soit
2,6 mégapixels poussés image par image dans SSH, en rendu logiciel. L'AVD
déclare maintenant 540x1140 à 240 ppp : 0,62 mégapixel, 4,2 fois moins. Mesuré
sur la VM après le changement : « wm size » rend 540x1140, et une capture plein
écran tombe de 220 Ko à 49 Ko. Android gère la densité et l'application ne s'en
aperçoit pas ; qui veut la taille réelle l'écrase par « -skin 1080x2400 ».

La commande affichée gagne aussi « -XC » au lieu de « -X », la compression X11
étant gratuite sur un écran distant, et « -no-boot-anim ». Vérifié :
ca.erplibre.home relancé à la nouvelle taille, MainActivity au premier plan.

Assisted-by: Claude Opus 5
2026-08-23 02:07:42 -04:00
07b4c81207 [FIX] todo qemu: find the APK where AGP writes it, and install it as it is
The app runs in the emulator now, and two of my own checks were wrong. With an
injected ABI, AGP writes to intermediates/apk/debug, not outputs/apk/debug: a
SUCCESSFUL build was reported "no APK produced" because only the second path
was searched. That same mode marks the APK testOnly, so adb refuses it without
-t — INSTALL_FAILED_TEST_ONLY. Both measured on the emulator, where
ca.erplibre.home then installed, launched, and ran its SQLite migrations.

The manifest also stops tracking sentencepiece at master, which made the build
non-reproducible and, since August, unbuildable: master fetches protobuf and
compiles protoc for the Android target before running it on the host. v0.2.1
ships its .pb.cc pre-generated. Its own absl subset still lacks
flags/flag.cc, so that library remains to be sorted out upstream.

--- FR ---

L'application tourne dans l'émulateur, et deux de mes propres contrôles étaient
faux. Avec une ABI injectée, AGP écrit dans intermediates/apk/debug et non dans
outputs/apk/debug : une compilation RÉUSSIE était rapportée « aucun APK
produit » parce que je ne regardais que le second chemin. Ce même mode marque
l'APK testOnly, et adb le refuse sans -t — INSTALL_FAILED_TEST_ONLY. Les deux
mesurés sur l'émulateur, où ca.erplibre.home s'est ensuite installé, lancé, et
a joué ses migrations SQLite.

Le manifeste cesse aussi de suivre sentencepiece à master, ce qui rendait la
compilation non reproductible et, depuis août, impossible : master récupère
protobuf et compile protoc pour la cible Android avant de l'exécuter sur
l'hôte. La v0.2.1 livre ses .pb.cc pré-générés. Son propre sous-ensemble absl
manque encore flags/flag.cc : cette bibliothèque reste à régler en amont.

Assisted-by: Claude Opus 5
2026-08-23 02:07:42 -04:00
9ad2048bbb [FIX] todo qemu: the printed emulator command could not run as printed
Reported from a workstation: "emulator: command not found". The line the
emulator step printed relied on PATH, and "ssh host 'command'" reads neither
~/.profile nor ~/.bashrc — Ubuntu even opens the latter with a return for
non-interactive shells. So what install-android.sh writes there never applies
to those commands. Both printed lines now carry absolute paths.

Behind it, a second one: the emulator ships TWO qemu binaries and only the
headless one does without PulseAudio. The windowed one — the ssh -X case —
links libpulse.so.0, absent from cloud images, and stops on "cannot open
shared object file" even with -no-audio. Measured: it is the only missing
library, the Qt dependencies travel in the bundle. Checked after the fix: the
windowed binary starts and reports its version.

--- FR ---

Remonté depuis un poste : « emulator: command not found ». La ligne affichée
par l'étape émulateur comptait sur le PATH, or « ssh hôte 'commande' » ne lit
ni ~/.profile ni ~/.bashrc — Ubuntu ouvre même le second par un return pour
les shells non interactifs. Ce que install-android.sh y écrit ne s'applique
donc jamais à ces commandes. Les deux lignes affichées portent maintenant des
chemins absolus.

Derrière, une seconde : l'émulateur livre DEUX binaires qemu et seul le
headless se passe de PulseAudio. Celui qui ouvre une fenêtre — le cas du
ssh -X — lie libpulse.so.0, absente des images cloud, et s'arrête sur
« cannot open shared object file » même avec -no-audio. Mesuré : c'est la
seule bibliothèque qui manque, les dépendances Qt voyagent dans le bundle.
Vérifié après correction : le binaire fenêtré démarre et donne sa version.

Assisted-by: Claude Opus 5
2026-08-23 02:07:42 -04:00
f3be40d74d [ADD] todo qemu: add an Android emulator, and fix what the real run exposed
Running it on a VM was the only way to find these. install_os never installed
python3-venv, so .venv.erplibre was born crippled — bin/python but no pip, no
activate — and everything downstream failed on "No module named git"; one line
of dependency fixes it. Capacitor 8 needs a JDK 21 where the mobile
repository's installer puts 17, and Gradle must RUN on it. That installer is
not idempotent either, so it is replayed only when something is missing.

The new emulator option creates an AVD from the SDK's own device list — the
newest plain Pixel, smallest screen — with software rendering written into its
config so ssh -X does not open a black screen, and adds the user to the kvm
group, without which it refuses to start. Checked on a VM: boot completed in
10 s, adb sees emulator-5554, Android 16 x86_64, no KVM refusal. Vitest: 1938
tests pass. The APK still fails, upstream: sentencepiece builds protoc for the
target then runs it on the host.

--- FR ---

Seule l'exécution sur une VM pouvait trouver ceci. install_os n'installait pas
python3-venv, si bien que .venv.erplibre naissait infirme — bin/python mais ni
pip ni activate — et tout ce qui en dépend tombait sur « No module named
git » ; une ligne de dépendance suffit. Capacitor 8 réclame un JDK 21 là où
l'installateur du dépôt mobile pose un 17, et Gradle doit TOURNER dessus. Cet
installateur n'est pas idempotent non plus : il n'est rejoué que s'il manque
quelque chose.

La nouvelle option crée un AVD depuis la liste de profils du SDK — le Pixel
simple le plus récent, plus petit écran —, écrit le rendu logiciel dans sa
configuration pour qu'ssh -X n'ouvre pas un écran noir, et ajoute
l'utilisateur au groupe kvm, sans quoi il refuse de démarrer. Vérifié sur une
VM : boot en 10 s, adb voit emulator-5554, Android 16 x86_64, aucun refus de
KVM. Vitest : 1938 tests passent. L'APK échoue encore, en amont :
sentencepiece bâtit protoc pour la cible puis l'exécute sur l'hôte.

Assisted-by: Claude Opus 5
2026-08-23 02:07:42 -04:00
1da54d0ba5 [IMP] todo qemu: let Android Studio find the SDK the build already installed
install-android.sh writes its exports into ~/.bashrc, which a GNOME session
never reads: Android Studio launched from the menu saw no ANDROID_HOME and
offered to download a second SDK, next to the one the mobile build had just
installed. environment.d is the channel the user session actually reads, so
both point at $HOME/android. An empty ~/.android/repositories.cfg is created
too — without it the first-run wizard stops on an error instead of offering
anything.

--- FR ---

install-android.sh écrit ses exports dans ~/.bashrc, qu'une session GNOME ne
lit jamais : Android Studio lancé depuis le menu ne voyait aucun ANDROID_HOME
et proposait de télécharger un second SDK, à côté de celui que la compilation
mobile venait de poser. environment.d est le canal que la session utilisateur
lit vraiment, et les deux pointent désormais $HOME/android. Un
~/.android/repositories.cfg vide est créé au passage : sans lui, l'assistant
de première ouverture s'arrête sur une erreur au lieu de proposer quoi que ce
soit.

Assisted-by: Claude Opus 5
2026-08-23 02:07:42 -04:00
5150cfde9c [ADD] todo qemu: build and test the mobile app, and fail the VM if it breaks
A VM was declared ready without anything proving it. The mobile option now
adds the repository to the manifest (additive, so it rides along an Odoo 18
install), runs the mobile repository's own install-android.sh — licences
accepted included — then npm ci, vite build, cap sync, gradlew assembleDebug
and npm test. A failure fails the VM: the exit code reaches the dashboard.

Two gaps in that upstream installer had to be filled: unzip and wget, which
no cloud image ships, and the SDK platform — it installs android-34 while
variables.gradle asks for compileSdk 36, so the number is read from the file
rather than frozen. Gradle writes tens of megabytes and hundreds of harmless
lines carrying the word error: that output goes to a file of its own, and the
log gets the named cause instead.

--- FR ---

Une VM était déclarée prête sans que rien ne le prouve. L'option mobile ajoute
maintenant le dépôt au manifeste (additif, donc il accompagne une installation
Odoo 18), lance l'install-android.sh du dépôt mobile lui-même — licences
acceptées comprises —, puis npm ci, vite build, cap sync, gradlew
assembleDebug et npm test. Un échec fait échouer la VM : le code de sortie
remonte au tableau de bord.

Deux manques de cet installateur amont ont dû être comblés : unzip et wget,
qu'aucune image cloud ne livre, et la plateforme SDK — il pose android-34
quand variables.gradle réclame compileSdk 36, d'où le chiffre lu dans le
fichier plutôt que figé. Gradle écrit des dizaines de mégaoctets et des
centaines de lignes anodines portant le mot error : cette sortie part dans un
fichier à part, et le journal reçoit la cause nommée.

Assisted-by: Claude Opus 5
2026-08-23 02:07:42 -04:00
6efe16a991 [FIX] todo qemu: open the PyCharm project without a screen, and unlicensed
Two measurements, one after the other. The unified PyCharm that
code=PCC&latest now serves stops on its licence: the log says
NoValidIdeLicense then "Get licenses: request requires authentication", and
no project is ever opened — so no .idea, so nothing for the install to
configure. The Community line asks for no account and is still patched
(2025.2.6.2 on 2026-07-29); it is resolved from the release feed, so no
version is frozen here.

That build then froze 1.3 s after startup: the trust dialog, invisible under
Xvfb and waiting for a click. idea.trust.all.projects unblocks it. Checked
on an Ubuntu 26.04 VM: .idea complete in 195 s, and pycharm_configuration.py
writes its exclusions into erplibre.iml.

--- FR ---

Deux mesures, l'une après l'autre. Le PyCharm unifié que sert désormais
code=PCC&latest s'arrête sur sa licence : le journal dit NoValidIdeLicense
puis « Get licenses: request requires authentication », et aucun projet ne
s'ouvre — donc pas de .idea, donc rien à configurer pour l'installation. La
ligne Community ne demande aucun compte et reste corrigée (2025.2.6.2 le
2026-07-29) ; elle est résolue depuis le flux des versions, sans qu'aucun
numéro ne soit figé ici.

Ce build se figeait ensuite 1,3 s après le démarrage : la fenêtre de
confiance, invisible sous Xvfb et attendant un clic.
idea.trust.all.projects la lève. Vérifié sur une VM Ubuntu 26.04 : .idea
complet en 195 s, et pycharm_configuration.py écrit ses exclusions dans
erplibre.iml.

Assisted-by: Claude Opus 5
2026-08-23 02:07:42 -04:00
c2852089a0 [ADD] todo qemu: offer PyCharm, Android Studio and GNOME extensions
A graphical VM was a desktop and nothing else: every developer tool had to
be installed by hand afterwards. A check list now carries them, filtered
per machine — Android Studio is x86_64 only, Google publishes no Linux
aarch64 build — and their disk cost reaches the plan before any qcow2 is
created. They are installed BEFORE the clone: PyCharm writes the .idea/ of
the repository, and the install that follows is what runs
pycharm_configuration.py, through update_env_version.pycharm_update().

Its launcher is named studio, which is enough to conclude the install
failed; it now answers to android-studio too. GNOME extensions come from
the site by UUID, per running Shell: the same endpoint serves gTile v59
for GNOME 46 and v62 for 48. Checked with stubs: every tool can fail and
the install's exit code still wins, 45 tests.

--- FR ---

Une VM graphique n'était qu'un bureau : chaque outil de développement
restait à poser à la main. Une liste à cocher les porte, filtrés machine
par machine — Android Studio n'existe qu'en x86_64, Google ne publiant
aucune archive Linux aarch64 — et leur place disque atteint le plan avant
qu'un seul qcow2 ne soit créé. Ils sont posés AVANT le clone : PyCharm
écrit le .idea/ du dépôt, et c'est l'installation qui suit qui lance
pycharm_configuration.py, via update_env_version.pycharm_update().

Son lanceur s'appelle studio, ce qui suffit à conclure à un échec ; il
répond désormais aussi à android-studio. Les extensions GNOME viennent du
site par UUID, selon le Shell qui tourne : le même point d'entrée sert
gTile v59 pour GNOME 46 et v62 pour 48. Vérifié avec des leurres : chaque
outil peut échouer sans que le code de sortie de l'installation ne change,
45 tests.

Assisted-by: Claude Opus 5
2026-08-23 02:07:42 -04:00
57ab0b506f [ADD] qemu: greet the SSH login with the distro's own commands
The catalogue spans four package managers and the operator changes
distribution at every deployment, yet nothing in the VM said which one it
was. /etc/motd now carries the commands of the machine you just entered,
and the host's git identity lands in ~/.gitconfig so a commit made there
is not signed erplibre@<vm-name>.

The seven cloud images were mounted read-only to settle the mechanism:
sshd is PrintMotd no everywhere and pam_motd shows the file, so it appears
once and never for ssh host 'command'. Checked: cloud-init schema reports
Valid schema on the 8 catalogue combinations, the YAML round-trip is
byte-identical, and the s390x initrd was rebuilt and unpacked. Its
early_command gained the `true` guard it lacked: the last diagnostic
returns 1 when enc1 is absent, stopping the installer it was meant to
explain.

--- FR ---

Le catalogue couvre quatre gestionnaires de paquets et l'opérateur change
de distribution à chaque déploiement, sans que rien dans la VM ne dise
laquelle. /etc/motd porte maintenant les commandes de la machine où l'on
vient d'entrer, et l'identité git de l'hôte atterrit dans ~/.gitconfig :
un commit fait là ne porte plus erplibre@<nom-de-vm>.

Les sept images cloud ont été montées en lecture seule pour trancher le
mécanisme : sshd est en PrintMotd no partout et c'est pam_motd qui affiche
le fichier, donc un seul affichage, et jamais pour ssh hôte 'commande'.
Vérifié : cloud-init schema rend Valid schema sur les 8 combinaisons du
catalogue, l'aller-retour YAML est octet pour octet, et l'initrd s390x a
été reconstruit puis déplié. Son early_command a reçu la garde `true` qui
lui manquait : son dernier diagnostic rend 1 quand enc1 est absente, et
arrêtait l'installateur qu'il devait éclairer.

Assisted-by: Claude Opus 5
2026-08-23 02:07:42 -04:00
c87cd2f5bd [FIX] install : os-release au lieu de lsb_release, collision d'IP mieux vue
Deux echecs distincts sur les VM Debian s390x posees par l'installateur.

lsb_release vient du paquet lsb-release, livre avec la tache
« standard ». Les images cloud l'ont ; une Debian posee par
debian-installer, non. Les trois variables devenaient VIDES et le script
concluait « Your version of Ubuntu is not supported » sur une Debian.
/etc/os-release appartient a systemd, il est toujours la, et donne ID,
VERSION_ID et VERSION_CODENAME sans rien installer.

L'autre echec etait pire : l'adresse fixe choisie appartenait deja a une
machine du parc, et l'installation ERPLibre s'est deroulee SUR CETTE
DERNIERE. Le journal ne le disait qu'a demi-mot — « git is already the
newest version », impossible sur un systeme que d-i vient de poser. Un
essai sur le port 22 avec 0,4 s laissait passer toute machine eteinte ou
filtree. On interroge desormais le voisinage ARP, puis ICMP, puis SSH.

--- EN ---

Two distinct failures on Debian s390x VMs laid down by the installer.

lsb_release comes from the lsb-release package, shipped with the
"standard" task. Cloud images have it; a Debian installed by
debian-installer does not. All three variables came out EMPTY and the
script concluded "Your version of Ubuntu is not supported" on a Debian.
/etc/os-release belongs to systemd, is always there, and gives ID,
VERSION_ID and VERSION_CODENAME without installing anything.

The other failure was worse: the chosen static address already belonged
to a machine in the fleet, and the ERPLibre install ran ON THAT ONE. The
log only hinted at it — "git is already the newest version", impossible
on a system d-i just laid down. A port-22 probe with 0.4 s let through
any machine that was off or filtered. We now check the ARP neighbourhood,
then ICMP, then SSH.

Assisted-by: Claude Opus 5
(cherry picked from commit c73db1642a676ece1a06cdbdac3e9cfa5b526f3c)
2026-08-23 02:07:42 -04:00
b181ad56e8 [FIX] qemu debian s390x : rallumer la VM quand l'installateur a fini
« This domain is not running » apres une installation reussie. Le disque
portait 1,22 Gio et le XML persistant amorcait deja sur « hd » : rien
n'avait echoue, la VM etait simplement eteinte.

virt-install mene l'installation en deux temps — amorcage transitoire
sur kernel+initrd, puis configuration definitive sur disque — et le
passage se fait par un ARRET. C'est virt-install qui rallume ensuite,
sauf qu'avec « --wait 0 » il est deja parti.

On ne peut pas le laisser attendre : sous emulation l'installation dure
des heures, et le deploiement rendrait la main a ce rythme. Un veilleur
detache fait donc le dernier geste, avec 6 h de garde.

Mesure : domaine « shut off », veilleur lance, domaine « running ».
Et la VM installee repond en SSH.

--- EN ---

"This domain is not running" after a successful install. The disk held
1.22 GiB and the persistent XML already booted from "hd": nothing had
failed, the VM was simply powered off.

virt-install runs the install in two stages — a transient kernel+initrd
boot, then the final disk-boot config — and the handover goes through a
SHUTDOWN. virt-install powers it back on afterwards, except that with
"--wait 0" it is already gone.

We cannot let it wait: under emulation the install takes hours, and
deployment would return at that pace. So a detached watcher makes the
last move, with a 6 h guard.

Measured: domain "shut off", watcher started, domain "running". And the
installed VM answers over SSH.

Assisted-by: Claude Opus 5
(cherry picked from commit 9c6cdb7f14ca504fd9d0855ba0dacc3dbf43c04f)
2026-08-23 02:07:42 -04:00
58da6cdacd [FIX] qemu debian s390x : desactiver network-console, reclamer partman-auto
Deux blocages, deux mecanismes propres a IBM Z.

network-console n'est pas une question a laquelle repondre : il demarre
sshd et ATTEND une connexion, indefiniment. Preseeder son mot de passe
ne fait avancer que d'un ecran. d-i prevoit le levier — un composant
dont « .isinstallable » sort en erreur quitte le menu. On ajoute donc a
l'initrd une version qui refuse toujours ; le fichier ecrit APRES
l'original prend sa place, le noyau depliant le cpio sequentiellement.

partman-auto n'est pas tire d'office sur s390x, ou la voie attendue est
le partitionnement DASD manuel. Mesure : « /lib/partman/
automatically_partition/ No such file or directory », d'ou « No root
file system is defined ». anna/choose_modules le reclame.

Mesure apres correctif : recette atomic appliquee, aucun ecran bloquant,
l'installateur deballe le systeme de base.

--- EN ---

Two stops, two IBM Z mechanisms.

network-console is not a question to answer: it starts sshd and WAITS
for a connection, forever. Preseeding its password only advances one
screen. d-i provides the lever — a component whose ".isinstallable"
exits non-zero leaves the menu. So the initrd gets a version that always
refuses; the file written AFTER the original wins, since the kernel
unpacks the cpio sequentially.

partman-auto is not pulled by default on s390x, where manual DASD
partitioning is the expected path. Measured: "/lib/partman/
automatically_partition/ No such file or directory", hence "No root file
system is defined". anna/choose_modules asks for it.

Measured after the fix: atomic recipe applied, no blocking screen, the
installer unpacks the base system.

Assisted-by: Claude Opus 5
(cherry picked from commit 1fb06ad84b65f43e9ffd1604b4e3d3a7aef9aaf2)
2026-08-23 02:07:42 -04:00
788c17dbb5 [FIX] qemu debian s390x : adresse fixe propre a chaque VM
« Premiere libre en partant du haut » donnait la MEME adresse a deux VM
deployees en parallele : ni l'une ni l'autre n'est montee quand l'autre
cherche, donc aucune ne voit l'autre. Mesure — debian-12 et debian-13
ont tous deux pris .250 et se sont disputes l'adresse, une seule
survivant. C'est ce qui faisait echouer le 12 quand le 13 passait.

Le depart du balayage vient desormais du NOM de la VM, par crc32. Les
noms different toujours, la collision disparait, et le tirage reste
stable d'un redeploiement a l'autre. Le balayage garde ses garde-fous :
baux existants et adresses qui repondent restent ecartes.

Verifie : debian-12 -> .216, debian-13 -> .218.

--- EN ---

"First free from the top" gave the SAME address to two VMs deployed in
parallel: neither is up when the other looks, so neither sees the other.
Measured — debian-12 and debian-13 both took .250 and fought over it,
only one surviving. That is what made 12 fail while 13 went through.

The scan now starts from the VM NAME, via crc32. Names always differ,
the collision is gone, and the draw stays stable across redeployments.
The scan keeps its guards: existing leases and answering addresses are
still skipped.

Verified: debian-12 -> .216, debian-13 -> .218.

Assisted-by: Claude Opus 5
(cherry picked from commit 91592383e213ad98873125866e6120e5da5874fe)
2026-08-23 02:07:42 -04:00
01daacba22 [ADD] qemu debian s390x : repondre au mot de passe de network-console
Sur IBM Z, d-i propose systematiquement de poursuivre par SSH — la
console y est historiquement limitee. Il refuse un mot de passe vide et
s'arretait sur « Empty password ».

Preseede, le mot de passe passe. Mais l'ecran suivant montre que cela ne
suffit PAS : network-console demarre sshd et attend une connexion de
l'utilisateur « installer ». Il ne rend jamais la main. Le rendre non
assiste demande de l'empecher de s'executer, pas de lui repondre.

Le secret ne vit que le temps de l'installateur, sur le reseau libvirt,
et disparait avec lui.

--- EN ---

On IBM Z, d-i always offers to continue over SSH — the console there is
historically limited. It refuses an empty password and stopped on
"Empty password".

Preseeded, the password goes through. But the next screen shows this is
NOT enough: network-console starts sshd and waits for the "installer"
user to connect. It never returns. Making this unattended requires
preventing it from running, not answering it.

The secret lives only for the installer's lifetime, on the libvirt
network, and vanishes with it.

Assisted-by: Claude Opus 5
(cherry picked from commit 13f0f201678d2ef866fb2342ec16f958d44b0351)
2026-08-23 02:07:42 -04:00
08ebf99c77 [FIX] qemu debian s390x : adresse fixe, l'initrd ne sait pas faire de DHCP
Le syslog de d-i a tranche : « Menu item 'netcfg-static' selected », puis
« Taking down interface enc1 ». netcfg-dhcp n'apparait JAMAIS — il n'est
pas dans l'initrd s390x, et aucun variant netboot n'existe pour cette
architecture (404 sur trixie comme sur bookworm).

Aucun DHCP n'etait donc tente. Sept hypotheses ont cherche pourquoi il
echouait ; il n'avait jamais lieu. C'est la convention IBM Z, ou le
reseau se donne au parmfile.

deploy_qemu choisit desormais une adresse libre en HAUT de la plage —
dnsmasq attribue depuis le bas — et la preseede. Mesure : l'ecran
d'adressage statique a disparu, l'installateur poursuit.

--- EN ---

d-i's syslog settled it: "Menu item 'netcfg-static' selected", then
"Taking down interface enc1". netcfg-dhcp NEVER appears — it is not in
the s390x initrd, and no netboot variant exists for this architecture
(404 on trixie and bookworm alike).

So no DHCP was ever attempted. Seven hypotheses looked for why it
failed; it never happened. This is the IBM Z convention, where the
network is given in the parmfile.

deploy_qemu now picks a free address at the TOP of the range — dnsmasq
allocates from the bottom — and preseeds it. Measured: the static
addressing screen is gone, the installer moves on.

Assisted-by: Claude Opus 5
(cherry picked from commit 8c630969a605ec191a65dc93896014ac9e3a1227)
2026-08-23 02:07:42 -04:00
56803b3807 [REF] qemu debian s390x : retirer les reglages netcfg sans effet
Quatre lignes avaient ete empilees sur des hypotheses successives —
link_wait_timeout, dhcp_timeout, dhcp_options, choose_interface. Aucune
n'a jamais ete verifiee, et l'essai nu le montre : sans elles, le
comportement est EXACTEMENT le meme.

Elles restaient donc comme du bruit, en laissant croire que la question
du reseau avait ete traitee. Le blocage sur l'adressage statique
persiste, avec un lien UP,LOWER_UP et un DHCP qui repond en deux
secondes a un udhcpc manuel.

--- EN ---

Four lines had been stacked on successive hypotheses —
link_wait_timeout, dhcp_timeout, dhcp_options, choose_interface. None
was ever verified, and the bare run shows it: without them the behaviour
is EXACTLY the same.

They remained as noise, suggesting the network question had been dealt
with. The static-addressing stop persists, with a UP,LOWER_UP link and a
DHCP server answering a manual udhcpc in two seconds.

Assisted-by: Claude Opus 5
(cherry picked from commit f52bc66a3b70075831e1a7bfdd2dd7b2a352ad32)
2026-08-23 02:07:42 -04:00
ec8a44b9ae [ADD] qemu debian s390x : activer enc1 et tracer avant netcfg
Le blocage sur l'adressage statique n'etait pas diagnosticable : netcfg
ecrit ses traces dans le syslog INTERNE de d-i, qu'on ne lit qu'en
ouvrant un shell a la main sur une console serie.

Un preseed/early_command ecrit desormais l'etat du reseau sur la
console, donc dans le journal. Il a immediatement montre que l'interface
etait ETEINTE — « enc1: <BROADCAST,MULTICAST> qdisc noop » — alors qu'un
udhcpc manuel obtenait un bail en deux secondes. Le reseau n'a jamais
ete en cause.

La commande allume donc enc1 avant que netcfg ne decide. L'interface est
bien UP,LOWER_UP ensuite, ce qui est correct en soi — mais netcfg
demande TOUJOURS une adresse statique. La cause reste a trouver.

--- EN ---

The static-addressing stop was not diagnosable: netcfg writes its traces
to d-i's INTERNAL syslog, readable only by opening a shell by hand on a
serial console.

A preseed/early_command now writes the network state to the console,
hence to the log. It immediately showed the interface was DOWN — "enc1:
<BROADCAST,MULTICAST> qdisc noop" — while a manual udhcpc obtained a
lease in two seconds. The network was never at fault.

So the command brings enc1 up before netcfg decides. The interface is
UP,LOWER_UP afterwards, which is right in itself — but netcfg STILL asks
for a static address. The cause remains to be found.

Assisted-by: Claude Opus 5
(cherry picked from commit e594fe5eab87d5060b878e44f9cf484e6d7b9e7f)
2026-08-23 02:07:42 -04:00
a33684ddd1 [FIX] qemu debian s390x : retirer auto=true, inutile en preseed local
« auto=true » vise le preseed par URL : il reordonne l'installation pour
monter le reseau avant tout le reste, afin d'aller chercher le fichier.
Le notre est embarque dans l'initrd, il n'y a rien a telecharger.

Le garder faisait donc courir netcfg tres tot pour rien. Cela n'a pas
suffi a debloquer l'installation — le blocage sur l'adressage statique
demeure — mais l'argument n'avait aucune raison d'etre la.

--- EN ---

"auto=true" targets URL preseeding: it reorders the install to bring the
network up before anything else, so the file can be fetched. Ours is
embedded in the initrd; there is nothing to download.

Keeping it ran netcfg very early for no reason. This did not unblock the
install — the static-addressing stop remains — but the argument had no
business being there.

Assisted-by: Claude Opus 5
(cherry picked from commit b805d352e0c3505cdb2522e1023c70e0dc506f15)
2026-08-23 02:07:42 -04:00
12db87e1c2 [FIX] qemu debian s390x : journal de console et question reseau Z
« L'installation a echoue, pas de sortie pertinente » : une console pty
ne garde RIEN. Quand d-i s'arrete, il l'ecrit a l'ecran d'une VM que
personne ne regarde, et il ne reste rien a lire. La voie installateur
ecrit desormais un journal qui survit a l'arret du domaine.

Ce journal a immediatement nomme la premiere cause : d-i s'arretait sur
« Configure the network device », une question propre a s390x posee par
le udeb s390-netdevice — ctc, qeth, iucv ou virtio. Elle n'a AUCUNE
valeur par defaut, donc priority=critical ne la saute pas. Preseedee a
virtio, elle disparait.

Reste un second arret, non resolu : netcfg n'essaie aucun DHCP et tombe
sur la saisie d'une adresse statique. Ni le delai de lien, ni le nom
explicite de l'interface n'y changent quoi que ce soit.

--- EN ---

"The install failed, no relevant output": a pty console keeps NOTHING.
When d-i stops, it says so on the screen of a VM nobody watches, and
nothing is left to read. The installer path now writes a log that
outlives the domain.

That log immediately named the first cause: d-i stopped on "Configure
the network device", an s390x-only question from the s390-netdevice
udeb — ctc, qeth, iucv or virtio. It has NO default, so
priority=critical does not skip it. Preseeded to virtio, it is gone.

A second stop remains, unsolved: netcfg attempts no DHCP and falls to
static address entry. Neither the link timeout nor naming the interface
explicitly changes anything.

Assisted-by: Claude Opus 5
(cherry picked from commit d1a371a8f4775e8488522fba24a79775a5a101e8)
2026-08-23 02:07:42 -04:00
4df994f721 [FIX] todo qemu : lire les distros par arch dans deploy_qemu
L'ecran de deploiement portait sa PROPRE copie de S390X_DISTROS, sous un
commentaire promettant qu'elle restait « coherente avec deploy_qemu.py ».
Une copie ne tient aucune promesse : Debian a gagne s390x la-bas et
l'ecran ne le proposait toujours pas.

_qemu_arch_distros lit desormais ARCH_DISTRO_SUPPORT, et _qemu_arches_for
y passe aussi pour que « toutes les architectures » n'offre rien que
deploy_qemu refuserait ensuite. Les tuples locaux restent en repli si
l'import echoue. L'import est memorise : le catalogue l'interrogeait une
fois par couple (distro, version).

Plancher memoire de 2048 Mio sur la voie installateur, annonce et jamais
abaissant : d-i deplie un systeme de fichiers en RAM la ou une image
cloud arrive installee, et le manque s'y voit comme un ecran fige.

--- EN ---

The deploy screen carried its OWN copy of S390X_DISTROS, under a comment
promising it stayed "consistent with deploy_qemu.py". A copy keeps no
promise: Debian gained s390x over there and the screen still did not
offer it.

_qemu_arch_distros now reads ARCH_DISTRO_SUPPORT, and _qemu_arches_for
goes through it too, so "all architectures" offers nothing deploy_qemu
would later refuse. The local tuples remain as a fallback if the import
fails. The import is memoised: the catalogue queried it once per
(distro, version) pair.

A 2048 MiB memory floor on the installer path, announced and never
lowering: d-i unpacks a filesystem into RAM where a cloud image arrives
installed, and running short shows up as a frozen screen.

Assisted-by: Claude Opus 5
(cherry picked from commit f0ee70c1ac43ba2bc9f5fb78fe71f821a15daf7d)
2026-08-23 02:07:42 -04:00
9641a5b749 [ADD] qemu : Debian sur s390x, par debian-installer
Debian ne publie aucune image cloud s390x — verifie sur le miroir :
bookworm et trixie ne servent que amd64, arm64, ppc64el et riscv64. Le
port existe pourtant, « binary-s390x » repond 200, et l'installateur
livre kernel + initrd pour les deux versions.

D'ou une seconde voie de deploiement, choisie par uses_installer() :
disque VIERGE au lieu d'un qcow2 converti, amorcage kernel+initrd au
lieu de --import, et un preseed a la place du seed cloud-init. Le
preseed refait ce que fait cloud-init — nom d'hote, utilisateur, cles
SSH, sudo, fuseau, paquets — sinon d-i pose la question sur une console
que personne ne regarde.

Le preseed voyage DANS l'initrd : pas de serveur HTTP a maintenir
pendant l'installation, et rien qui depende du moment ou le reseau
monte.

Verifie contre l'initrd s390x reel : preseed.cfg relu a la racine des
966 entrees, 1807 octets identiques a l'ecriture. Les voies image cloud
sont inchangees — amd64, arm64 et Ubuntu s390x gardent --import.

--- EN ---

Debian publishes no s390x cloud image — checked against the mirror:
bookworm and trixie only serve amd64, arm64, ppc64el and riscv64. Yet
the port exists, "binary-s390x" returns 200, and the installer ships
kernel + initrd for both versions.

Hence a second deployment path, picked by uses_installer(): a BLANK
disk instead of a converted qcow2, kernel+initrd boot instead of
--import, and a preseed in place of the cloud-init seed. The preseed
redoes what cloud-init does — hostname, user, SSH keys, sudo, timezone,
packages — otherwise d-i asks on a console nobody watches.

The preseed travels INSIDE the initrd: no HTTP server to keep alive
during the install, and nothing depending on when the network comes up.

Verified against the real s390x initrd: preseed.cfg read back at the
root of 966 entries, 1807 bytes identical to what was written. Cloud
image paths are unchanged — amd64, arm64 and Ubuntu s390x keep --import.

Assisted-by: Claude Opus 5
(cherry picked from commit d26ddee95bb5b3b3627950c33879155fb28bba58)
2026-08-23 02:07:42 -04:00
0da6c7fcbe [FIX] todo qemu : la console vise l'hyperviseur, pas la VM
Le choix [3] listait les domaines libvirt de la machine qui execute
todo.py. Or on pilote un parc imbrique depuis l'exterieur : cette
machine ne connait pas les VM d'un orchestrateur, et la bonne cible
n'apparaissait donc jamais dans la liste.

L'ecran VNC appartient a QEMU, donc a l'hyperviseur. Tunneler vers
l'invite ne trouve rien : le socket n'existe pas de ce cote. La cible
est le ProxyJump declare pour l'hote, lu par « ssh -G » — la seule
lecture qui couvre Match, wildcards et Include. Le nom compose
« saut+vm » n'est qu'un libelle.

Le port est lu sur cet hyperviseur, sans sudo d'abord : le groupe
libvirt suffit souvent, et « sudo -n » distant echoue faute de TTY.

--- EN ---

Choice [3] listed the libvirt domains of the machine running todo.py.
But a nested fleet is driven from outside: that machine knows nothing
of an orchestrator's VMs, so the right target never showed up.

The VNC screen belongs to QEMU, hence to the hypervisor. Tunnelling to
the guest finds nothing: the socket does not exist on that side. The
target is the ProxyJump declared for the host, read via "ssh -G" — the
only reading that covers Match, wildcards and Include. The composite
"jump+vm" name is just a label.

The port is read on that hypervisor, without sudo first: libvirt group
membership often suffices, and remote "sudo -n" fails for lack of a TTY.

Assisted-by: Claude Opus 5
(cherry picked from commit 2fb846d502cc8026732b58e1f487d7a6726f17eb)
2026-08-23 02:07:42 -04:00
d35d559273 [FIX] smoke: two sites still unpacked three fields from the failure tuple
My own regression. The tuple went from three fields to four and I
missed recheck_after_reset, so a live migration died on "too many values
to unpack" -- AFTER the COW copy had been reset, at the very moment the
tool was about to prove the fix worked.

Both sites wanted only the URL, so they index instead of unpacking. That
survives the next field; a comment saying "always four elements" does
not.

The pass had no test at all, which is why the suite and the mutations
both stayed green. It has three now, plus a guard that fails on any
three-field unpack of that list.

--- FR ---

Ma régression. Le tuple est passé de trois à quatre champs et j'ai
manqué recheck_after_reset : une migration en cours est morte sur « too
many values to unpack » — APRÈS la réinitialisation de la copie COW, au
moment précis où l'outil allait prouver que le correctif tenait.

Les deux sites ne voulaient que l'URL : ils indexent au lieu de
dépaqueter. Cela survit au prochain champ ; un commentaire « toujours
quatre éléments » non.

Cette passe n'avait aucun test, d'où le vert de la suite et des
mutations. Elle en a trois, plus un garde qui tombe sur tout
dépaquetage à trois champs de cette liste.

Assisted-by: Claude Opus 5
2026-08-23 02:07:42 -04:00
437ac4b457 [FIX] smoke: name the URL that actually failed, keep the previous log
The report named the sitemap URL, never the one the redirect chain
ended on. On this site every page goes through two or three hops --
measured, 146 for 55 pages, between the language prefix and the
canonical slug -- so a 500 at the end was reported against a page that
answers perfectly well. One goes and checks it, finds it healthy, and
concludes the tool is wrong.

urllib carries the answer: HTTPError.url is the URL that produced the
error, not the one requested. fetch now returns it and the report shows
it when it differs.

A timeout was never reported as 500 -- fetch returns 0 and the report
writes "no answer" -- but the evidence for a real 500 did evaporate: the
Odoo log was opened with "w", so replaying the test erased the trace of
the failure one had just seen. One generation is kept now.

--- FR ---

Le rapport nommait l'URL du sitemap, jamais celle où la chaîne de
redirections aboutit. Sur ce site chaque page en traverse deux ou trois
— mesuré, 146 pour 55 pages — donc un 500 au bout était imputé à une
page qui répond très bien. On va la vérifier, on la trouve saine, et
l'on conclut que l'outil se trompe.

urllib porte la réponse : HTTPError.url est l'URL qui a produit
l'erreur. `fetch` la rend, et le rapport l'affiche quand elle diffère.

Un dépassement de délai n'a jamais été rendu comme un 500 — `fetch`
rend 0, écrit « aucune réponse » — mais la preuve d'un vrai 500
s'évaporait : le journal Odoo était ouvert en « w », donc rejouer le
test effaçait la trace qu'on venait de voir. Une génération est gardée.

Assisted-by: Claude Opus 5
2026-08-23 02:07:42 -04:00
7e30377e56 [ADD] migration: convert the <tree> tags Odoo 18 renamed to <list>
OpenUpgrade converts the COLUMN and never the ARCH:

    def _fix_list_view_type(cr):
        cr.execute("UPDATE ir_ui_view SET type='list' WHERE type='tree'")

so the load dies on "the root node of a list view should be <list>".
A module view recovers at its next update, which rewrites the arch from
XML. A view with no xmlid -- a hand-made list, a website copy -- is
rewritten by nothing and stays broken forever. The report says which is
which, because the two call for different work.

Every occurrence, not just the root: a <tree> nested in a form is a
one2many list and is refused just the same. Odoo 18's own addons hold
none, so nothing legitimate is at risk. Gated on version 18: before it,
the tag is correct and "fixing" it would break healthy views.

Four tests run the SQL against a real PostgreSQL. A text assertion
cannot see what a query does -- it took a live run to prove every
language of the jsonb arch is converted, not just en_US.

--- FR ---

OpenUpgrade convertit la COLONNE et jamais l'ARCH, d'où l'échec sur « le
nœud racine d'une vue list devrait être <list> ». Une vue de module s'en
remet à sa prochaine mise à jour ; une vue sans xmlid — liste faite
main, copie de site — n'est réécrite par rien. Le rapport distingue les
deux, car le travail diffère.

Toutes les occurrences, pas seulement la racine : un <tree> imbriqué
dans un formulaire est une liste one2many, refusée pareillement. Les
addons d'Odoo 18 n'en portent aucun. Bridé à la 18 : avant, la balise
est juste.

Quatre tests exécutent le SQL contre un vrai PostgreSQL. Une assertion
sur du texte ne voit pas ce qu'une requête fait — il a fallu l'exécuter
pour prouver que toutes les langues du jsonb sont converties.

Assisted-by: Claude Opus 5
2026-08-22 07:24:00 -04:00
300238fe83 [ADD] migration: offer the theme uninstall where the theme is blamed
The question was asked once, at step one, and a flag kept it from ever
returning. But a theme only becomes incompatible at a given bump, hours
later -- asking at the start could not cover it. Entry [6] appears when
TWO conditions hold: a theme is installed, and the recent output names
it. One condition alone would offer to wreck the site's design over an
error that has nothing to do with it.

Also, the 17 to 18 rename OpenUpgrade declares and never applies:
  forum.post / tag_ids : column1 is now 'forum_post_id' ('forum_id')
website_forum/18.0.1.2/ holds only an analysis, no script, so the load
dies on a foreign key to a column that does not exist and leaves the
database half migrated.

--- FR ---

La question était posée une fois, à l'étape 1, et un drapeau
l'empêchait de revenir. Or un thème ne devient incompatible qu'à un
palier donné, des heures plus tard : la poser au départ ne pouvait pas
suffire. L'entrée [6] paraît quand DEUX conditions tiennent — un thème
installé, et la sortie récente qui le nomme. Une seule offrirait de
casser le design du site pour une panne étrangère.

Et le renommage 17 → 18 qu'OpenUpgrade déclare sans jamais l'appliquer :
  forum.post / tag_ids : column1 is now 'forum_post_id' ('forum_id')
website_forum/18.0.1.2/ ne porte qu'une analyse, aucun script : le
chargement meurt sur une clé étrangère vers une colonne absente et
laisse la base à moitié migrée.

Assisted-by: Claude Opus 5
2026-08-22 07:24:00 -04:00
89ef6a97fd [FIX] migration: exit 1 means findings, not failure, for the view fixer
Entry [5] never asked "Correct them?". The tool ran, printed its
diagnosis, exited 1 -- and todo_upgrade_execute read that as a failure
and reopened ITS error menu on top of ours. The menu looped and nothing
was ever corrected.

wait_at_error=False on both calls, as the COW tool next door already
does. The fake in the test now models the real contract: it refuses to
return a non-zero status when the flag is missing, exactly as the real
one refuses by recursing into its prompt.

--- FR ---

L'entrée [5] ne posait jamais la question « Les corriger ? ». L'outil
tournait, affichait son diagnostic, sortait 1 — et todo_upgrade_execute
y lisait un échec et rouvrait SON menu d'erreur par-dessus le nôtre. Le
menu tournait en rond et rien n'était corrigé.

wait_at_error=False sur les deux appels, comme le fait déjà l'outil COW
juste à côté. Le faux du test modèle désormais le vrai contrat : il
refuse de rendre un statut non nul quand le drapeau manque, comme le
vrai refuse en repartant dans son invite.

Assisted-by: Claude Opus 5
2026-08-22 07:24:00 -04:00
068022db82 [ADD] migration: repair views whose stored type contradicts their parent
Odoo refused to load a database: it validated a <search> arch with tree
rules. The view is not a COW copy -- the COW tools were right to say
they had nothing to reset -- its stored `type` simply lies.

Nothing would ever have fixed it. In ir_ui_view.py, `type` is filled in
`create`, and only when absent; `write` never recomputes it. A wrong
value stays wrong, and `-u module` fails on the validation it causes
before it could rewrite anything.

Plain SQL, no ORM: the registry is what will not load, and a repair that
needed Odoo to fix what stops Odoo would be useless.

The rule is Odoo's own -- an inherited view takes its parent's type --
and it holds: zero disagreement on a pristine 18 install and on four
migrated databases, exactly one on the database that refused to load.

--- FR ---

Odoo refusait de charger une base : il validait un arch <search> avec
les règles d'un tree. La vue n'est pas une copie COW — les outils COW
avaient raison de dire qu'ils n'avaient rien à réinitialiser — c'est son
`type` stocké qui ment.

Rien ne l'aurait jamais réparé. Dans ir_ui_view.py, `type` est rempli
dans `create`, et seulement s'il est absent ; `write` ne le recalcule
jamais. Une valeur fausse le reste, et `-u module` échoue sur la
validation qu'elle provoque avant de pouvoir réécrire quoi que ce soit.

En SQL, sans ORM : c'est le registre qui ne charge plus, et une
réparation qui aurait besoin d'Odoo ne servirait à rien.

La règle est celle d'Odoo — une vue héritée prend le type de son parent
— et elle tient : zéro écart sur une 18 neuve et sur quatre bases
migrées, exactement un sur celle qui refusait de charger.

Assisted-by: Claude Opus 5
2026-08-22 07:24:00 -04:00
ff8b6bcaf9 [ADD] migration quality: lay OpenUpgrade's declared changes over the real ones
The report counted + and -. A field that stops being stored is neither:
it loses its column and keeps its existence. Read as a loss it cries
wolf every step; ignored it would mask a real one. OpenUpgrade already
knows, in the upgrade_analysis.txt files the checkout carries -- the
same source the OCA coverage pages are generated from, read locally, at
the checkout's exact version.

Three categories keep the signal from drowning: a field whose model went
away is not a second finding (544 of them on one real step), a field of
a module OpenUpgrade does not analyse was never its business, and the
rest is grouped by field name -- __last_update counted itself 391 times.

--- FR ---

Le rapport comptait des + et des -. Un champ qui cesse d'être stocké
n'est ni l'un ni l'autre : il perd sa colonne et garde son existence. Lu
comme une perte, il alarme à chaque palier ; ignoré, il en masquerait
une vraie. OpenUpgrade le sait déjà, dans les upgrade_analysis.txt que
porte le checkout -- la source des pages de couverture de l'OCA, lue
localement, à la version exacte.

Trois catégories évitent que le signal se noie : un champ dont le modèle
a disparu n'est pas une trouvaille de plus (544 sur un palier réel), un
champ d'un module non analysé n'a jamais relevé d'OpenUpgrade, et le
reste est groupé par nom -- __last_update se comptait 391 fois.

Assisted-by: Claude Opus 5
2026-08-22 07:24:00 -04:00
8c7ff096b3 [ADD] migration: repair DMS at the 13 bump, and catch the whole class
The repair now runs by itself where the breakage happens: MuK becomes
OCA DMS at 13 and nowhere else, so the hook is gated on that bump. It
stays quiet on databases without DMS and on ones already repaired, and
a successful --apply now exits 0 instead of 1.

The wider hole stays open otherwise: data PRESENT that a global rule
hides entirely. Row counts said everything was there — true. The smoke
test served every page — true. Nobody could reach the records. The new
check runs at every bump and names any model with rows that no active
internal user can see a single one of. Proven both ways on a copy: 85
models clean after the repair, dms.file and dms.directory named before
it.

--- FR ---

La réparation s'exécute désormais là où la casse a lieu : MuK devient
OCA DMS au palier 13 et nulle part ailleurs, d'où le garde. Elle se tait
sur une base sans DMS et sur une base déjà réparée, et un --apply réussi
rend 0 au lieu de 1.

Le trou plus large restait ouvert : des données PRÉSENTES qu'une règle
globale masque entièrement. Les comptages disaient « tout est là » —
vrai. Le test de fumée servait les pages — vrai. Personne n'atteignait
les données. Le nouveau test tourne à chaque palier et nomme tout modèle
dont aucun utilisateur interne actif ne voit une seule ligne. Éprouvé
dans les deux sens sur une copie : 85 modèles sains après réparation,
dms.file et dms.directory nommés avant.

Assisted-by: Claude Opus 5
2026-08-22 07:24:00 -04:00
a9d9a08574 [ADD] migration: restore DMS visibility lost with MuK's access model
Nothing was lost at the 13 bump: 69 files, 16 folders and 23 MB of
content_binary are identical from 12 to 18. What changed is the security
model. MuK filtered on company alone; OCA DMS adds a GLOBAL rule on
permission_read, granted only through a dms.access.group or through
storages saved as attachment. The migration created no group — MuK had
none to convert — and the storages are database. Both doors shut, every
user sees zero.

Reports without writing unless --apply. The visibility count uses a real
user: the global rule spares the superuser, so a sudo count would call a
mute database healthy.

--- FR ---

Rien n'a été perdu au palier 13 : 69 fichiers, 16 dossiers et 23 Mo de
content_binary sont identiques de la 12 à la 18. C'est le modèle de
sécurité qui a changé. MuK ne filtrait que sur la société ; OCA DMS
ajoute une règle GLOBALE sur permission_read, accordée par une
dms.access.group ou par un stockage en attachment. La migration n'a créé
aucun groupe — MuK n'en avait aucun à convertir — et les stockages sont
en database. Les deux portes fermées, personne ne voit rien.

Rapport sans écriture sauf --apply. Le comptage de visibilité passe par
un vrai utilisateur : la règle globale épargne le super-utilisateur, donc
un comptage en sudo déclarerait saine une base muette.

Assisted-by: Claude Opus 5
2026-08-22 07:24:00 -04:00
d2f70f6535 [ADD] migration quality: explain ir_property and tracking values
ir_property never empties, it GROWS — 211 rows in 12, 457 in 17 — then
vanishes in 18. Its fields are jsonb columns there, verified on
res_partner.property_payment_term_id.

mail_tracking_value is a REAL loss, not a rename: `field` goes from
varchar to a foreign key in 14, and what would not resolve was deleted.
1528 of the 2336 belonged to the removed agile module; 706 more survived
under a new name. New kind "pruned" so the report cannot call a living
table retired.

--- FR ---

ir_property ne se vide jamais, elle GROSSIT — 211 lignes en 12, 457 en
17 — puis disparaît en 18. Ses champs y sont des colonnes jsonb, vérifié
sur res_partner.property_payment_term_id.

mail_tracking_value est une VRAIE perte, pas un renommage : `field`
passe de varchar à clé étrangère en 14, et ce qui ne se résolvait pas a
été supprimé. 1528 des 2336 venaient du module agile retiré ; 706 autres
ont survécu sous un autre nom. Nouveau genre « pruned » pour que le
rapport ne déclare pas retirée une table bien vivante.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
4103e13800 [ADD] migration quality: needaction became notifications in 15
Verified bump by bump rather than deduced from a name that looks alike:
1269 rows in 12, 13 and 14, the table disappears in 15 and
mail_notification holds exactly 1269. Not one lost. That row-for-row
carry-over is what earns the entry — a neighbouring name would have
proved nothing.

It is dated at 15 and not earlier: the table is still full at 14, and an
entry that applied sooner would mask a loss that happened before the
redesign.

The unexplained list goes from 56 to 55.

--- FR ---

[ADD] qualité de migration : needaction est devenu notifications en 15

Vérifié palier par palier plutôt que déduit d'un nom qui se ressemble :
1269 lignes en 12, 13 et 14, la table disparaît en 15 et
mail_notification en compte exactement 1269. Pas une perdue. C'est ce
report à l'unité près qui autorise l'entrée — un nom voisin n'aurait rien
prouvé.

Elle est datée de 15 et pas avant : la table est encore pleine en 14, et
une entrée qui s'appliquerait plus tôt masquerait une perte survenue avant
la refonte.

La liste des pertes inexpliquées passe de 56 à 55.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
53fa936168 [ADD] migration quality: biggest losses first, and the full lists behind « d »
Fifty-seven losses are read from the top, not in alphabetical order. They
are now sorted by rows LOST, not by rows there before: a table of ten
thousand that drops two matters less than one of a thousand that empties.

« d » cycles the pane through the complete lists — modules, models,
fields, COW copies, tables — and back to the summary. The summary cuts at
eight entries and is right to; but when one is looking for whether ONE
module survived, the truncated list does not answer, and that is exactly
when it is needed.

Two inventories were missing and both matter. FIELDS: a lost field is a
lost column of data, finer than a model, which can survive emptied of
half of its own. COW COPIES by key: it is the key one resets, and by the
key one finds them again across versions. On a real 12 → 18 they read
11 036 → 15 069 fields and 64 → 131 copies, of which 28 disappeared —
customisations nothing was reporting until now.

One mode replaces two flags: « show missing files » and « show the model
list » cannot both be true, and two booleans let that impossible state be
written. The mode is named in the sub-title, because a pane that changes
without saying why reads as a broken screen.

--- FR ---

[ADD] qualité de migration : les plus grosses pertes d'abord, les listes derrière « d »

Cinquante-sept pertes se lisent par le haut. Elles sont triées sur le
volume PERDU, non sur le volume présent avant : une table de dix mille qui
en perd deux compte moins qu'une de mille qui se vide.

« d » fait défiler le panneau à travers les listes entières — modules,
modèles, champs, copies COW, tables — puis revient au résumé.

Deux inventaires manquaient. Les CHAMPS : un champ perdu est une colonne
de données perdue. Les COPIES COW par leur clé : sur une vraie 12 → 18,
28 ont disparu — des personnalisations que rien ne signalait.

Un seul mode remplace deux drapeaux, qui laissaient écrire un état
impossible. Il est nommé dans le sous-titre.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
e75bc585ba [ADD] migration quality: tell Odoo's own redesigns from real losses
« 81 tables lost rows » buried the questions worth asking. The largest of
them — ir_translation, 32 984 rows — is a redesign Odoo made in 16, when
translations moved into jsonb columns. Putting redesigns and real losses
on the same footing is the surest way not to see the second kind.

A map now names what Odoo moves or retires, and the report splits the
list in two: unexplained first, then what Odoo did on purpose. On a real
12 → 18 the split is 57 and 14, and the partition is exact — an explained
loss is annotated, never removed, because removing it was the one thing
that could hide a real one.

Every entry was VERIFIED on that migration by counting both sides, not
written from memory: crm_lead_tag 6 → crm_tag +6, mail_channel 26 →
discuss_channel +26, account_invoice 651 → account_move +441. A map
written from memory would explain losses that are not losses.

And a merge whose target gained nothing says so: the explanation does not
hold, and that is exactly what the map was meant to surface.

--- FR ---

[ADD] qualité de migration : distinguer les refontes d'Odoo des vraies pertes

« 81 tables ont perdu des lignes » enterrait les questions qui valaient la
peine. La plus grosse — ir_translation, 32 984 lignes — est une refonte
d'Odoo 16, quand les traductions sont passées en colonnes jsonb.

Une carte nomme désormais ce qu'Odoo déplace ou retire, et le rapport
sépare la liste en deux : l'inexpliqué d'abord, puis ce qu'Odoo a fait
exprès. Sur une vraie 12 → 18 : 57 et 14, et la partition est exacte —
une perte expliquée est annotée, jamais retirée.

Chaque entrée a été VÉRIFIÉE sur cette migration en comptant les deux
côtés : crm_lead_tag 6 → crm_tag +6, mail_channel 26 → discuss_channel
+26, account_invoice 651 → account_move +441.

Et une fusion dont la table d'accueil n'a rien reçu le dit : l'explication
ne tient pas, et c'est précisément ce que la carte devait révéler.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
951b88abab [FIX] todo analyse: give the migration entry its icon
An entry with no icon, in a menu where every other one has one, reads as
unfinished. The section gets 🚚 and the entry 📐 — the same symbol the
quality report already wears in its own title, because it is the same
thing.

The spacing follows a convention this menu keeps without stating it: an
icon of « neutral » width — 🗄, 🖼 — occupies one column in a terminal
and takes TWO spaces to line up, while a wide one — 📏, 📐 — takes one.
Getting it wrong shifts the line, and nothing says so until it is under
your eyes. A test now states it.

--- FR ---

[FIX] todo analyse : donner son icône à l'entrée de migration

Une entrée sans icône, dans un menu où toutes les autres en ont une, se
lit comme une entrée inachevée. La section reçoit 🚚 et l'entrée 📐 — le
symbole que le rapport de qualité porte déjà dans son propre titre,
puisque c'est la même chose.

L'espacement suit une convention que ce menu tient sans la dire : une
icône de largeur « neutre » — 🗄, 🖼 — s'affiche sur une colonne dans un
terminal et prend DEUX espaces pour s'aligner, une icône large — 📏, 📐 —
un seul. Se tromper décale la ligne, et rien ne le signale avant de
l'avoir sous les yeux. Un test l'énonce désormais.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
37c9f216de [ADD] migration quality: name the missing files, and show every delta
« 254 attachment files missing » does not say which. « m » now lists
them, grouped by model AND FIELD — the field is the most useful thing in
the row, because it says which field lost its image. On a real migration
it splits into 234 res.country/image flags, 13 payment.provider logos,
and a handful of scattered attachments including one 255 kB event photo:
the first two are module data a reinstall restores, the last is not, and
the raw list put them on the same footing.

The metadata is read ONLY when asked. One more query per database would
lengthen a survey that runs in four seconds, for something one looks at
by asking for it.

And every figure of a step now carries its change against the previous
one. « 2283 views » is a number; « +61 » is information. The first step
gets none: inventing a zero there would claim a comparison that does not
exist.

--- FR ---

[ADD] qualité de migration : nommer les fichiers absents, et montrer les écarts

« 254 fichiers de pièces jointes absents » ne dit pas lesquels. « m » en
donne la liste, groupée par modèle ET PAR CHAMP — le champ est le
renseignement le plus utile de la ligne, car il dit lequel a perdu son
image. Sur une vraie migration : 234 drapeaux res.country/image, 13 logos
de fournisseurs de paiement, et une poignée de pièces jointes éparses dont
une photo d'événement de 255 ko. Les deux premiers groupes sont des
données de module qu'une réinstallation restaure, le dernier non.

Les métadonnées ne sont lues QU'À LA DEMANDE : une requête de plus par
base allongerait un parcours qui tient en quatre secondes.

Et chaque chiffre d'un palier porte son écart avec le précédent. « 2283
vues » est un nombre, « +61 » est une information. Le premier palier n'en
a pas : y inventer un zéro annoncerait une comparaison qui n'existe pas.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
0fdfeaa9ce [FIX] migration state: open the quality screen in its own process
Pressing « k » crashed with « asyncio.run() cannot be called from a
running event loop ». `suspend()` hands the terminal over but does NOT
stop the loop, and `app.run()` calls `asyncio.run()` underneath. A
subprocess has its own loop and paints the terminal it was given.

My test asserted that suspend() was CALLED, and that structure was
correct — the launch inside it was not. Structure is not behaviour, and
the test that would have caught this is the one that presses the key.
It exists now, and a guard refuses the nested start with a sentence
instead of forty lines of traceback.

The screen module also lost its shebang: it has no main, so the line
claimed something that was not true, and the permission rule was right
to say so.

--- FR ---

[FIX] état de migration : ouvrir l'écran de qualité dans son processus

« k » plantait sur « asyncio.run() cannot be called from a running event
loop ». `suspend()` rend le terminal mais n'arrête PAS la boucle, et
`app.run()` appelle `asyncio.run()` en dessous. Un sous-processus a sa
propre boucle.

Mon test vérifiait que suspend() était APPELÉ — et cette structure était
juste ; c'est le lancement à l'intérieur qui ne l'était pas. La structure
n'est pas le comportement, et le test qui aurait attrapé cela est celui
qui presse la touche. Il existe désormais, et un garde-fou refuse le
lancement imbriqué par une phrase plutôt que par quarante lignes de trace.

Le module d'écran perd aussi son shebang : sans `main`, cette ligne
annonçait une intention qui n'existait pas.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
4028421cb8 [ADD] analyse: what a migration gained and lost, step by step
A migration leaves one database per step and they all still exist, so the
tool compares them side by side rather than replaying anything. Six Odoo
starts would cost an hour, write to the databases and need the checkout
switched at each step; the same inspection in SQL takes under half a
second per database and touches nothing. Measured on a real migration:
seven databases surveyed in 3.8 seconds.

It reports what each step gained and lost — modules, models, views, menus,
attachments — and ends on the comparison of the ENDS, which is not the sum
of the steps: a module dropped at 15 and put back at 17 lost nothing, and
adding the steps would count it twice.

The signal that matters is rows. A module removed is visible; a table
going from four thousand rows to zero is visible nowhere.

Two rename heuristics were tried and rejected on real data before the one
that holds. Row count alone paired an account tag table with dms_directory
— both had seven rows. A shared five-letter word paired two cleanup
tables. Overall name similarity separates them. And a renamed table stays
in the loss list, annotated: removing it was the real danger, since a
wrong pairing would have hidden a genuine loss.

--- FR ---

[ADD] analyse : ce qu'une migration a gagné et perdu, palier par palier

Une migration laisse une base par palier et elles existent toutes encore :
l'outil les compare côte à côte plutôt que de rejouer quoi que ce soit.
Six démarrages d'Odoo coûteraient une heure et écriraient dans les bases ;
la même inspection en SQL prend moins d'une demi-seconde par base. Mesuré
sur une vraie migration : sept bases inspectées en 3,8 secondes.

Le bilan final compare les EXTRÉMITÉS, ce qui n'est pas la somme des
paliers : un module retiré en 15 puis remis en 17 n'a rien perdu.

Le signal qui compte, ce sont les lignes. Un module en moins se voit ; une
table qui passe de quatre mille lignes à zéro ne se voit nulle part.

Deux rapprochements de renommage ont été essayés et rejetés sur des
données réelles. Et une table renommée reste dans la liste des pertes,
annotée : l'en retirer était le vrai danger.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
6715700a16 [ADD] migration state: read the server log so nobody has to
A step log reaches thirteen megabytes — measured on a real migration.
Nobody reads that, and the question one asks in front of it is two words
long: where did it go wrong? The screen now answers it.

Each step carries its count of ERROR and CRITICAL, and the pane lists the
DISTINCT messages with how many times each occurred. Forty-eight
« Model X has no table » is one problem seen forty-eight times, not
forty-eight problems, and the raw list buries everything else. WARNING is
left out of the count: a migration produces thousands, and a total that
includes them means nothing.

Each message keeps its logger and its DATABASE, which is what tells the
six bumps apart — they shared one file before a step opened its own.

Measured: thirteen megabytes scanned in 0.09 s, seventy occurrences
reduced to fifteen messages, and cached on (size, mtime) because the
screen repaints on every keystroke.

--- FR ---

[ADD] état de migration : lire le journal pour que personne n'ait à le faire

Un journal d'étape atteint treize mégaoctets — mesuré. Personne ne le
lit, et la question qu'on se pose devant lui tient en deux mots : où
est-ce que ça a mal tourné ? L'écran y répond désormais.

Chaque étape porte son compte d'ERROR et de CRITICAL, et le panneau liste
les messages DISTINCTS avec leur nombre. Quarante-huit fois « Model X has
no table » est un problème vu quarante-huit fois, pas quarante-huit
problèmes. Les WARNING sont hors du compte : une migration en produit des
milliers.

Chaque message garde son logger et sa BASE, ce qui distingue les six
paliers — ils partageaient un fichier avant qu'une étape n'ouvre le sien.

Mesuré : treize mégaoctets analysés en 0,09 s, soixante-dix occurrences
ramenées à quinze messages.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
6dff7aa304 [ADD] migration: announce the countdown, and cycle three panel states
Every prompt now carries « ⏱5s » before the question. One that looks like
it waits forever does not invite you to step away, which is the whole
point of auto-run. The same clock reports the answer afterwards: it
announces the wait, then says what it decided.

Enter on the auto-run question already meant no; it now says so, with an
explicit default instead of a deduction. And the flag is cleared before
asking — an ERPLIBRE_AUTO_EXECUTE inherited from the shell would have
answered yes on someone's behalf, and that answer commits every later one.

« p » cycles three states rather than toggling one: a plain toggle freed
four lines, while what takes the room is the left COLUMN — on a server log
every character of width counts. All → no summary → detail only, and back.
The state is named in the sub-title, which stays visible in all three: a
panel that vanishes without a word reads as a broken screen.

--- FR ---

[ADD] migration : annoncer le compte à rebours, et trois états de panneaux

Chaque invite porte désormais « ⏱5s » devant la question. Une invite qui a
l'air d'attendre indéfiniment n'invite pas à s'absenter, et c'est pourtant
tout l'intérêt du mode auto. Le même chronomètre rend compte ensuite.

Entrée sur la question d'auto-exécution voulait déjà dire non ; elle le
DIT maintenant, par un défaut explicite plutôt qu'une déduction. Et le
drapeau est effacé avant de demander : une variable héritée du shell
aurait répondu « oui » à la place de quelqu'un.

« p » enchaîne trois états au lieu d'en basculer un : ce qui prend la
place, c'est la colonne de gauche. Tout → sans résumé → détail seul, et
l'on revient. L'état est nommé dans le sous-titre.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
e981500c94 [ADD] migration state: how long the migration took
From the first write to the LAST one. The progression file is rewritten
after every move, so its update date is the end — or the present moment
while the migration is still running. Both dates were already shown; what
was missing was the one number one actually wants.

The statistics screen already computed this. It is reused rather than
rewritten: two formulas would give two durations for the same migration
depending on which screen one opens.

--- FR ---

[ADD] état de migration : combien de temps elle a duré

Du premier écrit au DERNIER. Le fichier de progression est réécrit après
chaque geste, donc sa date de mise à jour est la fin — ou l'instant
présent tant que la migration tourne. Les deux dates étaient déjà
affichées ; il manquait le seul chiffre qu'on cherche.

L'écran de statistiques portait déjà ce calcul. Il est réutilisé plutôt
que réécrit : deux formules donneraient deux durées pour la même
migration selon l'écran qu'on ouvre.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
c0c5144680 [ADD] migration state: l, p, r and the two resize keys
Four keys, and one bug behind them. « l » separates the commands from the
server log: the first says what was LAUNCHED, the second what it
ANSWERED, and an update writing tens of thousands of lines buries three
commands. « p » hides the summary panel, « - » and « + » move the split,
« r » re-reads the disk — the migration writes while one watches, and
closing the screen to see the next bump is what one ended up doing.

The « missing logs » had two causes, both real. A tail was shown without
saying it was one; it now names how many lines are hidden. And the first
two steps run before the database is named, so their logs landed under
« sans-nom » — outside the migration they belong to, invisible from the
screen. They are brought back the moment the name is known, appended
never overwritten.

The step 4 bug was already fixed: its log held all six bumps because only
print_step opened one, and the bump loop never calls it.

--- FR ---

[ADD] état de migration : l, p, r et les deux touches de taille

Quatre touches, et un défaut derrière. « l » sépare les commandes du
journal : la première dit ce qui a été LANCÉ, le second ce que cela a
RÉPONDU, et une mise à jour qui écrit des dizaines de milliers de lignes
enterre trois commandes. « p » cache le panneau de résumé, « - » et « + »
déplacent la séparation, « r » relit le disque — la migration écrit
pendant qu'on regarde.

Les « logs manquants » avaient deux causes. On montrait une fin sans dire
que c'en était une ; le nombre de lignes cachées est désormais nommé. Et
les deux premières étapes tournent avant que la base ne soit nommée :
leurs journaux atterrissaient sous « sans-nom », hors de la migration à
laquelle ils appartiennent. Ils la rejoignent dès que le nom est connu.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
1e591dd949 [FIX] migration state: a test result belongs to a step, not to the run
You were right that it read as global — and the cause ran deeper than the
display. Twenty step headers go through add_comment_progression against
seven through print_step, and the whole version-bump loop uses only the
first. The current step was set in the other one, so every tool verdict
of every bump carried a stale step.

The header itself now becomes the step, at the one place the journal is
already cut on. And the summary groups by (step, tool) instead of by tool
alone: a migration runs the smoke test at EVERY bump, and one line said
« smoke_public_url ✅ » while bump 14 had passed and bump 17 had fallen.

Within a step the last verdict still wins, with its run count — that is a
repair, not two bumps. The order is the journal's, because sorting step
names puts « 4.10 » before « 4.2 ».

--- FR ---

[FIX] état de migration : un résultat de test appartient à une étape

Vous aviez raison, cela se lisait comme global — et la cause était plus
profonde que l'affichage. Vingt en-têtes d'étape passent par
add_comment_progression contre sept par print_step, et toute la boucle des
paliers n'utilise que la première. L'étape courante était posée dans
l'autre : chaque verdict d'outil portait donc une étape périmée.

L'en-tête devient désormais l'étape, au seul endroit où le journal est
déjà découpé. Et le résumé groupe par (étape, outil) : une migration lance
le test de fumée à CHAQUE palier, et une seule ligne annonçait
« smoke_public_url ✅ » quand le palier 14 était passé et le 17 tombé.

Dans une étape, le dernier verdict l'emporte toujours — c'est une
réparation, pas deux paliers.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
0f0f853be4 [ADD] migration state: colour the commands, and know when not to
A list of commands in flat text blends into its own headings the moment it
outgrows the screen — which is exactly when it gets read. Commands are now
cyan, steps bold blue, failed ones red, and each verdict wears the colour
of its meaning: 1 is amber, because « there is something to look at » is
not a crash and painting it red worries for nothing.

Colour is refused three times over, and each refusal has cost someone: a
log file full of escape codes, a grep that finds nothing, a terminal that
prints them as text. A pipe is not a screen, and NO_COLOR is honoured.

The full screen goes through Text.from_ansi, which does two things: it
renders the colours, and it takes the rest literally. Textual parses Rich
markup, so a command containing « [1] » was being eaten as a tag and
vanished from the pane without a word. That was true before the colour.

--- FR ---

[ADD] état de migration : colorer les commandes, et savoir s'en abstenir

Une liste de commandes en texte plat se confond avec ses titres dès
qu'elle dépasse l'écran — et c'est justement quand elle le dépasse qu'on
la lit. Les commandes sont en cyan, les étapes en bleu gras, celles qui
ont échoué en rouge, et chaque verdict porte la couleur de son sens : 1
est ambre, car « il y a quelque chose à regarder » n'est pas une panne.

La couleur se refuse trois fois, et chaque refus a coûté à quelqu'un : un
fichier de journal truffé de codes d'échappement, un grep qui ne trouve
plus rien, un terminal qui les affiche en clair.

Le plein écran passe par Text.from_ansi, qui rend les couleurs ET prend le
reste au pied de la lettre : une commande contenant « [1] » était avalée
comme du balisage Rich et disparaissait sans un mot.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
4e0bf7cc8b [FIX] migration: the countdown names the answer the prompt offered
« ⏱ → d » named a reply that was not on the menu. The prompt reads
« Enter = delete, after saving them, k = keep » and mentions « d »
nowhere, so the line asking to be trusted with a decision described it in
a vocabulary the reader had never been given.

The decision itself was right — Enter deletes, and that is what happened.
Only its report was written from the inside. It now says Enter, in the
migration's language, with the value in brackets for whoever wants the
precision. What the caller receives is unchanged.

--- FR ---

[FIX] migration : le compte à rebours nomme la réponse que l'invite offrait

« ⏱ → d » nommait une réponse qui n'était pas au menu. L'invite annonce
« Entrée = effacer, après les avoir sauvegardés, k = garder » et ne
mentionne « d » nulle part : la ligne qui demandait qu'on lui confie une
décision la décrivait donc dans un vocabulaire jamais montré.

La décision, elle, était juste — Entrée efface, et c'est ce qui a eu lieu.
Seul son compte rendu était écrit du dedans. Il dit maintenant Entrée,
dans la langue de la migration, la valeur entre parenthèses pour qui veut
la précision. Ce que l'appelant reçoit ne change pas.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
e214df7329 [FIX] smoke: judge the back office AFTER the repair, and say why it failed
No timeout was hit and no test was skipped. The back office failed for the
same reason the site did: the COW copy of website.submenu breaks
/web/login exactly as it breaks the public pages — both go through the
same layout. The pass simply ran BEFORE the reset, and unlike the URLs it
was never looked at again. It is now, on the same second server.

« Session expired » described the consequence and hid the cause. The login
page returned 500 and nothing said so, so one looks at the password. The
status of that page, and a missing CSRF token, are now named.

Diagnosing this, I started Odoo 18 on a 17 database and got 500 on all
thirty-seven URLs and on /web/login: a report that says the site is
entirely broken when nothing is. database_cleanup already refused that;
the smoke tools now share the same guard rather than reimplementing it.

--- FR ---

[FIX] smoke : juger le back-office APRÈS la réparation, et dire pourquoi

Aucun délai n'a été atteint et aucun test n'a été sauté. Le back-office a
échoué pour la raison même qui cassait le site : la copie COW de
website.submenu casse /web/login comme elle casse les pages publiques —
les deux passent par le même gabarit. La passe tournait avant la
réinitialisation et, contrairement aux URL, n'était jamais revue.

« Session expired » décrivait la conséquence et cachait la cause : la page
de connexion rendait 500 sans que rien ne le dise, et l'on cherchait du
côté du mot de passe.

En diagnostiquant, j'ai lancé un Odoo 18 sur une base 17 : 500 partout, un
rapport qui déclare le site entièrement cassé quand rien ne l'est.
database_cleanup refusait déjà cela ; les outils de fumée partagent
désormais sa garde au lieu d'en écrire une seconde.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
19d807a81e [ADD] migration: keep the logs on disk, one file per step
The state screen showed « no tool has run yet » after closing and
reopening. It was reading the progression file, and that file is archived
and reset when a migration restarts — so everything it knew vanished at
the exact moment one wants to understand why the restart was needed.

Each step now has its own log file, appended never overwritten, and the
failures and tool verdicts go to an append-only JSONL beside them. Both
outlive the progression, and the screen reads disk first, memory second,
without counting the overlap twice.

The command output itself is captured where every line already passes,
in the executor's read loop: the terminal still shows it live and nothing
about the run changes. What goes through the real terminal cannot be
captured — a pipe there makes full screens refuse, that lesson is paid —
so those keep at least their command and their exit code.

--- FR ---

[ADD] migration : garder les journaux sur disque, un fichier par étape

L'écran d'état affichait « aucun outil n'a encore tourné » après une
fermeture. Il lisait le fichier de progression, or celui-ci est archivé
puis remis à zéro quand on recommence : tout ce qu'il savait disparaissait
au moment précis où l'on cherche pourquoi il a fallu recommencer.

Chaque étape a désormais son fichier, en ajout et jamais en écrasement, et
les échecs comme les verdicts d'outils vont dans un JSONL à côté. Les deux
survivent à la progression, et l'écran lit le disque d'abord.

La sortie des commandes est captée là où chaque ligne passe déjà, dans la
boucle de lecture de l'exécuteur : le terminal la montre toujours en
direct. Ce qui passe par le vrai terminal n'est pas captable — un tube y
ferait renoncer les pleins écrans — et garde au moins son verdict.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
566070b4f8 [FIX] migration: Enter exports the backup at the end
After several hours of migration the archive is what one wanted anyway,
and the prompt asked for it with an empty default: pressing Enter — or
letting auto-run answer — walked past the only artifact of the whole run.

The three-way answer moved into a function of its own, because the third
way is the one that traps. « n » refuses, « y » or Enter takes the
timestamped name, and EVERYTHING ELSE is a filename. So « non.zip » is a
file, not a refusal: comparing the whole answer rather than its first
letter is what keeps it one.

--- FR ---

[FIX] migration : Entrée exporte la sauvegarde à la fin

Au bout de plusieurs heures de migration, l'archive est ce qu'on voulait
de toute façon, et l'invite la proposait avec un défaut vide : appuyer sur
Entrée — ou laisser l'auto-exécution répondre — passait à côté du seul
artefact de toute la course.

La réponse à trois sens est devenue une fonction à part, car c'est le
troisième qui piège. « n » refuse, « y » ou Entrée prend le nom horodaté,
et TOUT LE RESTE est un nom de fichier. « non.zip » est donc un fichier et
non un refus : comparer la réponse entière plutôt que sa première lettre
est ce qui le garantit.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
3a182f2b0d [ADD] migration: reach the state screen from the statistics too
The statistics screen answers « what was removed, and why ». The state
screen answers « where are we, and what broke ». Two neighbouring
questions asked at the same moment: separating them by a menu entry
rather than by two commands to remember is what makes them usable.

Same letter as the prompts. One that changes meaning from one screen to
the next is not learned, it is looked up — and nobody looks it up.

Adding it surfaced two ways that screen could not survive auto-run. The
interface question is the FIRST one asked after auto-run is switched on,
and it was a bare input(): an unattended migration stopped there before
it had begun. The statistics menu was one too. And the state screen
writes the progression before reading it — but the statistics screen runs
before the progression is even loaded, so that write failed on an
attribute that does not exist yet.

--- FR ---

[ADD] migration : atteindre l'état depuis les statistiques aussi

L'écran de statistiques répond « qu'a-t-on supprimé, et pourquoi ». Celui
de l'état répond « où en est-on, et qu'est-ce qui a cassé ». Deux
questions voisines, posées au même moment : les séparer par une entrée de
menu plutôt que par deux commandes à retenir est ce qui les rend
utilisables.

La même lettre que dans les invites. Une lettre qui change de sens d'un
écran à l'autre ne s'apprend pas, elle se cherche — et personne ne la
cherche.

L'ajout a révélé deux façons dont cet écran ne survivait pas au mode
auto : la question d'interface, PREMIÈRE posée après l'activation, était
un input() nu, et le menu des statistiques aussi. Et l'écran d'état écrit
la progression avant de la lire, alors que les statistiques s'ouvrent
avant même qu'elle ne soit chargée.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
6c186f71e9 [ADD] migration: « t » shows where the migration stands
A migration crosses six bumps, runs hundreds of commands and lasts hours.
The journal said what had been LAUNCHED; it never said what came of it.
Three hours in, one reads two hundred command lines without knowing which
one failed, nor what the smoke test concluded.

« v » could not carry this: it already means « view the differences » in
three prompts, and a letter meaning two things is worse than a letter
meaning nothing. « t » was free, and it is the same everywhere — one
shared string carries both shortcuts, so no prompt can drift.

Looking is not answering: the same question comes back afterwards.

What was missing was the data. Failures and tool verdicts are now
recorded with the step they happened in, bounded so a progression file
cannot grow without end. A tool rerun after a repair keeps its LAST
verdict and the count of its runs — showing both without distinction
would read a repair as a lasting failure.

--- FR ---

[ADD] migration : « t » montre où en est la migration

Une migration traverse six paliers, lance des centaines de commandes et
dure des heures. Le journal disait ce qui avait été LANCÉ, jamais ce que
cela avait donné. Trois heures plus tard on relit deux cents lignes sans
savoir laquelle a échoué, ni ce que le test de fumée a conclu.

« v » ne pouvait pas porter cela : il veut déjà dire « voir les
différences » dans trois invites, et une lettre qui signifie deux choses
est pire qu'une lettre qui ne signifie rien. « t » était libre, et il est
le même partout — une seule chaîne porte les deux raccourcis.

Regarder n'est pas répondre : la même question revient ensuite.

Ce qui manquait, c'étaient les données. Les échecs et les verdicts
d'outils sont désormais retenus avec l'étape où ils se sont produits.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
fc23bd6f96 [FIX] migration: a skipped back office must not read as a healthy one
The back-office pass was already there, and it works. What did not work
was the way it declined: one discreet line at the end of a long report,
saying « the database was not neutralized ». On six databases of a real
migration the test user is present up to the 15 bump and GONE at 17 and
18 — so the pass stopped silently exactly where a migration does the most
damage, and said something that was not even true.

The migration knows what it neutralized, so it now asks for the back
office by name. A missing test user on a database it neutralized is a
finding, printed loudly and counted as a failure. A missing tool is too:
returning None made the whole pass vanish without a word.

And it says UP FRONT which passes will run, on that database, by name.

--- FR ---

[FIX] migration : un back-office sauté ne doit pas se lire comme un sain

La passe back-office était déjà là et elle fonctionne. Ce qui ne
fonctionnait pas, c'est sa façon de renoncer : une ligne discrète en fin
d'un long rapport, disant « la base n'a pas été neutralisée ». Sur les six
bases d'une vraie migration, l'utilisateur test est présent jusqu'au
palier 15 et ABSENT en 17 et 18 — la passe s'arrêtait donc sans bruit là
où une migration fait le plus de dégâts, en disant quelque chose de faux.

La migration sait ce qu'elle a neutralisé : elle réclame désormais le
back-office. Un utilisateur test manquant sur une base qu'elle a
neutralisée est une trouvaille, affichée fort et comptée comme un échec.
Un outil absent aussi : rendre None faisait disparaître la passe entière.

Et elle annonce AVANT de lancer ce qui sera parcouru.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
1c7664409e [FIX] migration: the module-upgrade step no longer waits forever
Six « press to continue » prompts on the module-code migration were bare
input() calls. Auto-run never reached them, so it stopped there and said
nothing — the question had been asked, after all. They now go through the
timed reader, where Enter and the countdown mean the same thing.

They were English too, on a path the rest of which speaks the system
language.

The guard that forbids a bare input() only looked at a handful of
methods, and internal_module_upgrade was not among them — which is
exactly how these six survived the first sweep. It is now covered, and
the module menu's own prompts are documented as deliberately blocking:
they ask for a module name, a path, a version, and « » after five
seconds would be a wrong answer, not a convenience.

--- FR ---

[FIX] migration : l'étape de migration de code n'attend plus indéfiniment

Six invites « appuyez pour continuer » de la migration de code étaient des
input() nus. L'auto-exécution ne les atteignait pas : elle s'arrêtait là
sans rien dire — la question avait bien été posée. Elles passent
désormais par le lecteur temporisé, où Entrée et le compte à rebours font
la même chose.

Elles étaient aussi en anglais, sur un chemin dont tout le reste parle la
langue du système.

Le garde-fou qui interdit les input() nus ne regardait qu'une poignée de
méthodes, et internal_module_upgrade n'en faisait pas partie — c'est
ainsi que ces six-là avaient survécu. Il la couvre maintenant, et les
invites du menu de modules sont documentées comme bloquantes à dessein :
elles demandent un nom, un chemin, une version.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
7296a03179 [FIX] migration: repair, replay, and know when to stop
The failure that stops an upgrade is almost always the same one — a COW
copy left behind on the previous version — and the repair is known. So
Enter now repairs, and a repair that actually changed something replays
the command by itself.

A default that acts must know when to stop, and here it must twice over.
Repairing when there is nothing left to repair, then offering it again,
loops without end: measured, « no COW copy has drifted » over and over.
And a repair that does not help would replay the command forever. Three
attempts, then the prompt says plainly that this one needs a developer.

The turn counter is deliberately redundant with that logic: both guard
the same failure, but the counter holds even if the logic is broken one
day by accident. An endless loop in an unattended migration costs a
night.

Also: the smoke tool forced `ask=input` on its own prompt, which
short-circuited auto-run — the question waited for a keystroke nobody
was there to give.

--- FR ---

[FIX] migration : réparer, rejouer, et savoir s'arrêter

L'échec qui arrête une migration est presque toujours le même — une copie
COW restée sur la version d'avant — et la réparation est connue. Entrée
répare donc, et une réparation qui a vraiment changé quelque chose rejoue
la commande d'elle-même.

Un défaut qui agit doit savoir s'arrêter, et ici deux fois plutôt qu'une.
Réparer quand il n'y a plus rien à réparer, puis le reproposer, boucle
sans fin : mesuré, « Aucune copie COW n'a dérivé », encore et encore. Et
une réparation qui ne suffit pas rejouerait indéfiniment. Trois
tentatives, puis l'invite dit qu'il faut un développeur.

Le compteur de tours double volontairement cette logique : il tient même
si elle est cassée un jour par mégarde.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
58bdd70526 [FIX] database_cleanup: purge the batch, and say what you are doing
Seventeen minutes with no output looks exactly like an infinite loop. It
was not one: measured on the running process, zero lock waits and queries
changing every sample. It was working, silently, one module every thirty
seconds.

Both halves were mine. Purging line by line — added so one refusal could
not sink a category — calls button_immediate_uninstall() per line, and
that reloads the WHOLE registry: 5984 modules re-read each time. The
batch is now purged in one call, exactly as the OCA wizard intends, and
the per-line isolation only costs when a refusal actually happens.

And the tool captured its child's output, so nothing showed before the
end. It now relays each step as it arrives, with elapsed seconds, and a
timer kills the process group when the deadline passes.

--- FR ---

[FIX] database_cleanup : purger le lot, et dire ce qu'on fait

Dix-sept minutes sans une ligne ressemblent trait pour trait à une boucle
infinie. Ce n'en était pas une : mesuré sur le processus vivant, zéro
verrou en attente et des requêtes qui changeaient à chaque instantané. Il
travaillait, en silence, à raison d'un module toutes les trente secondes.

Les deux moitiés étaient de mon fait. Purger ligne par ligne — ajouté
pour qu'un refus n'emporte pas la catégorie — appelle
button_immediate_uninstall() par ligne, ce qui recharge TOUT le registre :
5984 modules relus à chaque fois. Le lot est désormais purgé en un seul
appel, comme le module OCA le prévoit, et l'isolement ne coûte que
lorsqu'un refus survient vraiment.

Et l'outil capturait la sortie de son enfant : rien avant la fin. Il
relaie maintenant chaque étape, temps écoulé compris.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
eaf70bdebf [ADD] migration: open /my as the test user, and blame the right request
The portal is a third rendering, neither the public site nor the back
office: QWeb frontend with counters that each query their own model. The
sitemap does not list /my — the page needs a session — and no RPC call
goes through it. A migration can break it with nothing else noticing.

Measured on a real database: /my answered 500 on a user field a module no
longer defines, while every public page and sixteen apps were fine.

Two guards the run proved necessary. Requested without a session, /my
redirects to the login form and answers 200: counting that as a success
would announce a healthy portal never seen. And the production error page
shows no traceback, so the reason comes from the server log — attributed
by PATH, because the portal is checked first and taking the last trace
blamed it for an app's failure. A wrong cause costs more than none.

--- FR ---

[ADD] migration : ouvrir /my avec l'utilisateur test, et accuser la bonne requête

Le portail est un troisième rendu, ni le site public ni le back-office :
du QWeb frontend, avec ses compteurs qui interrogent chacun leur modèle.
Le sitemap ne liste pas /my — la page demande une session — et aucun appel
RPC n'y passe. Une migration peut le casser sans que rien ne le dise.

Mesuré sur une vraie base : /my rendait 500 sur un champ utilisateur qu'un
module ne définit plus, alors que les pages publiques et seize
applications allaient bien.

Deux garde-fous que l'essai a rendus nécessaires. Sans session, /my
redirige vers le formulaire de connexion et rend 200 : le compter pour une
réussite annoncerait un portail jamais vu. Et la page d'erreur de
production ne montre aucune trace : la raison vient du journal, attribuée
par CHEMIN — le portail est interrogé en premier, et prendre la dernière
trace l'accusait de la panne d'une application.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
d8794f0e10 [ADD] migration: open every app as the neutralization test user
The public smoke test opens what a visitor reaches. It says nothing about
the back office, which is where a migration does most of its damage: a
field dropped from a model but still named in a form, a module installed
in the database whose code no longer ships with the target version. None
of it stops the module loading — it stops the day someone opens the app.

Neutralizing installs a `test` / `test` login carrying the SYSTEM user's
groups, and it survives the module's uninstall. So the tool checks for
that user rather than trusting a flag, and it rides the server the public
pass already started: booting Odoo is what costs minutes, not requests.

Measured on a real 18.0 database of 25 apps, which found four defects of
mine: web_search_read changed signature in 17, a bare 404 hides an
unregistered model, embedded sub-views are not the parent's fields, and
an empty psql result meant two different things.

--- FR ---

[ADD] migration : ouvrir chaque application avec l'utilisateur test

Le test de fumée public ouvre ce qu'un visiteur atteint. Il ne dit rien du
back-office, où une migration fait pourtant l'essentiel de ses dégâts : un
champ retiré du modèle mais toujours nommé dans un formulaire, un module
installé en base dont le code n'accompagne plus la version cible. Rien de
cela n'arrête le chargement ; cela arrête le jour où l'on ouvre l'appli.

La neutralisation pose un compte `test` / `test` portant les groupes du
superutilisateur, et il survit à la désinstallation du module. L'outil
vérifie donc cet utilisateur plutôt qu'un drapeau, et réutilise le serveur
déjà démarré : c'est le démarrage qui coûte, pas les requêtes.

Mesuré sur une vraie base 18.0 de 25 applications, ce qui a révélé quatre
défauts de mon fait.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
95a6ba558e [ADD] migration: make Enter mean the answer you always give
Auto-run promised to "take the default after five seconds", but every
default was EMPTY: it took nothing. Worse, half the prompts of a
migration are asked by separate processes — the theme uninstaller, the
stale-SCSS detector, the smoke test. They knew nothing of auto-run and
waited forever for a keystroke that never came.

The countdown now lives in one file and travels through the ENVIRONMENT,
the only channel a fork shares. Enter takes the default everywhere, not
only under auto-run: a prompt that prints (Y/n) and does otherwise is
worse than no prompt. Every text was rewritten to say what Enter does,
and every default kept an explicit way out.

Two defaults now write. Both are tenable only because the backup runs
first, and a test locks that ORDER rather than trusting a promise.

--- FR ---

[ADD] migration : faire d'Entrée la réponse qu'on donne toujours

L'auto-exécution promettait « le défaut après cinq secondes », mais tous
les défauts étaient VIDES : elle ne prenait rien. Pire, la moitié des
invites d'une migration sont posées par d'autres processus — le
désinstalleur de thème, le détecteur de SCSS figé, le test de fumée. Ils
ignoraient l'auto-exécution et attendaient sans fin une frappe.

Le compte à rebours tient désormais dans un seul fichier et voyage par
l'ENVIRONNEMENT, le seul canal qu'un fork partage. Entrée vaut le défaut
partout, pas seulement en auto : une invite qui affiche (Y/n) et fait
l'inverse est pire que pas d'invite. Chaque texte dit ce que fait Entrée,
et chaque défaut garde une issue explicite.

Deux défauts écrivent. Ils ne tiennent que parce que la sauvegarde passe
d'abord, et un test verrouille cet ORDRE plutôt qu'une promesse.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
e52d36277b [FIX] database_cleanup: recover the transaction, not the savepoint
One refusal killed seven categories: modules died on « savepoint ...
does not exist », then columns, tables, data, menus, indexes and
properties all reported « current transaction is aborted ». Six
victims that never got to try.

The savepoints were mine and they could not work. The OCA module
commits on its own — purge_columns.py:57 calls cr.commit(), and
purge_modules.find() purges at line 91 before returning. A COMMIT
destroys every savepoint, so ours vanished under our feet and nothing
put the transaction back on its feet.

Each entry is committed as it succeeds, and any failure rolls back.
The dry run rolls back too: find() was writing for real.

--- FR ---

[FIX] database_cleanup : rattraper la transaction, pas le point de reprise

Un seul refus en tuait sept : modules mourait sur « savepoint ... does
not exist », puis colonnes, tables, données, menus, index et
propriétés signalaient tous « current transaction is aborted ». Six
victimes qui n'ont jamais eu leur tour.

Les points de reprise étaient les miens et ne pouvaient pas tenir. Le
module OCA valide de lui-même — purge_columns.py:57 appelle cr.commit(),
et purge_modules.find() purge dès la ligne 91. Or un COMMIT détruit
tout point de reprise : le nôtre disparaissait sous nos pieds, et rien
ne remettait la transaction d'aplomb.

Chaque entrée est validée dès qu'elle réussit, tout échec défait la
sienne. La simulation défait aussi : find() écrivait pour de bon.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
0c0ed07aa8 [FIX] migration: the cleanup installs its own module, and survives a refusal
Three defects, all mine, all found on a real run.

Creating a wizard could fail and leave the transaction ABORTED. The next
name read died on it, outside any guard, and the whole script stopped
with no report at all — only a traceback. Creating and reading the names
now happen inside the savepoint, and a report is printed whatever
happens: knowing what was done matters more than the trace of what
broke.

« No orphaned models found » is a UserError: the module signals the
EMPTY by raising. Counting it as a failure made a healthy database look
broken, four warnings out of five kinds.

The module was installed at step 3, after being used at step 2, so the
first run had no wizard at all and answered « nothing to do ». It is now
installed by the tool itself, and step 3 no longer asks to redo by hand
what has just been done automatically.

--- FR ---

[FIX] migration : le nettoyage pose son module, et survit à un refus

Trois défauts, tous à moi, tous trouvés sur une vraie exécution.

Créer un assistant pouvait échouer en laissant la transaction AVORTÉE.
La lecture de nom suivante mourait dessus, hors de tout garde, et le
script s'arrêtait sans aucun rapport — juste une trace. La création et
la lecture des noms sont désormais dans le point de reprise, et un
rapport est imprimé quoi qu'il arrive : savoir ce qui a été fait vaut
mieux que la trace de ce qui a cassé.

« No orphaned models found » est une UserError : le module signale le
VIDE en levant. Le compter comme un échec faisait passer une base saine
pour cassée, quatre avertissements sur cinq catégories.

Le module était installé à l'étape 3, après avoir servi à l'étape 2 : le
premier passage n'avait donc aucun assistant et répondait « rien à
faire ». L'outil le pose lui-même, et l'étape 3 ne demande plus de
refaire à la main ce qui vient d'être fait.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
cb77394869 [ADD] migration: an auto-run that takes the default after five seconds
A migration asks dozens of questions whose answer is almost always the
one offered, and someone has to stand there pressing Enter for hours.
Asked before the version choice — so it covers that choice too, now
that it has a default — and off by default: it decides in your place.

Every prompt of the migration goes through one reader, seventeen of
them. A prompt left as a bare input() would know nothing of auto mode
and would block the run with nothing to say why; a test reads the
function and rejects any that appears.

click.prompt had to go: it cannot hand back control after a delay, so
auto would have stopped at the first question. select() rather than a
thread or an alarm — a thread would leave a blocked input() behind it,
stealing the next keystroke.

--- FR ---

[ADD] migration : une auto-exécution qui prend le défaut après cinq secondes

Une migration pose des dizaines de questions dont la réponse est presque
toujours celle proposée, et il faut rester devant à taper Entrée pendant
des heures. Posée avant le choix de version — donc elle vaut aussi pour
lui, maintenant qu'il a un défaut — et éteinte par défaut : elle décide
à votre place.

Toutes les invites de la migration passent par une seule lecture,
dix-sept d'un coup. Une invite laissée en input() nu ne saurait rien du
mode auto et bloquerait la migration sans rien dire ; un test lit la
fonction et refuse toute apparition.

click.prompt devait partir : il ne sait pas rendre la main après un
délai, l'auto se serait arrêté à la première question. select() plutôt
qu'un fil ou une alarme — un fil laisserait un input() bloqué derrière
lui, qui volerait la frappe suivante.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
1831b234ea [ADD] migration: run the OCA database cleanup before testing the pages
The eight purges are not independent: purging a model frees the columns
that referenced it, purging a table frees the data pointing at it. One
pass is never enough, so the requested order runs again until a full
pass repairs nothing new.

Refusals are expected — a foreign key still holds, a module says no.
Purging a list at once loses everything to the first one, so each entry
purges inside its own savepoint: a refusal rolls back that entry alone.
What one pass could not take, the next may, once its neighbours are
gone. Leftovers are reported as a warning: a database can carry some
that nothing removes, and stopping there would help no one.

It refuses a version mismatch. Measured while building it: a shell on
Odoo 14 opened against a 17.0 database went rewriting ir_model before
dying on a jsonb it did not know. An older Odoo does not merely fail on
a newer database — it writes on the way.

--- FR ---

[ADD] migration : lancer le nettoyage OCA avant de tester les pages

Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui
la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué
jusqu'à ce qu'une passe entière ne répare plus rien.

Les refus sont attendus — une clé étrangère tient, un module dit non.
Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son
propre point de reprise, un refus n'emporte que la sienne. Ce qu'une
passe n'a pu prendre, la suivante le peut, une fois les voisines
parties. Les restes sont un avertissement : une base peut en porter que
rien ne retire, et s'arrêter là n'aiderait personne.

Il refuse une version qui ne correspond pas. Mesuré en le construisant :
un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model
avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien
n'échoue pas simplement sur une base plus récente — il écrit en chemin.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
efc152bf62 [FIX] migration: name the template when the failure comes from rendering
A QWebException carries no [view_id … parent_id …] block — it names the
template. The tool only read the inheritance context, so it answered
« no parent view named » on the one failure that hit everything.

Measured at bump 17: a frozen copy of website.submenu still called
submenu.clean_url(), renamed _clean_url() in that version. Every page
renders the menu, so 34 of 37 public URLs returned 500, and nothing
pointed at the view. Resetting that one copy brought all 37 back.

Only keys that actually have a COW copy are proposed: naming one
without would send a reset against a module view, which does nothing.

--- FR ---

[FIX] migration : nommer le gabarit quand l'échec vient du rendu

Une QWebException ne porte pas de bloc [view_id … parent_id …] : elle
nomme le gabarit. L'outil ne lisait que le contexte d'héritage, et
répondait donc « aucune vue parente nommée » sur la seule panne qui
touchait tout.

Mesuré au palier 17 : une copie figée de website.submenu appelait
encore submenu.clean_url(), renommée _clean_url() par la version. Toute
page affiche le menu, donc 34 URL publiques sur 37 rendaient 500, sans
que rien ne désigne la vue. Réinitialiser cette seule copie les a
toutes ramenées.

Seules les clés ayant vraiment une copie COW sont proposées : en nommer
une sans copie enverrait réinitialiser une vue module, donc ne rien
faire.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
95ffbe6f13 [ADD] migration: browse the drifted COW copies full screen
The text report puts a thousand lines between the two things that decide:
what the copy holds that the module view does not — all a reset gives up
— and which child no longer finds its anchor, which is why anything
breaks. Space switches between them, « c » copies the reset command.

Offered as [4] on the error prompt, next to check and reset, and run on
the real terminal: a full screen started through the capturing executor
falls back to the text report with nothing to tell the two apart.

The diff is rendered in one place, shared with the command line. Two
renderings would drift without anything saying so.

--- FR ---

[ADD] migration : parcourir les copies COW dérivées en plein écran

Le rapport texte met mille lignes entre les deux choses qui décident :
ce que la copie porte et que la vue module n'a pas — tout ce qu'une
réinitialisation abandonne — et quel enfant ne trouve plus son ancrage,
la raison pour laquelle quoi que ce soit casse. L'espace bascule, « c »
copie la commande de réparation.

Proposé en [4] à l'invite d'erreur, à côté de vérifier et réinitialiser,
et lancé sur le vrai terminal : un plein écran passé par l'exécuteur qui
capture retombe sur le rapport texte, sans rien qui les distingue.

Le diff est rendu à un seul endroit, partagé avec la ligne de commande.
Deux rendus dériveraient sans que rien ne le signale.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
2eac9e9848 [ADD] migration: measure the public URLs before the first bump too
Without a starting point, a page that already answered 500 reads as
damage done by the migration, and the search goes the wrong way for
hours. Measured on the real one: two URLs were already failing before
anything had been migrated.

Asked in step 2, on the database before the bump — not on a bump
database that does not exist yet — and it says what it measures: a page
broken now will still be broken after, and that is not the migration.

--- FR ---

[ADD] migration : mesurer les URL publiques avant le premier palier aussi

Sans point de départ, une page qui rendait déjà 500 se lit comme un
dégât de la migration, et l'on cherche des heures du mauvais côté.
Mesuré sur la vraie : deux URL cassaient avant que quoi que ce soit
n'ait été migré.

Posée à l'étape 2, sur la base d'avant le palier — pas sur une base de
palier qui n'existe pas encore — et elle dit ce qu'elle mesure : une
page cassée maintenant le sera encore après, et ce ne sera pas la
migration.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
ec29781b7a [FIX] migration: reset the copy that is actually stale, and say when a key misses
Answering « all » reset one copy of two and reported success. Two
defects behind it, both mine.

A requested key matching no finding did nothing, silently: the tool
returned early on « nothing drifted » and never looked. Detection is
differential — it only sees a copy whose CHILD breaks — so a stale copy
without children escapes it. A key can now be reset outside detection,
an unknown one is named, and the exit code is 2.

And the culprit is not always the parent. On /contactus the parent was
identical to its module view; the CHILD held the stale arch. Both are
proposed now, parent first — it is the more common case.

Measured on the migration: 33 of 33 public URLs answer.

--- FR ---

[FIX] migration : réinitialiser la copie vraiment périmée, et signaler une clé sans objet

Répondre « toutes » réinitialisait une copie sur deux et annonçait un
succès. Deux défauts derrière, tous deux à moi.

Une clé demandée ne correspondant à aucun constat ne faisait rien, en
silence : l'outil sortait sur « rien n'a dérivé » sans jamais chercher.
La détection est différentielle — elle ne voit qu'une copie dont un
ENFANT casse — donc une copie périmée sans enfant lui échappe. Une clé
peut désormais être réinitialisée hors détection, une clé inconnue est
nommée, et le code de sortie vaut 2.

Et le coupable n'est pas toujours le parent. Sur /contactus, le parent
était identique à sa vue module ; c'est l'ENFANT qui portait l'arch
périmée. Les deux sont proposés, le parent d'abord — le cas le plus
fréquent.

Mesuré sur la migration : 33 URL publiques sur 33 répondent.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
e75ab8b128 [FIX] addons: browse the leftovers by integer, not by the string psql gave
Deleting the leftovers failed on « the database search does not have the
ids (('4457',)) and has extra ids ((4457,)) ». psql returns text; the ids
went into browse() as strings, Odoo compared them against integers, found
nothing and refused the whole batch.

Nothing was lost: the backup runs before the deletion, and the tool said
plainly that nothing was removed. Measured after the fact — 15 files
saved, 15 attachments still in database.

--- FR ---

[FIX] addons : parcourir les restes par entier, pas par la chaîne de psql

L'effacement échouait sur « la recherche en base n'a pas les identifiants
(('4457',)) et a des identifiants supplémentaires ((4457,)) ». psql rend
du texte ; les identifiants partaient dans browse() en chaînes, Odoo les
comparait à des entiers, ne trouvait rien et refusait tout le lot.

Rien n'a été perdu : la sauvegarde précède l'effacement, et l'outil a dit
franchement que rien n'avait été retiré. Vérifié après coup — 15 fichiers
sauvegardés, 15 pièces jointes toujours en base.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
1c0635831f [FIX] migration: never ask a question the terminal cannot show
The theme uninstaller ends by asking what to do with the leftovers, and
the migration ran it through the capturing executor. Its stdout was a
pipe, Python buffers by blocks, so the prompt stayed invisible while the
process waited. It reads as a freeze: you press Enter blind, the first
keystroke answers unseen and the extra ones fall into the next question.

Two locks, because one is not enough. The migration runs the script on
the real terminal. And the three tools that ask now require BOTH ends:
something to read the answer from, and something to show the question
on. Guarding on stdin alone was the bug — measured, stdin tty=True with
stdout tty=False, and the question was asked into a pipe.

--- FR ---

[FIX] migration : ne jamais poser une question que le terminal ne montre pas

Le désinstalleur de thème finit par demander quoi faire des restes, et
la migration le lançait par l'exécuteur qui capture. Sa sortie partait
dans un tube, Python bufferise par blocs, et l'invite restait invisible
pendant l'attente. Cela se lit comme un blocage : on tape Entrée à
l'aveugle, la première frappe répond sans être vue et les suivantes
tombent dans la question d'après.

Deux verrous, car un seul ne suffit pas. La migration lance le script
sur le vrai terminal. Et les trois outils qui questionnent exigent
désormais les DEUX bouts : de quoi lire la réponse, et de quoi montrer
la question. Ne garder que stdin était le défaut — mesuré, stdin
tty=True et stdout tty=False, la question partait dans un tube.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
dc4940089b [UPD] migration: let click carry the default, computed from the menu
click now receives the rank of the highest version, so it prints « [6] »
and returns « 6 » on Enter. The answer follows the normal path — the
special case for an empty line is gone, and with it a second behaviour
to keep in agreement with the first.

The rank is computed, never written down: 6 is where 18.0 sits today,
and one more version in the catalogue moves it. A test replays a
catalogue with 19.0 and expects [7].

--- FR ---

[UPD] migration : laisser click porter le défaut, calculé sur le menu

click reçoit désormais le rang de la version la plus haute : il affiche
« [6] » et rend « 6 » sur Entrée. La réponse suit le chemin normal — le
cas particulier de la ligne vide disparaît, et avec lui un second
comportement à tenir d'accord avec le premier.

Le rang est calculé, jamais écrit : 6 est la place de 18.0 aujourd'hui,
et une version de plus au catalogue la déplace. Un test rejoue un
catalogue avec 19.0 et attend [7].

Assisted-by: Claude Opus 5
(cherry picked from commit 5f0b0110d610187298e0129e45cef28e14bba092)
2026-08-22 07:23:59 -04:00
c67324a223 [ADD] migration: Enter targets the highest supported Odoo version
Migrating means going all the way, and that choice had to be typed
every time. Enter did nothing visible: click.prompt without a default
re-asks on an empty line without printing anything, so a default added
without `default=""` would never have been reached and nothing would
have said so.

The highest is computed, not read off the end of the list: display
order is not a guarantee, and « 9.0 » sorts after « 18.0 » as a string.
The prompt announces it — a default nobody sees is a default nobody
uses. No choice at all means no default rather than a crash.

--- FR ---

[ADD] migration : Entrée vise la version Odoo la plus élevée

Migrer, c'est aller au bout, et ce choix devait être tapé chaque fois.
Entrée ne faisait rien de visible : click.prompt sans défaut redemande
sur une ligne vide sans rien afficher, si bien qu'un défaut ajouté sans
`default=""` n'aurait jamais été atteint, sans que rien ne le signale.

La plus haute est calculée, pas lue au bout de la liste : l'ordre
d'affichage n'est pas une garantie, et « 9.0 » se trie après « 18.0 »
en chaînes. L'invite l'annonce — un défaut qu'on ne montre pas est un
défaut que personne n'utilise. Aucun choix possible ne donne aucun
défaut plutôt qu'une erreur.

Assisted-by: Claude Opus 5
(cherry picked from commit f0861363f496cdc3c0bb02216d30dd0aa8d3697b)
2026-08-22 07:23:59 -04:00
bc52a93e67 [ADD] migration: name the views behind a failing URL, and offer the reset
The smoke test stopped at « these URLs answer 500 ». Turning that into a
fix meant reading an id out of a traceback and translating it to a key
by hand, mid-migration — the copy where a character goes missing.

It now reads the error context Odoo logs — [view_id: N, parent_id: M],
the one line it does not translate — resolves the parent to its key,
offers the reset numbered with « all », and re-requests the failing URLs
afterwards. Applying without re-asking would be calling it fixed
without having seen it answer.

Two defects found while measuring, both mine. ./run.sh is a bash
wrapper: terminate() killed it and left odoo-bin holding the port, six
orphans in six runs, each later run silently querying the first one's
server. And the log was read before stopping, so only the 24 startup
lines existed — hence « no view in cause » on pages that named one.

--- FR ---

[ADD] migration : nommer les vues derrière une URL en échec, et corriger

Le test s'arrêtait à « ces URL répondent 500 ». En faire un correctif
demandait de relever un id dans une trace et de le traduire en clé à la
main, en pleine migration — la recopie où un caractère se perd.

Il lit désormais le contexte qu'Odoo journalise — [view_id: N,
parent_id: M], la seule ligne qu'il ne traduit pas —, résout le parent
en clé, propose la réinitialisation numérotée avec « toutes », et
redemande ensuite les URL en échec. Appliquer sans redemander, ce
serait déclarer réparé sans l'avoir vu répondre.

Deux défauts trouvés en mesurant, tous deux à moi. ./run.sh est une
enveloppe bash : terminate() la tuait et laissait odoo-bin tenir le
port, six orphelins en six essais, chaque essai suivant interrogeant
sans le savoir le serveur du premier. Et le journal était lu avant
l'arrêt, donc seules les 24 lignes de démarrage existaient — d'où
« aucune vue en cause » sur des pages qui en nommaient une.

Assisted-by: Claude Opus 5
(cherry picked from commit 30fbcd1aee20c4028ea6b24bd04eb518f0587ffe)
2026-08-22 07:23:59 -04:00
c65db269ed [ADD] migration: request every public URL before calling it done
A migration can load every module, log nothing, and still serve 500s on
pages nobody thought to open. Measured on the real one: 2 of 33 public
URLs failed — a blog post and /contactus — after a bump the log called
successful.

The list is the sitemap, what Odoo publishes for search engines. It
cannot be read from odoo-bin shell: enumerate_pages() asks for
http.root.get_db_router(request.db) and raises « object unbound »
without a real request. So the server is started on its own port,
asked, and always stopped — a forgotten one holds the port and fails
the next bump.

Offered before the Selenium prompt, default no: it boots a server and
can take minutes.

--- FR ---

[ADD] migration : interroger chaque URL publique avant de conclure

Une migration peut charger tous ses modules, ne rien écrire au journal,
et servir quand même des 500 sur des pages que personne n'ouvre.
Mesuré : 2 URL publiques sur 33 échouaient — un billet de blogue et
/contactus — après un palier que le journal disait réussi.

La liste est le sitemap, celle qu'Odoo publie pour les moteurs. Elle ne
se lit pas depuis odoo-bin shell : enumerate_pages() réclame
http.root.get_db_router(request.db) et lève « object unbound » sans
requête réelle. Le serveur est donc démarré sur son propre port,
interrogé, et toujours arrêté — un serveur oublié tient le port et fait
échouer le palier suivant.

Proposé avant l'invite Selenium, par défaut non : cela démarre un
serveur et peut durer.

Assisted-by: Claude Opus 5
(cherry picked from commit 277e85b3d06173beeb92be792467751b31ac0696)
2026-08-22 07:23:59 -04:00
ec6ac3a188 [FIX] addons: stop calling a leftover report a failed command
« Command returned error code: 1 » followed a theme that was removed
correctly. 1 is this toolkit's « there are findings », but the report
was the uninstaller's last command, so its code became the script's;
and the COW check was still read through the capturing executor, which
announces any non-zero code as an error.

The leftovers can now be dealt with instead of only listed: keep by
default, or delete after their content is written to private/. Listing
fifteen attachments and stopping there meant composing an unlink() by
hand, mid-migration, from identifiers read off a screen.

--- FR ---

[FIX] addons : cesser d'appeler « erreur » un rapport de restes

« Command returned error code: 1 » suivait un thème correctement
retiré. 1 veut dire « il y a des constats » dans cet outillage, mais le
rapport était la dernière commande du désinstalleur, donc son code
devenait celui du script ; et la vérification COW passait encore par
l'exécuteur qui capture, lequel annonce tout code non nul comme une
erreur.

Les restes peuvent désormais être traités, pas seulement listés :
garder par défaut, ou effacer après écriture de leur contenu sous
private/. Lister quinze pièces jointes et s'arrêter là revenait à faire
composer un unlink() à la main, en pleine migration.

Assisted-by: Claude Opus 5
(cherry picked from commit 304ce32b2680e3d01e38c3224bb85c2798cd6f53)
2026-08-22 07:23:59 -04:00
7e74455d63 [ADD] migration: pick the COW copy to reset from a list, not from memory
The error prompt printed « --reset <key> --apply » and left the key to
be found in a thousand-line diff. Copying it by hand is where a
character goes missing, and a key matching no copy is not an error for
the tool: the command runs and does nothing, silently.

[3] now asks the tool for the keys, numbers them, and offers « all ».
An out-of-range or unreadable answer resets nothing — falling back to
« all » would act on what was never asked for.

--- FR ---

[ADD] migration : choisir la copie COW à réinitialiser dans une liste

L'invite d'erreur imprimait « --reset <key> --apply » et laissait
retrouver la clé dans un diff de mille lignes. La recopier à la main
est l'endroit où un caractère se perd, et une clé ne correspondant à
aucune copie n'est pas une erreur pour l'outil : la commande tourne et
ne fait rien, en silence.

[3] demande désormais les clés à l'outil, les numérote et offre
« toutes ». Une réponse hors liste ou illisible ne réinitialise rien —
retomber sur « toutes » agirait sur ce qui n'a pas été demandé.

Assisted-by: Claude Opus 5
(cherry picked from commit ae08e13fe5bba2b02b8661c67c4024fcd79f29bc)
2026-08-22 07:23:59 -04:00
5c4d71e7a2 [FIX] migration: reset the stale SCSS after the bump, not before
The fix was offered while the checkout was still on the previous
version. Answering « a » ran reset_asset in an Odoo 12 shell, which has
no web_editor.assets: KeyError, nothing changed, and the migration went
on to break at the next bump — measured on a real run.

Predicting early is right; fixing early is not. The early call is now
--report-only, and a second call comes after the bump, on the upgraded
database, where the checkout can do it. The tool also refuses on its
own: it reads the checkout sources for reset_asset rather than trusting
a version number, and says when to come back.

--- FR ---

[FIX] migration : réinitialiser le SCSS périmé après le palier, pas avant

La correction était proposée alors que le checkout était encore sur la
version précédente. Répondre « a » lançait reset_asset dans un shell
Odoo 12, sans web_editor.assets : KeyError, rien de modifié, et la
migration continuait jusqu'à casser au palier suivant. Mesuré.

Prédire tôt est juste ; corriger tôt ne l'est pas. L'appel précoce est
désormais --report-only, et un second vient après le palier, sur la
base montée de version, là où le checkout sait le faire. L'outil refuse
aussi de lui-même : il cherche reset_asset dans les sources du checkout
plutôt que de se fier à un numéro, et dit quand revenir.

Assisted-by: Claude Opus 5
(cherry picked from commit 13d4d0a33c63efcbafb5fb29313a5377ba5f508e)
2026-08-22 07:23:59 -04:00
26de9a109f [ADD] migration: read the stale SCSS, then fix it, without leaving the tool
The report named the attachment and printed a command to paste. Deciding
« reset it » still meant accepting to lose one did not know what: the
diff against the module file is the only thing resetting gives up.

The tool now shows it and asks. --diff prints it, --tui browses it,
--apply resets without asking; with no flag and a terminal it asks, and
looking does not answer — the prompt comes back after each read.
--apply writes the copies under private/ first: reset_asset deletes the
attachment, so without that the customized lines would be nowhere.

The migration runs it on the real terminal, or none of this would be
reachable from there — the same pipe that was closing the TUI.

--- FR ---

[ADD] migration : lire le SCSS périmé, puis le corriger, sans sortir

Le rapport nommait la pièce jointe et imprimait une commande à coller.
Répondre « réinitialise » revenait encore à accepter de perdre on ne
sait quoi : l'écart avec le fichier du module est la seule chose que la
réinitialisation abandonne.

L'outil le montre et pose la question. --diff l'imprime, --tui le
parcourt, --apply réinitialise sans demander ; sans drapeau et devant
un terminal il demande, et regarder ne répond pas — l'invite revient
après chaque lecture. --apply écrit d'abord les copies sous private/ :
reset_asset supprime la pièce jointe, sans quoi les lignes
personnalisées ne seraient plus nulle part.

La migration le lance sur le vrai terminal, sinon rien de tout cela n'y
serait atteignable — le tube même qui fermait la TUI.

Assisted-by: Claude Opus 5
(cherry picked from commit 9e6c88622a74f7be408a8b1c523838eeadda1da3)
2026-08-22 07:23:59 -04:00
4bc82b4caa [ADD] migration: predict which customized SCSS the next bump breaks
Customized SCSS lives in ir_attachment, frozen the day it is written,
still using the variables of that version. A bump can rename them:
website declared $o-theme-font-number in 12.0 and replaced the whole
mechanism in 13.0, so a 2020 customization stopped the frontend
bundle. Same shape as a stale COW view.

Reading the stored SCSS against the target sources answers before the
bump and names the attachment. Booting Odoo and opening a page answers
after, with « Style error » and no name — on a page a broken frontend
bundle is exactly what keeps you from reaching.

Measured: on the pre-bump database, targeting 13.0, it predicts the
failure; against its own 12.0 sources it reports nothing. That noise
floor is what makes it usable — the first version cried wolf on six
mixin parameters and named @include arguments.

--- FR ---

[ADD] migration : prédire quel SCSS personnalisé le palier va casser

Un SCSS personnalisé vit dans ir_attachment, figé le jour où il est
écrit, employant encore les variables de cette version-là. Un palier
peut les renommer : website déclarait $o-theme-font-number en 12.0 et a
remplacé le mécanisme en 13.0, arrêtant le bundle sur une
personnalisation de 2020. Même forme qu'une vue COW périmée.

Lire le SCSS stocké contre les sources cibles répond AVANT le palier et
nomme la pièce jointe. Démarrer Odoo et ouvrir une page répond après,
par « Style error » sans nom — et un bundle cassé est justement ce qui
empêche d'atteindre la page.

Mesuré : sur la base d'avant le palier, cible 13.0, il prédit la
panne ; contre ses propres sources 12.0 il ne signale rien. Ce bruit de
fond nul est ce qui le rend utilisable — la première version criait à
tort sur six paramètres de mixin et arguments nommés.

Assisted-by: Claude Opus 5
(cherry picked from commit c6611ee1431cfee94e321536d20052b8cca3ec10)
2026-08-22 07:23:59 -04:00
3465bfdd19 [ADD] migration: offer to remove the themes before the first bump
A theme carries view copies and SCSS through every version bump, and a
bump can rename what they rely on. Removing it first drops a whole
family of failures, and it can be put back afterwards.

Asked in step 1, before OpenUpgrade runs, and only when a theme is
actually installed. Default is no: removing a theme changes how a site
looks, and a migration does not decide that for its owner.
theme_default is not offered — it IS the absence of a theme.

--- FR ---

[ADD] migration : proposer de retirer les thèmes avant le premier palier

Un thème traîne des copies de vues et des SCSS à travers chaque palier
de version, et un palier peut renommer ce dont ils dépendent. Le
retirer d'abord enlève une famille entière de pannes, et se refait
ensuite.

Posée à l'étape 1, avant OpenUpgrade, et seulement s'il y a vraiment un
thème installé. Par défaut non : retirer un thème change l'apparence
d'un site, et ce n'est pas à une migration de trancher cela à la place
de son propriétaire. theme_default n'est pas proposé — il EST
l'absence de thème.

Assisted-by: Claude Opus 5
(cherry picked from commit dcbee87a6b920408c315f7354a096b0ecfabcf06)
2026-08-22 07:23:59 -04:00
aa2f78c404 [ADD] addons: uninstall a theme the way Odoo removes one
There was no counterpart to install_addons_theme.sh, so themes were
removed with a plain --uninstall. That takes out the module, not the
theme: it skips _theme_remove(), whose first act is
_reset_default_config() — the call that writes font-number and its
three neighbours into user_values.scss.

Measured on a real 12 -> 13 migration: web.assets_frontend stopped on
« Undefined variable: $o-theme-font-number ». Odoo 12 defined it in
option_font_body_*, dropped in 13.0; only the theme still redefined
it, so removing the theme exposed a frozen 2020 customization.

theme_leftover.py then reports what unloading does not take — 15
attachments on the measured database. It deletes nothing: their
content may be the only trace of a customization.

--- FR ---

[ADD] addons : désinstaller un thème comme Odoo le retire

install_addons_theme.sh n'avait pas de symétrique : on retirait donc
les thèmes par un --uninstall nu. Cela enlève le module, pas le thème
— cela saute _theme_remove(), dont le premier geste est
_reset_default_config(), l'appel qui écrit font-number et ses trois
voisines dans user_values.scss.

Mesuré sur une vraie migration 12 → 13 : web.assets_frontend s'arrête
sur « Undefined variable: $o-theme-font-number ». Odoo 12 la
définissait dans option_font_body_*, supprimés en 13.0 ; seul le thème
la redéfinissait, et le retirer a mis à nu un SCSS figé en 2020.

theme_leftover.py signale ensuite ce que le déchargement ne prend pas
— 15 pièces jointes sur la base mesurée. Il ne supprime rien : leur
contenu peut être la seule trace d'une personnalisation.

Assisted-by: Claude Opus 5
(cherry picked from commit 16a7b1e1333752e2ef603d66f47374a22590d665)
2026-08-22 07:23:59 -04:00
0870696701 [FIX] script: make the analysis and migration tools executable
Nine of the thirteen carried a shebang without the bit, so only four
could be run by their own path. Set to 755, not chmod +x: two were
664 under the 0002 umask and would have become 775 — executed by
others while a group member could still rewrite them. That pairing is
the only real risk here; the bit alone grants nothing, since reading
the file is enough to run python3 on it.

Being runnable opens a path without the venv: the shebang resolves to
the system python3, which has no Textual. --tui fell back to the text
report saying nothing. It now names the interpreter and the venv.

--- FR ---

[FIX] script : rendre exécutables les outils d'analyse et de migration

Neuf des treize portaient un shebang sans le bit ; quatre seulement
se lançaient par leur chemin. Mis à 755, pas chmod +x : deux étaient
en 664 sous l'umask 0002 et seraient passés à 775 — exécutés par
d'autres alors qu'un membre du groupe pouvait encore les réécrire.
C'est la seule vraie prise ici ; le bit seul n'accorde rien, lire le
fichier suffit déjà à lancer python3 dessus.

Devenir lançable ouvre un chemin sans le venv : le shebang résout le
python du système, sans Textual. --tui retombait sur le rapport texte
sans rien dire. Il nomme désormais l'interpréteur et le venv.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
d2c5d92d11 [FIX] migration: the COW tools speak the system language
They run as subprocesses and had no i18n, so a French migration alternated
languages from one line to the next. The database upgrade itself had the
same gap.

The driver read English sentences out of their output to decide whether to
ask its question. Translating them would have made it mute -- no error, no
trace. The link is now the exit code, which no language touches: 0
nothing, 1 copies concerned, 2 the tool failed.

87 strings, five tools. A test rejects any displayed sentence that skips
t(), and proves itself on an untranslated one.

--- FR ---

Ils tournent en sous-processus et n'avaient aucun i18n : une migration en
français alternait les deux langues d'une ligne à l'autre. La mise à
niveau de la base elle-même souffrait du même manque.

Le pilote lisait des phrases anglaises dans leur sortie pour décider de
poser sa question. Les traduire l'aurait rendu muet — sans erreur, sans
trace. Le lien est désormais le code de sortie, qu'aucune langue ne
touche : 0 rien, 1 copies concernées, 2 l'outil a échoué.

87 chaînes, cinq outils. Un test refuse toute phrase affichée qui saute
t(), et fait sa preuve sur une chaîne non traduite.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
614f539489 [ADD] migration: act on the COW copies when they are announced
Step 2 announced the copies the next bump will break, invited to
arbitrate, then moved on: the question came at the bump, tens of minutes
later. Yet looking writes nothing, and neutralizing here covers every bump
-- each bump database is a clone of this one.

The announcement is now a prompt: view what a copy holds, why it breaks,
full screen, or neutralize right away. Both prompts build their command in
one place, so they cannot drift apart.

Full screen means a real terminal: the viewer was started without one and
drew nothing, the pager exiting at once on a pipe.

--- FR ---

L'étape 2 annonçait les copies que le palier suivant casserait, invitait à
arbitrer, puis passait : la question venait au palier, des dizaines de
minutes plus tard. Or regarder n'écrit rien, et neutraliser ici vaut pour
tous les paliers — chaque base de palier est un clone de celle-ci.

L'annonce est désormais une invite : voir ce que porte une copie, pourquoi
elle casse, en plein écran, ou neutraliser tout de suite. Les deux invites
composent leur commande au même endroit, elles ne peuvent donc pas
diverger.

Le plein écran suppose un vrai terminal : la vue était lancée sans, et ne
dessinait rien, le pagineur sortant aussitôt sur un tube.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
bcdc19b506 [ADD] migration: offer « go back to a step » on the resume screen
The screen could already replay from any step -- Enter on a row does it --
but that lived in a hint line under four named buttons. A capability you
have to guess is not offered, and it was reported as missing. It now has a
key, a button and a message, like the four others.

The b key added earlier went to the line-by-line prompts during the run,
not to this screen. Same letter on both, so going back is one key wherever
you are.

Two defects behind it. Replaying the chosen step actually SKIPPED it,
resuming at the next one. And a replayed step did not rewind the variables
it owns, so the second run started from the first one's leftovers.

Checked in a simulated terminal: b focuses the table, two arrows and Enter
return step 2, and the button focuses without choosing.

--- FR ---

L'écran savait déjà rejouer depuis n'importe quelle étape — Entrée sur une
rangée le fait — mais cela vivait dans une ligne d'indication sous quatre
boutons nommés. Une capacité qu'il faut deviner n'est pas offerte, et elle
a été signalée manquante. Elle a maintenant une touche, un bouton et un
message, comme les quatre autres.

La touche b ajoutée plus tôt s'adressait aux invites ligne à ligne pendant
l'exécution, pas à cet écran. Même lettre sur les deux : revenir en
arrière est une seule touche, où qu'on soit.

Deux défauts derrière cela. Rejouer l'étape choisie la SAUTAIT en fait,
reprenant à la suivante. Et une étape rejouée ne remettait pas à zéro les
variables qui lui appartiennent, si bien que la seconde exécution partait
des restes de la première.

Vérifié en terminal simulé : b donne le focus au tableau, deux flèches et
Entrée ramènent l'étape 2, et le bouton met le focus sans choisir.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
95532ec96f [ADD] migration: go back a step from a prompt, and name the DB
Realising at a prompt that an earlier step deserved another answer had one
way out: Ctrl+C. That leaves the progression as it stands and makes you find
the resume screen again to rewind. « b » does it properly — it shows the
steps, rewinds the state, writes it, and says what to relaunch.

Cancelling that must not stop the migration, which is the trap: it returns to
the same prompt, exactly where you were. Removing that guard makes two tests
fail.

The COW warning printed « -d DB -t odooXX.0 » for commands meant to be pasted.
It knows both values, so it prints them, and offers --shape as well since that
is half the answer.

Checked on the four outcomes: a normal answer passes through, « b » then a
step rewinds and stops, cancelling and an unknown step both continue. 15 tests.

--- FR ---

S'apercevoir à une invite qu'une étape antérieure méritait un autre choix
n'avait qu'une issue : Ctrl+C. Cela laisse la progression telle quelle et
oblige à retrouver l'écran de reprise pour rembobiner. « b » le fait
proprement — il montre les étapes, rembobine l'état, l'écrit, et dit quoi
relancer.

Y renoncer ne doit pas arrêter la migration, et c'est le piège : on revient à
la même invite, exactement là où l'on était. Retirer ce garde-fou fait tomber
deux tests.

L'avertissement COW affichait « -d DB -t odooXX.0 » pour des commandes faites
pour être collées. Il connaît les deux valeurs, donc il les écrit, et propose
aussi --shape puisque c'est la moitié de la réponse.

Vérifié sur les quatre issues : une réponse normale passe, « b » puis une
étape rembobine et arrête, annuler et un choix inconnu continuent. 15 tests.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
4ce612b008 [IMP] migration: say when the COW question is actually asked
An early check warns, hours before the bump, that a copy will break it. It
then says « arbitrate BEFORE launching the migration » and hands over a raw
UPDATE — and no question follows. Read on a resumed run it looks like the
question already went by and was skipped, which is exactly how it was read.

It now says the migration asks at the bump itself, and shows what each copy
holds before the answer. The manual UPDATE stays, but after the two commands
that do it reversibly: telling someone to hand-write SQL when --restore exists
is offering the sharper tool first.

--- FR ---

Un contrôle précoce prévient, des heures avant le palier, qu'une copie va le
casser. Il dit ensuite « arbitrez AVANT de lancer la migration » et livre un
UPDATE brut — et aucune question ne suit. Lu au cours d'une reprise, on croit
que la question est passée et a été sautée, ce qui est exactement la lecture
qui en a été faite.

Il dit maintenant que la migration pose la question au palier lui-même, en
montrant ce que chaque copie contient avant qu'on réponde. L'UPDATE manuel
reste, mais après les deux commandes qui le font de façon réversible : dire à
quelqu'un d'écrire du SQL à la main quand --restore existe, c'est tendre
l'outil le plus coupant en premier.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
fcfbcd25ce [IMP] migration: say why Odoo changing its own template is our problem
The declarations view showed a 12.0 template becoming a 13.0 extension and
stopped there. Read alone it says « Odoo changed its code », and the obvious
reaction is to ask why that is anyone's problem — which is exactly what it
prompted.

It is not the problem. On a database without a copy the module upgrade
rewrites the view and nothing breaks. It breaks because a COPY exists and
froze the old shape. That sentence was missing, and so was the other half of
the answer: what the copy actually holds. A copy identical to the view it
shadows costs nothing to neutralize; one carrying five lines of theme hooks
costs those five lines. Both are now stated where the question arises.

Checked on the database mid-migration: the copy that raised the question
reports +5/-3, and the two other cases — identical to its twin, and a page
with no twin at all — each say so.

--- FR ---

La vue des déclarations montrait un gabarit 12.0 devenant une extension 13.0
et s'arrêtait là. Lue seule, elle dit « Odoo a changé son code », et la
réaction naturelle est de demander en quoi cela nous regarde — ce qu'elle a
justement provoqué.

Ce n'est pas le problème. Sur une base sans copie, la mise à jour du module
réécrit la vue et rien ne casse. Ça casse parce qu'une COPIE existe et a figé
l'ancienne forme. Cette phrase manquait, et l'autre moitié de la réponse
aussi : ce que la copie contient réellement. Une copie identique à la vue
qu'elle masque ne coûte rien à neutraliser ; une qui porte cinq lignes
d'ancres de thème coûte ces cinq lignes. Les deux sont dites là où la question
se pose.

Vérifié sur la base en cours de migration : la copie qui a soulevé la question
rapporte +5/-3, et les deux autres cas — identique à sa jumelle, et page sans
jumelle — le disent chacun.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00
98be22cb77 [FIX] migration: updating the addons early left step 2 « not started »
A database coming from an old version is updated before the neutralization —
the tool offers it at the very start. That work IS step 2, done earlier, but
it was only recorded as state_1_update_all while the screen reads state_2_*.
So the step stayed « not started » right after running.

The costly half was invisible. Step 2 skipped the work through a local
variable, which is False again on a resume: with only the old flag written,
resuming a migration re-ran update_addons_all on an already-updated database.
Hours, for nothing. The check now reads the written trace, not the session.

The screen says when it happened rather than just « done », since the moment
is exactly what raised the doubt. Logs written before this fix read correctly
without being touched — the early flag alone is recognised.

Checked on the real log from the VM: step 2 goes from « not started » to
« done early, before the neutralization », the four other steps unchanged.
10 tests; removing the fix makes the resume one fail.

--- FR ---

Une base venant d'une vieille version se met à jour avant la neutralisation —
l'outil le propose au tout début. Ce travail EST l'étape 2, faite plus tôt,
mais il n'était enregistré que sous state_1_update_all quand l'écran lit
state_2_*. L'étape restait donc « non démarrée » juste après avoir tourné.

La moitié coûteuse était invisible. L'étape 2 sautait le travail grâce à une
variable locale, remise à False à la reprise : avec seulement l'ancien drapeau
écrit, reprendre une migration relançait update_addons_all sur une base déjà à
jour. Des heures, pour rien. Le contrôle lit maintenant la trace écrite, pas
la session.

L'écran dit quand cela a eu lieu plutôt que « terminée », le moment étant
justement la source du doute. Les journaux écrits avant ce correctif se lisent
correctement sans être modifiés — le drapeau précoce seul est reconnu.

Vérifié sur le vrai journal de la VM : l'étape 2 passe de « non démarrée » à
« faite plus tôt, avant la neutralisation », les quatre autres inchangées.
10 tests ; retirer le correctif fait tomber celui de la reprise.

Assisted-by: Claude Opus 5
2026-08-17 01:25:23 -04:00
4b6de55f30 [ADD] migration: keep the previous log when a run restarts
The progression log lives at one path and the next migration writes over it.
Restarting therefore erased everything known about the run before — which
steps passed, which modules were missing, how long each took — and that is
exactly what someone looks for after having had to restart.

Both answers that discard the progression now copy it aside first, named after
the origin database and the moment of the copy, so two attempts on the same
database do not cover each other and a file moved out of its folder still says
what it is. Partial replays keep the log, so they archive nothing.

It lands under private/, not beside the original: reinstalling wipes
.venv.erplibre/ and would take the history with it.

Two copies in the same second overwrote each other in silence — undoing the
very loss this prevents. The second one is numbered now, and the test that
had skipped that case asserts it.

Checked on the real 51 KB log from the VM: 18 states copied unchanged, the
name carries the database and the timestamp, and a log with no state at all is
not archived. 12 tests.

--- FR ---

Le journal de progression vit à un seul endroit et la migration suivante écrit
par-dessus. Recommencer effaçait donc tout ce qu'on savait de la tentative
précédente — quels paliers étaient passés, quels modules manquaient, combien
de temps chacun avait pris — soit exactement ce qu'on cherche après avoir dû
recommencer.

Les deux réponses qui jettent la progression la copient désormais d'abord,
sous un nom portant la base d'origine et l'instant de la copie, pour que deux
tentatives sur la même base ne se recouvrent pas et qu'un fichier sorti de son
dossier se décrive encore. Une reprise partielle garde le journal, donc
n'archive rien.

La copie va sous private/, pas à côté de l'originale : une réinstallation
efface .venv.erplibre/ et emporterait l'historique avec elle.

Deux copies dans la même seconde s'écrasaient en silence — annulant la perte
même qu'on évite. La seconde est numérotée, et le test qui sautait ce cas
l'affirme.

Vérifié sur le vrai journal de 51 Ko de la VM : 18 états copiés à l'identique,
le nom porte la base et l'horodatage, et un journal sans aucun état n'est pas
archivé. 12 tests.

Assisted-by: Claude Opus 5
2026-08-17 01:25:23 -04:00
76fb9198cb [ADD] migration: look at a COW copy before agreeing to give it up
The prompt asked whether to neutralize copies without showing what they hold.
Answering meant giving up a customization sight unseen — often three lines, an
id and a container width, sometimes a whole page, and nothing told them apart.

« v » now shows both halves of the question. What the copy changed, diffed
against the module view it shadows: on the database at hand, 5 lines added and
3 removed, two CSS anchors and a container width. And why it breaks, by
showing the declaration in each version — portal.frontend_layout is a
standalone template in 12.0 and inheritance specs in 13.0, which is the whole
explanation. « w » is the same two, full screen, space to switch.

The current version comes from ir_module_module, not .odoo-version: the
checkout is switched to the target before this runs, so reading the file
compared the target with itself and printed the same declaration twice. That
is what it did until it was run against a real migration.

--list says which copies a past neutralization put aside. A renamed key is
invisible in the interface, so without it the only trace was remembering.

Checked on the VM mid-migration: both views on view 2670, the screen builds
headless and toggles, --list reports and reports nothing when there is
nothing. A missing database now exits 2 with a message instead of a traceback.

--- FR ---

L'invite demandait de neutraliser des copies sans montrer ce qu'elles
contiennent. Répondre revenait à renoncer à une personnalisation sans l'avoir
vue — souvent trois lignes, un id et une largeur de conteneur, parfois une
page entière, et rien ne les distinguait.

« v » montre désormais les deux moitiés de la question. Ce que la copie a
changé, comparé à la vue de module qu'elle masque : sur la base en cours, 5
lignes ajoutées et 3 retirées, deux ancres CSS et une largeur. Et pourquoi ça
casse, en affichant la déclaration dans chaque version —
portal.frontend_layout est un gabarit autonome en 12.0 et des consignes
d'héritage en 13.0, ce qui est toute l'explication. « w » donne les deux en
plein écran, espace pour basculer.

La version courante vient d'ir_module_module, pas de .odoo-version : le
checkout est basculé sur la cible avant cette étape, donc lire le fichier
comparait la cible avec elle-même et affichait deux fois la même déclaration.
C'est ce qu'il faisait jusqu'à l'essai sur une vraie migration.

--list dit quelles copies une neutralisation passée a mises de côté. Une clé
renommée est invisible dans l'interface ; sans cela, la seule trace était de
s'en souvenir.

Vérifié sur la VM en cours de migration : les deux vues sur la vue 2670,
l'écran se construit sans terminal et bascule, --list rapporte et ne rapporte
rien quand il n'y a rien. Une base absente sort en 2 avec un message plutôt
qu'une trace d'appel.

Assisted-by: Claude Opus 5
2026-08-17 01:25:23 -04:00
f97e27fccd [ADD] tui qemu: finer presets for vCPU, RAM and disk
The gap between 4 and 8 GB was the first one worth filling -- it is the
usual size of an ERPLibre development VM -- but every neighbouring gap
had the same fault: the lists jumped precisely where development VMs
live.

RAM now offers every gigabyte up to 16, and vCPU every count up to 16.
Disk gains 30, 50, 100 and 160 G, the steps missing between those already
there. Above 16 nothing changes: those jumps are deliberate, a machine
that large not being sized by picking from a list.

A typed value was already accepted in both interfaces, so these are a
convenience, never a constraint.

--- FR ---

L'écart entre 4 et 8 Go était le premier à combler — c'est la taille
courante d'une VM de développement ERPLibre — mais tous les écarts
voisins avaient le même défaut : les listes sautaient précisément là où
vivent les VM de développement.

La RAM offre désormais chaque gigaoctet jusqu'à 16, et les vCPU chaque
valeur jusqu'à 16. Le disque gagne 30, 50, 100 et 160 G, les paliers qui
manquaient entre ceux déjà présents. Au-delà de 16, rien ne change : ces
sauts sont voulus, une machine de cette taille ne se dimensionne pas en
piochant dans une liste.

Une valeur tapée était déjà acceptée dans les deux interfaces : ce sont
donc des commodités, jamais une contrainte.

Assisted-by: Claude Opus 5
2026-08-17 00:40:01 -04:00
aaf02c4ee6 [ADD] tui qemu: F9 dumps the form state, widgets and model
A per-VM setting appears not to be taken into account, and nothing on
screen tells which link failed: the gap may be between the widget and
the model, or between the model and the spec. A screenshot shows
neither.

F9 writes both side by side into ~/.erplibre/deploy-form-dump.txt:
profile, shared values, overrides, locks, row generation, then VM by VM
what the list DISPLAYS against what the model HOLDS, and finally the
spec that would go to deployment.

The file can be pasted into a message, unlike an image, and it answers
the question on its own: if the list shows 16384 while the model says
1024, the defect is in the intake; if they agree and the created VM
differs, it is downstream, in deploy_qemu.

--- FR ---

Un réglage par VM ne semble pas pris en compte, et rien dans ce que
l'écran montre ne permet de trancher : l'écart peut être entre le widget
et le modèle, ou entre le modèle et la spec. Une capture d'écran ne dit
ni l'un ni l'autre.

F9 écrit les deux côte à côte dans ~/.erplibre/deploy-form-dump.txt :
profil, valeurs communes, surcharges, verrous, génération des rangées,
puis VM par VM ce que la liste AFFICHE contre ce que le modèle CONTIENT,
et enfin la spec qui partirait au déploiement.

Le fichier se recopie dans un message, contrairement à une image, et il
répond seul à la question : si la liste montre 16384 et que le modèle
dit 1024, le défaut est dans la prise en compte ; s'ils s'accordent et
que la VM créée diffère, il est en aval, dans deploy_qemu.

Assisted-by: Claude Opus 5
2026-08-17 00:40:01 -04:00
75498384c9 [ADD] qemu: build the tunnel to the remote desktop
A "Remote desktop tunnel" entry under SSH configuration. It lists the
targets, resolves them, and composes the full command -- nothing to fill
in.

todo.py runs on the libvirt HOST; the tunnel starts from the workstation.
It cannot open it, but it alone knows the VM's private IP, its port and
the address through which it was reached. That last one comes from
SSH_CONNECTION, whose third field is exactly the server address used --
far safer than a "hostname" that may resolve to nothing outside.

When ~/.ssh/config already carries the entry, the tunnel takes it:
"ssh -N -L 5902:localhost:5901 <vm>". ProxyJump makes the route and
"localhost" means the VM's OWN loopback, so the tunnel survives an IP
change. That config is also the right source of targets, not the local
libvirt: a graphical VM is often nested, and virsh would only ever list
the orchestrator.

Two details: the hypervisor console needs VNC bound to the loopback, as
"listen=none" opens no socket at all; and two menu entries had no icon.

--- FR ---

Une entrée « Tunnel bureau distant » sous Configuration SSH. Elle liste
les cibles, les résout, et compose la commande complète — rien à remplir.

todo.py tourne sur l'HÔTE libvirt ; le tunnel, lui, part du poste de
travail. Il ne peut donc pas l'ouvrir, mais il est le seul à connaître
l'IP privée de la VM, son port et l'adresse par laquelle on l'a joint.
Cette dernière vient de SSH_CONNECTION, dont le troisième champ est
exactement l'adresse serveur utilisée — bien plus sûr qu'un « hostname »
qui peut ne rien résoudre depuis l'extérieur.

Quand ~/.ssh/config porte déjà l'entrée, le tunnel l'emprunte :
« ssh -N -L 5902:localhost:5901 <vm> ». Le ProxyJump fait la route et
« localhost » désigne le bouclage DE LA VM, si bien que le tunnel survit à
un changement d'IP. Ce fichier est aussi la bonne source de cibles, pas le
libvirt local : une VM graphique est souvent imbriquée, et virsh ne
listerait jamais que l'orchestrateur.

Deux détails : la console de l'hyperviseur exige un VNC sur la boucle
locale, « listen=none » n'ouvrant aucun socket ; et deux entrées de menu
n'avaient pas d'icône.

Assisted-by: Claude Opus 5
2026-08-17 00:40:01 -04:00
a3aac909dd [ADD] qemu desktop: say how to REACH the remote desktop
The VM announced "Remote desktop: RDP 3389" without saying how to get
there. Its address is on libvirt's private network, which is not routed
from outside its host: the port alone helps nobody.

It now prints the tunnel to set up, address already filled in --
"ssh -L 3390:<ip>:3389 <user>@<libvirt-host>", then an RDP client on
localhost:3390. Likewise in VNC for Arch, on 5902.

This is the only workable route when the libvirt host is itself a VM
with no graphical interface: the SPICE console is on "listen=none" and
assumes virt-viewer ON that host.

--- FR ---

La VM annonçait « Bureau distant : RDP 3389 » sans dire par où passer.
Son adresse est sur le réseau privé de libvirt, qui n'est pas routé
depuis l'extérieur de son hôte : le port seul n'aide personne.

Elle imprime maintenant le tunnel à monter, adresse déjà remplie —
« ssh -L 3390:<ip>:3389 <user>@<hote-libvirt> », puis un client RDP sur
localhost:3390. Idem en VNC pour Arch, sur 5902.

C'est le seul chemin praticable quand l'hôte libvirt est lui-même une VM
sans interface graphique : la console SPICE est en « listen=none » et
suppose virt-viewer SUR cet hôte.

Assisted-by: Claude Opus 5
2026-08-17 00:40:01 -04:00
c6c860254c [ADD] tui qemu: pick the time zone from a list
The timezone was typed by hand. A misspelt IANA name is not rejected by
cloud-init: it is IGNORED. The VM stays on UTC, and you only notice from
the timestamps, once deployed.

A list of twenty-five zones, Québec first, then the rest of Canada and
the places one actually meets. The host's zone goes to the top, without
duplication: a machine outside this list must still see its own at a
glance.

NAMES, not offsets: "UTC-5" says nothing about daylight saving and
cloud-init will not take it. A name carries its own switching rules.

"free value…" keeps the door open to the other six hundred zones in the
database. The choice is copied into the field, which stays the only
value the spec reads -- one place holds the answer.

--- FR ---

Le fuseau se tapait à la main. Un nom IANA mal orthographié n'est pas
refusé par cloud-init : il est IGNORÉ. La VM reste en UTC, et on ne s'en
aperçoit qu'aux horodatages, une fois déployée.

Une liste de vingt-cinq fuseaux, le Québec d'abord, puis le reste du
Canada et les places qu'on rencontre en pratique. Le fuseau de l'hôte
passe en tête, sans doublon : une machine hors de cette liste doit voir
le sien en un coup d'œil.

Des NOMS, pas des décalages : « UTC-5 » ne dit rien de l'heure d'été et
cloud-init n'en veut pas. Un nom porte ses propres règles de bascule.

« libre… » garde la porte ouverte aux six cents autres fuseaux de la
base. Le choix est recopié dans le champ, qui reste la seule valeur lue
par la spec — un seul endroit porte la réponse.

Assisted-by: Claude Opus 5
2026-08-17 00:40:01 -04:00
8a946a0b89 [ADD] tui qemu: an Odoo version per VM
A profile list per row, beside the branch: "ERPLibre + Odoo 18",
"Odoo 17", "ERPLibre alone". The form's profile stays the default; picking
another for a VM creates an override, going back clears it.

It reaches down to execution, like the branch and the type before it.
"final_cmd" now takes either a string for the whole fleet or a
{name: command} map, and the remote command is built per machine as soon
as profiles differ. A VM can install Odoo 18 while another validates
Odoo 17, in the same deployment.

Both lists are widened -- branch to 24 columns, profile to 28. Truncated,
"develop" and "ERPLibre + Odoo 18" no longer showed what had been picked,
and that is precisely what one wants to re-read before deploying.

Two more defects: the global branch and Odoo choices reached nothing at
all, and the summary announced the global profile for every VM, then named
a version nothing would install.

--- FR ---

Une liste de profils par rangée, à côté de la branche : « ERPLibre +
Odoo 18 », « Odoo 17 », « ERPLibre seul ». Le profil du formulaire reste
le défaut ; en choisir un autre pour une VM crée une surcharge, y revenir
l'efface.

Il descend jusqu'à l'exécution, comme la branche et le type avant lui.
« final_cmd » accepte maintenant une chaîne pour tout le parc ou une carte
{nom: commande}, et la commande distante est bâtie par machine dès que les
profils diffèrent. Une VM peut donc installer Odoo 18 pendant qu'une autre
valide Odoo 17, dans le même déploiement.

Les deux listes sont élargies : la branche passe à 24 colonnes, le profil
à 28. Tronqués, « develop » et « ERPLibre + Odoo 18 » ne laissaient plus
voir ce qu'on avait choisi — et c'est précisément ce qu'on veut relire
avant de déployer.

Deux autres défauts : les choix globaux de branche et d'Odoo n'atteignaient
rien, et le sommaire annonçait le profil global pour toutes les VM, puis
nommait une version que rien n'installait.

Assisted-by: Claude Opus 5
2026-08-17 00:40:01 -04:00
2eee924759 [ADD] tui qemu: an ERPLibre version per VM
A branch list per row. The form's branch stays the default; picking
another for a VM creates an override, going back clears it -- the VM then
follows the form again, which is what one expects when restoring the
common choice.

It reaches all the way down to execution, like VM type before it.
"branch" now takes either a string for the whole fleet or a {name: branch}
map, and the remote command is built per machine as soon as branches
differ. The ground was ready: launch_installs already knows how to take
one command per VM.

A VM can therefore carry develop while another validates 1.6.0, in the
same deployment and the same monitoring table.

One trap: a row's branch fell back to develop on remount, the list being
rebuilt from the form rather than from the override it already held.

--- FR ---

Une liste de branches par rangée. La branche du formulaire reste le
défaut ; en choisir une autre pour une VM crée une surcharge, y revenir
l'efface — la VM suit alors de nouveau le formulaire, ce qui est le plus
attendu quand on remet le choix commun.

Elle descend jusqu'à l'exécution, comme le type de VM avant elle.
« branch » accepte désormais une chaîne pour tout le parc ou une carte
{nom: branche}, et la commande distante est bâtie par machine dès que les
branches diffèrent. Le terrain était prêt : launch_installs sait déjà
prendre une commande par VM.

Une VM peut donc porter develop pendant qu'une autre valide 1.6.0, sur le
même déploiement et dans le même tableau de suivi.

Un piège : la branche d'une rangée retombait sur develop au remontage, la
liste étant rebâtie depuis le formulaire plutôt que depuis la surcharge
qu'elle portait déjà.

Assisted-by: Claude Opus 5
2026-08-17 00:40:01 -04:00
17d0d60741 [ADD] tui qemu: rename a VM with the pencil
A "✎" per row opens an input prefilled with the current name. The given
name becomes an override like any other: it survives recomputation, and
F4 removes it along with the VM's other settings.

An explicit name makes automatic naming GIVE WAY. Switching the VM to
GNOME no longer appends "-gnome": adding a suffix would amount to
correcting the user. Clearing the field restores the automatic name,
copy and desktop suffixes included.

The name becomes the machine's HOSTNAME, so it is validated against
RFC 1123 before being accepted. One capital or one dot too many and
cloud-init silently ignores it -- the VM would stay "ubuntu", and you
would find out once deployed. Capitals are lowered, the rest is refused
with a message.

--- FR ---

Un « ✎ » par rangée ouvre une saisie préremplie du nom courant. Le nom
donné devient une surcharge comme les autres : il survit au recalcul, et
F4 le retire avec le reste des réglages de la VM.

Un nom explicite fait CÉDER le nommage automatique. Passer la VM en
GNOME n'y colle plus « -gnome » : y ajouter un suffixe reviendrait à
corriger l'utilisateur. Vider le champ rend le nom automatique, suffixes
de copie et de bureau compris.

Le nom devient le NOM D'HÔTE de la machine : il est donc validé en
RFC 1123 avant d'être retenu. Une majuscule ou un point de trop et
cloud-init l'ignore en silence — la VM resterait « ubuntu », et on le
découvrirait une fois déployée. Les majuscules sont abaissées, le reste
est refusé avec un message.

Assisted-by: Claude Opus 5
2026-08-17 00:40:01 -04:00
cbdded8fab [ADD] tui qemu: deploy several copies of one entry
A "+" per row adds a copy of the entry, a "−" removes it. The "−" only
appears on a copy: the original cannot be deleted by mistake, it is
unticked in the catalogue.

The name adapts on its own -- erplibre-ubuntu-2404, then -2, then -3.
The first keeps the catalogue name, so existing deployments keep theirs.

The real work is in identity. entry_key was (distro, version, arch): two
copies shared it, so setting the first set the second and their locks
fought each other. It now carries the instance number, and overrides as
well as locks follow the right machine. Each instance is a COPY of the
dictionary, never a shared reference.

Removing a copy forgets its settings: keeping them would resurrect old
values on the next copy, with nothing to explain it.

--- FR ---

Un « + » par rangée ajoute une copie de l'entrée, un « − » la retire. Le
« − » n'apparaît que sur une copie : l'original ne se supprime pas par
mégarde, il se décoche au catalogue.

Le nom s'adapte seul — erplibre-ubuntu-2404, puis -2, puis -3. Le
premier garde le nom du catalogue, pour que les déploiements existants
gardent le leur.

Le vrai travail est dans l'identité. entry_key valait
(distro, version, arch) : deux copies la partageaient, donc régler la
première réglait la seconde et leurs verrous se marchaient dessus. Elle
porte maintenant le numéro d'exemplaire, et surcharges comme verrous
suivent la bonne machine. Chaque exemplaire est une COPIE du
dictionnaire, jamais une référence partagée.

Retirer une copie oublie ses réglages : les garder ferait resurgir
d'anciennes valeurs à la copie suivante, sans que rien ne l'explique.

Assisted-by: Claude Opus 5
2026-08-17 00:40:01 -04:00
d35950ab97 [ADD] tui qemu: freeze one VM's resources, row in green
A 🔒 box at the head of each row. Ticked, the VM's four current values --
vCPU, RAM, disk, type -- are copied into its overrides: the x1..x4 profile
no longer reaches it. Unticked, they are removed and the VM falls back
under the profile. The lock shows on the WHOLE ROW, not in a box lost at
the end: that is what lets you scan the plan and see at once what escapes
the profile.

It rests on the override mechanism already proven, indexed by catalog
identity, so it survives a remount. Locked state stays distinct from
overrides: a VM can be edited without being frozen, and the lock covers
all four fields at once.

Four traps came with it. "remove_children()" is ASYNCHRONOUS, so giving
the card an id broke the remount, the old one still being there. Lists
kept stale values across a remount, and a refresh took back the free
entry. A frozen VM changed version all the same. And switching profile
wiped the disk size that had been set.

--- FR ---

Une case 🔒 en tête de chaque rangée. Cochée, les quatre valeurs courantes
de la VM — vCPU, RAM, disque, type — sont recopiées dans ses surcharges :
le profil x1..x4 ne l'atteint plus. Décochée, elles sont retirées et la VM
retombe sous le profil. Le verrou se voit à la LIGNE ENTIÈRE, pas à une
case perdue au bout : c'est ce qui permet de balayer le plan et de savoir
d'un coup ce qui échappe au profil.

Il s'appuie sur le mécanisme de surcharge déjà éprouvé, indexé par
identité de catalogue : il survit donc à un remontage. L'état verrouillé
reste distinct des surcharges : une VM peut être modifiée sans être figée,
et le verrou couvre les quatre champs d'un coup.

Quatre pièges l'ont accompagné. « remove_children() » est ASYNCHRONE :
donner un id à la carte faisait échouer le remontage, l'ancienne étant
encore là. Les listes gardaient des valeurs périmées au remontage, et un
rafraîchissement reprenait la saisie libre. Une VM figée changeait quand
même de version. Et changer de profil effaçait la taille de disque réglée.

Assisted-by: Claude Opus 5
2026-08-17 00:40:01 -04:00
e60f18806e [ADD] qemu monitor: VM architecture, scrolling and resizable columns
The architecture was missing, though it alone explains why an install
takes ten times longer: s390x and arm64 are EMULATED on an amd64 host.
Without it you look for the fault elsewhere. An "Arch" column, right after
the name.

The table was pinned at 74 columns: beyond that nothing was reachable and
no scrollbar showed, for want of reserved space. Automatic width capped at
60% of the screen, scrolling both ways, and VISIBLE bars rather than
guessable ones.

Columns can finally be adjusted: "+" widens the cursor's, "-" narrows it,
"0" returns them all to their original width -- the same keys as the mail
TUI. auto_width is turned off along the way, otherwise the setting did not
survive the table's first update, and the resize aimed at the "#" column
whatever the cursor was on.

--- FR ---

L'architecture manquait, alors qu'elle explique à elle seule pourquoi une
installation dure dix fois plus : s390x et arm64 sont ÉMULÉES sur un hôte
amd64. Sans elle, on cherche la faute ailleurs. Une colonne « Arch »,
juste après le nom.

Le tableau était figé à 74 colonnes : au-delà rien n'était atteignable et
aucune barre de défilement n'apparaissait, faute de place réservée.
Largeur automatique plafonnée à 60 % de l'écran, défilement dans les deux
sens, et barres VISIBLES plutôt que devinables.

Les colonnes s'ajustent enfin : « + » élargit celle du curseur, « - » la
rétrécit, « 0 » les ramène toutes à leur largeur d'origine — les mêmes
touches que la TUI courriel. auto_width est désactivé au passage, sans
quoi le réglage ne survivait pas à la première mise à jour du tableau, et
le redimensionnement visait la colonne « # » quel que soit le curseur.

Assisted-by: Claude Opus 5
2026-08-17 00:40:01 -04:00
18f5ddd69f [ADD] qemu monitor: act on a VM without leaving the dashboard
The monitor could only watch. The "a" key now opens the selected VM's
actions: update, restart Odoo, delete.

The update is chosen by parts, and the order is not free: system packages,
then git repositories, then Python dependencies -- the latter compile
against the former. Nothing is chained with "&&": one failing part must
not take the others down. Deleting demands a second hand, a confirmation
screen naming the disk to be erased.

Two defects the dashboard revealed. Detached installs were stealing the
shell's keystrokes, the child inheriting a terminal it had no business
reading. And the elapsed time restarted from zero every time the dashboard
was reopened, since it was counted from the view rather than from the
run's own first write.

--- FR ---

Le suivi ne savait que regarder. La touche « a » ouvre désormais les
actions de la VM sélectionnée : mettre à jour, redémarrer Odoo, supprimer.

La mise à jour se choisit par parties, et l'ordre n'est pas libre :
paquets système, puis dépôts git, puis dépendances Python — ces dernières
compilent contre les premiers. Rien n'est enchaîné par « && » : une partie
en échec ne doit pas emporter les autres. Supprimer exige une seconde
main, un écran de confirmation nommant le disque à effacer.

Deux défauts que le tableau de bord a révélés. Les installations détachées
volaient les frappes du shell, l'enfant héritant d'un terminal qu'il
n'avait pas à lire. Et la durée repartait de zéro à chaque réouverture,
comptée depuis la vue plutôt que depuis la première écriture du run.

Assisted-by: Claude Opus 5
2026-08-17 00:40:01 -04:00
3fda367151 [ADD] qemu: a graphical VM name carries its desktop
erplibre-ubuntu-2404-gnome, erplibre-ubuntu-2404-mint. A graphical VM is
recognisable from a "virsh list", and its hostname says so too --
deploy_qemu uses the name as hostname when --hostname is not given.

This is not only cosmetic. The name is the COLLISION KEY: a graphical VM
and its server twin carried the same one, so the second was reported as
"already exists" and silently skipped. They are now distinct.

The suffix is applied AFTER overrides, the only point where each
machine's type is known now that it is chosen VM by VM. In the form the
name therefore follows the choice live, both ways.

"mint" rather than "cinnamon": that is the name chosen for the fleet,
the installed package still being Cinnamon from the distribution's own
repositories. The suffix lives with the flavour, in _QEMU_DESKTOP, and
the CLI calls the same function as the TUI rather than writing a second.

--- FR ---

erplibre-ubuntu-2404-gnome, erplibre-ubuntu-2404-mint. Une VM graphique
se reconnaît d'un « virsh list », et son nom d'hôte le dit aussi —
deploy_qemu prend le nom pour hôte quand --hostname n'est pas donné.

Ce n'est pas que cosmétique. Le nom sert de CLÉ DE COLLISION : une VM
graphique et sa jumelle serveur portaient le même, donc la seconde était
signalée « existe déjà » et silencieusement ignorée. Elles se
distinguent maintenant.

Le suffixe est appliqué APRÈS les surcharges, seul moment où le type de
chaque machine est connu depuis qu'il se choisit VM par VM. Dans le
formulaire, le nom suit donc le choix en direct, dans les deux sens.

« mint » et non « cinnamon » : c'est le nom retenu pour le parc, le
paquet installé restant Cinnamon depuis les dépôts de la distribution.
Le suffixe vit avec la saveur, dans _QEMU_DESKTOP, et la CLI appelle la
même fonction que la TUI plutôt que d'en écrire une seconde.

Assisted-by: Claude Opus 5
2026-08-17 00:40:01 -04:00
1726cb5dab [ADD] qemu: choose the application store of a graphical VM
snap was not a choice but a fate: we disabled snapd, then gnome-core
pulled a Firefox snap that froze the install for thirty minutes. The
previous fix imposed "deb". Three answers are now offered.

  deb      nothing but .deb, epiphany-browser as the browser. Default,
           and lightest: nothing extra to download.
  flatpak  Flatpak tooling as well, WITHOUT the Flathub remote or any
           install. The machine is ready, the admin picks its remotes.
  snap     Ubuntu's default, snapd left running and Firefox as a snap.
           It is the only mode where snapd is not disabled -- disabling
           it was precisely the cause of the freeze.

The question is only asked when it means something: at least one
graphical VM on a distro shipping snapd, Ubuntu alone here. A server
pulls no snap, and Debian ships none. The TUI greys the choice out and
says why; the CLI does not ask.

Verified on a 26.04 VM: snapd is indeed preinstalled (2.75.2), flatpak
and its GNOME Software plugin are packaged, and firefox-esr does not
exist on Ubuntu.

--- FR ---

snap n'était plus un choix mais une fatalité : on coupait snapd, puis
gnome-core tirait un Firefox-snap qui figeait l'installation trente
minutes. Le correctif précédent imposait « deb ». Trois réponses sont
maintenant offertes.

  deb      rien que des .deb, epiphany-browser comme navigateur. Défaut,
           et le plus léger : rien de plus à télécharger.
  flatpak  l'outillage Flatpak en plus, SANS dépôt Flathub ni
           installation. La machine est prête, l'administrateur choisit
           ses dépôts.
  snap     le défaut d'Ubuntu, snapd laissé actif et Firefox en snap.
           C'est le seul mode où snapd n'est pas coupé — l'y couper
           était précisément la cause du blocage.

La question n'est posée que lorsqu'elle a un sens : au moins une VM
graphique sur une distribution qui livre snapd, Ubuntu seule ici. Un
serveur ne tire aucun snap, et Debian n'en livre pas. La TUI grise le
choix et dit pourquoi ; la CLI ne le demande pas.

Vérifié sur une VM 26.04 : snapd y est bien préinstallé (2.75.2),
flatpak et son greffon GNOME Software sont empaquetés, et firefox-esr
n'existe pas sur Ubuntu.

Assisted-by: Claude Opus 5
2026-08-17 00:40:01 -04:00
f32fa6c111 [FIX] qemu desktop: snap packages froze the build for 30 minutes
A graphical Ubuntu VM stopped making progress, with no error, on "Unable
to contact the store, trying every minute for the next 30 minutes".
Nothing in the log says why, and the install just looks stuck.

The cause is ours. We disable snapd a few lines earlier, to stop its
refreshes during the install. Then gnome-core recommends "firefox", which
on Ubuntu is only a transitional package whose postinst runs "snap
install" -- against a snapd we just stopped.

It is a RECOMMENDS, with alternatives. So we exclude the snap packages and
name epiphany-browser, a real .deb. Excluding firefox alone was not
enough: apt fell back to chromium-browser, a transitional package too, and
Cinnamon froze the same way on thunderbird. Measured on the VM: with this
list, GNOME (844 packages) and Cinnamon (1167) pull none, without an apt
error.

--- FR ---

Une VM Ubuntu graphique cessait d'avancer, sans erreur, sur « Unable to
contact the store, trying every minute for the next 30 minutes ». Rien
dans le log n'en dit la raison, et l'installation paraît simplement figée.

La cause est nôtre. On coupe snapd quelques lignes plus haut, pour
empêcher ses rafraîchissements pendant l'installation. Puis gnome-core
recommande « firefox », qui n'est plus sur Ubuntu qu'un paquet de
transition dont le postinst lance « snap install » — contre un snapd qu'on
vient d'arrêter.

C'est un RECOMMENDS, avec alternatives. On écarte donc les paquets snap et
on nomme epiphany-browser, un vrai .deb. Écarter le seul firefox ne
suffisait pas : apt retombait sur chromium-browser, paquet de transition
lui aussi, et Cinnamon figeait pareillement sur thunderbird. Mesuré sur la
VM : avec cette liste, GNOME (844 paquets) et Cinnamon (1167) n'en tirent
aucun, sans erreur apt.

Assisted-by: Claude Opus 5
2026-08-17 00:40:01 -04:00
62f3e62e83 [FIX] install s390x: the compiler was killed for lack of memory
matplotlib dies on "c++: fatal error: Killed signal terminated program
cc1plus". "Killed" is a SIGKILL: the kernel's OOM killer, and nothing in
the message names memory. A single matplotlib source file asks cc1plus
for up to 2.5 GiB.

Two causes compound. The VM is small -- the catalogue starts at 1 or
2 GiB -- and every build backend launches nproc compilations in
parallel, each with its own cc1plus. Six cores exhaust 8 GiB.

Hence two answers. A swap file tops memory up to 8 GiB, never taking
more than half the free disk nor touching fstab -- a declared and
missing swap degrades boot. And parallelism is bounded by actual
memory, 2 GiB per task.

ninja has no parallelism environment variable, but meson-python reads
"NINJA", the executable PATH -- verified in mesonpy 0.20. We point it at
a wrapper that adds the -j. That is what saves matplotlib; MAKEFLAGS and
CMAKE_BUILD_PARALLEL_LEVEL cover the rest.

--- FR ---

matplotlib s'arrête sur « c++: fatal error: Killed signal terminated
program cc1plus ». « Killed » est un SIGKILL : c'est le tueur du noyau,
et rien dans le message ne nomme la mémoire. Un seul fichier de
matplotlib demande jusqu'à 2,5 Gio à cc1plus.

Deux causes se cumulent. La VM est petite — le catalogue démarre à 1 ou
2 Gio — et chaque moteur de build lance nproc compilations en parallèle,
chacune avec son cc1plus. Six cœurs épuisent 8 Gio.

D'où deux réponses. Un fichier d'échange complète la mémoire jusqu'à
8 Gio, sans jamais prendre plus de la moitié du disque libre ni toucher
à fstab — un swap déclaré et disparu dégrade le démarrage. Et le
parallélisme est borné d'après la mémoire réelle, 2 Gio par tâche.

ninja n'a aucune variable de parallélisme, mais meson-python lit
« NINJA », le CHEMIN de l'exécutable — vérifié dans mesonpy 0.20. On y
met une enveloppe qui ajoute le -j. C'est ce qui sauve matplotlib ;
MAKEFLAGS et CMAKE_BUILD_PARALLEL_LEVEL couvrent les autres.

Assisted-by: Claude Opus 5
2026-08-17 00:40:01 -04:00
d6221ee48d [ADD] tui qemu: set every VM in place, type included
VM type was global: the whole fleet as servers, or all of them GNOME. It
now lives on the VM, all the way down -- the creation flag, and one remote
command per machine at install time, where a single one served them all.

The right pane is no longer a table but a row of widgets per VM: vCPU,
RAM, disk and type, each with its usual values and a free entry. The
left-hand fields become the shared default, which the screen now says. The
scope selector and the F2 modal go away: given two ways to do the same
thing, keep the visible one.

Three traps came out of it, and the tests lock them. Widget ids carry a
RANK, and the rank shifts when an entry is ticked, so an event from an
already destroyed widget applied to the VM that took its place -- rows now
carry a generation, marked BEFORE mounting, since mount_all empties the
pending children and marking after it was a race. The x1..x4 profile no
longer reached any VM. And a total of zero never said it had counted
nothing.

--- FR ---

Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il
vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une
commande distante par machine à l'installation, là où une seule les
servait toutes.

Le panneau de droite n'est plus un tableau mais une rangée de widgets par
VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une
saisie libre. Les champs de gauche deviennent le défaut commun, ce que
l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent :
entre deux façons de faire la même chose, on garde la visible.

Trois pièges en sont sortis, et les tests les verrouillent. Les
identifiants de widgets portent un RANG, et le rang se décale quand on
coche une entrée : un événement émis par un widget déjà détruit
s'appliquait à la VM qui avait pris sa place — les rangées portent
maintenant une génération, marquée AVANT le montage, car mount_all vide
les enfants en attente et marquer après était une course. Le profil x1..x4
n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il
n'avait rien compté.

Assisted-by: Claude Opus 5
2026-08-17 00:40:01 -04:00
834b1061ac [ADD] tui qemu: customise one VM without touching the others
The three resource fields applied to the whole fleet. With a single
machine needing 16 G, the other eight got it too. A scope selector
settles it: all VMs, or the row targeted in the plan. The x1..x4 profile
stays global, multiplying what each image asks for; under "selected VM"
the fields are absolute, hence active even outside the custom profile.

The F2 modal already existed but was undiscoverable, and its cursor was
unusable: the table never held focus. It stays, the toggle now focuses
the table, F4 returns a VM to the shared profile, and the plan marks
customised rows with a ✎ -- without it, two rows with different
resources have no explanation on screen.

One defect found along the way: clear() reset the cursor to the top on
every redraw, and the plan recomputes on every keystroke. You picked
debian, typed the value, the table redrew, and the next entry landed on
ubuntu with nothing to show for it.

--- FR ---

Les trois champs de ressources s'appliquaient à tout le parc. Une seule
machine ayant besoin de 16 G, il fallait les donner aux huit autres. Un
sélecteur de portée tranche : toutes les VM, ou la ligne visée dans le
plan. Le profil x1..x4 reste global, il multiplie ce que demande chaque
image ; en portée « une seule » les champs valent en absolu, et sont donc
actifs même hors profil personnalisé.

La modale F2 existait déjà mais restait introuvable, et son curseur était
inutilisable : le tableau n'avait jamais le focus. Elle demeure, la
bascule lui donne le focus, F4 rend une VM au profil commun, et le plan
marque d'un ✎ ce qui a été personnalisé — sans marque, deux lignes aux
ressources différentes n'ont aucune explication à l'écran.

Un défaut au passage : « clear() » ramenait le curseur en tête à chaque
redessin, et le plan se recalcule à chaque frappe. On choisissait debian,
on tapait la valeur, le tableau se redessinait, et la saisie suivante
partait sur ubuntu sans que rien ne le montre.

Assisted-by: Claude Opus 5
2026-08-17 00:40:01 -04:00
9c470f065e [ADD] qemu: openSUSE Leap 16.0, numbered, as the default
The catalogue only offered openSUSE Tumbleweed, a rolling release with
no version number. Nearly every openSUSE incident in this series came
from that: the cloud image is a snapshot lagging behind its own repos,
hence the mandatory "zypper dup", and two VMs deployed the same day saw
git-daemon 2.54 on s390x against 2.55 on amd64. For an ERP platform,
that is not what should be offered by default.

Leap 16.0 is numbered, stable, and publishes the three architectures
that matter -- verified, x86_64, aarch64 and s390x all 200. It also
unifies its tree: no separate /ports/, unlike Tumbleweed, whose
equivalent paths 404 for Leap.

Tumbleweed stays on offer, as a bellwether for breakage to come. Two
things separate them in the scripts: the repository path, and "dup" --
which on Leap means CHANGING version, not updating.

--- FR ---

Le catalogue n'offrait qu'openSUSE Tumbleweed, une rolling sans numéro
de version. Presque tous les incidents openSUSE de la série venaient de
là : l'image cloud est un instantané en retard sur ses dépôts, d'où le
« zypper dup » obligatoire, et deux VM déployées le même jour ont vu
git-daemon 2.54 sur s390x contre 2.55 sur amd64. Pour une plateforme
ERP, ce n'est pas ce qu'on veut proposer par défaut.

Leap 16.0 est numérotée, stable, et publie les trois architectures qui
comptent — vérifié, x86_64, aarch64 et s390x en 200. Elle unifie de plus
son arbre : pas de /ports/ séparé, contrairement à Tumbleweed, dont les
chemins équivalents rendent 404 pour Leap.

Tumbleweed reste offerte, comme banc d'essai des ruptures à venir.
Deux points les séparent dans les scripts : le chemin des dépôts, et
« dup » — qui sur Leap sert à CHANGER de version, pas à mettre à jour.

Assisted-by: Claude Opus 5
2026-08-17 00:40:01 -04:00
e20d8d9baa [ADD] install: uv to place Python packages, pip as fallback
uv replaces pip where it can: the tools venv and the Poetry bootstrap. The
choice lives in EL_PIP_PROVIDER -- auto, uv or pip -- and in a single
file, lib_pip_provider.sh, on the exact model of the mise/pyenv switch. A
uv failure falls back to pip: it is stricter on metadata and rejects
packages pip accepts.

Three guards. The target always goes through "--python" rather than being
inferred from the active venv: poetry.toml declares a "./.venv" uv would
also look for. Python 3.7, still used by Odoo 12 and 13, stays on pip. And
"uv pip sync" is never used: it would delete pip itself and everything
Poetry laid down.

One bug falls along the way: the idempotence guard aimed at
.venv.erplibre/bin/poetry while Poetry installs into the Odoo venv, so a
successful replay reported failure all the same.

--- FR ---

uv remplace pip là où il le peut : le venv d'outils et l'amorçage de
Poetry. Le choix vit dans EL_PIP_PROVIDER — auto, uv ou pip — et dans un
seul fichier, lib_pip_provider.sh, sur le modèle exact de l'aiguillage
mise/pyenv. Un échec d'uv retombe sur pip : il est plus strict sur les
métadonnées et refuse des paquets que pip accepte.

Trois gardes. La cible passe toujours par « --python » plutôt que d'être
déduite du venv actif : poetry.toml déclare un « ./.venv » qu'uv
chercherait aussi. Python 3.7, encore utilisé par Odoo 12 et 13, reste sur
pip. Et « uv pip sync » n'est jamais employé : il supprimerait pip
lui-même et tout ce que Poetry a posé.

Un défaut tombe au passage : le garde d'idempotence visait
.venv.erplibre/bin/poetry alors que Poetry s'installe dans le venv Odoo,
si bien qu'un rejeu réussi rapportait quand même un échec.

Assisted-by: Claude Opus 5
2026-08-16 23:33:49 -04:00
e4aea8b8a6 [ADD] arch: Canadian pacman mirrors first
Measured from Montréal on extra.db: mirror.quantum5.ca 2.0 s,
mirror.xenyth.net 7.1 s, against 8.0 s for geo.mirror.pkgbuild.com. The
official "geographic" mirror is therefore not the best one here, and
reflector, which sorts by throughput from inside the VM, had not picked
them.

Both are PREPENDED, never substituted: whatever reflector chose stays
below, as fallback. The write therefore comes after it, since "--save"
overwrites the file. Guarded on x86_64, these mirrors serving that
architecture only. Host side, the addition is idempotent.

The desktop install went through neither step: it ran without the near
mirrors and without the full update a rolling release demands, so it
pulled from Europe and broke on partial upgrades.

--- FR ---

Mesuré depuis Montréal sur extra.db : mirror.quantum5.ca 2,0 s,
mirror.xenyth.net 7,1 s, contre 8,0 s pour geo.mirror.pkgbuild.com. Le
miroir « géographique » officiel n'est donc pas le meilleur ici, et
reflector, qui trie par débit depuis la VM, ne les avait pas retenus.

Les deux sont mis EN TÊTE, jamais substitués : ce que reflector a choisi
reste dessous, en repli. L'écriture vient donc après lui, puisque
« --save » écrase le fichier. Gardé sur x86_64, ces miroirs ne servant que
cette architecture. Côté hôte, l'ajout est idempotent.

L'installation du bureau ne passait par aucune des deux étapes : elle
s'exécutait sans les miroirs proches et sans la mise à jour complète
qu'exige une rolling release, tirant donc d'Europe et cassant sur des
mises à jour partielles.

Assisted-by: Claude Opus 5
2026-08-16 23:33:49 -04:00
7f6c18037d [ADD] tui qemu: choose mise or pyenv at deploy time
The interpreter provider is now chosen when creating a VM, in both
interfaces. "mise" installs it in the VM and lays down a precompiled
CPython; "pyenv" keeps today's build-from-source.

The question is only asked where it means something: mise publishes
binaries for amd64 and arm64 only. An all-s390x fleet never sees it, a
mixed fleet sees it along with the architectures that will fall back to
pyenv -- said before deploying, not discovered in a log an hour later.

mise goes into /usr/local/bin rather than ~/.local/bin: the remote command
runs through "ssh host 'command'", where neither ~/.profile nor ~/.bashrc
is read. Choosing "mise" exports "auto", not "mise", so a failed install
can still fall back to pyenv.

The unavailability notice was translated as "pyenv required", which said
the opposite of what happens: pyenv is the fallback, not a demand.

--- FR ---

Le fournisseur d'interpréteur se choisit désormais à la création d'une VM,
dans les deux interfaces. « mise » l'installe dans la VM et pose un
CPython précompilé ; « pyenv » garde la compilation d'aujourd'hui.

La question n'est posée que là où elle a un sens : mise ne publie de
binaires que pour amd64 et arm64. Un parc tout s390x ne la voit jamais, un
parc mixte la voit avec les architectures qui retomberont sur pyenv — dit
avant de déployer, pas découvert dans un log une heure plus tard.

mise va dans /usr/local/bin plutôt que ~/.local/bin : la commande distante
passe par « ssh hôte 'commande' », où ni ~/.profile ni ~/.bashrc ne sont
lus. Choisir « mise » exporte « auto », pas « mise », pour qu'une
installation ratée puisse encore retomber sur pyenv.

L'avis d'indisponibilité était traduit par « pyenv exigé », qui disait
l'inverse de ce qui se passe : pyenv est le repli, pas une exigence.

Assisted-by: Claude Opus 5
2026-08-16 23:33:49 -04:00
70d9ad456c [ADD] install: mise as Python provider, pyenv as fallback
mise lays down a precompiled CPython where pyenv builds one: seconds
against one to three minutes, with no -dev package at all. The choice
lives in EL_PYTHON_PROVIDER -- auto, mise or pyenv -- and in a single
file, lib_python_provider.sh. The rest of the repository only knows venv
paths and needs to know none of this.

In auto mode an ALREADY installed interpreter wins, whichever provider put
it there. mise is never installed on its own, "curl | sh" commits too much
for a script to decide -- make install_mise carries that decision.
MISE_PYTHON_COMPILE=false forbids it from quietly compiling: without that
guard it would fall back to pyenv's own engine, giving us the slowness
without the tooling. That is also what stopped gcc from collapsing while
building CPython on a low-memory s390x guest, for nothing.

One real bug falls along the way: a failed venv did not stop the install,
which then went on and failed further down, far from the cause.

--- FR ---

mise pose un CPython précompilé là où pyenv en compile un : des secondes
contre une à trois minutes, et aucun paquet -dev. Le choix vit dans
EL_PYTHON_PROVIDER — auto, mise ou pyenv — et dans un seul fichier,
lib_python_provider.sh. Le reste du dépôt ne connaît que des chemins de
venv et n'a rien à savoir de tout cela.

En mode auto, un interpréteur DÉJÀ posé l'emporte, quel qu'en soit le
fournisseur. mise n'est jamais installé de lui-même, « curl | sh » engage
trop pour qu'un script en décide — make install_mise porte cette décision.
MISE_PYTHON_COMPILE=false lui interdit de compiler en silence : sans ce
garde, il retomberait sur le moteur de pyenv, et nous aurions sa lenteur
sans son outillage. C'est aussi ce qui a évité que gcc s'écroule en
bâtissant CPython sur une VM s390x à faible mémoire, inutilement.

Un vrai défaut tombe au passage : un venv raté n'arrêtait pas
l'installation, qui continuait et échouait plus loin, loin de la cause.

Assisted-by: Claude Opus 5
2026-08-16 23:33:49 -04:00
a579cc9cf3 [FIX] install: the EL9, EL10 and Fedora build chain
"dnf install: error: unrecognized arguments" on AlmaLinux 10 and Rocky 10,
on every call: PostgreSQL, build dependencies, pyenv. The tools group got
away with its fallback, which made the trace misleading -- gcc installed,
nothing after it did.

"--skip-unavailable" is a dnf5 option, which the script's own comment
already said while assuming it everywhere. Fedora 41+ ships dnf5, but EL9
and EL10 stay on dnf4, whose equivalent is "--setopt=strict=0". We now ask
dnf what it understands instead of inferring it from the distribution.

Three neighbouring fixes: the "c-development" group does not exist on EL,
where it is called "development"; CRB is enabled through /usr/bin/crb; and
g++ plus the missing headers are installed explicitly. Rust is added only
where wheels are absent, and qpdf is built when the distribution ships one
older than pikepdf demands.

--- FR ---

« dnf install: error: unrecognized arguments » sur AlmaLinux 10 et
Rocky 10, à chaque appel : PostgreSQL, dépendances de compilation, pyenv.
Le groupe d'outils s'en tirait par son repli, ce qui rendait la trace
trompeuse — gcc installé, rien après lui.

« --skip-unavailable » est une option de dnf5, ce que le commentaire du
script disait déjà tout en la supposant partout. Fedora 41+ livre dnf5,
mais EL9 et EL10 restent sur dnf4, dont l'équivalent est
« --setopt=strict=0 ». On demande maintenant à dnf ce qu'il comprend
plutôt que de le déduire de la distribution.

Trois correctifs voisins : le groupe « c-development » n'existe pas sur
EL, où il s'appelle « development » ; CRB s'active par /usr/bin/crb ; et
g++ ainsi que les en-têtes manquants sont posés explicitement. Rust n'est
ajouté que là où les roues manquent, et qpdf est compilé quand la
distribution en livre un plus ancien que ce qu'exige pikepdf.

Assisted-by: Claude Opus 5
2026-08-16 23:33:49 -04:00
7120732c25 [ADD] install: openSUSE support, mirrors and packages
Tumbleweed is the only catalog entry whose qpdf already clears the pikepdf
threshold: 12.3.2 against 12.2 required, so the half-hour qpdf build under
s390x emulation never runs there. It is also the only family foreign to
both RHEL and Debian, and it brings zypper, absent from the repository:
wired into the install_dev.sh dispatch, a dependency script, the remote
bootstrap and the desktop block.

Four SUSE traps, all met on real machines. Global options precede the
subcommand, and misplaced ones abort the call; without
"--auto-agree-with-licenses" zypper waits for an answer nobody gives in a
detached install. A compat package that PROVIDES another under a different
name makes zypper raise a conflict and drop the whole batch, so only what
nothing already provides is requested. pkg-config no longer exists as an
RPM, only as a capability. And a single mirror, unreachable one day, sent
everyone back to Europe -- three are probed in order now.

CentOS Stream is dropped again: it served as a canary but 10 duplicates
what Alma and Rocky already cover.

--- FR ---

Tumbleweed est la seule entrée du catalogue dont qpdf franchit déjà le
seuil de pikepdf : 12.3.2 contre 12.2 exigé, si bien que la demi-heure de
compilation de qpdf sous émulation s390x ne s'y déclenche jamais. C'est
aussi la seule famille étrangère à RHEL comme à Debian, et elle apporte
zypper, absent du dépôt : câblé dans l'aiguillage d'install_dev.sh, un
script de dépendances, l'amorçage distant et le bloc bureau.

Quatre pièges SUSE, tous rencontrés sur machine réelle. Les options
globales précèdent la sous-commande, mal placées elles arrêtent l'appel ;
sans « --auto-agree-with-licenses » zypper attend une réponse que personne
ne donne dans une installation détachée. Un paquet compat qui FOURNIT un
autre sous un nom différent fait lever un conflit à zypper, qui abandonne
le lot entier : on ne demande donc que ce que rien ne fournit déjà.
pkg-config n'existe plus comme RPM, seulement comme capacité. Et un miroir
unique, injoignable un jour, renvoyait tout le monde en Europe — trois
sont désormais sondés dans l'ordre.

CentOS Stream repart : il servait de canari, mais 10 double ce qu'Alma et
Rocky couvrent déjà.

Assisted-by: Claude Opus 5
2026-08-16 23:33:49 -04:00
96464ab0eb [ADD] catalogue: Fedora 43 for s390x
Fedora does publish an s390x cloud image, just elsewhere: the arch is
SECONDARY there, its images live under "fedora-secondary" rather than
/pub/fedora/linux/. The catalog comment read "404" -- it was the wrong
tree.

It brings what nothing else has on this architecture: qpdf 12.2.0,
exactly the pikepdf 10 threshold. The qpdf build, half an hour under
emulation, disappears. With GCC 15, Python 3.14 and cargo 1.90, none of
the s390x workarounds fire there.

Only 43 is kept: Fedora builds s390x for the current release only. 41 and
42 return 404 on the master mirror, 44 sits on third-party mirrors only.
Hence a positive per-architecture declaration, read by both interfaces --
the catalog never offers what deployment would refuse.

--- FR ---

Fedora publie bien une image cloud s390x, mais ailleurs : l'architecture
est SECONDAIRE chez elle, ses images vivent sous « fedora-secondary » et
non sous /pub/fedora/linux/. Le commentaire du catalogue disait « 404 » —
c'était la mauvaise arborescence.

Elle apporte ce qu'aucune autre n'a sur cette architecture : qpdf 12.2.0,
soit exactement le seuil de pikepdf 10. La compilation de qpdf, une demi-
heure sous émulation, disparaît. Avec GCC 15, Python 3.14 et cargo 1.90,
aucun des contournements s390x ne s'y déclenche.

Seule la 43 est retenue : Fedora ne construit s390x que pour la version
courante. Les 41 et 42 rendent 404 sur le miroir maître, la 44 n'est que
sur certains miroirs tiers. D'où une déclaration positive par
architecture, que les deux interfaces relisent — le catalogue ne propose
pas ce que le déploiement refuserait.

Assisted-by: Claude Opus 5
2026-08-16 23:33:49 -04:00
3d6d2395b3 [UPD] tui qemu: the plan shows each VM type
The server-or-desktop choice only showed on the left, in the form. The
table on the right, the one read before launching, said nothing about it:
two visually identical plans could produce different VMs.

The Status column, empty until now for a VM to create, therefore carries
the type, and the totals line repeats it. An already defined VM keeps its
collision message: it is not touched, showing a desktop there would
suggest one is about to be installed on it.

--- FR ---

Le choix serveur ou bureau ne se voyait qu'à gauche, dans le formulaire.
Le tableau de droite, celui qu'on relit avant de lancer, n'en disait
rien : deux plans identiques à l'écran pouvaient produire des VM
différentes.

La colonne Statut, vide jusqu'ici pour une VM à créer, porte donc le
type, et la ligne de totaux le rappelle. Une VM déjà définie garde son
message de collision : elle n'est pas retouchée, lui afficher un bureau
laisserait croire qu'on va lui en poser un.

Assisted-by: Claude Opus 5
2026-08-16 23:33:49 -04:00
f39b2e9451 [UPD] support: drop Ubuntu 20.04/22.04, add AlmaLinux and Rocky
Ubuntu 20.04 and 22.04 leave EVERY architecture, not just s390x. pikepdf
needs qpdf 12.2, whose build requires C++20, while focal ships GCC 9 and
publishes no g++-10 for s390x at all. Python 3.8, node 10, cargo 0.67 and
OpenSSL 1.1.1 each had a workaround; the pile of them did not. 18.04
follows, already off the lists. The refusal lands before any apt, this
script also serving existing machines.

AlmaLinux 9 and 10, Rocky 9 and 10 join the catalog on all four
architectures: the twelve "latest" URLs were opened, with no index to
parse unlike Fedora. They would have booted unreachable though -- the
cloud-config forced "groups: users, sudo", but the RHEL family has no
sudo group, only wheel, and an unknown group makes useradd fail, hence no
password and no key. The very trap already known for Debian, repeated
elsewhere. Host side, EPEL and CRB are enabled: without them most -devel
packages are missing, silently.

The server / graphical choice gains Cinnamon, the Linux Mint desktop,
from the distribution's own repositories. Mint's repository is set aside:
plain HTTP, and i386/amd64 only, which would rule out arm64 and s390x.
Along the way, dnf now installs an ENVIRONMENT rather than a group --
"gnome-desktop" brings gdm and gnome-shell but not base-x, hence no X
server.

--- FR ---

Ubuntu 20.04 et 22.04 partent de TOUTES les architectures, pas seulement
de s390x. pikepdf réclame qpdf 12.2, dont la compilation exige C++20,
quand focal livre GCC 9 et ne publie même pas de g++-10 pour s390x.
Python 3.8, node 10, cargo 0.67 et OpenSSL 1.1.1 avaient chacun leur
contournement ; leur accumulation, non. 18.04 suit, déjà hors des
listes. Le refus tombe avant tout apt, ce script servant aussi les
machines existantes.

AlmaLinux 9 et 10, Rocky 9 et 10 entrent au catalogue, sur les quatre
architectures : les douze URL « latest » ont été ouvertes, aucun index à
analyser contrairement à Fedora. Elles auraient pourtant démarré
inaccessibles — le cloud-config imposait « groups: users, sudo », or la
famille RHEL n'a pas de groupe sudo mais wheel, et un groupe inconnu fait
échouer useradd, donc ni mot de passe ni clé. C'est le piège déjà connu
pour Debian, reproduit ailleurs. Côté hôte, EPEL et CRB sont activés :
sans eux la plupart des -devel manquent, en silence.

Le choix serveur / graphique gagne Cinnamon, le bureau de Linux Mint,
depuis les dépôts de la distribution. Le dépôt de Mint lui-même est
écarté : il est en HTTP nu et ne publie que i386 et amd64, ce qui
exclurait arm64 et s390x. Au passage, dnf installe désormais un
ENVIRONNEMENT et non un groupe — « gnome-desktop » apporte gdm et
gnome-shell mais pas base-x, donc pas de serveur X.

Assisted-by: Claude Opus 5
2026-08-16 23:33:49 -04:00
bace74b32b [ADD] tui qemu: graphical VMs, with a GNOME desktop
VMs could only be servers. A server-or-graphical choice joins both
interfaces, installing GNOME along with remote access to it.

Packages come from the remote command, not cloud-init: their 1 to 2 GB
would stretch an already long boot there while leaving no trace in the
monitoring, and group installs do not go through it. The desktop
therefore does not depend on ERPLibre -- a VM may be wanted graphical
and bare.

Each distribution has its own names, taken from the source: Arch has no
xrdp in its official repositories and takes TigerVNC. The SPICE display
is set only on amd64 and arm64; s390x does expose virtio-gpu-ccw, but
nothing guarantees its kernel's DRM driver, whereas remote desktop works
everywhere.

--- FR ---

Les VM ne pouvaient être que des serveurs. Un choix serveur ou graphique
s'ajoute aux deux interfaces, et pose GNOME avec son accès distant.

Les paquets viennent de la commande distante, pas de cloud-init : leurs
1 à 2 Go y allongeraient un démarrage déjà long sans laisser de trace
dans le suivi, et les installations par groupe n'y passent pas. Le bureau
ne dépend donc pas d'ERPLibre — une VM peut être voulue graphique et nue.

Chaque distribution a ses noms, relevés à la source : Arch n'a pas xrdp
dans ses dépôts officiels et prend TigerVNC. L'écran virtuel SPICE n'est
posé que sur amd64 et arm64 ; s390x expose bien virtio-gpu-ccw, mais rien
ne garantit le pilote DRM de son noyau, alors que le bureau distant, lui,
marche partout.

Assisted-by: Claude Opus 5
2026-08-16 23:33:49 -04:00
891d412aa2 [UPD] tui qemu: pick the VM by number for its state
Changing state meant retyping the name, on a single comma-separated line.
Names are long and alike -- two of them sometimes differ by a suffix
only -- and a typo here decided whether a machine started or shut down.

The screen now reuses the numbered list from the delete screen, "all"
included, and its parser, which already accepts names: nothing is lost
for those who typed them. The warning on an unknown entry is kept, that
parser otherwise dropping it without a word.

--- FR ---

Changer l'état demandait de retaper le nom, sur une seule ligne séparée
par des virgules. Les noms sont longs et se ressemblent — deux d'entre
eux ne diffèrent parfois que par un suffixe — et une faute de frappe
portait ici sur le démarrage ou l'extinction d'une machine.

L'écran reprend donc la liste numérotée de la suppression, « all »
compris, et son analyseur, qui accepte déjà les noms : rien n'est perdu
pour qui les tapait. L'avertissement sur une entrée inconnue est
conservé, cet analyseur l'écartant autrement sans un mot.

Assisted-by: Claude Opus 5
2026-08-16 23:33:49 -04:00
c7b9d2396d [ADD] tui qemu: one run per installation
The form's parallelism was pinned at 4, while the CLI already counted
host cores: two interfaces, two answers.

A box ticked by default now gives one run per install -- five VMs, five
deployments -- and the core count stops capping it. Unticking hands
control back to the dropdown, whose default finally follows the host. The
CLI offers the same toggle through "n".

--- FR ---

Le parallélisme du formulaire était figé à 4, quand la CLI comptait déjà
les cœurs de l'hôte : deux interfaces, deux réponses.

Une case cochée par défaut donne désormais une exécution par
installation — cinq VM, cinq déploiements — et le nombre de cœurs cesse
alors de plafonner. La décocher rend la main à la liste, dont le défaut
suit enfin l'hôte. La CLI offre la même bascule par « n ».

Assisted-by: Claude Opus 5
2026-08-16 23:33:49 -04:00
571ccf3c90 [FIX] qemu monitor: follow the DHCP lease, and keep the log talking
Monitoring stayed on the same address for 1170 s, never catching up with
the VM. Two holes, in the very re-resolution meant to prevent that.

The lease fallback required an answer on port 22. But dnsmasq keeps one
lease per MAC: when cloud-init sets the real hostname and the DHCP client
asks again, the lease MOVES the address. The old one no longer belongs to
the VM and sshd will never answer there. The lease therefore wins as soon
as it stops listing the current address.

The other hole explains the silence: virsh was muted on both branches, so
an unreachable libvirt kept the initial IP without a single line saying
so. The log went quiet for a quarter of an hour for the same reason --
cloud-init holds the package lock while writing nothing.

--- FR ---

Le suivi restait sur la même adresse pendant 1170 s, sans jamais rattraper
la VM. Deux trous, dans la re-résolution censée l'éviter.

Le repli par bail exigeait une réponse sur le port 22. Or dnsmasq garde un
bail par MAC : quand cloud-init pose le vrai nom d'hôte et que le client
DHCP redemande, le bail DÉPLACE l'adresse. L'ancienne n'appartient plus à
la VM et sshd n'y répondra jamais. Le bail l'emporte donc dès qu'il cesse
de lister l'adresse courante.

L'autre trou explique le silence : virsh était muet sur les deux branches,
si bien qu'un libvirt injoignable conservait l'IP initiale sans une ligne
pour le dire. Le log se taisait un quart d'heure pour la même raison —
cloud-init tient le verrou des paquets sans rien écrire.

Assisted-by: Claude Opus 5
2026-08-16 23:33:49 -04:00
42391575d6 [REM] install: drop Ubuntu 20.04 and 22.04
Two blockers on 20.04, neither worth carrying. "TypeError: unsupported
operand type(s) for |" fired before the install even started:
update_env_version.py imports erplibre_state as the very first thing
"make install_os" does, hence before pyenv, on the system Python 3.8,
where a "str | None" annotation is evaluated at import. And cryptography
now demands OpenSSL 3, while focal ships 1.1.1.

Annotations are therefore deferred, a contract the bootstrap file already
stated in a comment and now holds across script/version and
script/install. But the releases themselves go: pikepdf requires
qpdf >= 12.2, compiled in C++20, when focal ships GCC 9.

--- FR ---

Deux blocages sur 20.04, aucun ne méritant d'être porté. « TypeError:
unsupported operand type(s) for | » se déclenchait avant même le début de
l'installation : update_env_version.py importe erplibre_state en tout
premier lieu de « make install_os », donc avant pyenv, sur le Python 3.8
du système, où une annotation « str | None » est évaluée à l'import. Et
cryptography exige désormais OpenSSL 3, quand focal livre 1.1.1.

Les annotations sont donc différées, contrat que le fichier d'amorçage
énonçait déjà en commentaire et qui tient maintenant sur script/version et
script/install. Mais les versions elles-mêmes partent : pikepdf réclame
qpdf >= 12.2, compilé en C++20, quand focal livre GCC 9.

Assisted-by: Claude Opus 5
2026-08-16 23:33:49 -04:00
b6de9b5506 [ADD] tui qemu: resume an installation already running
Installs run detached (setsid -f): closing the terminal does not stop
them, but it lost the only view onto them. With no way to resume, the
only way out was deleting the VMs and starting over.

Picking "Deploy" now looks at the latest run: if VMs there still lack an
exit marker, it offers to reopen its monitoring instead of starting
another. Time since the last write is shown, a dead run being otherwise
indistinguishable from a live one.

Only the latest run is examined: an old one left without a marker would
flag a phantom install forever. Dry-run creates nothing, so it does not
ask. "Reopen monitoring" moves to the Deployment section, where one
looks for it.

--- FR ---

Les installs partent détachées (setsid -f) : fermer le terminal ne les
arrête pas, mais faisait perdre la seule vue dessus. Sans moyen de
reprendre, la seule issue était d'effacer les VM et de recommencer.

Choisir « Déployer » regarde donc le dernier run : s'il lui reste des VM
sans marqueur de sortie, il propose de rouvrir son suivi plutôt que d'en
lancer un autre. Le silence depuis la dernière écriture est affiché, un
run mort n'étant pas distinguable autrement d'un run vivant.

Seul le dernier run est examiné : un run ancien laissé sans marqueur
signalerait éternellement une install fantôme. L'aperçu ne crée rien, il
ne pose pas la question. « Rouvrir le suivi » rejoint la section
Déploiement, où on le cherche.

Assisted-by: Claude Opus 5
2026-08-16 23:33:49 -04:00
7650710295 [ADD] tui qemu: free values for vCPU, RAM and disk
The CLI already took a typed value -- a letter picks a suggestion, a
digit stands for itself. The form, though, locked the custom profile
into its three dropdowns.

Each now gets a final "free value..." entry revealing an input below it.
An invalid entry overwrites nothing: the resource falls back to the
catalog value instead of freezing the view.

The 3 vCPU preset was missing too, between 2 and 4; it comes from the
same constant, so both interfaces gain it together.

--- FR ---

La CLI acceptait déjà une valeur tapée — une lettre choisit une
suggestion, un chiffre vaut pour lui-même. Le formulaire, lui, enfermait
le profil personnalisé dans ses trois listes déroulantes.

Chacune reçoit donc un dernier choix, « valeur libre… », qui révèle une
saisie sous elle. Une entrée invalide n'écrase rien : la ressource
retombe sur celle du catalogue plutôt que de bloquer la vue.

Le préréglage 3 vCPU manquait aussi, entre 2 et 4 ; il vient de la même
constante, donc les deux interfaces l'offrent ensemble.

Assisted-by: Claude Opus 5
2026-08-16 06:11:44 -04:00
36fc4e319a [FIX] tui qemu: the form crashed on the resource totals
"TypeError: unsupported operand type(s) for +: 'int' and 'NoSelection'"
as soon as the custom resource profile was picked.

Textual 8 turned Select.BLANK into a deprecated alias worth False, the
sentinel having become Select.NULL. The guard compared against False and
filtered nothing; NoSelection, lacking __bool__ and thus truthy, sailed
through "or default" into the plan totals.

The sentinel is now resolved at runtime, and both summed fields are
coerced to a positive int inside the pure function -- where the invariant
belongs, whatever Textual version is installed.

--- FR ---

« TypeError: unsupported operand type(s) for +: 'int' and 'NoSelection' »
dès qu'on choisissait le profil personnalisé.

Textual 8 a ramené Select.BLANK à un alias déprécié valant False, le
sentinelle étant devenu Select.NULL. La garde comparait donc à False et
ne filtrait plus rien ; NoSelection, dépourvu de __bool__ et tenu pour
vrai, traversait « ou valeur par défaut » jusqu'aux sommes du plan.

Le sentinelle est résolu à l'exécution, et les deux champs additionnés
sont ramenés à un entier positif dans la fonction pure — c'est là que
l'invariant appartient, quelle que soit la version de Textual.

Assisted-by: Claude Opus 5
2026-08-16 06:11:44 -04:00
3fbde848f8 [ADD] poetry: pin a dependency per architecture
factur-x >= 4.0 needs saxonche, which Saxonica ships no s390x wheel for.
Pin 3.x there, 4.2+ everywhere else.

PEP 508 markers now survive the whole chain: kept instead of evaluated,
and each line reaches "poetry add" as a single argument -- the
"grep -v ';'" silently dropped them. Since "poetry add" dedupes by name,
the multi-constraint form, which the CLI cannot express, is written back
into the pyproject and the lock regenerated.

PyMuPDF is set aside on s390x for the same reason: MuPDF does not build
there. Along the way, the ignore list missed pymssql, whose key carried
its version, and two pre-existing solver deadlocks, lxml-html-clean and
s3fs, blocked any regeneration.

--- FR ---

factur-x >= 4.0 dépend de saxonche, sans roue s390x chez Saxonica. On
épingle la 3.x là-bas, la 4.2+ ailleurs.

Le marqueur PEP 508 traverse maintenant la chaîne : il est conservé au
lieu d'être évalué, et chaque ligne devient un seul argument de « poetry
add » — le « grep -v ';' » écartait ces lignes en silence. « poetry add »
dédupliquant par nom, la forme multi-contraintes, hors de portée du CLI,
est réécrite dans le pyproject, puis le lock refait.

PyMuPDF est écarté sur s390x pour la même raison : MuPDF ne s'y construit
pas. Au passage, la liste d'ignorés ratait pymssql, dont la clé portait sa
version, et deux blocages préexistants du solveur, lxml-html-clean et
s3fs, interdisaient toute régénération.

Assisted-by: Claude Opus 5
2026-08-16 06:11:44 -04:00
b939aafc03 [FIX] install s390x: the Debian build chain, package by package
Bringing s390x up on Debian and Ubuntu turned up one blocker at a time,
each found on a real machine: no wheel is published there, so everything
compiles against distribution headers. manifold3d needs tbb, cmake and
ninja; pymupdf loads libclang.so by its bare name through ctypes and only
a versioned one is packaged; cryptography and bcrypt need Rust; pikepdf
needs qpdf, whose -dev package is named differently across releases.

Two traps cost the most time. One unknown name made apt refuse the whole
batch of fifteen without ever saying which, so apt itself is now asked
what is installable, and a failed batch retries package by package. And a
failed unpack leaves dpkg half-configured, after which every apt call
blames packages that are in fact installed -- hence the repair step.

npm is the other half: npm@latest outran the packaged node and aborted
install_os, NodeSource publishes nothing for s390x, and Ubuntu's nodejs
package carries no npm on any release.

--- FR ---

Porter s390x sur Debian et Ubuntu a révélé un blocage à la fois, chacun
sur une machine réelle : aucune roue n'y est publiée, donc tout se compile
contre les en-têtes de la distribution. manifold3d exige tbb, cmake et
ninja ; pymupdf charge libclang.so par son nom nu via ctypes et seul un
nom versionné est empaqueté ; cryptography et bcrypt réclament Rust ;
pikepdf veut qpdf, dont le paquet -dev change de nom selon la version.

Deux pièges ont coûté le plus. Un seul nom inconnu faisait refuser à apt
le lot entier de quinze, sans jamais dire lequel : on demande désormais à
apt ce qui est installable, et un lot en échec reprend paquet par paquet.
Et un dépaquetage raté laisse dpkg à moitié configuré, après quoi tout
apt accuse des paquets pourtant installés — d'où l'étape de réparation.

npm est l'autre moitié : npm@latest dépassait le node empaqueté et
arrêtait install_os, NodeSource ne publie rien pour s390x, et le paquet
nodejs d'Ubuntu ne porte npm sur aucune version.

Assisted-by: Claude Opus 5
2026-08-16 06:11:44 -04:00
fd2e2ca7ef [FIX] todo: test menu, file browser and KeePass refusals
Four papercuts met while using the CLI: the test menu returned a verdict
nobody could find at the end of the output, the file browser never closed
once a file was picked, a wrong KeePass password killed the whole CLI, and
a configuration key had drifted from the code that reads it.

--- FR ---

Quatre irritants rencontrés à l'usage : le menu Test rendait un verdict
introuvable en fin de sortie, le navigateur de fichiers ne se fermait
jamais une fois le fichier choisi, un mauvais mot de passe KeePass tuait
tout le CLI, et une clé de configuration avait dérivé du code qui la lit.

Assisted-by: Claude Opus 5
2026-08-16 03:56:34 -04:00
16aecc2d33 [ADD] mail: read and send email from the TODO CLI
An IMAP/SMTP client in the menu, with its tests against real servers. Most
of the work went into refusals: an application password is named only when
the server actually refuses, an accented password is reported as never
having left the machine, and a refusal is recognised by what the server
SAYS rather than by matching its wording. A malformed date no longer takes
the whole folder down, and a received email is never read as markup.

--- FR ---

Un client IMAP/SMTP dans le menu, avec ses tests contre de vrais serveurs.
L'essentiel du travail porte sur les refus : le mot de passe d'application
n'est nommé que lorsque le serveur refuse vraiment, un mot de passe accentué
est signalé comme n'ayant jamais quitté la machine, et un refus se reconnaît
à ce que le serveur DIT plutôt qu'à ses mots. Une date illisible n'emporte
plus le dossier entier, et un courriel reçu n'est jamais lu comme du balisage.

Assisted-by: Claude Opus 5
2026-08-16 03:56:34 -04:00
60fb60e156 [IMP] qemu: a VM one can reach, and a first boot that does not stall
The monitor froze the address given at launch, so it lost the VM as soon as
cloud-init renamed the host and DHCP handed out another lease. It now
re-resolves at each attempt, in the views too, and reads virsh without sudo
— « sudo -n » fails in a detached session with no tty.

First boot also stopped paying for what it does not need: the guest agent
leaves cloud-init, snapd and locale-gen go, apt takes the fastest mirror.
The timezone follows the host. And when KVM is missing, the deployment says
so before the wait instead of being mysteriously fifteen times slower.

--- FR ---

Le suivi figeait l'adresse connue au lancement : il perdait donc la VM dès
que cloud-init posait le vrai nom d'hôte et que DHCP donnait un autre bail.
Il la ré-résout désormais à chaque tentative, dans les vues aussi, et lit
virsh sans sudo — « sudo -n » échoue dans une session détachée, sans tty.

Le premier démarrage cesse aussi de payer l'inutile : l'agent invité sort
de cloud-init, snapd et locale-gen disparaissent, apt prend le miroir le
plus rapide. Le fuseau suit l'hôte. Et faute de KVM, le déploiement le dit
avant l'attente, au lieu d'être quinze fois plus lent sans raison visible.

Assisted-by: Claude Opus 5
2026-08-10 03:10:50 -04:00
c84fbc559d [FIX] manifest: give repo init a branch name, not a bare SHA
repo cannot resolve a commit into a manifest revision on a FRESH workspace:
it gave up on « unparseable HEAD », then repo sync on « manifest not
found ». An already-initialised .repo accepts the SHA, which is why only
new installations were hit — four fresh VMs failed identically.

--- FR ---

repo ne sait pas résoudre un commit en révision de manifeste sur un espace
de travail NEUF : il abandonnait sur « unparseable HEAD », puis repo sync
sur « manifest not found ». Un .repo déjà initialisé accepte le SHA, d'où
un défaut limité aux installations neuves — quatre VM ont échoué ainsi.

Assisted-by: Claude Opus 5
2026-08-10 03:10:50 -04:00
b28b734338 [ADD] migration: see and repair the website COW views
A copy-on-write view freezes the module view it came from; the module moves
on and the upgrade dies hours later on a missing anchor. These tools
predict, snapshot, diff, neutralize and reset them. The migration screen
gains the real state of each step, replay from any of them, and statistics.

--- FR ---

Une vue copy-on-write fige la vue de module dont elle vient ; le module
évolue et la mise à niveau meurt des heures plus tard sur un point
d'ancrage absent. Ces outils les prévoient, photographient, comparent,
neutralisent et réinitialisent. L'écran de migration gagne l'état réel de
chaque étape, la reprise depuis n'importe laquelle, et des statistiques.

Assisted-by: Claude Opus 5
2026-08-10 03:10:50 -04:00
b1c1095977 [ADD] analyse: inspect an Odoo database without restoring it
A read-only toolkit answering « what is in this database, and what will
break on a version bump »: schema size and tables no model claims,
customised views compared with their module source, and the x_ fields
Studio left behind. It reads a backup zip directly, so a 40 GB dump needs
no restore, and it reaches every answer from the TODO menu.

--- FR ---

Une boîte à outils en lecture seule qui répond à « qu'y a-t-il dans cette
base, et qu'est-ce qui cassera à la montée de version » : taille du schéma
et tables qu'aucun modèle ne réclame, vues personnalisées comparées à leur
source, champs x_ laissés par Studio. Elle lit un zip de sauvegarde tel
quel, donc sans restaurer, et tout s'atteint depuis le menu TODO.

Assisted-by: Claude Opus 5
2026-08-10 03:10:50 -04:00
18a6f064f5 [FIX] execute: redact the secrets before printing a command
CodeQL flagged five clear-text logging alerts here, and they are real:
todo.py and kdbx_manager.py build « --default_password_auth '<KeePass
password>' » and db_restore.py « --master_password=… », while this file
printed the command before and after every run and logged it on error. The
« if "password" in command » guard covered one branch out of six.

Redaction sits at the display point, not at construction: six outputs here
against commands built all over the repository. Only the value goes, never
the option name, and the RETURNED command stays clear — « [1] redo the
command » needs it. Verified that PreferredAuthentications=password survives.

--- FR ---

CodeQL signalait ici cinq journalisations en clair, et elles sont réelles :
todo.py et kdbx_manager.py construisent « --default_password_auth '<mot de
passe KeePass>' » et db_restore.py « --master_password=… », tandis que ce
fichier affichait la commande avant et après chaque exécution et la
journalisait en erreur. Le garde « if "password" in command » couvrait une
branche sur six.

Le caviardage est au point d'affichage, non à la construction : six sorties
ici, contre des commandes bâties partout dans le dépôt. Seule la valeur
part, jamais le nom de l'option, et la commande RENVOYÉE reste en clair —
« [1] refaire la commande » en dépend. Vérifié que
PreferredAuthentications=password reste intact.

Assisted-by: Claude Opus 5
2026-08-07 03:41:26 -04:00
67e902e416 [IMP] todo: menu sections, icons and prompts
Groups the long menus into sections with icons, accepts o/oui everywhere,
shows the default of each yes/no question, and stops offering a traceback
line as a database name when PostgreSQL is unreachable.

--- FR ---

Regroupe les menus longs en sections avec icônes, accepte o/oui partout,
affiche la valeur par défaut de chaque question oui/non, et cesse de
proposer une ligne de trace d'appel comme nom de base quand PostgreSQL est
injoignable.

Assisted-by: Claude Opus 4.8
Assisted-by: Claude Opus 5
2026-08-07 03:41:26 -04:00