@fkom13/mcp-sftp-orchestrator 6.0.0 → 11.3.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,324 @@
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
+
8
+ /**
9
+ * snapshotManager — Versioning d'infrastructure (style gencodedoc).
10
+ *
11
+ * Capture l'état de fichiers/dossiers (local OU remote via sourceAdapter),
12
+ * avec DÉDUPLICATION par contenu : stockage content-addressable façon git
13
+ * (chaque contenu est stocké une seule fois, nommé par son hash SHA-256).
14
+ *
15
+ * Choix technique : PAS de better-sqlite3 (module natif fragile sur Node v24).
16
+ * Stockage 100% fichiers JSON + blobs → robuste, inspectable, zéro dépendance native.
17
+ *
18
+ * <dataDir>/infra_snapshots/
19
+ * ├── objects/<hash[0:2]>/<hash[2:]> ← blobs dédupliqués
20
+ * └── index.json ← métadonnées des snapshots
21
+ *
22
+ * Le stockage est TOUJOURS local (sur l'hôte du MCP), que la source soit
23
+ * local ou remote. Un snapshot remote peut être restauré vers local (backup)
24
+ * et inversement (déploiement).
25
+ */
26
+
27
+ const SNAP_DIR = path.join(config.dataDir, 'infra_snapshots');
28
+ const OBJECTS_DIR = path.join(SNAP_DIR, 'objects');
29
+ const INDEX_FILE = path.join(SNAP_DIR, 'index.json');
30
+
31
+ function sha256(buffer) {
32
+ return crypto.createHash('sha256').update(buffer).digest('hex');
33
+ }
34
+
35
+ async function ensureDirs() {
36
+ await fs.mkdir(OBJECTS_DIR, { recursive: true });
37
+ }
38
+
39
+ async function readIndex() {
40
+ try {
41
+ const data = await fs.readFile(INDEX_FILE, 'utf8');
42
+ return JSON.parse(data);
43
+ } catch {
44
+ return { snapshots: [] };
45
+ }
46
+ }
47
+
48
+ async function writeIndex(index) {
49
+ await ensureDirs();
50
+ await fs.writeFile(INDEX_FILE, JSON.stringify(index, null, 2));
51
+ }
52
+
53
+ function objectPath(hash) {
54
+ return path.join(OBJECTS_DIR, hash.slice(0, 2), hash.slice(2));
55
+ }
56
+
57
+ async function objectExists(hash) {
58
+ try {
59
+ await fs.access(objectPath(hash));
60
+ return true;
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ // Stocke un blob s'il n'existe pas déjà (déduplication). Retourne true si nouveau.
67
+ async function storeObject(hash, buffer) {
68
+ if (await objectExists(hash)) return false;
69
+ const p = objectPath(hash);
70
+ await fs.mkdir(path.dirname(p), { recursive: true });
71
+ await fs.writeFile(p, buffer);
72
+ return true;
73
+ }
74
+
75
+ async function readObject(hash) {
76
+ return fs.readFile(objectPath(hash));
77
+ }
78
+
79
+ export default {
80
+ /**
81
+ * Crée un snapshot. Capture les fichiers listés (fichiers ou dossiers).
82
+ * source = { type:'local'|'remote', path? (ignoré), alias? }
83
+ * paths = liste de chemins ABSOLUS à capturer (fichiers ou dossiers)
84
+ * options = { tag, message, recursive (défaut true), ignorePatterns }
85
+ * Retourne { snapshotId, filesCount, totalBytes, newObjects, dedupedObjects }.
86
+ */
87
+ async createSnapshot(source, paths, options = {}) {
88
+ await ensureDirs();
89
+ const recursive = options.recursive !== false;
90
+ const ignorePatterns = options.ignorePatterns || [];
91
+ const origin = sourceAdapter.describe(source);
92
+
93
+ // 1. Résout la liste complète des fichiers absolus à capturer
94
+ const fileAbsPaths = [];
95
+ for (const p of paths) {
96
+ const kind = await sourceAdapter.exists({ ...source, path: p });
97
+ if (!kind) throw new Error(`Chemin introuvable sur ${origin.server} : ${p}`);
98
+ if (kind === 'd') {
99
+ const rels = await sourceAdapter.listFilesRecursive(
100
+ { ...source, path: p }, { recursive, ignorePatterns }
101
+ );
102
+ const sep = source.type === 'local' ? path.sep : '/';
103
+ for (const rel of rels) {
104
+ fileAbsPaths.push(p.replace(/\/+$/, '') + sep + rel);
105
+ }
106
+ } else {
107
+ fileAbsPaths.push(p);
108
+ }
109
+ }
110
+
111
+ // 2. Lit chaque fichier, stocke le blob (dedup), construit la file map
112
+ const fileMap = [];
113
+ let totalBytes = 0, newObjects = 0, dedupedObjects = 0;
114
+ for (const absPath of fileAbsPaths) {
115
+ const { content, mtime, size } = await sourceAdapter.readFile({ ...source, path: absPath });
116
+ const hash = sha256(content);
117
+ const isNew = await storeObject(hash, content);
118
+ if (isNew) newObjects++; else dedupedObjects++;
119
+ totalBytes += size;
120
+ fileMap.push({ path: absPath, hash, size, mtime });
121
+ }
122
+
123
+ // 3. Enregistre le snapshot dans l'index
124
+ const snapshotId = `snap_${Date.now()}_${crypto.randomBytes(3).toString('hex')}`;
125
+ const index = await readIndex();
126
+ index.snapshots.push({
127
+ id: snapshotId,
128
+ sourceType: source.type,
129
+ sourceAlias: source.type === 'remote' ? source.alias : null,
130
+ server: origin.server,
131
+ timestamp: Date.now(),
132
+ tag: options.tag || null,
133
+ message: options.message || null,
134
+ paths,
135
+ files: fileMap
136
+ });
137
+ await writeIndex(index);
138
+
139
+ return {
140
+ snapshotId,
141
+ server: origin.server,
142
+ filesCount: fileMap.length,
143
+ totalBytes,
144
+ newObjects,
145
+ dedupedObjects,
146
+ tag: options.tag || null
147
+ };
148
+ },
149
+
150
+ /**
151
+ * Liste les snapshots. filter = { sourceType, sourceAlias, tag, limit }
152
+ */
153
+ async listSnapshots(filter = {}) {
154
+ const index = await readIndex();
155
+ let snaps = index.snapshots.slice().reverse(); // plus récents d'abord
156
+
157
+ if (filter.sourceType) snaps = snaps.filter(s => s.sourceType === filter.sourceType);
158
+ if (filter.sourceAlias) snaps = snaps.filter(s => s.sourceAlias === filter.sourceAlias);
159
+ if (filter.tag) snaps = snaps.filter(s => s.tag === filter.tag);
160
+ if (filter.limit) snaps = snaps.slice(0, filter.limit);
161
+
162
+ // Résumé léger (sans la file map complète)
163
+ return snaps.map(s => ({
164
+ id: s.id,
165
+ server: s.server,
166
+ sourceType: s.sourceType,
167
+ tag: s.tag,
168
+ message: s.message,
169
+ timestamp: s.timestamp,
170
+ date: new Date(s.timestamp).toISOString(),
171
+ paths: s.paths,
172
+ filesCount: s.files.length
173
+ }));
174
+ },
175
+
176
+ async _getSnapshot(id) {
177
+ const index = await readIndex();
178
+ const snap = index.snapshots.find(s => s.id === id || s.tag === id);
179
+ if (!snap) throw new Error(`Snapshot '${id}' introuvable. Utilisez snapshot_list.`);
180
+ return snap;
181
+ },
182
+
183
+ /**
184
+ * Détails complets d'un snapshot (avec file map).
185
+ */
186
+ async getSnapshotDetails(id) {
187
+ const snap = await this._getSnapshot(id);
188
+ return snap;
189
+ },
190
+
191
+ /**
192
+ * Compare deux snapshots (par chemin de fichier). Fonctionne même entre
193
+ * un snapshot local et un snapshot remote.
194
+ * options = { includeDiff }
195
+ * Retourne { added[], removed[], modified[{path, hash1, hash2, added, removed, diff?}], identical, stats }.
196
+ */
197
+ async diffSnapshots(id1, id2, options = {}) {
198
+ const includeDiff = options.includeDiff === true;
199
+ const s1 = await this._getSnapshot(id1);
200
+ const s2 = await this._getSnapshot(id2);
201
+
202
+ const map1 = new Map(s1.files.map(f => [f.path, f]));
203
+ const map2 = new Map(s2.files.map(f => [f.path, f]));
204
+
205
+ const added = []; // dans s2, pas dans s1
206
+ const removed = []; // dans s1, pas dans s2
207
+ const modified = [];
208
+ let identical = 0;
209
+
210
+ for (const [p, f1] of map1) {
211
+ if (!map2.has(p)) { removed.push(p); continue; }
212
+ const f2 = map2.get(p);
213
+ if (f1.hash === f2.hash) { identical++; continue; }
214
+ const entry = { path: p, hash1: f1.hash, hash2: f2.hash };
215
+ try {
216
+ const c1 = (await readObject(f1.hash)).toString('utf8');
217
+ const c2 = (await readObject(f2.hash)).toString('utf8');
218
+ const patch = createTwoFilesPatch(`${id1}:${p}`, `${id2}:${p}`, c1, c2, 'snap1', 'snap2');
219
+ let a = 0, r = 0;
220
+ for (const line of patch.split('\n')) {
221
+ if (line.startsWith('+') && !line.startsWith('+++')) a++;
222
+ else if (line.startsWith('-') && !line.startsWith('---')) r++;
223
+ }
224
+ entry.added = a; entry.removed = r;
225
+ if (includeDiff) entry.diff = patch;
226
+ } catch {
227
+ entry.note = 'diff indisponible (binaire ?)';
228
+ }
229
+ modified.push(entry);
230
+ }
231
+ for (const p of map2.keys()) {
232
+ if (!map1.has(p)) added.push(p);
233
+ }
234
+
235
+ return {
236
+ snapshot1: { id: s1.id, server: s1.server, date: new Date(s1.timestamp).toISOString() },
237
+ snapshot2: { id: s2.id, server: s2.server, date: new Date(s2.timestamp).toISOString() },
238
+ added, removed, modified,
239
+ stats: {
240
+ added: added.length,
241
+ removed: removed.length,
242
+ modified: modified.length,
243
+ identical
244
+ }
245
+ };
246
+ },
247
+
248
+ /**
249
+ * Restaure un snapshot vers une cible (local ou remote).
250
+ * target = { type, alias? }
251
+ * options = { paths (filtre préfixe), dryRun (défaut true), force }
252
+ * Retourne { dryRun, restored[], skipped[], errors[] }.
253
+ */
254
+ async restoreSnapshot(id, target, options = {}) {
255
+ const dryRun = options.dryRun !== false;
256
+ const snap = await this._getSnapshot(id);
257
+ const targetOrigin = sourceAdapter.describe({ ...target, path: '/' });
258
+
259
+ let files = snap.files;
260
+ if (options.paths && options.paths.length > 0) {
261
+ files = files.filter(f => options.paths.some(p => f.path === p || f.path.startsWith(p.replace(/\/+$/, '') + '/')));
262
+ }
263
+
264
+ const restored = [], skipped = [], errors = [];
265
+ for (const f of files) {
266
+ if (dryRun) {
267
+ restored.push({ path: f.path, size: f.size });
268
+ continue;
269
+ }
270
+ try {
271
+ const buffer = await readObject(f.hash);
272
+ await sourceAdapter.writeFile({ ...target, path: f.path }, buffer, { createDirs: true });
273
+ restored.push({ path: f.path, size: f.size });
274
+ } catch (e) {
275
+ errors.push({ path: f.path, error: e.message });
276
+ }
277
+ }
278
+
279
+ return {
280
+ dryRun,
281
+ target: targetOrigin.server,
282
+ snapshotId: snap.id,
283
+ restored,
284
+ skipped,
285
+ errors,
286
+ stats: { total: files.length, restored: restored.length, errors: errors.length }
287
+ };
288
+ },
289
+
290
+ /**
291
+ * Supprime un snapshot et nettoie les blobs orphelins (dedup-aware).
292
+ * Retourne { deleted, freedObjects, freedBytes }.
293
+ */
294
+ async deleteSnapshot(id) {
295
+ const index = await readIndex();
296
+ const idx = index.snapshots.findIndex(s => s.id === id || s.tag === id);
297
+ if (idx === -1) throw new Error(`Snapshot '${id}' introuvable.`);
298
+
299
+ const removed = index.snapshots.splice(idx, 1)[0];
300
+ await writeIndex(index);
301
+
302
+ // Collecte les hash encore référencés par les snapshots restants
303
+ const stillUsed = new Set();
304
+ for (const s of index.snapshots) {
305
+ for (const f of s.files) stillUsed.add(f.hash);
306
+ }
307
+
308
+ // Supprime les blobs devenus orphelins
309
+ let freedObjects = 0, freedBytes = 0;
310
+ const uniqueHashes = new Set(removed.files.map(f => f.hash));
311
+ for (const hash of uniqueHashes) {
312
+ if (!stillUsed.has(hash)) {
313
+ try {
314
+ const stat = await fs.stat(objectPath(hash));
315
+ freedBytes += stat.size;
316
+ await fs.unlink(objectPath(hash));
317
+ freedObjects++;
318
+ } catch { /* déjà absent */ }
319
+ }
320
+ }
321
+
322
+ return { deleted: true, snapshotId: removed.id, freedObjects, freedBytes };
323
+ }
324
+ };
@@ -0,0 +1,357 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import crypto from 'crypto';
4
+ import micromatch from 'micromatch';
5
+ import serverManager from './servers.js';
6
+ import sshPool from './sshPool.js';
7
+ import { z } from 'zod';
8
+
9
+ /**
10
+ * sourceAdapter — Abstraction unifiée local/remote.
11
+ *
12
+ * Une "source" est un objet : { type: 'local'|'remote', path, alias? }
13
+ * - type 'local' → utilise fs directement (ZÉRO SSH, sur le PC hôte du MCP)
14
+ * - type 'remote' → réutilise une connexion du POOL SSH (sshPool) et ouvre un
15
+ * canal SFTP dessus (pas de reconnexion TCP/handshake par op).
16
+ *
17
+ * v10.2.0 : le transport remote passe par le pool SSH partagé (perf : plus de
18
+ * reconnexion à chaque opération). Un wrapper promisifié reproduit fidèlement
19
+ * l'API de ssh2-sftp-client précédemment utilisée (get/put/stat/exists/list/mkdir),
20
+ * pour zéro régression sur les couches supérieures (fileOps, diffEngine, snapshots).
21
+ *
22
+ * Toutes les opérations fichiers de haut niveau s'appuient sur ce module.
23
+ */
24
+
25
+ // Schéma Zod partagé pour une "source" (local ou remote). Défini ici pour
26
+ // cohabiter avec le traitement (sourceAdapter) et être importé par server.js.
27
+ const sourceSchema = z.object({
28
+ type: z.enum(['local', 'remote']).describe("'local' = PC hôte du MCP (sans SSH), 'remote' = serveur distant (SFTP)"),
29
+ path: z.string().describe("Chemin absolu du fichier"),
30
+ alias: z.string().optional().describe("Alias du serveur (requis si type='remote')"),
31
+ label: z.string().optional().describe("Nom lisible optionnel pour cette source (ex: 'nginx-prod'). Utile pour comparer des fichiers à des emplacements différents.")
32
+ });
33
+
34
+ // Enrobe le sous-système SFTP brut de ssh2 en une API Promise identique à
35
+ // celle de ssh2-sftp-client (sous-ensemble utilisé ici).
36
+ function promisifySftp(rawSftp) {
37
+ return {
38
+ // Lit un fichier → Buffer
39
+ get(remotePath) {
40
+ return new Promise((resolve, reject) => {
41
+ rawSftp.readFile(remotePath, (err, data) => err ? reject(err) : resolve(data));
42
+ });
43
+ },
44
+ // Écrit un Buffer
45
+ put(buffer, remotePath) {
46
+ return new Promise((resolve, reject) => {
47
+ rawSftp.writeFile(remotePath, buffer, (err) => err ? reject(err) : resolve());
48
+ });
49
+ },
50
+ // stat → { size, modifyTime(ms), accessTime(ms), isDirectory, isFile, mode }
51
+ stat(remotePath) {
52
+ return new Promise((resolve, reject) => {
53
+ rawSftp.stat(remotePath, (err, s) => {
54
+ if (err) return reject(err);
55
+ resolve({
56
+ size: s.size,
57
+ modifyTime: (s.mtime || 0) * 1000, // ssh2 = secondes → ms
58
+ accessTime: (s.atime || 0) * 1000,
59
+ isDirectory: s.isDirectory(),
60
+ isFile: s.isFile(),
61
+ mode: s.mode
62
+ });
63
+ });
64
+ });
65
+ },
66
+ // exists → false | 'd' | 'l' | '-'
67
+ exists(remotePath) {
68
+ return new Promise((resolve) => {
69
+ rawSftp.lstat(remotePath, (err, s) => {
70
+ if (err) return resolve(false);
71
+ if (s.isDirectory()) return resolve('d');
72
+ if (typeof s.isSymbolicLink === 'function' && s.isSymbolicLink()) return resolve('l');
73
+ return resolve('-');
74
+ });
75
+ });
76
+ },
77
+ // list → [{ name, type:'d'|'l'|'-' }]
78
+ list(remotePath) {
79
+ return new Promise((resolve, reject) => {
80
+ rawSftp.readdir(remotePath, (err, entries) => {
81
+ if (err) return reject(err);
82
+ resolve(entries.map(e => {
83
+ let type = '-';
84
+ const a = e.attrs;
85
+ if (a && typeof a.isDirectory === 'function') {
86
+ if (a.isDirectory()) type = 'd';
87
+ else if (typeof a.isSymbolicLink === 'function' && a.isSymbolicLink()) type = 'l';
88
+ } else if (e.longname && e.longname[0] === 'd') {
89
+ type = 'd';
90
+ } else if (e.longname && e.longname[0] === 'l') {
91
+ type = 'l';
92
+ }
93
+ return { name: e.filename, type };
94
+ }));
95
+ });
96
+ });
97
+ },
98
+ // mkdir(dir, recursive) — recursive implémenté (ssh2 brut ne le fait pas)
99
+ async mkdir(dir, recursive) {
100
+ const makeOne = (p) => new Promise((resolve, reject) => {
101
+ rawSftp.mkdir(p, (err) => {
102
+ // ignore "existe déjà" (Failure générique de SFTP)
103
+ if (err && !/failure|exist/i.test(err.message || '')) return reject(err);
104
+ resolve();
105
+ });
106
+ });
107
+ if (!recursive) return makeOne(dir);
108
+ const parts = dir.split('/').filter(Boolean);
109
+ let cur = dir.startsWith('/') ? '' : '.';
110
+ for (const part of parts) {
111
+ cur = cur === '' ? '/' + part : cur + '/' + part;
112
+ await makeOne(cur).catch(() => { /* niveau intermédiaire déjà présent */ });
113
+ }
114
+ }
115
+ };
116
+ }
117
+
118
+ // Réutilise une connexion du pool SSH, ouvre un canal SFTP dessus, exécute fn,
119
+ // ferme le canal (PAS la connexion) et rend la connexion au pool.
120
+ async function withSftp(alias, fn) {
121
+ const serverConfig = await serverManager.getServer(alias);
122
+ const conn = await sshPool.getConnection(alias, serverConfig);
123
+ let rawSftp = null;
124
+ try {
125
+ rawSftp = await new Promise((resolve, reject) => {
126
+ conn.client.sftp((err, sftp) => err ? reject(err) : resolve(sftp));
127
+ });
128
+ return await fn(promisifySftp(rawSftp));
129
+ } finally {
130
+ try { if (rawSftp) rawSftp.end(); } catch (e) { /* canal déjà fermé */ }
131
+ sshPool.releaseConnection(conn.id);
132
+ }
133
+ }
134
+
135
+ // Décrit une source de façon claire (évite que l'IA se perde entre serveurs)
136
+ // Retourne { server, type, path, label }
137
+ // server : 'localhost' (local) ou l'alias du serveur (remote)
138
+ // label : label personnalisé si fourni (source.label), sinon "server:path"
139
+ // Utile quand on compare des fichiers à des emplacements différents
140
+ // (ex: .bashrc sur un VPS vs .zshrc sur un autre → labels "shell-vps1"/"shell-vps2")
141
+ function describe(source) {
142
+ const server = source.type === 'local' ? 'localhost' : source.alias;
143
+ return {
144
+ server,
145
+ type: source.type,
146
+ path: source.path,
147
+ label: source.label || `${server}:${source.path}`
148
+ };
149
+ }
150
+
151
+ // Valide la structure d'une source
152
+ function validateSource(source) {
153
+ if (!source || typeof source !== 'object') {
154
+ throw new Error("Source invalide : objet attendu { type, path, alias? }");
155
+ }
156
+ if (source.type !== 'local' && source.type !== 'remote') {
157
+ throw new Error(`Type de source invalide : '${source.type}'. Attendu 'local' ou 'remote'.`);
158
+ }
159
+ if (!source.path) {
160
+ throw new Error("Source invalide : 'path' est requis.");
161
+ }
162
+ if (source.type === 'remote' && !source.alias) {
163
+ throw new Error("Source remote invalide : 'alias' est requis quand type='remote'.");
164
+ }
165
+ }
166
+
167
+ export { sourceSchema };
168
+
169
+ export default {
170
+ /**
171
+ * Décrit une source : { server, type, path, label }.
172
+ * server = 'localhost' ou alias serveur. Utile pour indiquer clairement
173
+ * l'origine d'un fichier lu (évite la confusion entre serveurs).
174
+ */
175
+ describe,
176
+
177
+ /**
178
+ * Lit un fichier. Retourne { content: Buffer, mtime, size }.
179
+ */
180
+ async readFile(source) {
181
+ validateSource(source);
182
+ if (source.type === 'local') {
183
+ const content = await fs.readFile(source.path);
184
+ const stat = await fs.stat(source.path);
185
+ return { content, mtime: stat.mtimeMs, size: stat.size };
186
+ }
187
+ return withSftp(source.alias, async (sftp) => {
188
+ const content = await sftp.get(source.path); // Buffer
189
+ const stat = await sftp.stat(source.path);
190
+ return { content, mtime: stat.modifyTime, size: stat.size };
191
+ });
192
+ },
193
+
194
+ /**
195
+ * Écrit un fichier. contentBuffer doit être un Buffer.
196
+ * options = { createDirs: bool (défaut true) }
197
+ * Retourne { size }.
198
+ */
199
+ async writeFile(source, contentBuffer, options = {}) {
200
+ validateSource(source);
201
+ const createDirs = options.createDirs !== false;
202
+
203
+ if (source.type === 'local') {
204
+ if (createDirs) {
205
+ await fs.mkdir(path.dirname(source.path), { recursive: true });
206
+ }
207
+ await fs.writeFile(source.path, contentBuffer);
208
+ const stat = await fs.stat(source.path);
209
+ return { size: stat.size };
210
+ }
211
+
212
+ return withSftp(source.alias, async (sftp) => {
213
+ if (createDirs) {
214
+ const dir = path.posix.dirname(source.path);
215
+ if (dir && dir !== '/' && dir !== '.') {
216
+ const exists = await sftp.exists(dir);
217
+ if (!exists) await sftp.mkdir(dir, true);
218
+ }
219
+ }
220
+ await sftp.put(contentBuffer, source.path);
221
+ const stat = await sftp.stat(source.path);
222
+ return { size: stat.size };
223
+ });
224
+ },
225
+
226
+ /**
227
+ * Retourne les stats d'un fichier/dossier (format brut fs ou sftp).
228
+ */
229
+ async stat(source) {
230
+ validateSource(source);
231
+ if (source.type === 'local') {
232
+ return fs.stat(source.path);
233
+ }
234
+ return withSftp(source.alias, async (sftp) => sftp.stat(source.path));
235
+ },
236
+
237
+ /**
238
+ * Vérifie l'existence d'un chemin. Retourne false, 'd', '-', 'l'.
239
+ */
240
+ async exists(source) {
241
+ validateSource(source);
242
+ if (source.type === 'local') {
243
+ try {
244
+ const stat = await fs.stat(source.path);
245
+ return stat.isDirectory() ? 'd' : '-';
246
+ } catch {
247
+ return false;
248
+ }
249
+ }
250
+ return withSftp(source.alias, async (sftp) => sftp.exists(source.path));
251
+ },
252
+
253
+ /**
254
+ * Liste le contenu d'un dossier. Retourne [{ name, type }].
255
+ * type: 'd' (dossier), '-' (fichier), 'l' (lien).
256
+ */
257
+ async listDir(source) {
258
+ validateSource(source);
259
+ if (source.type === 'local') {
260
+ const entries = await fs.readdir(source.path, { withFileTypes: true });
261
+ return entries.map(e => ({
262
+ name: e.name,
263
+ type: e.isDirectory() ? 'd' : e.isSymbolicLink() ? 'l' : '-'
264
+ }));
265
+ }
266
+ return withSftp(source.alias, async (sftp) => {
267
+ const list = await sftp.list(source.path);
268
+ return list.map(e => ({ name: e.name, type: e.type }));
269
+ });
270
+ },
271
+
272
+ /**
273
+ * Liste récursivement tous les FICHIERS d'un dossier.
274
+ * Retourne un tableau de chemins RELATIFS (posix, ex: "conf/nginx.conf").
275
+ * options = { recursive: bool (défaut true), ignorePatterns: string[] }
276
+ * ignorePatterns : motifs glob (micromatch) testés contre chaque chemin relatif
277
+ * ET chaque segment (ex: 'node_modules' ignore tout le dossier).
278
+ *
279
+ * Pour remote : tout le parcours se fait dans UNE SEULE connexion SFTP.
280
+ */
281
+ async listFilesRecursive(source, options = {}) {
282
+ validateSource(source);
283
+ const recursive = options.recursive !== false;
284
+ const ignore = options.ignorePatterns || [];
285
+
286
+ const shouldIgnore = (relPath) => {
287
+ if (ignore.length === 0) return false;
288
+ const segments = relPath.split('/');
289
+ // ignore si le chemin complet matche, ou si un segment matche
290
+ return micromatch.isMatch(relPath, ignore) ||
291
+ segments.some(seg => micromatch.isMatch(seg, ignore));
292
+ };
293
+
294
+ if (source.type === 'local') {
295
+ const results = [];
296
+ const walk = async (absDir, relDir) => {
297
+ const entries = await fs.readdir(absDir, { withFileTypes: true });
298
+ for (const e of entries) {
299
+ const relPath = relDir ? `${relDir}/${e.name}` : e.name;
300
+ if (shouldIgnore(relPath)) continue;
301
+ if (e.isDirectory()) {
302
+ if (recursive) await walk(path.join(absDir, e.name), relPath);
303
+ } else if (e.isFile()) {
304
+ results.push(relPath);
305
+ }
306
+ }
307
+ };
308
+ await walk(source.path, '');
309
+ return results.sort();
310
+ }
311
+
312
+ // Remote : une seule connexion SFTP pour tout le walk
313
+ return withSftp(source.alias, async (sftp) => {
314
+ const results = [];
315
+ const walk = async (absDir, relDir) => {
316
+ const list = await sftp.list(absDir);
317
+ for (const e of list) {
318
+ const relPath = relDir ? `${relDir}/${e.name}` : e.name;
319
+ if (shouldIgnore(relPath)) continue;
320
+ if (e.type === 'd') {
321
+ if (recursive) await walk(path.posix.join(absDir, e.name), relPath);
322
+ } else if (e.type === '-') {
323
+ results.push(relPath);
324
+ }
325
+ }
326
+ };
327
+ await walk(source.path, '');
328
+ return results.sort();
329
+ });
330
+ },
331
+
332
+ /**
333
+ * Lit plusieurs fichiers d'un dossier remote dans UNE connexion SFTP.
334
+ * relPaths : chemins relatifs à basePath. Retourne Map<relPath, {content:Buffer, hash}>.
335
+ * Pour local, lit simplement via fs (pas de coût connexion).
336
+ */
337
+ async readManyHashes(source, basePath, relPaths) {
338
+ validateSource(source);
339
+ const result = new Map();
340
+
341
+ if (source.type === 'local') {
342
+ for (const rel of relPaths) {
343
+ const buf = await fs.readFile(path.join(basePath, rel));
344
+ result.set(rel, { content: buf, hash: crypto.createHash('sha256').update(buf).digest('hex') });
345
+ }
346
+ return result;
347
+ }
348
+
349
+ return withSftp(source.alias, async (sftp) => {
350
+ for (const rel of relPaths) {
351
+ const buf = await sftp.get(path.posix.join(basePath, rel));
352
+ result.set(rel, { content: buf, hash: crypto.createHash('sha256').update(buf).digest('hex') });
353
+ }
354
+ return result;
355
+ });
356
+ }
357
+ };