import { execFile } from 'node:child_process'; import { createHash } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { promisify } from 'node:util'; const run = promisify(execFile); const DOWNLOAD_TIMEOUT_MS = 60_000; /** Marqueur attendu dans la sortie de `--check` : un bundle cassé ne l'imprime pas. */ const HEALTH_MARKER = 'obs-websocket'; export interface UpdateResult { previousBuildId: string; newBuildId: string; bytes: number; backup: string; restartInMs: number; } /** Chemin du bundle en cours d'exécution, ou null hors déploiement bundlé. */ export function runningBundlePath(): string | null { const entry = process.argv[1]; if (!entry) return null; const resolved = path.resolve(entry); return resolved.endsWith('.cjs') && fs.existsSync(resolved) ? resolved : null; } /** Empreinte courte du binaire courant, pour repérer les agents en retard. */ export function currentBuildId(): string | undefined { const bundle = runningBundlePath(); if (!bundle) return undefined; try { return createHash('sha256').update(fs.readFileSync(bundle)).digest('hex').slice(0, 8); } catch { return undefined; } } export interface UpdateDeps { /** État d'enregistrement : on ne coupe jamais une capture en cours. */ recordState(): Promise<{ active: boolean; paused: boolean }>; log(level: 'info' | 'warn' | 'error', message: string): void; } /** * Remplace le binaire de l'agent puis rend la main au superviseur. * * Le processus ne se relance pas lui-même : il sort, et systemd (Linux) ou la * tâche planifiée (Windows) le redémarre. C'est ce qui garde le mécanisme * simple — et c'est aussi ce qui impose de valider le nouveau binaire *avant* * de basculer, faute de quoi un bundle cassé produirait une boucle de crash * sans personne pour revenir en arrière. */ export async function selfUpdate( url: string | undefined, deps: UpdateDeps, ): Promise { const bundle = runningBundlePath(); if (!bundle) { throw new Error( "Mise à jour indisponible : l'agent ne tourne pas depuis un bundle (mode développement ?)", ); } if (!url) { throw new Error( "Aucune URL de paquet connue. Réinstalle l'agent une fois pour l'enregistrer, ou fournis-la explicitement.", ); } // 1. Ne jamais interrompre une capture en cours. const record = await deps.recordState().catch(() => ({ active: false, paused: false })); if (record.active) { throw new Error('Enregistrement en cours : mise à jour refusée. Arrête-le puis réessaie.'); } const dir = path.dirname(bundle); const staged = path.join(dir, 'agent-update.cjs'); const backup = path.join(dir, 'agent-previous.cjs'); // 2. Téléchargement. deps.log('info', `Mise à jour depuis ${url}`); const response = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) }); if (!response.ok) throw new Error(`Téléchargement impossible : HTTP ${response.status}`); const bytes = Buffer.from(await response.arrayBuffer()); if (bytes.length < 10_000) { throw new Error(`Paquet suspect : ${bytes.length} octets seulement`); } fs.writeFileSync(staged, bytes); const newBuildId = createHash('sha256').update(bytes).digest('hex').slice(0, 8); const previousBuildId = currentBuildId() ?? 'inconnu'; if (newBuildId === previousBuildId) { fs.rmSync(staged, { force: true }); throw new Error(`Déjà à jour (build ${previousBuildId})`); } try { // 3. Le fichier est-il seulement du JavaScript analysable ? await run(process.execPath, ['--check', staged], { timeout: 30_000 }); // 4. S'exécute-t-il vraiment ? `--check` sort en 1 quand le serveur est // injoignable : on valide la présence du diagnostic, pas le code retour. const probe = await run(process.execPath, [staged, '--check'], { timeout: 60_000, env: { ...process.env, AGENT_TOKEN: process.env.AGENT_TOKEN ?? 'update-selftest' }, }).catch((err: { stdout?: string; stderr?: string }) => ({ stdout: err.stdout ?? '', stderr: err.stderr ?? '', })); if (!`${probe.stdout}${probe.stderr}`.includes(HEALTH_MARKER)) { throw new Error('le nouveau binaire ne produit pas de diagnostic valide'); } } catch (err) { fs.rmSync(staged, { force: true }); const detail = err instanceof Error ? err.message : String(err); throw new Error(`Nouveau binaire rejeté (${detail}) — binaire actuel conservé`); } // 5. Bascule, en gardant de quoi revenir en arrière à la main. fs.copyFileSync(bundle, backup); fs.renameSync(staged, bundle); deps.log( 'info', `Binaire remplacé : ${previousBuildId} → ${newBuildId}. Redémarrage par le superviseur…`, ); // 6. Sortie différée : laisse le temps au résultat de partir vers le serveur. const restartInMs = 1500; setTimeout(() => process.exit(0), restartInMs).unref?.(); return { previousBuildId, newBuildId, bytes: bytes.length, backup, restartInMs }; }