@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.
package/sftp.js CHANGED
@@ -4,6 +4,8 @@ import path from 'path';
4
4
  import { glob } from 'glob';
5
5
  import queue from './queue.js';
6
6
  import serverManager from './servers.js';
7
+ import sourceAdapter from './sourceAdapter.js';
8
+ import fileOps from './fileOps.js';
7
9
  import micromatch from 'micromatch';
8
10
 
9
11
  // Fonction utilitaire pour créer un dossier parent si nécessaire
@@ -17,20 +19,31 @@ async function ensureLocalDir(filePath) {
17
19
  }
18
20
  }
19
21
 
22
+ function hasGlobPattern(str) {
23
+ return /[*?[\]]/.test(str);
24
+ }
25
+
20
26
  // Fonction pour créer un dossier distant si nécessaire
21
27
  async function ensureRemoteDir(sftp, filePath) {
22
- const dir = path.dirname(filePath);
28
+ const dir = path.posix.dirname(filePath); // Utiliser posix pour les chemins distants
29
+
30
+ if (dir === '/' || dir === '.') {
31
+ return; // Pas besoin de créer la racine
32
+ }
33
+
23
34
  try {
24
35
  const exists = await sftp.exists(dir);
25
36
  if (!exists) {
26
- await sftp.mkdir(dir, true);
37
+ await sftp.mkdir(dir, true); // true pour créer récursivement
27
38
  queue.log('info', `Dossier distant créé: ${dir}`);
28
39
  }
29
40
  } catch (err) {
30
- // Si le dossier existe déjà, on ignore l'erreur
31
- if (!err.message.includes('already exists')) {
32
- throw err;
41
+ // Vérifier si c'est vraiment une erreur ou si le dossier existe déjà
42
+ const exists = await sftp.exists(dir).catch(() => false);
43
+ if (!exists) {
44
+ throw new Error(`Impossible de créer le dossier distant ${dir}: ${err.message}`);
33
45
  }
46
+ // Sinon, le dossier existe, on continue
34
47
  }
35
48
  }
36
49
 
@@ -58,13 +71,13 @@ async function expandFileList(pattern, basePath = '') {
58
71
 
59
72
  // Si c'est un pattern glob
60
73
  if (pattern.includes('*') || pattern.includes('?') || pattern.includes('[')) {
61
- const fullPattern = path.join(normalizedBasePath, pattern);
74
+ const fullPattern = path.resolve(normalizedBasePath, pattern);
62
75
  const files = await glob(fullPattern, { nodir: false });
63
76
  return files;
64
77
  }
65
78
 
66
79
  // Sinon c'est un fichier/dossier simple
67
- const fullPath = path.join(normalizedBasePath, pattern);
80
+ const fullPath = path.resolve(normalizedBasePath, pattern);
68
81
  return [fullPath];
69
82
  }
70
83
 
@@ -104,29 +117,41 @@ async function executeTransfer(jobId) {
104
117
  // Déterminer si on traite plusieurs fichiers
105
118
  const files = job.files || [{ local: job.local, remote: job.remote }];
106
119
  const isMultiple = Array.isArray(job.files) && job.files.length > 1;
107
-
120
+ const force = job.force === true;
121
+
108
122
  let successCount = 0;
109
123
  let failedFiles = [];
110
124
  let totalFiles = 0;
111
-
112
- // Traiter chaque fichier
125
+
113
126
  for (let i = 0; i < files.length; i++) {
114
127
  const file = files[i];
115
128
  const progress = isMultiple ? ` (${i + 1}/${files.length})` : '';
116
-
129
+
117
130
  try {
118
131
  if (job.direction === 'upload') {
119
132
  const localFiles = await expandFileList(file.local);
120
133
  totalFiles += localFiles.length;
121
134
  for (const localFile of localFiles) {
122
135
  queue.log('info', `Transfert${progress}: ${localFile}`);
123
- await handleUpload(sftp, localFile, file.remote);
136
+ await handleUpload(sftp, localFile, file.remote, force);
124
137
  successCount++;
125
138
  }
126
139
  } else if (job.direction === 'download') {
127
- const downloadedCount = await handleDownload(sftp, file.remote, file.local);
140
+ const downloadedCount = await handleDownload(sftp, file.remote, file.local, force);
128
141
  totalFiles += downloadedCount;
129
142
  successCount += downloadedCount;
143
+ } else if (job.direction === 'server_to_server') {
144
+ // Transfert direct entre deux serveurs distants (ou local↔remote inversé)
145
+ const srcAlias = file.source_alias || job.source_alias;
146
+ const srcPath = file.source_path || file.local || file.remote;
147
+ const tgtAlias = job.alias;
148
+ const tgtPath = file.remote;
149
+
150
+ queue.log('info', `Transfert server_to_server${progress}: ${srcAlias}:${srcPath} → ${tgtAlias}:${tgtPath}`);
151
+ const buf = await sourceAdapter.readFile({ type: 'remote', alias: srcAlias, path: srcPath });
152
+ await sourceAdapter.writeFile({ type: 'remote', alias: tgtAlias, path: tgtPath }, buf.content, { createDirs: true });
153
+ successCount++;
154
+ totalFiles++;
130
155
  }
131
156
  } catch (err) {
132
157
  queue.log('error', `Échec transfert ${file.local || file.remote}: ${err.message}`);
@@ -161,31 +186,63 @@ async function executeTransfer(jobId) {
161
186
  }
162
187
 
163
188
  // Gestion spécifique de l'upload
164
- async function handleUpload(sftp, localPath, remotePath) {
189
+ async function handleUpload(sftp, localPath, remotePath, force = false) {
190
+ let localStats;
165
191
  try {
166
- const stats = await fs.stat(localPath);
167
-
168
- if (stats.isDirectory()) {
169
- // Upload d'un dossier entier
170
- await sftp.uploadDir(localPath, remotePath);
171
- } else {
172
- // Upload d'un fichier - créer le dossier parent si nécessaire
173
- await ensureRemoteDir(sftp, remotePath);
174
- await sftp.put(localPath, remotePath);
175
- }
192
+ localStats = await fs.stat(localPath);
176
193
  } catch (err) {
177
- // Si le fichier local n'existe pas
178
194
  if (err.code === 'ENOENT') {
179
195
  throw new Error(`Fichier local introuvable: ${localPath}`);
180
196
  }
181
197
  throw err;
182
198
  }
199
+
200
+ if (localStats.isDirectory()) {
201
+ const remoteExists = await sftp.exists(remotePath).catch(() => false);
202
+ if (remoteExists) {
203
+ try {
204
+ const remoteStats = await sftp.stat(remotePath);
205
+ if (!remoteStats.isDirectory) {
206
+ throw new Error(`Impossible d'envoyer un dossier local vers un fichier distant existant: ${remotePath}. Spécifiez un dossier de destination.`);
207
+ }
208
+ } catch (e) {
209
+ if (e.message && e.message.includes('Impossible d')) throw e;
210
+ }
211
+ }
212
+ await sftp.uploadDir(localPath, remotePath);
213
+ return;
214
+ }
215
+
216
+ let finalRemotePath;
217
+ const remoteExists = await sftp.exists(remotePath).catch(() => false);
218
+
219
+ if (remoteExists) {
220
+ let remoteStats;
221
+ try {
222
+ remoteStats = await sftp.stat(remotePath);
223
+ } catch (e) {
224
+ remoteStats = null;
225
+ }
226
+
227
+ if (remoteStats && remoteStats.isDirectory) {
228
+ finalRemotePath = path.posix.join(remotePath, path.basename(localPath));
229
+ } else {
230
+ if (!force) {
231
+ throw new Error(`Le fichier distant ${remotePath} existe déjà. Utilisez force:true pour l'écraser.`);
232
+ }
233
+ finalRemotePath = remotePath;
234
+ }
235
+ } else {
236
+ finalRemotePath = remotePath;
237
+ }
238
+
239
+ await ensureRemoteDir(sftp, finalRemotePath);
240
+ await sftp.put(localPath, finalRemotePath);
183
241
  }
184
242
 
185
243
  // Gestion spécifique du download
186
- async function handleDownload(sftp, remotePath, localPath) {
187
- // Si le chemin distant contient un glob
188
- if (micromatch.isMatch(remotePath, '**/*')) {
244
+ async function handleDownload(sftp, remotePath, localPath, force = false) {
245
+ if (hasGlobPattern(remotePath)) {
189
246
  const parentDir = path.dirname(remotePath);
190
247
  const pattern = path.basename(remotePath);
191
248
 
@@ -197,20 +254,22 @@ async function handleDownload(sftp, remotePath, localPath) {
197
254
  throw new Error(`Aucun fichier distant ne correspond au pattern: ${remotePath}`);
198
255
  }
199
256
 
200
- // S'assurer que le dossier local existe
201
- await ensureLocalDir(localPath);
257
+ await fs.mkdir(localPath, { recursive: true });
202
258
 
203
- // Télécharger chaque fichier correspondant
204
259
  for (const fileName of matchingFiles) {
205
260
  const remoteFile = path.join(parentDir, fileName);
206
261
  const localFile = path.join(localPath, fileName);
262
+
263
+ const localFileExists = await fs.access(localFile).then(() => true).catch(() => false);
264
+ if (localFileExists && !force) {
265
+ throw new Error(`Le fichier local ${localFile} existe déjà. Utilisez force:true pour l'écraser.`);
266
+ }
267
+
207
268
  queue.log('info', `Téléchargement (glob): ${remoteFile} -> ${localFile}`);
208
269
  await sftp.get(remoteFile, localFile);
209
270
  }
210
- // Retourner le nombre de fichiers téléchargés pour le rapport
211
271
  return matchingFiles.length;
212
272
  } catch (err) {
213
- // Si le dossier parent n'existe pas, on propage l'erreur
214
273
  if (err.code === 2) {
215
274
  throw new Error(`Le dossier parent pour le glob n'existe pas: ${parentDir}`);
216
275
  }
@@ -218,21 +277,52 @@ async function handleDownload(sftp, remotePath, localPath) {
218
277
  }
219
278
 
220
279
  } else {
221
- // Comportement normal pour un fichier ou un dossier unique
222
- const exists = await sftp.exists(remotePath);
223
- if (!exists) {
280
+ const remoteExists = await sftp.exists(remotePath);
281
+ if (!remoteExists) {
224
282
  throw new Error(`Fichier distant introuvable: ${remotePath}`);
225
283
  }
226
284
 
227
- const stats = await sftp.stat(remotePath);
228
- await ensureLocalDir(localPath);
285
+ const remoteStats = await sftp.stat(remotePath);
229
286
 
230
- if (stats.isDirectory) {
287
+ if (remoteStats.isDirectory) {
288
+ await fs.mkdir(localPath, { recursive: true });
231
289
  await sftp.downloadDir(remotePath, localPath);
290
+ return 1;
291
+ }
292
+
293
+ let finalLocalPath;
294
+ const localExists = await fs.access(localPath).then(() => true).catch(() => false);
295
+
296
+ if (localExists) {
297
+ let localStats;
298
+ try {
299
+ localStats = await fs.stat(localPath);
300
+ } catch (e) {
301
+ localStats = null;
302
+ }
303
+
304
+ if (localStats && localStats.isDirectory()) {
305
+ finalLocalPath = path.join(localPath, path.basename(remotePath));
306
+ } else {
307
+ if (!force) {
308
+ throw new Error(`Le fichier local ${localPath} existe déjà. Utilisez force:true pour l'écraser.`);
309
+ }
310
+ finalLocalPath = localPath;
311
+ }
232
312
  } else {
233
- await sftp.get(remotePath, localPath);
313
+ const hasExt = path.extname(localPath) !== '';
314
+ if (hasExt) {
315
+ finalLocalPath = localPath;
316
+ } else {
317
+ await fs.mkdir(localPath, { recursive: true });
318
+ finalLocalPath = path.join(localPath, path.basename(remotePath));
319
+ }
234
320
  }
235
- return 1; // Un seul item téléchargé
321
+
322
+ const localDir = path.dirname(finalLocalPath);
323
+ await fs.mkdir(localDir, { recursive: true });
324
+ await sftp.get(remotePath, finalLocalPath);
325
+ return 1;
236
326
  }
237
327
  }
238
328
 
@@ -0,0 +1,237 @@
1
+ import { Client } from 'ssh2';
2
+ import fs from 'fs/promises';
3
+ import crypto from 'crypto';
4
+ import serverManager from './servers.js';
5
+
6
+ /**
7
+ * shellSessions — Sessions shell PTY PERSISTANTES sur serveurs distants.
8
+ *
9
+ * Contrairement à task_exec (client.exec() = nouveau shell isolé à chaque appel),
10
+ * ici on ouvre un vrai shell interactif (client.shell()) maintenu ouvert. Ainsi
11
+ * `cd`, `export`, activations d'environnement, etc. PERSISTENT entre les commandes.
12
+ *
13
+ * === Détection de fin de commande (technique du marqueur echo-agnostique) ===
14
+ * Après chaque commande, on envoie : printf '%s:%s\n' "MARKER" "$?"
15
+ * - La sortie RÉELLE du printf donne "MARKER:0" (exit code résolu en nombre).
16
+ * - La ligne de commande ÉCHO (si echo actif) contient "MARKER:$?" (littéral).
17
+ * - La regex /MARKER:(\d+)/ ne matche QUE la sortie réelle → jamais l'écho.
18
+ * De plus on désactive l'écho du PTY (stty -echo) dès l'ouverture pour un parsing net.
19
+ *
20
+ * Chaque session utilise une connexion SSH DÉDIÉE (pas le pool) car le shell
21
+ * monopolise le canal pour toute sa durée de vie.
22
+ */
23
+
24
+ const DEFAULT_IDLE_TIMEOUT = 30 * 60 * 1000; // 30 min d'inactivité → fermeture auto
25
+ const DEFAULT_CMD_TIMEOUT = 300; // secondes
26
+
27
+ class ShellSessionManager {
28
+ constructor() {
29
+ this.sessions = new Map(); // id → session
30
+ this._startCleanup();
31
+ }
32
+
33
+ async _buildConnectConfig(alias) {
34
+ const sc = await serverManager.getServer(alias);
35
+ const config = {
36
+ host: sc.host,
37
+ port: sc.port || 22,
38
+ username: sc.user,
39
+ readyTimeout: 20000,
40
+ keepaliveInterval: 30000,
41
+ keepaliveCountMax: 3
42
+ };
43
+ if (sc.keyPath) {
44
+ config.privateKey = await fs.readFile(sc.keyPath);
45
+ } else if (sc.password) {
46
+ config.password = sc.password;
47
+ } else {
48
+ throw new Error(`Aucune méthode d'authentification pour le serveur '${alias}'.`);
49
+ }
50
+ return config;
51
+ }
52
+
53
+ /**
54
+ * Crée une session shell persistante.
55
+ * options = { workdir, env: {K:V}, cmdTimeout }
56
+ * Retourne { id, alias, ready }.
57
+ */
58
+ async createSession(alias, options = {}) {
59
+ const config = await this._buildConnectConfig(alias);
60
+ const conn = new Client();
61
+
62
+ await new Promise((resolve, reject) => {
63
+ let settled = false;
64
+ conn.on('ready', () => { if (!settled) { settled = true; resolve(); } });
65
+ conn.on('error', (err) => { if (!settled) { settled = true; reject(err); } });
66
+ conn.connect(config);
67
+ });
68
+
69
+ const stream = await new Promise((resolve, reject) => {
70
+ conn.shell({ term: 'xterm', modes: {} }, (err, s) => err ? reject(err) : resolve(s));
71
+ });
72
+
73
+ const id = `sh_${alias}_${Date.now()}_${crypto.randomBytes(3).toString('hex')}`;
74
+ const session = {
75
+ id, alias, conn, stream,
76
+ buffer: '',
77
+ createdAt: Date.now(),
78
+ lastUsed: Date.now(),
79
+ commandCount: 0,
80
+ busy: false,
81
+ closed: false,
82
+ cmdTimeout: options.cmdTimeout || DEFAULT_CMD_TIMEOUT
83
+ };
84
+
85
+ stream.on('data', (d) => { session.buffer += d.toString('utf8'); });
86
+ stream.on('close', () => { session.closed = true; });
87
+ conn.on('error', () => { session.closed = true; });
88
+
89
+ this.sessions.set(id, session);
90
+
91
+ // Setup : désactive l'écho (parsing net) et vide le prompt
92
+ await this._exec(session, "stty -echo 2>/dev/null; export PS1=''; export PS2=''", 15);
93
+
94
+ // Répertoire de travail initial
95
+ if (options.workdir) {
96
+ const r = await this._exec(session, `cd ${this._shellQuote(options.workdir)}`, 15);
97
+ if (r.exitCode !== 0) {
98
+ this.closeSession(id);
99
+ throw new Error(`Impossible de cd vers '${options.workdir}': ${r.output}`);
100
+ }
101
+ }
102
+
103
+ // Variables d'environnement initiales
104
+ if (options.env && typeof options.env === 'object') {
105
+ for (const [k, v] of Object.entries(options.env)) {
106
+ await this._exec(session, `export ${k}=${this._shellQuote(String(v))}`, 15);
107
+ }
108
+ }
109
+
110
+ return { id, alias, ready: true };
111
+ }
112
+
113
+ _shellQuote(str) {
114
+ // Quote sûr pour bash : entoure de ' et échappe les ' internes
115
+ return `'${String(str).replace(/'/g, `'\\''`)}'`;
116
+ }
117
+
118
+ /**
119
+ * Exécute une commande dans la session et attend le marqueur de fin.
120
+ * Retourne { output, exitCode, timedOut }.
121
+ */
122
+ async _exec(session, command, timeoutSec) {
123
+ const marker = `__ORCH_${crypto.randomBytes(8).toString('hex')}__`;
124
+ const re = new RegExp(`${marker}:(-?\\d+)`);
125
+
126
+ // Vide le buffer juste avant d'écrire (les commandes sont sérialisées via busy)
127
+ session.buffer = '';
128
+
129
+ // Commande utilisateur, puis marqueur avec code de sortie de CETTE commande
130
+ session.stream.write(`${command}\n`);
131
+ session.stream.write(`printf '%s:%s\\n' "${marker}" "$?"\n`);
132
+
133
+ const timeoutMs = timeoutSec === 0 ? 0 : (timeoutSec || DEFAULT_CMD_TIMEOUT) * 1000;
134
+ const start = Date.now();
135
+
136
+ return new Promise((resolve) => {
137
+ const check = setInterval(() => {
138
+ const m = session.buffer.match(re);
139
+ if (m) {
140
+ clearInterval(check);
141
+ const exitCode = parseInt(m[1], 10);
142
+ const raw = session.buffer.slice(0, m.index);
143
+ resolve({ output: this._clean(raw, marker).trim(), exitCode, timedOut: false });
144
+ } else if (session.closed) {
145
+ clearInterval(check);
146
+ resolve({ output: this._clean(session.buffer, marker).trim(), exitCode: null, timedOut: false, closed: true });
147
+ } else if (timeoutMs > 0 && Date.now() - start > timeoutMs) {
148
+ clearInterval(check);
149
+ // Tente de débloquer la commande (Ctrl-C) sans tuer la session
150
+ try { session.stream.write('\x03'); } catch (e) { /* ignore */ }
151
+ resolve({ output: this._clean(session.buffer, marker).trim(), exitCode: null, timedOut: true });
152
+ }
153
+ }, 100);
154
+ });
155
+ }
156
+
157
+ // Nettoie la sortie : strip séquences ANSI (couleurs, bracketed-paste \x1b[?2004h/l),
158
+ // strip les \r du PTY, retire les lignes écho/marqueur.
159
+ _clean(output, marker) {
160
+ return output
161
+ // eslint-disable-next-line no-control-regex
162
+ .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '') // séquences CSI ANSI
163
+ .replace(/\r/g, '')
164
+ .split('\n')
165
+ .filter(line => !line.includes(marker) && !line.includes("printf '%s:%s"))
166
+ .join('\n');
167
+ }
168
+
169
+ /**
170
+ * Exécute une commande dans une session existante (état persistant).
171
+ */
172
+ async execInSession(id, command, timeoutSec) {
173
+ const session = this.sessions.get(id);
174
+ if (!session) throw new Error(`Session '${id}' introuvable. Utilisez shell_list pour voir les sessions actives.`);
175
+ if (session.closed) {
176
+ this.sessions.delete(id);
177
+ throw new Error(`Session '${id}' est fermée (connexion perdue). Créez-en une nouvelle avec shell_create.`);
178
+ }
179
+ if (session.busy) throw new Error(`Session '${id}' occupée : une seule commande à la fois par session.`);
180
+
181
+ session.busy = true;
182
+ session.lastUsed = Date.now();
183
+ session.commandCount++;
184
+ try {
185
+ return await this._exec(session, command, timeoutSec ?? session.cmdTimeout);
186
+ } finally {
187
+ session.busy = false;
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Liste les sessions actives avec leur état.
193
+ */
194
+ listSessions() {
195
+ const now = Date.now();
196
+ return Array.from(this.sessions.values()).map(s => ({
197
+ id: s.id,
198
+ alias: s.alias,
199
+ ageSeconds: Math.round((now - s.createdAt) / 1000),
200
+ idleSeconds: Math.round((now - s.lastUsed) / 1000),
201
+ commandCount: s.commandCount,
202
+ busy: s.busy,
203
+ closed: s.closed
204
+ }));
205
+ }
206
+
207
+ /**
208
+ * Ferme proprement une session.
209
+ */
210
+ closeSession(id) {
211
+ const session = this.sessions.get(id);
212
+ if (!session) throw new Error(`Session '${id}' introuvable.`);
213
+ try { session.stream.end('exit\n'); } catch (e) { /* ignore */ }
214
+ try { session.conn.end(); } catch (e) { /* ignore */ }
215
+ this.sessions.delete(id);
216
+ return { closed: true, id };
217
+ }
218
+
219
+ _startCleanup() {
220
+ setInterval(() => {
221
+ const now = Date.now();
222
+ for (const [id, s] of this.sessions) {
223
+ if (s.closed || (!s.busy && (now - s.lastUsed) > DEFAULT_IDLE_TIMEOUT)) {
224
+ try { this.closeSession(id); } catch (e) { /* ignore */ }
225
+ }
226
+ }
227
+ }, 60000);
228
+ }
229
+
230
+ closeAll() {
231
+ for (const id of [...this.sessions.keys()]) {
232
+ try { this.closeSession(id); } catch (e) { /* ignore */ }
233
+ }
234
+ }
235
+ }
236
+
237
+ export default new ShellSessionManager();