# 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 " ``` --- ## 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 " ``` --- ## 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 " ``` --- ## 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 " ``` --- ## 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 " ``` --- ## 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 " ``` --- ## 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 " ``` --- ## 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 { 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 { 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 { 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 { 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 " ``` --- ## 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(); private listeners = new Map>(); 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 " ``` --- ## 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 = {}; 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 { 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 " ``` --- ## 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 { 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 " ``` --- ## 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 " ``` --- ## 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 = { apt_full_upgrade: "apt/full-upgrade.sh.tpl", reboot: "apt/reboot.sh.tpl", }; export async function runAction(machineId: string, action: ActionType): Promise { 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 " ``` --- ## 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 " ``` --- ## 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 " ``` --- ## 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 " ``` --- ## 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 System Update
``` - [ ] **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( , ); ``` - [ ] **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 " ``` --- ## 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(path: string, init?: RequestInit): Promise { 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; } export const api = { listMachines: () => req("/machines"), createMachine: (body: unknown) => req("/machines", { method: "POST", body: JSON.stringify(body) }), refresh: (id: string) => req(`/machines/${id}/refresh`, { method: "POST" }), snapshot: (id: string) => req(`/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 " ``` --- ## 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 ( ); } ``` - [ ] **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 = { 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 (
onSelect(machine.id)}>
{machine.name}
{machine.hostname}:{machine.port} · {machine.osFamily}
UPDATES{" "} {packageCount}
e.stopPropagation()}>
); } ``` > Note de design system : remplacer les ` ); } ``` - [ ] **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(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
; } ``` - [ ] **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([]); const [counts, setCounts] = useState>({}); 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 (

Machines

{machines.length === 0 &&

Aucune machine. Clique sur « + Ajouter ».

}
{machines.map((m) => ( { 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"); }} /> ))}
{adding && setAdding(false)} onCreated={load} />}
); } ``` - [ ] **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(null); return (
); } ``` - [ ] **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 " ``` --- ## 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//` 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 " ``` --- ## 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 " ``` --- ## 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`).