@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.
Files changed (3) hide show
  1. package/README.md +46 -0
  2. package/bin/shard.js +407 -0
  3. 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
+ }