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.
@@ -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-MGMU33LE.js";
26
+ } from "./chunk-NMLHD2KU.js";
8
27
 
9
28
  // packages/client/src/daemon.js
10
- import fs7 from "node:fs";
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
- /** Uploads canonical blobs the server doesn't have yet. blobs: [{ sha256, bytes }] */
469
- async uploadBlobs(blobs) {
470
- const unique = [...new Map(blobs.map((b) => [b.sha256, b])).values()];
471
- if (!unique.length) return;
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
- /** Downloads blobs for [{ artifactId, version? }] into the cache; skips blobs already cached. */
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
- if (!items.length) return [];
482
- const { downloads } = await this.call("POST", "/blobs/download", { items });
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.map(({ artifactId, version }) => ({ artifactId, version })));
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
- return writeLocal(p, this.blobFor(sha), eolMode, { exec, style });
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
- if (remote) await this.checkRemote();
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 === "editing") {
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) this.db.update(t.instanceId, { mode: "orphaned", orphanReason: reason });
888
- this.log(`${inst.path}: ${reason === "revoked" ? "access revoked" : reason === "deleted" ? "deleted on the server" : "removed from the server"}; file kept, no longer tracked`);
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 = { role: r.role, canDelete: r.canDelete, remoteVersion: r.version };
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) if (!c.deleted && !have.has(c.artifactId)) newChildren.push({ folder: f.inst, child: c });
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) this.applyIncoming(inst, state);
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
- if (state.blob !== inst.baseHash || !this.matchesBase(inst, p)) {
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.log(`${p}: updated to version ${state.version}`);
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.db.update(inst.instanceId, { lastError: e.message });
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.db.update(inst.instanceId, { lastError: e.message });
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
- async manage({ path: target, repo: repoRef, eol }) {
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
- async pull({ shareId, dest, cwd }) {
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
- let target = path4.resolve(cwd, dest ?? a.name);
1241
- if (dest && statOrNull(target)?.isDirectory() && a.kind === "file") target = path4.join(target, a.name);
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
- const existing = readLocal(target, a.eol);
1247
- if (existing && existing.hash !== a.blob) throw new UserError(`${target} already exists with different content; choose another destination`);
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: "normal",
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.push(c.relPath);
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 ex = readLocal(p, c.eol);
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
- edit({ path: p }) {
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
- return { changed: this.setMode(p, "editing", ["normal"]) };
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) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", '"': "&quot;", "'": "&apos;" })[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
- var VERSION = "0.1.0";
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
- fs7.mkdirSync(home, { recursive: true });
2463
+ fs8.mkdirSync(home, { recursive: true });
1600
2464
  process.chdir(home);
1601
2465
  const f = files(home);
1602
2466
  const recent = [];
1603
- const logStream = fs7.createWriteStream(f.log, { flags: "a" });
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 engine = new Engine({ db, api, cache, tracker, config, log });
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
- timer = setTimeout(async () => {
1633
- await runTick();
1634
- schedule();
1635
- }, config.get("tickSeconds") * 1e3);
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" || i.detached) continue;
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, recursive] of want) {
2586
+ for (const [dir, kind] of want) {
1668
2587
  if (watchers.has(dir)) continue;
1669
2588
  try {
1670
- const w = fs7.watch(dir, { recursive, persistent: false }, () => quickPass(250));
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 { user: creds?.user ?? null, server: config.get("server"), offline: engine.offline, lastTick: engine.lastTick, instances: await L((x) => engine.status(x))(p) };
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: L((p) => engine.edit(p)),
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
- return await h(params);
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
- fs7.writeFileSync(f.pid, String(process.pid));
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
- fs7.rmSync(f.pid, { force: true });
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,