@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,410 @@
1
+ import fs from 'fs/promises';
2
+ import fsSync from 'fs';
3
+ import path from 'path';
4
+ import crypto from 'crypto';
5
+ import micromatch from 'micromatch';
6
+ import serverManager from './servers.js';
7
+ import sshPool from './sshPool.js';
8
+ import { z } from 'zod';
9
+ import config from './config.js';
10
+
11
+ /**
12
+ * sourceAdapter — Abstraction unifiée local/remote.
13
+ *
14
+ * Une "source" est un objet : { type: 'local'|'remote', path, alias? }
15
+ * - type 'local' → utilise fs directement (ZÉRO SSH, sur le PC hôte du MCP)
16
+ * - type 'remote' → réutilise une connexion du POOL SSH (sshPool) et ouvre un
17
+ * canal SFTP dessus (pas de reconnexion TCP/handshake par op).
18
+ *
19
+ * v10.2.0 : le transport remote passe par le pool SSH partagé (perf : plus de
20
+ * reconnexion à chaque opération). Un wrapper promisifié reproduit fidèlement
21
+ * l'API de ssh2-sftp-client précédemment utilisée (get/put/stat/exists/list/mkdir),
22
+ * pour zéro régression sur les couches supérieures (fileOps, diffEngine, snapshots).
23
+ *
24
+ * Toutes les opérations fichiers de haut niveau s'appuient sur ce module.
25
+ */
26
+
27
+ // Schéma Zod partagé pour une "source" (local ou remote). Défini ici pour
28
+ // cohabiter avec le traitement (sourceAdapter) et être importé par server.js.
29
+ const sourceSchema = z.object({
30
+ type: z.enum(['local', 'remote']).describe("'local' = PC hôte du MCP (sans SSH), 'remote' = serveur distant (SFTP)"),
31
+ path: z.string().describe("Chemin absolu du fichier"),
32
+ alias: z.string().optional().describe("Alias du serveur (requis si type='remote')"),
33
+ label: z.string().optional().describe("Nom lisible optionnel pour cette source (ex: 'nginx-prod'). Utile pour comparer des fichiers à des emplacements différents.")
34
+ });
35
+
36
+ // Enrobe le sous-système SFTP brut de ssh2 en une API Promise identique à
37
+ // celle de ssh2-sftp-client (sous-ensemble utilisé ici).
38
+ function promisifySftp(rawSftp) {
39
+ return {
40
+ // Lit un fichier → Buffer
41
+ get(remotePath) {
42
+ return new Promise((resolve, reject) => {
43
+ rawSftp.readFile(remotePath, (err, data) => err ? reject(err) : resolve(data));
44
+ });
45
+ },
46
+ // Écrit un Buffer
47
+ put(buffer, remotePath) {
48
+ return new Promise((resolve, reject) => {
49
+ rawSftp.writeFile(remotePath, buffer, (err) => err ? reject(err) : resolve());
50
+ });
51
+ },
52
+ // stat → { size, modifyTime(ms), accessTime(ms), isDirectory, isFile, mode }
53
+ stat(remotePath) {
54
+ return new Promise((resolve, reject) => {
55
+ rawSftp.stat(remotePath, (err, s) => {
56
+ if (err) return reject(err);
57
+ resolve({
58
+ size: s.size,
59
+ modifyTime: (s.mtime || 0) * 1000, // ssh2 = secondes → ms
60
+ accessTime: (s.atime || 0) * 1000,
61
+ isDirectory: s.isDirectory(),
62
+ isFile: s.isFile(),
63
+ mode: s.mode
64
+ });
65
+ });
66
+ });
67
+ },
68
+ // exists → false | 'd' | 'l' | '-'
69
+ exists(remotePath) {
70
+ return new Promise((resolve) => {
71
+ rawSftp.lstat(remotePath, (err, s) => {
72
+ if (err) return resolve(false);
73
+ if (s.isDirectory()) return resolve('d');
74
+ if (typeof s.isSymbolicLink === 'function' && s.isSymbolicLink()) return resolve('l');
75
+ return resolve('-');
76
+ });
77
+ });
78
+ },
79
+ // list → [{ name, type:'d'|'l'|'-' }]
80
+ list(remotePath) {
81
+ return new Promise((resolve, reject) => {
82
+ rawSftp.readdir(remotePath, (err, entries) => {
83
+ if (err) return reject(err);
84
+ resolve(entries.map(e => {
85
+ let type = '-';
86
+ const a = e.attrs;
87
+ if (a && typeof a.isDirectory === 'function') {
88
+ if (a.isDirectory()) type = 'd';
89
+ else if (typeof a.isSymbolicLink === 'function' && a.isSymbolicLink()) type = 'l';
90
+ } else if (e.longname && e.longname[0] === 'd') {
91
+ type = 'd';
92
+ } else if (e.longname && e.longname[0] === 'l') {
93
+ type = 'l';
94
+ }
95
+ return { name: e.filename, type };
96
+ }));
97
+ });
98
+ });
99
+ },
100
+ // mkdir(dir, recursive) — recursive implémenté (ssh2 brut ne le fait pas)
101
+ async mkdir(dir, recursive) {
102
+ const makeOne = (p) => new Promise((resolve, reject) => {
103
+ rawSftp.mkdir(p, (err) => {
104
+ // ignore "existe déjà" (Failure générique de SFTP)
105
+ if (err && !/failure|exist/i.test(err.message || '')) return reject(err);
106
+ resolve();
107
+ });
108
+ });
109
+ if (!recursive) return makeOne(dir);
110
+ const parts = dir.split('/').filter(Boolean);
111
+ let cur = dir.startsWith('/') ? '' : '.';
112
+ for (const part of parts) {
113
+ cur = cur === '' ? '/' + part : cur + '/' + part;
114
+ await makeOne(cur).catch(() => { /* niveau intermédiaire déjà présent */ });
115
+ }
116
+ }
117
+ };
118
+ }
119
+
120
+ // Réutilise une connexion du pool SSH, ouvre un canal SFTP dessus, exécute fn,
121
+ // ferme le canal (PAS la connexion) et rend la connexion au pool.
122
+ async function withSftp(alias, fn) {
123
+ const serverConfig = await serverManager.getServer(alias);
124
+ const conn = await sshPool.getConnection(alias, serverConfig);
125
+ let rawSftp = null;
126
+ try {
127
+ rawSftp = await new Promise((resolve, reject) => {
128
+ conn.client.sftp((err, sftp) => err ? reject(err) : resolve(sftp));
129
+ });
130
+ return await fn(promisifySftp(rawSftp));
131
+ } finally {
132
+ try { if (rawSftp) rawSftp.end(); } catch (e) { /* canal déjà fermé */ }
133
+ sshPool.releaseConnection(conn.id);
134
+ }
135
+ }
136
+
137
+ // Décrit une source de façon claire (évite que l'IA se perde entre serveurs)
138
+ // Retourne { server, type, path, label }
139
+ // server : 'localhost' (local) ou l'alias du serveur (remote)
140
+ // label : label personnalisé si fourni (source.label), sinon "server:path"
141
+ // Utile quand on compare des fichiers à des emplacements différents
142
+ // (ex: .bashrc sur un VPS vs .zshrc sur un autre → labels "shell-vps1"/"shell-vps2")
143
+ function describe(source) {
144
+ const server = source.type === 'local' ? 'localhost' : source.alias;
145
+ return {
146
+ server,
147
+ type: source.type,
148
+ path: source.path,
149
+ label: source.label || `${server}:${source.path}`
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Politique transversale des chemins locaux.
155
+ *
156
+ * Historiquement MCP_ALLOWED_ROOTS n'était vérifié que dans fileOps.js, ce qui
157
+ * laissait snapshots/transferts contourner la restriction. La validation vit
158
+ * désormais au niveau sourceAdapter, porte d'entrée commune des accès locaux.
159
+ *
160
+ * La vérification combine :
161
+ * - contrôle lexical du chemin absolu ;
162
+ * - realpath du chemin (ou du parent existant le plus proche) pour empêcher
163
+ * un symlink situé dans une racine autorisée de sortir de cette racine.
164
+ */
165
+ function assertLocalPathAllowed(sourcePath) {
166
+ if (!config.allowedRoots || config.allowedRoots.length === 0) return;
167
+ if (!sourcePath || typeof sourcePath !== 'string') {
168
+ throw new Error("Chemin local invalide.");
169
+ }
170
+
171
+ const requested = path.resolve(sourcePath);
172
+
173
+ function realExistingAnchor(p) {
174
+ let cur = p;
175
+ while (!fsSync.existsSync(cur)) {
176
+ const parent = path.dirname(cur);
177
+ if (parent === cur) break;
178
+ cur = parent;
179
+ }
180
+ try { return fsSync.realpathSync.native(cur); } catch { return path.resolve(cur); }
181
+ }
182
+
183
+ const requestedAnchorReal = realExistingAnchor(requested);
184
+ const allowed = config.allowedRoots.some((root) => {
185
+ const rootAbs = path.resolve(root);
186
+ const lexical = requested === rootAbs || requested.startsWith(rootAbs + path.sep);
187
+ if (!lexical) return false;
188
+ const rootReal = realExistingAnchor(rootAbs);
189
+ return requestedAnchorReal === rootReal || requestedAnchorReal.startsWith(rootReal + path.sep);
190
+ });
191
+
192
+ if (!allowed) {
193
+ throw new Error(
194
+ `Accès local refusé : '${requested}' est hors MCP_ALLOWED_ROOTS (${config.allowedRoots.join(', ')}).`
195
+ );
196
+ }
197
+ }
198
+
199
+ // Valide la structure d'une source
200
+ function validateSource(source) {
201
+ if (!source || typeof source !== 'object') {
202
+ throw new Error("Source invalide : objet attendu { type, path, alias? }");
203
+ }
204
+ if (source.type !== 'local' && source.type !== 'remote') {
205
+ throw new Error(`Type de source invalide : '${source.type}'. Attendu 'local' ou 'remote'.`);
206
+ }
207
+ if (!source.path) {
208
+ throw new Error("Source invalide : 'path' est requis.");
209
+ }
210
+ if (source.type === 'remote' && !source.alias) {
211
+ throw new Error("Source remote invalide : 'alias' est requis quand type='remote'.");
212
+ }
213
+ if (source.type === 'local') {
214
+ assertLocalPathAllowed(source.path);
215
+ }
216
+ }
217
+
218
+ export { sourceSchema, assertLocalPathAllowed };
219
+
220
+ export default {
221
+ assertLocalPathAllowed,
222
+
223
+ /**
224
+ * Décrit une source : { server, type, path, label }.
225
+ * server = 'localhost' ou alias serveur. Utile pour indiquer clairement
226
+ * l'origine d'un fichier lu (évite la confusion entre serveurs).
227
+ */
228
+ describe,
229
+
230
+ /**
231
+ * Lit un fichier. Retourne { content: Buffer, mtime, size }.
232
+ */
233
+ async readFile(source) {
234
+ validateSource(source);
235
+ if (source.type === 'local') {
236
+ const content = await fs.readFile(source.path);
237
+ const stat = await fs.stat(source.path);
238
+ return { content, mtime: stat.mtimeMs, size: stat.size };
239
+ }
240
+ return withSftp(source.alias, async (sftp) => {
241
+ const content = await sftp.get(source.path); // Buffer
242
+ const stat = await sftp.stat(source.path);
243
+ return { content, mtime: stat.modifyTime, size: stat.size };
244
+ });
245
+ },
246
+
247
+ /**
248
+ * Écrit un fichier. contentBuffer doit être un Buffer.
249
+ * options = { createDirs: bool (défaut true) }
250
+ * Retourne { size }.
251
+ */
252
+ async writeFile(source, contentBuffer, options = {}) {
253
+ validateSource(source);
254
+ const createDirs = options.createDirs !== false;
255
+
256
+ if (source.type === 'local') {
257
+ if (createDirs) {
258
+ await fs.mkdir(path.dirname(source.path), { recursive: true });
259
+ }
260
+ await fs.writeFile(source.path, contentBuffer);
261
+ const stat = await fs.stat(source.path);
262
+ return { size: stat.size };
263
+ }
264
+
265
+ return withSftp(source.alias, async (sftp) => {
266
+ if (createDirs) {
267
+ const dir = path.posix.dirname(source.path);
268
+ if (dir && dir !== '/' && dir !== '.') {
269
+ const exists = await sftp.exists(dir);
270
+ if (!exists) await sftp.mkdir(dir, true);
271
+ }
272
+ }
273
+ await sftp.put(contentBuffer, source.path);
274
+ const stat = await sftp.stat(source.path);
275
+ return { size: stat.size };
276
+ });
277
+ },
278
+
279
+ /**
280
+ * Retourne les stats d'un fichier/dossier (format brut fs ou sftp).
281
+ */
282
+ async stat(source) {
283
+ validateSource(source);
284
+ if (source.type === 'local') {
285
+ return fs.stat(source.path);
286
+ }
287
+ return withSftp(source.alias, async (sftp) => sftp.stat(source.path));
288
+ },
289
+
290
+ /**
291
+ * Vérifie l'existence d'un chemin. Retourne false, 'd', '-', 'l'.
292
+ */
293
+ async exists(source) {
294
+ validateSource(source);
295
+ if (source.type === 'local') {
296
+ try {
297
+ const stat = await fs.stat(source.path);
298
+ return stat.isDirectory() ? 'd' : '-';
299
+ } catch {
300
+ return false;
301
+ }
302
+ }
303
+ return withSftp(source.alias, async (sftp) => sftp.exists(source.path));
304
+ },
305
+
306
+ /**
307
+ * Liste le contenu d'un dossier. Retourne [{ name, type }].
308
+ * type: 'd' (dossier), '-' (fichier), 'l' (lien).
309
+ */
310
+ async listDir(source) {
311
+ validateSource(source);
312
+ if (source.type === 'local') {
313
+ const entries = await fs.readdir(source.path, { withFileTypes: true });
314
+ return entries.map(e => ({
315
+ name: e.name,
316
+ type: e.isDirectory() ? 'd' : e.isSymbolicLink() ? 'l' : '-'
317
+ }));
318
+ }
319
+ return withSftp(source.alias, async (sftp) => {
320
+ const list = await sftp.list(source.path);
321
+ return list.map(e => ({ name: e.name, type: e.type }));
322
+ });
323
+ },
324
+
325
+ /**
326
+ * Liste récursivement tous les FICHIERS d'un dossier.
327
+ * Retourne un tableau de chemins RELATIFS (posix, ex: "conf/nginx.conf").
328
+ * options = { recursive: bool (défaut true), ignorePatterns: string[] }
329
+ * ignorePatterns : motifs glob (micromatch) testés contre chaque chemin relatif
330
+ * ET chaque segment (ex: 'node_modules' ignore tout le dossier).
331
+ *
332
+ * Pour remote : tout le parcours se fait dans UNE SEULE connexion SFTP.
333
+ */
334
+ async listFilesRecursive(source, options = {}) {
335
+ validateSource(source);
336
+ const recursive = options.recursive !== false;
337
+ const ignore = options.ignorePatterns || [];
338
+
339
+ const shouldIgnore = (relPath) => {
340
+ if (ignore.length === 0) return false;
341
+ const segments = relPath.split('/');
342
+ // ignore si le chemin complet matche, ou si un segment matche
343
+ return micromatch.isMatch(relPath, ignore) ||
344
+ segments.some(seg => micromatch.isMatch(seg, ignore));
345
+ };
346
+
347
+ if (source.type === 'local') {
348
+ const results = [];
349
+ const walk = async (absDir, relDir) => {
350
+ const entries = await fs.readdir(absDir, { withFileTypes: true });
351
+ for (const e of entries) {
352
+ const relPath = relDir ? `${relDir}/${e.name}` : e.name;
353
+ if (shouldIgnore(relPath)) continue;
354
+ if (e.isDirectory()) {
355
+ if (recursive) await walk(path.join(absDir, e.name), relPath);
356
+ } else if (e.isFile()) {
357
+ results.push(relPath);
358
+ }
359
+ }
360
+ };
361
+ await walk(source.path, '');
362
+ return results.sort();
363
+ }
364
+
365
+ // Remote : une seule connexion SFTP pour tout le walk
366
+ return withSftp(source.alias, async (sftp) => {
367
+ const results = [];
368
+ const walk = async (absDir, relDir) => {
369
+ const list = await sftp.list(absDir);
370
+ for (const e of list) {
371
+ const relPath = relDir ? `${relDir}/${e.name}` : e.name;
372
+ if (shouldIgnore(relPath)) continue;
373
+ if (e.type === 'd') {
374
+ if (recursive) await walk(path.posix.join(absDir, e.name), relPath);
375
+ } else if (e.type === '-') {
376
+ results.push(relPath);
377
+ }
378
+ }
379
+ };
380
+ await walk(source.path, '');
381
+ return results.sort();
382
+ });
383
+ },
384
+
385
+ /**
386
+ * Lit plusieurs fichiers d'un dossier remote dans UNE connexion SFTP.
387
+ * relPaths : chemins relatifs à basePath. Retourne Map<relPath, {content:Buffer, hash}>.
388
+ * Pour local, lit simplement via fs (pas de coût connexion).
389
+ */
390
+ async readManyHashes(source, basePath, relPaths) {
391
+ validateSource(source);
392
+ const result = new Map();
393
+
394
+ if (source.type === 'local') {
395
+ for (const rel of relPaths) {
396
+ const buf = await fs.readFile(path.join(basePath, rel));
397
+ result.set(rel, { content: buf, hash: crypto.createHash('sha256').update(buf).digest('hex') });
398
+ }
399
+ return result;
400
+ }
401
+
402
+ return withSftp(source.alias, async (sftp) => {
403
+ for (const rel of relPaths) {
404
+ const buf = await sftp.get(path.posix.join(basePath, rel));
405
+ result.set(rel, { content: buf, hash: crypto.createHash('sha256').update(buf).digest('hex') });
406
+ }
407
+ return result;
408
+ });
409
+ }
410
+ };