# machine-backup — Plan d'implémentation

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Objectif :** Construire `tools/machine-backup/`, un outil CLI Python de backup/restore reproductible de la machine (PostgreSQL, Homebrew, config shell), avec archive chiffrée `age`, manifest en clair, restore en dry-run par défaut et journalisation complète.

**Architecture :** Un cœur générique (contrat `Module` en `Protocol` + registre, états d'`Action` `satisfied`/`additive`/`destructive`, manifest, crypto, logs, console) implémenté et testé en premier avec un module factice, puis trois modules concrets branchés dessus dans l'ordre de valeur : `postgres`, `homebrew`, `shell`. Un `bootstrap.sh` POSIX idempotent amorce brew/uv puis délègue au CLI.

**Stack :** Python ≥ 3.12 géré par `uv` exclusivement (jamais pip/venv/poetry), `rich` (seule dépendance runtime), `pytest` (dev), binaires système `tar`, `zstd`, `age`, `brew`, `pg_dump`/`pg_dumpall`/`psql` — vérifiés au démarrage, jamais supposés.

## Contraintes globales (s'appliquent à CHAQUE tâche)

- Emplacement : `tools/machine-backup/`, self-contained (README, deps, point d'entrée). Aucun fichier de l'outil ailleurs.
- Python via `uv` uniquement : `uv run`, `uv sync`, `uv add`. Tests : `uv run pytest`.
- Le module de logs s'appelle **`logs.py`**, jamais `logging.py` (ombrage stdlib).
- INVARIANT : l'outil ne capture ni ne journalise **jamais** de variables d'environnement, et ne modifie **jamais** l'environnement de l'utilisateur (pas d'écriture PATH, pas d'`export` injecté, pas de `env=` loggé). Ne jamais lire `$SHELL` : utiliser `pwd.getpwuid(os.getuid()).pw_shell`.
- `debug.log` : toujours complet en DEBUG dans `~/.local/state/machine-backup/logs/` (dossier `0700`, rotation 20), **jamais** dans le snapshot. Rédaction des secrets obligatoire avant écriture.
- Restore : dry-run par défaut ; `--apply` pour exécuter ; actions `destructive` refusées sauf `--force <module>`.
- Chiffrement `age` systématique de l'archive ; `manifest.json` en clair à côté.
- Modules v1 : `postgres`, `homebrew`, `shell` — aucun autre. Ordre du registre = cet ordre.
- Sous-commandes CLI : `backup | list | restore | verify | doctor`. Options globales `-v`/`-vv`, `--progress`/`--no-progress`.
- Commits : Conventional Commits, scope `machine-backup`. Travailler sur une branche `feature/machine-backup` créée depuis `dev` (jamais de commit sur `main`).
- Toute commande externe passe par `run_logged` (argv explicite, jamais `shell=True`).
- Environnement machine réel (à respecter, pas à découvrir) : macOS arm64, brew dans `/opt/homebrew`, shell de login fish, PostgreSQL 18 keg-only (`/opt/homebrew/opt/postgresql@18/bin`), bases `advmfr_hr`, `elevaspeed`, `seo_insight_dev`, `seo_insight_test`, `postgres` ; résidus fish à exclure : `config.fish.backup`, `config.fish.bak-*`.

## Préambule git (une fois, avant la tâche 1)

```bash
cd "/Users/humperdoo/Github/Internal Tool"
git checkout dev && git pull
git checkout -b feature/machine-backup
```

---

## Structure de fichiers cible

```
tools/machine-backup/
  bootstrap.sh              # POSIX, idempotent (tâche 16)
  README.md                 # (tâche 17)
  pyproject.toml            # (tâche 1)
  mise.toml                 # python épinglé (tâche 1)
  src/machine_backup/
    __init__.py             # __version__ (tâche 1)
    cli.py                  # parsing + orchestrateurs backup/restore (tâches 1, 8, 9, 10, 14)
    config.py               # (tâche 5)
    manifest.py             # (tâche 6)
    crypto.py               # (tâche 7)
    sync.py                 # (tâche 15)
    console.py              # (tâche 4)
    logs.py                 # (tâche 3)
    modules/
      __init__.py           # importe les modules → enregistrement (tâches 2, 11-13)
      base.py               # Protocol + types + registre (tâche 2)
      postgres.py           # (tâche 11)
      homebrew.py           # (tâche 12)
      shell.py              # (tâche 13)
  tests/
    conftest.py             # FakeModule + fixtures (tâche 2)
    test_cli.py  test_base.py  test_logs.py  test_console.py  test_config.py
    test_manifest.py  test_crypto.py  test_backup_flow.py  test_list_verify.py
    test_restore_flow.py  test_postgres.py  test_homebrew.py  test_shell.py
    test_doctor.py  test_sync.py  test_bootstrap.py
```

## Formats de données de référence

### `manifest.json` (structure exacte, schema_version 1)

```json
{
  "schema_version": 1,
  "tool_version": "0.1.0",
  "created_at": "2026-08-05T14:30:12+02:00",
  "hostname": "mbp-humperdoo",
  "macos_version": "15.5",
  "status": "complete",
  "modules": {
    "postgres": {
      "status": "ok",
      "error": null,
      "duration_s": 41.2,
      "meta": {},
      "artifacts": [
        {"path": "postgres/globals.sql",     "sha256": "<hex64>", "size": 2048,     "sensitive": true},
        {"path": "postgres/advmfr_hr.dump",  "sha256": "<hex64>", "size": 10485760, "sensitive": true}
      ]
    },
    "homebrew": {
      "status": "ok", "error": null, "duration_s": 3.1, "meta": {},
      "artifacts": [
        {"path": "homebrew/Brewfile", "sha256": "<hex64>", "size": 9214, "sensitive": false}
      ]
    },
    "shell": {
      "status": "ok", "error": null, "duration_s": 0.4,
      "meta": {"login_shell": "/opt/homebrew/bin/fish"},
      "artifacts": [
        {"path": "shell/meta.json",           "sha256": "<hex64>", "size": 54,   "sensitive": false},
        {"path": "shell/fish/config.fish",    "sha256": "<hex64>", "size": 3120, "sensitive": false},
        {"path": "shell/fish/fish_variables", "sha256": "<hex64>", "size": 8123, "sensitive": true}
      ]
    }
  },
  "archive": {"file": "snapshot.tar.zst.age", "sha256": "<hex64>", "size": 52428800}
}
```

Règles :
- `status` global : `"complete"` si tous les modules sélectionnés sont `ok`, sinon `"partial"`.
- `status` module : `"ok" | "skipped" | "failed"` ; `error` non nul si `skipped`/`failed`.
- `path` : relatif à la racine du snapshot, séparateur `/`, jamais de `./` en tête.
- Le manifest est écrit **deux fois** : (1) dans `tmp/manifest.json` **sans** le bloc `archive` (il part dans l'archive — le hash de l'archive ne peut pas être dans l'archive) ; (2) en clair à côté de l'archive, **avec** le bloc `archive`. C'est la copie en clair qui fait foi pour `list` et `verify`.
- `created_at` : ISO 8601 heure locale avec offset (`datetime.now().astimezone().isoformat(timespec="seconds")`).

### Snapshot sur disque

```
~/Backups/machine/2026-08-05T14-30-12/        # dossier 0700
  manifest.json           # EN CLAIR (avec bloc archive)
  snapshot.tar.zst.age    # tar de tmp/ (manifest sans archive + <module>/…), zstd, age
```

Horodatage du dossier : `%Y-%m-%dT%H-%M-%S` heure locale (tirets, pas de `:`).

### `~/.config/machine-backup/config.toml`

```toml
[backup]
destination = "~/Backups/machine"   # défaut
keep = 7                            # rotation snapshots, défaut

[age]
recipient = "age1..."               # clé PUBLIQUE — requis pour backup
identity = "~/.config/machine-backup/identity.txt"  # clé PRIVÉE — requis pour restore/verify

[remote]
target = ""                         # vide = pas de sync

[logs]
keep = 20                           # rotation debug.log, défaut
```

Fichier absent → défauts (recipient/identity `None`). `recipient` manquant au moment d'un `backup` → erreur explicite, exit 2. `identity` manquante au moment d'un `restore`/`verify` → erreur explicite, exit 2.

### Codes de sortie

- `0` : succès complet.
- `1` : backup partiel (module `skipped`/`failed`), échec de `verify`, erreur pendant `restore --apply`, échec critique `doctor`.
- `2` : erreur d'usage ou de configuration (recipient manquant, snapshot introuvable, module inconnu dans `--only`/`--force`).

---

### Tâche 1 : Squelette du projet + CLI minimal

**Files:**
- Create: `tools/machine-backup/pyproject.toml`
- Create: `tools/machine-backup/mise.toml`
- Create: `tools/machine-backup/src/machine_backup/__init__.py`
- Create: `tools/machine-backup/src/machine_backup/cli.py`
- Test: `tools/machine-backup/tests/test_cli.py`

**Interfaces:**
- Produces: `machine_backup.__version__: str` (= `"0.1.0"`) ; `cli.main(argv: list[str] | None = None) -> int` ; `cli.build_parser() -> argparse.ArgumentParser` avec les 5 sous-commandes (stubs retournant 0), options globales `-v` (cumulable), `--progress`, `--no-progress` ; entry point console `machine-backup`.

- [ ] **Étape 1 : écrire les tests qui échouent**

```python
# tests/test_cli.py
import pytest
from machine_backup import __version__
from machine_backup.cli import build_parser, main

def test_version_string():
    assert __version__ == "0.1.0"

@pytest.mark.parametrize("cmd", ["backup", "list", "restore", "verify", "doctor"])
def test_subcommands_exist(cmd):
    argv = {"restore": [cmd, "latest"], "verify": [cmd, "latest"]}.get(cmd, [cmd])
    args = build_parser().parse_args(argv)
    assert args.command == cmd

def test_verbosity_cumulative():
    args = build_parser().parse_args(["-vv", "doctor"])
    assert args.verbose == 2

def test_progress_flags_mutually_exclusive():
    with pytest.raises(SystemExit):
        build_parser().parse_args(["--progress", "--no-progress", "doctor"])

def test_main_no_command_exits_2():
    assert main([]) == 2
```

- [ ] **Étape 2 : vérifier l'échec** — `cd tools/machine-backup && uv sync && uv run pytest tests/test_cli.py -v` → FAIL (`ModuleNotFoundError`).

- [ ] **Étape 3 : implémentation minimale**

`pyproject.toml` :

```toml
[project]
name = "machine-backup"
version = "0.1.0"
description = "Backup et restore reproductibles de la machine (postgres, homebrew, shell)"
requires-python = ">=3.12"
dependencies = ["rich>=13"]

[project.scripts]
machine-backup = "machine_backup.cli:main"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/machine_backup"]

[tool.pytest.ini_options]
testpaths = ["tests"]

[dependency-groups]
dev = ["pytest>=8"]
```

`mise.toml` :

```toml
[tools]
python = "3.12"
```

`src/machine_backup/__init__.py` : `__version__ = "0.1.0"`.

`src/machine_backup/cli.py` :

```python
from __future__ import annotations
import argparse
from machine_backup import __version__

def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(prog="machine-backup")
    p.add_argument("--version", action="version", version=__version__)
    p.add_argument("-v", "--verbose", action="count", default=0)
    g = p.add_mutually_exclusive_group()
    g.add_argument("--progress", dest="progress", action="store_true", default=None)
    g.add_argument("--no-progress", dest="progress", action="store_false")
    sub = p.add_subparsers(dest="command")

    b = sub.add_parser("backup")
    b.add_argument("--only", type=lambda s: s.split(","), default=None)
    b.add_argument("--no-sync", action="store_true")

    sub.add_parser("list")

    r = sub.add_parser("restore")
    r.add_argument("snapshot")                       # "latest" ou horodatage
    r.add_argument("--only", type=lambda s: s.split(","), default=None)
    r.add_argument("--shell", choices=["fish", "zsh", "both"], default="both")
    r.add_argument("--apply", action="store_true")
    r.add_argument("--force", action="append", default=[], metavar="MODULE")

    v = sub.add_parser("verify")
    v.add_argument("snapshot")

    sub.add_parser("doctor")
    return p

def main(argv: list[str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    if args.command is None:
        build_parser().print_help()
        return 2
    return 0   # stubs — remplacés dans les tâches 8, 9, 10, 14
```

- [ ] **Étape 4 : vérifier** — `uv run pytest tests/test_cli.py -v` → PASS ; `uv run machine-backup --version` → `0.1.0`.

- [ ] **Étape 5 : commit** — `git add tools/machine-backup && git commit -m "feat(machine-backup): squelette du projet et CLI minimal"`

---

### Tâche 2 : Contrat Module, états d'Action, registre (LE CŒUR — avant tout module concret)

**Files:**
- Create: `tools/machine-backup/src/machine_backup/modules/__init__.py`
- Create: `tools/machine-backup/src/machine_backup/modules/base.py`
- Create: `tools/machine-backup/tests/conftest.py`
- Test: `tools/machine-backup/tests/test_base.py`

**Interfaces (Produces — tout le reste du plan en dépend, ne pas dévier) :**

```python
class ActionState(str, Enum): SATISFIED="satisfied"; ADDITIVE="additive"; DESTRUCTIVE="destructive"
class ModuleStatus(str, Enum): OK="ok"; SKIPPED="skipped"; FAILED="failed"

@dataclass(frozen=True)
class Problem: module: str; message: str; fatal: bool = True

@dataclass(frozen=True)
class Action: module: str; state: ActionState; description: str; reason: str = ""

@dataclass(frozen=True)
class Artifact: path: str; sha256: str; size: int; sensitive: bool = False

@dataclass
class ModuleResult:
    module: str
    status: ModuleStatus
    artifacts: list[Artifact] = field(default_factory=list)
    duration_s: float = 0.0
    error: str | None = None
    meta: dict[str, str] = field(default_factory=dict)

@runtime_checkable
class Module(Protocol):
    name: str
    def check(self) -> list[Problem]: ...
    def collect(self, dest: Path) -> ModuleResult: ...
    def plan_restore(self, src: Path) -> list[Action]: ...
    def restore(self, src: Path, actions: list[Action], force: bool) -> ModuleResult: ...

def register(name: str)                      # décorateur de classe ; ordre d'enregistrement préservé
def available_modules() -> list[str]         # ["postgres", "homebrew", "shell"] une fois la v1 complète
def create_modules(only: list[str] | None = None, **kwargs) -> list[Module]
    # only inconnu → ValueError("module inconnu: <nom>") ; kwargs (ex. shell_choice) passés
    # uniquement aux constructeurs qui les acceptent (inspect.signature)
```

Fixture partagée `tests/conftest.py` : `FakeModule` (module factice complet, configurable : `problems`, `fail_collect`, plan retournant les trois états) + fixture `fake_module`. Le FakeModule écrit `data.txt` (contenu `"payload"`) dans `dest` et retourne un `ModuleResult` OK avec l'artifact hashé.

- [ ] **Étape 1 : tests qui échouent**

```python
# tests/test_base.py
from pathlib import Path
import pytest
from machine_backup.modules import base
from machine_backup.modules.base import (Action, ActionState, Module, ModuleResult,
                                         ModuleStatus, Problem, create_modules, register)

def test_action_states_values():
    assert [s.value for s in ActionState] == ["satisfied", "additive", "destructive"]

def test_fake_module_satisfies_protocol(fake_module):
    assert isinstance(fake_module, Module)

def test_registry_order_and_creation():
    @register("aaa")
    class A:
        name = "aaa"
        def check(self): return []
        def collect(self, dest: Path): return ModuleResult("aaa", ModuleStatus.OK)
        def plan_restore(self, src: Path): return []
        def restore(self, src, actions, force): return ModuleResult("aaa", ModuleStatus.OK)
    try:
        assert "aaa" in base.available_modules()
        (m,) = create_modules(only=["aaa"])
        assert m.name == "aaa"
    finally:
        base._REGISTRY.pop("aaa", None)

def test_create_modules_unknown_name_raises():
    with pytest.raises(ValueError, match="module inconnu"):
        create_modules(only=["nope"])

def test_collect_writes_and_hashes(tmp_path, fake_module):
    result = fake_module.collect(tmp_path / "fake")
    assert result.status is ModuleStatus.OK
    assert (tmp_path / "fake" / "data.txt").read_text() == "payload"
    (art,) = result.artifacts
    assert art.path == "fake/data.txt" and len(art.sha256) == 64 and art.size == 7
```

- [ ] **Étape 2 : vérifier l'échec** — `uv run pytest tests/test_base.py -v` → FAIL (import).

- [ ] **Étape 3 : implémenter** `base.py` exactement selon le bloc Interfaces ci-dessus (registre = `dict[str, type]` ordonné, `_REGISTRY`), plus dans `conftest.py` :

```python
# tests/conftest.py
import hashlib
from dataclasses import dataclass, field
from pathlib import Path
import pytest
from machine_backup.modules.base import (Action, ActionState, Artifact,
                                         ModuleResult, ModuleStatus, Problem)

@dataclass
class FakeModule:
    name: str = "fake"
    problems: list = field(default_factory=list)
    fail_collect: bool = False
    restored: list = field(default_factory=list)

    def check(self) -> list[Problem]:
        return self.problems

    def collect(self, dest: Path) -> ModuleResult:
        if self.fail_collect:
            raise RuntimeError("boom")
        dest.mkdir(parents=True, exist_ok=True)
        data = b"payload"
        (dest / "data.txt").write_bytes(data)
        art = Artifact(path=f"{self.name}/data.txt",
                       sha256=hashlib.sha256(data).hexdigest(), size=len(data))
        return ModuleResult(self.name, ModuleStatus.OK, artifacts=[art], duration_s=0.01)

    def plan_restore(self, src: Path) -> list[Action]:
        return [
            Action(self.name, ActionState.SATISFIED, "déjà conforme"),
            Action(self.name, ActionState.ADDITIVE, "recréer data.txt"),
            Action(self.name, ActionState.DESTRUCTIVE, "écraser existing.txt", reason="diverge"),
        ]

    def restore(self, src: Path, actions, force: bool) -> ModuleResult:
        for a in actions:
            if a.state is ActionState.ADDITIVE or (a.state is ActionState.DESTRUCTIVE and force):
                self.restored.append(a.description)
        return ModuleResult(self.name, ModuleStatus.OK)

@pytest.fixture
def fake_module():
    return FakeModule()
```

`modules/__init__.py` : vide pour l'instant (les imports d'enregistrement arrivent aux tâches 11-13).

- [ ] **Étape 4 : vérifier** — `uv run pytest tests/test_base.py -v` → PASS.
- [ ] **Étape 5 : commit** — `git commit -m "feat(machine-backup): contrat Module, états d'Action et registre de modules"`

---

### Tâche 3 : logs.py — debug.log, rotation, rédaction, run_logged

**Files:**
- Create: `tools/machine-backup/src/machine_backup/logs.py`
- Test: `tools/machine-backup/tests/test_logs.py`

**Interfaces:**
- Produces:
  - `redact(text: str) -> str`
  - `setup_logging(verbosity: int = 0, log_dir: Path | None = None, keep: int = 20) -> Path` (retourne le chemin du debug.log ; console sur stderr : 0→WARNING, 1→INFO, ≥2→DEBUG ; fichier toujours DEBUG)
  - `@dataclass CommandResult: argv: list[str]; returncode: int; stdout: str; stderr: str; duration_s: float`
  - `class CommandError(RuntimeError)` portant `.result: CommandResult`
  - `run_logged(argv: Sequence[str], *, cwd: Path | None = None, input_text: str | None = None, timeout: float | None = None, check: bool = False) -> CommandResult`
- Règles : dossier de logs `chmod 0700` ; un fichier par run `run-%Y%m%d-%H%M%S-<pid>.log` ; rotation AVANT création (garder `keep` fichiers max, nouveau inclus) ; `RedactingFormatter` applique `redact()` sur chaque ligne formatée ; `run_logged` trace argv (rédigé), cwd, code de sortie, durée, stdout/stderr (rédigés) ; ne passe JAMAIS `env=` à subprocess et ne journalise jamais l'environnement.

Motifs de rédaction (remplacement par `[REDACTED]`, insensible à la casse là où indiqué) :
1. `(?i)\b(password|passwd|pwd|secret)\s*[=:]\s*\S+` → `\1=[REDACTED]`
2. `\b[A-Za-z0-9_]*(?:_TOKEN|_KEY|_SECRET)\b\s*[=:]\s*\S+` → `<nom>=[REDACTED]`
3. `AGE-SECRET-KEY-1[0-9A-Z]+` → `[REDACTED]`
4. URLs à credentials : `([a-z][a-z0-9+.-]*://)[^/\s:@]+:[^@\s]+@` → `\1[REDACTED]@`

- [ ] **Étape 1 : tests qui échouent**

```python
# tests/test_logs.py
import logging, os, stat
from machine_backup.logs import CommandError, redact, run_logged, setup_logging
import pytest

@pytest.mark.parametrize("raw,gone", [
    ("password=SuperSecret123", "SuperSecret123"),
    ("MY_API_TOKEN: abcdef", "abcdef"),
    ("AGE-SECRET-KEY-1QQPZRV9X8GF2TVDW0S3JN54KHCE6MUA7L", "QQPZRV9X8"),
    ("postgres://bob:hunter2@localhost/db", "hunter2"),
])
def test_redact_removes_secret(raw, gone):
    out = redact(raw)
    assert gone not in out and "[REDACTED]" in out

def test_redact_preserves_normal_text():
    assert redact("dump de la base elevaspeed ok") == "dump de la base elevaspeed ok"

def test_setup_logging_dir_700_and_rotation(tmp_path):
    for i in range(25):
        (tmp_path / f"run-20260101-0000{i:02d}-1.log").write_text("old")
    path = setup_logging(log_dir=tmp_path, keep=20)
    assert stat.S_IMODE(tmp_path.stat().st_mode) == 0o700
    assert len(list(tmp_path.glob("run-*.log"))) == 20 and path.exists()

def test_debug_log_always_full_and_redacted(tmp_path):
    setup_logging(verbosity=0, log_dir=tmp_path, keep=20)
    logging.getLogger("machine_backup.test").debug("connexion password=TopSecret ok")
    logging.shutdown()
    content = max(tmp_path.glob("run-*.log")).read_text()
    assert "connexion" in content            # DEBUG écrit même avec verbosity=0
    assert "TopSecret" not in content        # rédaction appliquée

def test_run_logged_captures(tmp_path):
    setup_logging(log_dir=tmp_path)
    r = run_logged(["/bin/echo", "hello"])
    assert r.returncode == 0 and r.stdout.strip() == "hello" and r.duration_s >= 0

def test_run_logged_check_raises(tmp_path):
    setup_logging(log_dir=tmp_path)
    with pytest.raises(CommandError):
        run_logged(["/usr/bin/false"], check=True)
```

- [ ] **Étape 2 : vérifier l'échec** — `uv run pytest tests/test_logs.py -v` → FAIL.
- [ ] **Étape 3 : implémenter** `logs.py` selon les Interfaces (regex compilées au module ; `setup_logging` idempotent : purge les handlers `machine_backup` existants avant d'ajouter les siens, pour les tests).
- [ ] **Étape 4 : vérifier** — `uv run pytest tests/test_logs.py -v` → PASS.
- [ ] **Étape 5 : commit** — `git commit -m "feat(machine-backup): debug.log avec rotation et redaction des secrets, run_logged"`

---

### Tâche 4 : console.py — Reporter Rich TTY / non-TTY

**Files:**
- Create: `tools/machine-backup/src/machine_backup/console.py`
- Test: `tools/machine-backup/tests/test_console.py`

**Interfaces:**
- Produces:
  - `@dataclass SummaryRow: module: str; status: str; duration_s: float; size_bytes: int`
  - `class Reporter:`
    - `__init__(self, mode: Literal["auto","rich","linear"] = "auto", verbosity: int = 0, console: rich.console.Console | None = None)`
    - propriété `is_rich: bool` — `rich` forcé, ou `auto` + `sys.stdout.isatty()` + `verbosity < 2`
    - `start_module(name: str, total: int | None = None)` (barre si `total`, spinner sinon)
    - `advance(name: str, description: str = "")`
    - `finish_module(name: str, status: str)`
    - `log(message: str)` — ligne horodatée en mode linéaire, print Rich sinon
    - `summary(rows: list[SummaryRow])` — tableau *module × statut × durée × taille* (Rich `Table` en TTY, texte aligné sinon)
- Mapping CLI : `--progress` → mode `rich`, `--no-progress` → `linear`, sinon `auto`.
- Affichage du plan de restore (utilisé tâche 10) : `print_plan(actions: list[Action])` — `satisfied` en gris (`dim`), `additive` en vert, `destructive` en rouge avec sa `reason`.

- [ ] **Étape 1 : tests qui échouent**

```python
# tests/test_console.py
import io
from rich.console import Console
from machine_backup.console import Reporter, SummaryRow
from machine_backup.modules.base import Action, ActionState

def _reporter(mode, **kw):
    buf = io.StringIO()
    return Reporter(mode=mode, console=Console(file=buf, force_terminal=(mode == "rich"), width=100), **kw), buf

def test_forced_modes():
    assert _reporter("rich")[0].is_rich is True
    assert _reporter("linear")[0].is_rich is False

def test_auto_non_tty_is_linear():
    r, _ = _reporter("auto")
    assert r.is_rich is False          # Console sur StringIO → pas un TTY

def test_vv_forces_linear_even_in_rich_capable_console():
    r, _ = _reporter("auto", verbosity=2)
    assert r.is_rich is False

def test_linear_log_is_timestamped():
    r, buf = _reporter("linear")
    r.log("dump elevaspeed")
    line = buf.getvalue().strip()
    assert "dump elevaspeed" in line and line[:4].isdigit()

def test_summary_contains_all_columns():
    r, buf = _reporter("linear")
    r.summary([SummaryRow("postgres", "ok", 41.2, 52428800)])
    out = buf.getvalue()
    assert "postgres" in out and "ok" in out and "41" in out and "50.0" in out  # MiB

def test_print_plan_shows_reason_for_destructive():
    r, buf = _reporter("linear")
    r.print_plan([Action("postgres", ActionState.DESTRUCTIVE, "restaurer la base postgres",
                         reason="la base existe déjà")])
    assert "destructive" in buf.getvalue() and "existe déjà" in buf.getvalue()
```

- [ ] **Étape 2 : échec** — `uv run pytest tests/test_console.py -v` → FAIL.
- [ ] **Étape 3 : implémenter** `console.py` (Rich `Progress` avec une tâche par module ; en linéaire, `advance` émet une ligne horodatée par unité ; taille affichée en MiB avec une décimale).
- [ ] **Étape 4 : vérifier** — PASS.
- [ ] **Étape 5 : commit** — `git commit -m "feat(machine-backup): reporter console Rich avec bascule TTY/non-TTY"`

---

### Tâche 5 : config.py

**Files:**
- Create: `tools/machine-backup/src/machine_backup/config.py`
- Test: `tools/machine-backup/tests/test_config.py`

**Interfaces:**
- Produces:
  - `@dataclass Config: destination: Path; keep_snapshots: int = 7; log_keep: int = 20; age_recipient: str | None = None; age_identity: Path | None = None; remote_target: str | None = None`
  - `DEFAULT_CONFIG_PATH = Path("~/.config/machine-backup/config.toml")`
  - `load_config(path: Path | None = None) -> Config` — lit le TOML (format de référence ci-dessus, `tomllib`), expanduser sur tous les chemins, fichier absent → défauts (`destination=~/Backups/machine`), clé inconnue ignorée, TOML invalide → `ConfigError(ValueError)` avec message explicite.
  - `class ConfigError(ValueError)`

- [ ] **Étape 1 : tests qui échouent**

```python
# tests/test_config.py
from pathlib import Path
import pytest
from machine_backup.config import Config, ConfigError, load_config

def test_missing_file_gives_defaults(tmp_path):
    cfg = load_config(tmp_path / "absent.toml")
    assert cfg.destination == Path("~/Backups/machine").expanduser()
    assert cfg.keep_snapshots == 7 and cfg.log_keep == 20
    assert cfg.age_recipient is None and cfg.remote_target is None

def test_full_file_parsed(tmp_path):
    f = tmp_path / "config.toml"
    f.write_text('[backup]\ndestination = "~/Autre"\nkeep = 3\n'
                 '[age]\nrecipient = "age1abc"\nidentity = "~/id.txt"\n'
                 '[remote]\ntarget = "user@host:/backups"\n[logs]\nkeep = 5\n')
    cfg = load_config(f)
    assert cfg.destination == Path("~/Autre").expanduser()
    assert cfg.keep_snapshots == 3 and cfg.log_keep == 5
    assert cfg.age_recipient == "age1abc"
    assert cfg.age_identity == Path("~/id.txt").expanduser()
    assert cfg.remote_target == "user@host:/backups"

def test_empty_remote_target_is_none(tmp_path):
    f = tmp_path / "c.toml"; f.write_text('[remote]\ntarget = ""\n')
    assert load_config(f).remote_target is None

def test_invalid_toml_raises(tmp_path):
    f = tmp_path / "c.toml"; f.write_text("[[[")
    with pytest.raises(ConfigError):
        load_config(f)
```

- [ ] **Étape 2 : échec** → **Étape 3 : implémenter** → **Étape 4 :** `uv run pytest tests/test_config.py -v` PASS.
- [ ] **Étape 5 : commit** — `git commit -m "feat(machine-backup): lecture de config.toml avec valeurs par defaut"`

---

### Tâche 6 : manifest.py

**Files:**
- Create: `tools/machine-backup/src/machine_backup/manifest.py`
- Test: `tools/machine-backup/tests/test_manifest.py`

**Interfaces:**
- Produces:
  - `hash_file(path: Path) -> str` (SHA-256 hex, lecture par blocs de 1 MiB)
  - `hash_tree(root: Path, prefix: str) -> list[Artifact]` — fichiers réguliers, chemins `prefix/relatif` triés, symlinks ignorés
  - `build_manifest(results: list[ModuleResult], *, tool_version: str, hostname: str, macos_version: str, created_at: str) -> dict` — structure de référence, sans bloc `archive` ; `status` global calculé
  - `add_archive_block(manifest: dict, archive: Path) -> dict` — copie + bloc `archive` (file/sha256/size)
  - `write_manifest(manifest: dict, path: Path) -> None` (JSON indent=2, tri des clés stable, UTF-8, newline final)
  - `read_manifest(path: Path) -> dict` — vérifie `schema_version == 1`, sinon `ValueError`

- [ ] **Étape 1 : tests qui échouent**

```python
# tests/test_manifest.py
import hashlib, json
from machine_backup.manifest import (add_archive_block, build_manifest, hash_file,
                                     hash_tree, read_manifest, write_manifest)
from machine_backup.modules.base import Artifact, ModuleResult, ModuleStatus

def test_hash_file_known_value(tmp_path):
    f = tmp_path / "x"; f.write_bytes(b"payload")
    assert hash_file(f) == hashlib.sha256(b"payload").hexdigest()

def test_hash_tree_relative_sorted(tmp_path):
    (tmp_path / "sub").mkdir(); (tmp_path / "sub" / "b.txt").write_text("b")
    (tmp_path / "a.txt").write_text("a")
    arts = hash_tree(tmp_path, prefix="shell")
    assert [a.path for a in arts] == ["shell/a.txt", "shell/sub/b.txt"]

def test_build_manifest_status_partial_and_roundtrip(tmp_path):
    results = [ModuleResult("postgres", ModuleStatus.OK,
                            artifacts=[Artifact("postgres/g.sql", "0" * 64, 10, True)],
                            duration_s=1.0, meta={}),
               ModuleResult("homebrew", ModuleStatus.SKIPPED, error="brew absent")]
    m = build_manifest(results, tool_version="0.1.0", hostname="h",
                       macos_version="15.5", created_at="2026-08-05T14:30:12+02:00")
    assert m["status"] == "partial" and m["schema_version"] == 1
    assert m["modules"]["postgres"]["artifacts"][0]["sensitive"] is True
    assert m["modules"]["homebrew"]["error"] == "brew absent"
    assert "archive" not in m
    p = tmp_path / "manifest.json"; write_manifest(m, p)
    assert read_manifest(p) == m

def test_add_archive_block(tmp_path):
    arch = tmp_path / "snapshot.tar.zst.age"; arch.write_bytes(b"xyz")
    m2 = add_archive_block({"schema_version": 1, "modules": {}, "status": "complete"}, arch)
    assert m2["archive"]["file"] == "snapshot.tar.zst.age"
    assert m2["archive"]["size"] == 3 and len(m2["archive"]["sha256"]) == 64
```

- [ ] **Étape 2 : échec** → **Étape 3 : implémenter** → **Étape 4 :** `uv run pytest tests/test_manifest.py -v` PASS.
- [ ] **Étape 5 : commit** — `git commit -m "feat(machine-backup): construction, ecriture et lecture du manifest"`

---

### Tâche 7 : crypto.py — tar + zstd + age, verify en flux

**Files:**
- Create: `tools/machine-backup/src/machine_backup/crypto.py`
- Test: `tools/machine-backup/tests/test_crypto.py`

**Interfaces:**
- Produces:
  - `class CryptoError(RuntimeError)`
  - `require_binaries() -> list[str]` — noms manquants parmi `tar`, `zstd`, `age` (`shutil.which`)
  - `encrypt_dir(src: Path, dest_file: Path, recipient: str) -> None` — pipeline `tar -cf - -C src . | zstd -q -c | age -r recipient -o dest_file` (trois `subprocess.Popen` chaînés, vérification des trois codes retour, `CryptoError` détaillée sinon)
  - `decrypt_to_dir(archive: Path, identity: Path, dest: Path) -> None` — `age -d -i identity archive | zstd -d -q -c | tar -xf - -C dest` ; `dest` créé `0700`
  - `stream_member_hashes(archive: Path, identity: Path) -> dict[str, tuple[str, int]]` — déchiffre EN MÉMOIRE (aucune écriture disque) : `age | zstd -d` puis `tarfile.open(fileobj=..., mode="r|")`, retourne `{chemin_normalisé: (sha256, size)}` ; normalisation : retirer `./` en tête, ignorer non-fichiers
- Tous les tests de ce module portent `pytest.mark.skipif(shutil.which("age") is None or shutil.which("zstd") is None, reason="age/zstd absents")` et génèrent une paire de clés jetable via `age-keygen -o` dans `tmp_path`.

- [ ] **Étape 1 : tests qui échouent**

```python
# tests/test_crypto.py
import re, shutil, subprocess
import pytest
from machine_backup.crypto import (CryptoError, decrypt_to_dir, encrypt_dir,
                                   require_binaries, stream_member_hashes)
from machine_backup.manifest import hash_file

needs_bins = pytest.mark.skipif(
    shutil.which("age") is None or shutil.which("age-keygen") is None
    or shutil.which("zstd") is None, reason="age/zstd absents")

@pytest.fixture
def age_keys(tmp_path):
    ident = tmp_path / "identity.txt"
    out = subprocess.run(["age-keygen", "-o", str(ident)], capture_output=True, text=True)
    recipient = re.search(r"age1[0-9a-z]+", out.stderr).group(0)
    return recipient, ident

@needs_bins
def test_roundtrip(tmp_path, age_keys):
    recipient, ident = age_keys
    src = tmp_path / "src"; (src / "postgres").mkdir(parents=True)
    (src / "postgres" / "db.dump").write_bytes(b"DUMP")
    (src / "manifest.json").write_text("{}")
    arch = tmp_path / "snapshot.tar.zst.age"
    encrypt_dir(src, arch, recipient)
    assert arch.exists() and arch.read_bytes()[:20] != b"DUMP"  # bien chiffré
    out = tmp_path / "out"
    decrypt_to_dir(arch, ident, out)
    assert (out / "postgres" / "db.dump").read_bytes() == b"DUMP"

@needs_bins
def test_stream_member_hashes_matches_disk(tmp_path, age_keys):
    recipient, ident = age_keys
    src = tmp_path / "src"; src.mkdir()
    (src / "a.txt").write_bytes(b"aaa")
    arch = tmp_path / "s.age"; encrypt_dir(src, arch, recipient)
    hashes = stream_member_hashes(arch, ident)
    assert hashes["a.txt"] == (hash_file(src / "a.txt"), 3)

@needs_bins
def test_wrong_identity_raises(tmp_path, age_keys):
    recipient, _ = age_keys
    src = tmp_path / "src"; src.mkdir(); (src / "x").write_text("x")
    arch = tmp_path / "s.age"; encrypt_dir(src, arch, recipient)
    other = tmp_path / "other.txt"
    subprocess.run(["age-keygen", "-o", str(other)], capture_output=True)
    with pytest.raises(CryptoError):
        stream_member_hashes(arch, other)

def test_require_binaries_reports_missing(monkeypatch):
    monkeypatch.setattr(shutil, "which", lambda name: None)
    assert set(require_binaries()) == {"tar", "zstd", "age"}
```

- [ ] **Étape 2 : échec** → **Étape 3 : implémenter** → **Étape 4 :** `uv run pytest tests/test_crypto.py -v` PASS (ou SKIP proprement si `age`/`zstd` absents de la machine de dev — dans ce cas les installer : `brew install age zstd`).
- [ ] **Étape 5 : commit** — `git commit -m "feat(machine-backup): chiffrement age du snapshot et verification en flux"`

---

### Tâche 8 : Orchestrateur backup + rotation des snapshots

**Files:**
- Modify: `tools/machine-backup/src/machine_backup/cli.py`
- Test: `tools/machine-backup/tests/test_backup_flow.py`

**Interfaces:**
- Consumes: tout ce qui précède (`create_modules`, `Config`, `Reporter`, `build_manifest`/`add_archive_block`/`write_manifest`/`hash_tree`, `encrypt_dir`, `setup_logging`).
- Produces:
  - `run_backup(modules: list[Module], config: Config, reporter: Reporter, *, no_sync: bool = False, now: datetime | None = None) -> int` dans `cli.py`
  - `rotate_snapshots(destination: Path, keep: int) -> list[Path]` (retourne les dossiers supprimés)
  - `resolve_snapshot(destination: Path, ref: str) -> Path` — `"latest"` → dossier horodaté le plus récent contenant `manifest.json` ; horodatage exact ; chemin absolu existant ; sinon `FileNotFoundError`
  - `cmd_backup` branché dans `main()` : charge config, `setup_logging(verbosity, keep=config.log_keep)`, vérifie `require_binaries()` et `age_recipient` (absents → message explicite, exit 2), construit modules (`--only`), `Reporter`.

Flux exact de `run_backup` (= spec, étapes 1→6) :
1. Crée `destination/<stamp>/` en `0700` et `tmp/` dedans.
2. Pour chaque module : `check()` ; un `Problem.fatal` → `ModuleResult(status=SKIPPED, error=...)`, **les autres continuent** ; sinon `collect(tmp/<name>/)` dans un `try/except` → exception = `FAILED` (traceback dans le debug.log), les autres continuent.
3. `build_manifest(...)` → `write_manifest(m, tmp/manifest.json)` (sans bloc archive).
4. `encrypt_dir(tmp, snap/snapshot.tar.zst.age, recipient)` ; puis `write_manifest(add_archive_block(m, archive), snap/manifest.json)` ; puis `shutil.rmtree(tmp)`. Échec du chiffrement → le dossier snapshot incomplet est supprimé, exit 1.
5. `rotate_snapshots(destination, config.keep_snapshots)` — ne supprime QUE les dossiers au nom `^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}$` contenant un `manifest.json` (jamais un dossier arbitraire).
6. Sync (tâche 15) seulement si archive OK, remote configuré et pas `--no-sync` — à cette tâche-ci, laisser un point d'accroche `if config.remote_target and not no_sync: sync_snapshot(...)` derrière un import paresseux, `sync.py` n'existant pas encore : implémenter `sync_snapshot` en stub `raise NotImplementedError` N'EST PAS acceptable — à la place, ne brancher l'appel qu'à la tâche 15. Ici, `no_sync` est accepté et ignoré.
7. `reporter.summary(...)` ; retour `0` si `status == "complete"`, sinon `1` avec résumé des échecs.

- [ ] **Étape 1 : tests qui échouent** (module factice + clés age jetables ; mêmes `needs_bins`/`age_keys` que la tâche 7, factorisés dans `conftest.py`)

```python
# tests/test_backup_flow.py
from datetime import datetime
import pytest
from machine_backup.cli import resolve_snapshot, rotate_snapshots, run_backup
from machine_backup.config import Config
from machine_backup.console import Reporter
from machine_backup.manifest import read_manifest
from machine_backup.modules.base import Problem
from tests.conftest import FakeModule  # ou import conftest fixtures

def _cfg(tmp_path, recipient, ident):
    return Config(destination=tmp_path / "backups", keep_snapshots=7,
                  age_recipient=recipient, age_identity=ident)

@needs_bins
def test_backup_complete(tmp_path, age_keys):
    recipient, ident = age_keys
    code = run_backup([FakeModule()], _cfg(tmp_path, recipient, ident),
                      Reporter(mode="linear"), now=datetime(2026, 8, 5, 14, 30, 12))
    snap = tmp_path / "backups" / "2026-08-05T14-30-12"
    assert code == 0
    assert (snap / "snapshot.tar.zst.age").exists()
    m = read_manifest(snap / "manifest.json")
    assert m["status"] == "complete" and "archive" in m
    assert not (snap / "tmp").exists()               # tmp nettoyé
    assert m["modules"]["fake"]["artifacts"][0]["path"] == "fake/data.txt"

@needs_bins
def test_failed_module_gives_partial_exit_1_others_continue(tmp_path, age_keys):
    recipient, ident = age_keys
    mods = [FakeModule(name="boom", fail_collect=True), FakeModule(name="ok")]
    code = run_backup(mods, _cfg(tmp_path, recipient, ident), Reporter(mode="linear"))
    m = read_manifest(next((tmp_path / "backups").iterdir()) / "manifest.json")
    assert code == 1 and m["status"] == "partial"
    assert m["modules"]["boom"]["status"] == "failed"
    assert m["modules"]["ok"]["status"] == "ok"      # l'échec n'annule pas les autres

@needs_bins
def test_fatal_problem_marks_skipped(tmp_path, age_keys):
    recipient, ident = age_keys
    mod = FakeModule(problems=[Problem("fake", "pg_dump introuvable")])
    run_backup([mod], _cfg(tmp_path, recipient, ident), Reporter(mode="linear"))
    m = read_manifest(next((tmp_path / "backups").iterdir()) / "manifest.json")
    assert m["modules"]["fake"]["status"] == "skipped"
    assert "pg_dump" in m["modules"]["fake"]["error"]

def test_rotation_keeps_n_and_ignores_foreign_dirs(tmp_path):
    dest = tmp_path / "b"; dest.mkdir()
    for i in range(9):
        d = dest / f"2026-08-0{i % 9 + 1}T00-00-0{i}"
        d.mkdir(); (d / "manifest.json").write_text("{}")
    (dest / "pas-un-snapshot").mkdir()
    removed = rotate_snapshots(dest, keep=7)
    assert len(removed) == 2
    assert (dest / "pas-un-snapshot").exists()

def test_resolve_snapshot_latest_and_missing(tmp_path):
    dest = tmp_path / "b"
    for s in ["2026-08-04T10-00-00", "2026-08-05T10-00-00"]:
        (dest / s).mkdir(parents=True); (dest / s / "manifest.json").write_text("{}")
    assert resolve_snapshot(dest, "latest").name == "2026-08-05T10-00-00"
    assert resolve_snapshot(dest, "2026-08-04T10-00-00").name == "2026-08-04T10-00-00"
    with pytest.raises(FileNotFoundError):
        resolve_snapshot(dest, "2020-01-01T00-00-00")
```

- [ ] **Étape 2 : échec** → **Étape 3 : implémenter dans `cli.py`** → **Étape 4 :** `uv run pytest tests/test_backup_flow.py -v` PASS, et `uv run pytest` (toute la suite) PASS.
- [ ] **Étape 5 : commit** — `git commit -m "feat(machine-backup): orchestrateur backup avec statut partial et rotation des snapshots"`

---

### Tâche 9 : `list` et `verify`

**Files:**
- Modify: `tools/machine-backup/src/machine_backup/cli.py`
- Test: `tools/machine-backup/tests/test_list_verify.py`

**Interfaces:**
- Consumes: `resolve_snapshot`, `read_manifest`, `hash_file`, `stream_member_hashes`, `Reporter`.
- Produces:
  - `cmd_list(config, reporter) -> int` — parcourt `destination`, lit chaque `manifest.json` en clair, tableau : horodatage, statut, modules (avec statut), taille de l'archive. Destination vide → message « aucun snapshot », exit 0. Manifest illisible → ligne marquée `corrompu`, on continue.
  - `cmd_verify(snapshot_ref: str, config, reporter) -> int` — sans clé pour lister, mais `verify` exige `age_identity` (absente → exit 2). Contrôles dans l'ordre : (1) `hash_file(archive) == manifest["archive"]["sha256"]` ; (2) `stream_member_hashes` (en mémoire) et comparaison de CHAQUE artifact du manifest (hash + taille) + détection des membres manquants. Tout écart → détail affiché, exit 1 ; sinon « snapshot intègre », exit 0.

- [ ] **Étape 1 : tests qui échouent** — construire un vrai snapshot via `run_backup` + FakeModule (avec `needs_bins`/`age_keys`) :

```python
# tests/test_list_verify.py — extraits clés
@needs_bins
def test_verify_ok_then_corrupted(tmp_path, age_keys, capsys):
    recipient, ident = age_keys
    cfg = _cfg(tmp_path, recipient, ident)
    run_backup([FakeModule()], cfg, Reporter(mode="linear"))
    assert cmd_verify("latest", cfg, Reporter(mode="linear")) == 0
    snap = resolve_snapshot(cfg.destination, "latest")
    arch = snap / "snapshot.tar.zst.age"
    data = bytearray(arch.read_bytes()); data[-1] ^= 0xFF; arch.write_bytes(bytes(data))
    assert cmd_verify("latest", cfg, Reporter(mode="linear")) == 1

@needs_bins
def test_list_shows_snapshots(tmp_path, age_keys, capsys): ...
def test_list_empty_destination_exit_0(tmp_path): ...
def test_verify_without_identity_exit_2(tmp_path): ...
```

(Écrire les quatre tests en entier sur ce modèle : assertions sur le code retour et sur la présence de l'horodatage/du statut dans la sortie capturée.)

- [ ] **Étape 2 : échec** → **Étape 3 : implémenter** (brancher dans `main()`) → **Étape 4 :** suite complète PASS.
- [ ] **Étape 5 : commit** — `git commit -m "feat(machine-backup): commandes list et verify avec controle d'integrite"`

---

### Tâche 10 : Orchestrateur restore (dry-run, --apply, --force)

**Files:**
- Modify: `tools/machine-backup/src/machine_backup/cli.py`
- Test: `tools/machine-backup/tests/test_restore_flow.py`

**Interfaces:**
- Consumes: `resolve_snapshot`, `decrypt_to_dir`, `create_modules`, `Reporter.print_plan`.
- Produces: `run_restore(snapshot_dir: Path, modules: list[Module], config: Config, reporter: Reporter, *, apply: bool, force: set[str]) -> int` :
  1. Vérifie `age_identity` (sinon exit 2).
  2. `decrypt_to_dir` vers un répertoire temporaire privé (`tempfile.mkdtemp` → `0700`), supprimé en `finally` (`shutil.rmtree`) — les dumps déchiffrés ne doivent jamais survivre à la commande.
  3. Pour chaque module : si `workdir/<name>/` absent → note « absent du snapshot », continue ; sinon `actions = module.plan_restore(workdir/<name>)`.
  4. Affiche le plan complet (`print_plan`) : `satisfied` gris, `additive`, `destructive` + raison.
  5. Sans `--apply` : s'arrête là, exit 0. (« `restore` sans `--apply` n'affiche que le plan et ne modifie rien. »)
  6. Avec `--apply` : `module.restore(workdir/<name>, actions, force=module.name in force)`. Le contrat impose au module de : skipper `satisfied`, exécuter `additive`, skipper+signaler `destructive` sauf `force`.
  7. Résumé ; exit 0 si tous OK, 1 si au moins un `ModuleResult.status == FAILED`.
  8. `--force <nom>` avec un nom hors `--only`/registre → exit 2.
- `cmd_restore` branché dans `main()` (passe `shell_choice=args.shell` à `create_modules`).

- [ ] **Étape 1 : tests qui échouent** (FakeModule enregistre dans `.restored` ce qui a été exécuté) :

```python
# tests/test_restore_flow.py — comportements exigés
@needs_bins
def test_dry_run_executes_nothing(tmp_path, age_keys):
    # backup FakeModule, puis run_restore(apply=False) → mod.restored == [] et exit 0
@needs_bins
def test_apply_runs_additive_only(tmp_path, age_keys):
    # apply=True, force=set() → "recréer data.txt" dans restored, "écraser existing.txt" absent
@needs_bins
def test_force_module_runs_destructive(tmp_path, age_keys):
    # apply=True, force={"fake"} → les deux actions exécutées
@needs_bins
def test_workdir_cleaned_even_on_module_error(tmp_path, age_keys):
    # module.restore qui lève → exit 1 ET aucun répertoire temporaire résiduel
def test_force_unknown_module_exit_2(tmp_path): ...
```

(Écrire ces cinq tests en entier : chacun fait un vrai `run_backup` avec FakeModule puis appelle `run_restore` et asserte `mod.restored` + code retour.)

- [ ] **Étape 2 : échec** → **Étape 3 : implémenter** → **Étape 4 :** suite complète PASS.
- [ ] **Étape 5 : commit** — `git commit -m "feat(machine-backup): restore en dry-run par defaut avec --apply et --force par module"`

---

### Tâche 11 : Module postgres

**Files:**
- Create: `tools/machine-backup/src/machine_backup/modules/postgres.py`
- Modify: `tools/machine-backup/src/machine_backup/modules/__init__.py` (import d'enregistrement)
- Test: `tools/machine-backup/tests/test_postgres.py`

**Interfaces:**
- Consumes: `run_logged`, `register`, types de `base.py`, `hash_tree`.
- Produces: `@register("postgres") class PostgresModule` avec `__init__(self, runner=run_logged)` et :
  - `_find_pg_bin(tool: str) -> str | None` — `shutil.which(tool)`, sinon glob `/opt/homebrew/opt/postgresql@*/bin/<tool>` (version la plus haute) — PostgreSQL 18 Homebrew est keg-only, PAS forcément dans le PATH.
  - `check()` : `pg_dump`, `pg_dumpall`, `psql` introuvables → `Problem(fatal=True)` ; serveur injoignable (`psql -X -At -c "SELECT 1" postgres` ≠ 0) → `Problem(fatal=True, message=stderr résumé)`.
  - `collect(dest)` :
    - Liste des bases : `psql -X -At -c "SELECT datname FROM pg_database WHERE NOT datistemplate ORDER BY datname" postgres` — exclut structurellement `template0`/`template1`.
    - `pg_dumpall --globals-only -f dest/globals.sql` — **non négociable** (GRANTs vers `elevaspeed`, `humperdoo`).
    - Par base : `pg_dump -Fc -f dest/<datname>.dump <datname>` ; `reporter` non accessible ici — la progression par base passe par le champ `total` du Reporter côté orchestrateur v2 ; en v1 chaque dump est loggé via `run_logged` (suffisant, la barre par base est un raffinement de l'orchestrateur, pas du module).
    - Un dump qui échoue → le module continue les autres bases et retourne `FAILED` avec `error` listant les bases en échec ; les artefacts réussis restent dans le résultat.
    - Tous les artefacts `sensitive=True`.
  - `plan_restore(src)` :
    - `src/globals.sql` présent → `Action(ADDITIVE, "rejouer roles et GRANTs globaux (globals.sql)")` (voir Ambiguïtés, point 5).
    - Bases existantes via la même requête `psql` ; pour chaque `src/*.dump` : absente → `ADDITIVE`, existante → `DESTRUCTIVE` avec `reason="la base <nom> existe deja"`. (Pas d'état `satisfied` pour postgres : l'égalité de contenu n'est pas vérifiable à coût raisonnable.)
    - Serveur injoignable au moment du plan → lever `RuntimeError` explicite (le CLI l'affiche et marque le module FAILED).
  - `restore(src, actions, force)` :
    - Ordre : `globals.sql` d'abord (`psql -X -v ON_ERROR_STOP=0 -f globals.sql postgres` ; les erreurs « role already exists » sont normales, comptées en `meta["globals_errors"]`), puis les bases.
    - `ADDITIVE` : `createdb <db>` puis `pg_restore -d <db> src/<db>.dump`.
    - `DESTRUCTIVE` sans `force` : skip + signalé. Avec `force` : `dropdb <db>` puis `createdb <db>` puis `pg_restore`.
- Tests : logique de classification et parsing testés avec un `runner` factice (fonction enregistrant les argv et rejouant des sorties canned) — AUCUN contact machine ; plus un test d'intégration `collect` contre une base jetable `mb_test_<pid>` créée/détruite par le test, `pytest.mark.skipif` propre si pas de serveur local (`_find_pg_bin("psql") is None` ou `SELECT 1` échoue).

- [ ] **Étape 1 : tests qui échouent**

```python
# tests/test_postgres.py — extraits structurants
def make_runner(responses):     # dict "argv[0] base" -> CommandResult canned
    calls = []
    def runner(argv, **kw):
        calls.append(argv)
        key = Path(argv[0]).name
        return responses.get(key, CommandResult(list(argv), 0, "", "", 0.0))
    runner.calls = calls
    return runner

def test_check_reports_missing_binaries(monkeypatch):
    monkeypatch.setattr(postgres, "_find_pg_bin", lambda t: None)
    problems = PostgresModule().check()
    assert any("pg_dump" in p.message for p in problems) and all(p.fatal for p in problems)

def test_collect_excludes_templates_and_dumps_globals(tmp_path):
    r = make_runner({"psql": CommandResult(["psql"], 0, "advmfr_hr\nelevaspeed\npostgres\n", "", 0.0)})
    mod = PostgresModule(runner=r)  # _find_pg_bin monkeypatché vers "/fake/bin/<tool>"
    mod.collect(tmp_path / "postgres")
    joined = [" ".join(c) for c in r.calls]
    assert any("pg_dumpall" in c and "--globals-only" in c for c in joined)
    assert sum("pg_dump " in c and "-Fc" in c for c in joined) == 3
    assert not any("template0" in c or "template1" in c for c in joined)

def test_plan_restore_classification(tmp_path):
    (tmp_path / "globals.sql").write_text("CREATE ROLE elevaspeed;")
    (tmp_path / "advmfr_hr.dump").write_bytes(b"d")
    (tmp_path / "nouvelle.dump").write_bytes(b"d")
    r = make_runner({"psql": CommandResult(["psql"], 0, "advmfr_hr\npostgres\n", "", 0.0)})
    actions = PostgresModule(runner=r).plan_restore(tmp_path)
    by_desc = {a.description: a.state for a in actions}
    assert by_desc["rejouer roles et GRANTs globaux (globals.sql)"] is ActionState.ADDITIVE
    assert by_desc["restaurer la base advmfr_hr"] is ActionState.DESTRUCTIVE
    assert by_desc["restaurer la base nouvelle"] is ActionState.ADDITIVE

def test_restore_skips_destructive_without_force(tmp_path): ...   # aucun dropdb dans runner.calls
def test_restore_force_drops_and_recreates(tmp_path): ...          # dropdb puis createdb puis pg_restore, dans cet ordre

@pytest.mark.skipif(not _server_available(), reason="pas de serveur PostgreSQL local")
def test_integration_collect_real_disposable_db(tmp_path): ...
    # createdb mb_test_<pid> ; collect ; assert (dest/"mb_test_<pid>.dump").exists()
    # et "globals.sql" non vide ; finally: dropdb mb_test_<pid>
```

(Écrire tous ces tests en entier ; `_server_available()` = helper module de test qui tente `SELECT 1`.)

- [ ] **Étape 2 : échec** → **Étape 3 : implémenter** `postgres.py` + `modules/__init__.py` (`from machine_backup.modules import postgres  # noqa: F401`) → **Étape 4 :** `uv run pytest tests/test_postgres.py -v` PASS (intégration incluse sur cette machine, serveur `postgresql@18` démarré).
- [ ] **Étape 5 : commit** — `git commit -m "feat(machine-backup): module postgres avec dumps par base et globals-only"`

---

### Tâche 12 : Module homebrew

**Files:**
- Create: `tools/machine-backup/src/machine_backup/modules/homebrew.py`
- Modify: `tools/machine-backup/src/machine_backup/modules/__init__.py`
- Test: `tools/machine-backup/tests/test_homebrew.py`

**Interfaces:**
- Produces: `@register("homebrew") class HomebrewModule(__init__(self, runner=run_logged))` :
  - `check()` : `brew` introuvable (`shutil.which` puis `/opt/homebrew/bin/brew`) → `Problem(fatal=True)`.
  - `collect(dest)` : `brew bundle dump --describe --file dest/Brewfile` (dest fraîchement créé, donc pas de conflit d'overwrite). Artifact non sensible.
  - `@dataclass BrewfileEntries: taps: list[str]; formulae: list[str]; casks: list[str]; mas: list[str]` et `parse_brewfile(text: str) -> BrewfileEntries` — lignes `tap "x"`, `brew "x"`, `cask "x"`, `mas "Nom", id: 123` (regex, commentaires `#` ignorés, arguments après la première virgule ignorés).
  - `plan_restore(src)` : `src/Brewfile` absent → `[]` (module absent du snapshot). Sinon état installé via `brew tap`, `brew list --formula -1`, `brew list --cask -1` ; chaque entrée → `SATISFIED` si présente, sinon `ADDITIVE`. `mas` → `ADDITIVE` avec note si le binaire `mas` est absent. **Jamais d'action `DESTRUCTIVE`** : le restore homebrew est strictement additif.
  - `restore(src, actions, force)` : si au moins une action `ADDITIVE` → `brew bundle install --file src/Brewfile` (une seule commande ; brew gère lui-même les éléments déjà installés). **Jamais `--cleanup`** — l'argv construit ne doit contenir `--cleanup` sous aucune condition (testé). `force` sans effet (aucune action destructive).
- Tests : fixtures disque + runner factice uniquement, aucun contact avec le brew réel.

- [ ] **Étape 1 : tests qui échouent** — Brewfile synthétique :

```python
BREWFILE = '''# commentaire
tap "homebrew/services"
brew "postgresql@18", restart_service: true
brew "fish"
cask "raycast"
mas "Xcode", id: 497799835
'''
def test_parse_brewfile(): ...          # 1 tap, 2 formulae, 1 cask, 1 mas
def test_collect_dumps_brewfile(tmp_path): ...   # argv contient ["bundle", "dump", "--describe", "--file", ...]
def test_plan_restore_satisfied_vs_additive(tmp_path):
    # installed: formula fish + tap homebrew/services → fish SATISFIED,
    # postgresql@18 ADDITIVE, raycast ADDITIVE, aucun DESTRUCTIVE
def test_plan_restore_no_brewfile_returns_empty(tmp_path): ...
def test_restore_never_uses_cleanup(tmp_path): ...
    # après restore: assert not any("--cleanup" in c for call in runner.calls for c in call)
def test_restore_all_satisfied_runs_nothing(tmp_path): ...
```

(Écrire chaque test en entier sur le modèle `make_runner` de la tâche 11 — le factoriser dans `conftest.py` à cette occasion.)

- [ ] **Étape 2 : échec** → **Étape 3 : implémenter** → **Étape 4 :** `uv run pytest tests/test_homebrew.py -v` PASS.
- [ ] **Étape 5 : commit** — `git commit -m "feat(machine-backup): module homebrew via brew bundle, restore strictement additif"`

---

### Tâche 13 : Module shell

**Files:**
- Create: `tools/machine-backup/src/machine_backup/modules/shell.py`
- Modify: `tools/machine-backup/src/machine_backup/modules/__init__.py`
- Test: `tools/machine-backup/tests/test_shell.py`

**Interfaces:**
- Produces: `@register("shell") class ShellModule(__init__(self, home: Path | None = None, shell_choice: str = "both", runner=run_logged))` — `home` injectable pour les tests (défaut `Path.home()`).
  - Constantes : `FISH_EXCLUDE = lambda name: name == "config.fish.backup" or fnmatch.fnmatch(name, "config.fish.bak-*")` ; `ZSH_FILES = (".zshrc", ".zprofile", ".zshenv")`.
  - `_login_shell() -> str` : `pwd.getpwuid(os.getuid()).pw_shell` — JAMAIS `$SHELL` (invariant environnement).
  - `check()` : jamais fatal — s'il n'y a strictement rien à sauvegarder (ni fish ni zsh), `Problem(fatal=True, message="aucune config shell detectee")` ; sinon `[]`.
  - `collect(dest)` — sauvegarde indépendamment ce qui est présent :
    - `home/.config/fish/` → `dest/fish/` (`shutil.copytree`, `symlinks=True`, `ignore` basé sur `FISH_EXCLUDE` — exclut `config.fish.backup` et `config.fish.bak-*` à tous les niveaux).
    - Chacun de `ZSH_FILES` présent → `dest/zsh/<nom>` ; `home/.oh-my-zsh/custom/` → `dest/zsh/oh-my-zsh-custom/`.
    - `dest/meta.json` : `{"login_shell": "<pw_shell>"}`.
    - Artefacts via `hash_tree(dest, prefix="shell")`, puis `sensitive=True` forcé sur tout chemin se terminant par `fish_variables`.
    - `meta={"login_shell": ...}` dans le `ModuleResult`.
  - `plan_restore(src)` — filtré par `shell_choice` (`fish` → seulement `src/fish`, `zsh` → seulement `src/zsh`, `both` → tout ce que contient le snapshot) :
    - Par fichier du snapshot vs fichier cible sous `home` : absent → `ADDITIVE("creer <chemin>")` ; identique (SHA-256) → `SATISFIED` ; différent → `DESTRUCTIVE("ecraser <chemin>", reason="le fichier local differe du snapshot")`.
    - `shell_choice ∈ {zsh, both}` demandé mais aucun `src/zsh` : si `zsh` explicitement demandé → `ADDITIVE("installer oh-my-zsh propre")` + `ADDITIVE("ecrire un .zshrc depuis le template oh-my-zsh")` (jamais d'échec) ; si `both` → rien (on ne restaure que ce que contient le snapshot).
    - `src/meta.json` lu ; si `login_shell` du snapshot ≠ `_login_shell()` courant ET que ce shell est concerné par `shell_choice` → `DESTRUCTIVE("changer le shell de login via chsh -s <chemin>", reason="shell de login actuel: <courant>")`. **Jamais appliqué sans `--force shell`.**
  - `restore(src, actions, force)` : applique les `ADDITIVE` (copie fichier à fichier avec création des dossiers parents) ; `DESTRUCTIVE` uniquement si `force` (écrasement de fichier, ou `chsh -s` via runner). oh-my-zsh « propre » : `git clone --depth=1 https://github.com/ohmyzsh/ohmyzsh.git home/.oh-my-zsh` + copie de `templates/zshrc.zsh-template` vers `home/.zshrc` (voir Ambiguïtés, point 6). **Aucune traduction fish↔zsh, jamais.**
- Tests : arborescences factices sous `tmp_path` uniquement.

- [ ] **Étape 1 : tests qui échouent**

```python
# tests/test_shell.py — comportements exigés (écrire en entier)
def _fake_home(tmp_path):
    home = tmp_path / "home"
    fish = home / ".config" / "fish"; fish.mkdir(parents=True)
    (fish / "config.fish").write_text("set -g theme x")
    (fish / "fish_variables").write_text("SETUVAR secret:abc")
    (fish / "config.fish.backup").write_text("vieux")
    (fish / "config.fish.bak-2026-07-06").write_text("vieux")
    (fish / "functions").mkdir(); (fish / "functions" / "ll.fish").write_text("function ll; end")
    (home / ".zshrc").write_text("export EDITOR=vim")
    return home

def test_collect_excludes_residues_and_marks_sensitive(tmp_path):
    # dest/fish/config.fish.backup absent, dest/fish/config.fish.bak-2026-07-06 absent,
    # functions/ll.fish présent, artifact ...fish_variables sensitive=True,
    # meta["login_shell"] non vide, dest/meta.json existe
def test_plan_restore_three_states(tmp_path):
    # cible: config.fish identique → SATISFIED ; ll.fish absent → ADDITIVE ;
    # .zshrc différent → DESTRUCTIVE avec reason
def test_shell_choice_fish_ignores_zsh(tmp_path): ...
def test_zsh_requested_without_zsh_in_snapshot_installs_omz(tmp_path):
    # shell_choice="zsh", pas de src/zsh → actions contiennent "installer oh-my-zsh propre",
    # aucune exception
def test_chsh_is_destructive_and_never_applied_without_force(tmp_path):
    # meta.json login_shell="/opt/homebrew/bin/fish", _login_shell monkeypatché "/bin/zsh"
    # → action chsh DESTRUCTIVE ; restore(force=False) → runner.calls sans "chsh"
def test_restore_additive_copies_files(tmp_path): ...
def test_no_translation_between_shells(tmp_path):
    # restore fish n'écrit jamais sous zsh et réciproquement
```

- [ ] **Étape 2 : échec** → **Étape 3 : implémenter** → **Étape 4 :** `uv run pytest tests/test_shell.py -v` PASS.
- [ ] **Étape 5 : commit** — `git commit -m "feat(machine-backup): module shell fish/zsh avec exclusions et chsh destructif"`

---

### Tâche 14 : doctor

**Files:**
- Modify: `tools/machine-backup/src/machine_backup/cli.py`
- Test: `tools/machine-backup/tests/test_doctor.py`

**Interfaces:**
- Produces: `@dataclass DoctorCheck: name: str; ok: bool; detail: str; critical: bool` ; `run_doctor(config: Config) -> list[DoctorCheck]` ; `cmd_doctor(config, reporter) -> int` (tableau, exit 1 si un check `critical` échoue, 0 sinon).
- Checks (dans l'ordre) :
  1. Binaires cœur `tar`, `zstd`, `age` — chemin résolu + version (`age --version`) ; critiques.
  2. Binaires modules `brew`, `psql`/`pg_dump`/`pg_dumpall` (via `_find_pg_bin`), `fish`, `zsh` — non critiques (un module peut être skipped).
  3. Clé `age` : `recipient` configuré (critique) ; **`identity` atteignable** (fichier existant et lisible) — critique, c'est LE risque n°1 de la spec : alerter explicitement « sans cette cle, tous les snapshots sont illisibles ».
  4. Serveur PostgreSQL : `SELECT 1` — non critique.
  5. Destination : existe/créable, espace disque libre via `shutil.disk_usage` (affiché en GiB) — critique si non inscriptible.
  6. Remote configuré ou non (informatif).
  7. Emplacement des logs + permissions `0700` du dossier.

- [ ] **Étape 1 : tests qui échouent** — monkeypatch de `shutil.which` et d'un runner factice ; cas : tout OK → exit 0 ; identity absente → check critique KO, exit 1, message contenant « illisibles » ; `pg` absent → non critique, exit 0. (Écrire les trois tests en entier.)
- [ ] **Étape 2 : échec** → **Étape 3 : implémenter** → **Étape 4 :** `uv run pytest tests/test_doctor.py -v` PASS, et `uv run machine-backup doctor` sur la machine réelle affiche le tableau.
- [ ] **Étape 5 : commit** — `git commit -m "feat(machine-backup): commande doctor avec verification de la cle age"`

---

### Tâche 15 : sync.py — remote optionnel

**Files:**
- Create: `tools/machine-backup/src/machine_backup/sync.py`
- Modify: `tools/machine-backup/src/machine_backup/cli.py` (brancher l'étape 6 du flux backup)
- Test: `tools/machine-backup/tests/test_sync.py`

**Interfaces:**
- Produces: `sync_snapshot(snapshot_dir: Path, target: str, runner=run_logged) -> bool` — `rsync -a <snapshot_dir> <target>/` (copie le dossier horodaté entier : manifest en clair + archive chiffrée). Retourne `False` (et log ERROR) si rsync échoue — un échec de sync ne dégrade JAMAIS le statut du backup local (le local doit marcher seul) ; le CLI affiche un avertissement. Appelé uniquement si : archive OK, `config.remote_target` non nul, pas `--no-sync`.
- Voir Ambiguïtés, point 1 : le mécanisme exact n'est pas spécifié par la spec — `rsync -a` est le choix par défaut de ce plan.

- [ ] **Étape 1 : tests qui échouent** : (1) runner factice → argv exact `["rsync", "-a", str(snapshot_dir), "<target>/"]` ; (2) échec rsync → `False` sans exception ; (3) `run_backup` avec `remote_target` configuré et `no_sync=True` → runner jamais appelé ; (4) `run_backup` avec module en échec → sync quand même appelé si l'archive a été produite (le snapshot partial est syncable). (Écrire les quatre.)
- [ ] **Étape 2 : échec** → **Étape 3 : implémenter** → **Étape 4 :** suite complète PASS.
- [ ] **Étape 5 : commit** — `git commit -m "feat(machine-backup): sync remote optionnel du snapshot via rsync"`

---

### Tâche 16 : bootstrap.sh

**Files:**
- Create: `tools/machine-backup/bootstrap.sh` (exécutable, `chmod +x`)
- Test: `tools/machine-backup/tests/test_bootstrap.py`

**Contenu exact :**

```sh
#!/bin/sh
# bootstrap machine-backup — POSIX, idempotent : teste avant d'installer.
set -eu
say() { printf '%s\n' "$*"; }
DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)

if command -v brew >/dev/null 2>&1; then
    say "brew : trouve ($(command -v brew))"
else
    say "brew : absent — installation via l'installeur officiel"
    NONINTERACTIVE=1 /bin/bash -c \
        "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
    # PATH de CETTE session uniquement — aucun fichier de profil modifie par cet outil
    PATH="/opt/homebrew/bin:$PATH"
    say "brew : installe"
fi

if command -v uv >/dev/null 2>&1; then
    say "uv : trouve ($(command -v uv))"
elif [ -x "$HOME/.local/bin/mise" ] && "$HOME/.local/bin/mise" which uv >/dev/null 2>&1; then
    say "uv : trouve via mise"
    PATH="$(dirname "$("$HOME/.local/bin/mise" which uv)"):$PATH"
else
    say "uv : absent — installation (sans modification du profil shell)"
    curl -LsSf https://astral.sh/uv/install.sh | env UV_NO_MODIFY_PATH=1 sh
    PATH="$HOME/.local/bin:$PATH"
    say "uv : installe"
fi

exec uv run --project "$DIR" machine-backup "$@"
```

Points de conformité : POSIX (`sh`, pas de bashisme hors la sous-invocation officielle brew), idempotent (chaque étape teste avant d'agir), affiche systématiquement trouvé/installé, `UV_NO_MODIFY_PATH=1` et PATH modifié en session courante seulement (invariant environnement — voir Ambiguïtés, point 7 pour le cas brew).

- [ ] **Étape 1 : tests qui échouent**

```python
# tests/test_bootstrap.py
import os, stat, subprocess
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]

def test_posix_syntax():
    assert subprocess.run(["sh", "-n", str(ROOT / "bootstrap.sh")]).returncode == 0

def test_executable_bit():
    assert (ROOT / "bootstrap.sh").stat().st_mode & stat.S_IXUSR

def test_idempotent_with_stubs(tmp_path):
    stubs = tmp_path / "bin"; stubs.mkdir()
    (stubs / "brew").write_text("#!/bin/sh\nexit 0\n")
    (stubs / "uv").write_text(f'#!/bin/sh\nprintf \'%s \' "$@" > "{tmp_path}/uv-args"\nexit 0\n')
    (stubs / "curl").write_text("#!/bin/sh\nexit 97\n")   # tout telechargement = echec du test
    for f in stubs.iterdir(): f.chmod(0o755)
    env = {"PATH": f"{stubs}:/usr/bin:/bin", "HOME": str(tmp_path)}
    r = subprocess.run(["sh", str(ROOT / "bootstrap.sh"), "doctor"],
                       env=env, capture_output=True, text=True)
    assert r.returncode == 0
    assert "brew : trouve" in r.stdout and "uv : trouve" in r.stdout
    args = (tmp_path / "uv-args").read_text()
    assert "run" in args and "machine-backup doctor" in args   # rien installe, delegation directe
```

- [ ] **Étape 2 : échec** → **Étape 3 : écrire `bootstrap.sh`** (contenu ci-dessus) + `chmod +x` → **Étape 4 :** `uv run pytest tests/test_bootstrap.py -v` PASS.
- [ ] **Étape 5 : commit** — `git commit -m "feat(machine-backup): bootstrap.sh POSIX idempotent (brew, uv)"`

---

### Tâche 17 : README + vérification finale

**Files:**
- Create: `tools/machine-backup/README.md`

**Contenu du README (sections obligatoires) :** finalité ; démarrage (`./bootstrap.sh doctor` puis `./bootstrap.sh backup`) ; toutes les sous-commandes avec exemples (`restore latest --only postgres --apply --force postgres`, `--shell fish|zsh|both`) ; format du snapshot et du `manifest.json` (renvoyer à la structure) ; `config.toml` complet commenté ; génération des clés (`age-keygen`, recipient dans la config, identity HORS backup) ; **avertissement en gras sur la clé privée `age`** (sa perte rend tous les snapshots illisibles — la stocker dans le gestionnaire de mots de passe ou sur papier) ; emplacement des logs et politique de rédaction ; invariant environnement ; limites v1 (pas de traduction fish↔zsh, modules v1 uniquement).

- [ ] **Étape 1 : rédiger le README.**
- [ ] **Étape 2 : vérification finale complète**

```
cd tools/machine-backup
uv run pytest -v                          # TOUTE la suite passe
uv run machine-backup doctor              # tableau cohérent sur la machine réelle
uv run machine-backup backup --only shell # backup réel minimal (nécessite [age].recipient configuré)
uv run machine-backup list
uv run machine-backup verify latest
uv run machine-backup restore latest --only shell   # dry-run : plan « satisfied » partout
```

Attendu : le restore dry-run juste après un backup shell affiche des actions majoritairement `satisfied` (machine conforme à son propre snapshot) — c'est le test de cohérence bout en bout du cycle collect → plan_restore.

- [ ] **Étape 3 : commit** — `git commit -m "docs(machine-backup): README complet (usage, cles age, limites v1)"`
- [ ] **Étape 4 : intégration** — suivre le flow global : push `feature/machine-backup`, merge `--ff-only` dans `dev`, suppression de la branche. **Jamais vers `main` sans demande explicite de l'utilisateur.**

---

## Ambiguïtés et trous de la spec — à trancher par l'implémenteur (ne pas combler silencieusement)

1. **Mécanisme de sync remote non spécifié.** La spec dit seulement « étage remote optionnel ». Ce plan choisit `rsync -a` vers une cible unique `remote.target` (chaîne libre type `user@host:/chemin`). Alternatives : `rclone`, `scp`, cible S3. À valider avec l'utilisateur avant la tâche 15.
2. **Format de `config.toml` non spécifié.** Sections, noms de clés et défauts (hors `keep=7` snapshots et rotation logs 20) sont une proposition de ce plan. À valider (notamment l'emplacement par défaut de l'identity `~/.config/machine-backup/identity.txt` — qui vit alors sur la machine sauvegardée, voir point 3).
3. **Gestion de la clé age.** La spec impose que la clé privée vive « hors du backup » mais ne dit pas si l'outil doit accepter une identity absente du disque au moment du backup (seul le recipient est nécessaire pour chiffrer). Ce plan : backup exige seulement `recipient` ; `restore`/`verify` exigent `identity`. `doctor` alerte si l'identity est inatteignable sans bloquer le backup. À confirmer.
4. **Manifest dans l'archive ET en clair.** La spec demande les deux, mais le hash de l'archive ne peut pas figurer dans l'archive. Résolution de ce plan : copie interne sans bloc `archive`, copie en clair avec. À valider.
5. **Classification du restore de `globals.sql`.** La spec ne classe que les bases (absente→additive, existante→destructive). Rejouer les globals peut pourtant modifier des rôles existants (mots de passe, options). Ce plan le classe `additive` avec `ON_ERROR_STOP=0`. Position plus prudente possible : `destructive` si des rôles du fichier existent déjà.
6. **« oh-my-zsh installé propre » : méthode non définie.** Ce plan : `git clone --depth=1` + copie du template `.zshrc` (évite l'installeur officiel qui peut lancer `chsh` et modifier le profil). À valider.
7. **Tension bootstrap ↔ invariant environnement.** L'installeur officiel Homebrew ajoute `brew shellenv` au profil du shell sur une machine vierge — ce qui contredit littéralement « ne modifie jamais l'environnement ». Ce plan considère l'invariant comme portant sur l'outil Python et laisse l'installeur brew officiel faire son travail standard (bootstrap = machine en cours de réinstallation). De même, uv est installé en standalone (`UV_NO_MODIFY_PATH=1`) alors que la machine actuelle l'a via mise : faut-il préférer `mise install uv` quand mise est présent ? Ce plan détecte mise mais installe uv standalone en dernier recours.
8. **Chiffrement toujours-on vs « obligatoire dès que le remote est actif ».** Le tableau des décisions de la spec lie l'obligation d'age au remote, mais le format de sortie montre `snapshot.tar.zst.age` inconditionnel. Ce plan chiffre TOUJOURS (conforme au format et aux contraintes de la mission). Conséquence : impossible de faire un backup sans avoir généré une paire de clés — à assumer ou à assouplir.
9. **`verify <snapshot>` : forme de l'argument.** Non précisée. Ce plan accepte `latest`, un horodatage exact, ou un chemin absolu (`resolve_snapshot`). Idem pour `restore`.
10. **Périmètre exact de la sauvegarde zsh/oh-my-zsh.** « thème et plugins oh-my-zsh » : ce plan sauvegarde `~/.oh-my-zsh/custom/` uniquement (pas le framework complet, ré-installable). À valider si des plugins sont installés hors `custom/`.
11. **Progression « une barre par base PostgreSQL avec sa taille ».** Le contrat `Module.collect(dest)` ne reçoit pas de Reporter : la granularité par base exigerait d'étendre le contrat (callback de progression) ou de laisser l'orchestrateur interroger le module. Ce plan v1 s'en tient à une barre par module + logs par base via `run_logged`, et note l'extension du contrat (`collect(dest, progress: Callable | None = None)`) comme amélioration. À trancher si la barre par base est exigée dès la v1.
12. **Bibliothèque CLI et version Python non spécifiées.** Ce plan choisit `argparse` (stdlib, zéro dépendance ajoutée) et Python ≥ 3.12 épinglé via `mise.toml`.
