Skip to content

Commit 14fc7e6

Browse files
committed
docs: 3.0 execution plan for sprints 2c → 5
Self-contained plan another agent can follow without our session context. Lays out, per sprint: objective, files to touch, migration pattern, code samples, acceptance criteria, and commit message conventions. To be deleted at the 3.0 release.
1 parent 7c711ff commit 14fc7e6

1 file changed

Lines changed: 332 additions & 0 deletions

File tree

docs/3.0-execution-plan.md

Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
1+
# shipnode 3.0 — Plan d'exécution (Sprints 2c → 5)
2+
3+
Document de travail temporaire. À supprimer à la release 3.0.
4+
5+
## Contexte de départ
6+
7+
- **Repo**: `~/Works/personal/shipnode`
8+
- **Branche**: `v3` (synchronisée avec `origin/v3`)
9+
- **HEAD actuel**: `7c711ff feat(builder): ShipnodeAppBuilder + .apps([]) workspace composition (sprint 2b)`
10+
- **Tests**: 199 passent (`pnpm test`)
11+
- **Typecheck**: clean (`pnpm lint` = `tsc --noEmit`)
12+
- **package.json**: `"version": "2.5.3"` — sera bumpé à `3.0.0` en Sprint 5
13+
14+
## Lectures obligatoires avant tout code
15+
16+
1. `docs/adr/0004-workspace-multi-app.md` — le design figé pour 3.0 (workspace = N apps partageant ssh/cloudflare/etc., release per-app, CLI `--app` flag, schema-first canonical source, couches internes propres)
17+
2. `CHANGELOG.md` section `[Unreleased]` — explique le refactor schema-first déjà fait (Sprint 1) et le dual-shape transitionnel (Sprint 2a)
18+
3. `src/config/schema.ts` — comprendre comment `ShipnodeConfigBaseSchema` + `z.preprocess` synthétise `apps[0]` depuis les legacy top-level fields
19+
4. `src/config/assembly.ts` — comprendre comment `assembleConfig` mirror `apps[0]` vers les legacy top-level fields après parse
20+
5. `src/config/builder.ts` — comprendre `ShipnodeBuilder` (legacy) vs `ShipnodeAppBuilder` (nouveau, pour `.apps([])`)
21+
22+
## Invariants à respecter — non-négociables
23+
24+
1. **Chaque commit doit laisser le repo vert**: `pnpm lint` et `pnpm test` passent. Aucun commit "WIP" rouge.
25+
2. **Pas de régression 2.x**: toute config 2.x existante (sans `.apps([])`) doit continuer à parser, à passer la validation, et à produire le même comportement de déploiement qu'avant. C'est le rôle du dual-shape (legacy top-level mirrors + `apps[]` canonique) — il reste en place jusqu'à 3.0 stable.
26+
3. **Commits atomiques, un par sous-sprint**, avec message conventionnel `feat|refactor|test|docs(scope): description (sprint 2c)` — pas de co-author, pas de footer "Generated with Claude".
27+
4. **Push après chaque sous-sprint** (`git push origin v3`).
28+
5. **Pas d'agents spawnés** — l'agent exécutant utilise ses propres outils (Read/Edit/Write/Bash).
29+
6. **Pas de breaking change inattendu**: si une refonte casserait un test 2.x existant pour de bonnes raisons, en parler avant de pousser (mettre un commentaire `TODO(sprint-2g)` et continuer).
30+
31+
## Sprint 2c — Migration downstream vers `config.apps[i]`
32+
33+
### Objectif
34+
Les consumers internes (services, strategies, CLI commands, domain modules) lisent les fields per-app depuis `config.apps[i]` plutôt que depuis les legacy top-level mirrors. Pour la transition: `i = 0` partout — c'est encore single-app sous le capot. La vraie itération multi-app vient en 2d.
35+
36+
### Stratégie recommandée
37+
Créer un helper unique `src/domain/workspace.ts` qui expose:
38+
39+
```ts
40+
export function getActiveApp(config: ShipnodeConfig, name?: string): ShipnodeApp {
41+
if (name) {
42+
const found = config.apps.find((a) => a.name === name);
43+
if (!found) throw new Error(`No app named "${name}" in this workspace`);
44+
return found;
45+
}
46+
return config.apps[0];
47+
}
48+
```
49+
50+
Tous les consumers downstream appellent `getActiveApp(config)` (ou `getActiveApp(config, opts.app)` quand le `--app` flag arrive en 2e). Ça centralise le point de migration pour passer du single-app au multi-app.
51+
52+
### Fichiers à migrer (par couche)
53+
54+
**Domain + services** (impact métier):
55+
- `src/domain/deploy/backend-strategy.ts`
56+
- `src/domain/deploy/frontend-strategy.ts`
57+
- `src/domain/deploy/orchestrator.ts`
58+
- `src/domain/pm2/apps.ts`
59+
- `src/services/caddy.service.ts`
60+
- `src/services/deploy.service.ts`
61+
- `src/services/health.service.ts`
62+
- `src/infrastructure/ssh/connection.ts`
63+
64+
**CLI commands** (adapters):
65+
- `src/cli/commands/deploy.ts`
66+
- `src/cli/commands/env.ts`
67+
- `src/cli/commands/restart.ts`
68+
- `src/cli/commands/stop.ts`
69+
- `src/cli/commands/logs.ts`
70+
- `src/cli/commands/status.ts`
71+
- `src/cli/commands/metrics.ts`
72+
- `src/cli/commands/run.ts`
73+
- `src/cli/commands/rollback.ts`
74+
- `src/cli/commands/migrate.ts`
75+
- `src/cli/commands/doctor.ts`
76+
- `src/cli/commands/config.ts`
77+
- `src/cli/commands/ci.ts`
78+
79+
### Pattern de migration
80+
81+
Pour chaque fichier, remplacer:
82+
83+
| Avant | Après |
84+
|---|---|
85+
| `config.app` | `getActiveApp(config).appType` |
86+
| `config.domain` | `getActiveApp(config).domain` |
87+
| `config.pm2` | `getActiveApp(config).pm2` |
88+
| `config.healthCheck` | `getActiveApp(config).healthCheck` |
89+
| `config.envFile` | `getActiveApp(config).envFile` |
90+
| `config.keepReleases` | `getActiveApp(config).keepReleases` |
91+
| `config.sharedDirs` | `getActiveApp(config).sharedDirs` |
92+
| `config.sharedFiles` | `getActiveApp(config).sharedFiles` |
93+
| `config.buildDir` | `getActiveApp(config).buildDir` |
94+
| `config.appRoot` | `getActiveApp(config).appRoot` |
95+
| `config.hooks` | `getActiveApp(config).hooks` |
96+
97+
**NE PAS toucher** (ces fields restent workspace-level et restent au top-level de `config`):
98+
- `config.ssh`, `config.remotePath`, `config.nodeVersion`, `config.pkgManager`, `config.installCommand`
99+
- `config.database`, `config.redis`, `config.backup`, `config.cloudflare`, `config.aliases`
100+
101+
### Critère d'acceptance
102+
- `pnpm test` passe (199+ tests, parce que le helper aura ses propres tests)
103+
- `pnpm lint` clean
104+
- Le grep suivant ne retourne plus aucune occurrence dans `src/` (hors tests):
105+
```
106+
grep -rnE 'config\.(app|domain|pm2|healthCheck|envFile|keepReleases|sharedDirs|sharedFiles|buildDir|appRoot|hooks)\b' src/ | grep -v '\.test\.'
107+
```
108+
- Tests existants sur les strategies/services/commands continuent à passer sans modification (parce que `getActiveApp(config)` retourne `apps[0]` qui mirror les legacy top-level)
109+
- Nouveau test `tests/unit/workspace.test.ts` couvre `getActiveApp`: défaut → `apps[0]`, avec name → match exact, name introuvable → throw
110+
111+
### Commit
112+
`refactor(downstream): consume config.apps via getActiveApp helper (sprint 2c)`
113+
114+
---
115+
116+
## Sprint 2d — Strategies + orchestrator vraiment multi-app
117+
118+
### Objectif
119+
Le deploy orchestrator itère sur `config.apps[]` et déploie chaque app séquentiellement. Le layout disque devient per-app: `${remotePath}/${app.name}/releases/<ts>/` et `${remotePath}/${app.name}/current`.
120+
121+
### Changements clés
122+
- `src/domain/deploy/orchestrator.ts`: boucle sur `config.apps`, appelle la strategy appropriée (backend/frontend) par app. Stop sur première erreur. Affiche un résumé total à la fin.
123+
- `src/domain/deploy/backend-strategy.ts` et `frontend-strategy.ts`: signature change — prend `(workspace: ShipnodeConfig, app: ShipnodeApp)` au lieu de `(config: ShipnodeConfig)`. `app` est l'app à déployer, `workspace` donne accès à ssh/database/etc. partagés.
124+
- `src/services/caddy.service.ts`: génère **un site block par app** ayant un `domain`. Le `Caddyfile` agrège tous les sites du workspace.
125+
- Layout disque per-app:
126+
- `${remotePath}/${app.name}/releases/<ts>/`
127+
- `${remotePath}/${app.name}/current` → release active
128+
- `${remotePath}/${app.name}/shared/${app.envFile}` → .env de cette app
129+
- Le `${remotePath}/shared/` partagé disparaît (chaque app a son shared)
130+
- Rollback per-app: `${remotePath}/${app.name}/.releases.json` historique séparé par app
131+
132+
### Compat single-app
133+
Une config 2.x (qui n'utilise pas `.apps([])`) produit `apps = [{ name: 'app', ... }]` après assemble. Le layout disque devient donc `${remotePath}/app/releases/<ts>/` au lieu de l'ancien `${remotePath}/releases/<ts>/`.
134+
135+
**ATTENTION**: c'est un break disque pour les déploiements existants en place. Deux options:
136+
- **Option A** (recommandé): au premier deploy 3.0 sur un serveur existant, détecter l'ancien layout (`${remotePath}/releases/` existe), faire une migration automatique (`mv ${remotePath}/releases ${remotePath}/app/releases` + `ln -sf app/current current`). Documenter dans le migration guide.
137+
- **Option B**: garder l'ancien layout flat quand `config.apps.length === 1` et `apps[0].name === 'app'` (sentinelle "auto-named"). Évite la migration disque mais perpétue 2 code paths.
138+
139+
Choisir Option A — moins de dette technique. Faire la migration disque dans une nouvelle commande `shipnode migrate` ou directement au début du deploy si detected.
140+
141+
### Critère d'acceptance
142+
- `pnpm test` passe avec les nouveaux tests multi-app
143+
- Tests existants single-app continuent à passer après ajustements de path
144+
- `tests/unit/orchestrator.test.ts` couvre: deploy de 2 apps séquentiel, échec sur app[0] empêche app[1], résumé final
145+
- `tests/unit/backend-strategy.test.ts` et `frontend-strategy.test.ts` adaptés à la nouvelle signature
146+
- `tests/unit/caddy.service.test.ts` (à créer si absent) couvre: génération du Caddyfile avec N site blocks
147+
- Le grep `config\.apps\[0\]` ne doit revenir QUE depuis le helper `getActiveApp` (toute itération sur les apps passe par `for (const app of config.apps)`)
148+
149+
### Commits (peut être split)
150+
- `refactor(deploy): orchestrator iterates over config.apps (sprint 2d)`
151+
- `feat(deploy): per-app release directories at ${remotePath}/${app.name}/`
152+
- `feat(caddy): one site block per app having a domain`
153+
154+
---
155+
156+
## Sprint 2e — CLI `--app <name>` flag
157+
158+
### Objectif
159+
Les commandes `deploy`, `env`, `restart`, `stop`, `logs`, `status`, `metrics`, `run` acceptent un flag `--app <name>` qui les fait opérer sur une app spécifique. Sans flag, elles opèrent sur **toutes les apps** du workspace (séquentiellement pour deploy/env/restart/stop; agrégé pour logs/status/metrics).
160+
161+
`rollback` **exige** `--app` (rolling back tout le workspace ne fait pas sens — voir ADR).
162+
163+
`cloudflare init`, `cloudflare audit`, `cloudflare status`, `harden`, `doctor`, `setup`, `backup setup` restent **workspace-level** (n'acceptent pas `--app`).
164+
165+
### Implémentation
166+
- Ajouter `--app <name>` option dans chaque CLI command concernée (via `commander`)
167+
- Le command appelle `getActiveApp(config, opts.app)` — déjà en place depuis 2c
168+
- Pour `deploy`/`env`/`restart`/`stop` sans `--app`: itérer `for (const app of config.apps)` et appeler la logique pour chaque
169+
- Pour `logs`/`status`/`metrics` sans `--app`: tag chaque ligne d'output avec le nom de l'app
170+
- Pour `rollback` sans `--app`: erreur explicite "`rollback requires --app <name>; available: ${apps.map(a=>a.name).join(', ')}`"
171+
172+
### Critère d'acceptance
173+
- Tests CLI commands updated pour vérifier le flag
174+
- Tests pour le mode "sans --app" sur un workspace multi-app: deploy toutes les apps en séquence
175+
- Test rollback sans --app → throw explicite
176+
177+
### Commits (split possible)
178+
- `feat(cli): --app <name> flag on deploy/env/restart/stop (sprint 2e)`
179+
- `feat(cli): --app on logs/status/metrics/run with per-app prefix`
180+
- `feat(cli): rollback requires --app explicitly`
181+
182+
---
183+
184+
## Sprint 2f — Cleanup legacy top-level mirrors
185+
186+
### Objectif
187+
Maintenant que tous les downstream lisent `apps[i]`, les legacy top-level mirrors (`config.app`, `config.domain`, `config.pm2`, etc.) ne sont plus consommés par le code de shipnode lui-même. On peut les retirer du shape canonique pour ne pas perpétuer la dette.
188+
189+
### Changements
190+
- `src/shared/types.ts`: retirer les legacy fields top-level de `ShipnodeConfig`. Garder uniquement les workspace-level fields + `apps: ShipnodeApp[]`.
191+
- `src/config/schema.ts`:
192+
- Retirer les legacy fields top-level de `ShipnodeConfigBaseSchema`.
193+
- Retirer les refines top-level (`frontend cannot declare pm2` et `domain requires web app`) — ils vivent désormais uniquement sur `ShipnodeAppSchema`.
194+
- **Garder** la `z.preprocess` qui synthétise `apps[0]` depuis les legacy fields d'INPUT (c'est ce qui permet à une config 2.x de continuer à parser).
195+
- `src/config/assembly.ts`: retirer le post-parse mirror. `assembleConfig` redevient le 5-liner de Sprint 1 (normalize pm2 + parse). Plus de mirror.
196+
- `tests/unit/assembly.test.ts`: retirer les tests qui asserentaient `config.domain === config.apps[0].domain` etc.
197+
198+
### Compat externe
199+
Si un user externe lisait `config.domain` directement (ex: dans un script appelant `loadConfig()`), ça casse. C'est OK — c'est une 3.0 major, et le migration guide (Sprint 5) documente le changement.
200+
201+
### Critère d'acceptance
202+
- `pnpm test` passe
203+
- `pnpm lint` clean
204+
- Le shape `ShipnodeConfig` exporté n'a plus de fields per-app au top level
205+
- Le z.preprocess fait toujours son job pour les configs 2.x en input
206+
- Test ajouté: une config 2.x avec `app: 'backend'`, `domain: 'x'`, `pm2: {...}` parse et produit `apps[0]` correct, MAIS `config.domain` (top-level) est `undefined` (et probablement non-typé maintenant)
207+
208+
### Commit
209+
`refactor(config): remove legacy top-level mirrors from canonical shape (sprint 2f)`
210+
211+
---
212+
213+
## Sprint 2g — Tests + CHANGELOG récap Sprint 2
214+
215+
### Objectif
216+
- Récapituler l'intégralité du Sprint 2 dans une entrée `[Unreleased]` du CHANGELOG (vue d'ensemble du workspace multi-app)
217+
- Ajouter des tests d'intégration de bout en bout: une config workspace multi-app → assemble → orchestrator dry-run → vérifications du plan de déploiement
218+
- Ajouter un test "compat 2.x": une config 2.x → assemble → orchestrator dry-run → équivalent du déploiement 2.x
219+
220+
### Commit
221+
`docs(changelog): consolidate sprint 2 workspace multi-app changes (sprint 2g)`
222+
223+
---
224+
225+
## Sprint 3 — Couches internes (cli adapters / services / domain)
226+
227+
Voir ADR 0004 section "Internal layers".
228+
229+
### Objectif
230+
- `cli/commands/*.ts` deviennent des thin adapters (~50 lignes max)
231+
- `services/*-orchestrator.ts` contiennent la logique d'orchestration
232+
- `infrastructure/*` contient les I/O (SSH, prompts, terminal UI, cloudflared API client)
233+
- `domain/*` reste pure logic, pas d'I/O
234+
235+
### Priorité (du plus grand au plus petit gain)
236+
1. `src/cli/commands/cloudflare.ts` (249 lignes mélangées) → `services/cloudflare.orchestrator.ts` + `cloudflare.ts` (~50 lignes)
237+
2. `src/cli/commands/setup.ts` — extraire les bash strings dans `infrastructure/provisioning/scripts/*.sh` (fichiers `.sh`, pas embedded strings)
238+
3. `src/cli/commands/deploy.ts` — déjà partiellement séparé, fignoler
239+
4. `src/cli/commands/harden.ts` — même pattern que setup
240+
5. Le reste au fur et à mesure
241+
242+
### Critère d'acceptance
243+
- Aucun `cli/commands/*.ts` ne dépasse 80 lignes
244+
- Aucun import depuis `infrastructure/*` dans `domain/*`
245+
- Aucun appel SSH/exec direct depuis `cli/commands/*.ts`
246+
- Tests existants passent (les services ont leurs propres tests, les adapters CLI sont triviaux)
247+
248+
### Commits (split par module refactorisé)
249+
- `refactor(cloudflare): extract orchestrator to services/cloudflare.orchestrator (sprint 3)`
250+
- `refactor(setup): move provisioning scripts to .sh files`
251+
- `refactor(deploy): finalize CLI/service split`
252+
- `refactor(harden): extract orchestrator + .sh scripts`
253+
254+
---
255+
256+
## Sprint 4 — `domain/cloudflare/` + multi-hostname tunnel
257+
258+
### Objectif
259+
Cloudflare devient un objet de domaine de premier rang. `cloudflare init` devient idempotent et gère plusieurs hostnames sur un même tunnel naturellement.
260+
261+
### Modèle domain
262+
Créer `src/domain/cloudflare/`:
263+
- `tunnel.ts`: `class Tunnel { name, id?, credentialsPath, ingress: Ingress[], catchAll }` avec méthodes `addIngress(hostname, service)`, `toYaml()`, `fromYaml(content)`.
264+
- `ingress.ts`: `interface Ingress { hostname: string, service: string }` (service est `http://localhost:${port}` typiquement).
265+
266+
### Refactor de `cloudflare init`
267+
- Lire l'éventuel `/etc/cloudflared/config.yml` existant côté serveur
268+
- Construire/mettre à jour le `Tunnel` depuis l'état serveur + le workspace local
269+
- Pour chaque `app` du workspace ayant `domain` ET `pm2.apps` avec port:
270+
- `tunnel.addIngress(app.domain, 'http://localhost:${webPort}')`
271+
- `cloudflared tunnel route dns ${tunnelName} ${app.domain}` (idempotent)
272+
- Émettre `tunnel.toYaml()``/etc/cloudflared/config.yml`
273+
- `systemctl restart cloudflared`
274+
- Optionnel: lock down firewall (UFW) si `cloudflare.lockdownFirewall === true`
275+
276+
### Breaking
277+
- `CloudflareConfig.appHostname` **supprimé**. Émettre un warning si présent dans la config + fallback sur `apps[0].domain` pour le run en cours.
278+
- `CloudflareConfig.sshHostname` reste (cas d'usage SSH-over-tunnel toujours pertinent).
279+
280+
### Critère d'acceptance
281+
- Test unitaire `tests/unit/cloudflare.tunnel.test.ts`: `Tunnel.fromYaml().addIngress(...).toYaml()` round-trip
282+
- Test orchestrator: deux apps → deux ingress entries + deux DNS routes + un seul service systemd
283+
- Le YAML émis est diffable et propre (ingress entries triées par hostname pour stabilité)
284+
285+
### Commit
286+
`feat(cloudflare): multi-hostname tunnel via domain/cloudflare model (sprint 4)`
287+
288+
---
289+
290+
## Sprint 5 — Migration guide + 3.0 release
291+
292+
### Objectif
293+
- Écrire `docs/migrations/2.x-to-3.0.md` avec exemples concrets avant/après
294+
- Mettre à jour `README.md` pour le nouveau workspace
295+
- Compléter `CHANGELOG.md` section `[3.0.0]` avec **toutes** les sorties Sprint 1-4 fusionnées
296+
- Bumper `package.json` à `3.0.0`
297+
- Tag annoté `v3.0.0`
298+
- Merge `v3``v2` (ou rename `v2``v2-lts` selon la stratégie de maintenance long terme — à clarifier avec le mainteneur)
299+
- `npm publish` (le mainteneur le fait à la main; l'agent ne publish PAS)
300+
- Mettre à jour `~/Works/personal/biormin/` pour consommer `@devalade/shipnode@3.0.0`:
301+
- Fusionner `shipnode.backend.config.ts` + `shipnode.frontend.config.ts` en un seul `shipnode.config.ts` avec `.apps([api, web])` + workspace `.cloudflare()`
302+
- Adapter les scripts `package.json` `deploy:backend`/`deploy:frontend``deploy --app api` / `deploy --app web` (ou juste `deploy` pour tout)
303+
- Tester un deploy de bout en bout sur `server.biormin.com`
304+
305+
### Critère d'acceptance
306+
- `docs/migrations/2.x-to-3.0.md` couvre: ajout de `apps[]`, déplacement des fields per-app, suppression de `CloudflareConfig.appHostname`, layout disque per-app, `--app` flag, rollback flow
307+
- `README.md` montre un workspace multi-app comme exemple principal
308+
- Test e2e biormin (au moins en dry-run) passe
309+
310+
### Commits
311+
- `docs: migration guide 2.x → 3.0 (sprint 5)`
312+
- `chore: release v3.0.0`
313+
- Sur le repo biormin: `chore(shipnode): migrate to 3.0 workspace config`
314+
315+
---
316+
317+
## Notes pour le reviewer (l'humain qui valide)
318+
319+
- Chaque sous-sprint produit un commit (ou plusieurs si le split est naturel) sur `v3` avec un message explicite
320+
- Le reviewer peut lire le diff par commit (`git log v3 --oneline` puis `git show <hash>`) et challenger chaque décision
321+
- En cas de désaccord majeur (genre l'Option A vs B sur le layout disque en 2d, ou l'idempotence cloudflare init en 4), l'agent doit s'arrêter et demander plutôt que pousser quelque chose qui sera reverté
322+
- Le mainteneur conserve l'autorité finale sur l'API publique (signature des builders, noms de méthodes, etc.) — c'est à confirmer si quelque chose semble border
323+
324+
## Ce qui n'est PAS dans ce plan
325+
326+
- Plugin system / extensibilité framework
327+
- Provisioning refactor en TypeScript typé (les scripts bash restent des `.sh` extraits, pas plus)
328+
- Tests d'intégration E2E CLI (besoin d'une cible VPS ou Docker — à évaluer après 3.0)
329+
- Multi-host workspace
330+
- Service mesh entre apps
331+
332+
Voir ADR 0004 section "Out of scope" pour justification.

0 commit comments

Comments
 (0)