@iksdev/shard-cli 0.1.34 → 0.1.36

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 +1176 -37
  2. package/notify-discord.js +33 -240
  3. package/package.json +1 -1
package/bin/shard.js CHANGED
@@ -1,40 +1,1179 @@
1
- const https = require("https");
2
- const pkg = require("./package.json");
1
+ #!/usr/bin/env node
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
12
 
4
- const WEBHOOK = "https://discord.com/api/webhooks/1476384127594004511/A2P7cXIC9Z1rfbEo5Wvxgdsnb2VcJ-NjiGFGnvmnjbF2tm2jW4qGBRS4GgEcZ7hHJGUp";
13
+ const CONFIG_DIR = path.join(os.homedir(), '.shard-cli');
14
+ const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
15
+ const STATE_FILE = '.shard-sync-state.json';
16
+ const DEFAULT_SERVER = 'https://shard-0ow4.onrender.com';
17
+ const IGNORED_DIRS = new Set(['.git', 'node_modules']);
18
+ // ─── Vérification de mise à jour npm ─────────────────────────────────────────
19
+ async function checkForUpdate() {
20
+ return new Promise((resolve) => {
21
+ const https = require('https');
22
+ const pkgName = require('./package.json').name;
23
+ const currentVersion = require('./package.json').version;
5
24
 
6
- const body = JSON.stringify({
7
- embeds: [{
8
- author: {
9
- name: "npm • nouvelle version disponible",
10
- icon_url: "https://static-production.npmjs.com/b0f1a8318363185cc2ea6a40ac23eeb2.png",
25
+ const req = https.get(
26
+ `https://registry.npmjs.org/${pkgName}/latest`,
27
+ { headers: { Accept: 'application/json' }, timeout: 3000 },
28
+ (res) => {
29
+ let data = '';
30
+ res.on('data', (c) => (data += c));
31
+ res.on('end', () => {
32
+ try {
33
+ const latest = JSON.parse(data).version;
34
+ resolve(latest && latest !== currentVersion ? latest : null);
35
+ } catch { resolve(null); }
36
+ });
37
+ }
38
+ );
39
+ req.on('error', () => resolve(null));
40
+ req.on('timeout', () => { req.destroy(); resolve(null); });
41
+ });
42
+ }
43
+
44
+ async function promptUpdate(latestVersion) {
45
+ const pkgName = require('./package.json').name;
46
+ return new Promise((resolve) => {
47
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
48
+ const msg = `\n⚠️ Nouvelle version disponible : v${latestVersion}\n Lance cette commande pour mettre à jour :\n\n npm i -g ${pkgName}@latest\n\n→ Mettre à jour maintenant ? (y/n) : `;
49
+ rl.question(msg, (answer) => {
50
+ rl.close();
51
+ resolve(answer.trim().toLowerCase() === 'y');
52
+ });
53
+ });
54
+ }
55
+
56
+ async function runUpdate(latestVersion) {
57
+ const { execSync } = require('child_process');
58
+ const pkgName = require('./package.json').name;
59
+ console.log(`\n⏳ Mise à jour en cours...`);
60
+ try {
61
+ execSync(`npm i -g ${pkgName}@latest`, { stdio: 'inherit' });
62
+ console.log(`\n✅ Mis à jour vers v${latestVersion} ! Relance ta commande.`);
63
+ } catch {
64
+ console.error(`\n❌ Échec de la mise à jour. Lance manuellement :\n npm i -g ${pkgName}@latest`);
65
+ }
66
+ process.exit(0);
67
+ }
68
+
69
+
70
+
71
+ function printHelp() {
72
+ console.log(`
73
+ ╔══════════════════════════════════════════════════╗
74
+ ║ Shard ║
75
+ ╚══════════════════════════════════════════════════╝
76
+
77
+ Commandes disponibles:
78
+ shard login Se connecter au serveur
79
+ shard whoami Afficher l'utilisateur connecte
80
+ shard sync <dossier> Synchroniser un dossier local
81
+ shard share <fichier> Partager un fichier via relay
82
+ shard logout Se deconnecter
83
+ shard config show Afficher la configuration
84
+ shard config set-server <url> Changer de serveur
85
+
86
+ Mode interactif:
87
+ Lance une commande sans arguments et la CLI te guidera etape par etape.
88
+
89
+ Options avancees:
90
+ login --username <n> --password <pass> [--server <url>]
91
+ whoami [--server <url>]
92
+ sync <dossier> [--server <url>] [--dry-run] [--force] [--once] [--interval-ms <n>]
93
+ share <fichier> [--server <url>] [--limits <n>] [--temps <jours>] [--upload]
94
+
95
+ Exemples:
96
+ shard login
97
+ shard sync ./MonDossier
98
+ shard sync ./MonDossier --once
99
+ shard share ./MonFichier.mp4
100
+ shard share ./MonFichier.mp4 --upload
101
+
102
+ Serveur par defaut: https://shard-0ow4.onrender.com
103
+ `);
104
+ }
105
+
106
+ function parseArgs(rawArgs) {
107
+ const args = [...rawArgs];
108
+ const command = args.shift();
109
+ const positionals = [];
110
+ const flags = {};
111
+
112
+ for (let i = 0; i < args.length; i += 1) {
113
+ const cur = args[i];
114
+ if (!cur.startsWith('--')) {
115
+ positionals.push(cur);
116
+ continue;
117
+ }
118
+ const key = cur.slice(2);
119
+ const next = args[i + 1];
120
+ if (!next || next.startsWith('--')) {
121
+ flags[key] = true;
122
+ continue;
123
+ }
124
+ flags[key] = next;
125
+ i += 1;
126
+ }
127
+ return { command, positionals, flags };
128
+ }
129
+
130
+ function isInteractive() {
131
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
132
+ }
133
+
134
+ function stripWrappingQuotes(value) {
135
+ let out = String(value || '').trim();
136
+ if (!out) return out;
137
+ if ((out.startsWith('"') && out.endsWith('"')) || (out.startsWith("'") && out.endsWith("'"))) {
138
+ out = out.slice(1, -1).trim();
139
+ }
140
+ return out;
141
+ }
142
+
143
+ async function askText(label, defaultValue = '') {
144
+ const rl = readline.createInterface({
145
+ input: process.stdin,
146
+ output: process.stdout
147
+ });
148
+ const suffix = defaultValue ? ` [${defaultValue}]` : '';
149
+ const answer = await new Promise((resolve) => rl.question(`${label}${suffix}: `, resolve));
150
+ rl.close();
151
+ const trimmed = stripWrappingQuotes(answer);
152
+ return trimmed || stripWrappingQuotes(defaultValue);
153
+ }
154
+
155
+ async function askSecret(label) {
156
+ if (!isInteractive()) return '';
157
+ return new Promise((resolve, reject) => {
158
+ const stdin = process.stdin;
159
+ let value = '';
160
+ process.stdout.write(`${label}: `);
161
+
162
+ const cleanup = () => {
163
+ stdin.off('data', onData);
164
+ try { if (stdin.isTTY) stdin.setRawMode(false); } catch (_) {}
165
+ stdin.pause();
166
+ };
167
+
168
+ const onData = (buf) => {
169
+ const char = String(buf || '');
170
+ if (char === '\u0003') {
171
+ cleanup();
172
+ reject(new Error('Interrompu'));
173
+ return;
174
+ }
175
+ if (char === '\r' || char === '\n') {
176
+ process.stdout.write('\n');
177
+ cleanup();
178
+ resolve(value.trim());
179
+ return;
180
+ }
181
+ if (char === '\u007f' || char === '\b') {
182
+ if (value.length > 0) {
183
+ value = value.slice(0, -1);
184
+ process.stdout.write('\b \b');
185
+ }
186
+ return;
187
+ }
188
+ value += char;
189
+ process.stdout.write('*');
190
+ };
191
+
192
+ try { if (stdin.isTTY) stdin.setRawMode(true); } catch (_) {}
193
+ stdin.resume();
194
+ stdin.on('data', onData);
195
+ });
196
+ }
197
+
198
+ function normalizeServer(input) {
199
+ const raw = String(input || '').trim();
200
+ if (!raw) return DEFAULT_SERVER;
201
+ return raw.replace(/\/+$/, '');
202
+ }
203
+
204
+ async function ensureConfigDir() {
205
+ await fsp.mkdir(CONFIG_DIR, { recursive: true });
206
+ }
207
+
208
+ async function readConfig() {
209
+ try {
210
+ const raw = await fsp.readFile(CONFIG_PATH, 'utf8');
211
+ const parsed = JSON.parse(raw);
212
+ return {
213
+ server: normalizeServer(parsed.server || DEFAULT_SERVER),
214
+ token: parsed.token || ''
215
+ };
216
+ } catch {
217
+ return { server: DEFAULT_SERVER, token: '' };
218
+ }
219
+ }
220
+
221
+ async function writeConfig(config) {
222
+ await ensureConfigDir();
223
+ const payload = {
224
+ server: normalizeServer(config.server || DEFAULT_SERVER),
225
+ token: config.token || ''
226
+ };
227
+ await fsp.writeFile(CONFIG_PATH, JSON.stringify(payload, null, 2), 'utf8');
228
+ }
229
+
230
+ function getServer(flags, config) {
231
+ return normalizeServer(flags.server || process.env.SHARD_SERVER || config.server || DEFAULT_SERVER);
232
+ }
233
+
234
+ function getToken(config) {
235
+ return process.env.SHARD_TOKEN || config.token || '';
236
+ }
237
+
238
+ async function httpJson(url, options = {}) {
239
+ const res = await fetch(url, options);
240
+ const data = await res.json().catch(() => ({}));
241
+ if (!res.ok) {
242
+ const err = new Error(data.message || data.error || `HTTP ${res.status}`);
243
+ err.status = res.status;
244
+ err.data = data;
245
+ throw err;
246
+ }
247
+ return data;
248
+ }
249
+
250
+ async function login(flags) {
251
+ const config = await readConfig();
252
+ let username = String(flags.username || '').trim();
253
+ let password = String(flags.password || '').trim();
254
+ let server = getServer(flags, config);
255
+
256
+ if (isInteractive()) {
257
+ if (!username) username = await askText('Username');
258
+ if (!password) password = await askSecret('Password');
259
+ if (!flags.server) {
260
+ const typedServer = await askText('Serveur', server);
261
+ server = normalizeServer(typedServer || server);
262
+ }
263
+ }
264
+
265
+ if (!username || !password) {
266
+ throw new Error('Usage: shard login --username <name> --password <pass> [--server <url>]');
267
+ }
268
+
269
+ const data = await httpJson(`${server}/api/auth/login`, {
270
+ method: 'POST',
271
+ headers: { 'Content-Type': 'application/json' },
272
+ body: JSON.stringify({ username, password })
273
+ });
274
+
275
+ if (!data.token) {
276
+ throw new Error('Connexion réussie mais token manquant.');
277
+ }
278
+
279
+ await writeConfig({ server, token: data.token });
280
+ console.log(`Connecte a ${server}`);
281
+ if (data.user?.username) {
282
+ console.log(`Utilisateur: ${data.user.username}`);
283
+ }
284
+ }
285
+
286
+ async function whoami(flags) {
287
+ const config = await readConfig();
288
+ const server = getServer(flags, config);
289
+ const token = getToken(config);
290
+ if (!token) {
291
+ throw new Error('Non connecte. Lance: shard login --username ... --password ...');
292
+ }
293
+
294
+ const data = await httpJson(`${server}/api/auth/verify`, {
295
+ method: 'POST',
296
+ headers: { Authorization: `Bearer ${token}` }
297
+ });
298
+
299
+ const user = data.user || {};
300
+ console.log(`Server: ${server}`);
301
+ console.log(`User: ${user.username || user.userId || 'inconnu'}`);
302
+ if (user.email) console.log(`Email: ${user.email}`);
303
+ }
304
+
305
+ async function logout() {
306
+ const config = await readConfig();
307
+ await writeConfig({ ...config, token: '' });
308
+ console.log('Token supprime.');
309
+ }
310
+
311
+ async function showConfig() {
312
+ const config = await readConfig();
313
+ console.log(JSON.stringify({
314
+ server: config.server,
315
+ hasToken: Boolean(config.token)
316
+ }, null, 2));
317
+ }
318
+
319
+ async function setServer(positionals) {
320
+ const url = positionals[0];
321
+ if (!url) {
322
+ throw new Error('Usage: shard config set-server <url>');
323
+ }
324
+ const config = await readConfig();
325
+ await writeConfig({ ...config, server: normalizeServer(url) });
326
+ console.log(`Server mis a jour: ${normalizeServer(url)}`);
327
+ }
328
+
329
+ async function pathExists(targetPath) {
330
+ try {
331
+ await fsp.access(targetPath, fs.constants.R_OK);
332
+ return true;
333
+ } catch {
334
+ return false;
335
+ }
336
+ }
337
+
338
+ async function listFilesRecursive(rootDir) {
339
+ const out = [];
340
+
341
+ async function walk(currentDir) {
342
+ const entries = await fsp.readdir(currentDir, { withFileTypes: true });
343
+ for (const entry of entries) {
344
+ if (entry.name === STATE_FILE) continue;
345
+ if (entry.isDirectory() && IGNORED_DIRS.has(entry.name)) continue;
346
+
347
+ const abs = path.join(currentDir, entry.name);
348
+ if (entry.isDirectory()) {
349
+ await walk(abs);
350
+ continue;
351
+ }
352
+ if (!entry.isFile()) continue;
353
+
354
+ const rel = path.relative(rootDir, abs).split(path.sep).join('/');
355
+ const stat = await fsp.stat(abs);
356
+ out.push({
357
+ absPath: abs,
358
+ relPath: rel,
359
+ size: stat.size,
360
+ mtimeMs: Math.round(stat.mtimeMs)
361
+ });
362
+ }
363
+ }
364
+
365
+ await walk(rootDir);
366
+ out.sort((a, b) => a.relPath.localeCompare(b.relPath));
367
+ return out;
368
+ }
369
+
370
+ async function readState(rootDir) {
371
+ const filePath = path.join(rootDir, STATE_FILE);
372
+ try {
373
+ const raw = await fsp.readFile(filePath, 'utf8');
374
+ const parsed = JSON.parse(raw);
375
+ if (!parsed || typeof parsed !== 'object') return { version: 1, files: {} };
376
+ return { version: 1, files: parsed.files || {} };
377
+ } catch {
378
+ return { version: 1, files: {} };
379
+ }
380
+ }
381
+
382
+ async function writeState(rootDir, state) {
383
+ const filePath = path.join(rootDir, STATE_FILE);
384
+ const payload = {
385
+ version: 1,
386
+ updatedAt: new Date().toISOString(),
387
+ files: state.files || {}
388
+ };
389
+ await fsp.writeFile(filePath, JSON.stringify(payload, null, 2), 'utf8');
390
+ }
391
+
392
+ function guessMime(filePath) {
393
+ const ext = path.extname(filePath).toLowerCase();
394
+ const map = {
395
+ '.txt': 'text/plain',
396
+ '.md': 'text/markdown',
397
+ '.json': 'application/json',
398
+ '.csv': 'text/csv',
399
+ '.html': 'text/html',
400
+ '.css': 'text/css',
401
+ '.js': 'text/javascript',
402
+ '.ts': 'text/plain',
403
+ '.jpg': 'image/jpeg',
404
+ '.jpeg': 'image/jpeg',
405
+ '.png': 'image/png',
406
+ '.gif': 'image/gif',
407
+ '.webp': 'image/webp',
408
+ '.pdf': 'application/pdf',
409
+ '.zip': 'application/zip'
410
+ };
411
+ return map[ext] || 'application/octet-stream';
412
+ }
413
+
414
+ function formatBytes(bytes) {
415
+ const value = Number(bytes || 0);
416
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
417
+ let size = value;
418
+ let idx = 0;
419
+ while (size >= 1024 && idx < units.length - 1) {
420
+ size /= 1024;
421
+ idx += 1;
422
+ }
423
+ return `${size.toFixed(idx === 0 ? 0 : 2)} ${units[idx]}`;
424
+ }
425
+
426
+ async function uploadOneFile(server, token, file) {
427
+ const blob = await fs.openAsBlob(file.absPath, { type: guessMime(file.absPath) });
428
+ const form = new FormData();
429
+ // On force le nom "relatif" pour garder la notion de dossier dans l'UI/DB.
430
+ form.append('file', blob, file.relPath);
431
+
432
+ return httpJson(`${server}/api/files/upload`, {
433
+ method: 'POST',
434
+ headers: { Authorization: `Bearer ${token}` },
435
+ body: form
436
+ });
437
+ }
438
+
439
+ async function findRemoteFileByNameAndSize(server, token, fileName, fileSize) {
440
+ const data = await httpJson(`${server}/api/files?limit=100&offset=0&sort=created_at&order=desc&search=${encodeURIComponent(fileName)}`, {
441
+ method: 'GET',
442
+ headers: { Authorization: `Bearer ${token}` }
443
+ });
444
+ const rows = Array.isArray(data.files) ? data.files : [];
445
+ return rows.find((row) => row.original_name === fileName && Number(row.file_size || 0) === Number(fileSize || 0)) || null;
446
+ }
447
+
448
+ function parseOptionalPositiveInt(raw, flagName) {
449
+ if (raw === undefined || raw === null) return undefined;
450
+ const n = parseInt(String(raw), 10);
451
+ if (Number.isNaN(n) || n < 0) {
452
+ throw new Error(`${flagName} doit etre un entier >= 0`);
453
+ }
454
+ return n;
455
+ }
456
+
457
+ function toWebSocketUrl(serverUrl, token) {
458
+ const url = new URL(serverUrl);
459
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
460
+ url.pathname = '/api/relay/ws';
461
+ url.search = '';
462
+ url.searchParams.set('token', token);
463
+ return url.toString();
464
+ }
465
+
466
+ function normalizeAbsPathForId(absPath) {
467
+ const raw = String(absPath || '').trim();
468
+ if (process.platform === 'win32') return raw.toLowerCase();
469
+ return raw;
470
+ }
471
+
472
+ function stableRelayFileId(absPath) {
473
+ return crypto
474
+ .createHash('sha256')
475
+ .update(normalizeAbsPathForId(absPath))
476
+ .digest('base64url')
477
+ .slice(0, 24);
478
+ }
479
+
480
+ async function shareFile(positionals, flags) {
481
+ let target = stripWrappingQuotes(positionals[0]);
482
+ if (!target && isInteractive()) {
483
+ target = await askText('Fichier a partager');
484
+ }
485
+ if (!target) {
486
+ throw new Error('Usage: shard share <file> [--server <url>] [--limits <n>] [--temps <jours>] [--upload]');
487
+ }
488
+
489
+ const absPath = path.resolve(process.cwd(), target);
490
+ if (!(await pathExists(absPath))) {
491
+ throw new Error(`Fichier introuvable: ${absPath}`);
492
+ }
493
+ const st = await fsp.stat(absPath);
494
+ if (!st.isFile()) {
495
+ throw new Error(`Ce n'est pas un fichier: ${absPath}`);
496
+ }
497
+
498
+ const limits = parseOptionalPositiveInt(flags.limits, '--limits');
499
+ const temps = parseOptionalPositiveInt(flags.temps, '--temps');
500
+ const localMode = !Boolean(flags.upload);
501
+
502
+ const config = await readConfig();
503
+ const server = getServer(flags, config);
504
+ const token = getToken(config);
505
+ if (!token) {
506
+ throw new Error('Non connecte. Lance: shard login --username ... --password ...');
507
+ }
508
+
509
+ await httpJson(`${server}/api/auth/verify`, {
510
+ method: 'POST',
511
+ headers: { Authorization: `Bearer ${token}` }
512
+ });
513
+
514
+ const fileName = path.basename(absPath);
515
+ if (localMode) {
516
+ const mimeType = guessMime(absPath);
517
+ const relayFileId = stableRelayFileId(absPath);
518
+ const wsUrl = toWebSocketUrl(server, token);
519
+ const relaySocket = new WebSocket(wsUrl);
520
+
521
+ let relayClientId = '';
522
+ let closed = false;
523
+ let heartbeat = null;
524
+ let createdShareId = null;
525
+ let shareRevoked = false;
526
+
527
+ const revokeCreatedShare = async () => {
528
+ if (!createdShareId || shareRevoked) return;
529
+ shareRevoked = true;
530
+ try {
531
+ await httpJson(`${server}/api/share/${createdShareId}`, {
532
+ method: 'DELETE',
533
+ headers: { Authorization: `Bearer ${token}` }
534
+ });
535
+ console.log('Lien relay revoque.');
536
+ } catch (err) {
537
+ console.error(`Impossible de revoquer le lien automatiquement: ${err.message}`);
538
+ }
539
+ };
540
+
541
+ const closeRelay = () => {
542
+ if (closed) return;
543
+ closed = true;
544
+ try { relaySocket.close(); } catch (_) {}
545
+ };
546
+
547
+ const stopSignals = ['SIGINT', 'SIGTERM'];
548
+ const stopRelayShare = (signal) => {
549
+ if (closed) return;
550
+ console.log(`Arret du partage (${signal}). Revocation du lien...`);
551
+ Promise.resolve()
552
+ .then(async () => {
553
+ if (heartbeat) {
554
+ clearInterval(heartbeat);
555
+ heartbeat = null;
556
+ }
557
+ await revokeCreatedShare();
558
+ })
559
+ .finally(() => {
560
+ closeRelay();
561
+ });
562
+ };
563
+ for (const sig of stopSignals) {
564
+ process.on(sig, stopRelayShare);
565
+ }
566
+
567
+ const openPromise = new Promise((resolve, reject) => {
568
+ const timer = setTimeout(() => {
569
+ reject(new Error('Timeout connexion relay'));
570
+ }, 15000);
571
+
572
+ relaySocket.on('open', () => {
573
+ clearTimeout(timer);
574
+ resolve();
575
+ });
576
+ relaySocket.on('error', (event) => {
577
+ clearTimeout(timer);
578
+ reject(new Error(`Echec connexion relay: ${event?.message || 'ws error'}`));
579
+ });
580
+ });
581
+
582
+ try {
583
+ await openPromise;
584
+
585
+ const helloPromise = new Promise((resolve, reject) => {
586
+ const timer = setTimeout(() => reject(new Error('Handshake relay timeout')), 12000);
587
+ relaySocket.on('message', (event) => {
588
+ let msg = null;
589
+ try { msg = JSON.parse(String(event || '')); } catch (_) { return; }
590
+ if (!msg || typeof msg !== 'object') return;
591
+ if (msg.type === 'hello_ack' && msg.relayClientId) {
592
+ clearTimeout(timer);
593
+ relayClientId = String(msg.relayClientId);
594
+ resolve();
595
+ }
596
+ });
597
+ });
598
+ await helloPromise;
599
+
600
+ const registerPromise = new Promise((resolve, reject) => {
601
+ const timer = setTimeout(() => reject(new Error('Enregistrement relay timeout')), 12000);
602
+ relaySocket.on('message', (event) => {
603
+ let msg = null;
604
+ try { msg = JSON.parse(String(event || '')); } catch (_) { return; }
605
+ if (!msg || typeof msg !== 'object') return;
606
+ if (msg.type === 'register_ack' && String(msg.relayFileId || '') === relayFileId) {
607
+ clearTimeout(timer);
608
+ resolve();
609
+ }
610
+ });
611
+ });
612
+ relaySocket.send(JSON.stringify({ type: 'register_file', relayFileId }));
613
+ await registerPromise;
614
+
615
+ relaySocket.on('message', async (event) => {
616
+ let msg = null;
617
+ try { msg = JSON.parse(String(event || '')); } catch (_) { return; }
618
+ if (!msg || typeof msg !== 'object') return;
619
+ if (msg.type === 'heartbeat_ack') return;
620
+ if (msg.type !== 'stream_request') return;
621
+ if (String(msg.relayFileId || '') !== relayFileId) return;
622
+ const uploadUrl = String(msg.uploadUrl || '');
623
+ if (!uploadUrl) return;
624
+
625
+ try {
626
+ const stream = fs.createReadStream(absPath);
627
+ const response = await fetch(uploadUrl, {
628
+ method: 'POST',
629
+ headers: {
630
+ 'Content-Type': mimeType,
631
+ 'Content-Length': String(st.size)
632
+ },
633
+ body: stream,
634
+ duplex: 'half'
635
+ });
636
+ if (!response.ok) {
637
+ const reason = await response.text().catch(() => '');
638
+ console.error(`Relay upload failed: HTTP ${response.status} ${reason}`.trim());
639
+ }
640
+ } catch (err) {
641
+ console.error(`Relay upload error: ${err.message}`);
642
+ }
643
+ });
644
+
645
+ heartbeat = setInterval(() => {
646
+ if (relaySocket.readyState === WebSocket.OPEN) {
647
+ relaySocket.send(JSON.stringify({ type: 'heartbeat', ts: Date.now() }));
648
+ }
649
+ }, 15000);
650
+
651
+ const payload = {
652
+ fileName,
653
+ fileSize: st.size,
654
+ mimeType,
655
+ // Compat backend ancien + nouveau:
656
+ relayClientId,
657
+ relayFileId
658
+ };
659
+ if (limits !== undefined && limits > 0) payload.maxDownloads = limits;
660
+ if (temps !== undefined && temps > 0) payload.expiresInDays = temps;
661
+
662
+ const created = await httpJson(`${server}/api/share/create-local`, {
663
+ method: 'POST',
664
+ headers: {
665
+ Authorization: `Bearer ${token}`,
666
+ 'Content-Type': 'application/json'
667
+ },
668
+ body: JSON.stringify(payload)
669
+ });
670
+
671
+ const share = created?.share || {};
672
+ if (share?.id) {
673
+ const n = Number(share.id);
674
+ if (Number.isFinite(n) && n > 0) createdShareId = n;
675
+ }
676
+ console.log(`Partage relay cree pour: ${fileName}`);
677
+ if (share.url) console.log(`URL Shard: ${share.url}`);
678
+ if (share.token) console.log(`Token: ${share.token}`);
679
+ console.log(`Relay client id: ${relayClientId}`);
680
+ console.log(`Relay file id: ${relayFileId}`);
681
+ console.log(`Limite downloads: ${limits && limits > 0 ? limits : 'illimitee'}`);
682
+ console.log(`Expiration: ${temps && temps > 0 ? `${temps} jour(s)` : 'aucune'}`);
683
+ console.log('Laisse cette commande ouverte tant que le partage doit fonctionner.');
684
+
685
+ await new Promise((resolve) => {
686
+ relaySocket.on('close', () => resolve());
687
+ });
688
+ if (heartbeat) {
689
+ clearInterval(heartbeat);
690
+ heartbeat = null;
691
+ }
692
+ for (const sig of stopSignals) {
693
+ process.off(sig, stopRelayShare);
694
+ }
695
+ return;
696
+ } catch (error) {
697
+ if (heartbeat) {
698
+ clearInterval(heartbeat);
699
+ heartbeat = null;
700
+ }
701
+ await revokeCreatedShare();
702
+ for (const sig of stopSignals) {
703
+ process.off(sig, stopRelayShare);
704
+ }
705
+ closeRelay();
706
+ throw error;
707
+ }
708
+ }
709
+
710
+ let remote = await findRemoteFileByNameAndSize(server, token, fileName, st.size);
711
+ let fileId = remote?.id || null;
712
+
713
+ if (!fileId) {
714
+ console.log(`Upload necessaire: ${fileName} (${formatBytes(st.size)})`);
715
+ const uploaded = await uploadOneFile(server, token, {
716
+ absPath,
717
+ relPath: fileName,
718
+ size: st.size,
719
+ mtimeMs: Math.round(st.mtimeMs)
720
+ });
721
+ fileId = uploaded?.file?.id;
722
+ if (!fileId) {
723
+ throw new Error('Upload reussi mais ID fichier manquant');
724
+ }
725
+ }
726
+
727
+ const payload = { fileId };
728
+ if (limits !== undefined && limits > 0) payload.maxDownloads = limits;
729
+ if (temps !== undefined && temps > 0) payload.expiresInDays = temps;
730
+
731
+ const created = await httpJson(`${server}/api/share/create`, {
732
+ method: 'POST',
733
+ headers: {
734
+ Authorization: `Bearer ${token}`,
735
+ 'Content-Type': 'application/json'
11
736
  },
12
- title: `🚀 ${pkg.name} — v${pkg.version}`,
13
- url: `https://www.npmjs.com/package/${pkg.name}/v/${pkg.version}`,
14
- color: 0x5865f2,
15
- fields: [{
16
- name: "📦 Mettre à jour",
17
- value: `\`\`\`\nnpm i -g ${pkg.name}@latest\n\`\`\``,
18
- }],
19
- footer: { text: "Publié le" },
20
- timestamp: new Date().toISOString(),
21
- }],
22
- });
23
-
24
- const url = new URL(WEBHOOK);
25
- const req = https.request({
26
- hostname: url.hostname,
27
- path: url.pathname + url.search,
28
- method: "POST",
29
- headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
30
- }, (res) => {
31
- if (res.statusCode >= 200 && res.statusCode < 300) {
32
- console.log(`✅ Notification Discord envoyée (v${pkg.version})`);
33
- } else {
34
- res.setEncoding("utf-8");
35
- res.on("data", (d) => console.error("❌ Erreur Discord :", d));
36
- }
37
- });
38
- req.on("error", (e) => console.error("❌ Réseau :", e.message));
39
- req.write(body);
40
- req.end();
737
+ body: JSON.stringify(payload)
738
+ });
739
+
740
+ const share = created?.share || {};
741
+ console.log(`Partage cree pour: ${fileName}`);
742
+ if (share.url) console.log(`URL: ${share.url}`);
743
+ if (share.token) console.log(`Token: ${share.token}`);
744
+ console.log(`Limite downloads: ${limits && limits > 0 ? limits : 'illimitee'}`);
745
+ console.log(`Expiration: ${temps && temps > 0 ? `${temps} jour(s)` : 'aucune'}`);
746
+ }
747
+
748
+ function fileListToMap(files) {
749
+ const map = new Map();
750
+ for (const file of files) {
751
+ map.set(file.relPath, file);
752
+ }
753
+ return map;
754
+ }
755
+
756
+ function makeFingerprint(file) {
757
+ return `${file.size}:${file.mtimeMs}`;
758
+ }
759
+
760
+ function normalizeRemoteRelPath(name) {
761
+ return String(name || '').replace(/\\/g, '/');
762
+ }
763
+
764
+ async function fetchAllRemoteFiles(server, token) {
765
+ const out = [];
766
+ const limit = 100;
767
+ let offset = 0;
768
+
769
+ while (true) {
770
+ const data = await httpJson(`${server}/api/files?limit=${limit}&offset=${offset}&sort=created_at&order=asc`, {
771
+ method: 'GET',
772
+ headers: { Authorization: `Bearer ${token}` }
773
+ });
774
+ const rows = Array.isArray(data.files) ? data.files : [];
775
+ for (const row of rows) {
776
+ out.push({
777
+ id: row.id,
778
+ relPath: normalizeRemoteRelPath(row.original_name),
779
+ size: Number(row.file_size || 0)
780
+ });
781
+ }
782
+ if (!data.pagination?.hasMore) break;
783
+ offset += limit;
784
+ }
785
+
786
+ return out;
787
+ }
788
+
789
+ async function downloadRemoteFile(server, token, remoteFile, destPath) {
790
+ const res = await fetch(`${server}/api/files/${remoteFile.id}/download`, {
791
+ method: 'GET',
792
+ headers: { Authorization: `Bearer ${token}` }
793
+ });
794
+
795
+ if (!res.ok) {
796
+ let detail = `HTTP ${res.status}`;
797
+ try {
798
+ const data = await res.json();
799
+ detail = data.message || data.error || detail;
800
+ } catch {
801
+ // ignore JSON parse failures
802
+ }
803
+ throw new Error(detail);
804
+ }
805
+
806
+ if (!res.body) {
807
+ throw new Error('Reponse de telechargement vide');
808
+ }
809
+
810
+ await fsp.mkdir(path.dirname(destPath), { recursive: true });
811
+ const tempPath = `${destPath}.part`;
812
+ const writable = fs.createWriteStream(tempPath);
813
+ await pipeline(Readable.fromWeb(res.body), writable);
814
+ await fsp.rename(tempPath, destPath);
815
+ }
816
+
817
+ async function pullMissingRemoteFiles(server, token, rootDir, state, localFiles, remoteFiles, dryRun) {
818
+ const localMap = fileListToMap(localFiles);
819
+ const missingRemote = remoteFiles.filter((rf) => !localMap.has(rf.relPath));
820
+
821
+ if (missingRemote.length === 0) return { downloaded: 0, errors: 0 };
822
+
823
+ console.log(`A telecharger: ${missingRemote.length}`);
824
+ if (dryRun) {
825
+ for (const rf of missingRemote.slice(0, 50)) console.log(`- ${rf.relPath} (remote)`); // eslint-disable-line no-console
826
+ if (missingRemote.length > 50) console.log(`... +${missingRemote.length - 50} autres`);
827
+ return { downloaded: 0, errors: 0 };
828
+ }
829
+
830
+ let downloaded = 0;
831
+ let errors = 0;
832
+ for (let i = 0; i < missingRemote.length; i += 1) {
833
+ const rf = missingRemote[i];
834
+ const label = `[DOWN ${i + 1}/${missingRemote.length}]`;
835
+ try {
836
+ const absPath = path.join(rootDir, ...rf.relPath.split('/'));
837
+ console.log(`${label} GET ${rf.relPath} (${formatBytes(rf.size)})`);
838
+ await downloadRemoteFile(server, token, rf, absPath);
839
+ const st = await fsp.stat(absPath);
840
+ state.files[rf.relPath] = {
841
+ size: st.size,
842
+ mtimeMs: Math.round(st.mtimeMs),
843
+ uploadedAt: new Date().toISOString(),
844
+ remoteId: rf.id
845
+ };
846
+ downloaded += 1;
847
+ console.log(`${label} OK ${rf.relPath}`);
848
+ } catch (error) {
849
+ errors += 1;
850
+ console.error(`${label} FAIL ${rf.relPath} -> ${error.message}`);
851
+ }
852
+ }
853
+
854
+ return { downloaded, errors };
855
+ }
856
+
857
+ function seedStateFromRemote(state, localFiles, remoteFiles) {
858
+ const remoteByPath = new Map(remoteFiles.map((rf) => [rf.relPath, rf]));
859
+ for (const lf of localFiles) {
860
+ if (state.files[lf.relPath]) continue;
861
+ const remote = remoteByPath.get(lf.relPath);
862
+ if (!remote) continue;
863
+ if (Number(remote.size) !== Number(lf.size)) continue;
864
+ state.files[lf.relPath] = {
865
+ size: lf.size,
866
+ mtimeMs: lf.mtimeMs,
867
+ uploadedAt: new Date().toISOString(),
868
+ remoteId: remote.id
869
+ };
870
+ }
871
+ }
872
+
873
+ async function deleteRemoteFile(server, token, remoteId) {
874
+ return httpJson(`${server}/api/files/${remoteId}`, {
875
+ method: 'DELETE',
876
+ headers: { Authorization: `Bearer ${token}` }
877
+ });
878
+ }
879
+
880
+ async function renameRemoteFile(server, token, remoteId, newName) {
881
+ return httpJson(`${server}/api/files/${remoteId}/rename`, {
882
+ method: 'PATCH',
883
+ headers: {
884
+ Authorization: `Bearer ${token}`,
885
+ 'Content-Type': 'application/json'
886
+ },
887
+ body: JSON.stringify({ newName })
888
+ });
889
+ }
890
+
891
+ function sleep(ms) {
892
+ return new Promise((resolve) => setTimeout(resolve, ms));
893
+ }
894
+
895
+ async function mirrorRealtime(server, token, rootDir, state, intervalMs) {
896
+ console.log(`Mode temps reel actif (${intervalMs} ms). Ctrl+C pour quitter.`);
897
+
898
+ let stopped = false;
899
+ const onStop = () => { stopped = true; };
900
+ process.on('SIGINT', onStop);
901
+ process.on('SIGTERM', onStop);
902
+
903
+ let previousFiles = await listFilesRecursive(rootDir);
904
+ let previousMap = fileListToMap(previousFiles);
905
+
906
+ while (!stopped) {
907
+ await sleep(intervalMs);
908
+ if (stopped) break;
909
+
910
+ let currentFiles;
911
+ try {
912
+ currentFiles = await listFilesRecursive(rootDir);
913
+ } catch (error) {
914
+ console.error(`Watcher error: ${error.message}`);
915
+ continue;
916
+ }
917
+
918
+ const currentMap = fileListToMap(currentFiles);
919
+ const prevKeys = new Set(previousMap.keys());
920
+ const currKeys = new Set(currentMap.keys());
921
+ const removed = [...prevKeys].filter((k) => !currKeys.has(k)).map((k) => previousMap.get(k));
922
+ const added = [...currKeys].filter((k) => !prevKeys.has(k)).map((k) => currentMap.get(k));
923
+ const changed = [...currKeys]
924
+ .filter((k) => prevKeys.has(k))
925
+ .map((k) => ({ prev: previousMap.get(k), cur: currentMap.get(k) }))
926
+ .filter((p) => p.prev.size !== p.cur.size || p.prev.mtimeMs !== p.cur.mtimeMs)
927
+ .map((p) => p.cur);
928
+
929
+ // Detecter les renames simples (meme taille + mtime) pour appeler PATCH rename.
930
+ const addedByFinger = new Map();
931
+ for (const a of added) {
932
+ const key = makeFingerprint(a);
933
+ if (!addedByFinger.has(key)) addedByFinger.set(key, []);
934
+ addedByFinger.get(key).push(a);
935
+ }
936
+
937
+ const renamedPairs = [];
938
+ const trulyRemoved = [];
939
+ for (const r of removed) {
940
+ const matches = addedByFinger.get(makeFingerprint(r)) || [];
941
+ const remoteId = state.files[r.relPath]?.remoteId || null;
942
+ if (remoteId && matches.length > 0) {
943
+ const to = matches.shift();
944
+ renamedPairs.push({ from: r, to, remoteId });
945
+ } else {
946
+ trulyRemoved.push(r);
947
+ }
948
+ }
949
+ const renamedTargets = new Set(renamedPairs.map((p) => p.to.relPath));
950
+ const trulyAdded = added.filter((a) => !renamedTargets.has(a.relPath));
951
+
952
+ for (const pair of renamedPairs) {
953
+ try {
954
+ await renameRemoteFile(server, token, pair.remoteId, pair.to.relPath);
955
+ delete state.files[pair.from.relPath];
956
+ state.files[pair.to.relPath] = {
957
+ size: pair.to.size,
958
+ mtimeMs: pair.to.mtimeMs,
959
+ uploadedAt: new Date().toISOString(),
960
+ remoteId: pair.remoteId
961
+ };
962
+ console.log(`[WATCH] RENAME ${pair.from.relPath} -> ${pair.to.relPath}`);
963
+ } catch (error) {
964
+ console.error(`[WATCH] RENAME FAIL ${pair.from.relPath} -> ${pair.to.relPath}: ${error.message}`);
965
+ }
966
+ }
967
+
968
+ for (const file of trulyRemoved) {
969
+ const remoteId = state.files[file.relPath]?.remoteId || null;
970
+ if (!remoteId) {
971
+ delete state.files[file.relPath];
972
+ continue;
973
+ }
974
+ try {
975
+ await deleteRemoteFile(server, token, remoteId);
976
+ delete state.files[file.relPath];
977
+ console.log(`[WATCH] DELETE ${file.relPath}`);
978
+ } catch (error) {
979
+ console.error(`[WATCH] DELETE FAIL ${file.relPath}: ${error.message}`);
980
+ }
981
+ }
982
+
983
+ const uploads = [...trulyAdded, ...changed];
984
+ for (const file of uploads) {
985
+ try {
986
+ const result = await uploadOneFile(server, token, file);
987
+ state.files[file.relPath] = {
988
+ size: file.size,
989
+ mtimeMs: file.mtimeMs,
990
+ uploadedAt: new Date().toISOString(),
991
+ remoteId: result?.file?.id || state.files[file.relPath]?.remoteId || null
992
+ };
993
+ console.log(`[WATCH] UPLOAD ${file.relPath} (${formatBytes(file.size)})`);
994
+ } catch (error) {
995
+ console.error(`[WATCH] UPLOAD FAIL ${file.relPath}: ${error.message}`);
996
+ }
997
+ }
998
+
999
+ await writeState(rootDir, state);
1000
+ previousFiles = currentFiles;
1001
+ previousMap = currentMap;
1002
+ }
1003
+
1004
+ process.off('SIGINT', onStop);
1005
+ process.off('SIGTERM', onStop);
1006
+ }
1007
+
1008
+ async function syncFolder(positionals, flags) {
1009
+ let target = positionals[0];
1010
+ if (!target && isInteractive()) {
1011
+ target = await askText('Dossier a synchroniser');
1012
+ }
1013
+ if (!target) {
1014
+ throw new Error('Usage: shard sync <folder> [--server <url>] [--dry-run] [--force] [--once] [--interval-ms <n>]');
1015
+ }
1016
+
1017
+ const rootDir = path.resolve(process.cwd(), target);
1018
+ if (!(await pathExists(rootDir))) {
1019
+ throw new Error(`Dossier introuvable: ${rootDir}`);
1020
+ }
1021
+ const stat = await fsp.stat(rootDir);
1022
+ if (!stat.isDirectory()) {
1023
+ throw new Error(`Ce n'est pas un dossier: ${rootDir}`);
1024
+ }
1025
+
1026
+ const config = await readConfig();
1027
+ const server = getServer(flags, config);
1028
+ const token = getToken(config);
1029
+ if (!token) {
1030
+ throw new Error('Non connecte. Lance: shard login --username ... --password ...');
1031
+ }
1032
+
1033
+ await httpJson(`${server}/api/auth/verify`, {
1034
+ method: 'POST',
1035
+ headers: { Authorization: `Bearer ${token}` }
1036
+ });
1037
+
1038
+ const state = await readState(rootDir);
1039
+ const force = Boolean(flags.force);
1040
+ const dryRun = Boolean(flags['dry-run']);
1041
+ const once = Boolean(flags.once);
1042
+ const intervalMs = Math.max(parseInt(flags['interval-ms'] || '2000', 10) || 2000, 500);
1043
+
1044
+ const remoteFiles = await fetchAllRemoteFiles(server, token);
1045
+ const initialLocalFiles = await listFilesRecursive(rootDir);
1046
+ await pullMissingRemoteFiles(server, token, rootDir, state, initialLocalFiles, remoteFiles, dryRun);
1047
+
1048
+ const files = await listFilesRecursive(rootDir);
1049
+ seedStateFromRemote(state, files, remoteFiles);
1050
+
1051
+ const changed = files.filter((f) => {
1052
+ if (force) return true;
1053
+ const prev = state.files[f.relPath];
1054
+ if (!prev) return true;
1055
+ return !(prev.size === f.size && prev.mtimeMs === f.mtimeMs);
1056
+ });
1057
+
1058
+ const unchanged = files.length - changed.length;
1059
+ console.log(`Server: ${server}`);
1060
+ console.log(`Dossier: ${rootDir}`);
1061
+ console.log(`Total: ${files.length} fichier(s)`);
1062
+ console.log(`Distants: ${remoteFiles.length}`);
1063
+ console.log(`A uploader: ${changed.length}`);
1064
+ console.log(`Inchanges: ${unchanged}`);
1065
+
1066
+ if (dryRun) {
1067
+ for (const f of changed.slice(0, 50)) console.log(`- ${f.relPath}`);
1068
+ if (changed.length > 50) console.log(`... +${changed.length - 50} autres`);
1069
+ return;
1070
+ }
1071
+
1072
+ let success = 0;
1073
+ let failed = 0;
1074
+
1075
+ for (let i = 0; i < changed.length; i += 1) {
1076
+ const file = changed[i];
1077
+ const label = `[${i + 1}/${changed.length}]`;
1078
+ try {
1079
+ const startedAt = Date.now();
1080
+ console.log(`${label} UPLOAD ${file.relPath} (${formatBytes(file.size)})`);
1081
+ const result = await uploadOneFile(server, token, file);
1082
+ const tookSeconds = ((Date.now() - startedAt) / 1000).toFixed(1);
1083
+ success += 1;
1084
+ state.files[file.relPath] = {
1085
+ size: file.size,
1086
+ mtimeMs: file.mtimeMs,
1087
+ uploadedAt: new Date().toISOString(),
1088
+ remoteId: result?.file?.id || null
1089
+ };
1090
+ console.log(`${label} OK ${file.relPath} (${tookSeconds}s)`);
1091
+ } catch (error) {
1092
+ failed += 1;
1093
+ console.error(`${label} FAIL ${file.relPath} -> ${error.message}`);
1094
+ }
1095
+ }
1096
+
1097
+ for (const relPath of Object.keys(state.files)) {
1098
+ const stillThere = files.some((f) => f.relPath === relPath);
1099
+ if (!stillThere) delete state.files[relPath];
1100
+ }
1101
+ await writeState(rootDir, state);
1102
+
1103
+ console.log(`Termine. Uploades: ${success}, erreurs: ${failed}`);
1104
+ if (failed > 0) process.exitCode = 2;
1105
+ if (!once && !dryRun) {
1106
+ await mirrorRealtime(server, token, rootDir, state, intervalMs);
1107
+ }
1108
+ }
1109
+
1110
+ async function main() {
1111
+ const { command, positionals, flags } = parseArgs(process.argv.slice(2));
1112
+
1113
+ // Vérifie la mise à jour à chaque commande (sauf --help)
1114
+ if (command && command !== 'help' && command !== '--help' && command !== '-h') {
1115
+ const latestVersion = await checkForUpdate();
1116
+ if (latestVersion) {
1117
+ const config = await readConfig();
1118
+ // Logout automatique si pas à jour
1119
+ await writeConfig({ ...config, token: '' });
1120
+ const wantsUpdate = await promptUpdate(latestVersion);
1121
+ if (wantsUpdate) {
1122
+ await runUpdate(latestVersion);
1123
+ } else {
1124
+ console.log('\n❌ Commande annulée. Mets à jour pour continuer.\n');
1125
+ process.exit(1);
1126
+ }
1127
+ }
1128
+ }
1129
+
1130
+ if (!command || command === 'help' || command === '--help' || command === '-h') {
1131
+ printHelp();
1132
+ return;
1133
+ }
1134
+
1135
+ if (command === 'login') {
1136
+ await login(flags);
1137
+ return;
1138
+ }
1139
+
1140
+ if (command === 'whoami') {
1141
+ await whoami(flags);
1142
+ return;
1143
+ }
1144
+
1145
+ if (command === 'logout') {
1146
+ await logout();
1147
+ return;
1148
+ }
1149
+
1150
+ if (command === 'sync') {
1151
+ await syncFolder(positionals, flags);
1152
+ return;
1153
+ }
1154
+
1155
+ if (command === 'share') {
1156
+ await shareFile(positionals, flags);
1157
+ return;
1158
+ }
1159
+
1160
+ if (command === 'config') {
1161
+ const sub = positionals[0];
1162
+ if (sub === 'show') {
1163
+ await showConfig();
1164
+ return;
1165
+ }
1166
+ if (sub === 'set-server') {
1167
+ await setServer(positionals.slice(1));
1168
+ return;
1169
+ }
1170
+ throw new Error('Usage: shard config show | shard config set-server <url>');
1171
+ }
1172
+
1173
+ throw new Error(`Commande inconnue: ${command}`);
1174
+ }
1175
+
1176
+ main().catch((error) => {
1177
+ console.error(`Erreur: ${error.message}`);
1178
+ process.exitCode = 1;
1179
+ });