@fkom13/mcp-sftp-orchestrator 6.0.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/ssh.js ADDED
@@ -0,0 +1,492 @@
1
+ import { Client } from 'ssh2';
2
+ import fs from 'fs/promises';
3
+ import queue from './queue.js';
4
+ import serverManager from './servers.js';
5
+ import sshPool from './sshPool.js';
6
+ import config from './config.js';
7
+
8
+ const STREAMING_COMMANDS = [
9
+ /^pm2\s+logs?\b/i,
10
+ /^docker\s+logs?\s+(-f|--follow)/i,
11
+ /^tail\s+-f/i,
12
+ /^journalctl\s+-f/i,
13
+ /^watch\b/i,
14
+ /^top\b/i,
15
+ /^htop\b/i,
16
+ /^less\b/i,
17
+ /^more\b/i,
18
+ /^vim?\b/i,
19
+ /^nano\b/i
20
+ ];
21
+
22
+ function isStreamingCommand(cmd) {
23
+ return STREAMING_COMMANDS.some(pattern => pattern.test(cmd.trim()));
24
+ }
25
+
26
+ // Configuration des réponses automatiques pour les prompts interactifs
27
+ const INTERACTIVE_RESPONSES = {
28
+ // Patterns courants et leurs réponses
29
+ 'continue connecting': 'yes',
30
+ 'Are you sure': 'yes',
31
+ 'password:': null, // Sera géré séparément
32
+ 'Password:': null,
33
+ 'Do you want to continue': 'y',
34
+ 'Overwrite': 'y',
35
+ 'Save': 'y'
36
+ };
37
+
38
+ // Détecte si une sortie contient un prompt interactif
39
+ function detectInteractivePrompt(output) {
40
+ const lastLine = output.split('\n').pop().toLowerCase();
41
+
42
+ for (const [pattern, response] of Object.entries(INTERACTIVE_RESPONSES)) {
43
+ if (lastLine.includes(pattern.toLowerCase())) {
44
+ return { pattern, response, needsInput: true };
45
+ }
46
+ }
47
+
48
+ // Vérifier les patterns qui attendent une entrée
49
+ if (lastLine.endsWith(':') || lastLine.endsWith('?') ||
50
+ lastLine.includes('[y/n]') || lastLine.includes('(yes/no)')) {
51
+ return { pattern: 'generic', response: null, needsInput: true };
52
+ }
53
+
54
+ return { needsInput: false };
55
+ }
56
+
57
+ // Exécution de commande avec pool de connexions
58
+ async function executeCommand(jobId) {
59
+ const job = queue.getJob(jobId);
60
+ if (!job) return queue.log('error', `Tâche introuvable: ${jobId}`);
61
+
62
+ let connection = null;
63
+
64
+ try {
65
+ const serverConfig = await serverManager.getServer(job.alias);
66
+ queue.updateJobStatus(jobId, 'running');
67
+
68
+ // Utiliser le pool de connexions si pas de mode interactif
69
+ const usePool = !job.interactive && job.persistent !== false;
70
+
71
+ if (usePool) {
72
+ // Obtenir une connexion du pool
73
+ connection = await sshPool.getConnection(job.alias, serverConfig);
74
+ await executeWithPooledConnection(connection, job, jobId);
75
+ } else {
76
+ // Créer une connexion dédiée pour les commandes interactives
77
+ await executeWithNewConnection(serverConfig, job, jobId);
78
+ }
79
+
80
+ } catch (err) {
81
+ queue.updateJobStatus(jobId, 'failed', { error: err.message });
82
+ } finally {
83
+ // Libérer la connexion si elle vient du pool
84
+ if (connection) {
85
+ sshPool.releaseConnection(connection.id);
86
+ }
87
+ }
88
+ }
89
+
90
+ // Exécution avec une connexion du pool
91
+ async function executeWithPooledConnection(connection, job, jobId) {
92
+ return new Promise((resolve, reject) => {
93
+ const { client } = connection;
94
+
95
+ const isStreaming = job.streaming || isStreamingCommand(job.cmd);
96
+ let cmdToExecute = job.cmd;
97
+
98
+ if (isStreaming) {
99
+ const maxLines = job.maxLines || 100;
100
+ const streamTimeout = job.streamTimeout || 10;
101
+ if (/^pm2\s+logs?\b/i.test(cmdToExecute)) {
102
+ cmdToExecute = cmdToExecute.replace(/\s+--lines\s+\d+/gi, '').replace(/\s+--nostream/gi, '');
103
+ cmdToExecute += ` --lines ${maxLines} --nostream`;
104
+ } else if (/^docker\s+logs?\b/i.test(cmdToExecute)) {
105
+ cmdToExecute = cmdToExecute.replace(/\s+-f\b|--follow\b/gi, '').replace(/\s+--tail\s+\d+/gi, '');
106
+ cmdToExecute += ` --tail ${maxLines}`;
107
+ } else if (/^tail\s+-f/i.test(cmdToExecute)) {
108
+ cmdToExecute = `timeout ${streamTimeout} ` + cmdToExecute.replace(/-f\b/gi, '') + ` | head -n ${maxLines}`;
109
+ } else if (/^journalctl\s+-f/i.test(cmdToExecute)) {
110
+ cmdToExecute = cmdToExecute.replace(/-f\b/gi, '') + ` -n ${maxLines}`;
111
+ } else {
112
+ cmdToExecute = `timeout ${streamTimeout} ${cmdToExecute}`;
113
+ }
114
+ queue.log('info', `Commande streaming transformée: ${cmdToExecute}`);
115
+ }
116
+
117
+ client.exec(cmdToExecute, { pty: job.pty }, (err, stream) => {
118
+ if (err) {
119
+ reject(err);
120
+ return;
121
+ }
122
+
123
+ let output = '';
124
+ let stderr = '';
125
+ let lineCount = 0;
126
+ const startTime = Date.now();
127
+ const maxLines = job.maxLines || 1000;
128
+ const timeout = job.timeout || config.defaultCommandTimeout;
129
+
130
+ const timeoutId = setTimeout(() => {
131
+ stream.write('\x03'); // Envoyer Ctrl+C
132
+ setTimeout(() => {
133
+ stream.close();
134
+ const duration = Date.now() - startTime;
135
+ const result = { output: output.trim(), stderr: stderr.trim(), exitCode: 124, duration, lineCount, timedOut: true };
136
+ queue.updateJobStatus(jobId, 'completed', result);
137
+ resolve(result);
138
+ }, 500);
139
+ }, timeout);
140
+
141
+ stream.on('close', (code, signal) => {
142
+ clearTimeout(timeoutId);
143
+ const duration = Date.now() - startTime;
144
+ const result = { output: output.trim(), stderr: stderr.trim(), exitCode: code, signal, duration, lineCount };
145
+ if (code === 0 || code === 124) {
146
+ queue.updateJobStatus(jobId, 'completed', result);
147
+ resolve(result);
148
+ } else {
149
+ queue.updateJobStatus(jobId, 'failed', { ...result, error: `Commande terminée avec code ${code}` });
150
+ reject(new Error(`Exit code: ${code}`));
151
+ }
152
+ });
153
+
154
+ stream.on('data', (data) => {
155
+ const chunk = data.toString();
156
+ output += chunk;
157
+ lineCount += (chunk.match(/\n/g) || []).length;
158
+ if (isStreaming && lineCount >= maxLines) {
159
+ stream.write('\x03');
160
+ setTimeout(() => stream.close(), 500);
161
+ }
162
+ if (job.autoRespond) {
163
+ const prompt = detectInteractivePrompt(output);
164
+ if (prompt.needsInput && prompt.response) {
165
+ stream.write(prompt.response + '\n');
166
+ queue.log('info', `Réponse automatique au prompt: ${prompt.pattern}`);
167
+ }
168
+ }
169
+ });
170
+
171
+ stream.stderr.on('data', (data) => {
172
+ stderr += data.toString();
173
+ });
174
+ });
175
+ });
176
+ }
177
+
178
+ // Exécution avec une nouvelle connexion (pour commandes interactives)
179
+ async function executeWithNewConnection(serverConfig, job, jobId) {
180
+ return new Promise((resolve, reject) => {
181
+ const conn = new Client();
182
+
183
+ conn.on('ready', () => {
184
+ conn.shell((err, stream) => {
185
+ if (err) {
186
+ conn.end();
187
+ return reject(err);
188
+ }
189
+
190
+ let output = '';
191
+ const responses = job.responses || {};
192
+ const END_MARKER = `GEMINI_TASK_COMPLETE_MARKER_${job.id}`;
193
+ let responseSent = false;
194
+
195
+ const cleanupAndResolve = (status = 'completed') => {
196
+ clearTimeout(jobTimeout);
197
+ output = output.replace(END_MARKER, '').trim();
198
+ const result = { output, exitCode: 0 };
199
+ queue.updateJobStatus(jobId, status, result);
200
+ try {
201
+ stream.end();
202
+ conn.end();
203
+ } catch(e) {/* ignore */}
204
+ resolve(result);
205
+ };
206
+
207
+ const jobTimeout = setTimeout(() => {
208
+ cleanupAndResolve('failed');
209
+ }, (job.timeout ? job.timeout * 1000 : config.interactiveCommandTimeout));
210
+
211
+ stream.on('close', () => {
212
+ if (!output.includes(END_MARKER)) {
213
+ cleanupAndResolve('failed');
214
+ }
215
+ });
216
+
217
+ stream.on('data', (data) => {
218
+ const chunk = data.toString();
219
+ output += chunk;
220
+
221
+ if (output.includes(END_MARKER)) {
222
+ cleanupAndResolve();
223
+ return;
224
+ }
225
+
226
+ if (!responseSent && job.autoRespond) {
227
+ const lowerChunk = chunk.toLowerCase();
228
+ let responseToSend = null;
229
+
230
+ // Chercher une réponse personnalisée
231
+ for (const [pattern, response] of Object.entries(responses)) {
232
+ if (lowerChunk.includes(pattern.toLowerCase())) {
233
+ responseToSend = response;
234
+ queue.log('info', `Réponse interactive: ${pattern} -> ${response}`);
235
+ break;
236
+ }
237
+ }
238
+
239
+ // Sinon, chercher une réponse par défaut
240
+ if (!responseToSend) {
241
+ for (const [pattern, response] of Object.entries(INTERACTIVE_RESPONSES)) {
242
+ if (response && lowerChunk.includes(pattern.toLowerCase())) {
243
+ responseToSend = response;
244
+ queue.log('info', `Réponse auto: ${pattern} -> ${response}`);
245
+ break;
246
+ }
247
+ }
248
+ }
249
+
250
+ // Si une réponse a été trouvée, l'envoyer
251
+ if (responseToSend) {
252
+ stream.write(responseToSend + '\n');
253
+ responseSent = true;
254
+ // Envoyer le marqueur de fin juste après
255
+ setTimeout(() => stream.write(`echo "${END_MARKER}"; exit\n`), 100);
256
+ }
257
+ }
258
+ });
259
+
260
+ stream.stderr.on('data', (data) => {
261
+ output += `[STDERR] ${data.toString()}`;
262
+ });
263
+
264
+ // Envoyer uniquement la commande initiale
265
+ stream.write(job.cmd + '\n');
266
+ });
267
+ });
268
+
269
+ conn.on('error', (err) => {
270
+ reject(err);
271
+ });
272
+
273
+ const connectConfig = {
274
+ host: serverConfig.host,
275
+ port: 22,
276
+ username: serverConfig.user,
277
+ readyTimeout: 20000
278
+ };
279
+
280
+ (async () => {
281
+ if (serverConfig.keyPath) {
282
+ try {
283
+ connectConfig.privateKey = await fs.readFile(serverConfig.keyPath);
284
+ } catch (err) {
285
+ reject(new Error(`Impossible de lire la clé SSH: ${err.message}`));
286
+ return;
287
+ }
288
+ } else if (serverConfig.password) {
289
+ connectConfig.password = serverConfig.password;
290
+ } else {
291
+ reject(new Error("Aucune méthode d'authentification configurée"));
292
+ return;
293
+ }
294
+
295
+ conn.connect(connectConfig);
296
+ })();
297
+ });
298
+ }
299
+
300
+
301
+ // Nouvelle fonction pour exécuter plusieurs commandes en séquence
302
+ async function executeCommandSequence(jobId) {
303
+ const job = queue.getJob(jobId);
304
+ if (!job) return queue.log('error', `Tâche introuvable: ${jobId}`);
305
+
306
+ const results = [];
307
+ let connection = null;
308
+
309
+ try {
310
+ const serverConfig = await serverManager.getServer(job.alias);
311
+ queue.updateJobStatus(jobId, 'running');
312
+
313
+ // Obtenir une connexion du pool
314
+ connection = await sshPool.getConnection(job.alias, serverConfig);
315
+
316
+ // Exécuter chaque commande en séquence
317
+ for (let i = 0; i < job.commands.length; i++) {
318
+ const cmd = job.commands[i];
319
+ const stepJob = {
320
+ ...job,
321
+ cmd: typeof cmd === 'string' ? cmd : cmd.command,
322
+ timeout: cmd.timeout || job.timeout,
323
+ continueOnError: cmd.continueOnError || job.continueOnError
324
+ };
325
+
326
+ try {
327
+ queue.log('info', `Exécution étape ${i+1}/${job.commands.length}: ${stepJob.cmd}`);
328
+ const result = await executeWithPooledConnection(connection, stepJob, `${jobId}_step_${i}`);
329
+ results.push({
330
+ step: i + 1,
331
+ command: stepJob.cmd,
332
+ success: true,
333
+ ...result
334
+ });
335
+ } catch (err) {
336
+ results.push({
337
+ step: i + 1,
338
+ command: stepJob.cmd,
339
+ success: false,
340
+ error: err.message
341
+ });
342
+
343
+ if (!stepJob.continueOnError) {
344
+ throw new Error(`Échec à l'étape ${i+1}: ${err.message}`);
345
+ }
346
+ }
347
+ }
348
+
349
+ queue.updateJobStatus(jobId, 'completed', { results });
350
+
351
+ } catch (err) {
352
+ queue.updateJobStatus(jobId, 'failed', {
353
+ error: err.message,
354
+ results
355
+ });
356
+ } finally {
357
+ if (connection) {
358
+ sshPool.releaseConnection(connection.id);
359
+ }
360
+ }
361
+ }
362
+
363
+ // Obtenir les stats du pool
364
+ function getPoolStats() {
365
+ return sshPool.getStats();
366
+ }
367
+
368
+ function parseSystemResources(output) {
369
+ const resources = {
370
+ load_average: null,
371
+ memory: null,
372
+ disk: null
373
+ };
374
+ const lines = output.split('\n');
375
+
376
+ try {
377
+ const uptimeLine = lines.find(line => line.includes('load average:'));
378
+ if (uptimeLine) {
379
+ const parts = uptimeLine.split('load average:')[1];
380
+ resources.load_average = parts.split(',').map(s => s.trim());
381
+ }
382
+
383
+ const memLine = lines.find(line => line.trim().startsWith('Mem:'));
384
+ if (memLine) {
385
+ const parts = memLine.trim().split(/\s+/);
386
+ resources.memory = {
387
+ total: parts[1],
388
+ used: parts[2],
389
+ free: parts[3],
390
+ available: parts[6]
391
+ };
392
+ }
393
+
394
+ const diskLine = lines.find(line => line.startsWith('/dev/'));
395
+ if (diskLine) {
396
+ const parts = diskLine.trim().split(/\s+/);
397
+ resources.disk = {
398
+ filesystem: parts[0],
399
+ total: parts[1],
400
+ used: parts[2],
401
+ available: parts[3],
402
+ use_percent: parts[4]
403
+ };
404
+ }
405
+ } catch (e) {
406
+ // En cas d'erreur de parsing, retourner les données brutes
407
+ return { raw_output: output, parsing_error: e.message };
408
+ }
409
+
410
+ return resources;
411
+ }
412
+
413
+ function parseServicesStatus(output) {
414
+ const services = {
415
+ systemd: [],
416
+ docker: [],
417
+ pm2: []
418
+ };
419
+
420
+ try {
421
+ const sections = output.split(/---DOCKER---|---PM2---/);
422
+ const systemdRaw = sections[0] || '';
423
+ const dockerRaw = sections[1] || '';
424
+ const pm2Raw = sections[2] || '';
425
+
426
+ // Parse systemd
427
+ if (systemdRaw) {
428
+ const lines = systemdRaw.trim().split('\n');
429
+ lines.forEach(line => {
430
+ const match = line.match(/^(\S+)\s+loaded\s+active\s+running/);
431
+ if (match && match[1]) {
432
+ services.systemd.push({ name: match[1], status: 'running' });
433
+ }
434
+ });
435
+ }
436
+
437
+ // Parse Docker
438
+ if (dockerRaw) {
439
+ const lines = dockerRaw.trim().split('\n').filter(Boolean);
440
+ lines.forEach(line => {
441
+ const parts = line.split(': ');
442
+ if (parts.length >= 2) {
443
+ services.docker.push({ name: parts[0].trim(), status: parts.slice(1).join(': ').trim() });
444
+ }
445
+ });
446
+ }
447
+
448
+ // Parse PM2
449
+ if (pm2Raw) {
450
+ const lines = pm2Raw.trim().split('\n');
451
+ const tableStartIndex = lines.findIndex(line => line.includes('│ id │'));
452
+ if (tableStartIndex !== -1) {
453
+ const tableBody = lines.slice(tableStartIndex + 2, -1); // Skip header, separator, and footer
454
+ tableBody.forEach(line => {
455
+ const columns = line.split('│').map(s => s.trim()).filter(Boolean);
456
+ if (columns.length >= 4) { // id, name, namespace, status
457
+ services.pm2.push({ id: columns[0], name: columns[1], status: columns[3] });
458
+ }
459
+ });
460
+ }
461
+ }
462
+ } catch (e) {
463
+ return { raw_output: output, parsing_error: e.message };
464
+ }
465
+
466
+ return services;
467
+ }
468
+
469
+ function parseApiHealth(output) {
470
+ try {
471
+ const [codeStr, timeStr] = output.split(':');
472
+ const http_code = parseInt(codeStr, 10);
473
+ const response_time_ms = parseFloat(timeStr) * 1000;
474
+
475
+ return {
476
+ status: http_code >= 200 && http_code < 300 ? 'UP' : 'DOWN',
477
+ http_code: http_code,
478
+ response_time_ms: Math.round(response_time_ms)
479
+ };
480
+ } catch (e) {
481
+ return { status: 'ERROR', http_code: 0, response_time_ms: 0, parsing_error: e.message, raw_output: output };
482
+ }
483
+ }
484
+
485
+ export default {
486
+ executeCommand,
487
+ executeCommandSequence,
488
+ getPoolStats,
489
+ parseSystemResources,
490
+ parseServicesStatus,
491
+ parseApiHealth
492
+ };