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