// registry.js — topologie VIVANTE de la console admin-ka. // « Quelle app tourne sur quel nœud ? » n'est plus codé ici : la réponse vient // du registre de la passerelle mld (M1M32:~/dispatch/registry.json), que // l'orchestrateur maclustr-dispatch met à jour à chaque deploy/move/retire. // • poussé par mld dans data/registry.json (abonné « admin-ka », suit les migrations) ; // • tiré toutes les 2 min par ssh (M1M32 / gitsrv) en ceinture-et-bretelles ; // • le fichier local est surveillé : un push mld est appliqué en quelques secondes. // Ce module ne garde en dur que la CONNAISSANCE Ka (libellés, exceptions launchd, // process PM2 non déductibles) — jamais un emplacement. import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { execFile } from 'node:child_process'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const HOME = os.homedir(); const USER = 'simon-pierreboucher'; const GATEWAY_HOSTS = ['M1M32', 'gitsrv']; // alias ssh essayés pour tirer le registre const PULL_MS = 2 * 60_000; const WATCH_MS = 5_000; const STALE_MS = 3 * 3600_000; // registre plus vieux → « périmé » (mld en panne ?) const ENV_PATH = '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin'; // ---------- connaissance Ka (PAS d'emplacement ici) ---------- // label : nom affiché ; pm2 : process web quand la règle « *-web sinon 1er » ne suffit pas ; // unit/log : apps sous launchd (pas de pm2) ; repoDir : repo de travail ≠ dir du manifeste ; // monitor:false / project:false : exclure du monitoring / des projets Claude Code. export const KA_CATALOG = { 'lou-ka': { label: 'Lou·Ka' }, 'immo-ka': { label: 'Immo·Ka' }, 'vrai-prix': { label: 'Vrai-Prix' }, 'auto-ka': { label: 'Auto·Ka' }, 'fabri-ka': { label: 'Fabri·Ka' }, 'food-ka': { label: 'Food·Ka' }, 'sorti-ka': { label: 'Sorti·Ka' }, 'job-ka': { label: 'Job·Ka' }, 'groupe-ka': { label: 'Groupe·Ka' }, 'api-ka': { label: 'API·Ka', pm2: 'apika-api' }, 'ka-stats': { label: 'Ka·Stats' }, // Agents gardiens KA Guardian — un seul repo, launchd (pas de pm2) 'ka2': { label: 'KA·2 Guardian', unit: 'com.ka2.guardian', log: '~/cluster-projects/ka-guardian/logs/ka2.log', repoDir: '~/cluster-projects/ka-guardian' }, 'ka4': { label: 'KA·4 Guardian', unit: 'com.ka4.guardian', log: '~/cluster-projects/ka-guardian/logs/ka4.log', repoDir: '~/cluster-projects/ka-guardian' }, 'ka6': { label: 'KA·6 Guardian', unit: 'com.ka6.guardian', log: '~/cluster-projects/ka-guardian/logs/ka6.log', repoDir: '~/cluster-projects/ka-guardian' }, // la console elle-même : sert à détecter SON nœud, ni surveillée ni projet 'admin-ka': { label: 'Administration·Ka', monitor: false, project: false }, }; // toute app du registre au nom « Ka » (ex. un futur toit-ka) est incluse automatiquement const KA_NAME_RE = /(^|-)ka(\d|-|$)/i; // ---------- état ---------- export const SITES = []; // rempli EN PLACE (les importateurs gardent leur référence) let REG = { apps: {}, updated: null, gateway: null, fetchedAt: 0, source: null, error: null, mtime: 0 }; let SELF = 'M3U96a'; // repli tant que le registre n'a pas dit où tourne admin-ka let cachePath = null; let onChange = () => {}; let sshHosts = new Set(); const toolingChecked = {}; // node -> ts du dernier contrôle outillage function expandHome(p) { return p && p.startsWith('~') ? path.join(HOME, p.slice(1)) : p; } function loadSshHosts() { try { const txt = fs.readFileSync(path.join(HOME, '.ssh', 'config'), 'utf8'); sshHosts = new Set([...txt.matchAll(/^Host\s+(\S+)\s*$/gm)].map(m => m[1]).filter(h => h !== '*')); } catch { sshHosts = new Set(); } } // ---------- construction des sites depuis le registre ---------- function webProcess(app, processes, cat) { if (cat.pm2) return cat.pm2; const biz = processes.filter(p => !/(^|-)ngrok$/.test(p)); return biz.find(p => /-web$/.test(p)) || biz.find(p => p === app) || biz[0] || null; } function buildSites() { const apps = REG.apps || {}; const names = [ ...Object.keys(KA_CATALOG).filter(a => apps[a]), ...Object.keys(apps).filter(a => !KA_CATALOG[a] && KA_NAME_RE.test(a)).sort(), ]; const next = []; for (const app of names) { const cat = KA_CATALOG[app] || {}; const e = apps[app]; if (cat.monitor === false && cat.project === false) continue; const processes = Array.isArray(e.processes) ? e.processes : []; const launchd = Array.isArray(e.launchd) ? e.launchd : []; const unit = cat.unit || (processes.length === 0 && launchd.length ? launchd[0] : null); next.push({ app, label: cat.label || e.label || app, domain: e.domain || null, node: e.node, ip: e.ip || null, port: e.port || null, dir: e.dir, repoDir: cat.repoDir || e.dir, pm2: unit ? null : webProcess(app, processes, cat), unit, log: cat.log || null, processes, launchd, deployed: e.deployed || null, status: e.status || null, updated: e.updated || null, monitor: cat.monitor !== false, project: cat.project !== false, }); } return next; } function diffSites(before, after) { const ev = []; const b = new Map(before.map(s => [s.app, s])); const a = new Map(after.map(s => [s.app, s])); for (const [app, s] of a) { const o = b.get(app); if (!o) ev.push({ kind: 'added', app, label: s.label, to: s.node }); else if (o.node !== s.node) ev.push({ kind: 'moved', app, label: s.label, from: o.node, to: s.node }); else if (o.dir !== s.dir || o.port !== s.port) ev.push({ kind: 'changed', app, label: s.label, node: s.node, dir: s.dir, port: s.port }); } for (const [app, o] of b) if (!a.has(app)) ev.push({ kind: 'removed', app, label: o.label, from: o.node }); return ev; } function applyRegistry(data, source, mtime) { const apps = data && data.apps; if (!apps || typeof apps !== 'object' || !Object.keys(apps).length) throw new Error('registre sans apps'); const before = SITES.slice(); const prevSelf = SELF; REG = { apps, updated: data.updated || null, gateway: data.gateway || null, fetchedAt: Date.now(), source, error: null, mtime: mtime || Date.now() }; if (apps['admin-ka'] && apps['admin-ka'].node) SELF = apps['admin-ka'].node; const next = buildSites(); SITES.length = 0; SITES.push(...next); const events = diffSites(before, next); if (prevSelf !== SELF) events.push({ kind: 'self', from: prevSelf, to: SELF }); return events; } // ---------- cache local (poussé par mld) ---------- function readCache() { if (!cachePath || !fs.existsSync(cachePath)) return false; const st = fs.statSync(cachePath); if (st.mtimeMs === REG.mtime && SITES.length) return false; const data = JSON.parse(fs.readFileSync(cachePath, 'utf8')); const events = applyRegistry(data, 'cache:' + cachePath, st.mtimeMs); return events; } function writeCache(text) { const tmp = cachePath + '.tmp'; fs.writeFileSync(tmp, text); fs.renameSync(tmp, cachePath); } // ---------- tirage ssh ---------- function sshCat(host) { return new Promise((resolve) => { execFile('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', host, 'cat ~/dispatch/registry.json'], { timeout: 25_000, maxBuffer: 8 * 1024 * 1024, env: { ...process.env, PATH: ENV_PATH, HOME } }, (err, stdout, stderr) => resolve(err ? { error: (stderr || err.message).trim().slice(0, 200) } : { text: String(stdout) })); }); } export async function refreshRegistry(reason = 'periodic') { let lastErr = null; for (const host of GATEWAY_HOSTS) { if (!sshHosts.has(host) && host !== 'gitsrv') continue; const r = await sshCat(host); if (r.error) { lastErr = `${host}: ${r.error}`; continue; } let data; try { data = JSON.parse(r.text); } catch (e) { lastErr = `${host}: JSON invalide (${e.message})`; continue; } try { const events = applyRegistry(data, `ssh:${host}`, Date.now()); if (cachePath) { try { writeCache(JSON.stringify(data, null, 2)); REG.mtime = fs.statSync(cachePath).mtimeMs; } catch {} } finish(events, reason); return { ok: true, source: `ssh:${host}`, events }; } catch (e) { lastErr = `${host}: ${e.message}`; } } REG.error = lastErr || 'aucun hôte passerelle'; return { ok: false, error: REG.error }; } function finish(events, reason) { if (events && events.length) { for (const ev of events) console.log(`[registre] ${describe(ev)} (${reason})`); try { onChange(events); } catch {} } } export function describe(ev) { switch (ev.kind) { case 'moved': return `${ev.label} a déménagé ${ev.from} → ${ev.to}`; case 'added': return `${ev.label} apparaît dans le registre (${ev.to})`; case 'removed': return `${ev.label} a quitté le registre (était sur ${ev.from})`; case 'changed': return `${ev.label} : répertoire/port changés sur ${ev.node} (${ev.dir}:${ev.port})`; case 'self': return `la console admin-ka tourne maintenant sur ${ev.to} (avant ${ev.from})`; default: return JSON.stringify(ev); } } // ---------- API pour le reste du backend ---------- export function selfNode() { return SELF; } export function getNodes() { const set = new Set(SITES.filter(s => s.monitor).map(s => s.node)); set.add(SELF); return [...set].sort(); } export function ipOf(node) { for (const s of SITES) if (s.node === node && s.ip) return s.ip; for (const e of Object.values(REG.apps || {})) if (e.node === node && e.ip) return e.ip; return null; } // cible ssh d'un nœud : l'alias de ~/.ssh/config s'il existe (noms Bonjour, résistants au // DHCP), sinon user@IP LAN du registre (un nœud jamais vu par cette config reste joignable). export function sshTarget(node) { if (sshHosts.has(node)) return node; const ip = ipOf(node); return ip ? `${USER}@${ip}` : node; } export function getSites() { return SITES.filter(s => s.monitor); } export function getProjects() { return SITES.filter(s => s.project).map(s => ({ id: `${s.app}@${s.node}`, app: s.app, name: s.label, node: s.node === SELF ? null : s.node, dir: s.node === SELF ? expandHome(s.repoDir) : s.repoDir, nodeLabel: s.node, })); } // accepte les anciens ids « app@nœud » même si l'app a déménagé depuis export function findProject(id) { if (!id) return null; const app = String(id).split('@')[0]; return getProjects().find(p => p.app === app) || null; } export function getRegistry() { const age = REG.fetchedAt ? Date.now() - REG.fetchedAt : null; const missing = Object.keys(KA_CATALOG).filter(a => KA_CATALOG[a].monitor !== false && !(REG.apps || {})[a]); return { updated: REG.updated, gateway: REG.gateway, fetchedAt: REG.fetchedAt, source: REG.source, error: REG.error, ageMs: age, stale: age == null || age > STALE_MS, nApps: Object.keys(REG.apps || {}).length, self: SELF, nodes: getNodes(), missing, cache: cachePath, sites: SITES.map(s => ({ app: s.app, label: s.label, node: s.node, ip: s.ip, port: s.port, dir: s.dir, repoDir: s.repoDir, pm2: s.pm2, unit: s.unit, processes: s.processes, status: s.status, deployed: s.deployed, updated: s.updated, domain: s.domain })), }; } // Bloc texte injecté dans les prompts système (upgradeur, orchestrateur) : jamais de table figée. export function topologyText() { const when = REG.updated ? REG.updated.replace('T', ' ') : 'inconnu'; const lines = SITES.map(s => `- ${s.app} → nœud ${s.node}${s.node === SELF ? ' (local)' : ''}, repo ${s.repoDir}${s.pm2 ? `, pm2 ${s.processes.filter(p => !/ngrok$/.test(p)).join('/')}` : s.unit ? `, launchd ${s.unit}` : ''}${s.port ? `, port ${s.port}` : ''}`); return `Emplacements ACTUELS des apps (registre mld de la passerelle M1M32, mis à jour ${when} — les apps peuvent être déplacées par \`mld move\`, ne jamais supposer un nœud de mémoire ; pour vérifier : \`ssh gitsrv cat ~/dispatch/registry.json\`) :\n${lines.join('\n')}`; } // ---------- CLAUDE.md de l'orchestrateur multi-sites (régénéré à chaque changement) ---------- export function orchestratorDoc() { const when = REG.updated ? REG.updated.replace('T', ' ') : 'inconnu'; const rows = SITES.map(s => `| ${s.app.padEnd(10)} | ${s.node.padEnd(7)} | ${s.repoDir.padEnd(34)} | ${s.unit ? `launchd ${s.unit}` : s.processes.filter(p => !/ngrok$/.test(p)).join(', ')} |`).join('\n'); const locals = SITES.filter(s => s.node === SELF).map(s => s.app).join(', ') || '(aucune)'; const nodes = getNodes().filter(n => n !== SELF); return `# 🎛️ Orchestrateur multi-sites Groupe KA Tu es une session Claude Code **orchestratrice** lancée sur le nœud **${SELF}** (là où tourne la console administration-ka.com). Ton rôle : réaliser une tâche qui touche **plusieurs apps du Groupe KA à la fois**, en gardant le **contexte complet** d'un bout à l'autre (contrairement à la diffusion qui lance une session isolée par app). Tu travailles sur les repos de PROD **à distance via SSH** sur le nœud de chaque app. ## Table de déploiement (GÉNÉRÉE depuis le registre mld — ne pas éditer à la main) Source de vérité : \`M1M32:~/dispatch/registry.json\` (mis à jour ${when}). Ce fichier est régénéré par admin-ka à chaque changement du registre ; en cas de doute : \`ssh gitsrv cat ~/dispatch/registry.json\` ou \`ssh M1M32 ~/maclustr-dispatch/bin/mld status\`. | App | Nœud | Répertoire de prod | Process | |------------|---------|------------------------------------|---------| ${rows} - Les apps sur **${SELF}** (${locals}) : tu es déjà dessus → travaille en local (\`cd