# osx-backup — Design

Date : 2026-09-03
Statut : validé par l'utilisateur, prêt pour le plan d'implémentation.

## 1. Objectif

Outil interactif en ligne de commande qui sauvegarde tout ce qui compte sur un Mac de développeur vers un disque externe (ou tout dossier monté), et qui réinstalle l'ensemble sur un Mac vierge. Scénario principal : migration vers une nouvelle machine. Le backup est un miroir « latest », pas un système de snapshots.

Périmètre couvert :

- Outils : Homebrew (formulae, casks, taps, services), mise (runtimes et outils npm), uv (tools).
- Config : dotfiles via liste blanche, Claude Code (config uniquement), éditeurs (VS Code, Zed, Ghostty).
- Données : `~/Github` sans dépendances ni caches, `~/Documents`.
- Bases : PostgreSQL Homebrew (dumps par base), volumes Docker nommés sous Colima.
- Secrets : archive séparée chiffrée par passphrase (age).
- Inventaire : applications de `/Applications` hors casks (liste manuelle à réinstaller).

Hors périmètre (YAGNI) : snapshots datés, cloud, chiffrement global, images Docker, sessions Claude, macOS `defaults`, LaunchAgents.

## 2. Stack et structure

- Python 3.13, package `src/osx_backup/`, exécuté via `uv run osx-backup`.
- Dépendances : `typer` (CLI), `rich` (tables, progress bars, logs), `questionary` (checkboxes), `pyrage` (age en Python, passphrase sans TTY externe).
- Outils externes appelés : `brew`, `mise`, `uv`, `rsync`, `pg_dump`, `pg_dumpall`, `pg_restore`, `psql`, `createdb`, `docker`, `colima`, `code`.
- Tests : `pytest`. Lint/format : `ruff`.

```
osx-backup/
  pyproject.toml
  map.toml                     # liste blanche dotfiles + éditeurs
  src/osx_backup/
    cli.py                     # typer : map / backup / restore
    context.py                 # Context (dest, dry_run, yes, runner, console, home)
    runner.py                  # CommandRunner injectable (real / recording)
    manifest.py                # lecture/écriture manifest.json + historique
    ui.py                      # checklist questionary, tables et progress rich
    modules/
      base.py                  # Module ABC, Inventory, ModuleResult, registry
      brew.py mise.py uv.py dotfiles.py claude.py editors.py
      github.py documents.py postgres.py docker.py apps.py secrets.py
    rsync.py                   # wrapper rsync avec parsing de progression
  tests/
  docs/superpowers/specs/
```

## 3. CLI

Trois commandes, flags communs `--modules a,b,c`, `--all`, `--yes`, `--dry-run`, `--verbose`.

- `osx-backup map` : lance `inventory()` de chaque module, affiche une table (module, disponible ou non, nombre d'items, taille estimée). Aucune écriture.
- `osx-backup backup [--dest PATH]` : inventaire, checklist des modules (questionary checkbox, modules indisponibles grisés et non cochables, sélection précédente pré-cochée), affichage du plan, confirmation, exécution avec barres de progression, résumé.
- `osx-backup restore [--from PATH]` : même flow. Ordre de restauration imposé quel que soit l'ordre de sélection.

La destination est mémorisée dans `~/.config/osx-backup/config.toml` (`dest = "..."`) après le premier run. `--dest` / `--from` l'emportent. Sans destination connue, l'outil demande le chemin.

`--yes` saute checklist et confirmation (tous les modules disponibles, ou ceux de `--modules`). `--dry-run` affiche le plan détaillé (commandes et chemins) sans exécuter, y compris `rsync --dry-run --stats` pour les tailles réelles.

## 4. Modèle de module

```python
class Module(ABC):
    name: str
    description: str
    order: int                     # ordre de restauration
    def inventory(self, ctx) -> Inventory      # available, reason, items, size_bytes, warnings
    def backup(self, ctx, progress) -> ModuleResult
    def restore(self, ctx, progress) -> ModuleResult
```

- `Inventory.available = False` avec `reason` quand un prérequis manque (binaire absent, daemon arrêté, dossier inexistant).
- `ModuleResult` : `status` (ok / failed / skipped), `message`, `size_bytes`, `duration_s`, `items`, `warnings`.
- Un module qui échoue ne bloque pas les suivants. Code de sortie 1 si au moins un échec.
- Chaque module écrit dans `<dest>/<module>/` uniquement (sauf secrets : `<dest>/secrets.tar.age`).
- Toute commande externe passe par `ctx.runner.run(cmd, ...)` pour être enregistrable en test.
- Toute écriture dans `$HOME` à la restauration passe par `ctx.place(src, dst)` qui déplace un fichier ou dossier existant vers `~/.osx-backup-pre-restore/<timestamp>/<chemin relatif>` avant de copier.
- En `dry_run`, `ctx.runner.run` et `ctx.place` journalisent l'action au lieu de l'exécuter.

## 5. Modules

Ordre de restauration : brew → mise → uv → dotfiles → secrets → claude → editors → github → documents → postgres → docker → apps.

### brew
- Backup : `brew bundle dump --file <dest>/brew/Brewfile --force --describe`, `brew services list --json` → `services.json`.
- Restore : `brew bundle install --file <dest>/brew/Brewfile`, puis `brew services start <name>` pour chaque service dont le statut sauvegardé était `started`.
- Inventaire : nombres de formulae, casks, taps, services actifs (via `brew list --formula`, `brew list --cask`, `brew tap`, `brew services list --json`).

### mise
- Backup : copie de `~/.config/mise/config.toml` et `mise ls --json` → `ls.json` (référence).
- Restore : copie du config puis `mise trust ~/.config/mise/config.toml` et `mise install`.

### uv
- Backup : `uv tool list` parsé → `tools.json` (nom, version).
- Restore : `uv tool install <nom>` pour chaque outil ; échec d'un outil = warning, pas failed.

### dotfiles
- Source de vérité : `map.toml`, section `[[dotfiles]]` avec `path` (relatif à `~`), `secret` (bool, défaut false), `exclude` (globs optionnels), `description`.
- Backup : copie de chaque chemin non secret vers `<dest>/dotfiles/<path>` (les secrets sont pris en charge par le module secrets). Chemins absents ignorés avec warning dans l'inventaire.
- Restore : `ctx.place` de chaque chemin.
- Contenu initial de la map, non secret : `.zshrc`, `.zprofile`, `.gitconfig`, `.tmux.conf`, `.vim`, `.config/fish`, `.config/git`, `.config/starship.toml`, `.config/direnv`, `.config/beets`, `.config/cmux`, `.config/herdr`, `.codex`, `.gemini`, `.agents`, `.cursor`, `.continue`, `.serena`, `.claude-squad`, `.hermes`.
- Secret : `.ssh`, `.gnupg`, `.config/sops`, `.config/age`, `.config/gh`, `.config/gcloud`, `.claude.json`, `.docker/config.json`, `.npmrc`, `.netrc`, `.aws`, `.boto`, `.railway`, `.config/stripe`, `.config/vercel-plugin`, `.snowflake`, `.cloudflared`.
- Exclusions globales appliquées à toute copie de dotfiles : `**/cache`, `**/Cache`, `**/caches`, `**/logs`, `**/*.log`, `**/node_modules`, `**/.DS_Store`, `**/*.sock`.
- `.config/mise` et `.config/zed` ne sont pas dans la map : ils appartiennent aux modules mise et editors.

### claude
- Chemins : `~/.claude/settings.json`, `CLAUDE.md`, `RTK.md`, `statusline.sh`, `hooks/`, `skills/`, `plugins/` (sans `plugins/cache`), `projects/*/memory/` (glob). `~/.claude.json` est un secret (module secrets).
- Restore : `ctx.place` pour chaque chemin.

### editors
- VS Code : `code --list-extensions` → `vscode/extensions.txt` ; copie de `~/Library/Application Support/Code/User/{settings.json,keybindings.json,snippets/}`.
- Zed : `~/.config/zed/`.
- Ghostty : `~/Library/Application Support/com.mitchellh.ghostty/` et `~/.config/ghostty/` si présents.
- Restore : copie + `code --install-extension <id>` par extension (warning si `code` absent, le cask VS Code est installé par brew avant).

### github
- Backup : `rsync -a --delete --info=progress2` de `~/Github/` vers `<dest>/github/` avec exclusions : `node_modules`, `.venv`, `venv`, `__pycache__`, `.pytest_cache`, `.mypy_cache`, `.ruff_cache`, `.next`, `.nuxt`, `.svelte-kit`, `.output`, `.turbo`, `.cache`, `.parcel-cache`, `dist`, `build`, `target`, `.DS_Store`, `.env`, `.env.*`. `.git` conservé. Les `.env*` partent dans le module secrets.
- Progression : parse de `--info=progress2` pour une barre rich en octets.
- Inventaire : `rsync --dry-run --stats` pour la taille réelle ; affiche aussi le nombre de dossiers de premier niveau sans `.git`.
- Restore : rsync inverse sans `--delete`.

### documents
- Identique à github avec exclusions réduites (`.DS_Store`, `node_modules`). Restore sans `--delete`.

### postgres
- Prérequis : `pg_dump` dans le PATH et connexion locale possible (`psql -lqt`). Sinon indisponible avec raison.
- Backup : `pg_dumpall --globals-only` → `globals.sql` ; `databases.json` (nom, owner) ; pour chaque base hors `template0`, `template1`, `postgres` : `pg_dump -Fc <db>` → `<db>.dump`. Une barre par base.
- Restore : vérifie `pg_isready`, sinon tente `brew services start <postgresql formula>` (formule lue depuis `services.json` du module brew si présent, sinon `postgresql@18`) ; `psql -f globals.sql` (erreurs « already exists » tolérées) ; pour chaque dump : `createdb -O <owner>` si absente puis `pg_restore --no-owner --role=<owner> -d <db>`.

### docker
- Prérequis : `docker info` répond. Sinon indisponible (« Colima non démarré »).
- Backup : `inventory.json` avec containers (nom, image, état, labels compose `project` et `working_dir`), images, volumes. Pour chaque volume nommé (nom non hexadécimal de 64 caractères, sauf `--include-anonymous`) : identifie les containers qui le montent, stoppe ceux qui tournent (sauf `--no-stop`), exporte via `docker run --rm -v <vol>:/from:ro alpine tar czf - -C /from .` dont stdout est écrit dans `<dest>/docker/volumes/<vol>.tar.gz.tmp`, renommé en `<vol>.tar.gz` seulement si le code de retour est 0 et le fichier non vide ; redémarre les containers stoppés dans un `finally` (chaque container n'est arrêté et redémarré qu'une fois, quel que soit le nombre de volumes qu'il monte). Une barre par volume.
- Restore : `colima start` si `docker info` échoue, `docker volume create <vol>` puis `docker run --rm -i -v <vol>:/to alpine tar xzf - -C /to` alimenté par l'archive sur stdin. Affiche ensuite la liste des projets compose avec leur `working_dir` (réécrit de l'ancien `$HOME` vers le nouveau) sous forme de commandes `cd … && docker compose up -d` à lancer manuellement.
- L'archive transite par stdout/stdin et jamais par un bind mount du disque de destination : Colima ne partage que `$HOME` et `/tmp/colima` avec la VM, donc un `-v /Volumes/<disque>/…:/to` y serait créé à vide et le tar s'écrirait dans la VM sans rien déposer sur le disque externe.

### apps
- Backup : liste `/Applications/*.app` moins les apps fournies par les casks (présence d'un `.app` de même nom sous `$(brew --caskroom)/*/*/`). Écrit `apps/applications.md` avec nom, version (`CFBundleShortVersionString` via `plutil` ou `plistlib`), source devinée (App Store si `Contents/_MASReceipt` existe, Setapp si sous `/Applications/Setapp`, sinon manuel).
- Restore : affiche le Markdown. Pas d'installation automatique.

### secrets
- Collecte : entrées `secret = true` de `map.toml` + tous les fichiers `.env*` sous `~/Github` (hors dossiers exclus du module github), chemins conservés relatifs à `~`.
- Backup : tar construit dans un fichier temporaire du répertoire temp système, chiffré avec `pyrage.passphrase.encrypt` → `<dest>/secrets.tar.age`. Passphrase demandée deux fois (confirmation), ou via la variable `OSX_BACKUP_PASSPHRASE` pour le non-interactif. Fichier temporaire supprimé ; jamais de tar en clair sur le disque de destination.
- Restore : passphrase demandée, déchiffrement, extraction fichier par fichier via `ctx.place`, permissions `0600` pour les fichiers, `0700` pour les dossiers `.ssh` et `.gnupg`.
- Inventaire : nombre de fichiers et taille, liste des entrées manquantes en warning.

## 6. Disposition du disque de destination

```
<dest>/
  README.md                 # généré : bootstrap Mac vierge
  osx-backup/               # copie de l'outil (rsync du repo sans .venv, .git conservé)
  manifest.json             # dernier run
  manifests/<timestamp>.json
  logs/<timestamp>.log
  brew/  mise/  uv/  dotfiles/  claude/  editors/  github/  documents/
  postgres/  docker/  apps/
  secrets.tar.age
```

`manifest.json` : `created_at`, `hostname`, `user`, `home`, `tool_version`, `modules: {name: {status, size_bytes, duration_s, items, warnings, message, created_at}}`. Le disque étant un miroir cumulatif, le manifeste conserve les entrées des modules non ré-exécutés par le dernier run (chaque entrée porte le `created_at` du run qui l'a produite) : un `backup --modules github` ne fait pas disparaître de la restauration le brew ou les secrets déjà sauvegardés. `restore` lit le manifeste pour connaître les modules présents et l'ancien `$HOME` (réécriture des chemins compose).

`README.md` généré :
1. Installer Xcode Command Line Tools : `xcode-select --install`.
2. Installer Homebrew.
3. `brew install uv rsync`.
4. `cd /Volumes/<disque>/osx-backup && uv run osx-backup restore --from /Volumes/<disque>`.

## 7. Interface

- Checklist : `questionary.checkbox` avec un choix par module, libellé `nom  —  N items, taille  (description)`. Modules indisponibles listés en `disabled` avec la raison. Pré-sélection : dernier choix mémorisé dans `~/.config/osx-backup/config.toml`, sinon tous les disponibles.
- Plan : table rich (module, action, source, destination, taille). Confirmation `questionary.confirm`.
- Exécution : `rich.progress.Progress` avec une tâche globale (modules) et une tâche par module actif : octets pour rsync (parse `progress2`), nombre d'items pour les boucles (bases, volumes, extensions, outils uv), spinner pour les commandes opaques (`brew bundle`).
- Résumé : table finale avec statut coloré, durée, taille, warnings. Chemin du log.
- Toute sortie détaillée des commandes externes va dans `logs/<timestamp>.log`, pas dans le terminal, sauf `--verbose`.

## 8. Gestion des erreurs

- Prérequis vérifiés à l'inventaire, jamais pendant l'exécution si évitable.
- Espace disque : avant le backup, comparer la taille estimée totale avec `shutil.disk_usage(dest)` ; refuser si insuffisant sauf `--force`.
- Destination doit exister et être inscriptible ; refus si c'est `$HOME` ou un parent de `$HOME`.
- Interruption (Ctrl-C) : le module courant est marqué `failed: interrupted`, les containers Docker stoppés sont redémarrés (bloc `finally`), le manifeste est écrit avec l'état partiel.
- Restauration : un fichier existant n'est jamais écrasé sans copie dans `~/.osx-backup-pre-restore/<ts>/`.
- Secrets : mauvaise passphrase = échec du module uniquement.

## 9. Tests

- Unitaires : parsing de `map.toml`, résolution des globs et exclusions, filtre des volumes anonymes, parse `progress2`, parse `uv tool list`, parse `brew services list --json`, round-trip tar+age, écriture et lecture du manifeste, réécriture du `working_dir` compose.
- Modules fichiers (dotfiles, claude, editors, secrets) : fixture `HOME` temporaire avec arborescence factice, backup vers un `dest` temporaire, restore vers un second `HOME` temporaire, comparaison des arbres et vérification du dossier pre-restore.
- Modules à commandes (brew, mise, uv, postgres, docker, github via rsync) : `RecordingRunner` qui enregistre les commandes et renvoie des sorties préparées ; assertions sur la séquence de commandes, y compris redémarrage des containers dans le `finally`.
- Smoke test manuel sur la machine réelle : `osx-backup map` puis `osx-backup backup --dry-run --all`.

## 10. Convention de développement

- Branches selon les règles globales : `dev` intégration, `feature/<module>` par tâche, merge ff-only, suppression après merge. `main` créé au premier merge validé par l'utilisateur.
- Conventional Commits.
- Subagents : implémentation sur Opus, review et arbitrages d'architecture sur Fable, tâches mécaniques sur le modèle le plus léger qui suffit.
