L'option des outils d'assistance posait trois installateurs amont, puis laissait tout à retaper : hook global de rtk, zdiff3, hooks git du dépôt, commandes Claude, activation du venv. Elle les pose, en deux temps parce que hooks et gabarits VIVENT dans le dépôt ; le complément suit le clone, rend toujours 0 — un confort ne fait pas échouer une VM — et sans clone la moitié manquante est nommée. core.editor n'est posé qu'à défaut, l'hôte transmettant déjà le sien. L'aide « ? » et F1 dit ce que chaque option installe, là où un libellé de case porte trois des huit poses. Vérifié : 51 tests, les deux écrans montés sans terminal, « bash -n » sur les fragments distants. --- EN --- The AI tools box posed three upstream installers, then left every setting to retype: rtk's global hook, zdiff3, the checkout's git hooks, the Claude commands, activating the venv. It poses them, in two phases because hooks and templates LIVE in the checkout; the complement follows the clone, always returns 0 — a comfort must not fail a VM — and with no clone the missing half is named. core.editor is posed only where there is none, the host already transmitting its own. The `?` and F1 help says what each option installs, where a checkbox label carries three of this one's eight poses. Checked: 51 tests, both screens mounted headless, `bash -n` on the remote fragments. Assisted-by: Claude Opus 5
445 lines
18 KiB
Python
445 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
"""Les outils d'assistance et leur pré-configuration, dans une VM.
|
|
|
|
rtk, starship et UN agent — Claude Code ou opencode — sont des installateurs
|
|
amont, donc autant de curl vers l'extérieur lancés sur un SSH sans terminal.
|
|
Autour d'eux : trois paquets de terminal, les réglages git, les hooks du
|
|
dépôt, les commandes Claude et une entrée d'historique.
|
|
|
|
L'outil travaille en DEUX temps, et c'est le partage que ces tests gardent en
|
|
premier : ce qui s'installe avant le clone, puis ce qui a besoin du dépôt.
|
|
|
|
Ce que ces tests gardent :
|
|
|
|
- une seule autorité pour les URL amont : l'hôte et la VM posent les mêmes
|
|
outils, et deux copies dérivent dès que l'amont en change une ;
|
|
- aucune pose ne peut PENDRE : « || true » couvre l'échec, pas l'attente
|
|
d'une réponse que personne ne donnera ;
|
|
- toute ligne ajoutée à un fichier du HOME l'est UNE fois, sinon chaque
|
|
redéploiement d'une même VM le rallonge ;
|
|
- la pré-configuration rend TOUJOURS 0 : c'est l'installation qui porte le
|
|
verdict de la VM, pas un confort ;
|
|
- ce qui manque sans clone est NOMMÉ, jamais tu ;
|
|
- l'identité git saisie prime sur celle de l'hôte, champ par champ.
|
|
"""
|
|
|
|
import importlib.util
|
|
import shlex
|
|
import subprocess
|
|
import sys
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
sys.argv = ["todo.py"]
|
|
from script.todo import dev_tools # noqa: E402
|
|
from script.todo.deploy_form_lib import build_spec # noqa: E402
|
|
from script.todo.todo import TODO # noqa: E402
|
|
|
|
RACINE = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def _deploy_qemu():
|
|
chemin = RACINE / "script/qemu/deploy_qemu.py"
|
|
spec = importlib.util.spec_from_file_location("deploy_qemu", chemin)
|
|
mod = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(mod)
|
|
return mod
|
|
|
|
|
|
DQ = _deploy_qemu()
|
|
|
|
|
|
class UneSeuleAutorite(unittest.TestCase):
|
|
"""Les mêmes outils se posent sur l'hôte et dans une VM."""
|
|
|
|
def test_the_host_reads_the_shared_table(self):
|
|
"""Deux copies d'une URL dérivent dès que l'amont en change une, et
|
|
la seconde est celle qu'on oublie."""
|
|
self.assertIs(TODO._UPSTREAM_TOOLS, dev_tools.AGENTS)
|
|
self.assertIs(TODO._STARSHIP_LINE, dev_tools.STARSHIP_LINE)
|
|
self.assertEqual(TODO._STARSHIP_UPSTREAM, dev_tools.STARSHIP_UPSTREAM)
|
|
|
|
def test_both_agents_are_offered(self):
|
|
self.assertEqual(["claude", "opencode"], sorted(dev_tools.AGENTS))
|
|
|
|
def test_the_default_agent_is_one_of_them(self):
|
|
"""Le premier de la table ferait dépendre le défaut de l'ordre
|
|
d'écriture d'un dictionnaire."""
|
|
self.assertIn(dev_tools.AGENT_DEFAUT, dev_tools.AGENTS)
|
|
|
|
|
|
class LeCatalogue(unittest.TestCase):
|
|
def test_the_tool_exists_and_needs_no_desktop(self):
|
|
"""On s'en sert en SSH : une VM serveur le prend aussi."""
|
|
spec = TODO._QEMU_VM_TOOLS["aidev"]
|
|
self.assertFalse(spec["needs_desktop"])
|
|
self.assertEqual((), spec["arches"])
|
|
self.assertEqual((), spec["families"])
|
|
|
|
def test_it_is_posed_before_the_clone(self):
|
|
"""« before » est la phase où chaque outil se garde lui-même. En
|
|
« after », un curl sans réponse rendrait la VM rouge."""
|
|
self.assertEqual("before", TODO._QEMU_VM_TOOLS["aidev"]["phase"])
|
|
|
|
|
|
class LaCommandeDistante(unittest.TestCase):
|
|
def _cmd(self, agent=""):
|
|
return TODO.__new__(TODO)._qemu_aidev_remote_cmd(agent)
|
|
|
|
def test_it_poses_the_three_tools(self):
|
|
cmd = self._cmd("claude")
|
|
self.assertIn("rtk", cmd)
|
|
self.assertIn("starship", cmd)
|
|
self.assertIn("claude.ai", cmd)
|
|
|
|
def test_the_chosen_agent_is_the_one_posed(self):
|
|
"""Un seul agent, celui qu'on a demandé : poser les deux
|
|
installerait un outil que personne n'a coché."""
|
|
claude = self._cmd("claude")
|
|
opencode = self._cmd("opencode")
|
|
self.assertIn("claude.ai", claude)
|
|
self.assertNotIn("opencode.ai", claude)
|
|
self.assertIn("opencode.ai", opencode)
|
|
self.assertNotIn("claude.ai", opencode)
|
|
|
|
def test_an_unknown_agent_falls_back(self):
|
|
"""Une valeur inattendue ne doit pas faire tomber un déploiement."""
|
|
self.assertIn("claude.ai", self._cmd("gemini"))
|
|
self.assertIn("claude.ai", self._cmd(""))
|
|
|
|
def test_no_pose_can_hang(self):
|
|
"""« || true » couvre l'ÉCHEC, pas l'ATTENTE. Un installateur qui
|
|
pose une question resterait pendu sur un SSH sans terminal, et le
|
|
déploiement avec lui.
|
|
|
|
L'invariant est vérifié pose par pose plutôt que par un compte : un
|
|
compte figé se contente d'être mis à jour quand une pose s'ajoute,
|
|
sans rien dire de la nouvelle."""
|
|
cmd = self._cmd("claude")
|
|
bornees = [x for x in cmd.split("; ") if "timeout " in x]
|
|
self.assertGreaterEqual(len(bornees), 4)
|
|
for morceau in bornees:
|
|
self.assertIn("</dev/null", morceau)
|
|
self.assertIn("|| true", morceau)
|
|
|
|
def test_starship_is_told_not_to_ask(self):
|
|
"""Sans « -y », son installateur attend une confirmation."""
|
|
self.assertIn("-s -- -y", self._cmd("claude"))
|
|
|
|
def test_the_rc_lines_are_written_once(self):
|
|
"""Sans le « grep » qui précède, chaque redéploiement d'une même VM
|
|
rallonge son ~/.bashrc — ou son historique — d'une ligne identique.
|
|
|
|
Aucun ajout n'échappe à la règle : on compte les « >> » et non les
|
|
greps, pour qu'une ligne ajoutée sans garde fasse tomber le test."""
|
|
for agent in ("claude", "opencode"):
|
|
cmd = self._cmd(agent)
|
|
with self.subTest(agent=agent):
|
|
self.assertEqual(cmd.count(">> "), cmd.count("grep -qF"))
|
|
for morceau in cmd.split("; "):
|
|
if ">> " in morceau:
|
|
self.assertIn("grep -qF", morceau)
|
|
|
|
def test_the_local_bin_is_on_the_path_for_every_agent(self):
|
|
"""rtk se pose dans ~/.local/bin. La ligne de l'agent ne couvre ce
|
|
répertoire que pour Claude Code : avec opencode, qui s'installe
|
|
ailleurs, rtk resterait introuvable dans la VM."""
|
|
for agent in ("claude", "opencode"):
|
|
with self.subTest(agent=agent):
|
|
self.assertIn(
|
|
'export PATH="$HOME/.local/bin:$PATH"', self._cmd(agent)
|
|
)
|
|
|
|
def test_an_identical_path_line_is_not_written_twice(self):
|
|
"""Claude Code S'INSTALLE dans ~/.local/bin : deux lignes identiques
|
|
n'auraient qu'un effet, et deux fois la place dans le journal."""
|
|
cmd = self._cmd("claude")
|
|
self.assertEqual(1, cmd.count("$HOME/.local/bin:$PATH"))
|
|
|
|
def test_the_terminal_tools_are_posed(self):
|
|
"""tig, htop et vim portent le même nom dans les quatre familles :
|
|
aucune n'a de raison de les rater."""
|
|
cmd = self._cmd("claude")
|
|
for gestionnaire in ("apt-get", "dnf", "zypper", "pacman"):
|
|
self.assertIn(gestionnaire, cmd)
|
|
self.assertEqual(4, cmd.count("tig htop vim"))
|
|
|
|
def test_the_venv_activation_reaches_the_history(self):
|
|
"""La commande qu'on veut retrouver à la flèche du haut, dans le
|
|
fichier que bash relit, et une seule fois."""
|
|
cmd = self._cmd("claude")
|
|
ligne = "source .venv.erplibre/bin/activate"
|
|
self.assertIn(f"echo '{ligne}' >> ~/.bash_history", cmd)
|
|
self.assertIn(f"grep -qF '{ligne}' ~/.bash_history", cmd)
|
|
|
|
def test_the_history_keeps_the_rights_bash_gives_it(self):
|
|
"""Créé par une redirection, le fichier suit l'umask — lisible par
|
|
tous sur les images visées, là où bash le crée en 600."""
|
|
self.assertIn("chmod 600 ~/.bash_history", self._cmd("claude"))
|
|
|
|
def test_the_rtk_hook_is_called_by_absolute_path(self):
|
|
"""Le PATH de cette commande distante a été figé au démarrage du
|
|
shell SSH, avant que l'installateur ne pose le binaire : « rtk » nu
|
|
rendrait 127 sans dire que le hook n'a pas été écrit."""
|
|
cmd = self._cmd("claude")
|
|
self.assertIn('RTK="$(command -v rtk || echo "$HOME', cmd)
|
|
self.assertIn('"$RTK" init --global', cmd)
|
|
|
|
def test_git_is_configured_without_taking_the_editor_over(self):
|
|
"""zdiff3 est posé sans condition. L'éditeur, lui, ne l'est que s'il
|
|
n'y en a pas : deploy_qemu transmet celui de l'hôte, et deux
|
|
autorités sur un même réglage en font une de trop."""
|
|
cmd = self._cmd("claude")
|
|
self.assertIn("git config --global merge.conflictStyle zdiff3", cmd)
|
|
self.assertIn(
|
|
"git config --global --get core.editor >/dev/null 2>&1"
|
|
" || git config --global core.editor vim",
|
|
cmd,
|
|
)
|
|
|
|
def test_the_git_settings_survive_a_vm_without_git(self):
|
|
"""La phase « before » d'une VM sans installation ERPLibre n'a pas vu
|
|
l'amorçage qui pose git : hors d'un « if », « set -e » ferait tomber
|
|
le déploiement sur une commande introuvable."""
|
|
cmd = self._cmd("claude")
|
|
self.assertIn("if command -v git >/dev/null 2>&1; then", cmd)
|
|
|
|
def test_the_path_uses_home_not_a_tilde(self):
|
|
"""Entre guillemets, le tilde n'est pas étendu : le PATH porterait
|
|
un répertoire nommé « ~ », qui n'existe pas."""
|
|
cmd = self._cmd("opencode")
|
|
self.assertIn('export PATH="$HOME/.opencode/bin:$PATH"', cmd)
|
|
self.assertNotIn('PATH="~/', cmd)
|
|
|
|
def test_it_is_valid_shell(self):
|
|
"""Une commande mal citée casse tout le bloc des outils, pas
|
|
seulement le sien."""
|
|
for agent in ("claude", "opencode", ""):
|
|
with self.subTest(agent=agent):
|
|
fini = subprocess.run(
|
|
["bash", "-n"],
|
|
input=self._cmd(agent),
|
|
text=True,
|
|
capture_output=True,
|
|
)
|
|
self.assertEqual(0, fini.returncode, fini.stderr)
|
|
|
|
def test_nothing_is_posed_without_the_box(self):
|
|
todo = TODO.__new__(TODO)
|
|
cmd = todo._qemu_erplibre_remote_cmd("develop", tools=())
|
|
self.assertNotIn("starship", cmd)
|
|
|
|
def test_the_agent_reaches_the_remote_command(self):
|
|
"""L'épreuve du bout en bout : le choix doit traverser toute la
|
|
chaîne, sans quoi il reste un réglage sans effet."""
|
|
todo = TODO.__new__(TODO)
|
|
cmd = todo._qemu_erplibre_remote_cmd(
|
|
"develop", tools=("aidev",), ai_agent="opencode"
|
|
)
|
|
self.assertIn("opencode.ai", cmd)
|
|
|
|
|
|
class LeComplementApresClone(unittest.TestCase):
|
|
"""Ce que la pré-configuration ne peut poser qu'une fois le dépôt là."""
|
|
|
|
def _cmd(self, prod=False):
|
|
return TODO.__new__(TODO)._qemu_aidev_after_cmd(prod)
|
|
|
|
def test_the_hooks_point_at_the_checkout(self):
|
|
"""« git -C » et non le cwd : le dépôt porte des dépôts imbriqués, et
|
|
core.hooksPath écrit dans l'un d'eux laisserait la racine sans
|
|
garde-fou, sans le moindre message."""
|
|
cmd = self._cmd()
|
|
self.assertIn(
|
|
"git -C $HOME/git/erplibre config core.hooksPath"
|
|
" script/git/hooks",
|
|
cmd,
|
|
)
|
|
|
|
def test_the_hooks_get_their_execution_bit(self):
|
|
"""git saute SANS RIEN DIRE un hook qui ne l'a pas, et le garde-fou du
|
|
message de commit passe alors inaperçu."""
|
|
cmd = self._cmd()
|
|
for hook in TODO._GIT_HOOKS:
|
|
self.assertIn(f"hooks/{hook}", cmd)
|
|
self.assertIn("chmod +x", cmd)
|
|
|
|
def test_every_claude_command_is_deployed(self):
|
|
"""Une commande absente de la liste est une commande que la VM n'aura
|
|
pas, et personne ne s'en aperçoit avant d'en avoir besoin."""
|
|
cmd = self._cmd()
|
|
for nom, gabarit in TODO._QEMU_AIDEV_CLAUDE_CMDS:
|
|
self.assertIn(f"conf/{gabarit}", cmd)
|
|
self.assertIn(f"~/.claude/commands/{nom}.md", cmd)
|
|
|
|
def test_the_two_todo_commands_travel_together(self):
|
|
"""/todo_plan_max produit la spécification que /todo_add_command
|
|
implémente : l'une sans l'autre laisse la moitié de la chaîne."""
|
|
noms = [nom for nom, _g in TODO._QEMU_AIDEV_CLAUDE_CMDS]
|
|
self.assertIn("todo_plan_max", noms)
|
|
self.assertIn("todo_add_command", noms)
|
|
|
|
def test_the_identity_is_substituted_literally(self):
|
|
"""En python3 et non en sed : un nom qui porterait « & » ou le
|
|
séparateur choisi changerait de sens dans un « s/// »."""
|
|
cmd = self._cmd()
|
|
self.assertIn("python3 -c", cmd)
|
|
for marque, cle in TODO._QEMU_AIDEV_PLACEHOLDERS:
|
|
self.assertIn(marque, cmd)
|
|
self.assertIn(cle, cmd)
|
|
|
|
def test_it_follows_production_to_opt(self):
|
|
cmd = self._cmd(True)
|
|
self.assertIn("/opt/erplibre/conf/", cmd)
|
|
self.assertNotIn("$HOME/git/erplibre", cmd)
|
|
|
|
def test_nothing_in_it_can_fail_the_vm(self):
|
|
"""Une pré-configuration est un confort. Chaque commande se garde, et
|
|
la dernière — un compte-rendu — rend 0 par construction."""
|
|
for prod in (False, True):
|
|
with self.subTest(prod=prod):
|
|
cmd = self._cmd(prod)
|
|
for morceau in cmd.split("; "):
|
|
if not morceau.strip() or morceau.startswith("echo "):
|
|
continue
|
|
self.assertIn("|| true", morceau)
|
|
|
|
def test_it_is_valid_shell(self):
|
|
for prod in (False, True):
|
|
with self.subTest(prod=prod):
|
|
fini = subprocess.run(
|
|
["bash", "-n"],
|
|
input=self._cmd(prod),
|
|
text=True,
|
|
capture_output=True,
|
|
)
|
|
self.assertEqual(0, fini.returncode, fini.stderr)
|
|
|
|
def test_it_leads_the_after_phase(self):
|
|
"""En tête : quelques secondes de copies, contre une minute pour
|
|
Forgejo et une heure pour le SDK Android."""
|
|
todo = TODO.__new__(TODO)
|
|
cmd = todo._qemu_after_remote_cmd(("aidev", "forgejo"), False)
|
|
self.assertLess(
|
|
cmd.index("core.hooksPath"), cmd.index("install_forgejo.sh")
|
|
)
|
|
|
|
def test_it_reaches_the_full_remote_command(self):
|
|
"""L'épreuve du bout en bout : sans cela, la moitié « dépôt » de la
|
|
case resterait un réglage sans effet."""
|
|
todo = TODO.__new__(TODO)
|
|
cmd = todo._qemu_erplibre_remote_cmd("develop", tools=("aidev",))
|
|
self.assertIn("core.hooksPath", cmd)
|
|
self.assertIn("~/.claude/commands/commit.md", cmd)
|
|
|
|
def test_a_vm_without_a_checkout_says_what_it_skips(self):
|
|
"""Écarter en silence laisse croire qu'une case cochée a été
|
|
honorée. Les installations, elles, ont bien lieu."""
|
|
todo = TODO.__new__(TODO)
|
|
cmd = todo._qemu_erplibre_remote_cmd(
|
|
"", tools=("aidev",), desktop="gnome"
|
|
)
|
|
self.assertIn("starship", cmd)
|
|
self.assertNotIn("core.hooksPath", cmd)
|
|
self.assertIn("⚠", cmd)
|
|
|
|
|
|
class LIdentiteGit(unittest.TestCase):
|
|
"""Le formulaire montre l'identité de l'hôte et permet de la changer."""
|
|
|
|
def _args(self, **kw):
|
|
argv = ["--distro", "ubuntu", "--hostname", "vm"]
|
|
for cle, valeur in kw.items():
|
|
argv += [f"--{cle.replace('_', '-')}", valeur]
|
|
return DQ.build_parser().parse_args(argv)
|
|
|
|
def _identite(self, cc):
|
|
lu = {}
|
|
for ligne in cc.split("\n"):
|
|
for champ in ("name", "email"):
|
|
if ligne.strip().startswith(f"{champ} = "):
|
|
lu[champ] = ligne.split(" = ", 1)[1]
|
|
return lu
|
|
|
|
def test_what_was_typed_wins(self):
|
|
cc = DQ.build_cloud_config(
|
|
self._args(
|
|
git_name="Une Personne", git_email="qui@exemple.invalid"
|
|
),
|
|
None,
|
|
[],
|
|
)
|
|
self.assertEqual(
|
|
{"name": "Une Personne", "email": "qui@exemple.invalid"},
|
|
self._identite(cc),
|
|
)
|
|
|
|
def test_each_field_stands_alone(self):
|
|
"""Remplir le seul courriel ne doit pas effacer le nom : les deux
|
|
retombent sur l'hôte SÉPARÉMENT."""
|
|
cc = DQ.build_cloud_config(
|
|
self._args(git_email="qui@exemple.invalid"), None, []
|
|
)
|
|
lu = self._identite(cc)
|
|
self.assertEqual("qui@exemple.invalid", lu.get("email"))
|
|
self.assertNotEqual("", lu.get("name", ""))
|
|
|
|
def test_the_option_reaches_the_command(self):
|
|
"""Le réglage doit atteindre deploy_qemu, sinon il n'existe pas."""
|
|
todo = TODO.__new__(TODO)
|
|
cmd = todo._qemu_build_deploy_parts(
|
|
"ubuntu",
|
|
"24.04",
|
|
"amd64",
|
|
"vm",
|
|
2048,
|
|
2,
|
|
"20G",
|
|
"",
|
|
"",
|
|
True,
|
|
git_name="Une Personne",
|
|
git_email="qui@exemple.invalid",
|
|
)
|
|
self.assertIn("--git-name", cmd)
|
|
self.assertIn("Une Personne", cmd)
|
|
self.assertIn("--git-email", cmd)
|
|
|
|
def test_an_empty_identity_says_nothing(self):
|
|
"""Vide, deploy_qemu recopie l'hôte : ajouter l'option avec une
|
|
valeur vide écraserait cette identité par du rien."""
|
|
todo = TODO.__new__(TODO)
|
|
cmd = todo._qemu_build_deploy_parts(
|
|
"ubuntu", "24.04", "amd64", "vm", 2048, 2, "20G", "", "", True
|
|
)
|
|
self.assertNotIn("--git-name", cmd)
|
|
self.assertNotIn("--git-email", cmd)
|
|
|
|
|
|
class LaSpec(unittest.TestCase):
|
|
def test_the_three_settings_reach_the_spec(self):
|
|
"""Absents de l'assemblée, les réglages du formulaire n'atteignent
|
|
jamais le déploiement — la case resterait décorative."""
|
|
spec = build_spec(
|
|
[],
|
|
[],
|
|
{
|
|
"res_label": "x1",
|
|
"ssh_key": "",
|
|
"install": None,
|
|
"add_ssh_config": False,
|
|
"parallelism": 1,
|
|
"ai_agent": "opencode",
|
|
"git_name": "Une Personne",
|
|
"git_email": "qui@exemple.invalid",
|
|
},
|
|
)
|
|
self.assertEqual("opencode", spec["ai_agent"])
|
|
self.assertEqual("Une Personne", spec["git_name"])
|
|
self.assertEqual("qui@exemple.invalid", spec["git_email"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|