Files
system_update/docs/superpowers/plans/2026-06-04-jalon1-tranche-verticale-apt.md
T
gilles 1e1be7f627 docs: fondation projet (CLAUDE.md, design system, spec + plan jalon 1)
Ignore les dépôts de référence imbriqués (linux-update-dashboard, nas-ops).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 04:41:30 +02:00

2268 lines
70 KiB
Markdown

# Jalon 1 — Tranche verticale APT — Implementation Plan
> **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.
**Goal:** Ajouter une machine Debian/Ubuntu, rafraîchir ses mises à jour APT en tâche de fond, déclencher `full-upgrade`/`reboot` manuellement avec terminal live, et archiver un rapport Markdown.
**Architecture:** Mono-package pnpm. Backend Hono headless (cœur métier, seul à manipuler SSH et secrets) + worker in-process. SQLite via Drizzle. Les commandes APT vivent dans des templates shell `.sh.tpl` rendus en Mustache et poussés en SSH ; la sortie brute est parsée côté TypeScript en JSON canonique. Frontend React 19 + Vite consommant le design system Gruvbox, layout 3 volets (Hermes stub / dashboard / terminal xterm.js), flux live via WebSocket.
**Tech Stack:** TypeScript, Hono, @hono/node-server, Drizzle ORM + better-sqlite3, ssh2, ws, Mustache, croner, React 19, Vite, @xterm/xterm, vitest, Docker.
---
## File Structure
```
system_update/
├─ package.json # mono-package (client + server)
├─ tsconfig.json
├─ vite.config.ts # client React
├─ vitest.config.ts
├─ tsup.config.ts # bundle server pour prod
├─ drizzle.config.ts
├─ .env.example
├─ shared/
│ └─ types.ts # types JSON canoniques partagés
├─ server/
│ ├─ index.ts # entrée serveur (Hono + node-server + WS + worker)
│ ├─ env.ts # lecture/validation des variables d'env
│ ├─ crypto/secrets.ts # AES-256-GCM encrypt/decrypt
│ ├─ db/
│ │ ├─ schema.ts # tables Drizzle
│ │ ├─ client.ts # instance Drizzle/SQLite
│ │ └─ migrate.ts # applique les migrations au démarrage
│ ├─ templates/
│ │ ├─ render.ts # rendu Mustache d'un .sh.tpl
│ │ └─ aptReduce.ts # réducteur déterministe de lignes APT
│ ├─ ssh/client.ts # wrapper ssh2 (password, sudo -S, streaming)
│ ├─ services/
│ │ ├─ aptParse.ts # parse sortie apt -s full-upgrade -> AptPackage[]
│ │ ├─ machines.ts # CRUD machines + test-connection + détection OS
│ │ ├─ refresh.ts # exécute check -> snapshot
│ │ ├─ execute.ts # exécute full-upgrade/reboot -> execution + report
│ │ └─ report.ts # génère le rapport Markdown
│ ├─ ws/outputHub.ts # buffers + broadcast par machine
│ ├─ jobs/worker.ts # planificateur in-process (croner)
│ └─ routes/
│ ├─ machines.ts
│ ├─ actions.ts
│ └─ index.ts # monte toutes les routes
├─ client/
│ ├─ index.html
│ └─ src/
│ ├─ main.tsx
│ ├─ App.tsx # layout 3 volets
│ ├─ styles/app.css
│ ├─ components/ # design system porté (Button, Popup, StatusLed, Icon…)
│ ├─ panels/
│ │ ├─ HermesPanel.tsx # stub
│ │ ├─ Dashboard.tsx
│ │ └─ TerminalPanel.tsx
│ ├─ features/machines/
│ │ ├─ MachineTile.tsx
│ │ └─ AddMachineModal.tsx
│ └─ lib/{api.ts,ws.ts}
├─ templates/apt/
│ ├─ check.sh.tpl
│ ├─ full-upgrade.sh.tpl
│ └─ reboot.sh.tpl
├─ reports/ # .gitkeep ; rapports + logs archivés (volume)
└─ docker/
├─ Dockerfile
└─ docker-compose.yml
```
---
## Task 0: Scaffolding du mono-package
**Files:**
- Create: `package.json`, `tsconfig.json`, `vitest.config.ts`, `.env.example`, `.gitignore`, `reports/.gitkeep`
- [ ] **Step 1: Créer `package.json`**
```json
{
"name": "system-update",
"version": "0.1.0",
"type": "module",
"packageManager": "pnpm@10.33.0",
"engines": { "node": ">=22" },
"scripts": {
"dev": "pnpm run dev:server & pnpm run dev:client",
"dev:server": "tsx watch server/index.ts",
"dev:client": "vite",
"build": "vite build && tsup",
"start": "node dist/index.js",
"test": "vitest run",
"check": "tsc --noEmit",
"db:generate": "drizzle-kit generate"
},
"dependencies": {
"@hono/node-server": "^1.13.0",
"better-sqlite3": "^11.8.0",
"croner": "^9.0.0",
"drizzle-orm": "^0.38.0",
"hono": "^4.6.0",
"mustache": "^4.2.0",
"ssh2": "^1.16.0",
"ws": "^8.18.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12",
"@types/mustache": "^4.2.5",
"@types/node": "^22.10.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/ssh2": "^1.15.1",
"@types/ws": "^8.5.13",
"@vitejs/plugin-react": "^4.3.4",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"drizzle-kit": "^0.30.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tsup": "^8.3.5",
"tsx": "^4.19.2",
"typescript": "^5.7.0",
"vite": "^6.0.0",
"vitest": "^2.1.0"
}
}
```
- [ ] **Step 2: Créer `tsconfig.json`**
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"types": ["node", "vitest/globals"],
"paths": { "@shared/*": ["./shared/*"] },
"baseUrl": "."
},
"include": ["server", "client", "shared", "*.ts"]
}
```
- [ ] **Step 3: Créer `vitest.config.ts`**
```ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
include: ["server/**/*.test.ts", "shared/**/*.test.ts"],
environment: "node",
},
resolve: { alias: { "@shared": new URL("./shared", import.meta.url).pathname } },
});
```
- [ ] **Step 4: Créer `.env.example`**
```sh
# Clé maître de chiffrement des credentials (32 octets en hex = 64 caractères).
# Générer avec: openssl rand -hex 32
SU_MASTER_KEY=0000000000000000000000000000000000000000000000000000000000000000
# Chemin du fichier SQLite
SU_DB_PATH=./data/system-update.db
# Répertoire d'archivage des rapports + logs
SU_REPORTS_DIR=./reports
# Port HTTP du serveur
SU_PORT=8787
```
- [ ] **Step 5: Créer `.gitignore` et `reports/.gitkeep`**
`.gitignore`:
```
node_modules/
dist/
data/
.env
reports/*
!reports/.gitkeep
```
`reports/.gitkeep`: fichier vide.
- [ ] **Step 6: Installer les dépendances**
Run: `pnpm install`
Expected: installation sans erreur, `node_modules/` créé.
- [ ] **Step 7: Commit**
```bash
git checkout -b jalon1-apt
git add package.json tsconfig.json vitest.config.ts .env.example .gitignore reports/.gitkeep pnpm-lock.yaml
git commit -m "chore: scaffolding mono-package jalon 1 (APT)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 1: Types JSON canoniques partagés
**Files:**
- Create: `shared/types.ts`
- [ ] **Step 1: Écrire les types**
```ts
// shared/types.ts
export type OsFamily = "debian" | "ubuntu" | "unknown";
export type MachineStatus = "unknown" | "ok" | "updates_available" | "error" | "running";
export type AptProxyMode = "direct" | "runtime";
export type ActionType = "apt_full_upgrade" | "reboot";
export type ExecutionStatus = "ok" | "warning" | "error";
export interface AptPackage {
name: string;
currentVersion: string | null;
targetVersion: string;
origin: string | null;
}
export interface UpdateSnapshot {
machineId: string;
hostname: string;
os: { family: OsFamily; version: string };
checkedAt: string; // ISO 8601
status: MachineStatus;
apt: {
enabled: boolean;
count: number;
rebootRequired: boolean;
packages: AptPackage[];
};
rawHints?: { logImportantLines: string[] };
}
export interface ExecutionResult {
executionId: string;
machineId: string;
startedAt: string;
finishedAt: string;
mode: "manual";
action: ActionType;
status: ExecutionStatus;
rebootRequiredAfterRun: boolean;
importantLogLines: string[];
rawLogRef: string;
reportRef: string;
}
/** Vue machine renvoyée par l'API — NE CONTIENT JAMAIS de secret. */
export interface MachineView {
id: string;
name: string;
hostname: string;
port: number;
osFamily: OsFamily;
username: string;
aptProxyMode: AptProxyMode;
aptProxyUrl: string | null;
status: MachineStatus;
lastCheckedAt: string | null;
}
```
- [ ] **Step 2: Commit**
```bash
git add shared/types.ts
git commit -m "feat: types JSON canoniques partagés (snapshot, execution, machine)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 2: Chiffrement des secrets (AES-256-GCM) — TDD
**Files:**
- Create: `server/env.ts`, `server/crypto/secrets.ts`, `server/crypto/secrets.test.ts`
- [ ] **Step 1: Créer `server/env.ts`**
```ts
// server/env.ts
function required(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`Variable d'environnement manquante: ${name}`);
return v;
}
export const env = {
masterKeyHex: process.env.SU_MASTER_KEY ?? "",
dbPath: process.env.SU_DB_PATH ?? "./data/system-update.db",
reportsDir: process.env.SU_REPORTS_DIR ?? "./reports",
port: Number(process.env.SU_PORT ?? 8787),
requireMasterKey(): string {
const k = required("SU_MASTER_KEY");
if (k.length !== 64) throw new Error("SU_MASTER_KEY doit faire 64 caractères hex (32 octets).");
return k;
},
};
```
- [ ] **Step 2: Écrire le test (échec attendu)**
```ts
// server/crypto/secrets.test.ts
import { describe, it, expect } from "vitest";
import { encryptSecret, decryptSecret } from "./secrets.js";
const KEY = "a".repeat(64); // 32 octets en hex
describe("secrets", () => {
it("round-trip encrypt/decrypt restitue le texte clair", () => {
const blob = encryptSecret("hunter2", KEY);
expect(blob).not.toContain("hunter2");
expect(decryptSecret(blob, KEY)).toBe("hunter2");
});
it("produit un blob différent à chaque chiffrement (IV aléatoire)", () => {
expect(encryptSecret("x", KEY)).not.toBe(encryptSecret("x", KEY));
});
it("échoue si le blob a été altéré (tag GCM)", () => {
const blob = encryptSecret("secret", KEY);
const tampered = blob.slice(0, -2) + (blob.endsWith("a") ? "b" : "a");
expect(() => decryptSecret(tampered, KEY)).toThrow();
});
});
```
- [ ] **Step 3: Lancer le test (échec)**
Run: `pnpm vitest run server/crypto/secrets.test.ts`
Expected: FAIL — module `./secrets.js` introuvable.
- [ ] **Step 4: Implémenter `server/crypto/secrets.ts`**
```ts
// server/crypto/secrets.ts
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
const ALGO = "aes-256-gcm";
/** Chiffre une chaîne. Format de sortie: base64(iv).base64(tag).base64(ciphertext). */
export function encryptSecret(plaintext: string, keyHex: string): string {
const key = Buffer.from(keyHex, "hex");
const iv = randomBytes(12);
const cipher = createCipheriv(ALGO, key, iv);
const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return [iv.toString("base64"), tag.toString("base64"), ct.toString("base64")].join(".");
}
export function decryptSecret(blob: string, keyHex: string): string {
const key = Buffer.from(keyHex, "hex");
const [ivB64, tagB64, ctB64] = blob.split(".");
if (!ivB64 || !tagB64 || !ctB64) throw new Error("Blob chiffré invalide");
const decipher = createDecipheriv(ALGO, key, Buffer.from(ivB64, "base64"));
decipher.setAuthTag(Buffer.from(tagB64, "base64"));
return Buffer.concat([decipher.update(Buffer.from(ctB64, "base64")), decipher.final()]).toString("utf8");
}
```
- [ ] **Step 5: Lancer le test (succès)**
Run: `pnpm vitest run server/crypto/secrets.test.ts`
Expected: PASS (3 tests).
- [ ] **Step 6: Commit**
```bash
git add server/env.ts server/crypto/secrets.ts server/crypto/secrets.test.ts
git commit -m "feat: chiffrement AES-256-GCM des secrets + lecture env
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 3: Schéma de base de données (Drizzle / SQLite)
**Files:**
- Create: `drizzle.config.ts`, `server/db/schema.ts`, `server/db/client.ts`, `server/db/migrate.ts`
- [ ] **Step 1: Créer `server/db/schema.ts`**
```ts
// server/db/schema.ts
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
export const machines = sqliteTable("machines", {
id: text("id").primaryKey(),
name: text("name").notNull(),
hostname: text("hostname").notNull(),
port: integer("port").notNull().default(22),
osFamily: text("os_family").notNull().default("unknown"),
username: text("username").notNull(),
encPassword: text("enc_password").notNull(),
encSudoPassword: text("enc_sudo_password"),
aptProxyMode: text("apt_proxy_mode").notNull().default("direct"),
aptProxyUrl: text("apt_proxy_url"),
status: text("status").notNull().default("unknown"),
lastCheckedAt: text("last_checked_at"),
createdAt: text("created_at").notNull(),
});
export const snapshots = sqliteTable("snapshots", {
id: text("id").primaryKey(),
machineId: text("machine_id").notNull().references(() => machines.id, { onDelete: "cascade" }),
checkedAt: text("checked_at").notNull(),
status: text("status").notNull(),
payloadJson: text("payload_json").notNull(),
});
export const executions = sqliteTable("executions", {
id: text("id").primaryKey(),
machineId: text("machine_id").notNull().references(() => machines.id, { onDelete: "cascade" }),
action: text("action").notNull(),
mode: text("mode").notNull().default("manual"),
startedAt: text("started_at").notNull(),
finishedAt: text("finished_at"),
status: text("status").notNull(),
resultJson: text("result_json"),
reportPath: text("report_path"),
rawLogPath: text("raw_log_path"),
});
```
- [ ] **Step 2: Créer `drizzle.config.ts`**
```ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "sqlite",
schema: "./server/db/schema.ts",
out: "./server/db/migrations",
});
```
- [ ] **Step 3: Créer `server/db/client.ts`**
```ts
// server/db/client.ts
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { env } from "../env.js";
import * as schema from "./schema.js";
mkdirSync(dirname(env.dbPath), { recursive: true });
const sqlite = new Database(env.dbPath);
sqlite.pragma("journal_mode = WAL");
sqlite.pragma("foreign_keys = ON");
export const db = drizzle(sqlite, { schema });
export { schema };
```
- [ ] **Step 4: Générer la migration initiale**
Run: `pnpm db:generate`
Expected: un fichier SQL créé dans `server/db/migrations/`.
- [ ] **Step 5: Créer `server/db/migrate.ts`**
```ts
// server/db/migrate.ts
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { db } from "./client.js";
export function runMigrations(): void {
migrate(db, { migrationsFolder: "./server/db/migrations" });
}
```
- [ ] **Step 6: Commit**
```bash
git add drizzle.config.ts server/db/
git commit -m "feat: schéma Drizzle/SQLite (machines, snapshots, executions)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 4: Réducteur déterministe de lignes APT — TDD
**Files:**
- Create: `server/templates/aptReduce.ts`, `server/templates/aptReduce.test.ts`
- [ ] **Step 1: Écrire le test (échec attendu)**
```ts
// server/templates/aptReduce.test.ts
import { describe, it, expect } from "vitest";
import { reduceAptLines } from "./aptReduce.js";
describe("reduceAptLines", () => {
it("ne garde que les lignes utiles", () => {
const raw = [
"Reading package lists...",
"Building dependency tree...",
"Inst pve-manager [8.4-1] (8.4-3 Proxmox VE:8.x [amd64])",
"blabla inutile",
"Conf pve-manager (8.4-3 Proxmox VE:8.x [amd64])",
"E: Could not get lock",
"W: Some warning",
"dpkg: warning: x",
].join("\n");
expect(reduceAptLines(raw)).toEqual([
"Inst pve-manager [8.4-1] (8.4-3 Proxmox VE:8.x [amd64])",
"Conf pve-manager (8.4-3 Proxmox VE:8.x [amd64])",
"E: Could not get lock",
"W: Some warning",
"dpkg: warning: x",
]);
});
it("retourne un tableau vide si rien d'utile", () => {
expect(reduceAptLines("Reading package lists...\nDone")).toEqual([]);
});
});
```
- [ ] **Step 2: Lancer le test (échec)**
Run: `pnpm vitest run server/templates/aptReduce.test.ts`
Expected: FAIL — module introuvable.
- [ ] **Step 3: Implémenter `server/templates/aptReduce.ts`**
```ts
// server/templates/aptReduce.ts
const PREFIXES = ["Inst ", "Conf ", "Remv ", "Err ", "E:", "W:", "dpkg:"];
const CONTAINS = ["reboot-required", "REBOOT_REQUIRED"];
/** Garde uniquement les lignes informatives d'une sortie APT brute. */
export function reduceAptLines(raw: string): string[] {
return raw
.split("\n")
.map((l) => l.trimEnd())
.filter((l) => PREFIXES.some((p) => l.startsWith(p)) || CONTAINS.some((c) => l.includes(c)));
}
```
- [ ] **Step 4: Lancer le test (succès)**
Run: `pnpm vitest run server/templates/aptReduce.test.ts`
Expected: PASS (2 tests).
- [ ] **Step 5: Commit**
```bash
git add server/templates/aptReduce.ts server/templates/aptReduce.test.ts
git commit -m "feat: réducteur déterministe de lignes APT
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 5: Parser de sortie `apt-get -s full-upgrade` — TDD
**Files:**
- Create: `server/services/aptParse.ts`, `server/services/aptParse.test.ts`, `server/services/__fixtures__/apt-simulate.txt`
- [ ] **Step 1: Créer la fixture `server/services/__fixtures__/apt-simulate.txt`**
```
Reading package lists...
Building dependency tree...
Reading state information...
Calculating upgrade...
The following packages will be upgraded:
libc6 pve-manager
Inst libc6 [2.31-13] (2.31-13+deb11u5 Debian:11.6/stable [amd64])
Inst pve-manager [8.4-1] (8.4-3 Proxmox VE:8.x [amd64])
Inst newpkg (1.0.0 Debian:11.6/stable [all])
Conf libc6 (2.31-13+deb11u5 Debian:11.6/stable [amd64])
Conf pve-manager (8.4-3 Proxmox VE:8.x [amd64])
```
- [ ] **Step 2: Écrire le test (échec attendu)**
```ts
// server/services/aptParse.test.ts
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { parseAptSimulate, parseRebootRequired } from "./aptParse.js";
const raw = readFileSync(fileURLToPath(new URL("./__fixtures__/apt-simulate.txt", import.meta.url)), "utf8");
describe("parseAptSimulate", () => {
it("extrait les paquets upgradables avec versions et origine", () => {
const pkgs = parseAptSimulate(raw);
expect(pkgs).toEqual([
{ name: "libc6", currentVersion: "2.31-13", targetVersion: "2.31-13+deb11u5", origin: "Debian:11.6/stable" },
{ name: "pve-manager", currentVersion: "8.4-1", targetVersion: "8.4-3", origin: "Proxmox VE:8.x" },
{ name: "newpkg", currentVersion: null, targetVersion: "1.0.0", origin: "Debian:11.6/stable" },
]);
});
it("retourne un tableau vide quand aucun Inst", () => {
expect(parseAptSimulate("Reading package lists...\nDone")).toEqual([]);
});
});
describe("parseRebootRequired", () => {
it("détecte le marqueur REBOOT_REQUIRED=1", () => {
expect(parseRebootRequired("REBOOT_REQUIRED=1")).toBe(true);
expect(parseRebootRequired("REBOOT_REQUIRED=0")).toBe(false);
expect(parseRebootRequired("rien")).toBe(false);
});
});
```
- [ ] **Step 3: Lancer le test (échec)**
Run: `pnpm vitest run server/services/aptParse.test.ts`
Expected: FAIL — module introuvable.
- [ ] **Step 4: Implémenter `server/services/aptParse.ts`**
```ts
// server/services/aptParse.ts
import type { AptPackage } from "@shared/types.js";
// Exemple de ligne:
// Inst pve-manager [8.4-1] (8.4-3 Proxmox VE:8.x [amd64])
// Inst newpkg (1.0.0 Debian:11.6/stable [all])
const INST_RE = /^Inst (\S+) (?:\[([^\]]+)\] )?\((\S+) (.+?) \[[^\]]+\]\)\s*$/;
export function parseAptSimulate(raw: string): AptPackage[] {
const out: AptPackage[] = [];
for (const line of raw.split("\n")) {
const m = INST_RE.exec(line.trimEnd());
if (!m) continue;
out.push({
name: m[1]!,
currentVersion: m[2] ?? null,
targetVersion: m[3]!,
origin: (m[4] ?? "").trim() || null,
});
}
return out;
}
export function parseRebootRequired(raw: string): boolean {
return /REBOOT_REQUIRED=1/.test(raw);
}
```
- [ ] **Step 5: Lancer le test (succès)**
Run: `pnpm vitest run server/services/aptParse.test.ts`
Expected: PASS (3 tests).
- [ ] **Step 6: Commit**
```bash
git add server/services/aptParse.ts server/services/aptParse.test.ts server/services/__fixtures__/
git commit -m "feat: parser sortie apt-get -s full-upgrade -> AptPackage[]
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 6: Templates shell APT + rendu Mustache — TDD
**Files:**
- Create: `templates/apt/check.sh.tpl`, `templates/apt/full-upgrade.sh.tpl`, `templates/apt/reboot.sh.tpl`, `server/templates/render.ts`, `server/templates/render.test.ts`
- [ ] **Step 1: Créer `templates/apt/check.sh.tpl`**
```sh
#!/bin/sh
# Rendu sans sudo: le script entier est exécuté sous sudo par la couche SSH.
export LC_ALL=C
{{#aptProxy}}export http_proxy="{{aptProxy}}"; export https_proxy="{{aptProxy}}"
{{/aptProxy}}
echo "===SU:UPDATE==="
apt-get update -qq 2>&1
echo "===SU:SIMULATE==="
apt-get -s -y full-upgrade 2>&1
echo "===SU:REBOOT==="
if [ -f /var/run/reboot-required ]; then echo "REBOOT_REQUIRED=1"; else echo "REBOOT_REQUIRED=0"; fi
echo "===SU:END==="
```
- [ ] **Step 2: Créer `templates/apt/full-upgrade.sh.tpl`**
```sh
#!/bin/sh
export LC_ALL=C
export DEBIAN_FRONTEND=noninteractive
{{#aptProxy}}export http_proxy="{{aptProxy}}"; export https_proxy="{{aptProxy}}"
{{/aptProxy}}
echo "===SU:UPGRADE==="
apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold full-upgrade 2>&1
CODE=$?
echo "===SU:REBOOT==="
if [ -f /var/run/reboot-required ]; then echo "REBOOT_REQUIRED=1"; else echo "REBOOT_REQUIRED=0"; fi
echo "===SU:EXIT=${CODE}==="
```
- [ ] **Step 3: Créer `templates/apt/reboot.sh.tpl`**
```sh
#!/bin/sh
export LC_ALL=C
echo "===SU:REBOOT_NOW==="
# Reboot différé pour laisser le canal SSH se fermer proprement.
nohup sh -c 'sleep 2; reboot' >/dev/null 2>&1 &
echo "reboot planifié"
```
- [ ] **Step 4: Écrire le test (échec attendu)**
```ts
// server/templates/render.test.ts
import { describe, it, expect } from "vitest";
import { renderTemplate } from "./render.js";
describe("renderTemplate", () => {
it("rend check.sh.tpl sans proxy", () => {
const out = renderTemplate("apt/check.sh.tpl", { aptProxy: null });
expect(out).toContain("apt-get update -qq");
expect(out).toContain("===SU:SIMULATE===");
expect(out).not.toContain("http_proxy");
});
it("injecte le proxy quand fourni", () => {
const out = renderTemplate("apt/check.sh.tpl", { aptProxy: "http://cache:3142" });
expect(out).toContain('http_proxy="http://cache:3142"');
});
});
```
- [ ] **Step 5: Lancer le test (échec)**
Run: `pnpm vitest run server/templates/render.test.ts`
Expected: FAIL — module introuvable.
- [ ] **Step 6: Implémenter `server/templates/render.ts`**
```ts
// server/templates/render.ts
import Mustache from "mustache";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const TEMPLATES_ROOT = resolve(process.cwd(), "templates");
export interface TemplateVars {
aptProxy?: string | null;
}
export function renderTemplate(relPath: string, vars: TemplateVars): string {
const tpl = readFileSync(resolve(TEMPLATES_ROOT, relPath), "utf8");
// Mustache échappe le HTML par défaut; on désactive (ce sont des scripts shell).
return Mustache.render(tpl, vars, {}, { escape: (s) => s });
}
```
- [ ] **Step 7: Lancer le test (succès)**
Run: `pnpm vitest run server/templates/render.test.ts`
Expected: PASS (2 tests).
- [ ] **Step 8: Commit**
```bash
git add templates/apt/ server/templates/render.ts server/templates/render.test.ts
git commit -m "feat: templates shell APT + rendu Mustache
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 7: Couche SSH (ssh2, password, sudo -S, streaming)
**Files:**
- Create: `server/ssh/client.ts`
> Note: la couche SSH réelle est validée manuellement (Task 18). Pas de test unitaire (pas de mock SSH au MVP).
- [ ] **Step 1: Implémenter `server/ssh/client.ts`**
```ts
// server/ssh/client.ts
import { Client } from "ssh2";
export interface SshCreds {
hostname: string;
port: number;
username: string;
password: string;
sudoPassword?: string | null;
}
export interface RunResult {
stdout: string;
code: number;
}
function connect(creds: SshCreds): Promise<Client> {
return new Promise((resolve, reject) => {
const conn = new Client();
conn
.on("ready", () => resolve(conn))
.on("error", reject)
.connect({
host: creds.hostname,
port: creds.port,
username: creds.username,
password: creds.password,
readyTimeout: 15000,
});
});
}
/** Exécute une commande simple (sans sudo), renvoie stdout agrégé. */
export async function runPlain(creds: SshCreds, command: string): Promise<RunResult> {
const conn = await connect(creds);
try {
return await execStream(conn, command, null, () => {});
} finally {
conn.end();
}
}
/**
* Exécute un script shell sous sudo. Le script est encodé en base64 pour éviter
* tout problème de quoting; le mot de passe sudo est poussé sur stdin (sudo -S -p '').
* `onData` reçoit chaque chunk de sortie pour le streaming live.
*/
export async function runScriptSudo(
creds: SshCreds,
script: string,
onData: (chunk: string) => void,
): Promise<RunResult> {
const conn = await connect(creds);
try {
const b64 = Buffer.from(script, "utf8").toString("base64");
const cmd = `sudo -S -p '' sh -c "$(printf '%s' '${b64}' | base64 -d)"`;
return await execStream(conn, cmd, (creds.sudoPassword ?? creds.password) + "\n", onData);
} finally {
conn.end();
}
}
function execStream(
conn: Client,
command: string,
stdinData: string | null,
onData: (chunk: string) => void,
): Promise<RunResult> {
return new Promise((resolve, reject) => {
conn.exec(command, { pty: false }, (err, stream) => {
if (err) return reject(err);
let stdout = "";
let code = 0;
if (stdinData) {
stream.write(stdinData);
}
stream
.on("close", (c: number) => resolve({ stdout, code: c ?? code }))
.on("data", (d: Buffer) => {
const s = d.toString("utf8");
stdout += s;
onData(s);
});
stream.stderr.on("data", (d: Buffer) => {
const s = d.toString("utf8");
stdout += s;
onData(s);
});
});
});
}
```
- [ ] **Step 2: Vérifier la compilation**
Run: `pnpm check`
Expected: PASS (aucune erreur TypeScript).
- [ ] **Step 3: Commit**
```bash
git add server/ssh/client.ts
git commit -m "feat: couche SSH (password, sudo -S, exec streaming)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 8: Hub de sortie WebSocket (buffer + broadcast)
**Files:**
- Create: `server/ws/outputHub.ts`, `server/ws/outputHub.test.ts`
- [ ] **Step 1: Écrire le test (échec attendu)**
```ts
// server/ws/outputHub.test.ts
import { describe, it, expect, vi } from "vitest";
import { OutputHub } from "./outputHub.js";
describe("OutputHub", () => {
it("rejoue le buffer aux nouveaux abonnés", () => {
const hub = new OutputHub();
hub.publish("m1", "ligne1");
hub.publish("m1", "ligne2");
const received: string[] = [];
hub.subscribe("m1", (c) => received.push(c));
expect(received).toEqual(["ligne1", "ligne2"]);
});
it("diffuse les nouveaux chunks aux abonnés", () => {
const hub = new OutputHub();
const fn = vi.fn();
hub.subscribe("m1", fn);
hub.publish("m1", "x");
expect(fn).toHaveBeenCalledWith("x");
});
it("vide le buffer avec clear()", () => {
const hub = new OutputHub();
hub.publish("m1", "old");
hub.clear("m1");
const received: string[] = [];
hub.subscribe("m1", (c) => received.push(c));
expect(received).toEqual([]);
});
});
```
- [ ] **Step 2: Lancer le test (échec)**
Run: `pnpm vitest run server/ws/outputHub.test.ts`
Expected: FAIL — module introuvable.
- [ ] **Step 3: Implémenter `server/ws/outputHub.ts`**
```ts
// server/ws/outputHub.ts
type Listener = (chunk: string) => void;
const MAX_BUFFER = 5000; // lignes/chunks gardés pour le rejeu
export class OutputHub {
private buffers = new Map<string, string[]>();
private listeners = new Map<string, Set<Listener>>();
publish(machineId: string, chunk: string): void {
const buf = this.buffers.get(machineId) ?? [];
buf.push(chunk);
if (buf.length > MAX_BUFFER) buf.shift();
this.buffers.set(machineId, buf);
this.listeners.get(machineId)?.forEach((l) => l(chunk));
}
/** S'abonne et reçoit immédiatement le buffer existant (rejeu). */
subscribe(machineId: string, listener: Listener): () => void {
(this.buffers.get(machineId) ?? []).forEach((c) => listener(c));
const set = this.listeners.get(machineId) ?? new Set();
set.add(listener);
this.listeners.set(machineId, set);
return () => set.delete(listener);
}
clear(machineId: string): void {
this.buffers.delete(machineId);
}
}
export const outputHub = new OutputHub();
```
- [ ] **Step 4: Lancer le test (succès)**
Run: `pnpm vitest run server/ws/outputHub.test.ts`
Expected: PASS (3 tests).
- [ ] **Step 5: Commit**
```bash
git add server/ws/outputHub.ts server/ws/outputHub.test.ts
git commit -m "feat: hub de sortie WebSocket avec buffer rejouable
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 9: Service machines (CRUD + test-connection + détection OS)
**Files:**
- Create: `server/services/machines.ts`
- [ ] **Step 1: Implémenter `server/services/machines.ts`**
```ts
// server/services/machines.ts
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { db, schema } from "../db/client.js";
import { encryptSecret, decryptSecret } from "../crypto/secrets.js";
import { env } from "../env.js";
import { runPlain, type SshCreds } from "../ssh/client.js";
import type { MachineView, OsFamily } from "@shared/types.js";
export interface CreateMachineInput {
name: string;
hostname: string;
port: number;
username: string;
password: string;
sudoPassword?: string | null;
aptProxyMode?: "direct" | "runtime";
aptProxyUrl?: string | null;
}
type MachineRow = typeof schema.machines.$inferSelect;
function toView(m: MachineRow): MachineView {
return {
id: m.id,
name: m.name,
hostname: m.hostname,
port: m.port,
osFamily: m.osFamily as OsFamily,
username: m.username,
aptProxyMode: m.aptProxyMode as "direct" | "runtime",
aptProxyUrl: m.aptProxyUrl,
status: m.status as MachineView["status"],
lastCheckedAt: m.lastCheckedAt,
};
}
export function getCreds(m: MachineRow): SshCreds {
const key = env.requireMasterKey();
return {
hostname: m.hostname,
port: m.port,
username: m.username,
password: decryptSecret(m.encPassword, key),
sudoPassword: m.encSudoPassword ? decryptSecret(m.encSudoPassword, key) : null,
};
}
export function getMachineRow(id: string): MachineRow | undefined {
return db.select().from(schema.machines).where(eq(schema.machines.id, id)).get();
}
export function listMachines(): MachineView[] {
return db.select().from(schema.machines).all().map(toView);
}
/** Parse /etc/os-release pour déduire family + version. */
export function parseOsRelease(content: string): { family: OsFamily; version: string } {
const fields: Record<string, string> = {};
for (const line of content.split("\n")) {
const m = /^([A-Z_]+)=(.*)$/.exec(line.trim());
if (m) fields[m[1]!] = m[2]!.replace(/^"|"$/g, "");
}
const id = (fields.ID ?? "").toLowerCase();
const family: OsFamily = id === "ubuntu" ? "ubuntu" : id === "debian" ? "debian" : "unknown";
return { family, version: fields.VERSION_ID ?? fields.VERSION ?? "" };
}
export async function testConnection(creds: SshCreds): Promise<{ family: OsFamily; version: string }> {
const res = await runPlain(creds, "cat /etc/os-release");
return parseOsRelease(res.stdout);
}
export async function createMachine(input: CreateMachineInput): Promise<MachineView> {
const key = env.requireMasterKey();
const creds: SshCreds = {
hostname: input.hostname,
port: input.port,
username: input.username,
password: input.password,
sudoPassword: input.sudoPassword ?? null,
};
const os = await testConnection(creds); // lève si la connexion échoue
const id = randomUUID();
const row: MachineRow = {
id,
name: input.name,
hostname: input.hostname,
port: input.port,
osFamily: os.family,
username: input.username,
encPassword: encryptSecret(input.password, key),
encSudoPassword: input.sudoPassword ? encryptSecret(input.sudoPassword, key) : null,
aptProxyMode: input.aptProxyMode ?? "direct",
aptProxyUrl: input.aptProxyUrl ?? null,
status: "unknown",
lastCheckedAt: null,
createdAt: new Date().toISOString(),
};
db.insert(schema.machines).values(row).run();
return toView(row);
}
export function deleteMachine(id: string): void {
db.delete(schema.machines).where(eq(schema.machines.id, id)).run();
}
```
- [ ] **Step 2: Écrire un test pour `parseOsRelease`**
```ts
// server/services/machines.test.ts
import { describe, it, expect } from "vitest";
import { parseOsRelease } from "./machines.js";
describe("parseOsRelease", () => {
it("détecte Debian", () => {
const r = parseOsRelease('ID=debian\nVERSION_ID="11"\nPRETTY_NAME="Debian 11"');
expect(r).toEqual({ family: "debian", version: "11" });
});
it("détecte Ubuntu", () => {
const r = parseOsRelease('ID=ubuntu\nVERSION_ID="22.04"');
expect(r).toEqual({ family: "ubuntu", version: "22.04" });
});
it("retombe sur unknown", () => {
expect(parseOsRelease("ID=arch").family).toBe("unknown");
});
});
```
- [ ] **Step 3: Lancer le test**
Run: `pnpm vitest run server/services/machines.test.ts`
Expected: PASS (3 tests).
- [ ] **Step 4: Commit**
```bash
git add server/services/machines.ts server/services/machines.test.ts
git commit -m "feat: service machines (CRUD, test-connection, détection OS)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 10: Service refresh (check APT -> snapshot)
**Files:**
- Create: `server/services/refresh.ts`
- [ ] **Step 1: Implémenter `server/services/refresh.ts`**
```ts
// server/services/refresh.ts
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { db, schema } from "../db/client.js";
import { getMachineRow, getCreds } from "./machines.js";
import { renderTemplate } from "../templates/render.js";
import { reduceAptLines } from "../templates/aptReduce.js";
import { runScriptSudo } from "../ssh/client.js";
import { parseAptSimulate, parseRebootRequired } from "./aptParse.js";
import { outputHub } from "../ws/outputHub.js";
import type { UpdateSnapshot, MachineStatus } from "@shared/types.js";
/** Extrait la section entre deux marqueurs ===SU:X=== d'une sortie de script. */
export function extractSection(raw: string, start: string, end: string): string {
const s = raw.indexOf(start);
if (s === -1) return "";
const from = s + start.length;
const e = raw.indexOf(end, from);
return raw.slice(from, e === -1 ? undefined : e).trim();
}
export async function refreshMachine(machineId: string): Promise<UpdateSnapshot> {
const m = getMachineRow(machineId);
if (!m) throw new Error("Machine introuvable");
db.update(schema.machines).set({ status: "running" }).where(eq(schema.machines.id, machineId)).run();
outputHub.clear(machineId);
const proxy = m.aptProxyMode === "runtime" ? m.aptProxyUrl : null;
const script = renderTemplate("apt/check.sh.tpl", { aptProxy: proxy });
let raw = "";
try {
const res = await runScriptSudo(getCreds(m), script, (c) => {
raw += c;
outputHub.publish(machineId, c);
});
raw = res.stdout;
} catch (err) {
db.update(schema.machines).set({ status: "error" }).where(eq(schema.machines.id, machineId)).run();
throw err;
}
const simulate = extractSection(raw, "===SU:SIMULATE===", "===SU:REBOOT===");
const rebootSection = extractSection(raw, "===SU:REBOOT===", "===SU:END===");
const packages = parseAptSimulate(simulate);
const rebootRequired = parseRebootRequired(rebootSection);
const status: MachineStatus = packages.length > 0 ? "updates_available" : "ok";
const checkedAt = new Date().toISOString();
const snapshot: UpdateSnapshot = {
machineId,
hostname: m.hostname,
os: { family: m.osFamily as UpdateSnapshot["os"]["family"], version: "" },
checkedAt,
status,
apt: { enabled: true, count: packages.length, rebootRequired, packages },
rawHints: { logImportantLines: reduceAptLines(raw) },
};
db.insert(schema.snapshots).values({
id: randomUUID(),
machineId,
checkedAt,
status,
payloadJson: JSON.stringify(snapshot),
}).run();
db.update(schema.machines).set({ status, lastCheckedAt: checkedAt }).where(eq(schema.machines.id, machineId)).run();
return snapshot;
}
export function getLatestSnapshot(machineId: string): UpdateSnapshot | null {
const row = db.select().from(schema.snapshots).where(eq(schema.snapshots.machineId, machineId)).all().at(-1);
return row ? (JSON.parse(row.payloadJson) as UpdateSnapshot) : null;
}
```
- [ ] **Step 2: Écrire un test pour `extractSection`**
```ts
// server/services/refresh.test.ts
import { describe, it, expect } from "vitest";
import { extractSection } from "./refresh.js";
const raw = "===SU:UPDATE===\nok\n===SU:SIMULATE===\nInst a [1] (2 X [amd64])\n===SU:REBOOT===\nREBOOT_REQUIRED=0\n===SU:END===";
describe("extractSection", () => {
it("extrait le contenu entre deux marqueurs", () => {
expect(extractSection(raw, "===SU:SIMULATE===", "===SU:REBOOT===")).toBe("Inst a [1] (2 X [amd64])");
});
it("retourne vide si marqueur absent", () => {
expect(extractSection(raw, "===SU:NOPE===", "===SU:END===")).toBe("");
});
});
```
- [ ] **Step 3: Lancer le test**
Run: `pnpm vitest run server/services/refresh.test.ts`
Expected: PASS (2 tests).
- [ ] **Step 4: Commit**
```bash
git add server/services/refresh.ts server/services/refresh.test.ts
git commit -m "feat: service refresh (check APT -> snapshot canonique)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 11: Service report (Markdown) — TDD
**Files:**
- Create: `server/services/report.ts`, `server/services/report.test.ts`
- [ ] **Step 1: Écrire le test (échec attendu)**
```ts
// server/services/report.test.ts
import { describe, it, expect } from "vitest";
import { buildReportMarkdown } from "./report.js";
import type { ExecutionResult } from "@shared/types.js";
const exec: ExecutionResult = {
executionId: "exec_1", machineId: "m1", startedAt: "2026-06-04T12:00:00Z",
finishedAt: "2026-06-04T12:05:00Z", mode: "manual", action: "apt_full_upgrade",
status: "ok", rebootRequiredAfterRun: true,
importantLogLines: ["Inst libc6 [2.31-13] (2.31-13+deb11u5 Debian [amd64])"],
rawLogRef: "reports/m1/exec_1.log", reportRef: "reports/m1/exec_1.md",
};
describe("buildReportMarkdown", () => {
it("contient l'en-tête, le statut et les lignes importantes", () => {
const md = buildReportMarkdown(exec, "deb-01");
expect(md).toContain("# Rapport d'exécution — deb-01");
expect(md).toContain("apt_full_upgrade");
expect(md).toContain("Redémarrage requis : oui");
expect(md).toContain("Inst libc6");
});
});
```
- [ ] **Step 2: Lancer le test (échec)**
Run: `pnpm vitest run server/services/report.test.ts`
Expected: FAIL — module introuvable.
- [ ] **Step 3: Implémenter `server/services/report.ts`**
```ts
// server/services/report.ts
import type { ExecutionResult } from "@shared/types.js";
export function buildReportMarkdown(exec: ExecutionResult, machineName: string): string {
const lines = exec.importantLogLines.map((l) => ` ${l}`).join("\n");
return `# Rapport d'exécution — ${machineName}
- **Exécution** : ${exec.executionId}
- **Action** : ${exec.action}
- **Statut** : ${exec.status}
- **Début** : ${exec.startedAt}
- **Fin** : ${exec.finishedAt}
- **Redémarrage requis** : ${exec.rebootRequiredAfterRun ? "oui" : "non"}
## Lignes importantes
\`\`\`
${lines || " (aucune)"}
\`\`\`
## Log brut
Référence : \`${exec.rawLogRef}\`
`;
}
```
- [ ] **Step 4: Lancer le test (succès)**
Run: `pnpm vitest run server/services/report.test.ts`
Expected: PASS (1 test).
- [ ] **Step 5: Commit**
```bash
git add server/services/report.ts server/services/report.test.ts
git commit -m "feat: génération de rapport Markdown d'exécution
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 12: Service execute (full-upgrade / reboot -> execution + report)
**Files:**
- Create: `server/services/execute.ts`
- [ ] **Step 1: Implémenter `server/services/execute.ts`**
```ts
// server/services/execute.ts
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { db, schema } from "../db/client.js";
import { env } from "../env.js";
import { getMachineRow, getCreds } from "./machines.js";
import { renderTemplate } from "../templates/render.js";
import { reduceAptLines } from "../templates/aptReduce.js";
import { runScriptSudo } from "../ssh/client.js";
import { parseRebootRequired } from "./aptParse.js";
import { extractSection } from "./refresh.js";
import { buildReportMarkdown } from "./report.js";
import { outputHub } from "../ws/outputHub.js";
import type { ActionType, ExecutionResult, ExecutionStatus } from "@shared/types.js";
const TEMPLATE_FOR: Record<ActionType, string> = {
apt_full_upgrade: "apt/full-upgrade.sh.tpl",
reboot: "apt/reboot.sh.tpl",
};
export async function runAction(machineId: string, action: ActionType): Promise<ExecutionResult> {
const m = getMachineRow(machineId);
if (!m) throw new Error("Machine introuvable");
const executionId = `exec_${Date.now()}_${randomUUID().slice(0, 8)}`;
const startedAt = new Date().toISOString();
outputHub.clear(machineId);
db.update(schema.machines).set({ status: "running" }).where(eq(schema.machines.id, machineId)).run();
db.insert(schema.executions).values({
id: executionId, machineId, action, mode: "manual", startedAt, status: "running",
}).run();
const proxy = m.aptProxyMode === "runtime" ? m.aptProxyUrl : null;
const script = renderTemplate(TEMPLATE_FOR[action], { aptProxy: proxy });
let raw = "";
let status: ExecutionStatus = "ok";
try {
const res = await runScriptSudo(getCreds(m), script, (c) => {
raw += c;
outputHub.publish(machineId, c);
});
raw = res.stdout;
if (/===SU:EXIT=(\d+)===/.exec(raw)?.[1] && /===SU:EXIT=0===/.test(raw) === false) {
status = "error";
}
} catch (err) {
status = "error";
raw += `\n[ERREUR] ${(err as Error).message}\n`;
}
const finishedAt = new Date().toISOString();
const rebootRequired = parseRebootRequired(extractSection(raw, "===SU:REBOOT===", "===SU:EXIT") || raw);
// Archivage log brut + rapport.
const dir = join(env.reportsDir, machineId);
mkdirSync(dir, { recursive: true });
const rawLogPath = join(dir, `${executionId}.log`);
const reportPath = join(dir, `${executionId}.md`);
writeFileSync(rawLogPath, raw, "utf8");
const result: ExecutionResult = {
executionId, machineId, startedAt, finishedAt, mode: "manual", action, status,
rebootRequiredAfterRun: rebootRequired,
importantLogLines: reduceAptLines(raw),
rawLogRef: rawLogPath, reportRef: reportPath,
};
writeFileSync(reportPath, buildReportMarkdown(result, m.name), "utf8");
db.update(schema.executions).set({
finishedAt, status, resultJson: JSON.stringify(result), reportPath, rawLogPath,
}).where(eq(schema.executions.id, executionId)).run();
db.update(schema.machines).set({ status: status === "error" ? "error" : "unknown" })
.where(eq(schema.machines.id, machineId)).run();
outputHub.publish(machineId, `\n===SU:DONE status=${status}===\n`);
return result;
}
export function listExecutions(machineId: string) {
return db.select().from(schema.executions).where(eq(schema.executions.machineId, machineId)).all();
}
export function getExecution(executionId: string) {
return db.select().from(schema.executions).where(eq(schema.executions.id, executionId)).get();
}
```
- [ ] **Step 2: Vérifier la compilation**
Run: `pnpm check`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add server/services/execute.ts
git commit -m "feat: service execute (full-upgrade/reboot -> execution + rapport archivé)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 13: Worker in-process (refresh périodique)
**Files:**
- Create: `server/jobs/worker.ts`
- [ ] **Step 1: Implémenter `server/jobs/worker.ts`**
```ts
// server/jobs/worker.ts
import { Cron } from "croner";
import { listMachines } from "../services/machines.js";
import { refreshMachine } from "../services/refresh.js";
let job: Cron | null = null;
/** Rafraîchit toutes les machines toutes les 30 minutes (tâche de fond). */
export function startWorker(): void {
job = new Cron("*/30 * * * *", async () => {
for (const m of listMachines()) {
try {
await refreshMachine(m.id);
} catch (err) {
console.error(`[worker] refresh échoué pour ${m.id}:`, (err as Error).message);
}
}
});
}
export function stopWorker(): void {
job?.stop();
job = null;
}
```
- [ ] **Step 2: Vérifier la compilation**
Run: `pnpm check`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add server/jobs/worker.ts
git commit -m "feat: worker in-process de refresh périodique (croner)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 14: Routes HTTP (Hono)
**Files:**
- Create: `server/routes/machines.ts`, `server/routes/actions.ts`, `server/routes/index.ts`
- [ ] **Step 1: Implémenter `server/routes/machines.ts`**
```ts
// server/routes/machines.ts
import { Hono } from "hono";
import {
listMachines, createMachine, deleteMachine, getMachineRow, getCreds, testConnection,
type CreateMachineInput,
} from "../services/machines.js";
import { refreshMachine, getLatestSnapshot } from "../services/refresh.js";
export const machinesRoutes = new Hono();
machinesRoutes.get("/", (c) => c.json(listMachines()));
machinesRoutes.post("/", async (c) => {
const body = (await c.req.json()) as CreateMachineInput;
try {
const m = await createMachine(body);
return c.json(m, 201);
} catch (err) {
return c.json({ error: `Connexion échouée: ${(err as Error).message}` }, 400);
}
});
machinesRoutes.post("/:id/test-connection", async (c) => {
const m = getMachineRow(c.req.param("id"));
if (!m) return c.json({ error: "Machine introuvable" }, 404);
try {
return c.json(await testConnection(getCreds(m)));
} catch (err) {
return c.json({ error: (err as Error).message }, 400);
}
});
machinesRoutes.get("/:id/snapshot", (c) => {
const snap = getLatestSnapshot(c.req.param("id"));
return snap ? c.json(snap) : c.json({ error: "Aucun snapshot" }, 404);
});
machinesRoutes.post("/:id/refresh", async (c) => {
try {
return c.json(await refreshMachine(c.req.param("id")));
} catch (err) {
return c.json({ error: (err as Error).message }, 400);
}
});
machinesRoutes.delete("/:id", (c) => {
deleteMachine(c.req.param("id"));
return c.json({ ok: true });
});
```
- [ ] **Step 2: Implémenter `server/routes/actions.ts`**
```ts
// server/routes/actions.ts
import { Hono } from "hono";
import { readFileSync } from "node:fs";
import { runAction, listExecutions, getExecution } from "../services/execute.js";
import type { ActionType } from "@shared/types.js";
export const actionsRoutes = new Hono();
actionsRoutes.post("/:id/actions", async (c) => {
const { action } = (await c.req.json()) as { action: ActionType };
if (action !== "apt_full_upgrade" && action !== "reboot") {
return c.json({ error: "Action non autorisée" }, 400);
}
// Exécution lancée en arrière-plan; le suivi se fait via WebSocket.
runAction(c.req.param("id"), action).catch((err) =>
console.error("[action]", (err as Error).message),
);
return c.json({ ok: true, action }, 202);
});
actionsRoutes.get("/:id/executions", (c) => c.json(listExecutions(c.req.param("id"))));
actionsRoutes.get("/:id/executions/:execId", (c) => {
const e = getExecution(c.req.param("execId"));
return e ? c.json(e) : c.json({ error: "Exécution introuvable" }, 404);
});
actionsRoutes.get("/:id/executions/:execId/report", (c) => {
const e = getExecution(c.req.param("execId"));
if (!e?.reportPath) return c.json({ error: "Rapport introuvable" }, 404);
return c.text(readFileSync(e.reportPath, "utf8"));
});
```
- [ ] **Step 3: Implémenter `server/routes/index.ts`**
```ts
// server/routes/index.ts
import { Hono } from "hono";
import { machinesRoutes } from "./machines.js";
import { actionsRoutes } from "./actions.js";
export const api = new Hono();
api.route("/machines", machinesRoutes);
api.route("/machines", actionsRoutes);
```
- [ ] **Step 4: Vérifier la compilation**
Run: `pnpm check`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add server/routes/
git commit -m "feat: routes HTTP Hono (machines, refresh, actions, executions)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 15: Entrée serveur (Hono + node-server + WebSocket + worker)
**Files:**
- Create: `server/index.ts`
- [ ] **Step 1: Implémenter `server/index.ts`**
```ts
// server/index.ts
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { WebSocketServer } from "ws";
import type { IncomingMessage } from "node:http";
import { env } from "./env.js";
import { runMigrations } from "./db/migrate.js";
import { api } from "./routes/index.js";
import { outputHub } from "./ws/outputHub.js";
import { startWorker } from "./jobs/worker.js";
env.requireMasterKey();
runMigrations();
const app = new Hono();
app.route("/api", api);
app.get("/health", (c) => c.json({ ok: true }));
const server = serve({ fetch: app.fetch, port: env.port }, (info) =>
console.log(`[server] http://localhost:${info.port}`),
);
// WebSocket: /api/ws/machines/:id/output
const wss = new WebSocketServer({ noServer: true });
server.on("upgrade", (req: IncomingMessage, socket, head) => {
const match = /^\/api\/ws\/machines\/([^/]+)\/output$/.exec(req.url ?? "");
if (!match) {
socket.destroy();
return;
}
const machineId = match[1]!;
wss.handleUpgrade(req, socket, head, (ws) => {
const unsub = outputHub.subscribe(machineId, (chunk) => {
if (ws.readyState === ws.OPEN) ws.send(chunk);
});
ws.on("close", unsub);
});
});
startWorker();
```
- [ ] **Step 2: Lancer le serveur**
Run: `SU_MASTER_KEY=$(openssl rand -hex 32) pnpm dev:server`
Expected: log `[server] http://localhost:8787`. Vérifier `curl localhost:8787/health``{"ok":true}`. Arrêter (Ctrl-C).
- [ ] **Step 3: Commit**
```bash
git add server/index.ts
git commit -m "feat: entrée serveur (Hono + WebSocket /api/ws + worker)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 16: Scaffolding client (Vite + React + design system)
**Files:**
- Create: `vite.config.ts`, `client/index.html`, `client/src/main.tsx`, `client/src/styles/app.css`
- Copy: `design_system/tokens/tokens.css``client/src/styles/tokens.css`
- Port: `design_system/components/ui-kit.jsx``client/src/components/ui-kit.tsx`
- [ ] **Step 1: Créer `vite.config.ts`**
```ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
root: "client",
plugins: [react()],
resolve: { alias: { "@shared": new URL("./shared", import.meta.url).pathname } },
server: { proxy: { "/api": { target: "http://localhost:8787", ws: true } } },
build: { outDir: "../dist/client", emptyOutDir: true },
});
```
- [ ] **Step 2: Créer `client/index.html`**
```html
<!doctype html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>System Update</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
```
- [ ] **Step 3: Copier les tokens et porter le ui-kit**
```bash
cp design_system/tokens/tokens.css client/src/styles/tokens.css
cp design_system/components/ui-kit.jsx client/src/components/ui-kit.tsx
```
Puis dans `client/src/components/ui-kit.tsx`, ajouter en tête `// @ts-nocheck` (le design system est en JS non typé ; on le consomme tel quel pour le MVP) et vérifier que les imports React sont présents (`import React from "react";`).
- [ ] **Step 4: Créer `client/src/styles/app.css`**
```css
@import "./tokens.css";
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; }
body {
font-family: var(--font-ui);
background: var(--bg-1);
color: var(--ink-1);
}
.su-layout { display: flex; height: 100vh; }
.su-hermes { width: 280px; min-width: 200px; background: var(--bg-2); border-right: 1px solid var(--border-1); padding: 14px; }
.su-center { flex: 1; overflow: auto; padding: 18px; }
.su-terminal { width: 360px; min-width: 320px; background: var(--bg-0); border-left: 1px solid var(--border-1); }
.su-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; }
```
- [ ] **Step 5: Créer `client/src/main.tsx`**
```tsx
import React from "react";
import { createRoot } from "react-dom/client";
import "./styles/app.css";
import { App } from "./App.js";
createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
```
- [ ] **Step 6: Commit**
```bash
git add vite.config.ts client/index.html client/src/main.tsx client/src/styles/ client/src/components/ui-kit.tsx
git commit -m "feat: scaffolding client Vite/React + design system Gruvbox
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 17: Librairies client (API + WebSocket)
**Files:**
- Create: `client/src/lib/api.ts`, `client/src/lib/ws.ts`
- [ ] **Step 1: Créer `client/src/lib/api.ts`**
```ts
// client/src/lib/api.ts
import type { MachineView, UpdateSnapshot, ActionType } from "@shared/types.js";
async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`/api${path}`, {
headers: { "content-type": "application/json" },
...init,
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? res.statusText);
return res.json() as Promise<T>;
}
export const api = {
listMachines: () => req<MachineView[]>("/machines"),
createMachine: (body: unknown) => req<MachineView>("/machines", { method: "POST", body: JSON.stringify(body) }),
refresh: (id: string) => req<UpdateSnapshot>(`/machines/${id}/refresh`, { method: "POST" }),
snapshot: (id: string) => req<UpdateSnapshot>(`/machines/${id}/snapshot`),
runAction: (id: string, action: ActionType) =>
req<{ ok: boolean }>(`/machines/${id}/actions`, { method: "POST", body: JSON.stringify({ action }) }),
deleteMachine: (id: string) => req<{ ok: boolean }>(`/machines/${id}`, { method: "DELETE" }),
};
```
- [ ] **Step 2: Créer `client/src/lib/ws.ts`**
```ts
// client/src/lib/ws.ts
export function connectOutput(machineId: string, onChunk: (s: string) => void): () => void {
const proto = location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${proto}://${location.host}/api/ws/machines/${machineId}/output`);
ws.onmessage = (ev) => onChunk(typeof ev.data === "string" ? ev.data : "");
return () => ws.close();
}
```
- [ ] **Step 3: Vérifier la compilation**
Run: `pnpm check`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add client/src/lib/
git commit -m "feat: librairies client API REST + WebSocket
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 18: Composants UI (layout, tuiles, modale, terminal)
**Files:**
- Create: `client/src/App.tsx`, `client/src/panels/HermesPanel.tsx`, `client/src/panels/Dashboard.tsx`, `client/src/panels/TerminalPanel.tsx`, `client/src/features/machines/MachineTile.tsx`, `client/src/features/machines/AddMachineModal.tsx`
- [ ] **Step 1: Créer `client/src/panels/HermesPanel.tsx` (stub)**
```tsx
// client/src/panels/HermesPanel.tsx
export function HermesPanel() {
return (
<aside className="su-hermes">
<div className="label" style={{ marginBottom: 12 }}>HERMES</div>
<p style={{ color: "var(--ink-3)", fontSize: 13 }}>
Copilote d'exploitation à venir. Analyse des mises à jour, plans et rapports
seront disponibles ici dans un prochain jalon.
</p>
</aside>
);
}
```
- [ ] **Step 2: Créer `client/src/features/machines/MachineTile.tsx`**
```tsx
// client/src/features/machines/MachineTile.tsx
import type { MachineView } from "@shared/types.js";
interface Props {
machine: MachineView;
packageCount: number;
onSelect: (id: string) => void;
onRefresh: (id: string) => void;
onUpgrade: (id: string) => void;
onReboot: (id: string) => void;
}
const STATUS_COLOR: Record<string, string> = {
ok: "var(--ok)", updates_available: "var(--warn)", error: "var(--err)",
running: "var(--info)", unknown: "var(--ink-4)",
};
export function MachineTile({ machine, packageCount, onSelect, onRefresh, onUpgrade, onReboot }: Props) {
return (
<div className="glass" style={{ padding: 16, borderRadius: 10 }} onClick={() => onSelect(machine.id)}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ width: 10, height: 10, borderRadius: 999, background: STATUS_COLOR[machine.status] }} />
<strong>{machine.name}</strong>
</div>
<div className="mono" style={{ color: "var(--ink-3)", fontSize: 12 }}>
{machine.hostname}:{machine.port} · {machine.osFamily}
</div>
<div style={{ margin: "10px 0", fontSize: 13 }}>
<span className="label">UPDATES</span>{" "}
<span className="mono">{packageCount}</span>
</div>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }} onClick={(e) => e.stopPropagation()}>
<button className="interactive" onClick={() => onRefresh(machine.id)}>Refresh</button>
<button className="interactive" onClick={() => onUpgrade(machine.id)}>Upgrade</button>
<button className="interactive" onClick={() => onReboot(machine.id)}>Reboot</button>
</div>
</div>
);
}
```
> Note de design system : remplacer les `<button className="interactive">` par le composant `<Button>` de `components/ui-kit.tsx` une fois son API confirmée (variants `primary`/`ghost`/`danger`). Les `IconButton` isolés doivent recevoir un `label` (tooltip). Aucun hover, pression 3D uniquement.
- [ ] **Step 3: Créer `client/src/features/machines/AddMachineModal.tsx`**
```tsx
// client/src/features/machines/AddMachineModal.tsx
import { useState } from "react";
interface Props { onClose: () => void; onCreated: () => void; }
export function AddMachineModal({ onClose, onCreated }: Props) {
const [form, setForm] = useState({ name: "", hostname: "", port: 22, username: "", password: "", sudoPassword: "" });
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const set = (k: string, v: string | number) => setForm({ ...form, [k]: v });
async function submit() {
setBusy(true); setError(null);
try {
const res = await fetch("/api/machines", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ ...form, port: Number(form.port), sudoPassword: form.sudoPassword || null }),
});
if (!res.ok) throw new Error((await res.json()).error ?? "Échec");
onCreated(); onClose();
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
}
return (
<div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,.5)", display: "grid", placeItems: "center" }}>
<div className="glass-strong" style={{ padding: 20, borderRadius: 12, width: 380, display: "grid", gap: 10 }}>
<div className="label">AJOUTER UNE MACHINE</div>
{(["name", "hostname", "username"] as const).map((k) => (
<input key={k} placeholder={k} value={form[k]} onChange={(e) => set(k, e.target.value)} />
))}
<input placeholder="port" type="number" value={form.port} onChange={(e) => set("port", e.target.value)} />
<input placeholder="password" type="password" value={form.password} onChange={(e) => set("password", e.target.value)} />
<input placeholder="sudo password (optionnel)" type="password" value={form.sudoPassword} onChange={(e) => set("sudoPassword", e.target.value)} />
{error && <div style={{ color: "var(--err)", fontSize: 12 }}>{error}</div>}
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button onClick={onClose}>Annuler</button>
<button className="interactive" disabled={busy} onClick={submit}>{busy ? "Test…" : "Ajouter"}</button>
</div>
</div>
</div>
);
}
```
- [ ] **Step 4: Créer `client/src/panels/TerminalPanel.tsx`**
```tsx
// client/src/panels/TerminalPanel.tsx
import { useEffect, useRef } from "react";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import "@xterm/xterm/css/xterm.css";
import { connectOutput } from "../lib/ws.js";
export function TerminalPanel({ machineId }: { machineId: string | null }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current) return;
const term = new Terminal({
fontFamily: "'Share Tech Mono', monospace", fontSize: 12,
theme: { background: "#1d2021", foreground: "#ebdbb2" },
convertEol: true,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open(ref.current);
fit.fit();
term.writeln(machineId ? `# flux ${machineId}` : "# sélectionne une machine");
const disconnect = machineId ? connectOutput(machineId, (c) => term.write(c)) : () => {};
return () => { disconnect(); term.dispose(); };
}, [machineId]);
return <div className="su-terminal" ref={ref} style={{ padding: 6 }} />;
}
```
- [ ] **Step 5: Créer `client/src/panels/Dashboard.tsx`**
```tsx
// client/src/panels/Dashboard.tsx
import { useEffect, useState } from "react";
import type { MachineView } from "@shared/types.js";
import { api } from "../lib/api.js";
import { MachineTile } from "../features/machines/MachineTile.js";
import { AddMachineModal } from "../features/machines/AddMachineModal.js";
interface Props { onSelect: (id: string) => void; }
export function Dashboard({ onSelect }: Props) {
const [machines, setMachines] = useState<MachineView[]>([]);
const [counts, setCounts] = useState<Record<string, number>>({});
const [adding, setAdding] = useState(false);
async function load() {
const ms = await api.listMachines();
setMachines(ms);
const entries = await Promise.all(ms.map(async (m) => {
try { return [m.id, (await api.snapshot(m.id)).apt.count] as const; }
catch { return [m.id, 0] as const; }
}));
setCounts(Object.fromEntries(entries));
}
useEffect(() => { void load(); }, []);
return (
<main className="su-center">
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 16 }}>
<h2 style={{ margin: 0 }}>Machines</h2>
<button className="interactive" onClick={() => setAdding(true)}>+ Ajouter</button>
</div>
{machines.length === 0 && <p style={{ color: "var(--ink-3)" }}>Aucune machine. Clique sur « + Ajouter ».</p>}
<div className="su-tiles">
{machines.map((m) => (
<MachineTile
key={m.id} machine={m} packageCount={counts[m.id] ?? 0} onSelect={onSelect}
onRefresh={(id) => { onSelect(id); void api.refresh(id).then(load); }}
onUpgrade={(id) => { onSelect(id); void api.runAction(id, "apt_full_upgrade"); }}
onReboot={(id) => { onSelect(id); void api.runAction(id, "reboot"); }}
/>
))}
</div>
{adding && <AddMachineModal onClose={() => setAdding(false)} onCreated={load} />}
</main>
);
}
```
- [ ] **Step 6: Créer `client/src/App.tsx`**
```tsx
// client/src/App.tsx
import { useState } from "react";
import { HermesPanel } from "./panels/HermesPanel.js";
import { Dashboard } from "./panels/Dashboard.js";
import { TerminalPanel } from "./panels/TerminalPanel.js";
export function App() {
const [selected, setSelected] = useState<string | null>(null);
return (
<div className="su-layout">
<HermesPanel />
<Dashboard onSelect={setSelected} />
<TerminalPanel machineId={selected} />
</div>
);
}
```
- [ ] **Step 7: Vérifier la compilation + build client**
Run: `pnpm check && pnpm vite build`
Expected: PASS, bundle produit dans `dist/client`.
- [ ] **Step 8: Commit**
```bash
git add client/src/App.tsx client/src/panels/ client/src/features/
git commit -m "feat: UI 3 volets (Hermes stub, dashboard tuiles, terminal xterm.js)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 19: Vérification end-to-end manuelle (machine réelle)
**Files:** aucun (validation manuelle).
- [ ] **Step 1: Lancer l'app complète**
Run:
```bash
export SU_MASTER_KEY=$(openssl rand -hex 32)
pnpm dev
```
Expected: serveur sur `:8787`, client Vite sur `:5173` (proxy `/api` + ws actif).
- [ ] **Step 2: Ajouter une vraie machine Debian/Ubuntu**
Dans le navigateur, « + Ajouter » → renseigner host/user/password/sudo. Attendu : la tuile apparaît (test-connection a réussi, OS détecté).
- [ ] **Step 3: Refresh**
Cliquer Refresh. Attendu : compteur d'updates mis à jour, sortie visible dans le terminal de droite, snapshot persisté (`GET /api/machines/:id/snapshot`).
- [ ] **Step 4: Upgrade + rapport**
Cliquer Upgrade. Attendu : sortie live dans le terminal, puis `===SU:DONE===`. Vérifier qu'un `.md` et un `.log` existent dans `reports/<id>/` et que `GET /api/machines/:id/executions` liste l'exécution.
- [ ] **Step 5: Vérifier l'absence de secret**
Inspecter la base (`reports`, réponses API, logs serveur) : aucun mot de passe en clair (`enc_password` est un blob `iv.tag.ct`).
- [ ] **Step 6: Suite de tests complète**
Run: `pnpm test`
Expected: PASS (crypto, aptReduce, aptParse, render, outputHub, machines, refresh, report).
- [ ] **Step 7: Commit final du jalon**
```bash
git add -A
git commit -m "test: vérification end-to-end jalon 1 (APT)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 20: Packaging Docker
**Files:**
- Create: `tsup.config.ts`, `docker/Dockerfile`, `docker/docker-compose.yml`
- [ ] **Step 1: Créer `tsup.config.ts`**
```ts
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["server/index.ts"],
outDir: "dist",
format: ["esm"],
platform: "node",
target: "node22",
bundle: true,
noExternal: [/.*/],
external: ["better-sqlite3"],
});
```
- [ ] **Step 2: Créer `docker/Dockerfile`**
```dockerfile
FROM node:22-bookworm-slim AS build
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM node:22-bookworm-slim
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod
COPY --from=build /app/dist ./dist
COPY --from=build /app/server/db/migrations ./server/db/migrations
COPY --from=build /app/templates ./templates
ENV SU_DB_PATH=/data/system-update.db
ENV SU_REPORTS_DIR=/reports
EXPOSE 8787
CMD ["node", "dist/index.js"]
```
> Note: `dist/client` (front statique) peut être servi par un reverse proxy ou ajouté via `hono/serve-static`. Pour le MVP, le front est buildé et servi séparément ; le service du statique côté Hono est un suivi optionnel.
- [ ] **Step 3: Créer `docker/docker-compose.yml`**
```yaml
services:
system-update:
build:
context: ..
dockerfile: docker/Dockerfile
ports:
- "8787:8787"
environment:
SU_MASTER_KEY: ${SU_MASTER_KEY:?définir SU_MASTER_KEY}
volumes:
- su-data:/data
- su-reports:/reports
restart: unless-stopped
volumes:
su-data:
su-reports:
```
- [ ] **Step 4: Vérifier le build de l'image**
Run: `SU_MASTER_KEY=$(openssl rand -hex 32) docker compose -f docker/docker-compose.yml build`
Expected: image construite sans erreur.
- [ ] **Step 5: Commit**
```bash
git add tsup.config.ts docker/
git commit -m "feat: packaging Docker (Dockerfile + compose, volumes data/reports)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Self-Review (couverture du spec)
- **Ajout machine + test-connection** → Task 9 (`createMachine`/`testConnection`), Task 14 (routes), Task 18 (modale). ✓
- **Credentials chiffrés, aucun secret exposé** → Task 2 (crypto), Task 9 (stockage `enc_*`, `MachineView` sans secret), Task 19 step 5. ✓
- **Refresh de fond → snapshot canonique** → Task 10 (service), Task 13 (worker), Task 5/6 (parse/template). ✓
- **Tuile : nom, IP, OS, compteur, paquets** → Task 18 (`MachineTile`, `Dashboard`). ✓
- **full-upgrade live terminal** → Task 12 (execute + outputHub), Task 8 (hub), Task 15 (WS), Task 18 (TerminalPanel). ✓
- **reboot manuel** → Task 6 (`reboot.sh.tpl`), Task 12 (`TEMPLATE_FOR`), Task 14 (actions). ✓
- **Rapport .md + log brut archivés** → Task 11 (markdown), Task 12 (écriture fichiers). ✓
- **Docker + SU_MASTER_KEY + volumes** → Task 20. ✓
- **Tests vitest (parser, crypto, réducteur, template)** → Tasks 2,4,5,6,8,9,10,11 ; suite complète Task 19 step 6. ✓
- **API headless réutilisable** → Task 14/15 (toute la logique derrière `/api`, client sans logique métier). ✓
- **Réducteur déterministe APT** → Task 4, utilisé dans refresh (rawHints) et execute (importantLogLines). ✓
Aucun placeholder détecté. Noms de fonctions cohérents entre tâches (`refreshMachine`, `runAction`, `extractSection`, `reduceAptLines`, `parseAptSimulate`, `getCreds`, `outputHub`).