tether-sync 0.1.0 → 0.4.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.
- package/README.md +27 -21
- package/bin/chunk-NMLHD2KU.js +382 -0
- package/bin/{daemon-WGNVBSEJ.js → daemon-5IFWP5O3.js} +1026 -72
- package/bin/tether.js +88 -9
- package/bin/tetherd.js +1 -1
- package/package.json +1 -1
- package/bin/chunk-MGMU33LE.js +0 -109
|
@@ -1,13 +1,32 @@
|
|
|
1
1
|
import {
|
|
2
|
+
GIT_MODES,
|
|
3
|
+
VERSION,
|
|
4
|
+
blobAt,
|
|
5
|
+
changeFromGit,
|
|
6
|
+
changesBetween,
|
|
7
|
+
fileBlob,
|
|
2
8
|
files,
|
|
9
|
+
findRepo,
|
|
10
|
+
gitBinary,
|
|
11
|
+
gitBusy,
|
|
12
|
+
head,
|
|
13
|
+
indexBlob,
|
|
3
14
|
ipcEndpoint,
|
|
15
|
+
isAncestor,
|
|
16
|
+
isKept,
|
|
17
|
+
isNewer,
|
|
18
|
+
isTracked,
|
|
4
19
|
isWindows,
|
|
20
|
+
latestVersion,
|
|
5
21
|
listen,
|
|
22
|
+
removeHooks,
|
|
23
|
+
restage,
|
|
24
|
+
setKeep,
|
|
6
25
|
tetherHome
|
|
7
|
-
} from "./chunk-
|
|
26
|
+
} from "./chunk-NMLHD2KU.js";
|
|
8
27
|
|
|
9
28
|
// packages/client/src/daemon.js
|
|
10
|
-
import
|
|
29
|
+
import fs8 from "node:fs";
|
|
11
30
|
import path5 from "node:path";
|
|
12
31
|
import http from "node:http";
|
|
13
32
|
import crypto4 from "node:crypto";
|
|
@@ -21,7 +40,9 @@ var DEFAULTS = {
|
|
|
21
40
|
// defaults to the server URL
|
|
22
41
|
tickSeconds: 30,
|
|
23
42
|
graceMs: 2e3,
|
|
24
|
-
scanRoots: [os.homedir()]
|
|
43
|
+
scanRoots: [os.homedir()],
|
|
44
|
+
notifications: "all"
|
|
45
|
+
// all | errors | off
|
|
25
46
|
};
|
|
26
47
|
var NUMERIC = /* @__PURE__ */ new Set(["tickSeconds", "graceMs"]);
|
|
27
48
|
var Config = class {
|
|
@@ -43,6 +64,7 @@ var Config = class {
|
|
|
43
64
|
value = Number(value);
|
|
44
65
|
if (!Number.isFinite(value) || value <= 0) throw new Error(`${key} must be a positive number`);
|
|
45
66
|
}
|
|
67
|
+
if (key === "notifications" && !["all", "errors", "off"].includes(value)) throw new Error("notifications must be all, errors or off");
|
|
46
68
|
if (key === "scanRoots" && typeof value === "string") value = value.split(/[;,]/).map((s) => s.trim()).filter(Boolean);
|
|
47
69
|
this.values[key] = value;
|
|
48
70
|
fs.writeFileSync(this.file, JSON.stringify(this.values, null, 2));
|
|
@@ -93,11 +115,25 @@ var COLUMNS = {
|
|
|
93
115
|
needsRestore: "INTEGER DEFAULT 0",
|
|
94
116
|
// write a fresh copy at the expected path on the next tick
|
|
95
117
|
orphanReason: "TEXT",
|
|
118
|
+
realtime: "INTEGER DEFAULT 0",
|
|
119
|
+
// this client holds a realtime session and pushes every save
|
|
120
|
+
remoteRealtime: "TEXT",
|
|
121
|
+
// JSON { email, since } when someone else edits this file in realtime
|
|
122
|
+
gitMode: "TEXT",
|
|
123
|
+
// inside a git repo: keep (Tether reverts git's changes) | follow (git's changes are published)
|
|
124
|
+
gitRoot: "TEXT",
|
|
125
|
+
gitDir: "TEXT",
|
|
126
|
+
gitHooks: "TEXT",
|
|
127
|
+
// unused since 0.2.1 (hooks were replaced by watching the git directory)
|
|
128
|
+
gitWaiting: "INTEGER DEFAULT 0",
|
|
129
|
+
// follow: a newer Tether version came from a git commit not pulled here yet
|
|
130
|
+
gitOverlay: "INTEGER DEFAULT 0",
|
|
131
|
+
// follow: holds a Tether-only version that git does not have (hidden from git)
|
|
96
132
|
lastError: "TEXT",
|
|
97
133
|
createdAt: "REAL",
|
|
98
134
|
updatedAt: "REAL"
|
|
99
135
|
};
|
|
100
|
-
var BOOL = /* @__PURE__ */ new Set(["hasBom", "exec", "canDelete", "detached", "needsRestore"]);
|
|
136
|
+
var BOOL = /* @__PURE__ */ new Set(["gitOverlay", "gitWaiting", "realtime", "hasBom", "exec", "canDelete", "detached", "needsRestore"]);
|
|
101
137
|
var LocalDb = class {
|
|
102
138
|
constructor(file) {
|
|
103
139
|
this.db = new DatabaseSync(file);
|
|
@@ -322,7 +358,73 @@ function randomString(len) {
|
|
|
322
358
|
}
|
|
323
359
|
var newInstanceId = () => "i_" + randomString(10);
|
|
324
360
|
|
|
361
|
+
// packages/shared/src/chunks.js
|
|
362
|
+
var CHUNK_MIN = 2 * 1024;
|
|
363
|
+
var CHUNK_AVG = 8 * 1024;
|
|
364
|
+
var CHUNK_MAX = 64 * 1024;
|
|
365
|
+
var MASK_HARD = 4294836224;
|
|
366
|
+
var MASK_EASY = 4292870144;
|
|
367
|
+
var GEAR = (() => {
|
|
368
|
+
let a = 2122219134;
|
|
369
|
+
const t = new Uint32Array(256);
|
|
370
|
+
for (let i = 0; i < 256; i++) {
|
|
371
|
+
a = a + 1831565813 | 0;
|
|
372
|
+
let x = Math.imul(a ^ a >>> 15, 1 | a);
|
|
373
|
+
x = x + Math.imul(x ^ x >>> 7, 61 | x) ^ x;
|
|
374
|
+
t[i] = (x ^ x >>> 14) >>> 0;
|
|
375
|
+
}
|
|
376
|
+
return t;
|
|
377
|
+
})();
|
|
378
|
+
function chunkBoundaries(buf) {
|
|
379
|
+
const out = [];
|
|
380
|
+
const n = buf.length;
|
|
381
|
+
let start = 0;
|
|
382
|
+
while (start < n) {
|
|
383
|
+
if (n - start <= CHUNK_MIN) {
|
|
384
|
+
out.push({ o: start, s: n - start });
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
const end = Math.min(n, start + CHUNK_MAX);
|
|
388
|
+
const normal = Math.min(end, start + CHUNK_AVG);
|
|
389
|
+
let h = 0;
|
|
390
|
+
let cut = end;
|
|
391
|
+
let i = start + CHUNK_MIN;
|
|
392
|
+
for (; i < normal; i++) {
|
|
393
|
+
h = (h << 1) + GEAR[buf[i]] >>> 0;
|
|
394
|
+
if ((h & MASK_HARD) === 0) {
|
|
395
|
+
cut = i + 1;
|
|
396
|
+
break;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if (cut === end) {
|
|
400
|
+
for (; i < end; i++) {
|
|
401
|
+
h = (h << 1) + GEAR[buf[i]] >>> 0;
|
|
402
|
+
if ((h & MASK_EASY) === 0) {
|
|
403
|
+
cut = i + 1;
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
out.push({ o: start, s: cut - start });
|
|
409
|
+
start = cut;
|
|
410
|
+
}
|
|
411
|
+
return out;
|
|
412
|
+
}
|
|
413
|
+
function chunkContent(buf) {
|
|
414
|
+
return chunkBoundaries(buf).map(({ o, s }) => {
|
|
415
|
+
const bytes = buf.subarray(o, o + s);
|
|
416
|
+
return { h: sha256(bytes), s, bytes };
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
325
420
|
// packages/client/src/api.js
|
|
421
|
+
var PUT_BATCH_BYTES = 1024 * 1024;
|
|
422
|
+
var PUT_PARALLEL = 4;
|
|
423
|
+
var PUT_BATCH_CHUNKS = 100;
|
|
424
|
+
var FETCH_BATCH_BYTES = 1024 * 1024;
|
|
425
|
+
var FETCH_PARALLEL = 4;
|
|
426
|
+
var kb = (n) => n < 1024 ? `${n} B` : n < 1048576 ? `${Math.round(n / 1024)} KB` : `${(n / 1048576).toFixed(1)} MB`;
|
|
427
|
+
var describeTransfer = (t, verb) => t ? `${verb} ${t.moved} of ${t.total} chunk${t.total === 1 ? "" : "s"}, ${kb(t.bytes)}` : "";
|
|
326
428
|
var ApiError = class extends Error {
|
|
327
429
|
constructor(status, body) {
|
|
328
430
|
super(body?.message ?? `HTTP ${status}`);
|
|
@@ -355,6 +457,39 @@ var Api = class {
|
|
|
355
457
|
this.config = config;
|
|
356
458
|
this.credentials = credentials;
|
|
357
459
|
this.accessToken = null;
|
|
460
|
+
this.manifests = /* @__PURE__ */ new Map();
|
|
461
|
+
this.transfers = /* @__PURE__ */ new Map();
|
|
462
|
+
}
|
|
463
|
+
/** Authenticated POST with a binary body or a binary response; refreshes the token once on 401. */
|
|
464
|
+
async binary(p, { json, body, headers = {} }) {
|
|
465
|
+
const send = async (token) => {
|
|
466
|
+
let res;
|
|
467
|
+
try {
|
|
468
|
+
res = await fetch(this.base + "/v1" + p, {
|
|
469
|
+
method: "POST",
|
|
470
|
+
headers: { authorization: `Bearer ${token}`, "content-type": json ? "application/json" : "application/octet-stream", ...headers },
|
|
471
|
+
body: json ? JSON.stringify(json) : body,
|
|
472
|
+
signal: AbortSignal.timeout(18e4)
|
|
473
|
+
});
|
|
474
|
+
} catch (e) {
|
|
475
|
+
throw new OfflineError(`cannot reach the Tether server at ${this.base} (${e.cause?.code ?? e.message})`);
|
|
476
|
+
}
|
|
477
|
+
if (!res.ok) throw new ApiError(res.status, (await res.json().catch(() => ({}))).error);
|
|
478
|
+
return Buffer.from(await res.arrayBuffer());
|
|
479
|
+
};
|
|
480
|
+
try {
|
|
481
|
+
return await send(await this.token());
|
|
482
|
+
} catch (e) {
|
|
483
|
+
if (e instanceof ApiError && e.status === 401 && this.accessToken) {
|
|
484
|
+
this.accessToken = null;
|
|
485
|
+
return send(await this.token());
|
|
486
|
+
}
|
|
487
|
+
throw e;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
/** The chunk list to commit for content uploaded by uploadBlobs (undefined on old servers). */
|
|
491
|
+
manifestFor(sha) {
|
|
492
|
+
return this.manifests.get(sha);
|
|
358
493
|
}
|
|
359
494
|
get base() {
|
|
360
495
|
return this.config.get("server").replace(/\/$/, "");
|
|
@@ -465,10 +600,58 @@ var Api = class {
|
|
|
465
600
|
this.credentials.clear();
|
|
466
601
|
this.accessToken = null;
|
|
467
602
|
}
|
|
468
|
-
/**
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
603
|
+
/**
|
|
604
|
+
* Uploads content as chunks (delta sync): every file is split into content-defined chunks and
|
|
605
|
+
* only the chunks the server does not have are sent. blobs: [{ sha256, bytes }] (canonical).
|
|
606
|
+
* Afterwards manifestFor(sha) gives each file's chunk list for the commit.
|
|
607
|
+
*/
|
|
608
|
+
async uploadBlobs(blobs, { artifactId } = {}) {
|
|
609
|
+
const files2 = [...new Map(blobs.map((b) => [b.sha256, b])).values()];
|
|
610
|
+
if (!files2.length) return;
|
|
611
|
+
const all = /* @__PURE__ */ new Map();
|
|
612
|
+
for (const f of files2) {
|
|
613
|
+
const parts = chunkContent(f.bytes);
|
|
614
|
+
this.manifests.set(f.sha256, parts.map(({ h, s }) => ({ h, s })));
|
|
615
|
+
for (const c of parts) all.set(c.h, c.bytes);
|
|
616
|
+
}
|
|
617
|
+
let missing;
|
|
618
|
+
try {
|
|
619
|
+
({ missing } = await this.call("POST", "/chunks/missing", { hashes: [...all.keys()], artifactId }));
|
|
620
|
+
} catch (e) {
|
|
621
|
+
if (!(e instanceof ApiError && e.status === 404)) throw e;
|
|
622
|
+
for (const f of files2) this.manifests.delete(f.sha256);
|
|
623
|
+
return this.uploadWhole(files2);
|
|
624
|
+
}
|
|
625
|
+
const batches = [];
|
|
626
|
+
let batch = [];
|
|
627
|
+
let size = 0;
|
|
628
|
+
for (const h of missing) {
|
|
629
|
+
const bytes = all.get(h);
|
|
630
|
+
if (batch.length && (batch.length >= PUT_BATCH_CHUNKS || size + bytes.length > PUT_BATCH_BYTES)) {
|
|
631
|
+
batches.push(batch);
|
|
632
|
+
batch = [];
|
|
633
|
+
size = 0;
|
|
634
|
+
}
|
|
635
|
+
batch.push({ h, bytes });
|
|
636
|
+
size += bytes.length;
|
|
637
|
+
}
|
|
638
|
+
if (batch.length) batches.push(batch);
|
|
639
|
+
let next = 0;
|
|
640
|
+
await Promise.all(Array.from({ length: Math.min(PUT_PARALLEL, batches.length) }, async () => {
|
|
641
|
+
while (next < batches.length) {
|
|
642
|
+
const b = batches[next++];
|
|
643
|
+
await this.binary("/chunks/put", { body: Buffer.concat(b.map((c) => c.bytes)), headers: { "x-tether-chunks": b.map((c) => `${c.h}:${c.bytes.length}`).join(",") } });
|
|
644
|
+
}
|
|
645
|
+
}));
|
|
646
|
+
const sent = new Set(missing);
|
|
647
|
+
for (const f of files2) {
|
|
648
|
+
const m = this.manifests.get(f.sha256);
|
|
649
|
+
const moved = m.filter((c) => sent.has(c.h));
|
|
650
|
+
this.transfers.set(f.sha256, { moved: moved.length, total: m.length, bytes: moved.reduce((a, c) => a + c.s, 0) });
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
/** Whole-file upload through signed URLs, for servers without delta sync. */
|
|
654
|
+
async uploadWhole(unique) {
|
|
472
655
|
const { upload } = await this.call("POST", "/blobs/upload", { blobs: unique.map((b) => ({ sha256: b.sha256, size: b.bytes.length })) });
|
|
473
656
|
const bySha = new Map(unique.map((b) => [b.sha256, b]));
|
|
474
657
|
for (const u of upload) {
|
|
@@ -476,10 +659,63 @@ var Api = class {
|
|
|
476
659
|
if (!res.ok && res.status !== 412) throw new ApiError(res.status, { message: `upload failed (${res.status}): ${(await res.text().catch(() => "")).slice(0, 200)}` });
|
|
477
660
|
}
|
|
478
661
|
}
|
|
479
|
-
/**
|
|
662
|
+
/**
|
|
663
|
+
* Downloads content into the cache, by chunks (delta sync). items: [{ artifactId, version, sha,
|
|
664
|
+
* base? }] where base is content already cached (usually the version on disk): its chunks are
|
|
665
|
+
* reused, so only chunks that changed are downloaded.
|
|
666
|
+
*/
|
|
480
667
|
async fetchBlobs(cache, items) {
|
|
481
|
-
|
|
482
|
-
|
|
668
|
+
const need = [...new Map(items.filter((i) => !i.sha || !cache.has(i.sha)).map((i) => [`${i.artifactId}@${i.version}`, i])).values()];
|
|
669
|
+
if (!need.length) return;
|
|
670
|
+
let manifests;
|
|
671
|
+
try {
|
|
672
|
+
({ manifests } = await this.call("POST", "/chunks/manifest", { items: need.map(({ artifactId, version }) => ({ artifactId, version })) }));
|
|
673
|
+
} catch (e) {
|
|
674
|
+
if (!(e instanceof ApiError && e.status === 404)) throw e;
|
|
675
|
+
return this.fetchWhole(cache, need);
|
|
676
|
+
}
|
|
677
|
+
for (const [k, m] of manifests.entries()) {
|
|
678
|
+
if (cache.has(m.blob)) continue;
|
|
679
|
+
const local = /* @__PURE__ */ new Map();
|
|
680
|
+
const base = need[k].base;
|
|
681
|
+
if (base && cache.has(base)) for (const c of chunkContent(cache.get(base))) local.set(c.h, c.bytes);
|
|
682
|
+
const size = new Map(m.chunks.map((c) => [c.h, c.s]));
|
|
683
|
+
const want = [...size.keys()].filter((h) => !local.has(h));
|
|
684
|
+
let bytesIn = 0;
|
|
685
|
+
const batches = [];
|
|
686
|
+
for (let i = 0; i < want.length; ) {
|
|
687
|
+
const batch = [];
|
|
688
|
+
let total = 0;
|
|
689
|
+
while (i < want.length && (!batch.length || total + size.get(want[i]) <= FETCH_BATCH_BYTES)) {
|
|
690
|
+
batch.push(want[i]);
|
|
691
|
+
total += size.get(want[i++]);
|
|
692
|
+
}
|
|
693
|
+
batches.push(batch);
|
|
694
|
+
bytesIn += total;
|
|
695
|
+
}
|
|
696
|
+
let next = 0;
|
|
697
|
+
await Promise.all(Array.from({ length: Math.min(FETCH_PARALLEL, batches.length) }, async () => {
|
|
698
|
+
while (next < batches.length) {
|
|
699
|
+
const batch = batches[next++];
|
|
700
|
+
const buf = await this.binary("/chunks/fetch", { json: { artifactId: m.artifactId, version: m.version, hashes: batch } });
|
|
701
|
+
let off = 0;
|
|
702
|
+
for (const h of batch) {
|
|
703
|
+
const bytes = buf.subarray(off, off + size.get(h));
|
|
704
|
+
off += size.get(h);
|
|
705
|
+
if (sha256(bytes) !== h) throw new Error("downloaded chunk does not match its hash");
|
|
706
|
+
local.set(h, Buffer.from(bytes));
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}));
|
|
710
|
+
const content = Buffer.concat(m.chunks.map((c) => local.get(c.h)));
|
|
711
|
+
if (sha256(content) !== m.blob) throw new Error("downloaded content does not match its hash");
|
|
712
|
+
cache.put(m.blob, content);
|
|
713
|
+
this.transfers.set(m.blob, { moved: want.length, total: size.size, bytes: bytesIn });
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
/** Whole-file downloads through signed URLs, for servers without delta sync. */
|
|
717
|
+
async fetchWhole(cache, items) {
|
|
718
|
+
const { downloads } = await this.call("POST", "/blobs/download", { items: items.map(({ artifactId, version }) => ({ artifactId, version })) });
|
|
483
719
|
for (const d of downloads) {
|
|
484
720
|
if (cache.has(d.sha256)) continue;
|
|
485
721
|
const res = await fetch(d.url);
|
|
@@ -753,13 +989,19 @@ var UserError = class extends Error {
|
|
|
753
989
|
var ACTIVE = (i) => i.mode !== "orphaned";
|
|
754
990
|
var Engine = class {
|
|
755
991
|
constructor({ db, api, cache, tracker, config, log = () => {
|
|
756
|
-
}
|
|
992
|
+
}, notify = () => {
|
|
993
|
+
}, home = null, cliPath = null }) {
|
|
757
994
|
this.db = db;
|
|
758
995
|
this.api = api;
|
|
759
996
|
this.cache = cache;
|
|
760
997
|
this.tracker = tracker;
|
|
761
998
|
this.config = config;
|
|
762
999
|
this.log = log;
|
|
1000
|
+
this.notify = notify;
|
|
1001
|
+
this.home = home;
|
|
1002
|
+
this.cliPath = cliPath;
|
|
1003
|
+
this.gitState = /* @__PURE__ */ new Map();
|
|
1004
|
+
this.busyCache = null;
|
|
763
1005
|
this.offline = null;
|
|
764
1006
|
this.lastTick = null;
|
|
765
1007
|
this.graceDue = null;
|
|
@@ -793,22 +1035,330 @@ var Engine = class {
|
|
|
793
1035
|
if (!this.cache.has(sha)) throw new Error(`content ${sha.slice(0, 12)} is not in the local cache`);
|
|
794
1036
|
return this.cache.get(sha);
|
|
795
1037
|
}
|
|
1038
|
+
/** Downloads content not yet cached; `base` (content already here) lets only changed chunks travel. */
|
|
796
1039
|
async ensureBlobs(pairs) {
|
|
797
1040
|
const missing = pairs.filter((p) => p.sha && !this.cache.has(p.sha));
|
|
798
|
-
if (missing.length) await this.api.fetchBlobs(this.cache, missing
|
|
1041
|
+
if (missing.length) await this.api.fetchBlobs(this.cache, missing);
|
|
1042
|
+
}
|
|
1043
|
+
/** Uploads content (only chunks the server lacks) and returns the commit fields for it. */
|
|
1044
|
+
async uploadContent(local, artifactId) {
|
|
1045
|
+
await this.api.uploadBlobs([{ sha256: local.hash, bytes: local.canonical }], { artifactId });
|
|
1046
|
+
this.cache.put(local.hash, local.canonical);
|
|
1047
|
+
return { blob: local.hash, size: local.canonical.length, chunks: this.api.manifestFor(local.hash) };
|
|
1048
|
+
}
|
|
1049
|
+
/** ", sent 2 of 540 chunks, 17 KB" for log lines (empty when nothing is known). */
|
|
1050
|
+
sentNote(sha) {
|
|
1051
|
+
const t = describeTransfer(this.api.transfers.get(sha), "sent");
|
|
1052
|
+
return t ? `; ${t}` : "";
|
|
799
1053
|
}
|
|
800
1054
|
/** Writes content for an instance at `p`; records the result for the quick change check. */
|
|
801
1055
|
writeInstance(inst, p, sha, eolMode = inst.eolMode, exec = inst.exec) {
|
|
802
1056
|
const style = inst.eolStyle ? { eol: inst.eolStyle, bom: !!inst.hasBom } : null;
|
|
803
|
-
|
|
1057
|
+
const st = writeLocal(p, this.blobFor(sha), eolMode, { exec, style });
|
|
1058
|
+
if (inst.gitMode === "keep" && inst.gitRoot) {
|
|
1059
|
+
restage(inst.gitRoot, p);
|
|
1060
|
+
const after = statOrNull(p);
|
|
1061
|
+
if (after) Object.assign(st, { size: after.size, mtimeMs: after.mtimeMs });
|
|
1062
|
+
}
|
|
1063
|
+
return st;
|
|
804
1064
|
}
|
|
805
1065
|
// ---------- tick ----------
|
|
806
1066
|
async tick({ remote = true } = {}) {
|
|
1067
|
+
this.busyCache = /* @__PURE__ */ new Map();
|
|
807
1068
|
await this.locateAll();
|
|
808
|
-
|
|
1069
|
+
await this.gitPass();
|
|
1070
|
+
if (remote) {
|
|
1071
|
+
await this.checkRemote();
|
|
1072
|
+
await this.syncRealtime();
|
|
1073
|
+
}
|
|
809
1074
|
await this.enforceLocal();
|
|
810
1075
|
this.lastTick = Date.now();
|
|
811
1076
|
}
|
|
1077
|
+
// ---------- git integration ----------
|
|
1078
|
+
isGitBusy(inst) {
|
|
1079
|
+
if (!inst.gitDir) return false;
|
|
1080
|
+
this.busyCache ??= /* @__PURE__ */ new Map();
|
|
1081
|
+
if (!this.busyCache.has(inst.gitDir)) this.busyCache.set(inst.gitDir, gitBusy(inst.gitDir));
|
|
1082
|
+
return !!this.busyCache.get(inst.gitDir);
|
|
1083
|
+
}
|
|
1084
|
+
gitInstances() {
|
|
1085
|
+
return this.db.all().filter((i) => i.gitMode && i.gitRoot && i.mode !== "orphaned");
|
|
1086
|
+
}
|
|
1087
|
+
/** Sets the git mode on an instance (and a folder's files). Throws if follow is not allowed. */
|
|
1088
|
+
applyGitMode(root, mode) {
|
|
1089
|
+
if (!root) return;
|
|
1090
|
+
if (!GIT_MODES.includes(mode)) throw new UserError(`git mode must be one of ${GIT_MODES.join(", ")}`);
|
|
1091
|
+
if (mode === "follow" && root.role === "viewer") throw new UserError("git follow mode needs editor access");
|
|
1092
|
+
if (mode === "follow" && !gitBinary()) throw new UserError("git follow mode needs git to be installed");
|
|
1093
|
+
const repo = findRepo(root.path);
|
|
1094
|
+
if (!repo) throw new UserError(`${root.path} is not inside a git repository`);
|
|
1095
|
+
const fields = { gitMode: mode, gitRoot: repo.root, gitDir: repo.gitDir, gitWaiting: false };
|
|
1096
|
+
for (const i of [root, ...root.kind === "folder" ? this.db.childrenOf(root.instanceId) : []]) {
|
|
1097
|
+
this.db.update(i.instanceId, fields);
|
|
1098
|
+
if (i.kind === "file") setKeep(repo.root, i.path, mode === "keep");
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
/** Lets git see the file normally again (after unmanage, orphaning, or leaving keep mode). */
|
|
1102
|
+
releaseGit(inst) {
|
|
1103
|
+
if (inst.gitMode === "keep" && inst.kind === "file" && inst.gitRoot) setKeep(inst.gitRoot, inst.path, false);
|
|
1104
|
+
}
|
|
1105
|
+
/** `tether git <path> [--mode]`: show or change the git mode of managed files. */
|
|
1106
|
+
gitMode({ path: p, mode }) {
|
|
1107
|
+
const ts = this.targets(p);
|
|
1108
|
+
const folders = new Set(ts.filter((i) => i.kind === "folder").map((i) => i.instanceId));
|
|
1109
|
+
if (mode) for (const i of ts.filter((x) => !x.folderInstanceId || !folders.has(x.folderInstanceId))) this.applyGitMode(i.folderInstanceId ? this.db.get(i.folderInstanceId) : i, mode);
|
|
1110
|
+
return this.targets(p).map((i) => ({ path: i.path, kind: i.kind, role: i.role, gitMode: this.db.get(i.instanceId)?.gitMode ?? null, gitRoot: this.db.get(i.instanceId)?.gitRoot ?? findRepo(i.path)?.root ?? null }));
|
|
1111
|
+
}
|
|
1112
|
+
/** Tether 0.2.0 used git hooks; they are no longer needed. */
|
|
1113
|
+
cleanupLegacyHooks() {
|
|
1114
|
+
const dirs = this.db.getMeta("gitHooks") ?? [];
|
|
1115
|
+
for (const dir of dirs) if (this.home) removeHooks(dir, this.home);
|
|
1116
|
+
if (dirs.length) this.db.setMeta("gitHooks", null);
|
|
1117
|
+
}
|
|
1118
|
+
/** Managed files that ended up inside a git repository later (git init, moved into one) get keep mode. */
|
|
1119
|
+
detectNewRepos() {
|
|
1120
|
+
for (const i of this.db.all().filter((x) => !x.gitMode && !x.folderInstanceId && x.mode !== "orphaned" && !x.detached)) {
|
|
1121
|
+
const repo = findRepo(i.path);
|
|
1122
|
+
if (!repo) continue;
|
|
1123
|
+
this.applyGitMode(i, "keep");
|
|
1124
|
+
this.log(`${i.path}: now inside the git repository ${repo.root}; using keep mode`);
|
|
1125
|
+
this.notify({ kind: "git-new", level: "info", short: path4.basename(i.path), title: "Managed file is now in a git repository", body: `${path4.basename(i.path)} is inside ${repo.root}. Tether keeps it as the shared version and git ignores Tether's copy. Run "tether git ${path4.basename(i.path)} --mode follow" to publish git's changes instead.` });
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
/**
|
|
1129
|
+
* Runs after git may have changed files (watcher on the git directory, and every tick):
|
|
1130
|
+
* follows renames git made, brings back files git restored, keeps keep-mode files hidden from
|
|
1131
|
+
* git, and publishes follow-mode files whose content now matches git's committed or staged state.
|
|
1132
|
+
*/
|
|
1133
|
+
async gitPass({ only } = {}) {
|
|
1134
|
+
const repos = /* @__PURE__ */ new Map();
|
|
1135
|
+
for (const i of this.gitInstances()) if (!only || samePath(i.gitRoot, only)) repos.set(i.gitDir, i.gitRoot);
|
|
1136
|
+
if (!repos.size) return;
|
|
1137
|
+
const heads = this.db.getMeta("gitHeads") ?? {};
|
|
1138
|
+
const publish = [];
|
|
1139
|
+
for (const [gitDir, root] of repos) {
|
|
1140
|
+
if (gitBusy(gitDir)) continue;
|
|
1141
|
+
const h = gitBinary() ? head(root) : null;
|
|
1142
|
+
const prev = heads[gitDir];
|
|
1143
|
+
const headMoved = !!h && h !== prev;
|
|
1144
|
+
if (h && prev && h !== prev) await this.followGitHistory(root, prev, h);
|
|
1145
|
+
if (h) heads[gitDir] = h;
|
|
1146
|
+
for (const i of this.gitInstances().filter((x) => x.gitDir === gitDir)) {
|
|
1147
|
+
if (i.kind !== "file") continue;
|
|
1148
|
+
if (i.detached) {
|
|
1149
|
+
if (statOrNull(i.path)?.isFile()) {
|
|
1150
|
+
this.db.update(i.instanceId, { ...this.identity(i.path), detached: false, missingSince: null, size: null, mtimeMs: null });
|
|
1151
|
+
if (i.gitMode === "keep") setKeep(root, i.path, true);
|
|
1152
|
+
this.log(`${i.path}: back (restored by git)`);
|
|
1153
|
+
}
|
|
1154
|
+
continue;
|
|
1155
|
+
}
|
|
1156
|
+
if (i.mode !== "normal") continue;
|
|
1157
|
+
if (i.gitMode === "keep") {
|
|
1158
|
+
if ((headMoved || !prev) && gitBinary() && !isKept(root, i.path)) setKeep(root, i.path, true);
|
|
1159
|
+
continue;
|
|
1160
|
+
}
|
|
1161
|
+
if (!gitBinary()) continue;
|
|
1162
|
+
let local;
|
|
1163
|
+
try {
|
|
1164
|
+
local = readLocal(i.path, i.eolMode);
|
|
1165
|
+
} catch {
|
|
1166
|
+
continue;
|
|
1167
|
+
}
|
|
1168
|
+
if (i.gitOverlay) {
|
|
1169
|
+
if (fileBlob(root, i.path) !== blobAt(root, "HEAD", i.path)) continue;
|
|
1170
|
+
setKeep(root, i.path, false);
|
|
1171
|
+
this.db.update(i.instanceId, { gitOverlay: false });
|
|
1172
|
+
i.gitOverlay = false;
|
|
1173
|
+
}
|
|
1174
|
+
const folder = i.folderInstanceId ? this.folderOf(i) : null;
|
|
1175
|
+
const rel = folder ? this.localRel(i, folder) : null;
|
|
1176
|
+
const moved = folder && rel && rel !== i.relPath;
|
|
1177
|
+
const changed = local && local.hash !== i.baseHash;
|
|
1178
|
+
if (!changed && !moved) continue;
|
|
1179
|
+
const { fromGit, commit } = changed ? changeFromGit(root, i.path) : { fromGit: true, commit: null };
|
|
1180
|
+
if (fromGit) publish.push({ inst: i, local: changed ? local : null, commit, relPath: moved ? rel : void 0 });
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
this.db.setMeta("gitHeads", heads);
|
|
1184
|
+
if (publish.length) await this.publishGitChanges(publish);
|
|
1185
|
+
}
|
|
1186
|
+
/** Renames and new files git brought in between two commits. */
|
|
1187
|
+
async followGitHistory(root, from, to) {
|
|
1188
|
+
for (const c of changesBetween(root, from, to)) {
|
|
1189
|
+
if (c.status === "R") {
|
|
1190
|
+
const inst = this.gitInstances().find((i) => i.kind === "file" && samePath(i.path, c.from));
|
|
1191
|
+
if (inst && !statOrNull(c.from) && statOrNull(c.to)?.isFile()) {
|
|
1192
|
+
this.db.update(inst.instanceId, { path: canonicalPath(c.to), ...this.identity(c.to), detached: false, missingSince: null, size: null, mtimeMs: null });
|
|
1193
|
+
if (inst.gitMode === "keep") {
|
|
1194
|
+
setKeep(root, c.from, false);
|
|
1195
|
+
setKeep(root, c.to, true);
|
|
1196
|
+
}
|
|
1197
|
+
this.log(`${c.from} -> ${c.to}: followed a rename made in git`);
|
|
1198
|
+
}
|
|
1199
|
+
} else if (c.status === "A" && !this.instanceAt(c.to) && statOrNull(c.to)?.isFile()) {
|
|
1200
|
+
const folder = this.gitInstances().find((i) => i.kind === "folder" && i.gitMode === "follow" && isUnder(c.to, i.path) && !samePath(c.to, i.path));
|
|
1201
|
+
if (!folder) continue;
|
|
1202
|
+
const name = path4.basename(c.to);
|
|
1203
|
+
if (folder.role === "owner") {
|
|
1204
|
+
try {
|
|
1205
|
+
const r = await this.add({ path: c.to, gitCommit: to });
|
|
1206
|
+
this.log(`${c.to}: added to ${folder.path} (new in git)`);
|
|
1207
|
+
this.notify({ kind: "git-published", level: "info", short: name, title: "Published git's changes", body: `${r.relPath} is new in git and was added to ${path4.basename(folder.path)} for everyone.` });
|
|
1208
|
+
} catch (e) {
|
|
1209
|
+
this.log(`${c.to}: could not add (${e.message})`);
|
|
1210
|
+
}
|
|
1211
|
+
} else {
|
|
1212
|
+
this.notify({ kind: "git-new-file", level: "info", short: name, title: "New file in a managed folder", body: `git added ${name} inside ${path4.basename(folder.path)}. Only the owner can add files to it, so it is not shared through Tether.` });
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
/**
|
|
1218
|
+
* Publishes git's version of follow-mode files. If Tether already has exactly that content it is
|
|
1219
|
+
* adopted; if Tether moved on to something else, the file stays in editing mode for `tether done`.
|
|
1220
|
+
*/
|
|
1221
|
+
async publishGitChanges(list) {
|
|
1222
|
+
let results;
|
|
1223
|
+
try {
|
|
1224
|
+
({ results } = await this.api.call("POST", "/sync/check", { items: list.map(({ inst }) => ({ artifactId: inst.artifactId, version: inst.baseVersion })) }));
|
|
1225
|
+
} catch (e) {
|
|
1226
|
+
if (!(e instanceof OfflineError)) throw e;
|
|
1227
|
+
for (const { inst } of list) this.db.update(inst.instanceId, { mode: "editing", lastError: "git changed this file while offline; run `tether done` to publish it" });
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
for (const [k, { inst, local, commit, relPath }] of list.entries()) {
|
|
1231
|
+
const r = results[k];
|
|
1232
|
+
const name = path4.basename(inst.path);
|
|
1233
|
+
const hash = local?.hash ?? inst.baseHash;
|
|
1234
|
+
if (r.status === "gone" || r.state?.deleted || r.folderDeleted) {
|
|
1235
|
+
this.orphan(inst, r.reason ?? "deleted");
|
|
1236
|
+
continue;
|
|
1237
|
+
}
|
|
1238
|
+
if (r.role === "viewer") {
|
|
1239
|
+
this.db.update(inst.instanceId, { gitMode: "keep", role: "viewer" });
|
|
1240
|
+
setKeep(inst.gitRoot, inst.path, true);
|
|
1241
|
+
continue;
|
|
1242
|
+
}
|
|
1243
|
+
const conflict = () => {
|
|
1244
|
+
this.db.update(inst.instanceId, { mode: "editing", lastError: "conflict: git changed this file and Tether has a newer version; run `tether done` to resolve" });
|
|
1245
|
+
this.log(`${inst.path}: git changed it but Tether also has a newer version; left in editing mode`);
|
|
1246
|
+
this.notify({ kind: "conflict", level: "warn", title: "Merge conflict (git and Tether)", body: `${name}: git changed it, but someone also pushed a newer version to Tether. Git's version is kept and the file is in editing mode; run "tether done" to choose.`, short: name, artifactId: inst.artifactId });
|
|
1247
|
+
};
|
|
1248
|
+
const adopt = (state) => {
|
|
1249
|
+
this.db.update(inst.instanceId, { baseVersion: state.version, remoteVersion: state.version, baseHash: state.blob, relPath: state.relPath ?? inst.relPath, gitWaiting: false, lastError: null, ...local ? { size: local.size, mtimeMs: local.mtimeMs, ...styleOf(local) } : {} });
|
|
1250
|
+
this.log(`${inst.path}: git's version is already on Tether (v${state.version})`);
|
|
1251
|
+
};
|
|
1252
|
+
if (r.status === "changed") {
|
|
1253
|
+
if (r.state.blob === hash && (relPath === void 0 || r.state.relPath === relPath)) adopt(r.state);
|
|
1254
|
+
else conflict();
|
|
1255
|
+
continue;
|
|
1256
|
+
}
|
|
1257
|
+
try {
|
|
1258
|
+
const change = { gitCommit: commit ?? null };
|
|
1259
|
+
if (local) Object.assign(change, await this.uploadContent(local, inst.artifactId));
|
|
1260
|
+
if (relPath !== void 0) change.relPath = relPath;
|
|
1261
|
+
const c = await this.api.call("POST", `/artifacts/${inst.artifactId}/commit`, { baseVersion: inst.baseVersion, change });
|
|
1262
|
+
this.db.update(inst.instanceId, {
|
|
1263
|
+
baseVersion: c.version,
|
|
1264
|
+
remoteVersion: c.version,
|
|
1265
|
+
baseHash: hash,
|
|
1266
|
+
relPath: relPath ?? inst.relPath,
|
|
1267
|
+
gitWaiting: false,
|
|
1268
|
+
lastError: null,
|
|
1269
|
+
...local ? { size: local.size, mtimeMs: local.mtimeMs, ...styleOf(local) } : {}
|
|
1270
|
+
});
|
|
1271
|
+
const sent = local ? describeTransfer(this.api.transfers.get(local.hash), "sent") : "";
|
|
1272
|
+
this.log(`${inst.path}: published git's version as v${c.version}${sent ? ` (${sent})` : ""}`);
|
|
1273
|
+
this.notify({ kind: "git-published", level: "info", title: "Published git's changes", body: `${name} is now v${c.version} for everyone who has it.`, short: name, artifactId: inst.artifactId });
|
|
1274
|
+
} catch (e) {
|
|
1275
|
+
if (e instanceof ApiError && e.status === 409) conflict();
|
|
1276
|
+
else if (e instanceof ApiError) {
|
|
1277
|
+
this.db.update(inst.instanceId, { mode: "editing", lastError: `could not publish git's version: ${e.message}` });
|
|
1278
|
+
this.notify({ kind: "push-failed", level: "error", title: "Push failed", body: `${name}: ${e.message}. Git's version is kept in editing mode.`, short: name, artifactId: inst.artifactId });
|
|
1279
|
+
} else throw e;
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
/** Hook calls from Tether 0.2.0 installs, and explicit syncs. */
|
|
1284
|
+
async gitReconcile(root) {
|
|
1285
|
+
await this.locateAll();
|
|
1286
|
+
await this.gitPass({ only: root });
|
|
1287
|
+
}
|
|
1288
|
+
// ---------- realtime editing ----------
|
|
1289
|
+
/** Files this client is editing live (every save is pushed). */
|
|
1290
|
+
realtimeInstances() {
|
|
1291
|
+
return this.db.all().filter((i) => i.realtime && i.mode === "editing" && i.kind === "file");
|
|
1292
|
+
}
|
|
1293
|
+
/** True while anything held here is being edited live, so the daemon checks more often. */
|
|
1294
|
+
get realtimeActive() {
|
|
1295
|
+
return this.db.all().some((i) => i.realtime && i.mode === "editing" || i.remoteRealtime && i.mode !== "orphaned");
|
|
1296
|
+
}
|
|
1297
|
+
async releaseRealtime(inst) {
|
|
1298
|
+
this.db.update(inst.instanceId, { realtime: false });
|
|
1299
|
+
await this.api.call("POST", `/artifacts/${inst.artifactId}/realtime`, { action: "stop" }).catch(() => {
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
/**
|
|
1303
|
+
* Pushes live edits: renews the session, applies incoming versions when there is no local
|
|
1304
|
+
* change, pushes local changes when the server has not moved, and pauses on a conflict.
|
|
1305
|
+
*/
|
|
1306
|
+
async syncRealtime() {
|
|
1307
|
+
const rts = this.realtimeInstances().filter((i) => !i.detached);
|
|
1308
|
+
if (!rts.length) return;
|
|
1309
|
+
let results;
|
|
1310
|
+
try {
|
|
1311
|
+
({ results } = await this.api.call("POST", "/sync/check", { items: rts.map((i) => ({ artifactId: i.artifactId, version: i.baseVersion, realtime: true })) }));
|
|
1312
|
+
} catch (e) {
|
|
1313
|
+
if (e instanceof OfflineError) return this.offline = e.message;
|
|
1314
|
+
throw e;
|
|
1315
|
+
}
|
|
1316
|
+
for (const [k, inst] of rts.entries()) {
|
|
1317
|
+
const r = results[k];
|
|
1318
|
+
const cur = this.db.get(inst.instanceId);
|
|
1319
|
+
if (!cur || cur.mode !== "editing" || !cur.realtime) continue;
|
|
1320
|
+
if (r.status === "gone" || r.state?.deleted || r.folderDeleted) {
|
|
1321
|
+
this.orphan(cur, r.reason ?? "deleted");
|
|
1322
|
+
continue;
|
|
1323
|
+
}
|
|
1324
|
+
if (!r.realtime?.mine) {
|
|
1325
|
+
const why = r.role === "viewer" ? "you are no longer an editor" : r.realtime ? `taken over by ${r.realtime.email}` : "it ended";
|
|
1326
|
+
this.db.update(cur.instanceId, { realtime: false, lastError: r.role === "viewer" ? null : `realtime session lost (${why}); still editing, run \`tether done\` to push` });
|
|
1327
|
+
this.log(`${cur.path}: realtime session lost (${why})`);
|
|
1328
|
+
this.notify({ kind: "realtime-lost", level: "warn", title: "Live editing stopped", body: `${path4.basename(cur.path)}: your realtime session ${why}. Your changes are kept; run "tether done" to push them.`, short: path4.basename(cur.path), artifactId: cur.artifactId });
|
|
1329
|
+
continue;
|
|
1330
|
+
}
|
|
1331
|
+
try {
|
|
1332
|
+
const local = readLocal(cur.path, cur.eolMode);
|
|
1333
|
+
if (r.status === "changed") {
|
|
1334
|
+
if (!local || local.hash === cur.baseHash) {
|
|
1335
|
+
await this.ensureBlobs([{ artifactId: cur.artifactId, version: r.state.version, sha: r.state.blob, base: cur.baseHash }]);
|
|
1336
|
+
this.applyIncoming(cur, r.state);
|
|
1337
|
+
} else {
|
|
1338
|
+
if (!cur.lastError?.startsWith("conflict")) {
|
|
1339
|
+
this.log(`${cur.path}: someone else pushed while you were editing live; live pushing paused, run \`tether done\` to resolve`);
|
|
1340
|
+
this.notify({ kind: "conflict", level: "warn", title: "Merge conflict", body: `${path4.basename(cur.path)}: someone else pushed while you were editing live. Live pushing is paused; run "tether done" to choose what to keep.`, short: path4.basename(cur.path), artifactId: cur.artifactId });
|
|
1341
|
+
}
|
|
1342
|
+
this.db.update(cur.instanceId, { lastError: "conflict: the server changed; run `tether done` to resolve" });
|
|
1343
|
+
}
|
|
1344
|
+
continue;
|
|
1345
|
+
}
|
|
1346
|
+
if (!local || local.hash === cur.baseHash) continue;
|
|
1347
|
+
const content = await this.uploadContent(local, cur.artifactId);
|
|
1348
|
+
try {
|
|
1349
|
+
const c = await this.api.call("POST", `/artifacts/${cur.artifactId}/commit`, { baseVersion: cur.baseVersion, change: content });
|
|
1350
|
+
this.db.update(cur.instanceId, { baseVersion: c.version, remoteVersion: c.version, baseHash: local.hash, size: local.size, mtimeMs: local.mtimeMs, ...styleOf(local), lastError: null });
|
|
1351
|
+
this.log(`${cur.path}: pushed v${c.version} (realtime${this.sentNote(local.hash)})`);
|
|
1352
|
+
} catch (e) {
|
|
1353
|
+
if (!(e instanceof ApiError && e.status === 409)) throw e;
|
|
1354
|
+
this.db.update(cur.instanceId, { lastError: "conflict: the server changed; run `tether done` to resolve" });
|
|
1355
|
+
}
|
|
1356
|
+
} catch (e) {
|
|
1357
|
+
if (!(e instanceof LockedError)) throw e;
|
|
1358
|
+
this.db.update(cur.instanceId, { lastError: e.message });
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
812
1362
|
/** Refresh path and file ID of every tracked instance (DESIGN §5). */
|
|
813
1363
|
async locateAll() {
|
|
814
1364
|
const graceMs = this.config.get("graceMs");
|
|
@@ -871,7 +1421,10 @@ var Engine = class {
|
|
|
871
1421
|
this.log(`${inst.path}: folder deleted locally; no longer managed here`);
|
|
872
1422
|
return;
|
|
873
1423
|
}
|
|
874
|
-
if (inst.mode === "
|
|
1424
|
+
if (inst.gitMode && inst.mode === "normal") {
|
|
1425
|
+
this.db.update(inst.instanceId, { detached: true, missingSince: null });
|
|
1426
|
+
this.log(`${inst.path}: removed (by git?); waiting for git to bring it back`);
|
|
1427
|
+
} else if (inst.mode === "editing") {
|
|
875
1428
|
this.db.update(inst.instanceId, { detached: true, missingSince: null });
|
|
876
1429
|
this.log(`${inst.path}: deleted while editing; resolve with \`tether done\``);
|
|
877
1430
|
} else if (inst.folderInstanceId) {
|
|
@@ -884,8 +1437,13 @@ var Engine = class {
|
|
|
884
1437
|
}
|
|
885
1438
|
orphan(inst, reason) {
|
|
886
1439
|
const targets = inst.kind === "folder" ? [inst, ...this.db.childrenOf(inst.instanceId)] : [inst];
|
|
887
|
-
for (const t of targets)
|
|
888
|
-
|
|
1440
|
+
for (const t of targets) {
|
|
1441
|
+
this.releaseGit(t);
|
|
1442
|
+
this.db.update(t.instanceId, { mode: "orphaned", orphanReason: reason });
|
|
1443
|
+
}
|
|
1444
|
+
const what = reason === "revoked" ? "access revoked" : reason === "deleted" ? "deleted on the server" : "removed from the server";
|
|
1445
|
+
this.log(`${inst.path}: ${what}; file kept, no longer tracked`);
|
|
1446
|
+
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) });
|
|
889
1447
|
}
|
|
890
1448
|
/** One batched call for all instances (DESIGN §6.1 step 2). */
|
|
891
1449
|
async checkRemote() {
|
|
@@ -901,6 +1459,7 @@ var Engine = class {
|
|
|
901
1459
|
} catch (e) {
|
|
902
1460
|
if (e instanceof OfflineError || e instanceof ApiError && e.status === 401) {
|
|
903
1461
|
this.offline = e.message;
|
|
1462
|
+
if (e instanceof ApiError) this.notify({ kind: "auth", level: "error", key: "auth", cooldownMs: 6 * 3600 * 1e3, title: "Tether needs you to sign in", body: `${e.message}. Until then nothing syncs.` });
|
|
904
1463
|
return;
|
|
905
1464
|
}
|
|
906
1465
|
throw e;
|
|
@@ -913,10 +1472,21 @@ var Engine = class {
|
|
|
913
1472
|
if (!cur || cur.mode === "orphaned") return;
|
|
914
1473
|
if (r.status === "gone") return this.orphan(cur, r.reason);
|
|
915
1474
|
if (r.folderDeleted || r.state?.deleted) return this.orphan(cur, "deleted");
|
|
916
|
-
const patch = {
|
|
1475
|
+
const patch = {
|
|
1476
|
+
role: r.role,
|
|
1477
|
+
canDelete: r.canDelete,
|
|
1478
|
+
remoteVersion: r.version,
|
|
1479
|
+
remoteRealtime: r.realtime && !r.realtime.mine ? JSON.stringify({ email: r.realtime.email, since: r.realtime.since }) : null
|
|
1480
|
+
};
|
|
917
1481
|
if (cur.role && cur.role !== "viewer" && r.role === "viewer" && cur.mode === "editing") {
|
|
918
1482
|
patch.mode = "normal";
|
|
1483
|
+
patch.realtime = false;
|
|
919
1484
|
this.log(`${cur.path}: you are now a viewer; editing ended and local changes will be reverted`);
|
|
1485
|
+
this.notify({ kind: "demoted", level: "warn", title: "You are now a viewer", body: `${path4.basename(cur.path)}: your editor access was removed, so your unpushed changes are being reverted.`, short: path4.basename(cur.path), artifactId: cur.artifactId });
|
|
1486
|
+
}
|
|
1487
|
+
if (cur.gitMode === "follow" && r.role === "viewer") {
|
|
1488
|
+
patch.gitMode = "keep";
|
|
1489
|
+
this.notify({ kind: "demoted", level: "warn", title: "Git follow mode turned off", body: `${path4.basename(cur.path)}: you are no longer an editor, so git's changes to it will be reverted.`, short: path4.basename(cur.path), artifactId: cur.artifactId });
|
|
920
1490
|
}
|
|
921
1491
|
const now = this.db.update(cur.instanceId, patch);
|
|
922
1492
|
if (r.status !== "changed") return;
|
|
@@ -925,20 +1495,36 @@ var Engine = class {
|
|
|
925
1495
|
else if (!now.detached) incoming.push({ inst: now, state: r.state });
|
|
926
1496
|
});
|
|
927
1497
|
const newChildren = [];
|
|
1498
|
+
const waiting = /* @__PURE__ */ new Set();
|
|
928
1499
|
for (const f of folders) {
|
|
929
1500
|
const have = new Set(this.db.childrenOf(f.inst.instanceId).map((c) => c.artifactId));
|
|
930
|
-
for (const c of f.children)
|
|
1501
|
+
for (const c of f.children) {
|
|
1502
|
+
if (c.deleted || have.has(c.artifactId)) continue;
|
|
1503
|
+
if (f.inst.gitMode === "follow" && c.gitCommit && !isAncestor(f.inst.gitRoot, c.gitCommit)) {
|
|
1504
|
+
waiting.add(f.inst.instanceId);
|
|
1505
|
+
continue;
|
|
1506
|
+
}
|
|
1507
|
+
newChildren.push({ folder: f.inst, child: c });
|
|
1508
|
+
}
|
|
931
1509
|
}
|
|
932
1510
|
try {
|
|
933
1511
|
await this.ensureBlobs([
|
|
934
|
-
...incoming.map(({ state }) => ({ artifactId: state.artifactId, version: state.version, sha: state.blob })),
|
|
1512
|
+
...incoming.map(({ inst, state }) => ({ artifactId: state.artifactId, version: state.version, sha: state.blob, base: inst.baseHash })),
|
|
935
1513
|
...newChildren.map(({ child }) => ({ artifactId: child.artifactId, version: child.version, sha: child.blob }))
|
|
936
1514
|
]);
|
|
937
1515
|
} catch (e) {
|
|
938
1516
|
if (e instanceof OfflineError) return this.offline = e.message;
|
|
939
1517
|
throw e;
|
|
940
1518
|
}
|
|
941
|
-
for (const { inst, state } of incoming)
|
|
1519
|
+
for (const { inst, state } of incoming) {
|
|
1520
|
+
if (this.isGitBusy(inst)) continue;
|
|
1521
|
+
if (inst.gitMode === "follow" && state.gitCommit && !isAncestor(inst.gitRoot, state.gitCommit)) {
|
|
1522
|
+
if (!inst.gitWaiting) this.log(`${inst.path}: v${state.version} came from git commit ${state.gitCommit.slice(0, 8)}; it will arrive with your next git pull`);
|
|
1523
|
+
this.db.update(inst.instanceId, { gitWaiting: true });
|
|
1524
|
+
continue;
|
|
1525
|
+
}
|
|
1526
|
+
this.applyIncoming(inst, state);
|
|
1527
|
+
}
|
|
942
1528
|
for (const { folder, child } of newChildren) {
|
|
943
1529
|
this.db.insert({
|
|
944
1530
|
instanceId: newInstanceId(),
|
|
@@ -960,10 +1546,11 @@ var Engine = class {
|
|
|
960
1546
|
});
|
|
961
1547
|
this.log(`${folder.path}: new file ${child.relPath}`);
|
|
962
1548
|
}
|
|
963
|
-
for (const f of folders) this.db.update(f.inst.instanceId, { baseVersion: f.state.version });
|
|
1549
|
+
for (const f of folders) if (!waiting.has(f.inst.instanceId)) this.db.update(f.inst.instanceId, { baseVersion: f.state.version });
|
|
964
1550
|
}
|
|
965
1551
|
/** Normal mode: write an incoming version (content and/or layout). */
|
|
966
1552
|
applyIncoming(inst, state) {
|
|
1553
|
+
if (!statOrNull(inst.path)) return;
|
|
967
1554
|
try {
|
|
968
1555
|
let p = inst.path;
|
|
969
1556
|
if (inst.folderInstanceId && state.relPath !== inst.relPath) {
|
|
@@ -975,17 +1562,40 @@ var Engine = class {
|
|
|
975
1562
|
p = to;
|
|
976
1563
|
this.log(`${inst.path} -> ${to}: moved by the server`);
|
|
977
1564
|
}
|
|
978
|
-
const patch = { path: p, relPath: state.relPath, baseVersion: state.version, baseHash: state.blob, eolMode: state.eol, exec: state.exec, lastError: null };
|
|
979
|
-
|
|
1565
|
+
const patch = { path: p, relPath: state.relPath, baseVersion: state.version, baseHash: state.blob, eolMode: state.eol, exec: state.exec, lastError: null, gitWaiting: false };
|
|
1566
|
+
const localEdit = !this.matchesBase(inst, p) && readLocal(p, inst.eolMode)?.hash !== state.blob;
|
|
1567
|
+
if (state.blob !== inst.baseHash || localEdit) {
|
|
980
1568
|
Object.assign(patch, this.writeInstance(inst, p, state.blob, state.eol, state.exec));
|
|
981
|
-
this.
|
|
1569
|
+
const got = describeTransfer(this.api.transfers.get(state.blob), "received");
|
|
1570
|
+
this.api.transfers.delete(state.blob);
|
|
1571
|
+
this.log(`${p}: updated to version ${state.version}${localEdit ? " (local change replaced)" : ""}${got ? ` (${got})` : ""}`);
|
|
1572
|
+
if (inst.gitMode === "follow" && inst.gitRoot && !state.gitCommit && blobAt(inst.gitRoot, "HEAD", p) !== fileBlob(inst.gitRoot, p)) {
|
|
1573
|
+
setKeep(inst.gitRoot, p, true);
|
|
1574
|
+
patch.gitOverlay = true;
|
|
1575
|
+
this.notify({ kind: "git-overlay", level: "info", short: path4.basename(p), artifactId: inst.artifactId, title: "Changed through Tether", body: `${path4.basename(p)} now has Tether's v${state.version}, which is not in git. Git does not see this change; the next change to the file in git replaces it.` });
|
|
1576
|
+
}
|
|
1577
|
+
if (localEdit) {
|
|
1578
|
+
const name = path4.basename(p);
|
|
1579
|
+
this.notify({
|
|
1580
|
+
kind: "revert",
|
|
1581
|
+
level: "info",
|
|
1582
|
+
short: name,
|
|
1583
|
+
artifactId: inst.artifactId,
|
|
1584
|
+
title: inst.gitMode === "keep" ? "Tether undid a git change" : "Tether undid your change",
|
|
1585
|
+
body: `${name} is managed by Tether, so your local change was replaced with the shared version (v${state.version}). Run "tether edit ${name}" to change it.`
|
|
1586
|
+
});
|
|
1587
|
+
}
|
|
982
1588
|
}
|
|
983
1589
|
this.db.update(inst.instanceId, patch);
|
|
984
1590
|
} catch (e) {
|
|
985
1591
|
if (!(e instanceof LockedError)) throw e;
|
|
986
|
-
this.
|
|
1592
|
+
this.lockedNotice(inst, e);
|
|
987
1593
|
}
|
|
988
1594
|
}
|
|
1595
|
+
lockedNotice(inst, e) {
|
|
1596
|
+
if (!inst.lastError) this.notify({ kind: "locked", level: "warn", key: `locked:${inst.instanceId}`, cooldownMs: 3600 * 1e3, title: "File in use", body: `${path4.basename(inst.path)} is open in another program, so Tether cannot update it yet. It will retry automatically.`, short: path4.basename(inst.path) });
|
|
1597
|
+
this.db.update(inst.instanceId, { lastError: e.message });
|
|
1598
|
+
}
|
|
989
1599
|
/** If `to` is occupied by something that isn't this instance, move it aside (keep both). */
|
|
990
1600
|
makeRoom(to, inst) {
|
|
991
1601
|
const st = statOrNull(to);
|
|
@@ -1014,6 +1624,7 @@ var Engine = class {
|
|
|
1014
1624
|
}
|
|
1015
1625
|
for (const inst of this.db.all()) {
|
|
1016
1626
|
if (inst.mode !== "normal" || inst.kind !== "file" || inst.detached) continue;
|
|
1627
|
+
if (this.isGitBusy(inst)) continue;
|
|
1017
1628
|
try {
|
|
1018
1629
|
if (inst.needsRestore) {
|
|
1019
1630
|
if (this.cache.has(inst.baseHash)) this.restore(inst);
|
|
@@ -1034,9 +1645,18 @@ var Engine = class {
|
|
|
1034
1645
|
if (!this.cache.has(cur.baseHash)) continue;
|
|
1035
1646
|
this.db.update(cur.instanceId, { ...this.writeInstance(cur, cur.path, cur.baseHash), lastError: null });
|
|
1036
1647
|
this.log(`${cur.path}: local change reverted (use \`tether edit\` to change it)`);
|
|
1648
|
+
const byGit = cur.gitMode === "keep";
|
|
1649
|
+
this.notify({
|
|
1650
|
+
kind: "revert",
|
|
1651
|
+
level: "info",
|
|
1652
|
+
short: path4.basename(cur.path),
|
|
1653
|
+
artifactId: cur.artifactId,
|
|
1654
|
+
title: byGit ? "Tether undid a git change" : "Tether undid your change",
|
|
1655
|
+
body: `${path4.basename(cur.path)} is managed by Tether, so it was put back to the shared version. Run "tether edit ${path4.basename(cur.path)}" to change it${byGit ? ', or "tether git <path> --mode follow" to accept git updates' : ""}.`
|
|
1656
|
+
});
|
|
1037
1657
|
} catch (e) {
|
|
1038
1658
|
if (!(e instanceof LockedError)) throw e;
|
|
1039
|
-
this.
|
|
1659
|
+
this.lockedNotice(inst, e);
|
|
1040
1660
|
}
|
|
1041
1661
|
}
|
|
1042
1662
|
}
|
|
@@ -1094,7 +1714,13 @@ var Engine = class {
|
|
|
1094
1714
|
return { abs, bytes: canonical, sha256: sha256(canonical), size: canonical.length, eol: eolMode, exec: process.platform !== "win32" && !!(st.mode & 73), stat: st };
|
|
1095
1715
|
}
|
|
1096
1716
|
/** `tether manage <path> --repo <repo>`: upload; the caller becomes the owner. */
|
|
1097
|
-
|
|
1717
|
+
/** `tether manage`, then apply the git mode chosen for a path inside a git repository. */
|
|
1718
|
+
async manage({ gitMode, ...opts }) {
|
|
1719
|
+
const r = await this.manageCore(opts);
|
|
1720
|
+
if (gitMode) this.applyGitMode(this.instanceAt(r.path), gitMode);
|
|
1721
|
+
return r;
|
|
1722
|
+
}
|
|
1723
|
+
async manageCore({ path: target, repo: repoRef, eol }) {
|
|
1098
1724
|
const abs = canonicalPath(path4.resolve(target));
|
|
1099
1725
|
const st = statOrNull(abs);
|
|
1100
1726
|
if (!st) throw new UserError(`${abs} does not exist`);
|
|
@@ -1107,7 +1733,7 @@ var Engine = class {
|
|
|
1107
1733
|
if (st.isFile()) {
|
|
1108
1734
|
const f = this.prepareFile(abs, name, rules, eol);
|
|
1109
1735
|
await this.api.uploadBlobs([f]);
|
|
1110
|
-
const r2 = await this.api.call("POST", "/artifacts", { repoId: repo.repoId, kind: "file", name, blob: f.sha256, size: f.size, eol: f.eol, exec: f.exec });
|
|
1736
|
+
const r2 = await this.api.call("POST", "/artifacts", { repoId: repo.repoId, kind: "file", name, blob: f.sha256, size: f.size, chunks: this.api.manifestFor(f.sha256), eol: f.eol, exec: f.exec });
|
|
1111
1737
|
this.cache.put(f.sha256, f.bytes);
|
|
1112
1738
|
const local = readLocal(abs, f.eol);
|
|
1113
1739
|
this.db.insert({
|
|
@@ -1146,7 +1772,7 @@ var Engine = class {
|
|
|
1146
1772
|
repoId: repo.repoId,
|
|
1147
1773
|
kind: "folder",
|
|
1148
1774
|
name,
|
|
1149
|
-
files: prepared.map((f) => ({ relPath: f.rel, blob: f.sha256, size: f.size, eol: f.eol, exec: f.exec }))
|
|
1775
|
+
files: prepared.map((f) => ({ relPath: f.rel, blob: f.sha256, size: f.size, chunks: this.api.manifestFor(f.sha256), eol: f.eol, exec: f.exec }))
|
|
1150
1776
|
});
|
|
1151
1777
|
const folderInst = this.db.insert({
|
|
1152
1778
|
instanceId: newInstanceId(),
|
|
@@ -1193,7 +1819,7 @@ var Engine = class {
|
|
|
1193
1819
|
return { artifactId: r.artifact.artifactId, shareId: r.artifact.shareId, kind: "folder", path: abs, files: files2.length, skippedSymlinks: symlinks };
|
|
1194
1820
|
}
|
|
1195
1821
|
/** `tether add <path>`: owner adds a new file inside a managed folder. */
|
|
1196
|
-
async add({ path: target }) {
|
|
1822
|
+
async add({ path: target, gitCommit }) {
|
|
1197
1823
|
const abs = canonicalPath(path4.resolve(target));
|
|
1198
1824
|
if (!statOrNull(abs)?.isFile()) throw new UserError(`${abs} is not a file`);
|
|
1199
1825
|
if (this.instanceAt(abs)) throw new UserError(`${abs} is already managed`);
|
|
@@ -1206,7 +1832,7 @@ var Engine = class {
|
|
|
1206
1832
|
const repo = (await this.api.call("GET", "/repos")).find((r) => r.repoId === folder.repoId);
|
|
1207
1833
|
const f = this.prepareFile(abs, rel, repo?.eolDefaults ?? {});
|
|
1208
1834
|
await this.api.uploadBlobs([f]);
|
|
1209
|
-
const child = await this.api.call("POST", `/artifacts/${folder.artifactId}/children`, { relPath: rel, blob: f.sha256, size: f.size, eol: f.eol, exec: f.exec });
|
|
1835
|
+
const child = await this.api.call("POST", `/artifacts/${folder.artifactId}/children`, { relPath: rel, blob: f.sha256, size: f.size, chunks: this.api.manifestFor(f.sha256), eol: f.eol, exec: f.exec, gitCommit });
|
|
1210
1836
|
this.cache.put(f.sha256, f.bytes);
|
|
1211
1837
|
const local = readLocal(abs, f.eol);
|
|
1212
1838
|
this.db.insert({
|
|
@@ -1234,17 +1860,44 @@ var Engine = class {
|
|
|
1234
1860
|
return { artifactId: child.artifactId, relPath: rel, folder: folder.path };
|
|
1235
1861
|
}
|
|
1236
1862
|
/** `tether pull <shareId> [dest]`. Never overwrites existing user files. */
|
|
1237
|
-
|
|
1863
|
+
/** `tether pull`; with peek, only report where it would go and the caller's role. */
|
|
1864
|
+
async pull({ gitMode, peek, ...opts }) {
|
|
1865
|
+
if (peek) {
|
|
1866
|
+
const r2 = await this.api.call("POST", "/pull", { shareId: opts.shareId, peek: true });
|
|
1867
|
+
return { role: r2.role, kind: r2.artifact.kind, path: this.pullTarget(r2.artifact, opts) };
|
|
1868
|
+
}
|
|
1869
|
+
const r = await this.pullCore(opts, gitMode);
|
|
1870
|
+
if (gitMode) this.applyGitMode(this.instanceAt(r.path), gitMode);
|
|
1871
|
+
return r;
|
|
1872
|
+
}
|
|
1873
|
+
pullTarget(a, { dest, cwd }) {
|
|
1874
|
+
const target = path4.resolve(cwd, dest ?? a.name);
|
|
1875
|
+
return dest && statOrNull(target)?.isDirectory() && a.kind === "file" ? path4.join(target, a.name) : target;
|
|
1876
|
+
}
|
|
1877
|
+
async pullCore({ shareId, dest, cwd }, gitMode) {
|
|
1238
1878
|
const r = await this.api.call("POST", "/pull", { shareId });
|
|
1239
1879
|
const a = r.artifact;
|
|
1240
|
-
|
|
1241
|
-
|
|
1880
|
+
if (gitMode === "follow" && r.role === "viewer") throw new UserError("git follow mode needs editor access; you are a viewer (use --git keep)");
|
|
1881
|
+
const target = this.pullTarget(a, { dest, cwd });
|
|
1242
1882
|
if (this.instanceAt(target)) throw new UserError(`${target} is already a managed copy`);
|
|
1243
1883
|
const base = { repoId: a.repoId, role: r.role, canDelete: r.canDelete };
|
|
1244
1884
|
if (a.kind === "file") {
|
|
1245
1885
|
await this.ensureBlobs([{ artifactId: a.artifactId, version: a.version, sha: a.blob }]);
|
|
1246
|
-
|
|
1247
|
-
|
|
1886
|
+
let existing = readLocal(target, a.eol);
|
|
1887
|
+
let mode = "normal";
|
|
1888
|
+
let lastError = null;
|
|
1889
|
+
let note;
|
|
1890
|
+
if (existing && existing.hash !== a.blob) {
|
|
1891
|
+
const clash = this.gitClash(target, gitMode, a.relPath ?? a.name);
|
|
1892
|
+
if (clash === "replace") {
|
|
1893
|
+
existing = null;
|
|
1894
|
+
note = "replaced git's version with Tether's (git still has it)";
|
|
1895
|
+
} else {
|
|
1896
|
+
mode = "editing";
|
|
1897
|
+
lastError = "git's version differs from Tether's; run `tether done` to publish it or accept Tether's";
|
|
1898
|
+
note = "kept git's version; run `tether done` to publish it or accept Tether's";
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1248
1901
|
const st2 = existing ? { size: existing.size, mtimeMs: existing.mtimeMs, ...styleOf(existing) } : writeLocal(target, this.blobFor(a.blob), a.eol, { exec: a.exec });
|
|
1249
1902
|
this.db.insert({
|
|
1250
1903
|
...base,
|
|
@@ -1254,7 +1907,8 @@ var Engine = class {
|
|
|
1254
1907
|
name: a.name,
|
|
1255
1908
|
path: canonicalPath(target),
|
|
1256
1909
|
...this.identity(target),
|
|
1257
|
-
mode
|
|
1910
|
+
mode,
|
|
1911
|
+
lastError,
|
|
1258
1912
|
baseVersion: a.version,
|
|
1259
1913
|
baseHash: a.blob,
|
|
1260
1914
|
...st2,
|
|
@@ -1262,18 +1916,17 @@ var Engine = class {
|
|
|
1262
1916
|
exec: a.exec,
|
|
1263
1917
|
remoteVersion: a.version
|
|
1264
1918
|
});
|
|
1265
|
-
return { kind: "file", path: canonicalPath(target), role: r.role, version: a.version };
|
|
1919
|
+
return { kind: "file", path: canonicalPath(target), role: r.role, version: a.version, note };
|
|
1266
1920
|
}
|
|
1267
1921
|
const st = statOrNull(target);
|
|
1268
1922
|
if (st && !st.isDirectory()) throw new UserError(`${target} exists and is not a folder`);
|
|
1269
1923
|
await this.ensureBlobs(r.children.map((c) => ({ artifactId: c.artifactId, version: c.version, sha: c.blob })));
|
|
1270
|
-
const clashes =
|
|
1924
|
+
const clashes = /* @__PURE__ */ new Map();
|
|
1271
1925
|
for (const c of r.children) {
|
|
1272
1926
|
const p = path4.join(target, ...c.relPath.split("/"));
|
|
1273
1927
|
const ex = readLocal(p, c.eol);
|
|
1274
|
-
if (ex && ex.hash !== c.blob) clashes.
|
|
1928
|
+
if (ex && ex.hash !== c.blob) clashes.set(c.relPath, this.gitClash(p, gitMode, c.relPath));
|
|
1275
1929
|
}
|
|
1276
|
-
if (clashes.length) throw new UserError(`${target} already has different files: ${clashes.join(", ")}; choose another destination`);
|
|
1277
1930
|
fs6.mkdirSync(target, { recursive: true });
|
|
1278
1931
|
const folderInst = this.db.insert({
|
|
1279
1932
|
...base,
|
|
@@ -1289,7 +1942,8 @@ var Engine = class {
|
|
|
1289
1942
|
});
|
|
1290
1943
|
for (const c of r.children) {
|
|
1291
1944
|
const p = path4.join(folderInst.path, ...c.relPath.split("/"));
|
|
1292
|
-
const
|
|
1945
|
+
const clash = clashes.get(c.relPath);
|
|
1946
|
+
const ex = clash === "replace" ? null : readLocal(p, c.eol);
|
|
1293
1947
|
const cst = ex ? { size: ex.size, mtimeMs: ex.mtimeMs, ...styleOf(ex) } : writeLocal(p, this.blobFor(c.blob), c.eol, { exec: c.exec });
|
|
1294
1948
|
this.db.insert({
|
|
1295
1949
|
...base,
|
|
@@ -1301,10 +1955,11 @@ var Engine = class {
|
|
|
1301
1955
|
relPath: c.relPath,
|
|
1302
1956
|
path: canonicalPath(p),
|
|
1303
1957
|
...this.identity(p),
|
|
1304
|
-
mode: "normal",
|
|
1305
1958
|
baseVersion: c.version,
|
|
1306
1959
|
baseHash: c.blob,
|
|
1307
1960
|
...cst,
|
|
1961
|
+
mode: clash === "keep-git" ? "editing" : "normal",
|
|
1962
|
+
lastError: clash === "keep-git" ? "git's version differs from Tether's; run `tether done`" : null,
|
|
1308
1963
|
eolMode: c.eol,
|
|
1309
1964
|
exec: c.exec,
|
|
1310
1965
|
remoteVersion: c.version
|
|
@@ -1312,6 +1967,21 @@ var Engine = class {
|
|
|
1312
1967
|
}
|
|
1313
1968
|
return { kind: "folder", path: folderInst.path, role: r.role, files: r.children.length, version: a.version };
|
|
1314
1969
|
}
|
|
1970
|
+
/**
|
|
1971
|
+
* A pulled file already exists with different content. Only allowed when git holds that
|
|
1972
|
+
* content safely (tracked, no uncommitted changes): keep mode replaces it with Tether's version,
|
|
1973
|
+
* follow mode keeps git's version in editing mode. Otherwise the pull is refused.
|
|
1974
|
+
*/
|
|
1975
|
+
gitClash(p, gitMode, label) {
|
|
1976
|
+
const repo = gitMode && findRepo(p);
|
|
1977
|
+
if (repo && gitBinary() && isTracked(repo.root, p)) {
|
|
1978
|
+
const blob = fileBlob(repo.root, p);
|
|
1979
|
+
const clean = blob && (blob === blobAt(repo.root, "HEAD", p) || blob === indexBlob(repo.root, p));
|
|
1980
|
+
if (clean) return gitMode === "keep" ? "replace" : "keep-git";
|
|
1981
|
+
throw new UserError(`${label} differs from Tether's version and has uncommitted git changes; commit or stash them first`);
|
|
1982
|
+
}
|
|
1983
|
+
throw new UserError(`${p} already exists with different content; choose another destination`);
|
|
1984
|
+
}
|
|
1315
1985
|
/** Instances addressed by a path: a file, a managed folder (with its files), or any directory. */
|
|
1316
1986
|
targets(p) {
|
|
1317
1987
|
const abs = canonicalPath(path4.resolve(p));
|
|
@@ -1331,13 +2001,55 @@ var Engine = class {
|
|
|
1331
2001
|
}
|
|
1332
2002
|
return changed;
|
|
1333
2003
|
}
|
|
1334
|
-
|
|
2004
|
+
/**
|
|
2005
|
+
* `tether edit [--realtime]`. Realtime claims a live session per file (editors only, one
|
|
2006
|
+
* holder per file). A plain edit warns about files someone else is editing live.
|
|
2007
|
+
*/
|
|
2008
|
+
async edit({ path: p, realtime = false }) {
|
|
1335
2009
|
const ts = this.targets(p);
|
|
1336
2010
|
const unmanaged = ts.filter((i) => i.mode === "unmanaged" && !i.detached);
|
|
1337
2011
|
if (unmanaged.length === ts.length) throw new UserError("these files are unmanaged; use `tether track` first");
|
|
1338
|
-
|
|
2012
|
+
const files2 = ts.filter((i) => i.kind === "file" && ["normal", "editing"].includes(i.mode) && !i.detached);
|
|
2013
|
+
if (realtime) {
|
|
2014
|
+
if (!files2.length) throw new UserError("nothing here to edit");
|
|
2015
|
+
const viewerOf = files2.find((i) => i.role === "viewer");
|
|
2016
|
+
if (viewerOf) throw new UserError(`realtime editing needs editor access; you are a viewer of ${viewerOf.path}`);
|
|
2017
|
+
const claimed = [];
|
|
2018
|
+
for (const f of files2) {
|
|
2019
|
+
try {
|
|
2020
|
+
await this.api.call("POST", `/artifacts/${f.artifactId}/realtime`, { action: "start" });
|
|
2021
|
+
claimed.push(f);
|
|
2022
|
+
} catch (e) {
|
|
2023
|
+
for (const c of claimed) await this.releaseRealtime(c);
|
|
2024
|
+
if (e instanceof ApiError && e.status === 409 && e.body?.realtime) {
|
|
2025
|
+
const s = e.body.realtime;
|
|
2026
|
+
throw new UserError(`${f.path} is already being edited in realtime by ${s.email} (since ${new Date(s.since).toLocaleTimeString()}).
|
|
2027
|
+
You can still run \`tether edit\` without --realtime, but expect merge conflicts.`);
|
|
2028
|
+
}
|
|
2029
|
+
throw e;
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
this.setMode(p, "editing", ["normal", "editing"]);
|
|
2033
|
+
for (const f of files2) this.db.update(f.instanceId, { mode: "editing", realtime: true, lastError: null });
|
|
2034
|
+
return { changed: files2.map((f) => f.path), realtime: true };
|
|
2035
|
+
}
|
|
2036
|
+
const changed = this.setMode(p, "editing", ["normal"]);
|
|
2037
|
+
const warnings = [];
|
|
2038
|
+
if (files2.length) {
|
|
2039
|
+
try {
|
|
2040
|
+
const { results } = await this.api.call("POST", "/sync/check", { items: files2.map((i) => ({ artifactId: i.artifactId, version: i.baseVersion })) });
|
|
2041
|
+
results.forEach((r, k) => {
|
|
2042
|
+
if (r.realtime && !r.realtime.mine) warnings.push({ path: files2[k].path, email: r.realtime.email, since: r.realtime.since });
|
|
2043
|
+
});
|
|
2044
|
+
} catch (e) {
|
|
2045
|
+
if (!(e instanceof OfflineError)) throw e;
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
return { changed, warnings };
|
|
1339
2049
|
}
|
|
1340
|
-
unmanage({ path: p }) {
|
|
2050
|
+
async unmanage({ path: p }) {
|
|
2051
|
+
for (const i of this.targets(p).filter((x) => x.realtime)) await this.releaseRealtime(i);
|
|
2052
|
+
for (const i of this.targets(p)) this.releaseGit(i);
|
|
1341
2053
|
return { changed: this.setMode(p, "unmanaged", ["normal", "editing"]) };
|
|
1342
2054
|
}
|
|
1343
2055
|
/**
|
|
@@ -1420,6 +2132,7 @@ var Engine = class {
|
|
|
1420
2132
|
for (const f of this.targets(p).filter((i) => i.kind === "folder" && i.mode === from)) {
|
|
1421
2133
|
if (!this.db.childrenOf(f.instanceId).some((c) => c.mode === from && !c.detached)) this.db.update(f.instanceId, { mode: "normal" });
|
|
1422
2134
|
}
|
|
2135
|
+
for (const i of this.db.all().filter((x) => x.realtime && x.mode !== "editing")) await this.releaseRealtime(i);
|
|
1423
2136
|
await this.tick();
|
|
1424
2137
|
return { results: out };
|
|
1425
2138
|
}
|
|
@@ -1464,16 +2177,16 @@ var Engine = class {
|
|
|
1464
2177
|
case "push": {
|
|
1465
2178
|
const local = readLocal(inst.path, inst.eolMode);
|
|
1466
2179
|
const change = {};
|
|
1467
|
-
if (local && local.hash !== inst.baseHash)
|
|
1468
|
-
await this.api.uploadBlobs([{ sha256: local.hash, bytes: local.canonical }]);
|
|
1469
|
-
this.cache.put(local.hash, local.canonical);
|
|
1470
|
-
Object.assign(change, { blob: local.hash, size: local.canonical.length });
|
|
1471
|
-
}
|
|
2180
|
+
if (local && local.hash !== inst.baseHash) Object.assign(change, await this.uploadContent(local, inst.artifactId));
|
|
1472
2181
|
if (folder) {
|
|
1473
2182
|
const rel = this.localRel(inst, folder);
|
|
1474
2183
|
if (rel && rel !== inst.relPath) change.relPath = rel;
|
|
1475
2184
|
}
|
|
1476
2185
|
const baseVersion = item.incoming ? item.serverVersion : inst.baseVersion;
|
|
2186
|
+
if (inst.gitMode === "follow" && inst.gitRoot && change.blob) {
|
|
2187
|
+
const g = changeFromGit(inst.gitRoot, inst.path);
|
|
2188
|
+
if (g.fromGit && g.commit) change.gitCommit = g.commit;
|
|
2189
|
+
}
|
|
1477
2190
|
const r = await this.api.call("POST", `/artifacts/${inst.artifactId}/commit`, { baseVersion, change });
|
|
1478
2191
|
const state = r.state;
|
|
1479
2192
|
this.db.update(inst.instanceId, {
|
|
@@ -1486,7 +2199,7 @@ var Engine = class {
|
|
|
1486
2199
|
mtimeMs: local?.mtimeMs ?? null,
|
|
1487
2200
|
...local ? styleOf(local) : {}
|
|
1488
2201
|
});
|
|
1489
|
-
return { version: r.version };
|
|
2202
|
+
return { version: r.version, sent: change.blob ? describeTransfer(this.api.transfers.get(change.blob), "sent") : "" };
|
|
1490
2203
|
}
|
|
1491
2204
|
case "delete": {
|
|
1492
2205
|
await this.api.call("POST", `/artifacts/${inst.artifactId}/commit`, { baseVersion: item.serverVersion, change: { deleted: true } });
|
|
@@ -1512,7 +2225,7 @@ var Engine = class {
|
|
|
1512
2225
|
if (!inst || inst.kind !== "file") throw new UserError("diff needs a managed file");
|
|
1513
2226
|
const { artifact } = await this.api.call("GET", `/artifacts/${inst.artifactId}`);
|
|
1514
2227
|
if (artifact.deleted) throw new UserError("the artifact has been deleted on the server");
|
|
1515
|
-
await this.ensureBlobs([{ artifactId: inst.artifactId, version: artifact.version, sha: artifact.blob }]);
|
|
2228
|
+
await this.ensureBlobs([{ artifactId: inst.artifactId, version: artifact.version, sha: artifact.blob, base: inst.baseHash }]);
|
|
1516
2229
|
const server = this.cache.get(artifact.blob);
|
|
1517
2230
|
const local = inst.detached ? null : readLocal(inst.path, inst.eolMode);
|
|
1518
2231
|
const label = path4.basename(inst.path);
|
|
@@ -1544,6 +2257,11 @@ var Engine = class {
|
|
|
1544
2257
|
}
|
|
1545
2258
|
}
|
|
1546
2259
|
if (i.orphanReason) flags.push(i.orphanReason);
|
|
2260
|
+
if (i.realtime && i.mode === "editing") flags.push("realtime: your saves are pushed live");
|
|
2261
|
+
if (i.gitMode) flags.push(`git: ${i.gitMode}`);
|
|
2262
|
+
if (i.gitWaiting) flags.push("newer version arrives with your next git pull");
|
|
2263
|
+
if (i.gitOverlay) flags.push("Tether-only version (hidden from git)");
|
|
2264
|
+
if (i.remoteRealtime && i.mode !== "orphaned") flags.push(`realtime edits by ${JSON.parse(i.remoteRealtime).email}`);
|
|
1547
2265
|
return {
|
|
1548
2266
|
instanceId: i.instanceId,
|
|
1549
2267
|
artifactId: i.artifactId,
|
|
@@ -1592,15 +2310,161 @@ var Engine = class {
|
|
|
1592
2310
|
}
|
|
1593
2311
|
};
|
|
1594
2312
|
|
|
2313
|
+
// packages/client/src/notifier.js
|
|
2314
|
+
import fs7 from "node:fs";
|
|
2315
|
+
import { spawn } from "node:child_process";
|
|
2316
|
+
var AUMID = "Tether.Sync";
|
|
2317
|
+
var FLUSH_MS = 3e3;
|
|
2318
|
+
var LEVELS = { info: 0, warn: 1, error: 2 };
|
|
2319
|
+
var SHORTCUT_SCRIPT = String.raw`Add-Type -TypeDefinition @'
|
|
2320
|
+
using System;
|
|
2321
|
+
using System.Text;
|
|
2322
|
+
using System.Runtime.InteropServices;
|
|
2323
|
+
using System.Runtime.InteropServices.ComTypes;
|
|
2324
|
+
|
|
2325
|
+
namespace TetherShortcut {
|
|
2326
|
+
[ComImport, Guid("00021401-0000-0000-C000-000000000046")] class CShellLink {}
|
|
2327
|
+
|
|
2328
|
+
[ComImport, InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("000214F9-0000-0000-C000-000000000046")]
|
|
2329
|
+
interface IShellLinkW {
|
|
2330
|
+
void GetPath([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder f, int cch, IntPtr fd, uint flags);
|
|
2331
|
+
void GetIDList(out IntPtr pidl);
|
|
2332
|
+
void SetIDList(IntPtr pidl);
|
|
2333
|
+
void GetDescription([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder s, int cch);
|
|
2334
|
+
void SetDescription([MarshalAs(UnmanagedType.LPWStr)] string s);
|
|
2335
|
+
void GetWorkingDirectory([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder s, int cch);
|
|
2336
|
+
void SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string s);
|
|
2337
|
+
void GetArguments([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder s, int cch);
|
|
2338
|
+
void SetArguments([MarshalAs(UnmanagedType.LPWStr)] string s);
|
|
2339
|
+
void GetHotkey(out short k);
|
|
2340
|
+
void SetHotkey(short k);
|
|
2341
|
+
void GetShowCmd(out int c);
|
|
2342
|
+
void SetShowCmd(int c);
|
|
2343
|
+
void GetIconLocation([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder s, int cch, out int i);
|
|
2344
|
+
void SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string s, int i);
|
|
2345
|
+
void SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string s, uint r);
|
|
2346
|
+
void Resolve(IntPtr hwnd, uint flags);
|
|
2347
|
+
void SetPath([MarshalAs(UnmanagedType.LPWStr)] string s);
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
[StructLayout(LayoutKind.Sequential, Pack = 4)] public struct PropertyKey { public Guid fmtid; public uint pid; }
|
|
2351
|
+
[StructLayout(LayoutKind.Explicit)] public struct PropVariant { [FieldOffset(0)] public ushort vt; [FieldOffset(8)] public IntPtr p; }
|
|
2352
|
+
|
|
2353
|
+
[ComImport, InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99")]
|
|
2354
|
+
interface IPropertyStore {
|
|
2355
|
+
void GetCount(out uint c);
|
|
2356
|
+
void GetAt(uint i, out PropertyKey k);
|
|
2357
|
+
void GetValue(ref PropertyKey k, out PropVariant v);
|
|
2358
|
+
void SetValue(ref PropertyKey k, ref PropVariant v);
|
|
2359
|
+
void Commit();
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
public static class Maker {
|
|
2363
|
+
public static void Create(string lnk, string aumid, string url) {
|
|
2364
|
+
var link = (IShellLinkW)new CShellLink();
|
|
2365
|
+
link.SetPath(Environment.ExpandEnvironmentVariables("%SystemRoot%\\explorer.exe"));
|
|
2366
|
+
link.SetArguments(url);
|
|
2367
|
+
link.SetDescription("Tether");
|
|
2368
|
+
link.SetIconLocation(Environment.ExpandEnvironmentVariables("%SystemRoot%\\System32\\imageres.dll"), 228);
|
|
2369
|
+
var store = (IPropertyStore)link;
|
|
2370
|
+
var key = new PropertyKey { fmtid = new Guid("9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3"), pid = 5 };
|
|
2371
|
+
var v = new PropVariant { vt = 31, p = Marshal.StringToCoTaskMemUni(aumid) };
|
|
2372
|
+
store.SetValue(ref key, ref v);
|
|
2373
|
+
store.Commit();
|
|
2374
|
+
((IPersistFile)link).Save(lnk, true);
|
|
2375
|
+
Marshal.FreeCoTaskMem(v.p);
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
'@
|
|
2380
|
+
`;
|
|
2381
|
+
var Notifier = class {
|
|
2382
|
+
/** setting: 'all' | 'errors' | 'off' (config key `notifications`). */
|
|
2383
|
+
constructor({ setting = () => "all", consoleUrl = () => "", log = () => {
|
|
2384
|
+
} } = {}) {
|
|
2385
|
+
this.setting = setting;
|
|
2386
|
+
this.consoleUrl = consoleUrl;
|
|
2387
|
+
this.log = log;
|
|
2388
|
+
this.pending = /* @__PURE__ */ new Map();
|
|
2389
|
+
this.cooldown = /* @__PURE__ */ new Map();
|
|
2390
|
+
this.timer = null;
|
|
2391
|
+
this.registered = false;
|
|
2392
|
+
}
|
|
2393
|
+
/**
|
|
2394
|
+
* event: { kind, level: 'info'|'warn'|'error', title, body, path?, artifactId?, key?, cooldownMs? }
|
|
2395
|
+
* Events sharing `key` are suppressed for cooldownMs (default: no cooldown).
|
|
2396
|
+
*/
|
|
2397
|
+
notify(event) {
|
|
2398
|
+
const s = this.setting();
|
|
2399
|
+
if (s === "off" || s === "errors" && LEVELS[event.level] < LEVELS.warn) return;
|
|
2400
|
+
if (event.key) {
|
|
2401
|
+
if ((this.cooldown.get(event.key) ?? 0) > Date.now()) return;
|
|
2402
|
+
if (event.cooldownMs) this.cooldown.set(event.key, Date.now() + event.cooldownMs);
|
|
2403
|
+
}
|
|
2404
|
+
if (!this.pending.has(event.kind)) this.pending.set(event.kind, []);
|
|
2405
|
+
this.pending.get(event.kind).push(event);
|
|
2406
|
+
this.timer ??= setTimeout(() => this.flush(), FLUSH_MS);
|
|
2407
|
+
}
|
|
2408
|
+
flush() {
|
|
2409
|
+
this.timer = null;
|
|
2410
|
+
for (const events of this.pending.values()) {
|
|
2411
|
+
const first = events[0];
|
|
2412
|
+
const more = events.length - 1;
|
|
2413
|
+
const title = more ? `${first.title} (+${more} more)` : first.title;
|
|
2414
|
+
const body = more ? `${first.body}
|
|
2415
|
+
Also: ${events.slice(1, 4).map((e) => e.short ?? e.title).join("; ")}${more > 3 ? "\u2026" : ""}` : first.body;
|
|
2416
|
+
const base = this.consoleUrl().replace(/\/$/, "");
|
|
2417
|
+
const url = base ? first.artifactId && !more ? `${base}/artifacts/${first.artifactId}` : base : "";
|
|
2418
|
+
this.show({ title, body, url, level: first.level });
|
|
2419
|
+
}
|
|
2420
|
+
this.pending.clear();
|
|
2421
|
+
}
|
|
2422
|
+
show(n) {
|
|
2423
|
+
this.log(`notify [${n.level}] ${n.title}: ${n.body.replace(/\n/g, " ")}`);
|
|
2424
|
+
if (process.env.TETHER_NOTIFY_LOG) {
|
|
2425
|
+
fs7.appendFileSync(process.env.TETHER_NOTIFY_LOG, JSON.stringify(n) + "\n");
|
|
2426
|
+
return;
|
|
2427
|
+
}
|
|
2428
|
+
try {
|
|
2429
|
+
if (isWindows) this.toast(n);
|
|
2430
|
+
else spawn("notify-send", ["--app-name=Tether", n.level === "info" ? "--urgency=low" : "--urgency=normal", n.title, n.body], { stdio: "ignore" }).on("error", () => {
|
|
2431
|
+
});
|
|
2432
|
+
} catch (e) {
|
|
2433
|
+
this.log(`notification failed: ${e.message}`);
|
|
2434
|
+
}
|
|
2435
|
+
}
|
|
2436
|
+
toast({ title, body, url }) {
|
|
2437
|
+
const esc = (s) => s.replace(/[<>&"']/g, (c) => ({ "<": "<", ">": ">", "&": "&", '"': """, "'": "'" })[c]);
|
|
2438
|
+
const launch = url ? ` activationType="protocol" launch="${esc(url)}"` : "";
|
|
2439
|
+
const xml = `<toast${launch}><visual><binding template="ToastGeneric"><text>${esc(title)}</text><text>${esc(body)}</text></binding></visual></toast>`;
|
|
2440
|
+
const consoleUrl = (this.consoleUrl() || "https://tether-b10cf.web.app").replace(/'/g, "''");
|
|
2441
|
+
const register = this.registered ? "" : `$k='HKCU:\\Software\\Classes\\AppUserModelId\\${AUMID}'; if (-not (Test-Path $k)) { New-Item -Path $k -Force | Out-Null }; Set-ItemProperty -Path $k -Name DisplayName -Value 'Tether';
|
|
2442
|
+
$lnk = Join-Path $env:APPDATA 'Microsoft\\Windows\\Start Menu\\Programs\\Tether.lnk'
|
|
2443
|
+
if (-not (Test-Path $lnk)) { try {
|
|
2444
|
+
${SHORTCUT_SCRIPT}
|
|
2445
|
+
[TetherShortcut.Maker]::Create($lnk, '${AUMID}', '${consoleUrl}') } catch {} }`;
|
|
2446
|
+
this.registered = true;
|
|
2447
|
+
const script = `${register}
|
|
2448
|
+
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
|
|
2449
|
+
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null
|
|
2450
|
+
$x = New-Object Windows.Data.Xml.Dom.XmlDocument; $x.LoadXml('${xml.replace(/'/g, "''")}')
|
|
2451
|
+
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('${AUMID}').Show((New-Object Windows.UI.Notifications.ToastNotification $x))`;
|
|
2452
|
+
const child = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], { stdio: "ignore", windowsHide: true });
|
|
2453
|
+
child.on("error", (e) => this.log(`notification failed: ${e.message}`));
|
|
2454
|
+
}
|
|
2455
|
+
};
|
|
2456
|
+
|
|
1595
2457
|
// packages/client/src/daemon.js
|
|
1596
|
-
|
|
2458
|
+
import { fileURLToPath } from "node:url";
|
|
2459
|
+
var REALTIME_TICK_SECONDS = 3;
|
|
2460
|
+
var UPDATE_CHECK_MS = 24 * 60 * 60 * 1e3;
|
|
1597
2461
|
async function runDaemon() {
|
|
1598
2462
|
const home = tetherHome();
|
|
1599
|
-
|
|
2463
|
+
fs8.mkdirSync(home, { recursive: true });
|
|
1600
2464
|
process.chdir(home);
|
|
1601
2465
|
const f = files(home);
|
|
1602
2466
|
const recent = [];
|
|
1603
|
-
const logStream =
|
|
2467
|
+
const logStream = fs8.createWriteStream(f.log, { flags: "a" });
|
|
1604
2468
|
const log = (msg) => {
|
|
1605
2469
|
const line = `${(/* @__PURE__ */ new Date()).toISOString()} ${msg}`;
|
|
1606
2470
|
recent.push(line);
|
|
@@ -1613,26 +2477,62 @@ async function runDaemon() {
|
|
|
1613
2477
|
const api = new Api({ config, credentials });
|
|
1614
2478
|
const cache = new BlobCache(f.blobs);
|
|
1615
2479
|
const tracker = await createTracker(config);
|
|
1616
|
-
const
|
|
2480
|
+
const notifier = new Notifier({ setting: () => config.get("notifications"), consoleUrl: () => config.consoleUrl, log });
|
|
2481
|
+
const engine = new Engine({
|
|
2482
|
+
db,
|
|
2483
|
+
api,
|
|
2484
|
+
cache,
|
|
2485
|
+
tracker,
|
|
2486
|
+
config,
|
|
2487
|
+
log,
|
|
2488
|
+
home,
|
|
2489
|
+
notify: (e) => notifier.notify(e),
|
|
2490
|
+
cliPath: fileURLToPath(new URL("../bin/tether.js", import.meta.url))
|
|
2491
|
+
});
|
|
1617
2492
|
let timer = null;
|
|
1618
2493
|
let quickTimer = null;
|
|
2494
|
+
let realtimeTimer = null;
|
|
1619
2495
|
let stopping = false;
|
|
2496
|
+
let latest = null;
|
|
1620
2497
|
async function runTick(opts) {
|
|
1621
2498
|
try {
|
|
1622
2499
|
await engine.locked(() => engine.tick(opts));
|
|
1623
2500
|
} catch (e) {
|
|
1624
2501
|
log(`tick failed: ${e.stack || e.message}`);
|
|
2502
|
+
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.` });
|
|
1625
2503
|
}
|
|
1626
2504
|
refreshWatchers();
|
|
1627
2505
|
scheduleGrace();
|
|
2506
|
+
schedule();
|
|
1628
2507
|
}
|
|
1629
2508
|
function schedule() {
|
|
1630
2509
|
clearTimeout(timer);
|
|
1631
2510
|
if (stopping) return;
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
2511
|
+
const seconds = engine.realtimeActive ? Math.min(config.get("tickSeconds"), REALTIME_TICK_SECONDS) : config.get("tickSeconds");
|
|
2512
|
+
timer = setTimeout(() => runTick(), seconds * 1e3);
|
|
2513
|
+
}
|
|
2514
|
+
function realtimePass(delay = 1e3) {
|
|
2515
|
+
if (stopping || !engine.realtimeInstances().length) return;
|
|
2516
|
+
clearTimeout(realtimeTimer);
|
|
2517
|
+
realtimeTimer = setTimeout(async () => {
|
|
2518
|
+
realtimeTimer = null;
|
|
2519
|
+
try {
|
|
2520
|
+
await engine.locked(async () => {
|
|
2521
|
+
await engine.locateAll();
|
|
2522
|
+
await engine.syncRealtime();
|
|
2523
|
+
});
|
|
2524
|
+
} catch (e) {
|
|
2525
|
+
log(`realtime push failed: ${e.message}`);
|
|
2526
|
+
}
|
|
2527
|
+
}, delay);
|
|
2528
|
+
}
|
|
2529
|
+
let notifiedVersion = null;
|
|
2530
|
+
async function checkForUpdate() {
|
|
2531
|
+
latest = await latestVersion() ?? latest;
|
|
2532
|
+
if (isNewer(latest, VERSION) && notifiedVersion !== latest) {
|
|
2533
|
+
notifiedVersion = latest;
|
|
2534
|
+
notifier.notify({ kind: "update", level: "info", title: "Tether update available", body: `Version ${latest} is available (you have ${VERSION}). Run "tether update".` });
|
|
2535
|
+
}
|
|
1636
2536
|
}
|
|
1637
2537
|
function quickPass(delay) {
|
|
1638
2538
|
if (quickTimer || stopping) return;
|
|
@@ -1651,10 +2551,29 @@ async function runDaemon() {
|
|
|
1651
2551
|
if (engine.graceDue) quickPass(Math.max(50, engine.graceDue - Date.now() + 50));
|
|
1652
2552
|
}
|
|
1653
2553
|
const watchers = /* @__PURE__ */ new Map();
|
|
2554
|
+
let gitTimer = null;
|
|
2555
|
+
function gitPassSoon() {
|
|
2556
|
+
clearTimeout(gitTimer);
|
|
2557
|
+
gitTimer = setTimeout(async () => {
|
|
2558
|
+
gitTimer = null;
|
|
2559
|
+
try {
|
|
2560
|
+
await engine.locked(async () => {
|
|
2561
|
+
engine.busyCache = /* @__PURE__ */ new Map();
|
|
2562
|
+
await engine.locateAll();
|
|
2563
|
+
await engine.gitPass();
|
|
2564
|
+
await engine.enforceLocal();
|
|
2565
|
+
});
|
|
2566
|
+
} catch (e) {
|
|
2567
|
+
log(`git pass failed: ${e.message}`);
|
|
2568
|
+
}
|
|
2569
|
+
}, 600);
|
|
2570
|
+
}
|
|
1654
2571
|
function refreshWatchers() {
|
|
1655
2572
|
const want = /* @__PURE__ */ new Map();
|
|
1656
2573
|
for (const i of db.all()) {
|
|
1657
|
-
if (i.mode === "orphaned"
|
|
2574
|
+
if (i.mode === "orphaned") continue;
|
|
2575
|
+
if (i.gitDir && !want.has(i.gitDir)) want.set(i.gitDir, "git");
|
|
2576
|
+
if (i.detached) continue;
|
|
1658
2577
|
if (i.kind === "folder") want.set(i.path, true);
|
|
1659
2578
|
else if (!i.folderInstanceId) want.set(path5.dirname(i.path), false);
|
|
1660
2579
|
}
|
|
@@ -1664,10 +2583,13 @@ async function runDaemon() {
|
|
|
1664
2583
|
watchers.delete(dir);
|
|
1665
2584
|
}
|
|
1666
2585
|
}
|
|
1667
|
-
for (const [dir,
|
|
2586
|
+
for (const [dir, kind] of want) {
|
|
1668
2587
|
if (watchers.has(dir)) continue;
|
|
1669
2588
|
try {
|
|
1670
|
-
const w =
|
|
2589
|
+
const w = kind === "git" ? fs8.watch(dir, { persistent: false }, () => gitPassSoon()) : fs8.watch(dir, { recursive: kind, persistent: false }, () => {
|
|
2590
|
+
quickPass(250);
|
|
2591
|
+
realtimePass();
|
|
2592
|
+
});
|
|
1671
2593
|
w.on("error", () => {
|
|
1672
2594
|
w.close();
|
|
1673
2595
|
watchers.delete(dir);
|
|
@@ -1714,7 +2636,15 @@ async function runDaemon() {
|
|
|
1714
2636
|
ping: () => ({ pid: process.pid, version: VERSION, home, tracker: tracker.name, server: config.get("server") }),
|
|
1715
2637
|
async status(p) {
|
|
1716
2638
|
const creds = await credentials.load();
|
|
1717
|
-
return {
|
|
2639
|
+
return {
|
|
2640
|
+
user: creds?.user ?? null,
|
|
2641
|
+
server: config.get("server"),
|
|
2642
|
+
offline: engine.offline,
|
|
2643
|
+
lastTick: engine.lastTick,
|
|
2644
|
+
version: VERSION,
|
|
2645
|
+
latest,
|
|
2646
|
+
instances: await L((x) => engine.status(x))(p)
|
|
2647
|
+
};
|
|
1718
2648
|
},
|
|
1719
2649
|
sync: async () => {
|
|
1720
2650
|
await runTick();
|
|
@@ -1742,8 +2672,18 @@ async function runDaemon() {
|
|
|
1742
2672
|
manage: L((p) => engine.manage(p)),
|
|
1743
2673
|
add: L((p) => engine.add(p)),
|
|
1744
2674
|
pull: L((p) => engine.pull(p)),
|
|
1745
|
-
edit:
|
|
2675
|
+
edit: async (p) => {
|
|
2676
|
+
const r = await L((x) => engine.edit(x))(p);
|
|
2677
|
+
schedule();
|
|
2678
|
+
return r;
|
|
2679
|
+
},
|
|
1746
2680
|
unmanage: L((p) => engine.unmanage(p)),
|
|
2681
|
+
"git.mode": L((p) => engine.gitMode(p)),
|
|
2682
|
+
// Called by git hooks: answer at once so git is not held up; reconcile in the background.
|
|
2683
|
+
"git.sync": ({ repo }) => {
|
|
2684
|
+
engine.locked(() => engine.gitReconcile(path5.resolve(repo))).catch((e) => log(`git sync failed: ${e.message}`));
|
|
2685
|
+
return { queued: true };
|
|
2686
|
+
},
|
|
1747
2687
|
plan: L((p) => engine.plan(p)),
|
|
1748
2688
|
resolve: L((p) => engine.resolve(p)),
|
|
1749
2689
|
diff: L((p) => engine.diff(p)),
|
|
@@ -1796,11 +2736,14 @@ async function runDaemon() {
|
|
|
1796
2736
|
return { stopping: true };
|
|
1797
2737
|
}
|
|
1798
2738
|
};
|
|
2739
|
+
const CHANGES_WATCHED = /* @__PURE__ */ new Set(["manage", "add", "pull", "edit", "unmanage", "git.mode", "resolve", "locate"]);
|
|
1799
2740
|
async function handle(method, params) {
|
|
1800
2741
|
const h = handlers[method];
|
|
1801
2742
|
if (!h) throw new UserError(`unknown command ${method}`);
|
|
1802
2743
|
try {
|
|
1803
|
-
|
|
2744
|
+
const result = await h(params);
|
|
2745
|
+
if (CHANGES_WATCHED.has(method)) refreshWatchers();
|
|
2746
|
+
return result;
|
|
1804
2747
|
} catch (e) {
|
|
1805
2748
|
if (e instanceof ApiError || e instanceof UserError) throw e;
|
|
1806
2749
|
if (e.name === "OfflineError") throw e;
|
|
@@ -1809,25 +2752,36 @@ async function runDaemon() {
|
|
|
1809
2752
|
}
|
|
1810
2753
|
}
|
|
1811
2754
|
const server = await listen(ipcEndpoint(home), handle);
|
|
1812
|
-
|
|
2755
|
+
fs8.writeFileSync(f.pid, String(process.pid));
|
|
1813
2756
|
log(`tetherd ${VERSION} started (pid ${process.pid}, tracker ${tracker.name}, server ${config.get("server")})`);
|
|
1814
2757
|
function shutdown(code) {
|
|
1815
2758
|
if (stopping) return;
|
|
1816
2759
|
stopping = true;
|
|
1817
2760
|
clearTimeout(timer);
|
|
1818
2761
|
clearTimeout(quickTimer);
|
|
2762
|
+
clearTimeout(realtimeTimer);
|
|
2763
|
+
clearInterval(updateTimer);
|
|
2764
|
+
clearInterval(repoTimer);
|
|
2765
|
+
clearTimeout(gitTimer);
|
|
1819
2766
|
for (const w of watchers.values()) w.close();
|
|
1820
2767
|
server.close();
|
|
1821
2768
|
engine.locked(() => db.close()).finally(() => {
|
|
1822
|
-
|
|
2769
|
+
fs8.rmSync(f.pid, { force: true });
|
|
1823
2770
|
log("tetherd stopped");
|
|
1824
2771
|
logStream.end(() => process.exit(code));
|
|
1825
2772
|
});
|
|
1826
2773
|
}
|
|
1827
2774
|
process.on("SIGINT", () => shutdown(0));
|
|
1828
2775
|
process.on("SIGTERM", () => shutdown(0));
|
|
2776
|
+
await engine.locked(() => {
|
|
2777
|
+
engine.cleanupLegacyHooks();
|
|
2778
|
+
engine.detectNewRepos();
|
|
2779
|
+
}).catch((e) => log(`git setup: ${e.message}`));
|
|
2780
|
+
const repoTimer = setInterval(() => engine.locked(() => engine.detectNewRepos()).catch(() => {
|
|
2781
|
+
}), 60 * 60 * 1e3);
|
|
2782
|
+
const updateTimer = setInterval(checkForUpdate, UPDATE_CHECK_MS);
|
|
2783
|
+
setTimeout(checkForUpdate, 5e3);
|
|
1829
2784
|
await runTick();
|
|
1830
|
-
schedule();
|
|
1831
2785
|
}
|
|
1832
2786
|
export {
|
|
1833
2787
|
VERSION,
|