@iksdev/shard-cli 0.1.26 → 0.1.28

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.
Files changed (3) hide show
  1. package/bin/shard.js +387 -382
  2. package/notify-discord.js +147 -55
  3. package/package.json +1 -1
package/bin/shard.js CHANGED
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  /* eslint-disable no-console */
3
- const fs = require('fs');
4
- const fsp = require('fs/promises');
5
- const os = require('os');
6
- const path = require('path');
7
- const crypto = require('crypto');
8
- const readline = require('readline');
9
- const { WebSocket } = require('ws');
10
- const { Readable } = require('stream');
11
- const { pipeline } = require('stream/promises');
3
+ const fs = require('fs');
4
+ const fsp = require('fs/promises');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const crypto = require('crypto');
8
+ const readline = require('readline');
9
+ const { WebSocket } = require('ws');
10
+ const { Readable } = require('stream');
11
+ const { pipeline } = require('stream/promises');
12
12
 
13
13
  const CONFIG_DIR = path.join(os.homedir(), '.shard-cli');
14
14
  const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
@@ -16,37 +16,42 @@ const STATE_FILE = '.shard-sync-state.json';
16
16
  const DEFAULT_SERVER = 'https://shard-0ow4.onrender.com';
17
17
  const IGNORED_DIRS = new Set(['.git', 'node_modules']);
18
18
 
19
- function printHelp() {
20
- console.log(`Shard CLI
21
-
22
- Commandes:
23
- shard login
24
- shard whoami
25
- shard sync
26
- shard share
27
- shard logout
28
- shard config show
29
- shard config set-server <url>
30
-
31
- Mode interactif:
32
- La CLI pose les questions (fichier, dossier, serveur, identifiants) si tu ne passes pas d'arguments.
33
-
34
- Options avancees (optionnelles):
35
- login: --username <name> --password <pass> --server <url>
36
- whoami: --server <url>
37
- sync: <folder> --server <url> --dry-run --force --once --interval-ms <n>
38
- share: <file> --server <url> --limits <n> --temps <jours> --upload
39
-
40
- Examples:
41
- shard login
42
- shard sync
43
- shard share
44
- shard sync ./MonDossier --once
45
- shard share ./MonFichier.mp4 --upload
46
- `);
47
- }
48
-
49
- function parseArgs(rawArgs) {
19
+ function printHelp() {
20
+ console.log(`
21
+ ╔══════════════════════════════════════════════════╗
22
+ ║ Shard CLI 🚀 ║
23
+ ╚══════════════════════════════════════════════════╝
24
+
25
+ Commandes disponibles:
26
+ shard login Se connecter au serveur
27
+ shard whoami Afficher l'utilisateur connecte
28
+ shard sync <dossier> Synchroniser un dossier local
29
+ shard share <fichier> Partager un fichier via relay
30
+ shard logout Se deconnecter
31
+ shard config show Afficher la configuration
32
+ shard config set-server <url> Changer de serveur
33
+
34
+ Mode interactif:
35
+ Lance une commande sans arguments et la CLI te guidera etape par etape.
36
+
37
+ Options avancees:
38
+ login --username <n> --password <pass> [--server <url>]
39
+ whoami [--server <url>]
40
+ sync <dossier> [--server <url>] [--dry-run] [--force] [--once] [--interval-ms <n>]
41
+ share <fichier> [--server <url>] [--limits <n>] [--temps <jours>] [--upload]
42
+
43
+ Exemples:
44
+ shard login
45
+ shard sync ./MonDossier
46
+ shard sync ./MonDossier --once
47
+ shard share ./MonFichier.mp4
48
+ shard share ./MonFichier.mp4 --upload
49
+
50
+ Serveur par defaut: https://shard-0ow4.onrender.com
51
+ `);
52
+ }
53
+
54
+ function parseArgs(rawArgs) {
50
55
  const args = [...rawArgs];
51
56
  const command = args.shift();
52
57
  const positionals = [];
@@ -67,76 +72,76 @@ function parseArgs(rawArgs) {
67
72
  flags[key] = next;
68
73
  i += 1;
69
74
  }
70
- return { command, positionals, flags };
71
- }
72
-
73
- function isInteractive() {
74
- return Boolean(process.stdin.isTTY && process.stdout.isTTY);
75
- }
76
-
77
- function stripWrappingQuotes(value) {
78
- let out = String(value || '').trim();
79
- if (!out) return out;
80
- if ((out.startsWith('"') && out.endsWith('"')) || (out.startsWith("'") && out.endsWith("'"))) {
81
- out = out.slice(1, -1).trim();
82
- }
83
- return out;
84
- }
85
-
86
- async function askText(label, defaultValue = '') {
87
- const rl = readline.createInterface({
88
- input: process.stdin,
89
- output: process.stdout
90
- });
91
- const suffix = defaultValue ? ` [${defaultValue}]` : '';
92
- const answer = await new Promise((resolve) => rl.question(`${label}${suffix}: `, resolve));
93
- rl.close();
94
- const trimmed = stripWrappingQuotes(answer);
95
- return trimmed || stripWrappingQuotes(defaultValue);
96
- }
97
-
98
- async function askSecret(label) {
99
- if (!isInteractive()) return '';
100
- return new Promise((resolve, reject) => {
101
- const stdin = process.stdin;
102
- let value = '';
103
- process.stdout.write(`${label}: `);
104
-
105
- const cleanup = () => {
106
- stdin.off('data', onData);
107
- try { if (stdin.isTTY) stdin.setRawMode(false); } catch (_) {}
108
- stdin.pause();
109
- };
110
-
111
- const onData = (buf) => {
112
- const char = String(buf || '');
113
- if (char === '\u0003') {
114
- cleanup();
115
- reject(new Error('Interrompu'));
116
- return;
117
- }
118
- if (char === '\r' || char === '\n') {
119
- process.stdout.write('\n');
120
- cleanup();
121
- resolve(value.trim());
122
- return;
123
- }
124
- if (char === '\u007f' || char === '\b') {
125
- if (value.length > 0) {
126
- value = value.slice(0, -1);
127
- process.stdout.write('\b \b');
128
- }
129
- return;
130
- }
131
- value += char;
132
- process.stdout.write('*');
133
- };
134
-
135
- try { if (stdin.isTTY) stdin.setRawMode(true); } catch (_) {}
136
- stdin.resume();
137
- stdin.on('data', onData);
138
- });
139
- }
75
+ return { command, positionals, flags };
76
+ }
77
+
78
+ function isInteractive() {
79
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
80
+ }
81
+
82
+ function stripWrappingQuotes(value) {
83
+ let out = String(value || '').trim();
84
+ if (!out) return out;
85
+ if ((out.startsWith('"') && out.endsWith('"')) || (out.startsWith("'") && out.endsWith("'"))) {
86
+ out = out.slice(1, -1).trim();
87
+ }
88
+ return out;
89
+ }
90
+
91
+ async function askText(label, defaultValue = '') {
92
+ const rl = readline.createInterface({
93
+ input: process.stdin,
94
+ output: process.stdout
95
+ });
96
+ const suffix = defaultValue ? ` [${defaultValue}]` : '';
97
+ const answer = await new Promise((resolve) => rl.question(`${label}${suffix}: `, resolve));
98
+ rl.close();
99
+ const trimmed = stripWrappingQuotes(answer);
100
+ return trimmed || stripWrappingQuotes(defaultValue);
101
+ }
102
+
103
+ async function askSecret(label) {
104
+ if (!isInteractive()) return '';
105
+ return new Promise((resolve, reject) => {
106
+ const stdin = process.stdin;
107
+ let value = '';
108
+ process.stdout.write(`${label}: `);
109
+
110
+ const cleanup = () => {
111
+ stdin.off('data', onData);
112
+ try { if (stdin.isTTY) stdin.setRawMode(false); } catch (_) {}
113
+ stdin.pause();
114
+ };
115
+
116
+ const onData = (buf) => {
117
+ const char = String(buf || '');
118
+ if (char === '\u0003') {
119
+ cleanup();
120
+ reject(new Error('Interrompu'));
121
+ return;
122
+ }
123
+ if (char === '\r' || char === '\n') {
124
+ process.stdout.write('\n');
125
+ cleanup();
126
+ resolve(value.trim());
127
+ return;
128
+ }
129
+ if (char === '\u007f' || char === '\b') {
130
+ if (value.length > 0) {
131
+ value = value.slice(0, -1);
132
+ process.stdout.write('\b \b');
133
+ }
134
+ return;
135
+ }
136
+ value += char;
137
+ process.stdout.write('*');
138
+ };
139
+
140
+ try { if (stdin.isTTY) stdin.setRawMode(true); } catch (_) {}
141
+ stdin.resume();
142
+ stdin.on('data', onData);
143
+ });
144
+ }
140
145
 
141
146
  function normalizeServer(input) {
142
147
  const raw = String(input || '').trim();
@@ -190,24 +195,24 @@ async function httpJson(url, options = {}) {
190
195
  return data;
191
196
  }
192
197
 
193
- async function login(flags) {
194
- const config = await readConfig();
195
- let username = String(flags.username || '').trim();
196
- let password = String(flags.password || '').trim();
197
- let server = getServer(flags, config);
198
-
199
- if (isInteractive()) {
200
- if (!username) username = await askText('Username');
201
- if (!password) password = await askSecret('Password');
202
- if (!flags.server) {
203
- const typedServer = await askText('Serveur', server);
204
- server = normalizeServer(typedServer || server);
205
- }
206
- }
207
-
208
- if (!username || !password) {
209
- throw new Error('Usage: shard login --username <name> --password <pass> [--server <url>]');
210
- }
198
+ async function login(flags) {
199
+ const config = await readConfig();
200
+ let username = String(flags.username || '').trim();
201
+ let password = String(flags.password || '').trim();
202
+ let server = getServer(flags, config);
203
+
204
+ if (isInteractive()) {
205
+ if (!username) username = await askText('Username');
206
+ if (!password) password = await askSecret('Password');
207
+ if (!flags.server) {
208
+ const typedServer = await askText('Serveur', server);
209
+ server = normalizeServer(typedServer || server);
210
+ }
211
+ }
212
+
213
+ if (!username || !password) {
214
+ throw new Error('Usage: shard login --username <name> --password <pass> [--server <url>]');
215
+ }
211
216
 
212
217
  const data = await httpJson(`${server}/api/auth/login`, {
213
218
  method: 'POST',
@@ -388,46 +393,46 @@ async function findRemoteFileByNameAndSize(server, token, fileName, fileSize) {
388
393
  return rows.find((row) => row.original_name === fileName && Number(row.file_size || 0) === Number(fileSize || 0)) || null;
389
394
  }
390
395
 
391
- function parseOptionalPositiveInt(raw, flagName) {
392
- if (raw === undefined || raw === null) return undefined;
393
- const n = parseInt(String(raw), 10);
394
- if (Number.isNaN(n) || n < 0) {
395
- throw new Error(`${flagName} doit etre un entier >= 0`);
396
- }
397
- return n;
398
- }
399
-
400
- function toWebSocketUrl(serverUrl, token) {
401
- const url = new URL(serverUrl);
402
- url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
403
- url.pathname = '/api/relay/ws';
404
- url.search = '';
405
- url.searchParams.set('token', token);
406
- return url.toString();
407
- }
408
-
409
- function normalizeAbsPathForId(absPath) {
410
- const raw = String(absPath || '').trim();
411
- if (process.platform === 'win32') return raw.toLowerCase();
412
- return raw;
413
- }
414
-
415
- function stableRelayFileId(absPath) {
416
- return crypto
417
- .createHash('sha256')
418
- .update(normalizeAbsPathForId(absPath))
419
- .digest('base64url')
420
- .slice(0, 24);
421
- }
422
-
423
- async function shareFile(positionals, flags) {
424
- let target = stripWrappingQuotes(positionals[0]);
425
- if (!target && isInteractive()) {
426
- target = await askText('Fichier a partager');
427
- }
428
- if (!target) {
429
- throw new Error('Usage: shard share <file> [--server <url>] [--limits <n>] [--temps <jours>] [--upload]');
430
- }
396
+ function parseOptionalPositiveInt(raw, flagName) {
397
+ if (raw === undefined || raw === null) return undefined;
398
+ const n = parseInt(String(raw), 10);
399
+ if (Number.isNaN(n) || n < 0) {
400
+ throw new Error(`${flagName} doit etre un entier >= 0`);
401
+ }
402
+ return n;
403
+ }
404
+
405
+ function toWebSocketUrl(serverUrl, token) {
406
+ const url = new URL(serverUrl);
407
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
408
+ url.pathname = '/api/relay/ws';
409
+ url.search = '';
410
+ url.searchParams.set('token', token);
411
+ return url.toString();
412
+ }
413
+
414
+ function normalizeAbsPathForId(absPath) {
415
+ const raw = String(absPath || '').trim();
416
+ if (process.platform === 'win32') return raw.toLowerCase();
417
+ return raw;
418
+ }
419
+
420
+ function stableRelayFileId(absPath) {
421
+ return crypto
422
+ .createHash('sha256')
423
+ .update(normalizeAbsPathForId(absPath))
424
+ .digest('base64url')
425
+ .slice(0, 24);
426
+ }
427
+
428
+ async function shareFile(positionals, flags) {
429
+ let target = stripWrappingQuotes(positionals[0]);
430
+ if (!target && isInteractive()) {
431
+ target = await askText('Fichier a partager');
432
+ }
433
+ if (!target) {
434
+ throw new Error('Usage: shard share <file> [--server <url>] [--limits <n>] [--temps <jours>] [--upload]');
435
+ }
431
436
 
432
437
  const absPath = path.resolve(process.cwd(), target);
433
438
  if (!(await pathExists(absPath))) {
@@ -438,9 +443,9 @@ async function shareFile(positionals, flags) {
438
443
  throw new Error(`Ce n'est pas un fichier: ${absPath}`);
439
444
  }
440
445
 
441
- const limits = parseOptionalPositiveInt(flags.limits, '--limits');
442
- const temps = parseOptionalPositiveInt(flags.temps, '--temps');
443
- const localMode = !Boolean(flags.upload);
446
+ const limits = parseOptionalPositiveInt(flags.limits, '--limits');
447
+ const temps = parseOptionalPositiveInt(flags.temps, '--temps');
448
+ const localMode = !Boolean(flags.upload);
444
449
 
445
450
  const config = await readConfig();
446
451
  const server = getServer(flags, config);
@@ -449,208 +454,208 @@ async function shareFile(positionals, flags) {
449
454
  throw new Error('Non connecte. Lance: shard login --username ... --password ...');
450
455
  }
451
456
 
452
- await httpJson(`${server}/api/auth/verify`, {
453
- method: 'POST',
454
- headers: { Authorization: `Bearer ${token}` }
455
- });
456
-
457
- const fileName = path.basename(absPath);
458
- if (localMode) {
459
- const mimeType = guessMime(absPath);
460
- const relayFileId = stableRelayFileId(absPath);
461
- const wsUrl = toWebSocketUrl(server, token);
462
- const relaySocket = new WebSocket(wsUrl);
463
-
464
- let relayClientId = '';
465
- let closed = false;
466
- let heartbeat = null;
467
- let createdShareId = null;
468
- let shareRevoked = false;
469
-
470
- const revokeCreatedShare = async () => {
471
- if (!createdShareId || shareRevoked) return;
472
- shareRevoked = true;
473
- try {
474
- await httpJson(`${server}/api/share/${createdShareId}`, {
475
- method: 'DELETE',
476
- headers: { Authorization: `Bearer ${token}` }
477
- });
478
- console.log('Lien relay revoque.');
479
- } catch (err) {
480
- console.error(`Impossible de revoquer le lien automatiquement: ${err.message}`);
481
- }
482
- };
483
-
484
- const closeRelay = () => {
485
- if (closed) return;
486
- closed = true;
487
- try { relaySocket.close(); } catch (_) {}
488
- };
489
-
490
- const stopSignals = ['SIGINT', 'SIGTERM'];
491
- const stopRelayShare = (signal) => {
492
- if (closed) return;
493
- console.log(`Arret du partage (${signal}). Revocation du lien...`);
494
- Promise.resolve()
495
- .then(async () => {
496
- if (heartbeat) {
497
- clearInterval(heartbeat);
498
- heartbeat = null;
499
- }
500
- await revokeCreatedShare();
501
- })
502
- .finally(() => {
503
- closeRelay();
504
- });
505
- };
506
- for (const sig of stopSignals) {
507
- process.on(sig, stopRelayShare);
508
- }
509
-
510
- const openPromise = new Promise((resolve, reject) => {
511
- const timer = setTimeout(() => {
512
- reject(new Error('Timeout connexion relay'));
513
- }, 15000);
514
-
515
- relaySocket.on('open', () => {
516
- clearTimeout(timer);
517
- resolve();
518
- });
519
- relaySocket.on('error', (event) => {
520
- clearTimeout(timer);
521
- reject(new Error(`Echec connexion relay: ${event?.message || 'ws error'}`));
522
- });
523
- });
524
-
525
- try {
526
- await openPromise;
527
-
528
- const helloPromise = new Promise((resolve, reject) => {
529
- const timer = setTimeout(() => reject(new Error('Handshake relay timeout')), 12000);
530
- relaySocket.on('message', (event) => {
531
- let msg = null;
532
- try { msg = JSON.parse(String(event || '')); } catch (_) { return; }
533
- if (!msg || typeof msg !== 'object') return;
534
- if (msg.type === 'hello_ack' && msg.relayClientId) {
535
- clearTimeout(timer);
536
- relayClientId = String(msg.relayClientId);
537
- resolve();
538
- }
539
- });
540
- });
541
- await helloPromise;
542
-
543
- const registerPromise = new Promise((resolve, reject) => {
544
- const timer = setTimeout(() => reject(new Error('Enregistrement relay timeout')), 12000);
545
- relaySocket.on('message', (event) => {
546
- let msg = null;
547
- try { msg = JSON.parse(String(event || '')); } catch (_) { return; }
548
- if (!msg || typeof msg !== 'object') return;
549
- if (msg.type === 'register_ack' && String(msg.relayFileId || '') === relayFileId) {
550
- clearTimeout(timer);
551
- resolve();
552
- }
553
- });
554
- });
555
- relaySocket.send(JSON.stringify({ type: 'register_file', relayFileId }));
556
- await registerPromise;
557
-
558
- relaySocket.on('message', async (event) => {
559
- let msg = null;
560
- try { msg = JSON.parse(String(event || '')); } catch (_) { return; }
561
- if (!msg || typeof msg !== 'object') return;
562
- if (msg.type === 'heartbeat_ack') return;
563
- if (msg.type !== 'stream_request') return;
564
- if (String(msg.relayFileId || '') !== relayFileId) return;
565
- const uploadUrl = String(msg.uploadUrl || '');
566
- if (!uploadUrl) return;
567
-
568
- try {
569
- const stream = fs.createReadStream(absPath);
570
- const response = await fetch(uploadUrl, {
571
- method: 'POST',
572
- headers: {
573
- 'Content-Type': mimeType,
574
- 'Content-Length': String(st.size)
575
- },
576
- body: stream,
577
- duplex: 'half'
578
- });
579
- if (!response.ok) {
580
- const reason = await response.text().catch(() => '');
581
- console.error(`Relay upload failed: HTTP ${response.status} ${reason}`.trim());
582
- }
583
- } catch (err) {
584
- console.error(`Relay upload error: ${err.message}`);
585
- }
586
- });
587
-
588
- heartbeat = setInterval(() => {
589
- if (relaySocket.readyState === WebSocket.OPEN) {
590
- relaySocket.send(JSON.stringify({ type: 'heartbeat', ts: Date.now() }));
591
- }
592
- }, 15000);
593
-
594
- const payload = {
595
- fileName,
596
- fileSize: st.size,
597
- mimeType,
598
- // Compat backend ancien + nouveau:
599
- relayClientId,
600
- relayFileId
601
- };
602
- if (limits !== undefined && limits > 0) payload.maxDownloads = limits;
603
- if (temps !== undefined && temps > 0) payload.expiresInDays = temps;
604
-
605
- const created = await httpJson(`${server}/api/share/create-local`, {
606
- method: 'POST',
607
- headers: {
608
- Authorization: `Bearer ${token}`,
609
- 'Content-Type': 'application/json'
610
- },
611
- body: JSON.stringify(payload)
612
- });
613
-
614
- const share = created?.share || {};
615
- if (share?.id) {
616
- const n = Number(share.id);
617
- if (Number.isFinite(n) && n > 0) createdShareId = n;
618
- }
619
- console.log(`Partage relay cree pour: ${fileName}`);
620
- if (share.url) console.log(`URL Shard: ${share.url}`);
621
- if (share.token) console.log(`Token: ${share.token}`);
622
- console.log(`Relay client id: ${relayClientId}`);
623
- console.log(`Relay file id: ${relayFileId}`);
624
- console.log(`Limite downloads: ${limits && limits > 0 ? limits : 'illimitee'}`);
625
- console.log(`Expiration: ${temps && temps > 0 ? `${temps} jour(s)` : 'aucune'}`);
626
- console.log('Laisse cette commande ouverte tant que le partage doit fonctionner.');
627
-
628
- await new Promise((resolve) => {
629
- relaySocket.on('close', () => resolve());
630
- });
631
- if (heartbeat) {
632
- clearInterval(heartbeat);
633
- heartbeat = null;
634
- }
635
- for (const sig of stopSignals) {
636
- process.off(sig, stopRelayShare);
637
- }
638
- return;
639
- } catch (error) {
640
- if (heartbeat) {
641
- clearInterval(heartbeat);
642
- heartbeat = null;
643
- }
644
- await revokeCreatedShare();
645
- for (const sig of stopSignals) {
646
- process.off(sig, stopRelayShare);
647
- }
648
- closeRelay();
649
- throw error;
650
- }
651
- }
652
-
653
- let remote = await findRemoteFileByNameAndSize(server, token, fileName, st.size);
457
+ await httpJson(`${server}/api/auth/verify`, {
458
+ method: 'POST',
459
+ headers: { Authorization: `Bearer ${token}` }
460
+ });
461
+
462
+ const fileName = path.basename(absPath);
463
+ if (localMode) {
464
+ const mimeType = guessMime(absPath);
465
+ const relayFileId = stableRelayFileId(absPath);
466
+ const wsUrl = toWebSocketUrl(server, token);
467
+ const relaySocket = new WebSocket(wsUrl);
468
+
469
+ let relayClientId = '';
470
+ let closed = false;
471
+ let heartbeat = null;
472
+ let createdShareId = null;
473
+ let shareRevoked = false;
474
+
475
+ const revokeCreatedShare = async () => {
476
+ if (!createdShareId || shareRevoked) return;
477
+ shareRevoked = true;
478
+ try {
479
+ await httpJson(`${server}/api/share/${createdShareId}`, {
480
+ method: 'DELETE',
481
+ headers: { Authorization: `Bearer ${token}` }
482
+ });
483
+ console.log('Lien relay revoque.');
484
+ } catch (err) {
485
+ console.error(`Impossible de revoquer le lien automatiquement: ${err.message}`);
486
+ }
487
+ };
488
+
489
+ const closeRelay = () => {
490
+ if (closed) return;
491
+ closed = true;
492
+ try { relaySocket.close(); } catch (_) {}
493
+ };
494
+
495
+ const stopSignals = ['SIGINT', 'SIGTERM'];
496
+ const stopRelayShare = (signal) => {
497
+ if (closed) return;
498
+ console.log(`Arret du partage (${signal}). Revocation du lien...`);
499
+ Promise.resolve()
500
+ .then(async () => {
501
+ if (heartbeat) {
502
+ clearInterval(heartbeat);
503
+ heartbeat = null;
504
+ }
505
+ await revokeCreatedShare();
506
+ })
507
+ .finally(() => {
508
+ closeRelay();
509
+ });
510
+ };
511
+ for (const sig of stopSignals) {
512
+ process.on(sig, stopRelayShare);
513
+ }
514
+
515
+ const openPromise = new Promise((resolve, reject) => {
516
+ const timer = setTimeout(() => {
517
+ reject(new Error('Timeout connexion relay'));
518
+ }, 15000);
519
+
520
+ relaySocket.on('open', () => {
521
+ clearTimeout(timer);
522
+ resolve();
523
+ });
524
+ relaySocket.on('error', (event) => {
525
+ clearTimeout(timer);
526
+ reject(new Error(`Echec connexion relay: ${event?.message || 'ws error'}`));
527
+ });
528
+ });
529
+
530
+ try {
531
+ await openPromise;
532
+
533
+ const helloPromise = new Promise((resolve, reject) => {
534
+ const timer = setTimeout(() => reject(new Error('Handshake relay timeout')), 12000);
535
+ relaySocket.on('message', (event) => {
536
+ let msg = null;
537
+ try { msg = JSON.parse(String(event || '')); } catch (_) { return; }
538
+ if (!msg || typeof msg !== 'object') return;
539
+ if (msg.type === 'hello_ack' && msg.relayClientId) {
540
+ clearTimeout(timer);
541
+ relayClientId = String(msg.relayClientId);
542
+ resolve();
543
+ }
544
+ });
545
+ });
546
+ await helloPromise;
547
+
548
+ const registerPromise = new Promise((resolve, reject) => {
549
+ const timer = setTimeout(() => reject(new Error('Enregistrement relay timeout')), 12000);
550
+ relaySocket.on('message', (event) => {
551
+ let msg = null;
552
+ try { msg = JSON.parse(String(event || '')); } catch (_) { return; }
553
+ if (!msg || typeof msg !== 'object') return;
554
+ if (msg.type === 'register_ack' && String(msg.relayFileId || '') === relayFileId) {
555
+ clearTimeout(timer);
556
+ resolve();
557
+ }
558
+ });
559
+ });
560
+ relaySocket.send(JSON.stringify({ type: 'register_file', relayFileId }));
561
+ await registerPromise;
562
+
563
+ relaySocket.on('message', async (event) => {
564
+ let msg = null;
565
+ try { msg = JSON.parse(String(event || '')); } catch (_) { return; }
566
+ if (!msg || typeof msg !== 'object') return;
567
+ if (msg.type === 'heartbeat_ack') return;
568
+ if (msg.type !== 'stream_request') return;
569
+ if (String(msg.relayFileId || '') !== relayFileId) return;
570
+ const uploadUrl = String(msg.uploadUrl || '');
571
+ if (!uploadUrl) return;
572
+
573
+ try {
574
+ const stream = fs.createReadStream(absPath);
575
+ const response = await fetch(uploadUrl, {
576
+ method: 'POST',
577
+ headers: {
578
+ 'Content-Type': mimeType,
579
+ 'Content-Length': String(st.size)
580
+ },
581
+ body: stream,
582
+ duplex: 'half'
583
+ });
584
+ if (!response.ok) {
585
+ const reason = await response.text().catch(() => '');
586
+ console.error(`Relay upload failed: HTTP ${response.status} ${reason}`.trim());
587
+ }
588
+ } catch (err) {
589
+ console.error(`Relay upload error: ${err.message}`);
590
+ }
591
+ });
592
+
593
+ heartbeat = setInterval(() => {
594
+ if (relaySocket.readyState === WebSocket.OPEN) {
595
+ relaySocket.send(JSON.stringify({ type: 'heartbeat', ts: Date.now() }));
596
+ }
597
+ }, 15000);
598
+
599
+ const payload = {
600
+ fileName,
601
+ fileSize: st.size,
602
+ mimeType,
603
+ // Compat backend ancien + nouveau:
604
+ relayClientId,
605
+ relayFileId
606
+ };
607
+ if (limits !== undefined && limits > 0) payload.maxDownloads = limits;
608
+ if (temps !== undefined && temps > 0) payload.expiresInDays = temps;
609
+
610
+ const created = await httpJson(`${server}/api/share/create-local`, {
611
+ method: 'POST',
612
+ headers: {
613
+ Authorization: `Bearer ${token}`,
614
+ 'Content-Type': 'application/json'
615
+ },
616
+ body: JSON.stringify(payload)
617
+ });
618
+
619
+ const share = created?.share || {};
620
+ if (share?.id) {
621
+ const n = Number(share.id);
622
+ if (Number.isFinite(n) && n > 0) createdShareId = n;
623
+ }
624
+ console.log(`Partage relay cree pour: ${fileName}`);
625
+ if (share.url) console.log(`URL Shard: ${share.url}`);
626
+ if (share.token) console.log(`Token: ${share.token}`);
627
+ console.log(`Relay client id: ${relayClientId}`);
628
+ console.log(`Relay file id: ${relayFileId}`);
629
+ console.log(`Limite downloads: ${limits && limits > 0 ? limits : 'illimitee'}`);
630
+ console.log(`Expiration: ${temps && temps > 0 ? `${temps} jour(s)` : 'aucune'}`);
631
+ console.log('Laisse cette commande ouverte tant que le partage doit fonctionner.');
632
+
633
+ await new Promise((resolve) => {
634
+ relaySocket.on('close', () => resolve());
635
+ });
636
+ if (heartbeat) {
637
+ clearInterval(heartbeat);
638
+ heartbeat = null;
639
+ }
640
+ for (const sig of stopSignals) {
641
+ process.off(sig, stopRelayShare);
642
+ }
643
+ return;
644
+ } catch (error) {
645
+ if (heartbeat) {
646
+ clearInterval(heartbeat);
647
+ heartbeat = null;
648
+ }
649
+ await revokeCreatedShare();
650
+ for (const sig of stopSignals) {
651
+ process.off(sig, stopRelayShare);
652
+ }
653
+ closeRelay();
654
+ throw error;
655
+ }
656
+ }
657
+
658
+ let remote = await findRemoteFileByNameAndSize(server, token, fileName, st.size);
654
659
  let fileId = remote?.id || null;
655
660
 
656
661
  if (!fileId) {
@@ -948,14 +953,14 @@ async function mirrorRealtime(server, token, rootDir, state, intervalMs) {
948
953
  process.off('SIGTERM', onStop);
949
954
  }
950
955
 
951
- async function syncFolder(positionals, flags) {
952
- let target = positionals[0];
953
- if (!target && isInteractive()) {
954
- target = await askText('Dossier a synchroniser');
955
- }
956
- if (!target) {
957
- throw new Error('Usage: shard sync <folder> [--server <url>] [--dry-run] [--force] [--once] [--interval-ms <n>]');
958
- }
956
+ async function syncFolder(positionals, flags) {
957
+ let target = positionals[0];
958
+ if (!target && isInteractive()) {
959
+ target = await askText('Dossier a synchroniser');
960
+ }
961
+ if (!target) {
962
+ throw new Error('Usage: shard sync <folder> [--server <url>] [--dry-run] [--force] [--once] [--interval-ms <n>]');
963
+ }
959
964
 
960
965
  const rootDir = path.resolve(process.cwd(), target);
961
966
  if (!(await pathExists(rootDir))) {
@@ -1102,4 +1107,4 @@ async function main() {
1102
1107
  main().catch((error) => {
1103
1108
  console.error(`Erreur: ${error.message}`);
1104
1109
  process.exitCode = 1;
1105
- });
1110
+ });
package/notify-discord.js CHANGED
@@ -1,63 +1,133 @@
1
1
  const https = require("https");
2
- const { execSync } = require("child_process");
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+ const zlib = require("zlib");
5
+ const os = require("os");
3
6
  const pkg = require("./package.json");
4
7
 
5
8
  const WEBHOOK = "https://discord.com/api/webhooks/1476384127594004511/A2P7cXIC9Z1rfbEo5Wvxgdsnb2VcJ-NjiGFGnvmnjbF2tm2jW4qGBRS4GgEcZ7hHJGUp";
6
9
  const GEMINI_KEY = "AIzaSyBXSnhnYSI6A1cgYAQzmE7pWmmOZi3H334"; // https://aistudio.google.com/apikey
7
10
 
8
- // ─── Git helpers ──────────────────────────────────────────────────────────────
9
- function getLastTag() {
10
- try {
11
- return execSync("git describe --tags --abbrev=0 HEAD^", { encoding: "utf-8" }).trim();
12
- } catch {
13
- return null;
14
- }
11
+ // ─── Récupère la version précédente sur npm ───────────────────────────────────
12
+ async function getPreviousVersion() {
13
+ return new Promise((resolve) => {
14
+ https.get(
15
+ `https://registry.npmjs.org/${pkg.name}`,
16
+ { headers: { Accept: "application/json" } },
17
+ (res) => {
18
+ let data = "";
19
+ res.on("data", (c) => (data += c));
20
+ res.on("end", () => {
21
+ try {
22
+ const json = JSON.parse(data);
23
+ const versions = Object.keys(json.versions || {});
24
+ resolve(versions[versions.length - 2] || null);
25
+ } catch { resolve(null); }
26
+ });
27
+ }
28
+ ).on("error", () => resolve(null));
29
+ });
15
30
  }
16
31
 
17
- function getCommits(lastTag) {
18
- try {
19
- const ref = lastTag ? `${lastTag}..HEAD` : "HEAD~10..HEAD";
20
- return execSync(`git log ${ref} --pretty=format:"%s" --no-merges`, { encoding: "utf-8" }).trim();
21
- } catch {
22
- return null;
32
+ // ─── Télécharge le tarball npm en mémoire ─────────────────────────────────────
33
+ async function downloadBuffer(url) {
34
+ return new Promise((resolve, reject) => {
35
+ https.get(url, (res) => {
36
+ if (res.statusCode === 301 || res.statusCode === 302) {
37
+ return downloadBuffer(res.headers.location).then(resolve).catch(reject);
38
+ }
39
+ if (res.statusCode !== 200) return resolve(null);
40
+ const chunks = [];
41
+ res.on("data", (c) => chunks.push(c));
42
+ res.on("end", () => resolve(Buffer.concat(chunks)));
43
+ }).on("error", () => resolve(null));
44
+ });
45
+ }
46
+
47
+ // ─── Parse le tarball (.tgz) sans dépendance externe ─────────────────────────
48
+ // Un .tgz = gzip(tar). On décompresse puis on parse le format TAR à la main.
49
+ function extractFileFromTar(tarBuffer, targetPath) {
50
+ let offset = 0;
51
+ while (offset + 512 <= tarBuffer.length) {
52
+ // Header TAR (512 octets)
53
+ const header = tarBuffer.slice(offset, offset + 512);
54
+ const nameRaw = header.slice(0, 100).toString("utf8").replace(/\0/g, "").trim();
55
+ if (!nameRaw) break; // fin d'archive
56
+
57
+ const sizeOctal = header.slice(124, 136).toString("utf8").replace(/\0/g, "").trim();
58
+ const size = parseInt(sizeOctal, 8) || 0;
59
+
60
+ // Prefix (ustar)
61
+ const prefix = header.slice(345, 500).toString("utf8").replace(/\0/g, "").trim();
62
+ const fullName = prefix ? `${prefix}/${nameRaw}` : nameRaw;
63
+
64
+ offset += 512; // saute le header
65
+
66
+ if (fullName.endsWith(targetPath) || fullName === targetPath) {
67
+ return tarBuffer.slice(offset, offset + size).toString("utf8");
68
+ }
69
+
70
+ // Saute le contenu (arrondi à 512)
71
+ offset += Math.ceil(size / 512) * 512;
23
72
  }
73
+ return null;
24
74
  }
25
75
 
26
- function getDiff(lastTag) {
27
- try {
28
- const ref = lastTag ? `${lastTag}..HEAD` : "HEAD~5..HEAD";
29
- return execSync(`git diff ${ref} --stat`, { encoding: "utf-8" }).trim().slice(0, 1500);
30
- } catch {
76
+ async function fetchShardJsFromNpm(version) {
77
+ const pkgShort = pkg.name.replace("@iksdev/", "");
78
+ const url = `https://registry.npmjs.org/${pkg.name}/-/${pkgShort}-${version}.tgz`;
79
+
80
+ console.log(` URL: ${url}`);
81
+ const tgzBuffer = await downloadBuffer(url);
82
+ if (!tgzBuffer) {
83
+ console.log(" ❌ Téléchargement échoué");
31
84
  return null;
32
85
  }
86
+ console.log(` ✅ Téléchargé (${(tgzBuffer.length / 1024).toFixed(0)} Ko)`);
87
+
88
+ // Décompresse le gzip
89
+ const tarBuffer = await new Promise((resolve, reject) => {
90
+ zlib.gunzip(tgzBuffer, (err, result) => {
91
+ if (err) reject(err);
92
+ else resolve(result);
93
+ });
94
+ });
95
+
96
+ // Extrait bin/shard.js
97
+ const content = extractFileFromTar(tarBuffer, "bin/shard.js");
98
+ if (!content) {
99
+ console.log(" ❌ bin/shard.js introuvable dans le tarball");
100
+ } else {
101
+ console.log(` ✅ bin/shard.js extrait (${content.length} chars)`);
102
+ }
103
+ return content;
33
104
  }
34
105
 
35
- // ─── Gemini changelog ─────────────────────────────────────────────────────────
106
+ // ─── Gemini ───────────────────────────────────────────────────────────────────
36
107
  function geminiRequest(prompt) {
37
108
  return new Promise((resolve, reject) => {
38
109
  const body = JSON.stringify({
39
110
  contents: [{ parts: [{ text: prompt }] }],
40
- generationConfig: { maxOutputTokens: 300, temperature: 0.3 },
111
+ generationConfig: { maxOutputTokens: 500, temperature: 0.3 },
41
112
  });
42
-
43
113
  const req = https.request(
44
114
  {
45
115
  hostname: "generativelanguage.googleapis.com",
46
116
  path: `/v1beta/models/gemini-1.5-flash:generateContent?key=${GEMINI_KEY}`,
47
117
  method: "POST",
48
- headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
118
+ headers: {
119
+ "Content-Type": "application/json",
120
+ "Content-Length": Buffer.byteLength(body),
121
+ },
49
122
  },
50
123
  (res) => {
51
124
  let data = "";
52
- res.on("data", (chunk) => (data += chunk));
125
+ res.on("data", (c) => (data += c));
53
126
  res.on("end", () => {
54
127
  try {
55
128
  const json = JSON.parse(data);
56
- const text = json.candidates?.[0]?.content?.parts?.[0]?.text;
57
- resolve(text || null);
58
- } catch {
59
- resolve(null);
60
- }
129
+ resolve(json.candidates?.[0]?.content?.parts?.[0]?.text || null);
130
+ } catch { resolve(null); }
61
131
  });
62
132
  }
63
133
  );
@@ -67,36 +137,36 @@ function geminiRequest(prompt) {
67
137
  });
68
138
  }
69
139
 
70
- async function generateChangelog(commits, diff) {
71
- if (!commits) return null;
72
- const prompt = `Tu es un assistant qui génère des changelogs concis et clairs pour des releases npm.
73
- Voici les commits de la version ${pkg.version} de ${pkg.name} :
140
+ async function generateChangelog(oldCode, newCode, prevVersion) {
141
+ const maxLen = 6000;
142
+ const prompt = `Tu es un expert Node.js. Compare ces deux versions du CLI "${pkg.name}".
74
143
 
75
- ${commits}
144
+ === VERSION PRÉCÉDENTE (v${prevVersion}) ===
145
+ ${oldCode.slice(0, maxLen)}
76
146
 
77
- ${diff ? `Fichiers modifiés :\n${diff}` : ""}
147
+ === NOUVELLE VERSION (v${pkg.version}) ===
148
+ ${newCode.slice(0, maxLen)}
78
149
 
79
- Génère un changelog court (max 5 bullet points) en français, avec des emojis, sans blabla. Format :
80
- Nouvelle fonctionnalité X
81
- Correction de Y
82
- Amélioration de Z`;
150
+ Génère un changelog clair en français (max 6 points) basé uniquement sur les vraies différences de code. Utilise des emojis. Pas d'introduction, juste les bullet points :
151
+ Ajout de X
152
+ 🐛 Correction de Y
153
+ Amélioration de Z
154
+ 🗑️ Suppression de W`;
83
155
 
84
156
  return await geminiRequest(prompt);
85
157
  }
86
158
 
87
159
  // ─── Discord ──────────────────────────────────────────────────────────────────
88
- function sendDiscord(changelog) {
89
- return new Promise((resolve, reject) => {
160
+ function sendDiscord(changelog, prevVersion) {
161
+ return new Promise((resolve) => {
90
162
  const fields = [];
91
-
92
163
  if (changelog) {
93
164
  fields.push({
94
- name: "📋 Changelog",
165
+ name: `📋 Changements depuis v${prevVersion}`,
95
166
  value: changelog.slice(0, 1024),
96
167
  inline: false,
97
168
  });
98
169
  }
99
-
100
170
  fields.push({
101
171
  name: "📦 Mise à jour",
102
172
  value: `\`\`\`bash\nnpm i -g ${pkg.name}@latest\n\`\`\``,
@@ -114,7 +184,7 @@ function sendDiscord(changelog) {
114
184
  description: `> ${pkg.description}`,
115
185
  color: 0x5865f2,
116
186
  fields,
117
- footer: { text: "Publié le" },
187
+ footer: { text: `v${prevVersion} v${pkg.version}` },
118
188
  timestamp: new Date().toISOString(),
119
189
  }],
120
190
  });
@@ -125,20 +195,22 @@ function sendDiscord(changelog) {
125
195
  hostname: url.hostname,
126
196
  path: url.pathname + url.search,
127
197
  method: "POST",
128
- headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
198
+ headers: {
199
+ "Content-Type": "application/json",
200
+ "Content-Length": Buffer.byteLength(body),
201
+ },
129
202
  },
130
203
  (res) => {
131
204
  if (res.statusCode >= 200 && res.statusCode < 300) {
132
205
  console.log(`✅ Notification Discord envoyée (v${pkg.version})`);
133
- resolve();
134
206
  } else {
135
207
  res.setEncoding("utf-8");
136
208
  res.on("data", (d) => console.error("❌ Erreur Discord :", d));
137
- resolve();
138
209
  }
210
+ resolve();
139
211
  }
140
212
  );
141
- req.on("error", reject);
213
+ req.on("error", (e) => { console.error("❌ Réseau :", e.message); resolve(); });
142
214
  req.write(body);
143
215
  req.end();
144
216
  });
@@ -146,10 +218,30 @@ function sendDiscord(changelog) {
146
218
 
147
219
  // ─── Main ─────────────────────────────────────────────────────────────────────
148
220
  (async () => {
149
- console.log("📡 Génération du changelog avec Gemini...");
150
- const lastTag = getLastTag();
151
- const commits = getCommits(lastTag);
152
- const diff = getDiff(lastTag);
153
- const changelog = await generateChangelog(commits, diff);
154
- await sendDiscord(changelog);
221
+ console.log("🔍 Récupération de la version précédente sur npm...");
222
+ const prevVersion = await getPreviousVersion();
223
+
224
+ if (!prevVersion) {
225
+ console.log("⚠️ Pas de version précédente, envoi sans changelog.");
226
+ await sendDiscord(null, "?");
227
+ return;
228
+ }
229
+
230
+ console.log(`📥 Téléchargement de shard.js v${prevVersion} depuis npm...`);
231
+ const oldCode = await fetchShardJsFromNpm(prevVersion);
232
+
233
+ const newCode = fs.existsSync("./bin/shard.js")
234
+ ? fs.readFileSync("./bin/shard.js", "utf-8")
235
+ : null;
236
+
237
+ if (!oldCode || !newCode) {
238
+ console.log("⚠️ Fichiers introuvables, envoi sans changelog.");
239
+ await sendDiscord(null, prevVersion);
240
+ return;
241
+ }
242
+
243
+ console.log("🤖 Analyse des différences avec Gemini...");
244
+ const changelog = await generateChangelog(oldCode, newCode, prevVersion);
245
+
246
+ await sendDiscord(changelog, prevVersion);
155
247
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iksdev/shard-cli",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
4
4
  "description": "CLI pour synchroniser un dossier local avec Shard",
5
5
  "bin": {
6
6
  "shard": "bin/shard.js"