@iksdev/shard-cli 0.1.6 → 0.1.7

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/README.md +9 -1
  2. package/bin/shard.js +870 -741
  3. package/package.json +1 -1
package/bin/shard.js CHANGED
@@ -1,791 +1,920 @@
1
- #!/usr/bin/env node
2
- /* eslint-disable no-console */
1
+ #!/usr/bin/env node
2
+ /* eslint-disable no-console */
3
3
  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 http = require('http');
8
+ const crypto = require('crypto');
7
9
  const { Readable } = require('stream');
8
10
  const { pipeline } = require('stream/promises');
9
-
10
- const CONFIG_DIR = path.join(os.homedir(), '.shard-cli');
11
- const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
12
- const STATE_FILE = '.shard-sync-state.json';
13
- const DEFAULT_SERVER = 'http://localhost:3000';
14
- const IGNORED_DIRS = new Set(['.git', 'node_modules']);
15
-
16
- function printHelp() {
17
- console.log(`Shard CLI
18
-
19
- Usage:
20
- shard login --username <name> --password <pass> [--server <url>]
21
- shard whoami [--server <url>]
22
- shard sync <folder> [--server <url>] [--dry-run] [--force] [--once] [--interval-ms <n>]
23
- shard share <file> [--server <url>] [--limits <n>] [--temps <jours>]
24
- shard logout
25
- shard config show
26
- shard config set-server <url>
27
-
28
- Examples:
29
- shard login --server http://localhost:3000 --username admin --password secret
30
- shard sync ./MonDossier
31
- shard sync ./MonDossier --once
32
- shard sync ./MonDossier --dry-run
33
- shard share ./MonFichier.mp4 --limits 0 --temps 0
11
+
12
+ const CONFIG_DIR = path.join(os.homedir(), '.shard-cli');
13
+ const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
14
+ const STATE_FILE = '.shard-sync-state.json';
15
+ const DEFAULT_SERVER = 'https://shard-0ow4.onrender.com';
16
+ const IGNORED_DIRS = new Set(['.git', 'node_modules']);
17
+
18
+ function printHelp() {
19
+ console.log(`Shard CLI
20
+
21
+ Usage:
22
+ shard login --username <name> --password <pass> [--server <url>]
23
+ shard whoami [--server <url>]
24
+ shard sync <folder> [--server <url>] [--dry-run] [--force] [--once] [--interval-ms <n>]
25
+ shard share <file> [--server <url>] [--limits <n>] [--temps <jours>] [--local --public-url <url> --port <n>]
26
+ shard logout
27
+ shard config show
28
+ shard config set-server <url>
29
+
30
+ Examples:
31
+ shard login --server https://shard-0ow4.onrender.com --username admin --password secret
32
+ shard sync ./MonDossier
33
+ shard sync ./MonDossier --once
34
+ shard sync ./MonDossier --dry-run
35
+ shard share ./MonFichier.mp4 --local --public-url https://xxxx.trycloudflare.com --limits 0 --temps 0
34
36
  `);
35
37
  }
36
-
37
- function parseArgs(rawArgs) {
38
- const args = [...rawArgs];
39
- const command = args.shift();
40
- const positionals = [];
41
- const flags = {};
42
-
43
- for (let i = 0; i < args.length; i += 1) {
44
- const cur = args[i];
45
- if (!cur.startsWith('--')) {
46
- positionals.push(cur);
47
- continue;
48
- }
49
- const key = cur.slice(2);
50
- const next = args[i + 1];
51
- if (!next || next.startsWith('--')) {
52
- flags[key] = true;
53
- continue;
54
- }
55
- flags[key] = next;
56
- i += 1;
38
+
39
+ function parseArgs(rawArgs) {
40
+ const args = [...rawArgs];
41
+ const command = args.shift();
42
+ const positionals = [];
43
+ const flags = {};
44
+
45
+ for (let i = 0; i < args.length; i += 1) {
46
+ const cur = args[i];
47
+ if (!cur.startsWith('--')) {
48
+ positionals.push(cur);
49
+ continue;
50
+ }
51
+ const key = cur.slice(2);
52
+ const next = args[i + 1];
53
+ if (!next || next.startsWith('--')) {
54
+ flags[key] = true;
55
+ continue;
56
+ }
57
+ flags[key] = next;
58
+ i += 1;
59
+ }
60
+ return { command, positionals, flags };
61
+ }
62
+
63
+ function normalizeServer(input) {
64
+ const raw = String(input || '').trim();
65
+ if (!raw) return DEFAULT_SERVER;
66
+ return raw.replace(/\/+$/, '');
67
+ }
68
+
69
+ async function ensureConfigDir() {
70
+ await fsp.mkdir(CONFIG_DIR, { recursive: true });
71
+ }
72
+
73
+ async function readConfig() {
74
+ try {
75
+ const raw = await fsp.readFile(CONFIG_PATH, 'utf8');
76
+ const parsed = JSON.parse(raw);
77
+ return {
78
+ server: normalizeServer(parsed.server || DEFAULT_SERVER),
79
+ token: parsed.token || ''
80
+ };
81
+ } catch {
82
+ return { server: DEFAULT_SERVER, token: '' };
83
+ }
84
+ }
85
+
86
+ async function writeConfig(config) {
87
+ await ensureConfigDir();
88
+ const payload = {
89
+ server: normalizeServer(config.server || DEFAULT_SERVER),
90
+ token: config.token || ''
91
+ };
92
+ await fsp.writeFile(CONFIG_PATH, JSON.stringify(payload, null, 2), 'utf8');
93
+ }
94
+
95
+ function getServer(flags, config) {
96
+ return normalizeServer(flags.server || process.env.SHARD_SERVER || config.server || DEFAULT_SERVER);
97
+ }
98
+
99
+ function getToken(config) {
100
+ return process.env.SHARD_TOKEN || config.token || '';
101
+ }
102
+
103
+ async function httpJson(url, options = {}) {
104
+ const res = await fetch(url, options);
105
+ const data = await res.json().catch(() => ({}));
106
+ if (!res.ok) {
107
+ const err = new Error(data.message || data.error || `HTTP ${res.status}`);
108
+ err.status = res.status;
109
+ err.data = data;
110
+ throw err;
111
+ }
112
+ return data;
113
+ }
114
+
115
+ async function login(flags) {
116
+ const username = flags.username;
117
+ const password = flags.password;
118
+ if (!username || !password) {
119
+ throw new Error('Usage: shard login --username <name> --password <pass> [--server <url>]');
120
+ }
121
+
122
+ const config = await readConfig();
123
+ const server = getServer(flags, config);
124
+
125
+ const data = await httpJson(`${server}/api/auth/login`, {
126
+ method: 'POST',
127
+ headers: { 'Content-Type': 'application/json' },
128
+ body: JSON.stringify({ username, password })
129
+ });
130
+
131
+ if (!data.token) {
132
+ throw new Error('Connexion réussie mais token manquant.');
133
+ }
134
+
135
+ await writeConfig({ server, token: data.token });
136
+ console.log(`Connecte a ${server}`);
137
+ if (data.user?.username) {
138
+ console.log(`Utilisateur: ${data.user.username}`);
139
+ }
140
+ }
141
+
142
+ async function whoami(flags) {
143
+ const config = await readConfig();
144
+ const server = getServer(flags, config);
145
+ const token = getToken(config);
146
+ if (!token) {
147
+ throw new Error('Non connecte. Lance: shard login --username ... --password ...');
148
+ }
149
+
150
+ const data = await httpJson(`${server}/api/auth/verify`, {
151
+ method: 'POST',
152
+ headers: { Authorization: `Bearer ${token}` }
153
+ });
154
+
155
+ const user = data.user || {};
156
+ console.log(`Server: ${server}`);
157
+ console.log(`User: ${user.username || user.userId || 'inconnu'}`);
158
+ if (user.email) console.log(`Email: ${user.email}`);
159
+ }
160
+
161
+ async function logout() {
162
+ const config = await readConfig();
163
+ await writeConfig({ ...config, token: '' });
164
+ console.log('Token supprime.');
165
+ }
166
+
167
+ async function showConfig() {
168
+ const config = await readConfig();
169
+ console.log(JSON.stringify({
170
+ server: config.server,
171
+ hasToken: Boolean(config.token)
172
+ }, null, 2));
173
+ }
174
+
175
+ async function setServer(positionals) {
176
+ const url = positionals[0];
177
+ if (!url) {
178
+ throw new Error('Usage: shard config set-server <url>');
179
+ }
180
+ const config = await readConfig();
181
+ await writeConfig({ ...config, server: normalizeServer(url) });
182
+ console.log(`Server mis a jour: ${normalizeServer(url)}`);
183
+ }
184
+
185
+ async function pathExists(targetPath) {
186
+ try {
187
+ await fsp.access(targetPath, fs.constants.R_OK);
188
+ return true;
189
+ } catch {
190
+ return false;
191
+ }
192
+ }
193
+
194
+ async function listFilesRecursive(rootDir) {
195
+ const out = [];
196
+
197
+ async function walk(currentDir) {
198
+ const entries = await fsp.readdir(currentDir, { withFileTypes: true });
199
+ for (const entry of entries) {
200
+ if (entry.name === STATE_FILE) continue;
201
+ if (entry.isDirectory() && IGNORED_DIRS.has(entry.name)) continue;
202
+
203
+ const abs = path.join(currentDir, entry.name);
204
+ if (entry.isDirectory()) {
205
+ await walk(abs);
206
+ continue;
207
+ }
208
+ if (!entry.isFile()) continue;
209
+
210
+ const rel = path.relative(rootDir, abs).split(path.sep).join('/');
211
+ const stat = await fsp.stat(abs);
212
+ out.push({
213
+ absPath: abs,
214
+ relPath: rel,
215
+ size: stat.size,
216
+ mtimeMs: Math.round(stat.mtimeMs)
217
+ });
218
+ }
219
+ }
220
+
221
+ await walk(rootDir);
222
+ out.sort((a, b) => a.relPath.localeCompare(b.relPath));
223
+ return out;
224
+ }
225
+
226
+ async function readState(rootDir) {
227
+ const filePath = path.join(rootDir, STATE_FILE);
228
+ try {
229
+ const raw = await fsp.readFile(filePath, 'utf8');
230
+ const parsed = JSON.parse(raw);
231
+ if (!parsed || typeof parsed !== 'object') return { version: 1, files: {} };
232
+ return { version: 1, files: parsed.files || {} };
233
+ } catch {
234
+ return { version: 1, files: {} };
235
+ }
236
+ }
237
+
238
+ async function writeState(rootDir, state) {
239
+ const filePath = path.join(rootDir, STATE_FILE);
240
+ const payload = {
241
+ version: 1,
242
+ updatedAt: new Date().toISOString(),
243
+ files: state.files || {}
244
+ };
245
+ await fsp.writeFile(filePath, JSON.stringify(payload, null, 2), 'utf8');
246
+ }
247
+
248
+ function guessMime(filePath) {
249
+ const ext = path.extname(filePath).toLowerCase();
250
+ const map = {
251
+ '.txt': 'text/plain',
252
+ '.md': 'text/markdown',
253
+ '.json': 'application/json',
254
+ '.csv': 'text/csv',
255
+ '.html': 'text/html',
256
+ '.css': 'text/css',
257
+ '.js': 'text/javascript',
258
+ '.ts': 'text/plain',
259
+ '.jpg': 'image/jpeg',
260
+ '.jpeg': 'image/jpeg',
261
+ '.png': 'image/png',
262
+ '.gif': 'image/gif',
263
+ '.webp': 'image/webp',
264
+ '.pdf': 'application/pdf',
265
+ '.zip': 'application/zip'
266
+ };
267
+ return map[ext] || 'application/octet-stream';
268
+ }
269
+
270
+ function formatBytes(bytes) {
271
+ const value = Number(bytes || 0);
272
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
273
+ let size = value;
274
+ let idx = 0;
275
+ while (size >= 1024 && idx < units.length - 1) {
276
+ size /= 1024;
277
+ idx += 1;
278
+ }
279
+ return `${size.toFixed(idx === 0 ? 0 : 2)} ${units[idx]}`;
280
+ }
281
+
282
+ async function uploadOneFile(server, token, file) {
283
+ const blob = await fs.openAsBlob(file.absPath, { type: guessMime(file.absPath) });
284
+ const form = new FormData();
285
+ // On force le nom "relatif" pour garder la notion de dossier dans l'UI/DB.
286
+ form.append('file', blob, file.relPath);
287
+
288
+ return httpJson(`${server}/api/files/upload`, {
289
+ method: 'POST',
290
+ headers: { Authorization: `Bearer ${token}` },
291
+ body: form
292
+ });
293
+ }
294
+
295
+ async function findRemoteFileByNameAndSize(server, token, fileName, fileSize) {
296
+ const data = await httpJson(`${server}/api/files?limit=100&offset=0&sort=created_at&order=desc&search=${encodeURIComponent(fileName)}`, {
297
+ method: 'GET',
298
+ headers: { Authorization: `Bearer ${token}` }
299
+ });
300
+ const rows = Array.isArray(data.files) ? data.files : [];
301
+ return rows.find((row) => row.original_name === fileName && Number(row.file_size || 0) === Number(fileSize || 0)) || null;
302
+ }
303
+
304
+ function parseOptionalPositiveInt(raw, flagName) {
305
+ if (raw === undefined || raw === null) return undefined;
306
+ const n = parseInt(String(raw), 10);
307
+ if (Number.isNaN(n) || n < 0) {
308
+ throw new Error(`${flagName} doit etre un entier >= 0`);
57
309
  }
58
- return { command, positionals, flags };
310
+ return n;
59
311
  }
60
312
 
61
- function normalizeServer(input) {
313
+ function normalizePublicUrl(input) {
62
314
  const raw = String(input || '').trim();
63
- if (!raw) return DEFAULT_SERVER;
315
+ if (!raw) return '';
64
316
  return raw.replace(/\/+$/, '');
65
317
  }
66
318
 
67
- async function ensureConfigDir() {
68
- await fsp.mkdir(CONFIG_DIR, { recursive: true });
69
- }
319
+ function startLocalSingleFileServer({ filePath, fileName, mimeType, port, accessKey }) {
320
+ const safeName = encodeURIComponent(fileName);
321
+ const routePath = `/download/${accessKey}`;
70
322
 
71
- async function readConfig() {
72
- try {
73
- const raw = await fsp.readFile(CONFIG_PATH, 'utf8');
74
- const parsed = JSON.parse(raw);
75
- return {
76
- server: normalizeServer(parsed.server || DEFAULT_SERVER),
77
- token: parsed.token || ''
78
- };
79
- } catch {
80
- return { server: DEFAULT_SERVER, token: '' };
81
- }
82
- }
83
-
84
- async function writeConfig(config) {
85
- await ensureConfigDir();
86
- const payload = {
87
- server: normalizeServer(config.server || DEFAULT_SERVER),
88
- token: config.token || ''
89
- };
90
- await fsp.writeFile(CONFIG_PATH, JSON.stringify(payload, null, 2), 'utf8');
91
- }
92
-
93
- function getServer(flags, config) {
94
- return normalizeServer(flags.server || process.env.SHARD_SERVER || config.server || DEFAULT_SERVER);
95
- }
96
-
97
- function getToken(config) {
98
- return process.env.SHARD_TOKEN || config.token || '';
99
- }
323
+ const server = http.createServer((req, res) => {
324
+ const requestUrl = new URL(req.url || '/', 'http://127.0.0.1');
100
325
 
101
- async function httpJson(url, options = {}) {
102
- const res = await fetch(url, options);
103
- const data = await res.json().catch(() => ({}));
104
- if (!res.ok) {
105
- const err = new Error(data.message || data.error || `HTTP ${res.status}`);
106
- err.status = res.status;
107
- err.data = data;
108
- throw err;
109
- }
110
- return data;
111
- }
112
-
113
- async function login(flags) {
114
- const username = flags.username;
115
- const password = flags.password;
116
- if (!username || !password) {
117
- throw new Error('Usage: shard login --username <name> --password <pass> [--server <url>]');
118
- }
119
-
120
- const config = await readConfig();
121
- const server = getServer(flags, config);
122
-
123
- const data = await httpJson(`${server}/api/auth/login`, {
124
- method: 'POST',
125
- headers: { 'Content-Type': 'application/json' },
126
- body: JSON.stringify({ username, password })
127
- });
128
-
129
- if (!data.token) {
130
- throw new Error('Connexion réussie mais token manquant.');
131
- }
132
-
133
- await writeConfig({ server, token: data.token });
134
- console.log(`Connecte a ${server}`);
135
- if (data.user?.username) {
136
- console.log(`Utilisateur: ${data.user.username}`);
137
- }
138
- }
139
-
140
- async function whoami(flags) {
141
- const config = await readConfig();
142
- const server = getServer(flags, config);
143
- const token = getToken(config);
144
- if (!token) {
145
- throw new Error('Non connecte. Lance: shard login --username ... --password ...');
146
- }
147
-
148
- const data = await httpJson(`${server}/api/auth/verify`, {
149
- method: 'POST',
150
- headers: { Authorization: `Bearer ${token}` }
151
- });
152
-
153
- const user = data.user || {};
154
- console.log(`Server: ${server}`);
155
- console.log(`User: ${user.username || user.userId || 'inconnu'}`);
156
- if (user.email) console.log(`Email: ${user.email}`);
157
- }
158
-
159
- async function logout() {
160
- const config = await readConfig();
161
- await writeConfig({ ...config, token: '' });
162
- console.log('Token supprime.');
163
- }
164
-
165
- async function showConfig() {
166
- const config = await readConfig();
167
- console.log(JSON.stringify({
168
- server: config.server,
169
- hasToken: Boolean(config.token)
170
- }, null, 2));
171
- }
172
-
173
- async function setServer(positionals) {
174
- const url = positionals[0];
175
- if (!url) {
176
- throw new Error('Usage: shard config set-server <url>');
177
- }
178
- const config = await readConfig();
179
- await writeConfig({ ...config, server: normalizeServer(url) });
180
- console.log(`Server mis a jour: ${normalizeServer(url)}`);
181
- }
182
-
183
- async function pathExists(targetPath) {
184
- try {
185
- await fsp.access(targetPath, fs.constants.R_OK);
186
- return true;
187
- } catch {
188
- return false;
189
- }
190
- }
191
-
192
- async function listFilesRecursive(rootDir) {
193
- const out = [];
194
-
195
- async function walk(currentDir) {
196
- const entries = await fsp.readdir(currentDir, { withFileTypes: true });
197
- for (const entry of entries) {
198
- if (entry.name === STATE_FILE) continue;
199
- if (entry.isDirectory() && IGNORED_DIRS.has(entry.name)) continue;
200
-
201
- const abs = path.join(currentDir, entry.name);
202
- if (entry.isDirectory()) {
203
- await walk(abs);
204
- continue;
205
- }
206
- if (!entry.isFile()) continue;
207
-
208
- const rel = path.relative(rootDir, abs).split(path.sep).join('/');
209
- const stat = await fsp.stat(abs);
210
- out.push({
211
- absPath: abs,
212
- relPath: rel,
213
- size: stat.size,
214
- mtimeMs: Math.round(stat.mtimeMs)
215
- });
326
+ if (requestUrl.pathname === '/health') {
327
+ res.statusCode = 200;
328
+ res.setHeader('Content-Type', 'application/json');
329
+ res.end(JSON.stringify({ ok: true, mode: 'local-share', file: fileName }));
330
+ return;
216
331
  }
217
- }
218
-
219
- await walk(rootDir);
220
- out.sort((a, b) => a.relPath.localeCompare(b.relPath));
221
- return out;
222
- }
223
-
224
- async function readState(rootDir) {
225
- const filePath = path.join(rootDir, STATE_FILE);
226
- try {
227
- const raw = await fsp.readFile(filePath, 'utf8');
228
- const parsed = JSON.parse(raw);
229
- if (!parsed || typeof parsed !== 'object') return { version: 1, files: {} };
230
- return { version: 1, files: parsed.files || {} };
231
- } catch {
232
- return { version: 1, files: {} };
233
- }
234
- }
235
-
236
- async function writeState(rootDir, state) {
237
- const filePath = path.join(rootDir, STATE_FILE);
238
- const payload = {
239
- version: 1,
240
- updatedAt: new Date().toISOString(),
241
- files: state.files || {}
242
- };
243
- await fsp.writeFile(filePath, JSON.stringify(payload, null, 2), 'utf8');
244
- }
245
332
 
246
- function guessMime(filePath) {
247
- const ext = path.extname(filePath).toLowerCase();
248
- const map = {
249
- '.txt': 'text/plain',
250
- '.md': 'text/markdown',
251
- '.json': 'application/json',
252
- '.csv': 'text/csv',
253
- '.html': 'text/html',
254
- '.css': 'text/css',
255
- '.js': 'text/javascript',
256
- '.ts': 'text/plain',
257
- '.jpg': 'image/jpeg',
258
- '.jpeg': 'image/jpeg',
259
- '.png': 'image/png',
260
- '.gif': 'image/gif',
261
- '.webp': 'image/webp',
262
- '.pdf': 'application/pdf',
263
- '.zip': 'application/zip'
264
- };
265
- return map[ext] || 'application/octet-stream';
266
- }
267
-
268
- function formatBytes(bytes) {
269
- const value = Number(bytes || 0);
270
- const units = ['B', 'KB', 'MB', 'GB', 'TB'];
271
- let size = value;
272
- let idx = 0;
273
- while (size >= 1024 && idx < units.length - 1) {
274
- size /= 1024;
275
- idx += 1;
276
- }
277
- return `${size.toFixed(idx === 0 ? 0 : 2)} ${units[idx]}`;
278
- }
279
-
280
- async function uploadOneFile(server, token, file) {
281
- const blob = await fs.openAsBlob(file.absPath, { type: guessMime(file.absPath) });
282
- const form = new FormData();
283
- // On force le nom "relatif" pour garder la notion de dossier dans l'UI/DB.
284
- form.append('file', blob, file.relPath);
333
+ if (requestUrl.pathname !== routePath) {
334
+ res.statusCode = 404;
335
+ res.setHeader('Content-Type', 'application/json');
336
+ res.end(JSON.stringify({ error: 'Not found' }));
337
+ return;
338
+ }
285
339
 
286
- return httpJson(`${server}/api/files/upload`, {
287
- method: 'POST',
288
- headers: { Authorization: `Bearer ${token}` },
289
- body: form
340
+ const stream = fs.createReadStream(filePath);
341
+ res.statusCode = 200;
342
+ res.setHeader('Content-Type', mimeType || 'application/octet-stream');
343
+ res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${safeName}`);
344
+ stream.on('error', () => {
345
+ if (!res.headersSent) {
346
+ res.statusCode = 500;
347
+ res.end('Read error');
348
+ } else {
349
+ res.destroy();
350
+ }
351
+ });
352
+ stream.pipe(res);
290
353
  });
291
- }
292
354
 
293
- async function findRemoteFileByNameAndSize(server, token, fileName, fileSize) {
294
- const data = await httpJson(`${server}/api/files?limit=100&offset=0&sort=created_at&order=desc&search=${encodeURIComponent(fileName)}`, {
295
- method: 'GET',
296
- headers: { Authorization: `Bearer ${token}` }
355
+ return new Promise((resolve, reject) => {
356
+ server.once('error', reject);
357
+ server.listen(port, '0.0.0.0', () => {
358
+ server.removeListener('error', reject);
359
+ resolve({ server, routePath });
360
+ });
297
361
  });
298
- const rows = Array.isArray(data.files) ? data.files : [];
299
- return rows.find((row) => row.original_name === fileName && Number(row.file_size || 0) === Number(fileSize || 0)) || null;
300
- }
301
-
302
- function parseOptionalPositiveInt(raw, flagName) {
303
- if (raw === undefined || raw === null) return undefined;
304
- const n = parseInt(String(raw), 10);
305
- if (Number.isNaN(n) || n < 0) {
306
- throw new Error(`${flagName} doit etre un entier >= 0`);
307
- }
308
- return n;
309
362
  }
310
-
363
+
311
364
  async function shareFile(positionals, flags) {
312
365
  const target = positionals[0];
313
366
  if (!target) {
314
- throw new Error('Usage: shard share <file> [--server <url>] [--limits <n>] [--temps <jours>]');
315
- }
316
-
317
- const absPath = path.resolve(process.cwd(), target);
318
- if (!(await pathExists(absPath))) {
319
- throw new Error(`Fichier introuvable: ${absPath}`);
320
- }
321
- const st = await fsp.stat(absPath);
322
- if (!st.isFile()) {
323
- throw new Error(`Ce n'est pas un fichier: ${absPath}`);
324
- }
325
-
367
+ throw new Error('Usage: shard share <file> [--server <url>] [--limits <n>] [--temps <jours>] [--local --public-url <url> --port <n>]');
368
+ }
369
+
370
+ const absPath = path.resolve(process.cwd(), target);
371
+ if (!(await pathExists(absPath))) {
372
+ throw new Error(`Fichier introuvable: ${absPath}`);
373
+ }
374
+ const st = await fsp.stat(absPath);
375
+ if (!st.isFile()) {
376
+ throw new Error(`Ce n'est pas un fichier: ${absPath}`);
377
+ }
378
+
326
379
  const limits = parseOptionalPositiveInt(flags.limits, '--limits');
327
380
  const temps = parseOptionalPositiveInt(flags.temps, '--temps');
328
-
329
- const config = await readConfig();
330
- const server = getServer(flags, config);
331
- const token = getToken(config);
332
- if (!token) {
333
- throw new Error('Non connecte. Lance: shard login --username ... --password ...');
334
- }
335
-
381
+ const localMode = Boolean(flags.local);
382
+
383
+ const config = await readConfig();
384
+ const server = getServer(flags, config);
385
+ const token = getToken(config);
386
+ if (!token) {
387
+ throw new Error('Non connecte. Lance: shard login --username ... --password ...');
388
+ }
389
+
336
390
  await httpJson(`${server}/api/auth/verify`, {
337
391
  method: 'POST',
338
392
  headers: { Authorization: `Bearer ${token}` }
339
393
  });
340
394
 
341
395
  const fileName = path.basename(absPath);
342
- let remote = await findRemoteFileByNameAndSize(server, token, fileName, st.size);
343
- let fileId = remote?.id || null;
344
-
345
- if (!fileId) {
346
- console.log(`Upload necessaire: ${fileName} (${formatBytes(st.size)})`);
347
- const uploaded = await uploadOneFile(server, token, {
348
- absPath,
349
- relPath: fileName,
350
- size: st.size,
351
- mtimeMs: Math.round(st.mtimeMs)
352
- });
353
- fileId = uploaded?.file?.id;
354
- if (!fileId) {
355
- throw new Error('Upload reussi mais ID fichier manquant');
356
- }
357
- }
358
-
359
- const payload = { fileId };
360
- if (limits !== undefined && limits > 0) payload.maxDownloads = limits;
361
- if (temps !== undefined && temps > 0) payload.expiresInDays = temps;
362
-
363
- const created = await httpJson(`${server}/api/share/create`, {
364
- method: 'POST',
365
- headers: {
366
- Authorization: `Bearer ${token}`,
367
- 'Content-Type': 'application/json'
368
- },
369
- body: JSON.stringify(payload)
370
- });
371
-
372
- const share = created?.share || {};
373
- console.log(`Partage cree pour: ${fileName}`);
374
- if (share.url) console.log(`URL: ${share.url}`);
375
- if (share.token) console.log(`Token: ${share.token}`);
376
- console.log(`Limite downloads: ${limits && limits > 0 ? limits : 'illimitee'}`);
377
- console.log(`Expiration: ${temps && temps > 0 ? `${temps} jour(s)` : 'aucune'}`);
378
- }
379
-
380
- function fileListToMap(files) {
381
- const map = new Map();
382
- for (const file of files) {
383
- map.set(file.relPath, file);
384
- }
385
- return map;
386
- }
387
-
388
- function makeFingerprint(file) {
389
- return `${file.size}:${file.mtimeMs}`;
390
- }
391
-
392
- function normalizeRemoteRelPath(name) {
393
- return String(name || '').replace(/\\/g, '/');
394
- }
395
-
396
- async function fetchAllRemoteFiles(server, token) {
397
- const out = [];
398
- const limit = 100;
399
- let offset = 0;
400
-
401
- while (true) {
402
- const data = await httpJson(`${server}/api/files?limit=${limit}&offset=${offset}&sort=created_at&order=asc`, {
403
- method: 'GET',
404
- headers: { Authorization: `Bearer ${token}` }
405
- });
406
- const rows = Array.isArray(data.files) ? data.files : [];
407
- for (const row of rows) {
408
- out.push({
409
- id: row.id,
410
- relPath: normalizeRemoteRelPath(row.original_name),
411
- size: Number(row.file_size || 0)
412
- });
396
+ if (localMode) {
397
+ const publicUrl = normalizePublicUrl(flags['public-url'] || process.env.SHARD_PUBLIC_URL);
398
+ if (!publicUrl) {
399
+ throw new Error('Mode local: --public-url est requis (ou SHARD_PUBLIC_URL)');
413
400
  }
414
- if (!data.pagination?.hasMore) break;
415
- offset += limit;
416
- }
417
-
418
- return out;
419
- }
420
-
421
- async function downloadRemoteFile(server, token, remoteFile, destPath) {
422
- const res = await fetch(`${server}/api/files/${remoteFile.id}/download`, {
423
- method: 'GET',
424
- headers: { Authorization: `Bearer ${token}` }
425
- });
426
401
 
427
- if (!res.ok) {
428
- let detail = `HTTP ${res.status}`;
402
+ let publicUrlParsed;
429
403
  try {
430
- const data = await res.json();
431
- detail = data.message || data.error || detail;
404
+ publicUrlParsed = new URL(publicUrl);
432
405
  } catch {
433
- // ignore JSON parse failures
406
+ throw new Error(`--public-url invalide: ${publicUrl}`);
434
407
  }
435
- throw new Error(detail);
436
- }
437
-
438
- if (!res.body) {
439
- throw new Error('Reponse de telechargement vide');
440
- }
441
-
442
- await fsp.mkdir(path.dirname(destPath), { recursive: true });
443
- const tempPath = `${destPath}.part`;
444
- const writable = fs.createWriteStream(tempPath);
445
- await pipeline(Readable.fromWeb(res.body), writable);
446
- await fsp.rename(tempPath, destPath);
447
- }
448
-
449
- async function pullMissingRemoteFiles(server, token, rootDir, state, localFiles, remoteFiles, dryRun) {
450
- const localMap = fileListToMap(localFiles);
451
- const missingRemote = remoteFiles.filter((rf) => !localMap.has(rf.relPath));
452
-
453
- if (missingRemote.length === 0) return { downloaded: 0, errors: 0 };
454
-
455
- console.log(`A telecharger: ${missingRemote.length}`);
456
- if (dryRun) {
457
- for (const rf of missingRemote.slice(0, 50)) console.log(`- ${rf.relPath} (remote)`); // eslint-disable-line no-console
458
- if (missingRemote.length > 50) console.log(`... +${missingRemote.length - 50} autres`);
459
- return { downloaded: 0, errors: 0 };
460
- }
461
-
462
- let downloaded = 0;
463
- let errors = 0;
464
- for (let i = 0; i < missingRemote.length; i += 1) {
465
- const rf = missingRemote[i];
466
- const label = `[DOWN ${i + 1}/${missingRemote.length}]`;
467
- try {
468
- const absPath = path.join(rootDir, ...rf.relPath.split('/'));
469
- console.log(`${label} GET ${rf.relPath} (${formatBytes(rf.size)})`);
470
- await downloadRemoteFile(server, token, rf, absPath);
471
- const st = await fsp.stat(absPath);
472
- state.files[rf.relPath] = {
473
- size: st.size,
474
- mtimeMs: Math.round(st.mtimeMs),
475
- uploadedAt: new Date().toISOString(),
476
- remoteId: rf.id
477
- };
478
- downloaded += 1;
479
- console.log(`${label} OK ${rf.relPath}`);
480
- } catch (error) {
481
- errors += 1;
482
- console.error(`${label} FAIL ${rf.relPath} -> ${error.message}`);
408
+ if (!['http:', 'https:'].includes(publicUrlParsed.protocol)) {
409
+ throw new Error('--public-url doit etre en http(s)');
483
410
  }
484
- }
485
-
486
- return { downloaded, errors };
487
- }
488
-
489
- function seedStateFromRemote(state, localFiles, remoteFiles) {
490
- const remoteByPath = new Map(remoteFiles.map((rf) => [rf.relPath, rf]));
491
- for (const lf of localFiles) {
492
- if (state.files[lf.relPath]) continue;
493
- const remote = remoteByPath.get(lf.relPath);
494
- if (!remote) continue;
495
- if (Number(remote.size) !== Number(lf.size)) continue;
496
- state.files[lf.relPath] = {
497
- size: lf.size,
498
- mtimeMs: lf.mtimeMs,
499
- uploadedAt: new Date().toISOString(),
500
- remoteId: remote.id
501
- };
502
- }
503
- }
504
-
505
- async function deleteRemoteFile(server, token, remoteId) {
506
- return httpJson(`${server}/api/files/${remoteId}`, {
507
- method: 'DELETE',
508
- headers: { Authorization: `Bearer ${token}` }
509
- });
510
- }
511
411
 
512
- async function renameRemoteFile(server, token, remoteId, newName) {
513
- return httpJson(`${server}/api/files/${remoteId}/rename`, {
514
- method: 'PATCH',
515
- headers: {
516
- Authorization: `Bearer ${token}`,
517
- 'Content-Type': 'application/json'
518
- },
519
- body: JSON.stringify({ newName })
520
- });
521
- }
522
-
523
- function sleep(ms) {
524
- return new Promise((resolve) => setTimeout(resolve, ms));
525
- }
526
-
527
- async function mirrorRealtime(server, token, rootDir, state, intervalMs) {
528
- console.log(`Mode temps reel actif (${intervalMs} ms). Ctrl+C pour quitter.`);
529
-
530
- let stopped = false;
531
- const onStop = () => { stopped = true; };
532
- process.on('SIGINT', onStop);
533
- process.on('SIGTERM', onStop);
534
-
535
- let previousFiles = await listFilesRecursive(rootDir);
536
- let previousMap = fileListToMap(previousFiles);
537
-
538
- while (!stopped) {
539
- await sleep(intervalMs);
540
- if (stopped) break;
541
-
542
- let currentFiles;
412
+ const port = Math.max(parseInt(flags.port || process.env.SHARD_LOCAL_PORT || '8787', 10) || 8787, 1);
413
+ const accessKey = crypto.randomBytes(18).toString('base64url');
414
+ const mimeType = guessMime(absPath);
415
+ let localServer = null;
543
416
  try {
544
- currentFiles = await listFilesRecursive(rootDir);
545
- } catch (error) {
546
- console.error(`Watcher error: ${error.message}`);
547
- continue;
548
- }
549
-
550
- const currentMap = fileListToMap(currentFiles);
551
- const prevKeys = new Set(previousMap.keys());
552
- const currKeys = new Set(currentMap.keys());
553
- const removed = [...prevKeys].filter((k) => !currKeys.has(k)).map((k) => previousMap.get(k));
554
- const added = [...currKeys].filter((k) => !prevKeys.has(k)).map((k) => currentMap.get(k));
555
- const changed = [...currKeys]
556
- .filter((k) => prevKeys.has(k))
557
- .map((k) => ({ prev: previousMap.get(k), cur: currentMap.get(k) }))
558
- .filter((p) => p.prev.size !== p.cur.size || p.prev.mtimeMs !== p.cur.mtimeMs)
559
- .map((p) => p.cur);
560
-
561
- // Detecter les renames simples (meme taille + mtime) pour appeler PATCH rename.
562
- const addedByFinger = new Map();
563
- for (const a of added) {
564
- const key = makeFingerprint(a);
565
- if (!addedByFinger.has(key)) addedByFinger.set(key, []);
566
- addedByFinger.get(key).push(a);
567
- }
568
-
569
- const renamedPairs = [];
570
- const trulyRemoved = [];
571
- for (const r of removed) {
572
- const matches = addedByFinger.get(makeFingerprint(r)) || [];
573
- const remoteId = state.files[r.relPath]?.remoteId || null;
574
- if (remoteId && matches.length > 0) {
575
- const to = matches.shift();
576
- renamedPairs.push({ from: r, to, remoteId });
577
- } else {
578
- trulyRemoved.push(r);
579
- }
580
- }
581
- const renamedTargets = new Set(renamedPairs.map((p) => p.to.relPath));
582
- const trulyAdded = added.filter((a) => !renamedTargets.has(a.relPath));
583
-
584
- for (const pair of renamedPairs) {
585
- try {
586
- await renameRemoteFile(server, token, pair.remoteId, pair.to.relPath);
587
- delete state.files[pair.from.relPath];
588
- state.files[pair.to.relPath] = {
589
- size: pair.to.size,
590
- mtimeMs: pair.to.mtimeMs,
591
- uploadedAt: new Date().toISOString(),
592
- remoteId: pair.remoteId
593
- };
594
- console.log(`[WATCH] RENAME ${pair.from.relPath} -> ${pair.to.relPath}`);
595
- } catch (error) {
596
- console.error(`[WATCH] RENAME FAIL ${pair.from.relPath} -> ${pair.to.relPath}: ${error.message}`);
597
- }
598
- }
599
-
600
- for (const file of trulyRemoved) {
601
- const remoteId = state.files[file.relPath]?.remoteId || null;
602
- if (!remoteId) {
603
- delete state.files[file.relPath];
604
- continue;
605
- }
606
- try {
607
- await deleteRemoteFile(server, token, remoteId);
608
- delete state.files[file.relPath];
609
- console.log(`[WATCH] DELETE ${file.relPath}`);
610
- } catch (error) {
611
- console.error(`[WATCH] DELETE FAIL ${file.relPath}: ${error.message}`);
612
- }
613
- }
614
-
615
- const uploads = [...trulyAdded, ...changed];
616
- for (const file of uploads) {
617
- try {
618
- const result = await uploadOneFile(server, token, file);
619
- state.files[file.relPath] = {
620
- size: file.size,
621
- mtimeMs: file.mtimeMs,
622
- uploadedAt: new Date().toISOString(),
623
- remoteId: result?.file?.id || state.files[file.relPath]?.remoteId || null
624
- };
625
- console.log(`[WATCH] UPLOAD ${file.relPath} (${formatBytes(file.size)})`);
626
- } catch (error) {
627
- console.error(`[WATCH] UPLOAD FAIL ${file.relPath}: ${error.message}`);
628
- }
629
- }
630
-
631
- await writeState(rootDir, state);
632
- previousFiles = currentFiles;
633
- previousMap = currentMap;
634
- }
635
-
636
- process.off('SIGINT', onStop);
637
- process.off('SIGTERM', onStop);
638
- }
639
-
640
- async function syncFolder(positionals, flags) {
641
- const target = positionals[0];
642
- if (!target) {
643
- throw new Error('Usage: shard sync <folder> [--server <url>] [--dry-run] [--force] [--once] [--interval-ms <n>]');
644
- }
645
-
646
- const rootDir = path.resolve(process.cwd(), target);
647
- if (!(await pathExists(rootDir))) {
648
- throw new Error(`Dossier introuvable: ${rootDir}`);
649
- }
650
- const stat = await fsp.stat(rootDir);
651
- if (!stat.isDirectory()) {
652
- throw new Error(`Ce n'est pas un dossier: ${rootDir}`);
653
- }
654
-
655
- const config = await readConfig();
656
- const server = getServer(flags, config);
657
- const token = getToken(config);
658
- if (!token) {
659
- throw new Error('Non connecte. Lance: shard login --username ... --password ...');
660
- }
661
-
662
- await httpJson(`${server}/api/auth/verify`, {
663
- method: 'POST',
664
- headers: { Authorization: `Bearer ${token}` }
665
- });
666
-
667
- const state = await readState(rootDir);
668
- const force = Boolean(flags.force);
669
- const dryRun = Boolean(flags['dry-run']);
670
- const once = Boolean(flags.once);
671
- const intervalMs = Math.max(parseInt(flags['interval-ms'] || '2000', 10) || 2000, 500);
672
-
673
- const remoteFiles = await fetchAllRemoteFiles(server, token);
674
- const initialLocalFiles = await listFilesRecursive(rootDir);
675
- await pullMissingRemoteFiles(server, token, rootDir, state, initialLocalFiles, remoteFiles, dryRun);
676
-
677
- const files = await listFilesRecursive(rootDir);
678
- seedStateFromRemote(state, files, remoteFiles);
679
-
680
- const changed = files.filter((f) => {
681
- if (force) return true;
682
- const prev = state.files[f.relPath];
683
- if (!prev) return true;
684
- return !(prev.size === f.size && prev.mtimeMs === f.mtimeMs);
685
- });
686
-
687
- const unchanged = files.length - changed.length;
688
- console.log(`Server: ${server}`);
689
- console.log(`Dossier: ${rootDir}`);
690
- console.log(`Total: ${files.length} fichier(s)`);
691
- console.log(`Distants: ${remoteFiles.length}`);
692
- console.log(`A uploader: ${changed.length}`);
693
- console.log(`Inchanges: ${unchanged}`);
694
-
695
- if (dryRun) {
696
- for (const f of changed.slice(0, 50)) console.log(`- ${f.relPath}`);
697
- if (changed.length > 50) console.log(`... +${changed.length - 50} autres`);
698
- return;
699
- }
700
-
701
- let success = 0;
702
- let failed = 0;
417
+ localServer = await startLocalSingleFileServer({
418
+ filePath: absPath,
419
+ fileName,
420
+ mimeType,
421
+ port,
422
+ accessKey
423
+ });
703
424
 
704
- for (let i = 0; i < changed.length; i += 1) {
705
- const file = changed[i];
706
- const label = `[${i + 1}/${changed.length}]`;
707
- try {
708
- const startedAt = Date.now();
709
- console.log(`${label} UPLOAD ${file.relPath} (${formatBytes(file.size)})`);
710
- const result = await uploadOneFile(server, token, file);
711
- const tookSeconds = ((Date.now() - startedAt) / 1000).toFixed(1);
712
- success += 1;
713
- state.files[file.relPath] = {
714
- size: file.size,
715
- mtimeMs: file.mtimeMs,
716
- uploadedAt: new Date().toISOString(),
717
- remoteId: result?.file?.id || null
425
+ const directDownloadUrl = `${publicUrl}${localServer.routePath}`;
426
+ const payload = {
427
+ fileName,
428
+ fileSize: st.size,
429
+ mimeType,
430
+ downloadUrl: directDownloadUrl
718
431
  };
719
- console.log(`${label} OK ${file.relPath} (${tookSeconds}s)`);
720
- } catch (error) {
721
- failed += 1;
722
- console.error(`${label} FAIL ${file.relPath} -> ${error.message}`);
723
- }
724
- }
725
-
726
- for (const relPath of Object.keys(state.files)) {
727
- const stillThere = files.some((f) => f.relPath === relPath);
728
- if (!stillThere) delete state.files[relPath];
729
- }
730
- await writeState(rootDir, state);
731
-
732
- console.log(`Termine. Uploades: ${success}, erreurs: ${failed}`);
733
- if (failed > 0) process.exitCode = 2;
734
- if (!once && !dryRun) {
735
- await mirrorRealtime(server, token, rootDir, state, intervalMs);
736
- }
737
- }
738
-
739
- async function main() {
740
- const { command, positionals, flags } = parseArgs(process.argv.slice(2));
741
-
742
- if (!command || command === 'help' || command === '--help' || command === '-h') {
743
- printHelp();
744
- return;
745
- }
746
-
747
- if (command === 'login') {
748
- await login(flags);
749
- return;
750
- }
751
-
752
- if (command === 'whoami') {
753
- await whoami(flags);
754
- return;
755
- }
756
-
757
- if (command === 'logout') {
758
- await logout();
759
- return;
760
- }
761
-
762
- if (command === 'sync') {
763
- await syncFolder(positionals, flags);
764
- return;
765
- }
766
-
767
- if (command === 'share') {
768
- await shareFile(positionals, flags);
769
- return;
770
- }
432
+ if (limits !== undefined && limits > 0) payload.maxDownloads = limits;
433
+ if (temps !== undefined && temps > 0) payload.expiresInDays = temps;
434
+
435
+ const created = await httpJson(`${server}/api/share/create-local`, {
436
+ method: 'POST',
437
+ headers: {
438
+ Authorization: `Bearer ${token}`,
439
+ 'Content-Type': 'application/json'
440
+ },
441
+ body: JSON.stringify(payload)
442
+ });
771
443
 
772
- if (command === 'config') {
773
- const sub = positionals[0];
774
- if (sub === 'show') {
775
- await showConfig();
776
- return;
777
- }
778
- if (sub === 'set-server') {
779
- await setServer(positionals.slice(1));
444
+ const share = created?.share || {};
445
+ console.log(`Partage local cree pour: ${fileName}`);
446
+ if (share.url) console.log(`URL Shard: ${share.url}`);
447
+ console.log(`URL agent locale: ${directDownloadUrl}`);
448
+ if (share.token) console.log(`Token: ${share.token}`);
449
+ console.log(`Agent local en ecoute sur: http://0.0.0.0:${port}${localServer.routePath}`);
450
+ console.log(`Limite downloads: ${limits && limits > 0 ? limits : 'illimitee'}`);
451
+ console.log(`Expiration: ${temps && temps > 0 ? `${temps} jour(s)` : 'aucune'}`);
452
+ console.log('Laisse cette commande ouverte tant que le partage doit fonctionner.');
453
+
454
+ const stopSignals = ['SIGINT', 'SIGTERM'];
455
+ await new Promise((resolve) => {
456
+ const stop = () => {
457
+ for (const sig of stopSignals) process.off(sig, stop);
458
+ localServer.server.close(() => resolve());
459
+ };
460
+ for (const sig of stopSignals) process.on(sig, stop);
461
+ });
780
462
  return;
463
+ } catch (error) {
464
+ if (localServer && localServer.server) {
465
+ await new Promise((resolve) => localServer.server.close(() => resolve()));
466
+ }
467
+ throw error;
781
468
  }
782
- throw new Error('Usage: shard config show | shard config set-server <url>');
783
469
  }
784
470
 
785
- throw new Error(`Commande inconnue: ${command}`);
786
- }
787
-
788
- main().catch((error) => {
789
- console.error(`Erreur: ${error.message}`);
790
- process.exitCode = 1;
791
- });
471
+ let remote = await findRemoteFileByNameAndSize(server, token, fileName, st.size);
472
+ let fileId = remote?.id || null;
473
+
474
+ if (!fileId) {
475
+ console.log(`Upload necessaire: ${fileName} (${formatBytes(st.size)})`);
476
+ const uploaded = await uploadOneFile(server, token, {
477
+ absPath,
478
+ relPath: fileName,
479
+ size: st.size,
480
+ mtimeMs: Math.round(st.mtimeMs)
481
+ });
482
+ fileId = uploaded?.file?.id;
483
+ if (!fileId) {
484
+ throw new Error('Upload reussi mais ID fichier manquant');
485
+ }
486
+ }
487
+
488
+ const payload = { fileId };
489
+ if (limits !== undefined && limits > 0) payload.maxDownloads = limits;
490
+ if (temps !== undefined && temps > 0) payload.expiresInDays = temps;
491
+
492
+ const created = await httpJson(`${server}/api/share/create`, {
493
+ method: 'POST',
494
+ headers: {
495
+ Authorization: `Bearer ${token}`,
496
+ 'Content-Type': 'application/json'
497
+ },
498
+ body: JSON.stringify(payload)
499
+ });
500
+
501
+ const share = created?.share || {};
502
+ console.log(`Partage cree pour: ${fileName}`);
503
+ if (share.url) console.log(`URL: ${share.url}`);
504
+ if (share.token) console.log(`Token: ${share.token}`);
505
+ console.log(`Limite downloads: ${limits && limits > 0 ? limits : 'illimitee'}`);
506
+ console.log(`Expiration: ${temps && temps > 0 ? `${temps} jour(s)` : 'aucune'}`);
507
+ }
508
+
509
+ function fileListToMap(files) {
510
+ const map = new Map();
511
+ for (const file of files) {
512
+ map.set(file.relPath, file);
513
+ }
514
+ return map;
515
+ }
516
+
517
+ function makeFingerprint(file) {
518
+ return `${file.size}:${file.mtimeMs}`;
519
+ }
520
+
521
+ function normalizeRemoteRelPath(name) {
522
+ return String(name || '').replace(/\\/g, '/');
523
+ }
524
+
525
+ async function fetchAllRemoteFiles(server, token) {
526
+ const out = [];
527
+ const limit = 100;
528
+ let offset = 0;
529
+
530
+ while (true) {
531
+ const data = await httpJson(`${server}/api/files?limit=${limit}&offset=${offset}&sort=created_at&order=asc`, {
532
+ method: 'GET',
533
+ headers: { Authorization: `Bearer ${token}` }
534
+ });
535
+ const rows = Array.isArray(data.files) ? data.files : [];
536
+ for (const row of rows) {
537
+ out.push({
538
+ id: row.id,
539
+ relPath: normalizeRemoteRelPath(row.original_name),
540
+ size: Number(row.file_size || 0)
541
+ });
542
+ }
543
+ if (!data.pagination?.hasMore) break;
544
+ offset += limit;
545
+ }
546
+
547
+ return out;
548
+ }
549
+
550
+ async function downloadRemoteFile(server, token, remoteFile, destPath) {
551
+ const res = await fetch(`${server}/api/files/${remoteFile.id}/download`, {
552
+ method: 'GET',
553
+ headers: { Authorization: `Bearer ${token}` }
554
+ });
555
+
556
+ if (!res.ok) {
557
+ let detail = `HTTP ${res.status}`;
558
+ try {
559
+ const data = await res.json();
560
+ detail = data.message || data.error || detail;
561
+ } catch {
562
+ // ignore JSON parse failures
563
+ }
564
+ throw new Error(detail);
565
+ }
566
+
567
+ if (!res.body) {
568
+ throw new Error('Reponse de telechargement vide');
569
+ }
570
+
571
+ await fsp.mkdir(path.dirname(destPath), { recursive: true });
572
+ const tempPath = `${destPath}.part`;
573
+ const writable = fs.createWriteStream(tempPath);
574
+ await pipeline(Readable.fromWeb(res.body), writable);
575
+ await fsp.rename(tempPath, destPath);
576
+ }
577
+
578
+ async function pullMissingRemoteFiles(server, token, rootDir, state, localFiles, remoteFiles, dryRun) {
579
+ const localMap = fileListToMap(localFiles);
580
+ const missingRemote = remoteFiles.filter((rf) => !localMap.has(rf.relPath));
581
+
582
+ if (missingRemote.length === 0) return { downloaded: 0, errors: 0 };
583
+
584
+ console.log(`A telecharger: ${missingRemote.length}`);
585
+ if (dryRun) {
586
+ for (const rf of missingRemote.slice(0, 50)) console.log(`- ${rf.relPath} (remote)`); // eslint-disable-line no-console
587
+ if (missingRemote.length > 50) console.log(`... +${missingRemote.length - 50} autres`);
588
+ return { downloaded: 0, errors: 0 };
589
+ }
590
+
591
+ let downloaded = 0;
592
+ let errors = 0;
593
+ for (let i = 0; i < missingRemote.length; i += 1) {
594
+ const rf = missingRemote[i];
595
+ const label = `[DOWN ${i + 1}/${missingRemote.length}]`;
596
+ try {
597
+ const absPath = path.join(rootDir, ...rf.relPath.split('/'));
598
+ console.log(`${label} GET ${rf.relPath} (${formatBytes(rf.size)})`);
599
+ await downloadRemoteFile(server, token, rf, absPath);
600
+ const st = await fsp.stat(absPath);
601
+ state.files[rf.relPath] = {
602
+ size: st.size,
603
+ mtimeMs: Math.round(st.mtimeMs),
604
+ uploadedAt: new Date().toISOString(),
605
+ remoteId: rf.id
606
+ };
607
+ downloaded += 1;
608
+ console.log(`${label} OK ${rf.relPath}`);
609
+ } catch (error) {
610
+ errors += 1;
611
+ console.error(`${label} FAIL ${rf.relPath} -> ${error.message}`);
612
+ }
613
+ }
614
+
615
+ return { downloaded, errors };
616
+ }
617
+
618
+ function seedStateFromRemote(state, localFiles, remoteFiles) {
619
+ const remoteByPath = new Map(remoteFiles.map((rf) => [rf.relPath, rf]));
620
+ for (const lf of localFiles) {
621
+ if (state.files[lf.relPath]) continue;
622
+ const remote = remoteByPath.get(lf.relPath);
623
+ if (!remote) continue;
624
+ if (Number(remote.size) !== Number(lf.size)) continue;
625
+ state.files[lf.relPath] = {
626
+ size: lf.size,
627
+ mtimeMs: lf.mtimeMs,
628
+ uploadedAt: new Date().toISOString(),
629
+ remoteId: remote.id
630
+ };
631
+ }
632
+ }
633
+
634
+ async function deleteRemoteFile(server, token, remoteId) {
635
+ return httpJson(`${server}/api/files/${remoteId}`, {
636
+ method: 'DELETE',
637
+ headers: { Authorization: `Bearer ${token}` }
638
+ });
639
+ }
640
+
641
+ async function renameRemoteFile(server, token, remoteId, newName) {
642
+ return httpJson(`${server}/api/files/${remoteId}/rename`, {
643
+ method: 'PATCH',
644
+ headers: {
645
+ Authorization: `Bearer ${token}`,
646
+ 'Content-Type': 'application/json'
647
+ },
648
+ body: JSON.stringify({ newName })
649
+ });
650
+ }
651
+
652
+ function sleep(ms) {
653
+ return new Promise((resolve) => setTimeout(resolve, ms));
654
+ }
655
+
656
+ async function mirrorRealtime(server, token, rootDir, state, intervalMs) {
657
+ console.log(`Mode temps reel actif (${intervalMs} ms). Ctrl+C pour quitter.`);
658
+
659
+ let stopped = false;
660
+ const onStop = () => { stopped = true; };
661
+ process.on('SIGINT', onStop);
662
+ process.on('SIGTERM', onStop);
663
+
664
+ let previousFiles = await listFilesRecursive(rootDir);
665
+ let previousMap = fileListToMap(previousFiles);
666
+
667
+ while (!stopped) {
668
+ await sleep(intervalMs);
669
+ if (stopped) break;
670
+
671
+ let currentFiles;
672
+ try {
673
+ currentFiles = await listFilesRecursive(rootDir);
674
+ } catch (error) {
675
+ console.error(`Watcher error: ${error.message}`);
676
+ continue;
677
+ }
678
+
679
+ const currentMap = fileListToMap(currentFiles);
680
+ const prevKeys = new Set(previousMap.keys());
681
+ const currKeys = new Set(currentMap.keys());
682
+ const removed = [...prevKeys].filter((k) => !currKeys.has(k)).map((k) => previousMap.get(k));
683
+ const added = [...currKeys].filter((k) => !prevKeys.has(k)).map((k) => currentMap.get(k));
684
+ const changed = [...currKeys]
685
+ .filter((k) => prevKeys.has(k))
686
+ .map((k) => ({ prev: previousMap.get(k), cur: currentMap.get(k) }))
687
+ .filter((p) => p.prev.size !== p.cur.size || p.prev.mtimeMs !== p.cur.mtimeMs)
688
+ .map((p) => p.cur);
689
+
690
+ // Detecter les renames simples (meme taille + mtime) pour appeler PATCH rename.
691
+ const addedByFinger = new Map();
692
+ for (const a of added) {
693
+ const key = makeFingerprint(a);
694
+ if (!addedByFinger.has(key)) addedByFinger.set(key, []);
695
+ addedByFinger.get(key).push(a);
696
+ }
697
+
698
+ const renamedPairs = [];
699
+ const trulyRemoved = [];
700
+ for (const r of removed) {
701
+ const matches = addedByFinger.get(makeFingerprint(r)) || [];
702
+ const remoteId = state.files[r.relPath]?.remoteId || null;
703
+ if (remoteId && matches.length > 0) {
704
+ const to = matches.shift();
705
+ renamedPairs.push({ from: r, to, remoteId });
706
+ } else {
707
+ trulyRemoved.push(r);
708
+ }
709
+ }
710
+ const renamedTargets = new Set(renamedPairs.map((p) => p.to.relPath));
711
+ const trulyAdded = added.filter((a) => !renamedTargets.has(a.relPath));
712
+
713
+ for (const pair of renamedPairs) {
714
+ try {
715
+ await renameRemoteFile(server, token, pair.remoteId, pair.to.relPath);
716
+ delete state.files[pair.from.relPath];
717
+ state.files[pair.to.relPath] = {
718
+ size: pair.to.size,
719
+ mtimeMs: pair.to.mtimeMs,
720
+ uploadedAt: new Date().toISOString(),
721
+ remoteId: pair.remoteId
722
+ };
723
+ console.log(`[WATCH] RENAME ${pair.from.relPath} -> ${pair.to.relPath}`);
724
+ } catch (error) {
725
+ console.error(`[WATCH] RENAME FAIL ${pair.from.relPath} -> ${pair.to.relPath}: ${error.message}`);
726
+ }
727
+ }
728
+
729
+ for (const file of trulyRemoved) {
730
+ const remoteId = state.files[file.relPath]?.remoteId || null;
731
+ if (!remoteId) {
732
+ delete state.files[file.relPath];
733
+ continue;
734
+ }
735
+ try {
736
+ await deleteRemoteFile(server, token, remoteId);
737
+ delete state.files[file.relPath];
738
+ console.log(`[WATCH] DELETE ${file.relPath}`);
739
+ } catch (error) {
740
+ console.error(`[WATCH] DELETE FAIL ${file.relPath}: ${error.message}`);
741
+ }
742
+ }
743
+
744
+ const uploads = [...trulyAdded, ...changed];
745
+ for (const file of uploads) {
746
+ try {
747
+ const result = await uploadOneFile(server, token, file);
748
+ state.files[file.relPath] = {
749
+ size: file.size,
750
+ mtimeMs: file.mtimeMs,
751
+ uploadedAt: new Date().toISOString(),
752
+ remoteId: result?.file?.id || state.files[file.relPath]?.remoteId || null
753
+ };
754
+ console.log(`[WATCH] UPLOAD ${file.relPath} (${formatBytes(file.size)})`);
755
+ } catch (error) {
756
+ console.error(`[WATCH] UPLOAD FAIL ${file.relPath}: ${error.message}`);
757
+ }
758
+ }
759
+
760
+ await writeState(rootDir, state);
761
+ previousFiles = currentFiles;
762
+ previousMap = currentMap;
763
+ }
764
+
765
+ process.off('SIGINT', onStop);
766
+ process.off('SIGTERM', onStop);
767
+ }
768
+
769
+ async function syncFolder(positionals, flags) {
770
+ const target = positionals[0];
771
+ if (!target) {
772
+ throw new Error('Usage: shard sync <folder> [--server <url>] [--dry-run] [--force] [--once] [--interval-ms <n>]');
773
+ }
774
+
775
+ const rootDir = path.resolve(process.cwd(), target);
776
+ if (!(await pathExists(rootDir))) {
777
+ throw new Error(`Dossier introuvable: ${rootDir}`);
778
+ }
779
+ const stat = await fsp.stat(rootDir);
780
+ if (!stat.isDirectory()) {
781
+ throw new Error(`Ce n'est pas un dossier: ${rootDir}`);
782
+ }
783
+
784
+ const config = await readConfig();
785
+ const server = getServer(flags, config);
786
+ const token = getToken(config);
787
+ if (!token) {
788
+ throw new Error('Non connecte. Lance: shard login --username ... --password ...');
789
+ }
790
+
791
+ await httpJson(`${server}/api/auth/verify`, {
792
+ method: 'POST',
793
+ headers: { Authorization: `Bearer ${token}` }
794
+ });
795
+
796
+ const state = await readState(rootDir);
797
+ const force = Boolean(flags.force);
798
+ const dryRun = Boolean(flags['dry-run']);
799
+ const once = Boolean(flags.once);
800
+ const intervalMs = Math.max(parseInt(flags['interval-ms'] || '2000', 10) || 2000, 500);
801
+
802
+ const remoteFiles = await fetchAllRemoteFiles(server, token);
803
+ const initialLocalFiles = await listFilesRecursive(rootDir);
804
+ await pullMissingRemoteFiles(server, token, rootDir, state, initialLocalFiles, remoteFiles, dryRun);
805
+
806
+ const files = await listFilesRecursive(rootDir);
807
+ seedStateFromRemote(state, files, remoteFiles);
808
+
809
+ const changed = files.filter((f) => {
810
+ if (force) return true;
811
+ const prev = state.files[f.relPath];
812
+ if (!prev) return true;
813
+ return !(prev.size === f.size && prev.mtimeMs === f.mtimeMs);
814
+ });
815
+
816
+ const unchanged = files.length - changed.length;
817
+ console.log(`Server: ${server}`);
818
+ console.log(`Dossier: ${rootDir}`);
819
+ console.log(`Total: ${files.length} fichier(s)`);
820
+ console.log(`Distants: ${remoteFiles.length}`);
821
+ console.log(`A uploader: ${changed.length}`);
822
+ console.log(`Inchanges: ${unchanged}`);
823
+
824
+ if (dryRun) {
825
+ for (const f of changed.slice(0, 50)) console.log(`- ${f.relPath}`);
826
+ if (changed.length > 50) console.log(`... +${changed.length - 50} autres`);
827
+ return;
828
+ }
829
+
830
+ let success = 0;
831
+ let failed = 0;
832
+
833
+ for (let i = 0; i < changed.length; i += 1) {
834
+ const file = changed[i];
835
+ const label = `[${i + 1}/${changed.length}]`;
836
+ try {
837
+ const startedAt = Date.now();
838
+ console.log(`${label} UPLOAD ${file.relPath} (${formatBytes(file.size)})`);
839
+ const result = await uploadOneFile(server, token, file);
840
+ const tookSeconds = ((Date.now() - startedAt) / 1000).toFixed(1);
841
+ success += 1;
842
+ state.files[file.relPath] = {
843
+ size: file.size,
844
+ mtimeMs: file.mtimeMs,
845
+ uploadedAt: new Date().toISOString(),
846
+ remoteId: result?.file?.id || null
847
+ };
848
+ console.log(`${label} OK ${file.relPath} (${tookSeconds}s)`);
849
+ } catch (error) {
850
+ failed += 1;
851
+ console.error(`${label} FAIL ${file.relPath} -> ${error.message}`);
852
+ }
853
+ }
854
+
855
+ for (const relPath of Object.keys(state.files)) {
856
+ const stillThere = files.some((f) => f.relPath === relPath);
857
+ if (!stillThere) delete state.files[relPath];
858
+ }
859
+ await writeState(rootDir, state);
860
+
861
+ console.log(`Termine. Uploades: ${success}, erreurs: ${failed}`);
862
+ if (failed > 0) process.exitCode = 2;
863
+ if (!once && !dryRun) {
864
+ await mirrorRealtime(server, token, rootDir, state, intervalMs);
865
+ }
866
+ }
867
+
868
+ async function main() {
869
+ const { command, positionals, flags } = parseArgs(process.argv.slice(2));
870
+
871
+ if (!command || command === 'help' || command === '--help' || command === '-h') {
872
+ printHelp();
873
+ return;
874
+ }
875
+
876
+ if (command === 'login') {
877
+ await login(flags);
878
+ return;
879
+ }
880
+
881
+ if (command === 'whoami') {
882
+ await whoami(flags);
883
+ return;
884
+ }
885
+
886
+ if (command === 'logout') {
887
+ await logout();
888
+ return;
889
+ }
890
+
891
+ if (command === 'sync') {
892
+ await syncFolder(positionals, flags);
893
+ return;
894
+ }
895
+
896
+ if (command === 'share') {
897
+ await shareFile(positionals, flags);
898
+ return;
899
+ }
900
+
901
+ if (command === 'config') {
902
+ const sub = positionals[0];
903
+ if (sub === 'show') {
904
+ await showConfig();
905
+ return;
906
+ }
907
+ if (sub === 'set-server') {
908
+ await setServer(positionals.slice(1));
909
+ return;
910
+ }
911
+ throw new Error('Usage: shard config show | shard config set-server <url>');
912
+ }
913
+
914
+ throw new Error(`Commande inconnue: ${command}`);
915
+ }
916
+
917
+ main().catch((error) => {
918
+ console.error(`Erreur: ${error.message}`);
919
+ process.exitCode = 1;
920
+ });