@fkom13/mcp-sftp-orchestrator 6.0.0 → 11.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,265 @@
1
+ import { Client } from 'ssh2';
2
+ import fs from 'fs/promises';
3
+ import crypto from 'crypto';
4
+ import serverManager from './servers.js';
5
+ import policies from './policies.js';
6
+ import utils from './utils.js';
7
+
8
+ /**
9
+ * shellSessions — Sessions shell PTY PERSISTANTES sur serveurs distants.
10
+ *
11
+ * Contrairement à task_exec (client.exec() = nouveau shell isolé à chaque appel),
12
+ * ici on ouvre un vrai shell interactif (client.shell()) maintenu ouvert. Ainsi
13
+ * `cd`, `export`, activations d'environnement, etc. PERSISTENT entre les commandes.
14
+ *
15
+ * === Détection de fin de commande (technique du marqueur echo-agnostique) ===
16
+ * Après chaque commande, on envoie : printf '%s:%s\n' "MARKER" "$?"
17
+ * - La sortie RÉELLE du printf donne "MARKER:0" (exit code résolu en nombre).
18
+ * - La ligne de commande ÉCHO (si echo actif) contient "MARKER:$?" (littéral).
19
+ * - La regex /MARKER:(\d+)/ ne matche QUE la sortie réelle → jamais l'écho.
20
+ * De plus on désactive l'écho du PTY (stty -echo) dès l'ouverture pour un parsing net.
21
+ *
22
+ * Chaque session utilise une connexion SSH DÉDIÉE (pas le pool) car le shell
23
+ * monopolise le canal pour toute sa durée de vie.
24
+ */
25
+
26
+ const DEFAULT_IDLE_TIMEOUT = 30 * 60 * 1000; // 30 min d'inactivité → fermeture auto
27
+ const DEFAULT_CMD_TIMEOUT = 300; // secondes
28
+
29
+ class ShellSessionManager {
30
+ constructor() {
31
+ this.sessions = new Map(); // id → session
32
+ this._startCleanup();
33
+ }
34
+
35
+ async _buildConnectConfig(alias) {
36
+ const sc = await serverManager.getServer(alias);
37
+ const config = {
38
+ host: sc.host,
39
+ port: utils.resolveSshPort(sc),
40
+ username: sc.user,
41
+ readyTimeout: 20000,
42
+ keepaliveInterval: 30000,
43
+ keepaliveCountMax: 3
44
+ };
45
+ if (sc.keyPath) {
46
+ config.privateKey = await fs.readFile(sc.keyPath);
47
+ } else if (sc.password) {
48
+ config.password = sc.password;
49
+ } else {
50
+ throw new Error(`Aucune méthode d'authentification pour le serveur '${alias}'.`);
51
+ }
52
+ return config;
53
+ }
54
+
55
+ /**
56
+ * Crée une session shell persistante.
57
+ * options = { workdir, env: {K:V}, cmdTimeout }
58
+ * Retourne { id, alias, ready }.
59
+ */
60
+ async createSession(alias, options = {}) {
61
+ const config = await this._buildConnectConfig(alias);
62
+ const conn = new Client();
63
+
64
+ await new Promise((resolve, reject) => {
65
+ let settled = false;
66
+ conn.on('ready', () => { if (!settled) { settled = true; resolve(); } });
67
+ conn.on('error', (err) => { if (!settled) { settled = true; reject(err); } });
68
+ conn.connect(config);
69
+ });
70
+
71
+ const stream = await new Promise((resolve, reject) => {
72
+ conn.shell({ term: 'xterm', modes: {} }, (err, s) => err ? reject(err) : resolve(s));
73
+ });
74
+
75
+ const id = `sh_${alias}_${Date.now()}_${crypto.randomBytes(3).toString('hex')}`;
76
+ const session = {
77
+ id, alias, conn, stream,
78
+ buffer: '',
79
+ createdAt: Date.now(),
80
+ lastUsed: Date.now(),
81
+ commandCount: 0,
82
+ busy: false,
83
+ closed: false,
84
+ cmdTimeout: options.cmdTimeout || DEFAULT_CMD_TIMEOUT
85
+ };
86
+
87
+ stream.on('data', (d) => { session.buffer += d.toString('utf8'); });
88
+ stream.on('close', () => { session.closed = true; });
89
+ conn.on('error', () => { session.closed = true; });
90
+
91
+ this.sessions.set(id, session);
92
+
93
+ // Setup : désactive l'écho (parsing net) et vide le prompt
94
+ await this._exec(session, "stty -echo 2>/dev/null; export PS1=''; export PS2=''", 15);
95
+
96
+ // Répertoire de travail initial
97
+ if (options.workdir) {
98
+ const r = await this._exec(session, `cd ${this._shellQuote(options.workdir)}`, 15);
99
+ if (r.exitCode !== 0) {
100
+ this.closeSession(id);
101
+ throw new Error(`Impossible de cd vers '${options.workdir}': ${r.output}`);
102
+ }
103
+ }
104
+
105
+ // Variables d'environnement initiales
106
+ if (options.env && typeof options.env === 'object') {
107
+ for (const [k, v] of Object.entries(options.env)) {
108
+ await this._exec(session, `export ${k}=${this._shellQuote(String(v))}`, 15);
109
+ }
110
+ }
111
+
112
+ return { id, alias, ready: true };
113
+ }
114
+
115
+ _shellQuote(str) {
116
+ // Quote sûr pour bash : entoure de ' et échappe les ' internes
117
+ return `'${String(str).replace(/'/g, `'\\''`)}'`;
118
+ }
119
+
120
+ /**
121
+ * Exécute une commande dans la session et attend le marqueur de fin.
122
+ * Retourne { output, exitCode, timedOut }.
123
+ */
124
+ async _exec(session, command, timeoutSec) {
125
+ const marker = `__ORCH_${crypto.randomBytes(8).toString('hex')}__`;
126
+ const re = new RegExp(`${marker}:(-?\\d+)`);
127
+
128
+ // Vide le buffer juste avant d'écrire (les commandes sont sérialisées via busy)
129
+ session.buffer = '';
130
+
131
+ // Commande utilisateur, puis marqueur avec code de sortie de CETTE commande
132
+ session.stream.write(`${command}\n`);
133
+ session.stream.write(`printf '%s:%s\\n' "${marker}" "$?"\n`);
134
+
135
+ const timeoutMs = timeoutSec === 0 ? 0 : (timeoutSec || DEFAULT_CMD_TIMEOUT) * 1000;
136
+ const start = Date.now();
137
+
138
+ return new Promise((resolve) => {
139
+ const check = setInterval(() => {
140
+ const m = session.buffer.match(re);
141
+ if (m) {
142
+ clearInterval(check);
143
+ const exitCode = parseInt(m[1], 10);
144
+ const raw = session.buffer.slice(0, m.index);
145
+ resolve({ output: this._clean(raw, marker).trim(), exitCode, timedOut: false });
146
+ } else if (session.closed) {
147
+ clearInterval(check);
148
+ resolve({ output: this._clean(session.buffer, marker).trim(), exitCode: null, timedOut: false, closed: true });
149
+ } else if (timeoutMs > 0 && Date.now() - start > timeoutMs) {
150
+ clearInterval(check);
151
+ // Tente de débloquer la commande (Ctrl-C) sans tuer la session
152
+ try { session.stream.write('\x03'); } catch (e) { /* ignore */ }
153
+ resolve({ output: this._clean(session.buffer, marker).trim(), exitCode: null, timedOut: true });
154
+ }
155
+ }, 100);
156
+ });
157
+ }
158
+
159
+ // Nettoie la sortie : strip séquences ANSI (couleurs, bracketed-paste \x1b[?2004h/l),
160
+ // strip les \r du PTY, retire les lignes écho/marqueur.
161
+ _clean(output, marker) {
162
+ return output
163
+ // eslint-disable-next-line no-control-regex
164
+ .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '') // séquences CSI ANSI
165
+ .replace(/\r/g, '')
166
+ .split('\n')
167
+ .filter(line => !line.includes(marker) && !line.includes("printf '%s:%s"))
168
+ .join('\n');
169
+ }
170
+
171
+ /**
172
+ * Exécute une commande dans une session existante (état persistant).
173
+ * options = { timeoutSec, skip_policy }
174
+ */
175
+ async execInSession(id, command, timeoutSec, options = {}) {
176
+ // Rétrocompat : 3e arg peut être un number (timeout) ou un objet options
177
+ let opts = options;
178
+ let timeout = timeoutSec;
179
+ if (typeof timeoutSec === 'object' && timeoutSec !== null) {
180
+ opts = timeoutSec;
181
+ timeout = opts.timeoutSec ?? opts.timeout;
182
+ }
183
+
184
+ const session = this.sessions.get(id);
185
+ if (!session) throw new Error(`Session '${id}' introuvable. Utilisez shell_list pour voir les sessions actives.`);
186
+ if (session.closed) {
187
+ this.sessions.delete(id);
188
+ throw new Error(`Session '${id}' est fermée (connexion perdue). Créez-en une nouvelle avec shell_create.`);
189
+ }
190
+ if (session.busy) throw new Error(`Session '${id}' occupée : une seule commande à la fois par session.`);
191
+
192
+ if (!opts.skip_policy) {
193
+ const policyCheck = await policies.check(command);
194
+ if (policyCheck.blocked) {
195
+ throw new Error(
196
+ `Commande bloquée par la politique de sécurité (pattern: "${policyCheck.pattern}"). ` +
197
+ `Utilisez skip_policy:true pour forcer.`
198
+ );
199
+ }
200
+ }
201
+
202
+ session.busy = true;
203
+ session.lastUsed = Date.now();
204
+ session.commandCount++;
205
+ try {
206
+ return await this._exec(session, command, timeout ?? session.cmdTimeout);
207
+ } finally {
208
+ session.busy = false;
209
+ }
210
+ }
211
+
212
+ /** Retourne les métadonnées minimales d'une session sans exposer la connexion. */
213
+ getSessionInfo(id) {
214
+ const s = this.sessions.get(id);
215
+ if (!s) return null;
216
+ return { id: s.id, alias: s.alias, closed: s.closed, busy: s.busy };
217
+ }
218
+
219
+ /**
220
+ * Liste les sessions actives avec leur état.
221
+ */
222
+ listSessions() {
223
+ const now = Date.now();
224
+ return Array.from(this.sessions.values()).map(s => ({
225
+ id: s.id,
226
+ alias: s.alias,
227
+ ageSeconds: Math.round((now - s.createdAt) / 1000),
228
+ idleSeconds: Math.round((now - s.lastUsed) / 1000),
229
+ commandCount: s.commandCount,
230
+ busy: s.busy,
231
+ closed: s.closed
232
+ }));
233
+ }
234
+
235
+ /**
236
+ * Ferme proprement une session.
237
+ */
238
+ closeSession(id) {
239
+ const session = this.sessions.get(id);
240
+ if (!session) throw new Error(`Session '${id}' introuvable.`);
241
+ try { session.stream.end('exit\n'); } catch (e) { /* ignore */ }
242
+ try { session.conn.end(); } catch (e) { /* ignore */ }
243
+ this.sessions.delete(id);
244
+ return { closed: true, id };
245
+ }
246
+
247
+ _startCleanup() {
248
+ setInterval(() => {
249
+ const now = Date.now();
250
+ for (const [id, s] of this.sessions) {
251
+ if (s.closed || (!s.busy && (now - s.lastUsed) > DEFAULT_IDLE_TIMEOUT)) {
252
+ try { this.closeSession(id); } catch (e) { /* ignore */ }
253
+ }
254
+ }
255
+ }, 60000);
256
+ }
257
+
258
+ closeAll() {
259
+ for (const id of [...this.sessions.keys()]) {
260
+ try { this.closeSession(id); } catch (e) { /* ignore */ }
261
+ }
262
+ }
263
+ }
264
+
265
+ export default new ShellSessionManager();
@@ -0,0 +1,320 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import crypto from 'crypto';
4
+ import { createTwoFilesPatch } from 'diff';
5
+ import config from './config.js';
6
+ import sourceAdapter from './sourceAdapter.js';
7
+ import jsonStore from './atomicJsonStore.js';
8
+
9
+ /**
10
+ * snapshotManager — Versioning d'infrastructure (style gencodedoc).
11
+ *
12
+ * Capture l'état de fichiers/dossiers (local OU remote via sourceAdapter),
13
+ * avec DÉDUPLICATION par contenu : stockage content-addressable façon git
14
+ * (chaque contenu est stocké une seule fois, nommé par son hash SHA-256).
15
+ *
16
+ * Choix technique : PAS de better-sqlite3 (module natif fragile sur Node v24).
17
+ * Stockage 100% fichiers JSON + blobs → robuste, inspectable, zéro dépendance native.
18
+ *
19
+ * <dataDir>/infra_snapshots/
20
+ * ├── objects/<hash[0:2]>/<hash[2:]> ← blobs dédupliqués
21
+ * └── index.json ← métadonnées des snapshots
22
+ *
23
+ * Le stockage est TOUJOURS local (sur l'hôte du MCP), que la source soit
24
+ * local ou remote. Un snapshot remote peut être restauré vers local (backup)
25
+ * et inversement (déploiement).
26
+ */
27
+
28
+ const SNAP_DIR = path.join(config.dataDir, 'infra_snapshots');
29
+ const OBJECTS_DIR = path.join(SNAP_DIR, 'objects');
30
+ const INDEX_FILE = path.join(SNAP_DIR, 'index.json');
31
+
32
+ function sha256(buffer) {
33
+ return crypto.createHash('sha256').update(buffer).digest('hex');
34
+ }
35
+
36
+ async function ensureDirs() {
37
+ await fs.mkdir(OBJECTS_DIR, { recursive: true });
38
+ }
39
+
40
+ async function readIndex() {
41
+ return jsonStore.readJson(INDEX_FILE, { snapshots: [] });
42
+ }
43
+
44
+ async function writeIndex(index) {
45
+ await ensureDirs();
46
+ await jsonStore.writeJsonAtomic(INDEX_FILE, index);
47
+ }
48
+
49
+ function objectPath(hash) {
50
+ return path.join(OBJECTS_DIR, hash.slice(0, 2), hash.slice(2));
51
+ }
52
+
53
+ async function objectExists(hash) {
54
+ try {
55
+ await fs.access(objectPath(hash));
56
+ return true;
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ // Stocke un blob s'il n'existe pas déjà (déduplication). Retourne true si nouveau.
63
+ async function storeObject(hash, buffer) {
64
+ if (await objectExists(hash)) return false;
65
+ const p = objectPath(hash);
66
+ await fs.mkdir(path.dirname(p), { recursive: true });
67
+ await fs.writeFile(p, buffer);
68
+ return true;
69
+ }
70
+
71
+ async function readObject(hash) {
72
+ return fs.readFile(objectPath(hash));
73
+ }
74
+
75
+ export default {
76
+ /**
77
+ * Crée un snapshot. Capture les fichiers listés (fichiers ou dossiers).
78
+ * source = { type:'local'|'remote', path? (ignoré), alias? }
79
+ * paths = liste de chemins ABSOLUS à capturer (fichiers ou dossiers)
80
+ * options = { tag, message, recursive (défaut true), ignorePatterns }
81
+ * Retourne { snapshotId, filesCount, totalBytes, newObjects, dedupedObjects }.
82
+ */
83
+ async createSnapshot(source, paths, options = {}) {
84
+ await ensureDirs();
85
+ const recursive = options.recursive !== false;
86
+ const ignorePatterns = options.ignorePatterns || [];
87
+ const origin = sourceAdapter.describe(source);
88
+
89
+ // 1. Résout la liste complète des fichiers absolus à capturer
90
+ const fileAbsPaths = [];
91
+ for (const p of paths) {
92
+ const kind = await sourceAdapter.exists({ ...source, path: p });
93
+ if (!kind) throw new Error(`Chemin introuvable sur ${origin.server} : ${p}`);
94
+ if (kind === 'd') {
95
+ const rels = await sourceAdapter.listFilesRecursive(
96
+ { ...source, path: p }, { recursive, ignorePatterns }
97
+ );
98
+ const sep = source.type === 'local' ? path.sep : '/';
99
+ for (const rel of rels) {
100
+ fileAbsPaths.push(p.replace(/\/+$/, '') + sep + rel);
101
+ }
102
+ } else {
103
+ fileAbsPaths.push(p);
104
+ }
105
+ }
106
+
107
+ // 2. Lit chaque fichier, stocke le blob (dedup), construit la file map
108
+ const fileMap = [];
109
+ let totalBytes = 0, newObjects = 0, dedupedObjects = 0;
110
+ for (const absPath of fileAbsPaths) {
111
+ const { content, mtime, size } = await sourceAdapter.readFile({ ...source, path: absPath });
112
+ const hash = sha256(content);
113
+ const isNew = await storeObject(hash, content);
114
+ if (isNew) newObjects++; else dedupedObjects++;
115
+ totalBytes += size;
116
+ fileMap.push({ path: absPath, hash, size, mtime });
117
+ }
118
+
119
+ // 3. Enregistre le snapshot dans l'index
120
+ const snapshotId = `snap_${Date.now()}_${crypto.randomBytes(3).toString('hex')}`;
121
+ const index = await readIndex();
122
+ index.snapshots.push({
123
+ id: snapshotId,
124
+ sourceType: source.type,
125
+ sourceAlias: source.type === 'remote' ? source.alias : null,
126
+ server: origin.server,
127
+ timestamp: Date.now(),
128
+ tag: options.tag || null,
129
+ message: options.message || null,
130
+ paths,
131
+ files: fileMap
132
+ });
133
+ await writeIndex(index);
134
+
135
+ return {
136
+ snapshotId,
137
+ server: origin.server,
138
+ filesCount: fileMap.length,
139
+ totalBytes,
140
+ newObjects,
141
+ dedupedObjects,
142
+ tag: options.tag || null
143
+ };
144
+ },
145
+
146
+ /**
147
+ * Liste les snapshots. filter = { sourceType, sourceAlias, tag, limit }
148
+ */
149
+ async listSnapshots(filter = {}) {
150
+ const index = await readIndex();
151
+ let snaps = index.snapshots.slice().reverse(); // plus récents d'abord
152
+
153
+ if (filter.sourceType) snaps = snaps.filter(s => s.sourceType === filter.sourceType);
154
+ if (filter.sourceAlias) snaps = snaps.filter(s => s.sourceAlias === filter.sourceAlias);
155
+ if (filter.tag) snaps = snaps.filter(s => s.tag === filter.tag);
156
+ if (filter.limit) snaps = snaps.slice(0, filter.limit);
157
+
158
+ // Résumé léger (sans la file map complète)
159
+ return snaps.map(s => ({
160
+ id: s.id,
161
+ server: s.server,
162
+ sourceType: s.sourceType,
163
+ tag: s.tag,
164
+ message: s.message,
165
+ timestamp: s.timestamp,
166
+ date: new Date(s.timestamp).toISOString(),
167
+ paths: s.paths,
168
+ filesCount: s.files.length
169
+ }));
170
+ },
171
+
172
+ async _getSnapshot(id) {
173
+ const index = await readIndex();
174
+ const snap = index.snapshots.find(s => s.id === id || s.tag === id);
175
+ if (!snap) throw new Error(`Snapshot '${id}' introuvable. Utilisez snapshot_list.`);
176
+ return snap;
177
+ },
178
+
179
+ /**
180
+ * Détails complets d'un snapshot (avec file map).
181
+ */
182
+ async getSnapshotDetails(id) {
183
+ const snap = await this._getSnapshot(id);
184
+ return snap;
185
+ },
186
+
187
+ /**
188
+ * Compare deux snapshots (par chemin de fichier). Fonctionne même entre
189
+ * un snapshot local et un snapshot remote.
190
+ * options = { includeDiff }
191
+ * Retourne { added[], removed[], modified[{path, hash1, hash2, added, removed, diff?}], identical, stats }.
192
+ */
193
+ async diffSnapshots(id1, id2, options = {}) {
194
+ const includeDiff = options.includeDiff === true;
195
+ const s1 = await this._getSnapshot(id1);
196
+ const s2 = await this._getSnapshot(id2);
197
+
198
+ const map1 = new Map(s1.files.map(f => [f.path, f]));
199
+ const map2 = new Map(s2.files.map(f => [f.path, f]));
200
+
201
+ const added = []; // dans s2, pas dans s1
202
+ const removed = []; // dans s1, pas dans s2
203
+ const modified = [];
204
+ let identical = 0;
205
+
206
+ for (const [p, f1] of map1) {
207
+ if (!map2.has(p)) { removed.push(p); continue; }
208
+ const f2 = map2.get(p);
209
+ if (f1.hash === f2.hash) { identical++; continue; }
210
+ const entry = { path: p, hash1: f1.hash, hash2: f2.hash };
211
+ try {
212
+ const c1 = (await readObject(f1.hash)).toString('utf8');
213
+ const c2 = (await readObject(f2.hash)).toString('utf8');
214
+ const patch = createTwoFilesPatch(`${id1}:${p}`, `${id2}:${p}`, c1, c2, 'snap1', 'snap2');
215
+ let a = 0, r = 0;
216
+ for (const line of patch.split('\n')) {
217
+ if (line.startsWith('+') && !line.startsWith('+++')) a++;
218
+ else if (line.startsWith('-') && !line.startsWith('---')) r++;
219
+ }
220
+ entry.added = a; entry.removed = r;
221
+ if (includeDiff) entry.diff = patch;
222
+ } catch {
223
+ entry.note = 'diff indisponible (binaire ?)';
224
+ }
225
+ modified.push(entry);
226
+ }
227
+ for (const p of map2.keys()) {
228
+ if (!map1.has(p)) added.push(p);
229
+ }
230
+
231
+ return {
232
+ snapshot1: { id: s1.id, server: s1.server, date: new Date(s1.timestamp).toISOString() },
233
+ snapshot2: { id: s2.id, server: s2.server, date: new Date(s2.timestamp).toISOString() },
234
+ added, removed, modified,
235
+ stats: {
236
+ added: added.length,
237
+ removed: removed.length,
238
+ modified: modified.length,
239
+ identical
240
+ }
241
+ };
242
+ },
243
+
244
+ /**
245
+ * Restaure un snapshot vers une cible (local ou remote).
246
+ * target = { type, alias? }
247
+ * options = { paths (filtre préfixe), dryRun (défaut true), force }
248
+ * Retourne { dryRun, restored[], skipped[], errors[] }.
249
+ */
250
+ async restoreSnapshot(id, target, options = {}) {
251
+ const dryRun = options.dryRun !== false;
252
+ const snap = await this._getSnapshot(id);
253
+ const targetOrigin = sourceAdapter.describe({ ...target, path: '/' });
254
+
255
+ let files = snap.files;
256
+ if (options.paths && options.paths.length > 0) {
257
+ files = files.filter(f => options.paths.some(p => f.path === p || f.path.startsWith(p.replace(/\/+$/, '') + '/')));
258
+ }
259
+
260
+ const restored = [], skipped = [], errors = [];
261
+ for (const f of files) {
262
+ if (dryRun) {
263
+ restored.push({ path: f.path, size: f.size });
264
+ continue;
265
+ }
266
+ try {
267
+ const buffer = await readObject(f.hash);
268
+ await sourceAdapter.writeFile({ ...target, path: f.path }, buffer, { createDirs: true });
269
+ restored.push({ path: f.path, size: f.size });
270
+ } catch (e) {
271
+ errors.push({ path: f.path, error: e.message });
272
+ }
273
+ }
274
+
275
+ return {
276
+ dryRun,
277
+ target: targetOrigin.server,
278
+ snapshotId: snap.id,
279
+ restored,
280
+ skipped,
281
+ errors,
282
+ stats: { total: files.length, restored: restored.length, errors: errors.length }
283
+ };
284
+ },
285
+
286
+ /**
287
+ * Supprime un snapshot et nettoie les blobs orphelins (dedup-aware).
288
+ * Retourne { deleted, freedObjects, freedBytes }.
289
+ */
290
+ async deleteSnapshot(id) {
291
+ const index = await readIndex();
292
+ const idx = index.snapshots.findIndex(s => s.id === id || s.tag === id);
293
+ if (idx === -1) throw new Error(`Snapshot '${id}' introuvable.`);
294
+
295
+ const removed = index.snapshots.splice(idx, 1)[0];
296
+ await writeIndex(index);
297
+
298
+ // Collecte les hash encore référencés par les snapshots restants
299
+ const stillUsed = new Set();
300
+ for (const s of index.snapshots) {
301
+ for (const f of s.files) stillUsed.add(f.hash);
302
+ }
303
+
304
+ // Supprime les blobs devenus orphelins
305
+ let freedObjects = 0, freedBytes = 0;
306
+ const uniqueHashes = new Set(removed.files.map(f => f.hash));
307
+ for (const hash of uniqueHashes) {
308
+ if (!stillUsed.has(hash)) {
309
+ try {
310
+ const stat = await fs.stat(objectPath(hash));
311
+ freedBytes += stat.size;
312
+ await fs.unlink(objectPath(hash));
313
+ freedObjects++;
314
+ } catch { /* déjà absent */ }
315
+ }
316
+ }
317
+
318
+ return { deleted: true, snapshotId: removed.id, freedObjects, freedBytes };
319
+ }
320
+ };