tether-sync 0.4.0 → 0.6.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.
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
restage,
|
|
24
24
|
setKeep,
|
|
25
25
|
tetherHome
|
|
26
|
-
} from "./chunk-
|
|
26
|
+
} from "./chunk-KG4OTE34.js";
|
|
27
27
|
|
|
28
28
|
// packages/client/src/daemon.js
|
|
29
29
|
import fs8 from "node:fs";
|
|
@@ -45,6 +45,7 @@ var DEFAULTS = {
|
|
|
45
45
|
// all | errors | off
|
|
46
46
|
};
|
|
47
47
|
var NUMERIC = /* @__PURE__ */ new Set(["tickSeconds", "graceMs"]);
|
|
48
|
+
var MIN_TICK_SECONDS = Number(process.env.TETHER_MIN_TICK_SECONDS) || 10;
|
|
48
49
|
var Config = class {
|
|
49
50
|
constructor(file) {
|
|
50
51
|
this.file = file;
|
|
@@ -63,6 +64,7 @@ var Config = class {
|
|
|
63
64
|
if (NUMERIC.has(key)) {
|
|
64
65
|
value = Number(value);
|
|
65
66
|
if (!Number.isFinite(value) || value <= 0) throw new Error(`${key} must be a positive number`);
|
|
67
|
+
if (key === "tickSeconds" && value < MIN_TICK_SECONDS) throw new Error(`tickSeconds must be at least ${MIN_TICK_SECONDS}`);
|
|
66
68
|
}
|
|
67
69
|
if (key === "notifications" && !["all", "errors", "off"].includes(value)) throw new Error("notifications must be all, errors or off");
|
|
68
70
|
if (key === "scanRoots" && typeof value === "string") value = value.split(/[;,]/).map((s) => s.trim()).filter(Boolean);
|
|
@@ -987,6 +989,7 @@ function walkFiles(root) {
|
|
|
987
989
|
var UserError = class extends Error {
|
|
988
990
|
};
|
|
989
991
|
var ACTIVE = (i) => i.mode !== "orphaned";
|
|
992
|
+
var FULL_CHECK_MS = 60 * 60 * 1e3;
|
|
990
993
|
var Engine = class {
|
|
991
994
|
constructor({ db, api, cache, tracker, config, log = () => {
|
|
992
995
|
}, notify = () => {
|
|
@@ -1445,6 +1448,28 @@ var Engine = class {
|
|
|
1445
1448
|
this.log(`${inst.path}: ${what}; file kept, no longer tracked`);
|
|
1446
1449
|
this.notify({ kind: "orphaned", level: "warn", title: "No longer synced", body: `${path4.basename(inst.path)}: ${what}. Your copy stays on disk but Tether no longer keeps it up to date.`, short: path4.basename(inst.path) });
|
|
1447
1450
|
}
|
|
1451
|
+
/** What a check asks about; a change cursor is only valid for the same question. */
|
|
1452
|
+
checkKey(insts) {
|
|
1453
|
+
return insts.map((i) => `${i.artifactId}@${i.baseVersion}:${i.mode}`).join(",");
|
|
1454
|
+
}
|
|
1455
|
+
/**
|
|
1456
|
+
* The change cursor from the last full check, if it can be reused: same instances, nothing
|
|
1457
|
+
* pending, no live sessions, and a full check at least every FULL_CHECK_MS as a safety net.
|
|
1458
|
+
*/
|
|
1459
|
+
reusableCursor(insts) {
|
|
1460
|
+
const f = this.feed;
|
|
1461
|
+
if (!f || this.realtimeActive || Date.now() - f.at > FULL_CHECK_MS) return void 0;
|
|
1462
|
+
return f.key === this.checkKey(insts) ? f.cursor : void 0;
|
|
1463
|
+
}
|
|
1464
|
+
/**
|
|
1465
|
+
* Keeps the server's cursor only when nothing is pending here: an incoming version held back
|
|
1466
|
+
* (editing, unmanaged, waiting for git) must be offered again by a full check once it can apply.
|
|
1467
|
+
*/
|
|
1468
|
+
rememberCursor(cursor) {
|
|
1469
|
+
const insts = this.db.all().filter(ACTIVE);
|
|
1470
|
+
const settled = insts.every((i) => i.remoteVersion == null || i.remoteVersion === i.baseVersion);
|
|
1471
|
+
this.feed = cursor !== void 0 && settled ? { cursor, key: this.checkKey(insts), at: Date.now() } : null;
|
|
1472
|
+
}
|
|
1448
1473
|
/** One batched call for all instances (DESIGN §6.1 step 2). */
|
|
1449
1474
|
async checkRemote() {
|
|
1450
1475
|
const insts = this.db.all().filter(ACTIVE);
|
|
@@ -1452,10 +1477,15 @@ var Engine = class {
|
|
|
1452
1477
|
this.offline = null;
|
|
1453
1478
|
return;
|
|
1454
1479
|
}
|
|
1455
|
-
let results;
|
|
1480
|
+
let results, cursor;
|
|
1456
1481
|
try {
|
|
1457
|
-
|
|
1482
|
+
const rootOf = (i) => i.folderInstanceId ? this.db.get(i.folderInstanceId)?.artifactId : void 0;
|
|
1483
|
+
const items = insts.map((i) => ({ artifactId: i.artifactId, version: i.baseVersion, root: rootOf(i) }));
|
|
1484
|
+
const body = { items, cursor: this.reusableCursor(insts) };
|
|
1485
|
+
const res = await this.api.call("POST", "/sync/check", body);
|
|
1458
1486
|
this.offline = null;
|
|
1487
|
+
if (res.unchanged) return;
|
|
1488
|
+
({ results, cursor } = res);
|
|
1459
1489
|
} catch (e) {
|
|
1460
1490
|
if (e instanceof OfflineError || e instanceof ApiError && e.status === 401) {
|
|
1461
1491
|
this.offline = e.message;
|
|
@@ -1468,6 +1498,7 @@ var Engine = class {
|
|
|
1468
1498
|
const folders = [];
|
|
1469
1499
|
insts.forEach((inst, idx) => {
|
|
1470
1500
|
const r = results[idx];
|
|
1501
|
+
if (!r) return;
|
|
1471
1502
|
const cur = this.db.get(inst.instanceId);
|
|
1472
1503
|
if (!cur || cur.mode === "orphaned") return;
|
|
1473
1504
|
if (r.status === "gone") return this.orphan(cur, r.reason);
|
|
@@ -1547,6 +1578,8 @@ var Engine = class {
|
|
|
1547
1578
|
this.log(`${folder.path}: new file ${child.relPath}`);
|
|
1548
1579
|
}
|
|
1549
1580
|
for (const f of folders) if (!waiting.has(f.inst.instanceId)) this.db.update(f.inst.instanceId, { baseVersion: f.state.version });
|
|
1581
|
+
if (results.some((r) => r && r.status !== "ok")) this.activityAt = Date.now();
|
|
1582
|
+
this.rememberCursor(cursor);
|
|
1550
1583
|
}
|
|
1551
1584
|
/** Normal mode: write an incoming version (content and/or layout). */
|
|
1552
1585
|
applyIncoming(inst, state) {
|
|
@@ -1613,6 +1646,22 @@ var Engine = class {
|
|
|
1613
1646
|
const local = readLocal(p, inst.eolMode);
|
|
1614
1647
|
return local?.hash === inst.baseHash;
|
|
1615
1648
|
}
|
|
1649
|
+
/**
|
|
1650
|
+
* Follow mode, just before a revert: is this content git's own (a git operation that started
|
|
1651
|
+
* after this pass checked, e.g. a cherry-pick between writing the file and moving HEAD)? Asks git
|
|
1652
|
+
* afresh, since the pass's busy check may be stale by now.
|
|
1653
|
+
*/
|
|
1654
|
+
gitWroteIt(inst) {
|
|
1655
|
+
if (inst.gitMode !== "follow" || !inst.gitRoot) return false;
|
|
1656
|
+
if (gitBusy(inst.gitDir)) return true;
|
|
1657
|
+
const blob = fileBlob(inst.gitRoot, inst.path);
|
|
1658
|
+
if (!blob) return false;
|
|
1659
|
+
if (blob === indexBlob(inst.gitRoot, inst.path) || blob === blobAt(inst.gitRoot, "HEAD", inst.path)) {
|
|
1660
|
+
this.gitPassDue = true;
|
|
1661
|
+
return true;
|
|
1662
|
+
}
|
|
1663
|
+
return false;
|
|
1664
|
+
}
|
|
1616
1665
|
/** Normal mode: restore missing files, put moved files back, revert local edits. */
|
|
1617
1666
|
async enforceLocal() {
|
|
1618
1667
|
const restores = this.db.all().filter((i) => ACTIVE(i) && i.needsRestore && i.mode === "normal");
|
|
@@ -1643,6 +1692,7 @@ var Engine = class {
|
|
|
1643
1692
|
continue;
|
|
1644
1693
|
}
|
|
1645
1694
|
if (!this.cache.has(cur.baseHash)) continue;
|
|
1695
|
+
if (this.gitWroteIt(cur)) continue;
|
|
1646
1696
|
this.db.update(cur.instanceId, { ...this.writeInstance(cur, cur.path, cur.baseHash), lastError: null });
|
|
1647
1697
|
this.log(`${cur.path}: local change reverted (use \`tether edit\` to change it)`);
|
|
1648
1698
|
const byGit = cur.gitMode === "keep";
|
|
@@ -2454,9 +2504,90 @@ $x = New-Object Windows.Data.Xml.Dom.XmlDocument; $x.LoadXml('${xml.replace(/'/g
|
|
|
2454
2504
|
}
|
|
2455
2505
|
};
|
|
2456
2506
|
|
|
2507
|
+
// packages/client/src/feed.js
|
|
2508
|
+
var RETRY_MS = 5 * 60 * 1e3;
|
|
2509
|
+
var FeedListener = class {
|
|
2510
|
+
/** onChange(seq) is called with the feed counter on every snapshot from the server. */
|
|
2511
|
+
constructor({ api, log, onChange }) {
|
|
2512
|
+
this.api = api;
|
|
2513
|
+
this.log = log;
|
|
2514
|
+
this.onChange = onChange;
|
|
2515
|
+
this.live = false;
|
|
2516
|
+
this.unsupported = false;
|
|
2517
|
+
this.session = 0;
|
|
2518
|
+
}
|
|
2519
|
+
async start() {
|
|
2520
|
+
if (this.unsupported || this.unsub || this.starting) return;
|
|
2521
|
+
this.starting = true;
|
|
2522
|
+
const session = ++this.session;
|
|
2523
|
+
try {
|
|
2524
|
+
const t = await this.api.call("POST", "/feed/token");
|
|
2525
|
+
const [{ initializeApp }, A, F] = await Promise.all([import("firebase/app"), import("firebase/auth"), import("firebase/firestore")]);
|
|
2526
|
+
if (session !== this.session) return;
|
|
2527
|
+
this.app = initializeApp(t.firebase, `tether-feed-${session}-${Date.now()}`);
|
|
2528
|
+
const auth = A.getAuth(this.app);
|
|
2529
|
+
const db = F.getFirestore(this.app);
|
|
2530
|
+
if (t.emulators?.auth) A.connectAuthEmulator(auth, `http://${t.emulators.auth}`, { disableWarnings: true });
|
|
2531
|
+
if (t.emulators?.firestore) {
|
|
2532
|
+
const [host, port] = t.emulators.firestore.split(":");
|
|
2533
|
+
F.connectFirestoreEmulator(db, host, Number(port));
|
|
2534
|
+
}
|
|
2535
|
+
await A.signInWithCustomToken(auth, t.customToken);
|
|
2536
|
+
if (session !== this.session) return this.dispose();
|
|
2537
|
+
this.unsub = F.onSnapshot(F.doc(db, "feeds", t.uid), { includeMetadataChanges: true }, (snap) => {
|
|
2538
|
+
const wasLive = this.live;
|
|
2539
|
+
this.live = !snap.metadata.fromCache;
|
|
2540
|
+
if (this.live && !wasLive) this.log("push channel connected");
|
|
2541
|
+
if (this.live) this.onChange(snap.get("seq") ?? 0);
|
|
2542
|
+
}, (e) => this.fail(e, session));
|
|
2543
|
+
} catch (e) {
|
|
2544
|
+
this.fail(e, session);
|
|
2545
|
+
} finally {
|
|
2546
|
+
this.starting = false;
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
fail(e, session) {
|
|
2550
|
+
if (session !== this.session) return;
|
|
2551
|
+
if (e instanceof ApiError && e.status === 404) {
|
|
2552
|
+
this.unsupported = true;
|
|
2553
|
+
this.stop();
|
|
2554
|
+
return;
|
|
2555
|
+
}
|
|
2556
|
+
this.log(`push channel unavailable (${e.message}); polling until it reconnects`);
|
|
2557
|
+
this.stop();
|
|
2558
|
+
clearTimeout(this.retry);
|
|
2559
|
+
this.retry = setTimeout(() => this.start(), RETRY_MS);
|
|
2560
|
+
this.retry.unref?.();
|
|
2561
|
+
}
|
|
2562
|
+
stop() {
|
|
2563
|
+
this.session++;
|
|
2564
|
+
clearTimeout(this.retry);
|
|
2565
|
+
this.unsub?.();
|
|
2566
|
+
this.unsub = null;
|
|
2567
|
+
if (this.live) this.log("push channel closed");
|
|
2568
|
+
this.live = false;
|
|
2569
|
+
this.dispose();
|
|
2570
|
+
}
|
|
2571
|
+
dispose() {
|
|
2572
|
+
const app = this.app;
|
|
2573
|
+
this.app = null;
|
|
2574
|
+
if (app) import("firebase/app").then(({ deleteApp }) => deleteApp(app)).catch(() => {
|
|
2575
|
+
});
|
|
2576
|
+
}
|
|
2577
|
+
/** After a login, logout or server change: start over (or stay off when signed out). */
|
|
2578
|
+
restart() {
|
|
2579
|
+
this.unsupported = false;
|
|
2580
|
+
this.stop();
|
|
2581
|
+
this.start();
|
|
2582
|
+
}
|
|
2583
|
+
};
|
|
2584
|
+
|
|
2457
2585
|
// packages/client/src/daemon.js
|
|
2458
2586
|
import { fileURLToPath } from "node:url";
|
|
2459
2587
|
var REALTIME_TICK_SECONDS = 3;
|
|
2588
|
+
var IDLE_AFTER_MS = 10 * 60 * 1e3;
|
|
2589
|
+
var IDLE_MAX_SECONDS = 180;
|
|
2590
|
+
var PUSH_TICK_SECONDS = 15 * 60;
|
|
2460
2591
|
var UPDATE_CHECK_MS = 24 * 60 * 60 * 1e3;
|
|
2461
2592
|
async function runDaemon() {
|
|
2462
2593
|
const home = tetherHome();
|
|
@@ -2494,23 +2625,54 @@ async function runDaemon() {
|
|
|
2494
2625
|
let realtimeTimer = null;
|
|
2495
2626
|
let stopping = false;
|
|
2496
2627
|
let latest = null;
|
|
2497
|
-
|
|
2628
|
+
let remoteAt = 0;
|
|
2629
|
+
async function runTick({ remote } = {}) {
|
|
2630
|
+
remote ??= !feed.live || engine.realtimeActive || Date.now() - remoteAt >= PUSH_TICK_SECONDS * 1e3;
|
|
2631
|
+
if (remote) remoteAt = Date.now();
|
|
2498
2632
|
try {
|
|
2499
|
-
await engine.locked(() => engine.tick(
|
|
2633
|
+
await engine.locked(() => engine.tick({ remote }));
|
|
2500
2634
|
} catch (e) {
|
|
2501
2635
|
log(`tick failed: ${e.stack || e.message}`);
|
|
2502
2636
|
notifier.notify({ kind: "error", level: "error", key: "tick-failed", cooldownMs: 3600 * 1e3, title: "Tether hit an error", body: `${e.message}. Run "tether log" for details.` });
|
|
2503
2637
|
}
|
|
2638
|
+
if (engine.gitPassDue) {
|
|
2639
|
+
engine.gitPassDue = false;
|
|
2640
|
+
gitPassSoon();
|
|
2641
|
+
}
|
|
2504
2642
|
refreshWatchers();
|
|
2505
2643
|
scheduleGrace();
|
|
2506
2644
|
schedule();
|
|
2507
2645
|
}
|
|
2646
|
+
let activeAt = Date.now();
|
|
2647
|
+
let timerDue = 0;
|
|
2648
|
+
function interval() {
|
|
2649
|
+
const base = Math.max(MIN_TICK_SECONDS, config.get("tickSeconds"));
|
|
2650
|
+
if (engine.realtimeActive) return Math.min(base, REALTIME_TICK_SECONDS);
|
|
2651
|
+
const idle = Date.now() - Math.max(activeAt, engine.activityAt ?? 0);
|
|
2652
|
+
if (idle < IDLE_AFTER_MS) return base;
|
|
2653
|
+
return Math.max(base, Math.min(IDLE_MAX_SECONDS, base * 2 ** Math.floor(idle / IDLE_AFTER_MS)));
|
|
2654
|
+
}
|
|
2508
2655
|
function schedule() {
|
|
2509
2656
|
clearTimeout(timer);
|
|
2510
2657
|
if (stopping) return;
|
|
2511
|
-
const seconds =
|
|
2658
|
+
const seconds = interval();
|
|
2659
|
+
timerDue = Date.now() + seconds * 1e3;
|
|
2512
2660
|
timer = setTimeout(() => runTick(), seconds * 1e3);
|
|
2513
2661
|
}
|
|
2662
|
+
const feed = new FeedListener({
|
|
2663
|
+
api,
|
|
2664
|
+
log,
|
|
2665
|
+
onChange: (seq) => {
|
|
2666
|
+
if (stopping || engine.feed?.cursor === seq) return;
|
|
2667
|
+
clearTimeout(timer);
|
|
2668
|
+
timerDue = Date.now() + 300;
|
|
2669
|
+
timer = setTimeout(() => runTick({ remote: true }), 300);
|
|
2670
|
+
}
|
|
2671
|
+
});
|
|
2672
|
+
function markActive() {
|
|
2673
|
+
activeAt = Date.now();
|
|
2674
|
+
if (timer && timerDue - activeAt > interval() * 1e3) schedule();
|
|
2675
|
+
}
|
|
2514
2676
|
function realtimePass(delay = 1e3) {
|
|
2515
2677
|
if (stopping || !engine.realtimeInstances().length) return;
|
|
2516
2678
|
clearTimeout(realtimeTimer);
|
|
@@ -2587,6 +2749,7 @@ async function runDaemon() {
|
|
|
2587
2749
|
if (watchers.has(dir)) continue;
|
|
2588
2750
|
try {
|
|
2589
2751
|
const w = kind === "git" ? fs8.watch(dir, { persistent: false }, () => gitPassSoon()) : fs8.watch(dir, { recursive: kind, persistent: false }, () => {
|
|
2752
|
+
markActive();
|
|
2590
2753
|
quickPass(250);
|
|
2591
2754
|
realtimePass();
|
|
2592
2755
|
});
|
|
@@ -2631,7 +2794,7 @@ async function runDaemon() {
|
|
|
2631
2794
|
return { state, url: `${config.consoleUrl.replace(/\/$/, "")}/cli-login?port=${port}&state=${state}` };
|
|
2632
2795
|
}
|
|
2633
2796
|
const L = (fn) => (params) => engine.locked(() => fn(params));
|
|
2634
|
-
const soon = () => setTimeout(() => runTick(), 10);
|
|
2797
|
+
const soon = () => setTimeout(() => runTick({ remote: true }), 10);
|
|
2635
2798
|
const handlers = {
|
|
2636
2799
|
ping: () => ({ pid: process.pid, version: VERSION, home, tracker: tracker.name, server: config.get("server") }),
|
|
2637
2800
|
async status(p) {
|
|
@@ -2647,11 +2810,13 @@ async function runDaemon() {
|
|
|
2647
2810
|
};
|
|
2648
2811
|
},
|
|
2649
2812
|
sync: async () => {
|
|
2650
|
-
await runTick();
|
|
2813
|
+
await runTick({ remote: true });
|
|
2651
2814
|
return { offline: engine.offline };
|
|
2652
2815
|
},
|
|
2653
2816
|
async "login.password"({ email, password, signup }) {
|
|
2654
2817
|
const user = await api.passwordLogin({ email, password, signup });
|
|
2818
|
+
engine.feed = null;
|
|
2819
|
+
feed.restart();
|
|
2655
2820
|
soon();
|
|
2656
2821
|
return user;
|
|
2657
2822
|
},
|
|
@@ -2661,13 +2826,19 @@ async function runDaemon() {
|
|
|
2661
2826
|
if (!p) throw new UserError("no login in progress");
|
|
2662
2827
|
try {
|
|
2663
2828
|
const user = await p;
|
|
2829
|
+
engine.feed = null;
|
|
2830
|
+
feed.restart();
|
|
2664
2831
|
soon();
|
|
2665
2832
|
return user;
|
|
2666
2833
|
} finally {
|
|
2667
2834
|
logins.delete(state);
|
|
2668
2835
|
}
|
|
2669
2836
|
},
|
|
2670
|
-
logout: async () =>
|
|
2837
|
+
logout: async () => {
|
|
2838
|
+
engine.feed = null;
|
|
2839
|
+
feed.stop();
|
|
2840
|
+
return api.logout();
|
|
2841
|
+
},
|
|
2671
2842
|
whoami: () => api.call("GET", "/me"),
|
|
2672
2843
|
manage: L((p) => engine.manage(p)),
|
|
2673
2844
|
add: L((p) => engine.add(p)),
|
|
@@ -2727,6 +2898,10 @@ async function runDaemon() {
|
|
|
2727
2898
|
"config.get": ({ key }) => key ? { [key]: config.get(key) } : config.values,
|
|
2728
2899
|
"config.set": ({ key, value }) => {
|
|
2729
2900
|
config.set(key, value);
|
|
2901
|
+
if (key === "server") {
|
|
2902
|
+
engine.feed = null;
|
|
2903
|
+
feed.restart();
|
|
2904
|
+
}
|
|
2730
2905
|
if (key === "tickSeconds") schedule();
|
|
2731
2906
|
return { [key]: config.get(key) };
|
|
2732
2907
|
},
|
|
@@ -2740,6 +2915,7 @@ async function runDaemon() {
|
|
|
2740
2915
|
async function handle(method, params) {
|
|
2741
2916
|
const h = handlers[method];
|
|
2742
2917
|
if (!h) throw new UserError(`unknown command ${method}`);
|
|
2918
|
+
if (method !== "ping") markActive();
|
|
2743
2919
|
try {
|
|
2744
2920
|
const result = await h(params);
|
|
2745
2921
|
if (CHANGES_WATCHED.has(method)) refreshWatchers();
|
|
@@ -2763,6 +2939,7 @@ async function runDaemon() {
|
|
|
2763
2939
|
clearInterval(updateTimer);
|
|
2764
2940
|
clearInterval(repoTimer);
|
|
2765
2941
|
clearTimeout(gitTimer);
|
|
2942
|
+
feed.stop();
|
|
2766
2943
|
for (const w of watchers.values()) w.close();
|
|
2767
2944
|
server.close();
|
|
2768
2945
|
engine.locked(() => db.close()).finally(() => {
|
|
@@ -2782,6 +2959,7 @@ async function runDaemon() {
|
|
|
2782
2959
|
const updateTimer = setInterval(checkForUpdate, UPDATE_CHECK_MS);
|
|
2783
2960
|
setTimeout(checkForUpdate, 5e3);
|
|
2784
2961
|
await runTick();
|
|
2962
|
+
feed.start();
|
|
2785
2963
|
}
|
|
2786
2964
|
export {
|
|
2787
2965
|
VERSION,
|
package/bin/tether.js
CHANGED
package/bin/tetherd.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
process.removeAllListeners("warning");
|
|
5
5
|
var i = process.argv.indexOf("--home");
|
|
6
6
|
if (i > 0 && process.argv[i + 1]) process.env.TETHER_HOME = process.argv[i + 1];
|
|
7
|
-
var { runDaemon } = await import("./daemon-
|
|
7
|
+
var { runDaemon } = await import("./daemon-Z2NE3MNG.js");
|
|
8
8
|
runDaemon().catch((e) => {
|
|
9
9
|
if (e.code === "EADDRINUSE") {
|
|
10
10
|
console.error("tetherd is already running");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tether-sync",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Keep files identical to a central source of truth, wherever they live on your machine.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"koffi": "^3.3.3",
|
|
23
23
|
"commander": "^14.0.1",
|
|
24
|
-
"diff": "^8.0.2"
|
|
24
|
+
"diff": "^8.0.2",
|
|
25
|
+
"firebase": "^12.19.0"
|
|
25
26
|
},
|
|
26
27
|
"keywords": [
|
|
27
28
|
"sync",
|