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>
70 KiB
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
{
"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
{
"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
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
# 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
.gitignoreetreports/.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
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
// 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
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
// 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)
// 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
// 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
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
// 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
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
// 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
// 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
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)
// 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
// 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
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)
// 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
// 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
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
#!/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
#!/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
#!/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)
// 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
// 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
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
// 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
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)
// 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
// 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
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
// 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
// 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
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
// 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
// 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
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)
// 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
// 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
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
// 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
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
// 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
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
// 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
// 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
// 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
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
// 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
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
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
<!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
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
@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
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
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
// 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
// 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
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)
// 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
// 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>decomponents/ui-kit.tsxune fois son API confirmée (variantsprimary/ghost/danger). LesIconButtonisolés doivent recevoir unlabel(tooltip). Aucun hover, pression 3D uniquement.
- Step 3: Créer
client/src/features/machines/AddMachineModal.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
// 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
// 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
// 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
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:
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
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
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
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é viahono/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
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
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_*,MachineViewsans 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).