@fkom13/mcp-sftp-orchestrator 11.3.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/policies.js CHANGED
@@ -1,76 +1,8 @@
1
- import fs from 'fs/promises';
2
1
  import path from 'path';
3
2
  import config from './config.js';
4
-
5
- const POLICIES_PATH = path.join(config.dataDir, 'policies.json');
6
-
7
- const DEFAULT_POLICIES = {
8
- command_blocklist: [
9
- "rm -rf /",
10
- "rm -rf /*",
11
- "mkfs*",
12
- ":(){:|:&};:",
13
- "dd if=/dev/zero",
14
- "> /dev/sda",
15
- "chmod -R 000 /",
16
- "mv / /dev/null",
17
- "wget -O- http://* | sh",
18
- "curl http://* | sh",
19
- "eval $(curl",
20
- "eval $(wget",
21
- "git clone http://* | sh"
22
- ]
23
- };
24
-
25
- async function loadPolicies() {
26
- try {
27
- await fs.access(POLICIES_PATH);
28
- const data = await fs.readFile(POLICIES_PATH, 'utf-8');
29
- return JSON.parse(data);
30
- } catch {
31
- await fs.writeFile(POLICIES_PATH, JSON.stringify(DEFAULT_POLICIES, null, 2));
32
- return { ...DEFAULT_POLICIES };
33
- }
34
- }
35
-
36
- async function savePolicies(policies) {
37
- await fs.writeFile(POLICIES_PATH, JSON.stringify(policies, null, 2));
38
- }
39
-
40
- function checkCommand(cmd, blocklist) {
41
- const cmdTrimmed = cmd.trim();
42
- for (const pattern of blocklist) {
43
- const regex = pattern.includes('*')
44
- ? new RegExp('^' + pattern.replace(/\*/g, '.*') + '$', 'i')
45
- : new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
46
- if (regex.test(cmdTrimmed)) {
47
- return { blocked: true, pattern };
48
- }
49
- }
50
- return { blocked: false };
51
- }
52
-
53
- export default {
54
- async list() {
55
- return await loadPolicies();
56
- },
57
- async add(pattern) {
58
- const policies = await loadPolicies();
59
- if (!policies.command_blocklist.includes(pattern)) {
60
- policies.command_blocklist.push(pattern);
61
- await savePolicies(policies);
62
- }
63
- return policies;
64
- },
65
- async remove(pattern) {
66
- const policies = await loadPolicies();
67
- policies.command_blocklist = policies.command_blocklist.filter(p => p !== pattern);
68
- await savePolicies(policies);
69
- return policies;
70
- },
71
- async check(cmd) {
72
- const policies = await loadPolicies();
73
- return checkCommand(cmd, policies.command_blocklist);
74
- },
75
- checkCommand
76
- };
3
+ import jsonStore from './atomicJsonStore.js';
4
+ const POLICIES_PATH=path.join(config.dataDir,'policies.json');
5
+ const DEFAULT_POLICIES={command_blocklist:["rm -rf /","rm -rf /*","mkfs*",":(){:|:&};:","dd if=/dev/zero","> /dev/sda","chmod -R 000 /","mv / /dev/null","wget -O- http://* | sh","curl http://* | sh","eval $(curl","eval $(wget","git clone http://* | sh"]};
6
+ const loadPolicies=()=>jsonStore.readJson(POLICIES_PATH,DEFAULT_POLICIES);
7
+ function checkCommand(cmd,blocklist){ const c=(cmd||'').trim(); if(!c)return{blocked:false}; for(const pattern of blocklist){ if(!pattern)continue; if(pattern.includes('*')){const r=new RegExp('^'+pattern.replace(/[.+^${}()|[\]\\]/g,'\\$&').replace(/\*/g,'.*')+'$','i'); if(r.test(c))return{blocked:true,pattern}; continue;} const e=pattern.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'); const r=/\/$/.test(pattern)?new RegExp(e+'(?=$|\\s|\\*)','i'):new RegExp(e,'i'); if(r.test(c))return{blocked:true,pattern}; } return{blocked:false}; }
8
+ export default { async list(){return loadPolicies();}, async add(pattern){return jsonStore.updateJson(POLICIES_PATH,DEFAULT_POLICIES,p=>{if(!p.command_blocklist.includes(pattern))p.command_blocklist.push(pattern);return p;});}, async remove(pattern){return jsonStore.updateJson(POLICIES_PATH,DEFAULT_POLICIES,p=>{p.command_blocklist=p.command_blocklist.filter(x=>x!==pattern);return p;});}, async check(cmd){const p=await loadPolicies();return checkCommand(cmd,p.command_blocklist);}, checkCommand };
package/projects.js ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * projects.js — Registre sémantique projet ↔ serveur(s) ↔ paths local/remote.
3
+ * Stockage atomique: <dataDir>/projects.json
4
+ */
5
+ import path from 'path';
6
+ import config from './config.js';
7
+ import servers from './servers.js';
8
+ import jsonStore from './atomicJsonStore.js';
9
+
10
+ const PROJECTS_PATH = path.join(config.dataDir, 'projects.json');
11
+ const EMPTY = {};
12
+
13
+ const load = () => jsonStore.readJson(PROJECTS_PATH, EMPTY);
14
+
15
+ function validateName(name) {
16
+ if (!name || !/^[a-zA-Z0-9_./-]+$/.test(name)) {
17
+ throw new Error("Nom de projet invalide (alphanumérique, _ . / -).");
18
+ }
19
+ }
20
+
21
+ export default {
22
+ async list() {
23
+ return load();
24
+ },
25
+
26
+ async get(name) {
27
+ const all = await load();
28
+ if (!all[name]) throw new Error(`Projet '${name}' introuvable. project_list pour voir.`);
29
+ return { name, ...all[name] };
30
+ },
31
+
32
+ async set(name, fields = {}) {
33
+ validateName(name);
34
+
35
+ // Valider d'abord les aliases hors verrou projets afin de ne pas garder
36
+ // le lock pendant une I/O vers le registre serveurs.
37
+ let known = null;
38
+ if (fields.servers !== undefined) known = await servers.listServers();
39
+
40
+ let merged;
41
+ await jsonStore.updateJson(PROJECTS_PATH, EMPTY, async (all) => {
42
+ const existing = all[name] || {};
43
+ merged = { ...existing };
44
+ for (const key of ['description', 'ignore', 'local', 'servers', 'tags', 'network']) {
45
+ if (fields[key] !== undefined) merged[key] = fields[key];
46
+ }
47
+
48
+ if (merged.servers && typeof merged.servers === 'object') {
49
+ const serverMap = known || await servers.listServers();
50
+ for (const [role, cfg] of Object.entries(merged.servers)) {
51
+ if (!cfg || !cfg.alias) throw new Error(`servers.${role}: 'alias' requis`);
52
+ if (!serverMap[cfg.alias]) throw new Error(`servers.${role}: alias '${cfg.alias}' inconnu (server_list)`);
53
+ if (!cfg.path) throw new Error(`servers.${role}: 'path' requis`);
54
+ }
55
+ }
56
+
57
+ merged.updatedAt = new Date().toISOString();
58
+ all[name] = merged;
59
+ return all;
60
+ });
61
+ return { name, ...merged };
62
+ },
63
+
64
+ async remove(name) {
65
+ await jsonStore.updateJson(PROJECTS_PATH, EMPTY, (all) => {
66
+ if (!all[name]) throw new Error(`Projet '${name}' introuvable.`);
67
+ delete all[name];
68
+ return all;
69
+ });
70
+ return { success: true, message: `Projet '${name}' supprimé.` };
71
+ },
72
+
73
+ async resolve(name, role = null) {
74
+ const project = await this.get(name);
75
+ const ignore = project.ignore || ['node_modules', '.git', 'dist', 'coverage', '.env'];
76
+ let remote = null;
77
+ let runtime = null;
78
+ let url = null;
79
+ const serverEntries = project.servers || {};
80
+ const roles = Object.keys(serverEntries);
81
+
82
+ if (roles.length) {
83
+ const useRole = role || (serverEntries.prod ? 'prod' : roles[0]);
84
+ const cfg = serverEntries[useRole];
85
+ if (!cfg) throw new Error(`Rôle '${useRole}' absent. Rôles: ${roles.join(', ')}`);
86
+ remote = { type: 'remote', alias: cfg.alias, path: cfg.path, label: `${name}:${useRole}` };
87
+ runtime = cfg.runtime || null;
88
+ url = cfg.url || null;
89
+ }
90
+
91
+ const local = project.local?.path
92
+ ? { type: 'local', path: project.local.path, label: `${name}:local` }
93
+ : null;
94
+
95
+ return {
96
+ name,
97
+ role: role || (serverEntries.prod ? 'prod' : roles[0] || null),
98
+ local,
99
+ remote,
100
+ ignore,
101
+ runtime,
102
+ url,
103
+ description: project.description || null,
104
+ network: project.network || null,
105
+ project
106
+ };
107
+ }
108
+ };
package/queue.js CHANGED
@@ -5,8 +5,8 @@ import config from './config.js';
5
5
 
6
6
  const QUEUE_FILE = path.join(config.dataDir, 'queue.json');
7
7
  const QUEUE_BACKUP = path.join(config.dataDir, 'queue.backup.json');
8
- const SAVE_INTERVAL = 5000;
9
- const MAX_QUEUE_SIZE = 1000;
8
+ const SAVE_INTERVAL = config.saveInterval || 5000;
9
+ const MAX_QUEUE_SIZE = config.maxQueueSize || 1000;
10
10
 
11
11
  // ✅ Mode silencieux par défaut (logs désactivés sauf si MCP_DEBUG=true)
12
12
  const SILENT_MODE = process.env.MCP_DEBUG !== 'true';
@@ -17,6 +17,12 @@ const MAX_LOGS = 500;
17
17
 
18
18
  let saveTimer = null;
19
19
  let isDirty = false;
20
+ let dirtyRevision = 0;
21
+
22
+ function markDirty() {
23
+ isDirty = true;
24
+ dirtyRevision++;
25
+ }
20
26
 
21
27
  // Charger la queue au démarrage
22
28
  async function loadQueue() {
@@ -63,11 +69,17 @@ let saveLock = null;
63
69
  async function saveQueue() {
64
70
  if (!isDirty) return;
65
71
 
66
- // Attendre le verrou précédent
67
- if (saveLock) await saveLock;
72
+ // Attendre le save précédent puis revalider : il a peut-être déjà persisté
73
+ // toutes les mutations connues au moment de cet appel.
74
+ if (saveLock) {
75
+ await saveLock;
76
+ if (!isDirty) return;
77
+ }
68
78
 
69
79
  saveLock = (async () => {
70
80
  isSaving = true;
81
+ let tmpPath = null;
82
+ const revisionAtStart = dirtyRevision;
71
83
  try {
72
84
  try {
73
85
  await fs.copyFile(QUEUE_FILE, QUEUE_BACKUP);
@@ -88,14 +100,19 @@ async function saveQueue() {
88
100
  }
89
101
  }
90
102
 
91
- await fs.writeFile(
92
- QUEUE_FILE,
93
- JSON.stringify(filteredQueue, null, 2)
94
- );
103
+ // Écriture atomique : un crash ne peut plus laisser queue.json tronqué.
104
+ tmpPath = `${QUEUE_FILE}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
105
+ await fs.writeFile(tmpPath, JSON.stringify(filteredQueue, null, 2));
106
+ await fs.rename(tmpPath, QUEUE_FILE);
107
+ tmpPath = null;
95
108
 
96
- isDirty = false;
97
- log('debug', `Queue sauvegardée (${Object.keys(filteredQueue).length} tâches)`);
109
+ // Une mutation a pu arriver pendant les awaits ci-dessus. Ne jamais
110
+ // effacer son dirty flag : le prochain tick la persistera.
111
+ isDirty = dirtyRevision !== revisionAtStart;
112
+ log('debug', `Queue sauvegardée (${Object.keys(filteredQueue).length} tâches, dirty=${isDirty})`);
98
113
  } catch (err) {
114
+ isDirty = true;
115
+ if (tmpPath) await fs.rm(tmpPath, { force: true }).catch(() => {});
99
116
  log('error', `Erreur lors de la sauvegarde de la queue: ${err.message}`);
100
117
  } finally {
101
118
  isSaving = false;
@@ -166,7 +183,7 @@ function addJob(details) {
166
183
  }
167
184
 
168
185
  jobQueue[id] = job;
169
- isDirty = true;
186
+ markDirty();
170
187
  log('info', `Nouvelle tâche ${id} (${job.type}) ajoutée.`);
171
188
  return jobQueue[id];
172
189
  }
@@ -192,7 +209,7 @@ function updateJobStatus(id, status, data = {}) {
192
209
  log('info', `Tâche ${id}: ${oldStatus} -> ${status}`);
193
210
  }
194
211
 
195
- isDirty = true;
212
+ markDirty();
196
213
  }
197
214
  }
198
215
 
@@ -250,7 +267,7 @@ function cleanOldJobs() {
250
267
 
251
268
  if (toDelete.length > 0) {
252
269
  log('info', `${toDelete.length} vieilles tâches supprimées de la queue`);
253
- isDirty = true;
270
+ markDirty();
254
271
  }
255
272
  }
256
273
 
@@ -285,7 +302,7 @@ async function retryJob(id) {
285
302
  delete newJob.completedAt;
286
303
 
287
304
  jobQueue[newJob.id] = newJob;
288
- isDirty = true;
305
+ markDirty();
289
306
 
290
307
  log('info', `Tâche ${id} réessayée -> nouvelle tâche ${newJob.id} (tentative ${newJob.retryCount}/${newJob.maxRetries})`);
291
308
 
@@ -300,6 +317,56 @@ function getCrashedJobs() {
300
317
  );
301
318
  }
302
319
 
320
+ function getRetryableJobs(statusFilter = null) {
321
+ return Object.values(jobQueue).filter(job => {
322
+ if (!['failed', 'crashed'].includes(job.status)) return false;
323
+ if (statusFilter && job.status !== statusFilter) return false;
324
+ if ((job.retryCount || 0) >= (job.maxRetries || 3)) return false;
325
+ return true;
326
+ });
327
+ }
328
+
329
+ /**
330
+ * Purge des jobs terminés/crashés.
331
+ * options = { status?: 'crashed'|'failed'|'completed'|'all_terminal', olderThanDays?: number, dryRun?: bool }
332
+ */
333
+ function purgeJobs(options = {}) {
334
+ const olderThanDays = options.olderThanDays ?? 0;
335
+ const dryRun = options.dryRun === true;
336
+ const status = options.status || 'crashed';
337
+ const now = Date.now();
338
+ const maxAge = olderThanDays > 0 ? olderThanDays * 86400000 : 0;
339
+
340
+ const terminal = new Set(['completed', 'failed', 'crashed', 'partial']);
341
+ const toDelete = [];
342
+
343
+ for (const [id, job] of Object.entries(jobQueue)) {
344
+ let match = false;
345
+ if (status === 'all_terminal') {
346
+ match = terminal.has(job.status);
347
+ } else {
348
+ match = job.status === status;
349
+ }
350
+ if (!match) continue;
351
+
352
+ if (maxAge > 0) {
353
+ const created = job.createdAt ? new Date(job.createdAt).getTime() : now;
354
+ if (now - created < maxAge) continue;
355
+ }
356
+ toDelete.push(id);
357
+ }
358
+
359
+ if (!dryRun) {
360
+ for (const id of toDelete) delete jobQueue[id];
361
+ if (toDelete.length) {
362
+ markDirty();
363
+ log('info', `Purge: ${toDelete.length} tâche(s) supprimée(s) (status=${status})`);
364
+ }
365
+ }
366
+
367
+ return { purged: toDelete.length, ids: toDelete, dryRun };
368
+ }
369
+
303
370
  function getStats() {
304
371
  const stats = {
305
372
  total: Object.keys(jobQueue).length,
@@ -355,6 +422,8 @@ export default {
355
422
  log,
356
423
  retryJob,
357
424
  getCrashedJobs,
425
+ getRetryableJobs,
426
+ purgeJobs,
358
427
  getStats,
359
428
  cleanOldJobs,
360
429
  saveQueue,