@iksdev/shard-cli 0.1.1 → 0.1.4
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/bin/shard.js +294 -4
- package/package.json +1 -1
package/bin/shard.js
CHANGED
|
@@ -4,6 +4,8 @@ const fs = require('fs');
|
|
|
4
4
|
const fsp = require('fs/promises');
|
|
5
5
|
const os = require('os');
|
|
6
6
|
const path = require('path');
|
|
7
|
+
const { Readable } = require('stream');
|
|
8
|
+
const { pipeline } = require('stream/promises');
|
|
7
9
|
|
|
8
10
|
const CONFIG_DIR = path.join(os.homedir(), '.shard-cli');
|
|
9
11
|
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
|
|
@@ -17,7 +19,7 @@ function printHelp() {
|
|
|
17
19
|
Usage:
|
|
18
20
|
shard login --username <name> --password <pass> [--server <url>]
|
|
19
21
|
shard whoami [--server <url>]
|
|
20
|
-
shard sync <folder> [--server <url>] [--dry-run] [--force]
|
|
22
|
+
shard sync <folder> [--server <url>] [--dry-run] [--force] [--once] [--interval-ms <n>]
|
|
21
23
|
shard logout
|
|
22
24
|
shard config show
|
|
23
25
|
shard config set-server <url>
|
|
@@ -25,6 +27,7 @@ Usage:
|
|
|
25
27
|
Examples:
|
|
26
28
|
shard login --server http://localhost:3000 --username admin --password secret
|
|
27
29
|
shard sync ./MonDossier
|
|
30
|
+
shard sync ./MonDossier --once
|
|
28
31
|
shard sync ./MonDossier --dry-run
|
|
29
32
|
`);
|
|
30
33
|
}
|
|
@@ -260,6 +263,18 @@ function guessMime(filePath) {
|
|
|
260
263
|
return map[ext] || 'application/octet-stream';
|
|
261
264
|
}
|
|
262
265
|
|
|
266
|
+
function formatBytes(bytes) {
|
|
267
|
+
const value = Number(bytes || 0);
|
|
268
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
269
|
+
let size = value;
|
|
270
|
+
let idx = 0;
|
|
271
|
+
while (size >= 1024 && idx < units.length - 1) {
|
|
272
|
+
size /= 1024;
|
|
273
|
+
idx += 1;
|
|
274
|
+
}
|
|
275
|
+
return `${size.toFixed(idx === 0 ? 0 : 2)} ${units[idx]}`;
|
|
276
|
+
}
|
|
277
|
+
|
|
263
278
|
async function uploadOneFile(server, token, file) {
|
|
264
279
|
const blob = await fs.openAsBlob(file.absPath, { type: guessMime(file.absPath) });
|
|
265
280
|
const form = new FormData();
|
|
@@ -273,10 +288,270 @@ async function uploadOneFile(server, token, file) {
|
|
|
273
288
|
});
|
|
274
289
|
}
|
|
275
290
|
|
|
291
|
+
function fileListToMap(files) {
|
|
292
|
+
const map = new Map();
|
|
293
|
+
for (const file of files) {
|
|
294
|
+
map.set(file.relPath, file);
|
|
295
|
+
}
|
|
296
|
+
return map;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function makeFingerprint(file) {
|
|
300
|
+
return `${file.size}:${file.mtimeMs}`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function normalizeRemoteRelPath(name) {
|
|
304
|
+
return String(name || '').replace(/\\/g, '/');
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function fetchAllRemoteFiles(server, token) {
|
|
308
|
+
const out = [];
|
|
309
|
+
const limit = 100;
|
|
310
|
+
let offset = 0;
|
|
311
|
+
|
|
312
|
+
while (true) {
|
|
313
|
+
const data = await httpJson(`${server}/api/files?limit=${limit}&offset=${offset}&sort=created_at&order=asc`, {
|
|
314
|
+
method: 'GET',
|
|
315
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
316
|
+
});
|
|
317
|
+
const rows = Array.isArray(data.files) ? data.files : [];
|
|
318
|
+
for (const row of rows) {
|
|
319
|
+
out.push({
|
|
320
|
+
id: row.id,
|
|
321
|
+
relPath: normalizeRemoteRelPath(row.original_name),
|
|
322
|
+
size: Number(row.file_size || 0)
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
if (!data.pagination?.hasMore) break;
|
|
326
|
+
offset += limit;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return out;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function downloadRemoteFile(server, token, remoteFile, destPath) {
|
|
333
|
+
const res = await fetch(`${server}/api/files/${remoteFile.id}/download`, {
|
|
334
|
+
method: 'GET',
|
|
335
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
if (!res.ok) {
|
|
339
|
+
let detail = `HTTP ${res.status}`;
|
|
340
|
+
try {
|
|
341
|
+
const data = await res.json();
|
|
342
|
+
detail = data.message || data.error || detail;
|
|
343
|
+
} catch {
|
|
344
|
+
// ignore JSON parse failures
|
|
345
|
+
}
|
|
346
|
+
throw new Error(detail);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (!res.body) {
|
|
350
|
+
throw new Error('Reponse de telechargement vide');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
await fsp.mkdir(path.dirname(destPath), { recursive: true });
|
|
354
|
+
const tempPath = `${destPath}.part`;
|
|
355
|
+
const writable = fs.createWriteStream(tempPath);
|
|
356
|
+
await pipeline(Readable.fromWeb(res.body), writable);
|
|
357
|
+
await fsp.rename(tempPath, destPath);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function pullMissingRemoteFiles(server, token, rootDir, state, localFiles, remoteFiles, dryRun) {
|
|
361
|
+
const localMap = fileListToMap(localFiles);
|
|
362
|
+
const missingRemote = remoteFiles.filter((rf) => !localMap.has(rf.relPath));
|
|
363
|
+
|
|
364
|
+
if (missingRemote.length === 0) return { downloaded: 0, errors: 0 };
|
|
365
|
+
|
|
366
|
+
console.log(`A telecharger: ${missingRemote.length}`);
|
|
367
|
+
if (dryRun) {
|
|
368
|
+
for (const rf of missingRemote.slice(0, 50)) console.log(`- ${rf.relPath} (remote)`); // eslint-disable-line no-console
|
|
369
|
+
if (missingRemote.length > 50) console.log(`... +${missingRemote.length - 50} autres`);
|
|
370
|
+
return { downloaded: 0, errors: 0 };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
let downloaded = 0;
|
|
374
|
+
let errors = 0;
|
|
375
|
+
for (let i = 0; i < missingRemote.length; i += 1) {
|
|
376
|
+
const rf = missingRemote[i];
|
|
377
|
+
const label = `[DOWN ${i + 1}/${missingRemote.length}]`;
|
|
378
|
+
try {
|
|
379
|
+
const absPath = path.join(rootDir, ...rf.relPath.split('/'));
|
|
380
|
+
console.log(`${label} GET ${rf.relPath} (${formatBytes(rf.size)})`);
|
|
381
|
+
await downloadRemoteFile(server, token, rf, absPath);
|
|
382
|
+
const st = await fsp.stat(absPath);
|
|
383
|
+
state.files[rf.relPath] = {
|
|
384
|
+
size: st.size,
|
|
385
|
+
mtimeMs: Math.round(st.mtimeMs),
|
|
386
|
+
uploadedAt: new Date().toISOString(),
|
|
387
|
+
remoteId: rf.id
|
|
388
|
+
};
|
|
389
|
+
downloaded += 1;
|
|
390
|
+
console.log(`${label} OK ${rf.relPath}`);
|
|
391
|
+
} catch (error) {
|
|
392
|
+
errors += 1;
|
|
393
|
+
console.error(`${label} FAIL ${rf.relPath} -> ${error.message}`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return { downloaded, errors };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function seedStateFromRemote(state, localFiles, remoteFiles) {
|
|
401
|
+
const remoteByPath = new Map(remoteFiles.map((rf) => [rf.relPath, rf]));
|
|
402
|
+
for (const lf of localFiles) {
|
|
403
|
+
if (state.files[lf.relPath]) continue;
|
|
404
|
+
const remote = remoteByPath.get(lf.relPath);
|
|
405
|
+
if (!remote) continue;
|
|
406
|
+
if (Number(remote.size) !== Number(lf.size)) continue;
|
|
407
|
+
state.files[lf.relPath] = {
|
|
408
|
+
size: lf.size,
|
|
409
|
+
mtimeMs: lf.mtimeMs,
|
|
410
|
+
uploadedAt: new Date().toISOString(),
|
|
411
|
+
remoteId: remote.id
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function deleteRemoteFile(server, token, remoteId) {
|
|
417
|
+
return httpJson(`${server}/api/files/${remoteId}`, {
|
|
418
|
+
method: 'DELETE',
|
|
419
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function renameRemoteFile(server, token, remoteId, newName) {
|
|
424
|
+
return httpJson(`${server}/api/files/${remoteId}/rename`, {
|
|
425
|
+
method: 'PATCH',
|
|
426
|
+
headers: {
|
|
427
|
+
Authorization: `Bearer ${token}`,
|
|
428
|
+
'Content-Type': 'application/json'
|
|
429
|
+
},
|
|
430
|
+
body: JSON.stringify({ newName })
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function sleep(ms) {
|
|
435
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function mirrorRealtime(server, token, rootDir, state, intervalMs) {
|
|
439
|
+
console.log(`Mode temps reel actif (${intervalMs} ms). Ctrl+C pour quitter.`);
|
|
440
|
+
|
|
441
|
+
let stopped = false;
|
|
442
|
+
const onStop = () => { stopped = true; };
|
|
443
|
+
process.on('SIGINT', onStop);
|
|
444
|
+
process.on('SIGTERM', onStop);
|
|
445
|
+
|
|
446
|
+
let previousFiles = await listFilesRecursive(rootDir);
|
|
447
|
+
let previousMap = fileListToMap(previousFiles);
|
|
448
|
+
|
|
449
|
+
while (!stopped) {
|
|
450
|
+
await sleep(intervalMs);
|
|
451
|
+
if (stopped) break;
|
|
452
|
+
|
|
453
|
+
let currentFiles;
|
|
454
|
+
try {
|
|
455
|
+
currentFiles = await listFilesRecursive(rootDir);
|
|
456
|
+
} catch (error) {
|
|
457
|
+
console.error(`Watcher error: ${error.message}`);
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const currentMap = fileListToMap(currentFiles);
|
|
462
|
+
const prevKeys = new Set(previousMap.keys());
|
|
463
|
+
const currKeys = new Set(currentMap.keys());
|
|
464
|
+
const removed = [...prevKeys].filter((k) => !currKeys.has(k)).map((k) => previousMap.get(k));
|
|
465
|
+
const added = [...currKeys].filter((k) => !prevKeys.has(k)).map((k) => currentMap.get(k));
|
|
466
|
+
const changed = [...currKeys]
|
|
467
|
+
.filter((k) => prevKeys.has(k))
|
|
468
|
+
.map((k) => ({ prev: previousMap.get(k), cur: currentMap.get(k) }))
|
|
469
|
+
.filter((p) => p.prev.size !== p.cur.size || p.prev.mtimeMs !== p.cur.mtimeMs)
|
|
470
|
+
.map((p) => p.cur);
|
|
471
|
+
|
|
472
|
+
// Detecter les renames simples (meme taille + mtime) pour appeler PATCH rename.
|
|
473
|
+
const addedByFinger = new Map();
|
|
474
|
+
for (const a of added) {
|
|
475
|
+
const key = makeFingerprint(a);
|
|
476
|
+
if (!addedByFinger.has(key)) addedByFinger.set(key, []);
|
|
477
|
+
addedByFinger.get(key).push(a);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const renamedPairs = [];
|
|
481
|
+
const trulyRemoved = [];
|
|
482
|
+
for (const r of removed) {
|
|
483
|
+
const matches = addedByFinger.get(makeFingerprint(r)) || [];
|
|
484
|
+
const remoteId = state.files[r.relPath]?.remoteId || null;
|
|
485
|
+
if (remoteId && matches.length > 0) {
|
|
486
|
+
const to = matches.shift();
|
|
487
|
+
renamedPairs.push({ from: r, to, remoteId });
|
|
488
|
+
} else {
|
|
489
|
+
trulyRemoved.push(r);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
const renamedTargets = new Set(renamedPairs.map((p) => p.to.relPath));
|
|
493
|
+
const trulyAdded = added.filter((a) => !renamedTargets.has(a.relPath));
|
|
494
|
+
|
|
495
|
+
for (const pair of renamedPairs) {
|
|
496
|
+
try {
|
|
497
|
+
await renameRemoteFile(server, token, pair.remoteId, pair.to.relPath);
|
|
498
|
+
delete state.files[pair.from.relPath];
|
|
499
|
+
state.files[pair.to.relPath] = {
|
|
500
|
+
size: pair.to.size,
|
|
501
|
+
mtimeMs: pair.to.mtimeMs,
|
|
502
|
+
uploadedAt: new Date().toISOString(),
|
|
503
|
+
remoteId: pair.remoteId
|
|
504
|
+
};
|
|
505
|
+
console.log(`[WATCH] RENAME ${pair.from.relPath} -> ${pair.to.relPath}`);
|
|
506
|
+
} catch (error) {
|
|
507
|
+
console.error(`[WATCH] RENAME FAIL ${pair.from.relPath} -> ${pair.to.relPath}: ${error.message}`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
for (const file of trulyRemoved) {
|
|
512
|
+
const remoteId = state.files[file.relPath]?.remoteId || null;
|
|
513
|
+
if (!remoteId) {
|
|
514
|
+
delete state.files[file.relPath];
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
try {
|
|
518
|
+
await deleteRemoteFile(server, token, remoteId);
|
|
519
|
+
delete state.files[file.relPath];
|
|
520
|
+
console.log(`[WATCH] DELETE ${file.relPath}`);
|
|
521
|
+
} catch (error) {
|
|
522
|
+
console.error(`[WATCH] DELETE FAIL ${file.relPath}: ${error.message}`);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const uploads = [...trulyAdded, ...changed];
|
|
527
|
+
for (const file of uploads) {
|
|
528
|
+
try {
|
|
529
|
+
const result = await uploadOneFile(server, token, file);
|
|
530
|
+
state.files[file.relPath] = {
|
|
531
|
+
size: file.size,
|
|
532
|
+
mtimeMs: file.mtimeMs,
|
|
533
|
+
uploadedAt: new Date().toISOString(),
|
|
534
|
+
remoteId: result?.file?.id || state.files[file.relPath]?.remoteId || null
|
|
535
|
+
};
|
|
536
|
+
console.log(`[WATCH] UPLOAD ${file.relPath} (${formatBytes(file.size)})`);
|
|
537
|
+
} catch (error) {
|
|
538
|
+
console.error(`[WATCH] UPLOAD FAIL ${file.relPath}: ${error.message}`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
await writeState(rootDir, state);
|
|
543
|
+
previousFiles = currentFiles;
|
|
544
|
+
previousMap = currentMap;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
process.off('SIGINT', onStop);
|
|
548
|
+
process.off('SIGTERM', onStop);
|
|
549
|
+
}
|
|
550
|
+
|
|
276
551
|
async function syncFolder(positionals, flags) {
|
|
277
552
|
const target = positionals[0];
|
|
278
553
|
if (!target) {
|
|
279
|
-
throw new Error('Usage: shard sync <folder> [--server <url>] [--dry-run] [--force]');
|
|
554
|
+
throw new Error('Usage: shard sync <folder> [--server <url>] [--dry-run] [--force] [--once] [--interval-ms <n>]');
|
|
280
555
|
}
|
|
281
556
|
|
|
282
557
|
const rootDir = path.resolve(process.cwd(), target);
|
|
@@ -300,10 +575,18 @@ async function syncFolder(positionals, flags) {
|
|
|
300
575
|
headers: { Authorization: `Bearer ${token}` }
|
|
301
576
|
});
|
|
302
577
|
|
|
303
|
-
const files = await listFilesRecursive(rootDir);
|
|
304
578
|
const state = await readState(rootDir);
|
|
305
579
|
const force = Boolean(flags.force);
|
|
306
580
|
const dryRun = Boolean(flags['dry-run']);
|
|
581
|
+
const once = Boolean(flags.once);
|
|
582
|
+
const intervalMs = Math.max(parseInt(flags['interval-ms'] || '2000', 10) || 2000, 500);
|
|
583
|
+
|
|
584
|
+
const remoteFiles = await fetchAllRemoteFiles(server, token);
|
|
585
|
+
const initialLocalFiles = await listFilesRecursive(rootDir);
|
|
586
|
+
await pullMissingRemoteFiles(server, token, rootDir, state, initialLocalFiles, remoteFiles, dryRun);
|
|
587
|
+
|
|
588
|
+
const files = await listFilesRecursive(rootDir);
|
|
589
|
+
seedStateFromRemote(state, files, remoteFiles);
|
|
307
590
|
|
|
308
591
|
const changed = files.filter((f) => {
|
|
309
592
|
if (force) return true;
|
|
@@ -316,6 +599,7 @@ async function syncFolder(positionals, flags) {
|
|
|
316
599
|
console.log(`Server: ${server}`);
|
|
317
600
|
console.log(`Dossier: ${rootDir}`);
|
|
318
601
|
console.log(`Total: ${files.length} fichier(s)`);
|
|
602
|
+
console.log(`Distants: ${remoteFiles.length}`);
|
|
319
603
|
console.log(`A uploader: ${changed.length}`);
|
|
320
604
|
console.log(`Inchanges: ${unchanged}`);
|
|
321
605
|
|
|
@@ -332,7 +616,10 @@ async function syncFolder(positionals, flags) {
|
|
|
332
616
|
const file = changed[i];
|
|
333
617
|
const label = `[${i + 1}/${changed.length}]`;
|
|
334
618
|
try {
|
|
619
|
+
const startedAt = Date.now();
|
|
620
|
+
console.log(`${label} UPLOAD ${file.relPath} (${formatBytes(file.size)})`);
|
|
335
621
|
const result = await uploadOneFile(server, token, file);
|
|
622
|
+
const tookSeconds = ((Date.now() - startedAt) / 1000).toFixed(1);
|
|
336
623
|
success += 1;
|
|
337
624
|
state.files[file.relPath] = {
|
|
338
625
|
size: file.size,
|
|
@@ -340,7 +627,7 @@ async function syncFolder(positionals, flags) {
|
|
|
340
627
|
uploadedAt: new Date().toISOString(),
|
|
341
628
|
remoteId: result?.file?.id || null
|
|
342
629
|
};
|
|
343
|
-
console.log(`${label} OK ${file.relPath}`);
|
|
630
|
+
console.log(`${label} OK ${file.relPath} (${tookSeconds}s)`);
|
|
344
631
|
} catch (error) {
|
|
345
632
|
failed += 1;
|
|
346
633
|
console.error(`${label} FAIL ${file.relPath} -> ${error.message}`);
|
|
@@ -355,6 +642,9 @@ async function syncFolder(positionals, flags) {
|
|
|
355
642
|
|
|
356
643
|
console.log(`Termine. Uploades: ${success}, erreurs: ${failed}`);
|
|
357
644
|
if (failed > 0) process.exitCode = 2;
|
|
645
|
+
if (!once && !dryRun) {
|
|
646
|
+
await mirrorRealtime(server, token, rootDir, state, intervalMs);
|
|
647
|
+
}
|
|
358
648
|
}
|
|
359
649
|
|
|
360
650
|
async function main() {
|