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

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

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 .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
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.cssclient/src/styles/tokens.css

  • Port: design_system/components/ui-kit.jsxclient/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> de components/ui-kit.tsx une fois son API confirmée (variants primary/ghost/danger). Les IconButton isolés doivent recevoir un label (tooltip). Aucun hover, pression 3D uniquement.

  • Step 3: Créer client/src/features/machines/AddMachineModal.tsx
// 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é 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
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_*, 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).