@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.
package/fileOps.js ADDED
@@ -0,0 +1,275 @@
1
+ import crypto from 'crypto';
2
+ import pathModule from 'path';
3
+ import { createTwoFilesPatch } from 'diff';
4
+ import sourceAdapter from './sourceAdapter.js';
5
+ import config from './config.js';
6
+
7
+ /**
8
+ * fileOps — Opérations fichiers de haut niveau (local ET remote via sourceAdapter).
9
+ *
10
+ * Ajoute par-dessus sourceAdapter :
11
+ * - calcul de hash SHA-256 (protection contre modifications concurrentes)
12
+ * - génération de diff unifié
13
+ * - édition sécurisée (read → vérif hash → diff → write)
14
+ *
15
+ * Gère texte (utf8) et binaire (base64).
16
+ */
17
+
18
+ function sha256(buffer) {
19
+ return crypto.createHash('sha256').update(buffer).digest('hex');
20
+ }
21
+
22
+ // E — Détection binaire (heuristique git) : un octet nul dans les 8000 premiers
23
+ // octets = contenu binaire. Rapide et fiable pour éviter la corruption utf8.
24
+ function looksBinary(buffer) {
25
+ const len = Math.min(buffer.length, 8000);
26
+ for (let i = 0; i < len; i++) {
27
+ if (buffer[i] === 0) return true;
28
+ }
29
+ return false;
30
+ }
31
+
32
+ function resolvePath(sourcePath) {
33
+ // Nettoie les traversées de répertoire
34
+ const normalized = pathModule.normalize(sourcePath).replace(/\/$/, '');
35
+ return normalized;
36
+ }
37
+
38
+ function validatePath(sourcePath) {
39
+ if (!config.allowedRoots || config.allowedRoots.length === 0) {
40
+ return; // Aucune restriction configurée
41
+ }
42
+
43
+ const resolved = resolvePath(sourcePath);
44
+ const allowed = config.allowedRoots.some(root => {
45
+ const normalRoot = pathModule.normalize(root).replace(/\/$/, '');
46
+ return resolved === normalRoot || resolved.startsWith(normalRoot + '/');
47
+ });
48
+
49
+ if (!allowed) {
50
+ const roots = config.allowedRoots.join(', ');
51
+ throw new Error(`Accès refusé : '${resolved}' n'est pas dans les racines autorisées. Ajoutez le chemin à MCP_ALLOWED_ROOTS ou modifiez allowedRoots. Racines: ${roots}`);
52
+ }
53
+ }
54
+
55
+ export default {
56
+ /**
57
+ * Lit un fichier et retourne { content, hash, mtime, size, encoding, ... }.
58
+ * encoding 'utf8' → content est une string ; 'base64' → content encodé base64.
59
+ *
60
+ * E — Détection binaire auto : si encoding='utf8' mais le contenu est binaire
61
+ * (octet nul détecté), bascule automatiquement en base64 et signale via
62
+ * binaryDetected:true (évite la corruption silencieuse d'un .png/.gz lu en utf8).
63
+ * Désactivable avec options.autoDetect=false.
64
+ *
65
+ * options = { offset, limit, autoDetect } :
66
+ * - offset : ligne de départ (1-indexée) — lecture partielle PAR LIGNES (utf8)
67
+ * - limit : nombre de lignes à retourner
68
+ * Mode consultation pour gros fichiers. Le HASH reste TOUJOURS celui du
69
+ * fichier COMPLET, pour que file_edit fonctionne (il relit le fichier entier).
70
+ */
71
+ async readFile(source, encoding = 'utf8', options = {}) {
72
+ if (source.path) validatePath(source.path);
73
+ const { content, mtime, size } = await sourceAdapter.readFile(source);
74
+ // content est un Buffer ; le hash est TOUJOURS calculé sur le fichier complet
75
+ const hash = sha256(content);
76
+ // origin : d'où vient le fichier (serveur + chemin) — évite la confusion
77
+ const origin = sourceAdapter.describe(source);
78
+
79
+ // E — bascule auto utf8 → base64 si contenu binaire détecté
80
+ const autoDetect = options.autoDetect !== false;
81
+ let binaryDetected = false;
82
+ let effectiveEncoding = encoding;
83
+ if (encoding === 'utf8' && autoDetect && looksBinary(content)) {
84
+ effectiveEncoding = 'base64';
85
+ binaryDetected = true;
86
+ }
87
+
88
+ if (effectiveEncoding === 'base64') {
89
+ // offset/limit n'ont pas de sens sur du binaire
90
+ return {
91
+ origin, content: content.toString('base64'), hash, mtime, size,
92
+ encoding: 'base64',
93
+ ...(binaryDetected ? { binaryDetected: true, note: "Contenu binaire détecté (octet nul) — retourné en base64 automatiquement." } : {})
94
+ };
95
+ }
96
+
97
+ const full = content.toString('utf8');
98
+ const { offset, limit } = options;
99
+
100
+ // Lecture complète (défaut) → edit-safe
101
+ if (offset === undefined && limit === undefined) {
102
+ return { origin, content: full, hash, mtime, size, encoding: 'utf8' };
103
+ }
104
+
105
+ // Lecture partielle par lignes (mode consultation)
106
+ const lines = full.split('\n');
107
+ const totalLines = lines.length;
108
+ const start = offset && offset > 0 ? offset - 1 : 0; // offset 1-indexé
109
+ const end = limit ? start + limit : totalLines;
110
+ const slice = lines.slice(start, end).join('\n');
111
+
112
+ return {
113
+ origin,
114
+ content: slice,
115
+ hash, // hash du fichier COMPLET (protection file_edit)
116
+ mtime,
117
+ size,
118
+ encoding,
119
+ isPartial: true,
120
+ offset: start + 1,
121
+ limit: limit ?? (totalLines - start),
122
+ totalLines,
123
+ returnedLines: Math.min(end, totalLines) - start
124
+ };
125
+ },
126
+
127
+ /**
128
+ * Écrit un fichier. content est une string (utf8 ou base64 selon encoding).
129
+ * options = { createDirs }
130
+ * Retourne { hash, size }.
131
+ */
132
+ async writeFile(source, content, encoding = 'utf8', options = {}) {
133
+ if (source.path) validatePath(source.path);
134
+ const buffer = encoding === 'base64'
135
+ ? Buffer.from(content, 'base64')
136
+ : Buffer.from(content, 'utf8');
137
+ const newHash = sha256(buffer);
138
+ const origin = sourceAdapter.describe(source);
139
+
140
+ // B — dryRun : montre ce qui serait écrit (diff vs existant) sans écrire
141
+ if (options.dryRun) {
142
+ let existed = false;
143
+ let oldContent = '';
144
+ try {
145
+ const cur = await sourceAdapter.readFile(source);
146
+ existed = true;
147
+ oldContent = cur.content.toString('utf8');
148
+ } catch { /* fichier inexistant → création */ }
149
+ const patch = encoding === 'base64'
150
+ ? '(binaire — diff non affiché)'
151
+ : createTwoFilesPatch(origin.label, origin.label, oldContent, content, existed ? 'avant' : '(nouveau)', 'après');
152
+ return { origin, dryRun: true, wouldWrite: true, existed, hash: newHash, size: buffer.length, diff: patch };
153
+ }
154
+
155
+ // C — backup auto avant écriture
156
+ let backup = null;
157
+ if (options.backup) backup = await this._backup(source, options.backupMessage);
158
+
159
+ const result = await sourceAdapter.writeFile(source, buffer, options);
160
+ return { origin, hash: newHash, size: result.size, ...(backup ? { backup } : {}) };
161
+ },
162
+
163
+ /**
164
+ * C — Crée un snapshot du fichier avant modification (filet de sécurité).
165
+ * Import dynamique de snapshotManager pour éviter tout couplage au chargement.
166
+ * Retourne { snapshotId, tag } ou null si le fichier n'existe pas encore.
167
+ */
168
+ async _backup(source, message) {
169
+ const exists = await sourceAdapter.exists(source);
170
+ if (!exists) return null; // rien à sauvegarder (nouveau fichier)
171
+ const { default: snapshotManager } = await import('./snapshotManager.js');
172
+ const snapSource = source.type === 'local'
173
+ ? { type: 'local' }
174
+ : { type: 'remote', alias: source.alias };
175
+ const tag = `auto-backup-${Date.now()}`;
176
+ const res = await snapshotManager.createSnapshot(snapSource, [source.path], {
177
+ tag,
178
+ message: message || `Backup auto avant modification de ${source.path}`
179
+ });
180
+ return { snapshotId: res.snapshotId, tag };
181
+ },
182
+
183
+ /**
184
+ * Édite un fichier avec protection par hash. DEUX MODES :
185
+ *
186
+ * A) Chirurgical : { oldString, newString, replaceAll? }
187
+ * Remplace un bout précis. Sûr : erreur si oldString absent (OLDSTRING_NOT_FOUND)
188
+ * ou présent plusieurs fois sans replaceAll (MULTIPLE_MATCHES).
189
+ * Économe en tokens (pas besoin de renvoyer tout le fichier).
190
+ *
191
+ * B) Complet : { newContent } → remplace tout le contenu.
192
+ *
193
+ * Options communes : { expectedHash, encoding, dryRun, backup, backupMessage }
194
+ * - expectedHash : protège contre modification concurrente (HASH_MISMATCH)
195
+ * - dryRun : retourne le diff sans écrire
196
+ * - backup : snapshot du fichier avant écriture (undo via snapshot_restore)
197
+ *
198
+ * Retourne { origin, hash, diff, applied, added, removed, size, backup? }.
199
+ */
200
+ async editFile(source, params = {}, legacyExpectedHash, legacyEncoding) {
201
+ // Rétrocompat : ancienne signature editFile(source, newContent, expectedHash, encoding)
202
+ if (typeof params === 'string') {
203
+ params = { newContent: params, expectedHash: legacyExpectedHash, encoding: legacyEncoding };
204
+ }
205
+ const encoding = params.encoding || 'utf8';
206
+ const current = await this.readFile(source, encoding);
207
+
208
+ // Protection hash (optionnelle mais recommandée)
209
+ if (params.expectedHash && current.hash !== params.expectedHash) {
210
+ const err = new Error(
211
+ `Le fichier a été modifié depuis la dernière lecture (hash attendu: ${params.expectedHash}, actuel: ${current.hash}).`
212
+ );
213
+ err.code = 'HASH_MISMATCH';
214
+ err.current = current;
215
+ throw err;
216
+ }
217
+
218
+ // Détermine le nouveau contenu selon le mode
219
+ let newContent;
220
+ if (params.oldString !== undefined) {
221
+ // Mode A — chirurgical
222
+ const occurrences = current.content.split(params.oldString).length - 1;
223
+ if (occurrences === 0) {
224
+ const err = new Error(`oldString introuvable dans le fichier. Vérifiez le texte exact (espaces/indentation inclus).`);
225
+ err.code = 'OLDSTRING_NOT_FOUND';
226
+ throw err;
227
+ }
228
+ if (occurrences > 1 && !params.replaceAll) {
229
+ const err = new Error(`oldString présent ${occurrences} fois. Précisez plus de contexte pour un match unique, ou utilisez replaceAll:true.`);
230
+ err.code = 'MULTIPLE_MATCHES';
231
+ err.occurrences = occurrences;
232
+ throw err;
233
+ }
234
+ newContent = params.replaceAll
235
+ ? current.content.split(params.oldString).join(params.newString ?? '')
236
+ : current.content.replace(params.oldString, params.newString ?? '');
237
+ } else if (params.newContent !== undefined) {
238
+ // Mode B — remplacement complet
239
+ newContent = params.newContent;
240
+ } else {
241
+ throw new Error("Aucune modification fournie : utilisez { oldString, newString } (chirurgical) ou { newContent } (complet).");
242
+ }
243
+
244
+ // Diff lisible
245
+ const origin = sourceAdapter.describe(source);
246
+ const patch = createTwoFilesPatch(origin.label, origin.label, current.content, newContent, 'avant', 'après');
247
+ let added = 0, removed = 0;
248
+ for (const line of patch.split('\n')) {
249
+ if (line.startsWith('+') && !line.startsWith('+++')) added++;
250
+ else if (line.startsWith('-') && !line.startsWith('---')) removed++;
251
+ }
252
+
253
+ // B — dryRun : preview sans écrire
254
+ if (params.dryRun) {
255
+ return { origin, dryRun: true, applied: false, diff: patch, added, removed };
256
+ }
257
+
258
+ // C — backup avant écriture
259
+ let backup = null;
260
+ if (params.backup) backup = await this._backup(source, params.backupMessage);
261
+
262
+ const writeResult = await this.writeFile(source, newContent, encoding);
263
+
264
+ return {
265
+ origin,
266
+ hash: writeResult.hash,
267
+ diff: patch,
268
+ applied: true,
269
+ added,
270
+ removed,
271
+ size: writeResult.size,
272
+ ...(backup ? { backup } : {})
273
+ };
274
+ }
275
+ };
package/fleet.js ADDED
@@ -0,0 +1,170 @@
1
+ /**
2
+ * fleet.js — Diagnostic parallèle du parc (ping SSH + ressources légères).
3
+ */
4
+ import queue from './queue.js';
5
+ import servers from './servers.js';
6
+ import sshPool from './sshPool.js';
7
+ import utils from './utils.js';
8
+
9
+ const PROBE_CMD = `echo OK; date -u +%Y-%m-%dT%H:%M:%SZ; uptime; free -h 2>/dev/null | head -2; df -h / 2>/dev/null | tail -1`;
10
+
11
+ /**
12
+ * Probe un serveur : latence handshake + commande légère.
13
+ * Retourne { alias, ok, latencyMs, host, user, output?, error?, resources? }
14
+ */
15
+ async function probeServer(alias, timeoutMs = 15000) {
16
+ const started = Date.now();
17
+ let connection = null;
18
+
19
+ try {
20
+ const serverConfig = await servers.getServer(alias);
21
+ // Vérifier que la clé existe (fail rapide)
22
+ if (serverConfig.keyPath) {
23
+ const fs = await import('fs/promises');
24
+ try {
25
+ await fs.access(serverConfig.keyPath);
26
+ } catch {
27
+ return {
28
+ alias,
29
+ ok: false,
30
+ latencyMs: Date.now() - started,
31
+ host: serverConfig.host,
32
+ user: serverConfig.user,
33
+ port: utils.resolveSshPort(serverConfig),
34
+ error: `Clé SSH introuvable: ${serverConfig.keyPath}`
35
+ };
36
+ }
37
+ }
38
+
39
+ connection = await Promise.race([
40
+ sshPool.getConnection(alias, serverConfig),
41
+ new Promise((_, reject) =>
42
+ setTimeout(() => reject(new Error(`Timeout connexion ${timeoutMs}ms`)), timeoutMs)
43
+ )
44
+ ]);
45
+
46
+ const latencyMs = Date.now() - started;
47
+ const output = await execOnClient(connection.client, PROBE_CMD, Math.max(5000, timeoutMs - latencyMs));
48
+
49
+ return {
50
+ alias,
51
+ ok: true,
52
+ latencyMs,
53
+ host: serverConfig.host,
54
+ user: serverConfig.user,
55
+ port: utils.resolveSshPort(serverConfig),
56
+ output: (output || '').trim().slice(0, 800),
57
+ resources: parseLightResources(output)
58
+ };
59
+ } catch (err) {
60
+ let host = null, user = null, port = 22;
61
+ try {
62
+ const sc = await servers.getServer(alias);
63
+ host = sc.host; user = sc.user; port = utils.resolveSshPort(sc);
64
+ } catch { /* alias inconnu */ }
65
+
66
+ return {
67
+ alias,
68
+ ok: false,
69
+ latencyMs: Date.now() - started,
70
+ host,
71
+ user,
72
+ port,
73
+ error: err.message
74
+ };
75
+ } finally {
76
+ if (connection) {
77
+ try { sshPool.releaseConnection(connection.id); } catch { /* ignore */ }
78
+ }
79
+ }
80
+ }
81
+
82
+ function execOnClient(client, cmd, timeoutMs) {
83
+ return new Promise((resolve, reject) => {
84
+ let settled = false;
85
+ const timer = setTimeout(() => {
86
+ if (!settled) {
87
+ settled = true;
88
+ reject(new Error(`Timeout exec ${timeoutMs}ms`));
89
+ }
90
+ }, timeoutMs);
91
+
92
+ client.exec(cmd, (err, stream) => {
93
+ if (err) {
94
+ clearTimeout(timer);
95
+ if (!settled) { settled = true; reject(err); }
96
+ return;
97
+ }
98
+ let out = '';
99
+ stream.on('data', d => { out += d.toString(); });
100
+ stream.stderr.on('data', d => { out += d.toString(); });
101
+ stream.on('close', (code) => {
102
+ clearTimeout(timer);
103
+ if (settled) return;
104
+ settled = true;
105
+ if (code !== 0 && code !== null) {
106
+ // Certaines distros n'ont pas free -h : on accepte quand même si OK en tête
107
+ if (out.includes('OK')) resolve(out);
108
+ else reject(new Error(`exit ${code}: ${out.slice(0, 200)}`));
109
+ } else {
110
+ resolve(out);
111
+ }
112
+ });
113
+ });
114
+ });
115
+ }
116
+
117
+ function parseLightResources(output) {
118
+ if (!output) return null;
119
+ const resources = { load: null, disk_use: null, mem_line: null };
120
+ try {
121
+ const loadMatch = output.match(/load average:\s*([0-9.,\s]+)/i);
122
+ if (loadMatch) resources.load = loadMatch[1].trim();
123
+ const lines = output.split('\n');
124
+ const mem = lines.find(l => /^\s*Mem:/i.test(l));
125
+ if (mem) resources.mem_line = mem.trim();
126
+ const disk = lines.find(l => /\/\s*$/.test(l.trim()) || l.includes('%'));
127
+ // dernière ligne df souvent "… 42% /"
128
+ const df = [...lines].reverse().find(l => /\d+%/.test(l) && l.includes('/'));
129
+ if (df) {
130
+ const m = df.match(/(\d+%)/);
131
+ if (m) resources.disk_use = m[1];
132
+ }
133
+ } catch { /* ignore */ }
134
+ return resources;
135
+ }
136
+
137
+ /**
138
+ * Status de tout le parc (ou d'une liste d'alias).
139
+ * options = { aliases?: string[], timeoutMs?: number }
140
+ */
141
+ async function fleetStatus(options = {}) {
142
+ const all = await servers.listServers();
143
+ let aliases = options.aliases;
144
+
145
+ if (!aliases || aliases.length === 0) {
146
+ aliases = Object.keys(all);
147
+ }
148
+
149
+ // Résolution group: / all déjà faite en amont idéalement
150
+ const timeoutMs = options.timeoutMs || 15000;
151
+ queue.log('info', `fleet_status: probe de ${aliases.length} serveur(s)...`);
152
+
153
+ const results = await Promise.all(
154
+ aliases.map(alias => probeServer(alias, timeoutMs))
155
+ );
156
+
157
+ const ok = results.filter(r => r.ok).length;
158
+ const failed = results.filter(r => !r.ok);
159
+
160
+ return {
161
+ total: results.length,
162
+ online: ok,
163
+ offline: failed.length,
164
+ results: results.sort((a, b) => a.alias.localeCompare(b.alias)),
165
+ failed_aliases: failed.map(r => r.alias),
166
+ timestamp: new Date().toISOString()
167
+ };
168
+ }
169
+
170
+ export default { probeServer, fleetStatus };
package/groups.js ADDED
@@ -0,0 +1,25 @@
1
+ /** Groupes d'alias serveurs. Stockage atomique: <dataDir>/server_groups.json */
2
+ import path from 'path';
3
+ import config from './config.js';
4
+ import servers from './servers.js';
5
+ import jsonStore from './atomicJsonStore.js';
6
+ const GROUPS_PATH = path.join(config.dataDir, 'server_groups.json');
7
+ const DEFAULT_GROUPS = { oci:['fkomprodmini1_prod','fkomprodmini2_prod','fkomprodmini2_vpn'], contabo:['vps_contabo'], vpsparfait:['vpsparfait_fkomp','vpsparfait2_fkomp'], local:['pc1_tailscale'] };
8
+ const loadGroups = () => jsonStore.readJson(GROUPS_PATH, DEFAULT_GROUPS);
9
+ async function listGroups(){ return loadGroups(); }
10
+ async function setGroup(name, aliases){
11
+ if(!name || !/^[a-zA-Z0-9_-]+$/.test(name)) throw new Error("Nom de groupe invalide (alphanumérique, _ ou -).");
12
+ if(!Array.isArray(aliases)||!aliases.length) throw new Error("aliases doit être un tableau non vide d'alias serveurs.");
13
+ const all=await servers.listServers(); const unknown=aliases.filter(a=>!all[a]); if(unknown.length) throw new Error(`Alias inconnus: ${unknown.join(', ')}. Utilisez server_list.`);
14
+ const unique=[...new Set(aliases)]; await jsonStore.updateJson(GROUPS_PATH,DEFAULT_GROUPS,g=>{g[name]=unique;return g;}); return {success:true,group:name,aliases:unique};
15
+ }
16
+ async function removeGroup(name){ await jsonStore.updateJson(GROUPS_PATH,DEFAULT_GROUPS,g=>{if(!g[name]) throw new Error(`Groupe '${name}' introuvable.`); delete g[name]; return g;}); return {success:true,message:`Groupe '${name}' supprimé.`}; }
17
+ async function resolveAliases(aliasInput){
18
+ const allServers=await servers.listServers(), allKeys=Object.keys(allServers); if(aliasInput==null||aliasInput==='') throw new Error("Alias requis.");
19
+ if(Array.isArray(aliasInput)){ const expanded=[]; for(const a of aliasInput){ if(String(a).startsWith('group:')) expanded.push(...await expandGroup(String(a).slice(6))); else {if(!allServers[a]) throw new Error(`Alias inconnu: ${a}`); expanded.push(a);} } return [...new Set(expanded)]; }
20
+ if(aliasInput==='all') return allKeys; if(String(aliasInput).startsWith('group:')) return expandGroup(String(aliasInput).slice(6));
21
+ if(!allServers[aliasInput]){ const groups=await loadGroups(); if(groups[aliasInput]) return expandGroup(aliasInput); throw new Error(`Alias de serveur '${aliasInput}' est inconnu. Utilisez server_list ou group:<nom>.`); }
22
+ return [aliasInput];
23
+ }
24
+ async function expandGroup(name){ const groups=await loadGroups(); if(!groups[name]) throw new Error(`Groupe '${name}' introuvable. Groupes: ${Object.keys(groups).join(', ') || '(aucun)'}`); const all=await servers.listServers(); const aliases=groups[name].filter(a=>all[a]); if(!aliases.length) throw new Error(`Groupe '${name}' ne contient aucun alias valide actuellement.`); return aliases; }
25
+ export default {listGroups,setGroup,removeGroup,resolveAliases,expandGroup};