@anraktech/sync 0.1.0

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 (2) hide show
  1. package/dist/cli.js +945 -0
  2. package/package.json +34 -0
package/dist/cli.js ADDED
@@ -0,0 +1,945 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { Command } from "commander";
5
+ import { createInterface } from "readline/promises";
6
+ import { stdin, stdout } from "process";
7
+ import { existsSync as existsSync2, statSync as statSync2 } from "fs";
8
+ import { resolve } from "path";
9
+ import chalk2 from "chalk";
10
+
11
+ // src/config.ts
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
13
+ import { homedir } from "os";
14
+ import { join } from "path";
15
+
16
+ // src/logger.ts
17
+ import chalk from "chalk";
18
+ var PREFIX = chalk.bold.blue("[anrak-sync]");
19
+ var log = {
20
+ info: (msg) => console.log(`${PREFIX} ${msg}`),
21
+ success: (msg) => console.log(`${PREFIX} ${chalk.green("\u2713")} ${msg}`),
22
+ warn: (msg) => console.log(`${PREFIX} ${chalk.yellow("\u26A0")} ${msg}`),
23
+ error: (msg) => console.error(`${PREFIX} ${chalk.red("\u2717")} ${msg}`),
24
+ debug: (msg) => {
25
+ if (process.env.ANRAK_DEBUG) console.log(`${PREFIX} ${chalk.gray(msg)}`);
26
+ },
27
+ dim: (msg) => console.log(`${PREFIX} ${chalk.dim(msg)}`),
28
+ file: (action, path) => console.log(`${PREFIX} ${chalk.cyan(action)} ${chalk.dim(path)}`)
29
+ };
30
+
31
+ // src/config.ts
32
+ var CONFIG_DIR = join(homedir(), ".anrak-sync");
33
+ var CONFIG_FILE = join(CONFIG_DIR, "config.json");
34
+ var CACHE_FILE = join(CONFIG_DIR, "cache.json");
35
+ function ensureDir() {
36
+ if (!existsSync(CONFIG_DIR)) {
37
+ mkdirSync(CONFIG_DIR, { recursive: true });
38
+ }
39
+ }
40
+ function loadConfig() {
41
+ try {
42
+ if (!existsSync(CONFIG_FILE)) return null;
43
+ const raw = readFileSync(CONFIG_FILE, "utf-8");
44
+ return JSON.parse(raw);
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+ function saveConfig(config) {
50
+ ensureDir();
51
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
52
+ log.debug(`Config saved to ${CONFIG_FILE}`);
53
+ }
54
+ function loadCache() {
55
+ try {
56
+ if (!existsSync(CACHE_FILE)) return { files: {}, mappings: {} };
57
+ const raw = readFileSync(CACHE_FILE, "utf-8");
58
+ return JSON.parse(raw);
59
+ } catch {
60
+ return { files: {}, mappings: {} };
61
+ }
62
+ }
63
+ function saveCache(cache2) {
64
+ ensureDir();
65
+ writeFileSync(CACHE_FILE, JSON.stringify(cache2, null, 2), "utf-8");
66
+ }
67
+ function getConfigDir() {
68
+ return CONFIG_DIR;
69
+ }
70
+
71
+ // src/auth.ts
72
+ import { createClient } from "@supabase/supabase-js";
73
+ import { createServer } from "http";
74
+ import { exec } from "child_process";
75
+ var supabase = null;
76
+ function getSupabase(config) {
77
+ if (!supabase) {
78
+ supabase = createClient(config.supabaseUrl, config.supabaseAnonKey, {
79
+ auth: { persistSession: false }
80
+ });
81
+ }
82
+ return supabase;
83
+ }
84
+ function openBrowser(url) {
85
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
86
+ exec(`${cmd} "${url}"`);
87
+ }
88
+ function findFreePort() {
89
+ return new Promise((resolve2, reject) => {
90
+ const srv = createServer();
91
+ srv.listen(0, () => {
92
+ const addr = srv.address();
93
+ if (!addr || typeof addr === "string") {
94
+ srv.close();
95
+ reject(new Error("Could not find a free port"));
96
+ return;
97
+ }
98
+ const port = addr.port;
99
+ srv.close(() => resolve2(port));
100
+ });
101
+ });
102
+ }
103
+ async function browserLogin(apiUrl) {
104
+ const port = await findFreePort();
105
+ return new Promise(
106
+ (resolve2, reject) => {
107
+ let server;
108
+ const timeout = setTimeout(() => {
109
+ server?.close();
110
+ reject(
111
+ new Error(
112
+ "Authorization timed out (5 minutes). Run `anrak-sync login` to try again."
113
+ )
114
+ );
115
+ }, 5 * 60 * 1e3);
116
+ server = createServer(async (req, res) => {
117
+ const url = new URL(req.url ?? "/", `http://localhost:${port}`);
118
+ if (url.pathname !== "/callback") {
119
+ res.writeHead(404);
120
+ res.end("Not found");
121
+ return;
122
+ }
123
+ const code = url.searchParams.get("code");
124
+ if (!code) {
125
+ res.writeHead(400);
126
+ res.end("Missing code");
127
+ return;
128
+ }
129
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
130
+ res.end(`<!DOCTYPE html>
131
+ <html><head><title>AnrakLegal Sync</title></head>
132
+ <body style="background:#0a0a0a;color:#fff;font-family:system-ui;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0">
133
+ <div style="text-align:center">
134
+ <div style="font-size:48px;margin-bottom:16px">&#10003;</div>
135
+ <h1 style="color:#3b82f6;margin:0 0 8px">Authorized</h1>
136
+ <p style="color:#888;margin:0">You can close this window and return to the terminal.</p>
137
+ </div></body></html>`);
138
+ try {
139
+ const resp = await fetch(`${apiUrl}/api/sync/auth/exchange`, {
140
+ method: "POST",
141
+ headers: { "Content-Type": "application/json" },
142
+ body: JSON.stringify({ code })
143
+ });
144
+ if (!resp.ok) {
145
+ const body = await resp.text().catch(() => "");
146
+ throw new Error(
147
+ `Code exchange failed (${resp.status}): ${body.slice(0, 200)}`
148
+ );
149
+ }
150
+ const tokens = await resp.json();
151
+ clearTimeout(timeout);
152
+ server.close();
153
+ resolve2(tokens);
154
+ } catch (err) {
155
+ clearTimeout(timeout);
156
+ server.close();
157
+ reject(err);
158
+ }
159
+ });
160
+ server.listen(port, () => {
161
+ const authorizeUrl = `${apiUrl}/sync/authorize?port=${port}`;
162
+ log.info("Opening browser for authorization...");
163
+ log.dim(`If browser doesn't open, visit: ${authorizeUrl}`);
164
+ openBrowser(authorizeUrl);
165
+ });
166
+ }
167
+ );
168
+ }
169
+ async function signIn(config, email, password) {
170
+ const sb = getSupabase(config);
171
+ const { data, error } = await sb.auth.signInWithPassword({
172
+ email,
173
+ password
174
+ });
175
+ if (error) throw new Error(`Login failed: ${error.message}`);
176
+ if (!data.session) throw new Error("No session returned");
177
+ return {
178
+ accessToken: data.session.access_token,
179
+ refreshToken: data.session.refresh_token
180
+ };
181
+ }
182
+ async function refreshSession(config) {
183
+ const sb = getSupabase(config);
184
+ const { data, error } = await sb.auth.refreshSession({
185
+ refresh_token: config.refreshToken
186
+ });
187
+ if (error || !data.session) {
188
+ log.warn(
189
+ "Session refresh failed \u2014 run `anrak-sync login` to re-authenticate"
190
+ );
191
+ return null;
192
+ }
193
+ config.accessToken = data.session.access_token;
194
+ config.refreshToken = data.session.refresh_token;
195
+ saveConfig(config);
196
+ return data.session.access_token;
197
+ }
198
+ async function getAccessToken(config) {
199
+ try {
200
+ const payload = JSON.parse(
201
+ Buffer.from(config.accessToken.split(".")[1], "base64").toString()
202
+ );
203
+ const expiresAt = payload.exp * 1e3;
204
+ const buffer = 6e4;
205
+ if (Date.now() < expiresAt - buffer) {
206
+ return config.accessToken;
207
+ }
208
+ } catch {
209
+ }
210
+ log.debug("Access token expired, refreshing...");
211
+ const newToken = await refreshSession(config);
212
+ if (!newToken) {
213
+ throw new Error(
214
+ "Authentication expired. Run `anrak-sync login` to re-authenticate."
215
+ );
216
+ }
217
+ return newToken;
218
+ }
219
+ async function fetchServerConfig(apiUrl) {
220
+ const resp = await fetch(`${apiUrl}/api/sync/config`);
221
+ if (!resp.ok) {
222
+ throw new Error(
223
+ `Failed to fetch server config (${resp.status}). Is ${apiUrl} reachable?`
224
+ );
225
+ }
226
+ return await resp.json();
227
+ }
228
+
229
+ // src/api.ts
230
+ import { readFile, stat } from "fs/promises";
231
+ import { basename } from "path";
232
+ import { lookup } from "mime-types";
233
+ async function authHeaders(config) {
234
+ const token = await getAccessToken(config);
235
+ return {
236
+ Authorization: `Bearer ${token}`
237
+ };
238
+ }
239
+ async function listCases(config) {
240
+ const headers = await authHeaders(config);
241
+ const resp = await fetch(`${config.apiUrl}/api/sync/cases`, { headers });
242
+ if (!resp.ok) {
243
+ const body = await resp.text().catch(() => "");
244
+ throw new Error(`Failed to list cases (${resp.status}): ${body.slice(0, 200)}`);
245
+ }
246
+ const data = await resp.json();
247
+ return data.cases;
248
+ }
249
+ async function uploadFile(config, caseId, filePath) {
250
+ const headers = await authHeaders(config);
251
+ const filename = basename(filePath);
252
+ const mimeType = lookup(filename) || "application/octet-stream";
253
+ const buffer = await readFile(filePath);
254
+ const blob = new Blob([buffer], { type: mimeType });
255
+ const formData = new FormData();
256
+ formData.append("files", blob, filename);
257
+ const resp = await fetch(
258
+ `${config.apiUrl}/api/sync/cases/${caseId}/upload`,
259
+ {
260
+ method: "POST",
261
+ headers,
262
+ body: formData
263
+ }
264
+ );
265
+ if (!resp.ok) {
266
+ const body = await resp.text().catch(() => "");
267
+ throw new Error(
268
+ `Upload failed for ${filename} (${resp.status}): ${body.slice(0, 200)}`
269
+ );
270
+ }
271
+ return await resp.json();
272
+ }
273
+ async function createCase(config, caseNumber, caseName) {
274
+ const headers = await authHeaders(config);
275
+ const resp = await fetch(`${config.apiUrl}/api/sync/cases`, {
276
+ method: "POST",
277
+ headers: { ...headers, "Content-Type": "application/json" },
278
+ body: JSON.stringify({ caseNumber, caseName })
279
+ });
280
+ if (!resp.ok) {
281
+ const body = await resp.text().catch(() => "");
282
+ throw new Error(`Failed to create case (${resp.status}): ${body.slice(0, 200)}`);
283
+ }
284
+ const data = await resp.json();
285
+ return data.case;
286
+ }
287
+ var SUPPORTED_EXTENSIONS = /* @__PURE__ */ new Set([
288
+ ".pdf",
289
+ ".doc",
290
+ ".docx",
291
+ ".ppt",
292
+ ".pptx",
293
+ ".xls",
294
+ ".xlsx",
295
+ ".txt",
296
+ ".md",
297
+ ".csv",
298
+ ".json",
299
+ ".xml",
300
+ ".html",
301
+ ".jpg",
302
+ ".jpeg",
303
+ ".png",
304
+ ".bmp",
305
+ ".tiff",
306
+ ".tif",
307
+ ".webp",
308
+ ".gif"
309
+ ]);
310
+ function isSupportedFile(filename) {
311
+ const ext = filename.toLowerCase().slice(filename.lastIndexOf("."));
312
+ return SUPPORTED_EXTENSIONS.has(ext);
313
+ }
314
+ var IGNORED_PATTERNS = [
315
+ /^\./,
316
+ // dotfiles
317
+ /^~\$/,
318
+ // Office temp files
319
+ /\.tmp$/i,
320
+ /\.bak$/i,
321
+ /^Thumbs\.db$/i,
322
+ /^desktop\.ini$/i,
323
+ /^\.DS_Store$/
324
+ ];
325
+ function isIgnoredFile(filename) {
326
+ return IGNORED_PATTERNS.some((p) => p.test(filename));
327
+ }
328
+
329
+ // src/cache.ts
330
+ import { createHash } from "crypto";
331
+ import { createReadStream } from "fs";
332
+ var cache = null;
333
+ function getCache() {
334
+ if (!cache) cache = loadCache();
335
+ return cache;
336
+ }
337
+ function persist() {
338
+ if (cache) saveCache(cache);
339
+ }
340
+ function hashFile(filePath) {
341
+ return new Promise((resolve2, reject) => {
342
+ const hash = createHash("sha256");
343
+ const stream = createReadStream(filePath);
344
+ stream.on("data", (chunk) => hash.update(chunk));
345
+ stream.on("end", () => resolve2(hash.digest("hex")));
346
+ stream.on("error", reject);
347
+ });
348
+ }
349
+ function getFileEntry(filePath) {
350
+ return getCache().files[filePath] ?? null;
351
+ }
352
+ function hasFileChanged(filePath, currentHash) {
353
+ const entry = getFileEntry(filePath);
354
+ if (!entry) return true;
355
+ return entry.hash !== currentHash;
356
+ }
357
+ function markSynced(filePath, hash, size, caseId, documentId) {
358
+ getCache().files[filePath] = {
359
+ hash,
360
+ size,
361
+ caseId,
362
+ documentId,
363
+ lastSynced: Date.now(),
364
+ status: "synced"
365
+ };
366
+ persist();
367
+ }
368
+ function markPending(filePath, hash, size, caseId) {
369
+ getCache().files[filePath] = {
370
+ hash,
371
+ size,
372
+ caseId,
373
+ documentId: null,
374
+ lastSynced: null,
375
+ status: "pending"
376
+ };
377
+ persist();
378
+ }
379
+ function markError(filePath) {
380
+ const entry = getCache().files[filePath];
381
+ if (entry) {
382
+ entry.status = "error";
383
+ persist();
384
+ }
385
+ }
386
+ function getFolderMapping(folderName) {
387
+ return getCache().mappings[folderName] ?? null;
388
+ }
389
+ function setFolderMapping(folderName, caseId, caseName, caseNumber) {
390
+ getCache().mappings[folderName] = {
391
+ caseId,
392
+ caseName,
393
+ caseNumber,
394
+ mappedAt: Date.now()
395
+ };
396
+ persist();
397
+ }
398
+ function getAllMappings() {
399
+ return { ...getCache().mappings };
400
+ }
401
+ function getStats() {
402
+ const c = getCache();
403
+ const files = Object.values(c.files);
404
+ return {
405
+ totalFiles: files.length,
406
+ synced: files.filter((f) => f.status === "synced").length,
407
+ pending: files.filter((f) => f.status === "pending").length,
408
+ errors: files.filter((f) => f.status === "error").length,
409
+ mappedFolders: Object.keys(c.mappings).length
410
+ };
411
+ }
412
+ function resetCache() {
413
+ cache = { files: {}, mappings: {} };
414
+ persist();
415
+ }
416
+
417
+ // src/watcher.ts
418
+ import { watch } from "chokidar";
419
+ import { readdirSync, statSync } from "fs";
420
+ import { join as join2, relative as relative2, basename as basename4 } from "path";
421
+
422
+ // src/uploader.ts
423
+ import { stat as stat2 } from "fs/promises";
424
+ import { basename as basename3, dirname, relative } from "path";
425
+
426
+ // src/mapper.ts
427
+ import { basename as basename2 } from "path";
428
+ function normalize(s) {
429
+ return s.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim();
430
+ }
431
+ function similarity(a, b) {
432
+ const wordsA = new Set(normalize(a).split(" "));
433
+ const wordsB = new Set(normalize(b).split(" "));
434
+ if (wordsA.size === 0 || wordsB.size === 0) return 0;
435
+ let overlap = 0;
436
+ for (const w of wordsA) {
437
+ if (wordsB.has(w)) overlap++;
438
+ }
439
+ return overlap / Math.max(wordsA.size, wordsB.size);
440
+ }
441
+ function findBestMatch(folderName, cases) {
442
+ const normalized = normalize(folderName);
443
+ for (const c of cases) {
444
+ const num = normalize(c.caseNumber);
445
+ if (num && normalized.includes(num)) {
446
+ return c;
447
+ }
448
+ }
449
+ let bestCase = null;
450
+ let bestScore = 0;
451
+ for (const c of cases) {
452
+ const nameScore = similarity(folderName, c.caseName);
453
+ const numScore = similarity(folderName, c.caseNumber);
454
+ const score = Math.max(nameScore, numScore);
455
+ if (score > bestScore) {
456
+ bestScore = score;
457
+ bestCase = c;
458
+ }
459
+ }
460
+ if (bestScore >= 0.5 && bestCase) {
461
+ return bestCase;
462
+ }
463
+ return null;
464
+ }
465
+ function deriveCaseInfo(folderName) {
466
+ const caseNumPattern = /(\d{4})[\/\-_\s]?([A-Z]{2,5})[\/\-_\s]?(\d{1,6})/i;
467
+ const match = folderName.match(caseNumPattern);
468
+ if (match) {
469
+ const caseNumber = `${match[1]}/${match[2].toUpperCase()}/${match[3]}`;
470
+ const remaining = folderName.replace(match[0], "").trim().replace(/^[\-_\s]+|[\-_\s]+$/g, "");
471
+ const caseName = remaining || folderName;
472
+ return { caseNumber, caseName };
473
+ }
474
+ const sanitized = folderName.replace(/[\/\\]/g, "-");
475
+ return {
476
+ caseNumber: `SYNC-${Date.now().toString(36).toUpperCase()}`,
477
+ caseName: sanitized
478
+ };
479
+ }
480
+ async function resolveFolder(config, folderPath, cases) {
481
+ const folderName = basename2(folderPath);
482
+ const cached = getFolderMapping(folderName);
483
+ if (cached) {
484
+ return {
485
+ caseId: cached.caseId,
486
+ caseName: cached.caseName,
487
+ caseNumber: cached.caseNumber,
488
+ created: false
489
+ };
490
+ }
491
+ const allCases = cases ?? await listCases(config);
492
+ const match = findBestMatch(folderName, allCases);
493
+ if (match) {
494
+ log.success(`Mapped "${folderName}" -> ${match.caseNumber} (${match.caseName})`);
495
+ setFolderMapping(folderName, match.id, match.caseName, match.caseNumber);
496
+ return {
497
+ caseId: match.id,
498
+ caseName: match.caseName,
499
+ caseNumber: match.caseNumber,
500
+ created: false
501
+ };
502
+ }
503
+ const { caseNumber, caseName } = deriveCaseInfo(folderName);
504
+ log.info(`No match for "${folderName}" \u2014 creating case ${caseNumber}`);
505
+ try {
506
+ const newCase = await createCase(config, caseNumber, caseName);
507
+ setFolderMapping(folderName, newCase.id, newCase.caseName, newCase.caseNumber);
508
+ log.success(`Created case ${newCase.caseNumber} (${newCase.caseName})`);
509
+ return {
510
+ caseId: newCase.id,
511
+ caseName: newCase.caseName,
512
+ caseNumber: newCase.caseNumber,
513
+ created: true
514
+ };
515
+ } catch (err) {
516
+ throw new Error(
517
+ `Failed to create case for folder "${folderName}": ${err instanceof Error ? err.message : err}`
518
+ );
519
+ }
520
+ }
521
+
522
+ // src/uploader.ts
523
+ var MAX_RETRIES = 3;
524
+ var RATE_LIMIT_INTERVAL_MS = 3e3;
525
+ var MAX_FILE_SIZE = 50 * 1024 * 1024;
526
+ var queue = [];
527
+ var processing = false;
528
+ var lastUploadTime = 0;
529
+ async function enqueue(filePath, watchFolder) {
530
+ const filename = basename3(filePath);
531
+ if (isIgnoredFile(filename)) return;
532
+ if (!isSupportedFile(filename)) {
533
+ log.debug(`Skipping unsupported file: ${filename}`);
534
+ return;
535
+ }
536
+ try {
537
+ const s = await stat2(filePath);
538
+ if (s.size > MAX_FILE_SIZE) {
539
+ log.warn(`Skipping ${filename} \u2014 exceeds 50MB limit (${(s.size / 1024 / 1024).toFixed(1)}MB)`);
540
+ return;
541
+ }
542
+ if (s.size === 0) return;
543
+ } catch {
544
+ return;
545
+ }
546
+ const hash = await hashFile(filePath);
547
+ if (!hasFileChanged(filePath, hash)) {
548
+ log.debug(`Unchanged: ${filename}`);
549
+ return;
550
+ }
551
+ const relPath = relative(watchFolder, filePath);
552
+ const topFolder = relPath.split(/[/\\]/)[0];
553
+ const folderPath = topFolder === basename3(filePath) ? watchFolder : dirname(filePath).split(/[/\\]/).slice(0, watchFolder.split(/[/\\]/).length + 1).join("/");
554
+ queue.push({ filePath, folderPath, retries: 0 });
555
+ log.file("queued", relative(watchFolder, filePath));
556
+ }
557
+ async function processQueue(config, cases) {
558
+ if (processing) return { uploaded: 0, failed: 0 };
559
+ processing = true;
560
+ let uploaded = 0;
561
+ let failed = 0;
562
+ while (queue.length > 0) {
563
+ const item = queue.shift();
564
+ const filename = basename3(item.filePath);
565
+ const elapsed = Date.now() - lastUploadTime;
566
+ if (elapsed < RATE_LIMIT_INTERVAL_MS) {
567
+ await sleep(RATE_LIMIT_INTERVAL_MS - elapsed);
568
+ }
569
+ try {
570
+ const { caseId, caseName } = await resolveFolder(
571
+ config,
572
+ item.folderPath,
573
+ cases
574
+ );
575
+ const hash = await hashFile(item.filePath);
576
+ const s = await stat2(item.filePath);
577
+ markPending(item.filePath, hash, s.size, caseId);
578
+ log.file("uploading", `${filename} -> ${caseName}`);
579
+ const result = await uploadFile(config, caseId, item.filePath);
580
+ lastUploadTime = Date.now();
581
+ if (result.success && result.linkedDocumentIds.length > 0) {
582
+ markSynced(
583
+ item.filePath,
584
+ hash,
585
+ s.size,
586
+ caseId,
587
+ result.linkedDocumentIds[0]
588
+ );
589
+ log.success(`Synced ${filename} to ${caseName}`);
590
+ uploaded++;
591
+ } else {
592
+ markError(item.filePath);
593
+ log.warn(`Upload returned no linked documents for ${filename}`);
594
+ failed++;
595
+ }
596
+ } catch (err) {
597
+ const msg = err instanceof Error ? err.message : String(err);
598
+ if (item.retries < MAX_RETRIES) {
599
+ item.retries++;
600
+ queue.push(item);
601
+ log.warn(`Retry ${item.retries}/${MAX_RETRIES} for ${filename}: ${msg}`);
602
+ } else {
603
+ markError(item.filePath);
604
+ log.error(`Failed ${filename} after ${MAX_RETRIES} retries: ${msg}`);
605
+ failed++;
606
+ }
607
+ }
608
+ }
609
+ processing = false;
610
+ return { uploaded, failed };
611
+ }
612
+ function queueSize() {
613
+ return queue.length;
614
+ }
615
+ function sleep(ms) {
616
+ return new Promise((r) => setTimeout(r, ms));
617
+ }
618
+
619
+ // src/watcher.ts
620
+ async function scanFolder(config) {
621
+ const folder = config.watchFolder;
622
+ let scanned = 0;
623
+ let queued = 0;
624
+ function walk(dir) {
625
+ let entries;
626
+ try {
627
+ entries = readdirSync(dir);
628
+ } catch {
629
+ return;
630
+ }
631
+ for (const entry of entries) {
632
+ if (isIgnoredFile(entry)) continue;
633
+ const fullPath = join2(dir, entry);
634
+ let s;
635
+ try {
636
+ s = statSync(fullPath);
637
+ } catch {
638
+ continue;
639
+ }
640
+ if (s.isDirectory()) {
641
+ walk(fullPath);
642
+ } else if (s.isFile() && isSupportedFile(entry)) {
643
+ scanned++;
644
+ void enqueue(fullPath, folder).then(() => queued++);
645
+ }
646
+ }
647
+ }
648
+ walk(folder);
649
+ await new Promise((r) => setTimeout(r, 500));
650
+ return { scanned, queued: queueSize() };
651
+ }
652
+ async function pushSync(config) {
653
+ log.info(`Scanning ${config.watchFolder}...`);
654
+ const cases = await listCases(config);
655
+ log.info(`Found ${cases.length} case(s) on server`);
656
+ const { scanned, queued } = await scanFolder(config);
657
+ log.info(`Scanned ${scanned} files, ${queued} need syncing`);
658
+ if (queued === 0) {
659
+ log.success("Everything up to date");
660
+ return;
661
+ }
662
+ const result = await processQueue(config, cases);
663
+ log.success(
664
+ `Sync complete: ${result.uploaded} uploaded, ${result.failed} failed`
665
+ );
666
+ }
667
+ async function startWatching(config) {
668
+ const folder = config.watchFolder;
669
+ log.info(`Scanning ${folder}...`);
670
+ let cases = await listCases(config);
671
+ log.info(`Found ${cases.length} case(s) on server`);
672
+ const { scanned, queued } = await scanFolder(config);
673
+ log.info(`Scanned ${scanned} files, ${queued} need syncing`);
674
+ if (queued > 0) {
675
+ const result = await processQueue(config, cases);
676
+ log.info(
677
+ `Initial sync: ${result.uploaded} uploaded, ${result.failed} failed`
678
+ );
679
+ }
680
+ const refreshInterval = setInterval(async () => {
681
+ try {
682
+ cases = await listCases(config);
683
+ } catch {
684
+ }
685
+ }, 5 * 60 * 1e3);
686
+ log.info(`Watching for changes...`);
687
+ log.dim("Press Ctrl+C to stop\n");
688
+ const watcher = watch(folder, {
689
+ ignored: /(^|[\/\\])(\.|~\$|Thumbs\.db|desktop\.ini)/,
690
+ persistent: true,
691
+ ignoreInitial: true,
692
+ // We already scanned
693
+ awaitWriteFinish: {
694
+ stabilityThreshold: 2e3,
695
+ // Wait 2s after last change
696
+ pollInterval: 500
697
+ },
698
+ depth: 5
699
+ // Max folder depth
700
+ });
701
+ let debounceTimer = null;
702
+ function scheduleProcess() {
703
+ if (debounceTimer) clearTimeout(debounceTimer);
704
+ debounceTimer = setTimeout(async () => {
705
+ if (queueSize() > 0) {
706
+ const result = await processQueue(config, cases);
707
+ if (result.uploaded > 0 || result.failed > 0) {
708
+ log.info(
709
+ `Batch: ${result.uploaded} uploaded, ${result.failed} failed`
710
+ );
711
+ }
712
+ }
713
+ }, 3e3);
714
+ }
715
+ watcher.on("add", async (path) => {
716
+ const filename = basename4(path);
717
+ if (!isSupportedFile(filename) || isIgnoredFile(filename)) return;
718
+ log.file("detected", relative2(folder, path));
719
+ await enqueue(path, folder);
720
+ scheduleProcess();
721
+ });
722
+ watcher.on("change", async (path) => {
723
+ const filename = basename4(path);
724
+ if (!isSupportedFile(filename) || isIgnoredFile(filename)) return;
725
+ log.file("changed", relative2(folder, path));
726
+ await enqueue(path, folder);
727
+ scheduleProcess();
728
+ });
729
+ watcher.on("unlink", (path) => {
730
+ log.file("deleted", relative2(folder, path));
731
+ });
732
+ watcher.on("error", (err) => {
733
+ log.error(`Watcher error: ${err instanceof Error ? err.message : String(err)}`);
734
+ });
735
+ const shutdown = () => {
736
+ log.info("Shutting down...");
737
+ clearInterval(refreshInterval);
738
+ if (debounceTimer) clearTimeout(debounceTimer);
739
+ watcher.close().then(() => {
740
+ log.success("Stopped");
741
+ process.exit(0);
742
+ });
743
+ };
744
+ process.on("SIGINT", shutdown);
745
+ process.on("SIGTERM", shutdown);
746
+ }
747
+
748
+ // src/cli.ts
749
+ var program = new Command();
750
+ program.name("anrak-sync").description("AnrakLegal desktop file sync \u2014 watches local folders, syncs to case management").version("0.1.0");
751
+ program.command("init").description("Set up AnrakLegal Sync (first-time configuration)").option("--password", "Use email/password login instead of browser").action(async (opts) => {
752
+ const rl = createInterface({ input: stdin, output: stdout });
753
+ try {
754
+ console.log(chalk2.bold.blue("\n AnrakLegal Sync \u2014 Setup\n"));
755
+ const apiUrl = await rl.question(
756
+ ` AnrakLegal URL ${chalk2.dim("(https://anrak.legal)")}: `
757
+ ) || "https://anrak.legal";
758
+ log.info("Connecting to server...");
759
+ const serverConfig = await fetchServerConfig(apiUrl);
760
+ log.success("Server connected");
761
+ let tokens;
762
+ if (opts.password) {
763
+ const email = await rl.question(" Email: ");
764
+ const password = await rl.question(" Password: ");
765
+ if (!email || !password) {
766
+ log.error("Email and password are required");
767
+ process.exit(1);
768
+ }
769
+ log.info("Authenticating...");
770
+ const partialConfig = {
771
+ apiUrl,
772
+ supabaseUrl: serverConfig.supabaseUrl,
773
+ supabaseAnonKey: serverConfig.supabaseAnonKey,
774
+ accessToken: "",
775
+ refreshToken: "",
776
+ watchFolder: ""
777
+ };
778
+ tokens = await signIn(partialConfig, email, password);
779
+ } else {
780
+ rl.close();
781
+ tokens = await browserLogin(apiUrl);
782
+ }
783
+ log.success("Authenticated");
784
+ const rl2 = createInterface({ input: stdin, output: stdout });
785
+ const defaultFolder = process.platform === "win32" ? "C:\\Cases" : `${process.env.HOME}/Cases`;
786
+ const watchInput = await rl2.question(
787
+ ` Watch folder ${chalk2.dim(`(${defaultFolder})`)}: `
788
+ );
789
+ const watchFolder = resolve(watchInput || defaultFolder);
790
+ rl2.close();
791
+ if (!existsSync2(watchFolder)) {
792
+ log.warn(
793
+ `Folder ${watchFolder} does not exist \u2014 it will be created when you add files`
794
+ );
795
+ } else if (!statSync2(watchFolder).isDirectory()) {
796
+ log.error(`${watchFolder} is not a directory`);
797
+ process.exit(1);
798
+ }
799
+ const config = {
800
+ apiUrl,
801
+ supabaseUrl: serverConfig.supabaseUrl,
802
+ supabaseAnonKey: serverConfig.supabaseAnonKey,
803
+ accessToken: tokens.accessToken,
804
+ refreshToken: tokens.refreshToken,
805
+ watchFolder
806
+ };
807
+ saveConfig(config);
808
+ console.log("");
809
+ log.success("Setup complete!");
810
+ log.info(`Config saved to ${getConfigDir()}`);
811
+ log.info(`Watching: ${watchFolder}`);
812
+ console.log(
813
+ chalk2.dim("\n Run `anrak-sync start` to begin syncing\n")
814
+ );
815
+ } catch (err) {
816
+ log.error(err instanceof Error ? err.message : String(err));
817
+ process.exit(1);
818
+ }
819
+ });
820
+ program.command("login").description("Re-authenticate with AnrakLegal").option("--password", "Use email/password login instead of browser").action(async (opts) => {
821
+ const config = requireConfig();
822
+ try {
823
+ let tokens;
824
+ if (opts.password) {
825
+ const rl = createInterface({ input: stdin, output: stdout });
826
+ try {
827
+ const email = await rl.question(" Email: ");
828
+ const password = await rl.question(" Password: ");
829
+ log.info("Authenticating...");
830
+ tokens = await signIn(config, email, password);
831
+ } finally {
832
+ rl.close();
833
+ }
834
+ } else {
835
+ tokens = await browserLogin(config.apiUrl);
836
+ }
837
+ config.accessToken = tokens.accessToken;
838
+ config.refreshToken = tokens.refreshToken;
839
+ saveConfig(config);
840
+ log.success("Logged in successfully");
841
+ } catch (err) {
842
+ log.error(err instanceof Error ? err.message : String(err));
843
+ process.exit(1);
844
+ }
845
+ });
846
+ program.command("start").description("Start watching for file changes and syncing").action(async () => {
847
+ const config = requireConfig();
848
+ console.log(chalk2.bold.blue("\n AnrakLegal Sync\n"));
849
+ log.info(`Watching: ${config.watchFolder}`);
850
+ log.info(`Server: ${config.apiUrl}`);
851
+ console.log("");
852
+ try {
853
+ await startWatching(config);
854
+ } catch (err) {
855
+ log.error(err instanceof Error ? err.message : String(err));
856
+ process.exit(1);
857
+ }
858
+ });
859
+ program.command("push").description("One-time sync \u2014 upload all new/changed files, then exit").action(async () => {
860
+ const config = requireConfig();
861
+ console.log(chalk2.bold.blue("\n AnrakLegal Sync \u2014 Push\n"));
862
+ log.info(`Folder: ${config.watchFolder}`);
863
+ log.info(`Server: ${config.apiUrl}`);
864
+ console.log("");
865
+ try {
866
+ await pushSync(config);
867
+ } catch (err) {
868
+ log.error(err instanceof Error ? err.message : String(err));
869
+ process.exit(1);
870
+ }
871
+ });
872
+ program.command("status").description("Show sync status").action(async () => {
873
+ const config = requireConfig();
874
+ const stats = getStats();
875
+ console.log(chalk2.bold.blue("\n AnrakLegal Sync \u2014 Status\n"));
876
+ console.log(` Server: ${config.apiUrl}`);
877
+ console.log(` Watch folder: ${config.watchFolder}`);
878
+ console.log(` Config: ${getConfigDir()}`);
879
+ console.log("");
880
+ console.log(` Files tracked: ${stats.totalFiles}`);
881
+ console.log(` Synced: ${chalk2.green(stats.synced)}`);
882
+ console.log(` Pending: ${chalk2.yellow(stats.pending)}`);
883
+ console.log(` Errors: ${chalk2.red(stats.errors)}`);
884
+ console.log(` Mapped folders: ${stats.mappedFolders}`);
885
+ try {
886
+ const cases = await listCases(config);
887
+ console.log(`
888
+ Server cases: ${cases.length}`);
889
+ console.log(` Auth: ${chalk2.green("valid")}`);
890
+ } catch {
891
+ console.log(`
892
+ Auth: ${chalk2.red("expired \u2014 run anrak-sync login")}`);
893
+ }
894
+ console.log("");
895
+ });
896
+ program.command("map").description("Show folder-to-case mappings").action(async () => {
897
+ const config = requireConfig();
898
+ const mappings = getAllMappings();
899
+ const entries = Object.entries(mappings);
900
+ console.log(chalk2.bold.blue("\n AnrakLegal Sync \u2014 Mappings\n"));
901
+ if (entries.length === 0) {
902
+ log.info("No mappings yet. Run `anrak-sync push` or `anrak-sync start` to create them.");
903
+ } else {
904
+ for (const [folder, mapping] of entries) {
905
+ console.log(
906
+ ` ${chalk2.cyan(folder)} -> ${mapping.caseNumber} (${chalk2.dim(mapping.caseName)})`
907
+ );
908
+ }
909
+ }
910
+ try {
911
+ const cases = await listCases(config);
912
+ const mappedIds = new Set(entries.map(([, m]) => m.caseId));
913
+ const unmapped = cases.filter((c) => !mappedIds.has(c.id));
914
+ if (unmapped.length > 0) {
915
+ console.log(chalk2.dim("\n Unmapped server cases:"));
916
+ for (const c of unmapped) {
917
+ console.log(` ${chalk2.dim(c.caseNumber)} ${chalk2.dim(c.caseName)}`);
918
+ }
919
+ }
920
+ } catch {
921
+ }
922
+ console.log("");
923
+ });
924
+ program.command("reset").description("Clear sync cache (will re-scan and re-upload on next sync)").action(() => {
925
+ resetCache();
926
+ log.success("Cache cleared. Run `anrak-sync push` to re-sync.");
927
+ });
928
+ program.command("logout").description("Clear stored credentials").action(() => {
929
+ const config = loadConfig();
930
+ if (config) {
931
+ config.accessToken = "";
932
+ config.refreshToken = "";
933
+ saveConfig(config);
934
+ }
935
+ log.success("Logged out. Run `anrak-sync login` to re-authenticate.");
936
+ });
937
+ function requireConfig() {
938
+ const config = loadConfig();
939
+ if (!config) {
940
+ log.error("Not configured. Run `anrak-sync init` first.");
941
+ process.exit(1);
942
+ }
943
+ return config;
944
+ }
945
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@anraktech/sync",
3
+ "version": "0.1.0",
4
+ "description": "AnrakLegal desktop file sync agent — watches local folders and syncs to case management",
5
+ "type": "module",
6
+ "bin": {
7
+ "anrak-sync": "./dist/cli.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsup",
11
+ "dev": "tsx src/cli.ts",
12
+ "start": "node dist/cli.js"
13
+ },
14
+ "dependencies": {
15
+ "@supabase/supabase-js": "^2.49.0",
16
+ "chalk": "^5.4.0",
17
+ "chokidar": "^4.0.0",
18
+ "commander": "^13.1.0",
19
+ "mime-types": "^2.1.35"
20
+ },
21
+ "devDependencies": {
22
+ "@types/mime-types": "^2.1.4",
23
+ "@types/node": "^22.0.0",
24
+ "tsup": "^8.0.0",
25
+ "tsx": "^4.0.0",
26
+ "typescript": "^5.7.0"
27
+ },
28
+ "engines": {
29
+ "node": ">=20.0.0"
30
+ },
31
+ "files": [
32
+ "dist"
33
+ ]
34
+ }