@iksdev/shard-cli 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -0
- package/bin/shard.js +407 -0
- package/package.json +16 -0
package/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# shard-cli
|
|
2
|
+
|
|
3
|
+
CLI pour synchroniser un dossier local vers ton serveur Shard.
|
|
4
|
+
|
|
5
|
+
## Installation locale (dev)
|
|
6
|
+
|
|
7
|
+
Depuis la racine du repo:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm i -g ./shard-cli
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Puis verifier:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
shard --help
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage rapide
|
|
20
|
+
|
|
21
|
+
1. Login
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
shard login --server http://localhost:3000 --username admin --password secret
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
2. Sync un dossier
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
shard sync ./MonDossier
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Commandes
|
|
34
|
+
|
|
35
|
+
- `shard login --username <name> --password <pass> [--server <url>]`
|
|
36
|
+
- `shard whoami [--server <url>]`
|
|
37
|
+
- `shard sync <folder> [--server <url>] [--dry-run] [--force]`
|
|
38
|
+
- `shard logout`
|
|
39
|
+
- `shard config show`
|
|
40
|
+
- `shard config set-server <url>`
|
|
41
|
+
|
|
42
|
+
## Notes
|
|
43
|
+
|
|
44
|
+
- Le CLI stocke la config dans `~/.shard-cli/config.json`.
|
|
45
|
+
- Le CLI stocke l'etat de sync dans `<ton-dossier>/.shard-sync-state.json`.
|
|
46
|
+
- Les uploads passent par `POST /api/files/upload` avec token `Bearer`.
|
package/bin/shard.js
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
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
|
+
|
|
8
|
+
const CONFIG_DIR = path.join(os.homedir(), '.shard-cli');
|
|
9
|
+
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
|
|
10
|
+
const STATE_FILE = '.shard-sync-state.json';
|
|
11
|
+
const DEFAULT_SERVER = 'http://localhost:3000';
|
|
12
|
+
const IGNORED_DIRS = new Set(['.git', 'node_modules']);
|
|
13
|
+
|
|
14
|
+
function printHelp() {
|
|
15
|
+
console.log(`Shard CLI
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
shard login --username <name> --password <pass> [--server <url>]
|
|
19
|
+
shard whoami [--server <url>]
|
|
20
|
+
shard sync <folder> [--server <url>] [--dry-run] [--force]
|
|
21
|
+
shard logout
|
|
22
|
+
shard config show
|
|
23
|
+
shard config set-server <url>
|
|
24
|
+
|
|
25
|
+
Examples:
|
|
26
|
+
shard login --server http://localhost:3000 --username admin --password secret
|
|
27
|
+
shard sync ./MonDossier
|
|
28
|
+
shard sync ./MonDossier --dry-run
|
|
29
|
+
`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseArgs(rawArgs) {
|
|
33
|
+
const args = [...rawArgs];
|
|
34
|
+
const command = args.shift();
|
|
35
|
+
const positionals = [];
|
|
36
|
+
const flags = {};
|
|
37
|
+
|
|
38
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
39
|
+
const cur = args[i];
|
|
40
|
+
if (!cur.startsWith('--')) {
|
|
41
|
+
positionals.push(cur);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const key = cur.slice(2);
|
|
45
|
+
const next = args[i + 1];
|
|
46
|
+
if (!next || next.startsWith('--')) {
|
|
47
|
+
flags[key] = true;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
flags[key] = next;
|
|
51
|
+
i += 1;
|
|
52
|
+
}
|
|
53
|
+
return { command, positionals, flags };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeServer(input) {
|
|
57
|
+
const raw = String(input || '').trim();
|
|
58
|
+
if (!raw) return DEFAULT_SERVER;
|
|
59
|
+
return raw.replace(/\/+$/, '');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function ensureConfigDir() {
|
|
63
|
+
await fsp.mkdir(CONFIG_DIR, { recursive: true });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function readConfig() {
|
|
67
|
+
try {
|
|
68
|
+
const raw = await fsp.readFile(CONFIG_PATH, 'utf8');
|
|
69
|
+
const parsed = JSON.parse(raw);
|
|
70
|
+
return {
|
|
71
|
+
server: normalizeServer(parsed.server || DEFAULT_SERVER),
|
|
72
|
+
token: parsed.token || ''
|
|
73
|
+
};
|
|
74
|
+
} catch {
|
|
75
|
+
return { server: DEFAULT_SERVER, token: '' };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function writeConfig(config) {
|
|
80
|
+
await ensureConfigDir();
|
|
81
|
+
const payload = {
|
|
82
|
+
server: normalizeServer(config.server || DEFAULT_SERVER),
|
|
83
|
+
token: config.token || ''
|
|
84
|
+
};
|
|
85
|
+
await fsp.writeFile(CONFIG_PATH, JSON.stringify(payload, null, 2), 'utf8');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function getServer(flags, config) {
|
|
89
|
+
return normalizeServer(flags.server || process.env.SHARD_SERVER || config.server || DEFAULT_SERVER);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function getToken(config) {
|
|
93
|
+
return process.env.SHARD_TOKEN || config.token || '';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function httpJson(url, options = {}) {
|
|
97
|
+
const res = await fetch(url, options);
|
|
98
|
+
const data = await res.json().catch(() => ({}));
|
|
99
|
+
if (!res.ok) {
|
|
100
|
+
const err = new Error(data.message || data.error || `HTTP ${res.status}`);
|
|
101
|
+
err.status = res.status;
|
|
102
|
+
err.data = data;
|
|
103
|
+
throw err;
|
|
104
|
+
}
|
|
105
|
+
return data;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function login(flags) {
|
|
109
|
+
const username = flags.username;
|
|
110
|
+
const password = flags.password;
|
|
111
|
+
if (!username || !password) {
|
|
112
|
+
throw new Error('Usage: shard login --username <name> --password <pass> [--server <url>]');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const config = await readConfig();
|
|
116
|
+
const server = getServer(flags, config);
|
|
117
|
+
|
|
118
|
+
const data = await httpJson(`${server}/api/auth/login`, {
|
|
119
|
+
method: 'POST',
|
|
120
|
+
headers: { 'Content-Type': 'application/json' },
|
|
121
|
+
body: JSON.stringify({ username, password })
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (!data.token) {
|
|
125
|
+
throw new Error('Connexion réussie mais token manquant.');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
await writeConfig({ server, token: data.token });
|
|
129
|
+
console.log(`Connecte a ${server}`);
|
|
130
|
+
if (data.user?.username) {
|
|
131
|
+
console.log(`Utilisateur: ${data.user.username}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function whoami(flags) {
|
|
136
|
+
const config = await readConfig();
|
|
137
|
+
const server = getServer(flags, config);
|
|
138
|
+
const token = getToken(config);
|
|
139
|
+
if (!token) {
|
|
140
|
+
throw new Error('Non connecte. Lance: shard login --username ... --password ...');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const data = await httpJson(`${server}/api/auth/verify`, {
|
|
144
|
+
method: 'POST',
|
|
145
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const user = data.user || {};
|
|
149
|
+
console.log(`Server: ${server}`);
|
|
150
|
+
console.log(`User: ${user.username || user.userId || 'inconnu'}`);
|
|
151
|
+
if (user.email) console.log(`Email: ${user.email}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function logout() {
|
|
155
|
+
const config = await readConfig();
|
|
156
|
+
await writeConfig({ ...config, token: '' });
|
|
157
|
+
console.log('Token supprime.');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function showConfig() {
|
|
161
|
+
const config = await readConfig();
|
|
162
|
+
console.log(JSON.stringify({
|
|
163
|
+
server: config.server,
|
|
164
|
+
hasToken: Boolean(config.token)
|
|
165
|
+
}, null, 2));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function setServer(positionals) {
|
|
169
|
+
const url = positionals[0];
|
|
170
|
+
if (!url) {
|
|
171
|
+
throw new Error('Usage: shard config set-server <url>');
|
|
172
|
+
}
|
|
173
|
+
const config = await readConfig();
|
|
174
|
+
await writeConfig({ ...config, server: normalizeServer(url) });
|
|
175
|
+
console.log(`Server mis a jour: ${normalizeServer(url)}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function pathExists(targetPath) {
|
|
179
|
+
try {
|
|
180
|
+
await fsp.access(targetPath, fs.constants.R_OK);
|
|
181
|
+
return true;
|
|
182
|
+
} catch {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function listFilesRecursive(rootDir) {
|
|
188
|
+
const out = [];
|
|
189
|
+
|
|
190
|
+
async function walk(currentDir) {
|
|
191
|
+
const entries = await fsp.readdir(currentDir, { withFileTypes: true });
|
|
192
|
+
for (const entry of entries) {
|
|
193
|
+
if (entry.name === STATE_FILE) continue;
|
|
194
|
+
if (entry.isDirectory() && IGNORED_DIRS.has(entry.name)) continue;
|
|
195
|
+
|
|
196
|
+
const abs = path.join(currentDir, entry.name);
|
|
197
|
+
if (entry.isDirectory()) {
|
|
198
|
+
await walk(abs);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (!entry.isFile()) continue;
|
|
202
|
+
|
|
203
|
+
const rel = path.relative(rootDir, abs).split(path.sep).join('/');
|
|
204
|
+
const stat = await fsp.stat(abs);
|
|
205
|
+
out.push({
|
|
206
|
+
absPath: abs,
|
|
207
|
+
relPath: rel,
|
|
208
|
+
size: stat.size,
|
|
209
|
+
mtimeMs: Math.round(stat.mtimeMs)
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
await walk(rootDir);
|
|
215
|
+
out.sort((a, b) => a.relPath.localeCompare(b.relPath));
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function readState(rootDir) {
|
|
220
|
+
const filePath = path.join(rootDir, STATE_FILE);
|
|
221
|
+
try {
|
|
222
|
+
const raw = await fsp.readFile(filePath, 'utf8');
|
|
223
|
+
const parsed = JSON.parse(raw);
|
|
224
|
+
if (!parsed || typeof parsed !== 'object') return { version: 1, files: {} };
|
|
225
|
+
return { version: 1, files: parsed.files || {} };
|
|
226
|
+
} catch {
|
|
227
|
+
return { version: 1, files: {} };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function writeState(rootDir, state) {
|
|
232
|
+
const filePath = path.join(rootDir, STATE_FILE);
|
|
233
|
+
const payload = {
|
|
234
|
+
version: 1,
|
|
235
|
+
updatedAt: new Date().toISOString(),
|
|
236
|
+
files: state.files || {}
|
|
237
|
+
};
|
|
238
|
+
await fsp.writeFile(filePath, JSON.stringify(payload, null, 2), 'utf8');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function guessMime(filePath) {
|
|
242
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
243
|
+
const map = {
|
|
244
|
+
'.txt': 'text/plain',
|
|
245
|
+
'.md': 'text/markdown',
|
|
246
|
+
'.json': 'application/json',
|
|
247
|
+
'.csv': 'text/csv',
|
|
248
|
+
'.html': 'text/html',
|
|
249
|
+
'.css': 'text/css',
|
|
250
|
+
'.js': 'text/javascript',
|
|
251
|
+
'.ts': 'text/plain',
|
|
252
|
+
'.jpg': 'image/jpeg',
|
|
253
|
+
'.jpeg': 'image/jpeg',
|
|
254
|
+
'.png': 'image/png',
|
|
255
|
+
'.gif': 'image/gif',
|
|
256
|
+
'.webp': 'image/webp',
|
|
257
|
+
'.pdf': 'application/pdf',
|
|
258
|
+
'.zip': 'application/zip'
|
|
259
|
+
};
|
|
260
|
+
return map[ext] || 'application/octet-stream';
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function uploadOneFile(server, token, file) {
|
|
264
|
+
const blob = await fs.openAsBlob(file.absPath, { type: guessMime(file.absPath) });
|
|
265
|
+
const form = new FormData();
|
|
266
|
+
// On force le nom "relatif" pour garder la notion de dossier dans l'UI/DB.
|
|
267
|
+
form.append('file', blob, file.relPath);
|
|
268
|
+
|
|
269
|
+
return httpJson(`${server}/api/files/upload`, {
|
|
270
|
+
method: 'POST',
|
|
271
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
272
|
+
body: form
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function syncFolder(positionals, flags) {
|
|
277
|
+
const target = positionals[0];
|
|
278
|
+
if (!target) {
|
|
279
|
+
throw new Error('Usage: shard sync <folder> [--server <url>] [--dry-run] [--force]');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const rootDir = path.resolve(process.cwd(), target);
|
|
283
|
+
if (!(await pathExists(rootDir))) {
|
|
284
|
+
throw new Error(`Dossier introuvable: ${rootDir}`);
|
|
285
|
+
}
|
|
286
|
+
const stat = await fsp.stat(rootDir);
|
|
287
|
+
if (!stat.isDirectory()) {
|
|
288
|
+
throw new Error(`Ce n'est pas un dossier: ${rootDir}`);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const config = await readConfig();
|
|
292
|
+
const server = getServer(flags, config);
|
|
293
|
+
const token = getToken(config);
|
|
294
|
+
if (!token) {
|
|
295
|
+
throw new Error('Non connecte. Lance: shard login --username ... --password ...');
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
await httpJson(`${server}/api/auth/verify`, {
|
|
299
|
+
method: 'POST',
|
|
300
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const files = await listFilesRecursive(rootDir);
|
|
304
|
+
const state = await readState(rootDir);
|
|
305
|
+
const force = Boolean(flags.force);
|
|
306
|
+
const dryRun = Boolean(flags['dry-run']);
|
|
307
|
+
|
|
308
|
+
const changed = files.filter((f) => {
|
|
309
|
+
if (force) return true;
|
|
310
|
+
const prev = state.files[f.relPath];
|
|
311
|
+
if (!prev) return true;
|
|
312
|
+
return !(prev.size === f.size && prev.mtimeMs === f.mtimeMs);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const unchanged = files.length - changed.length;
|
|
316
|
+
console.log(`Server: ${server}`);
|
|
317
|
+
console.log(`Dossier: ${rootDir}`);
|
|
318
|
+
console.log(`Total: ${files.length} fichier(s)`);
|
|
319
|
+
console.log(`A uploader: ${changed.length}`);
|
|
320
|
+
console.log(`Inchanges: ${unchanged}`);
|
|
321
|
+
|
|
322
|
+
if (dryRun) {
|
|
323
|
+
for (const f of changed.slice(0, 50)) console.log(`- ${f.relPath}`);
|
|
324
|
+
if (changed.length > 50) console.log(`... +${changed.length - 50} autres`);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
let success = 0;
|
|
329
|
+
let failed = 0;
|
|
330
|
+
|
|
331
|
+
for (let i = 0; i < changed.length; i += 1) {
|
|
332
|
+
const file = changed[i];
|
|
333
|
+
const label = `[${i + 1}/${changed.length}]`;
|
|
334
|
+
try {
|
|
335
|
+
const result = await uploadOneFile(server, token, file);
|
|
336
|
+
success += 1;
|
|
337
|
+
state.files[file.relPath] = {
|
|
338
|
+
size: file.size,
|
|
339
|
+
mtimeMs: file.mtimeMs,
|
|
340
|
+
uploadedAt: new Date().toISOString(),
|
|
341
|
+
remoteId: result?.file?.id || null
|
|
342
|
+
};
|
|
343
|
+
console.log(`${label} OK ${file.relPath}`);
|
|
344
|
+
} catch (error) {
|
|
345
|
+
failed += 1;
|
|
346
|
+
console.error(`${label} FAIL ${file.relPath} -> ${error.message}`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
for (const relPath of Object.keys(state.files)) {
|
|
351
|
+
const stillThere = files.some((f) => f.relPath === relPath);
|
|
352
|
+
if (!stillThere) delete state.files[relPath];
|
|
353
|
+
}
|
|
354
|
+
await writeState(rootDir, state);
|
|
355
|
+
|
|
356
|
+
console.log(`Termine. Uploades: ${success}, erreurs: ${failed}`);
|
|
357
|
+
if (failed > 0) process.exitCode = 2;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function main() {
|
|
361
|
+
const { command, positionals, flags } = parseArgs(process.argv.slice(2));
|
|
362
|
+
|
|
363
|
+
if (!command || command === 'help' || command === '--help' || command === '-h') {
|
|
364
|
+
printHelp();
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (command === 'login') {
|
|
369
|
+
await login(flags);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (command === 'whoami') {
|
|
374
|
+
await whoami(flags);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (command === 'logout') {
|
|
379
|
+
await logout();
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (command === 'sync') {
|
|
384
|
+
await syncFolder(positionals, flags);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (command === 'config') {
|
|
389
|
+
const sub = positionals[0];
|
|
390
|
+
if (sub === 'show') {
|
|
391
|
+
await showConfig();
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (sub === 'set-server') {
|
|
395
|
+
await setServer(positionals.slice(1));
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
throw new Error('Usage: shard config show | shard config set-server <url>');
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
throw new Error(`Commande inconnue: ${command}`);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
main().catch((error) => {
|
|
405
|
+
console.error(`Erreur: ${error.message}`);
|
|
406
|
+
process.exitCode = 1;
|
|
407
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@iksdev/shard-cli",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "CLI pour synchroniser un dossier local avec Shard",
|
|
5
|
+
"bin": {
|
|
6
|
+
"shard": "bin/shard.js"
|
|
7
|
+
},
|
|
8
|
+
"type": "commonjs",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"check": "node --check bin/shard.js"
|
|
11
|
+
},
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=20.0.0"
|
|
14
|
+
},
|
|
15
|
+
"license": "MIT"
|
|
16
|
+
}
|