tether-sync 0.1.0 → 0.2.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,21 @@
1
1
  import {
2
+ GIT_MODES,
3
+ VERSION,
2
4
  files,
5
+ findRepo,
6
+ gitBusy,
7
+ installHooks,
3
8
  ipcEndpoint,
9
+ isNewer,
4
10
  isWindows,
11
+ latestVersion,
5
12
  listen,
13
+ removeHooks,
6
14
  tetherHome
7
- } from "./chunk-MGMU33LE.js";
15
+ } from "./chunk-NDKBAHAG.js";
8
16
 
9
17
  // packages/client/src/daemon.js
10
- import fs7 from "node:fs";
18
+ import fs8 from "node:fs";
11
19
  import path5 from "node:path";
12
20
  import http from "node:http";
13
21
  import crypto4 from "node:crypto";
@@ -21,7 +29,9 @@ var DEFAULTS = {
21
29
  // defaults to the server URL
22
30
  tickSeconds: 30,
23
31
  graceMs: 2e3,
24
- scanRoots: [os.homedir()]
32
+ scanRoots: [os.homedir()],
33
+ notifications: "all"
34
+ // all | errors | off
25
35
  };
26
36
  var NUMERIC = /* @__PURE__ */ new Set(["tickSeconds", "graceMs"]);
27
37
  var Config = class {
@@ -43,6 +53,7 @@ var Config = class {
43
53
  value = Number(value);
44
54
  if (!Number.isFinite(value) || value <= 0) throw new Error(`${key} must be a positive number`);
45
55
  }
56
+ if (key === "notifications" && !["all", "errors", "off"].includes(value)) throw new Error("notifications must be all, errors or off");
46
57
  if (key === "scanRoots" && typeof value === "string") value = value.split(/[;,]/).map((s) => s.trim()).filter(Boolean);
47
58
  this.values[key] = value;
48
59
  fs.writeFileSync(this.file, JSON.stringify(this.values, null, 2));
@@ -93,11 +104,20 @@ var COLUMNS = {
93
104
  needsRestore: "INTEGER DEFAULT 0",
94
105
  // write a fresh copy at the expected path on the next tick
95
106
  orphanReason: "TEXT",
107
+ realtime: "INTEGER DEFAULT 0",
108
+ // this client holds a realtime session and pushes every save
109
+ remoteRealtime: "TEXT",
110
+ // JSON { email, since } when someone else edits this file in realtime
111
+ gitMode: "TEXT",
112
+ // inside a git repo: keep (Tether reverts git's changes) | follow (git's changes are published)
113
+ gitRoot: "TEXT",
114
+ gitDir: "TEXT",
115
+ gitHooks: "TEXT",
96
116
  lastError: "TEXT",
97
117
  createdAt: "REAL",
98
118
  updatedAt: "REAL"
99
119
  };
100
- var BOOL = /* @__PURE__ */ new Set(["hasBom", "exec", "canDelete", "detached", "needsRestore"]);
120
+ var BOOL = /* @__PURE__ */ new Set(["realtime", "hasBom", "exec", "canDelete", "detached", "needsRestore"]);
101
121
  var LocalDb = class {
102
122
  constructor(file) {
103
123
  this.db = new DatabaseSync(file);
@@ -753,13 +773,19 @@ var UserError = class extends Error {
753
773
  var ACTIVE = (i) => i.mode !== "orphaned";
754
774
  var Engine = class {
755
775
  constructor({ db, api, cache, tracker, config, log = () => {
756
- } }) {
776
+ }, notify = () => {
777
+ }, home = null, cliPath = null }) {
757
778
  this.db = db;
758
779
  this.api = api;
759
780
  this.cache = cache;
760
781
  this.tracker = tracker;
761
782
  this.config = config;
762
783
  this.log = log;
784
+ this.notify = notify;
785
+ this.home = home;
786
+ this.cliPath = cliPath;
787
+ this.gitState = /* @__PURE__ */ new Map();
788
+ this.busyCache = null;
763
789
  this.offline = null;
764
790
  this.lastTick = null;
765
791
  this.graceDue = null;
@@ -804,11 +830,212 @@ var Engine = class {
804
830
  }
805
831
  // ---------- tick ----------
806
832
  async tick({ remote = true } = {}) {
833
+ this.busyCache = /* @__PURE__ */ new Map();
807
834
  await this.locateAll();
808
- if (remote) await this.checkRemote();
835
+ if (remote) await this.gitTransitions();
836
+ if (remote) {
837
+ await this.checkRemote();
838
+ await this.syncRealtime();
839
+ }
809
840
  await this.enforceLocal();
810
841
  this.lastTick = Date.now();
811
842
  }
843
+ // ---------- git integration ----------
844
+ isGitBusy(inst) {
845
+ if (!inst.gitDir) return false;
846
+ this.busyCache ??= /* @__PURE__ */ new Map();
847
+ if (!this.busyCache.has(inst.gitDir)) this.busyCache.set(inst.gitDir, gitBusy(inst.gitDir));
848
+ return !!this.busyCache.get(inst.gitDir);
849
+ }
850
+ /** Sets the git mode on an instance (and a folder's files). Throws if follow is not allowed. */
851
+ applyGitMode(root, mode) {
852
+ if (!root) return;
853
+ if (!GIT_MODES.includes(mode)) throw new UserError(`git mode must be one of ${GIT_MODES.join(", ")}`);
854
+ if (mode === "follow" && root.role === "viewer") throw new UserError("git follow mode needs editor access");
855
+ const repo = findRepo(root.path);
856
+ if (!repo) throw new UserError(`${root.path} is not inside a git repository`);
857
+ const fields = { gitMode: mode, gitRoot: repo.root, gitDir: repo.gitDir, gitHooks: repo.hooksDir };
858
+ for (const i of [root, ...root.kind === "folder" ? this.db.childrenOf(root.instanceId) : []]) this.db.update(i.instanceId, fields);
859
+ this.syncGitHooks();
860
+ }
861
+ /** `tether git <path> [--mode]`: show or change the git mode of managed files. */
862
+ gitMode({ path: p, mode }) {
863
+ const ts = this.targets(p);
864
+ const folders = new Set(ts.filter((i) => i.kind === "folder").map((i) => i.instanceId));
865
+ 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);
866
+ 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 }));
867
+ }
868
+ /** Installs hooks in repositories with follow-mode files and removes them where none remain. */
869
+ syncGitHooks() {
870
+ if (!this.home || !this.cliPath) return;
871
+ const want = new Set(this.db.all().filter((i) => i.gitMode === "follow" && i.mode !== "orphaned" && i.gitHooks).map((i) => i.gitHooks));
872
+ const had = new Set(this.db.getMeta("gitHooks") ?? []);
873
+ for (const dir of want) {
874
+ try {
875
+ installHooks(dir, { home: this.home, node: process.execPath, cli: this.cliPath });
876
+ } catch (e) {
877
+ this.log(`could not install git hooks in ${dir}: ${e.message}`);
878
+ }
879
+ }
880
+ for (const dir of had) if (!want.has(dir)) removeHooks(dir, this.home);
881
+ this.db.setMeta("gitHooks", [...want]);
882
+ }
883
+ /** Repositories that just finished a git operation (merge resolved, rebase done) get reconciled. */
884
+ async gitTransitions() {
885
+ const dirs = /* @__PURE__ */ new Map();
886
+ for (const i of this.db.all()) if (i.gitMode === "follow" && i.gitDir && i.mode !== "orphaned") dirs.set(i.gitDir, i.gitRoot);
887
+ for (const [gitDir, root] of dirs) {
888
+ const busy = gitBusy(gitDir);
889
+ const was = this.gitState.get(gitDir);
890
+ this.gitState.set(gitDir, busy);
891
+ if (was && !busy) await this.gitReconcile(root);
892
+ }
893
+ }
894
+ /**
895
+ * After git changed files (hook, or the end of a merge/rebase): publish git's version of
896
+ * follow-mode files. If Tether also moved on, the file stays in editing mode for `tether done`.
897
+ */
898
+ async gitReconcile(root) {
899
+ if (gitBusy(findRepo(root)?.gitDir)) return { busy: true };
900
+ const mine = () => this.db.all().filter((i) => i.gitMode === "follow" && i.kind === "file" && i.mode === "normal" && i.gitRoot && samePath(i.gitRoot, root));
901
+ for (const i of mine().filter((x) => x.detached)) {
902
+ if (statOrNull(i.path)?.isFile()) {
903
+ this.db.update(i.instanceId, { ...this.identity(i.path), detached: false, missingSince: null, size: null, mtimeMs: null });
904
+ this.log(`${i.path}: back (restored by git)`);
905
+ }
906
+ }
907
+ await this.locateAll();
908
+ const changed = [];
909
+ for (const i of mine().filter((x) => !x.detached)) {
910
+ try {
911
+ const local = readLocal(i.path, i.eolMode);
912
+ if (local && local.hash !== i.baseHash) changed.push({ inst: i, local });
913
+ } catch {
914
+ }
915
+ }
916
+ if (!changed.length) return { published: 0 };
917
+ let results;
918
+ try {
919
+ ({ results } = await this.api.call("POST", "/sync/check", { items: changed.map(({ inst }) => ({ artifactId: inst.artifactId, version: inst.baseVersion })) }));
920
+ } catch (e) {
921
+ if (!(e instanceof OfflineError)) throw e;
922
+ for (const { inst } of changed) this.db.update(inst.instanceId, { mode: "editing", lastError: "git changed this file while offline; run `tether done` to publish it" });
923
+ return { published: 0, offline: true };
924
+ }
925
+ let published = 0;
926
+ for (const [k, { inst, local }] of changed.entries()) {
927
+ const r = results[k];
928
+ const name = path4.basename(inst.path);
929
+ if (r.status === "gone" || r.state?.deleted || r.folderDeleted) {
930
+ this.orphan(inst, r.reason ?? "deleted");
931
+ continue;
932
+ }
933
+ if (r.role === "viewer") {
934
+ this.db.update(inst.instanceId, { gitMode: "keep", role: "viewer" });
935
+ continue;
936
+ }
937
+ const conflict = () => {
938
+ this.db.update(inst.instanceId, { mode: "editing", lastError: "conflict: git changed this file and Tether has a newer version; run `tether done` to resolve" });
939
+ this.log(`${inst.path}: git changed it but Tether also has a newer version; left in editing mode`);
940
+ 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 });
941
+ };
942
+ if (r.status === "changed") {
943
+ conflict();
944
+ continue;
945
+ }
946
+ try {
947
+ await this.api.uploadBlobs([{ sha256: local.hash, bytes: local.canonical }]);
948
+ this.cache.put(local.hash, local.canonical);
949
+ const c = await this.api.call("POST", `/artifacts/${inst.artifactId}/commit`, { baseVersion: inst.baseVersion, change: { blob: local.hash, size: local.canonical.length } });
950
+ this.db.update(inst.instanceId, { baseVersion: c.version, remoteVersion: c.version, baseHash: local.hash, size: local.size, mtimeMs: local.mtimeMs, ...styleOf(local), lastError: null });
951
+ this.log(`${inst.path}: published git's version as v${c.version}`);
952
+ 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 });
953
+ published++;
954
+ } catch (e) {
955
+ if (e instanceof ApiError && e.status === 409) conflict();
956
+ else if (e instanceof ApiError) {
957
+ this.db.update(inst.instanceId, { mode: "editing", lastError: `could not publish git's version: ${e.message}` });
958
+ 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 });
959
+ } else throw e;
960
+ }
961
+ }
962
+ return { published };
963
+ }
964
+ // ---------- realtime editing ----------
965
+ /** Files this client is editing live (every save is pushed). */
966
+ realtimeInstances() {
967
+ return this.db.all().filter((i) => i.realtime && i.mode === "editing" && i.kind === "file");
968
+ }
969
+ /** True while anything held here is being edited live, so the daemon checks more often. */
970
+ get realtimeActive() {
971
+ return this.db.all().some((i) => i.realtime && i.mode === "editing" || i.remoteRealtime && i.mode !== "orphaned");
972
+ }
973
+ async releaseRealtime(inst) {
974
+ this.db.update(inst.instanceId, { realtime: false });
975
+ await this.api.call("POST", `/artifacts/${inst.artifactId}/realtime`, { action: "stop" }).catch(() => {
976
+ });
977
+ }
978
+ /**
979
+ * Pushes live edits: renews the session, applies incoming versions when there is no local
980
+ * change, pushes local changes when the server has not moved, and pauses on a conflict.
981
+ */
982
+ async syncRealtime() {
983
+ const rts = this.realtimeInstances().filter((i) => !i.detached);
984
+ if (!rts.length) return;
985
+ let results;
986
+ try {
987
+ ({ results } = await this.api.call("POST", "/sync/check", { items: rts.map((i) => ({ artifactId: i.artifactId, version: i.baseVersion, realtime: true })) }));
988
+ } catch (e) {
989
+ if (e instanceof OfflineError) return this.offline = e.message;
990
+ throw e;
991
+ }
992
+ for (const [k, inst] of rts.entries()) {
993
+ const r = results[k];
994
+ const cur = this.db.get(inst.instanceId);
995
+ if (!cur || cur.mode !== "editing" || !cur.realtime) continue;
996
+ if (r.status === "gone" || r.state?.deleted || r.folderDeleted) {
997
+ this.orphan(cur, r.reason ?? "deleted");
998
+ continue;
999
+ }
1000
+ if (!r.realtime?.mine) {
1001
+ const why = r.role === "viewer" ? "you are no longer an editor" : r.realtime ? `taken over by ${r.realtime.email}` : "it ended";
1002
+ this.db.update(cur.instanceId, { realtime: false, lastError: r.role === "viewer" ? null : `realtime session lost (${why}); still editing, run \`tether done\` to push` });
1003
+ this.log(`${cur.path}: realtime session lost (${why})`);
1004
+ 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 });
1005
+ continue;
1006
+ }
1007
+ try {
1008
+ const local = readLocal(cur.path, cur.eolMode);
1009
+ if (r.status === "changed") {
1010
+ if (!local || local.hash === cur.baseHash) {
1011
+ await this.ensureBlobs([{ artifactId: cur.artifactId, version: r.state.version, sha: r.state.blob }]);
1012
+ this.applyIncoming(cur, r.state);
1013
+ } else {
1014
+ if (!cur.lastError?.startsWith("conflict")) {
1015
+ this.log(`${cur.path}: someone else pushed while you were editing live; live pushing paused, run \`tether done\` to resolve`);
1016
+ 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 });
1017
+ }
1018
+ this.db.update(cur.instanceId, { lastError: "conflict: the server changed; run `tether done` to resolve" });
1019
+ }
1020
+ continue;
1021
+ }
1022
+ if (!local || local.hash === cur.baseHash) continue;
1023
+ await this.api.uploadBlobs([{ sha256: local.hash, bytes: local.canonical }]);
1024
+ this.cache.put(local.hash, local.canonical);
1025
+ try {
1026
+ const c = await this.api.call("POST", `/artifacts/${cur.artifactId}/commit`, { baseVersion: cur.baseVersion, change: { blob: local.hash, size: local.canonical.length } });
1027
+ this.db.update(cur.instanceId, { baseVersion: c.version, remoteVersion: c.version, baseHash: local.hash, size: local.size, mtimeMs: local.mtimeMs, ...styleOf(local), lastError: null });
1028
+ this.log(`${cur.path}: pushed v${c.version} (realtime)`);
1029
+ } catch (e) {
1030
+ if (!(e instanceof ApiError && e.status === 409)) throw e;
1031
+ this.db.update(cur.instanceId, { lastError: "conflict: the server changed; run `tether done` to resolve" });
1032
+ }
1033
+ } catch (e) {
1034
+ if (!(e instanceof LockedError)) throw e;
1035
+ this.db.update(cur.instanceId, { lastError: e.message });
1036
+ }
1037
+ }
1038
+ }
812
1039
  /** Refresh path and file ID of every tracked instance (DESIGN §5). */
813
1040
  async locateAll() {
814
1041
  const graceMs = this.config.get("graceMs");
@@ -871,7 +1098,10 @@ var Engine = class {
871
1098
  this.log(`${inst.path}: folder deleted locally; no longer managed here`);
872
1099
  return;
873
1100
  }
874
- if (inst.mode === "editing") {
1101
+ if (inst.gitMode === "follow" && inst.mode === "normal" && !inst.folderInstanceId) {
1102
+ this.db.update(inst.instanceId, { detached: true, missingSince: null });
1103
+ this.log(`${inst.path}: removed (by git?); waiting for git to bring it back`);
1104
+ } else if (inst.mode === "editing") {
875
1105
  this.db.update(inst.instanceId, { detached: true, missingSince: null });
876
1106
  this.log(`${inst.path}: deleted while editing; resolve with \`tether done\``);
877
1107
  } else if (inst.folderInstanceId) {
@@ -885,7 +1115,9 @@ var Engine = class {
885
1115
  orphan(inst, reason) {
886
1116
  const targets = inst.kind === "folder" ? [inst, ...this.db.childrenOf(inst.instanceId)] : [inst];
887
1117
  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`);
1118
+ const what = reason === "revoked" ? "access revoked" : reason === "deleted" ? "deleted on the server" : "removed from the server";
1119
+ this.log(`${inst.path}: ${what}; file kept, no longer tracked`);
1120
+ 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
1121
  }
890
1122
  /** One batched call for all instances (DESIGN §6.1 step 2). */
891
1123
  async checkRemote() {
@@ -901,6 +1133,7 @@ var Engine = class {
901
1133
  } catch (e) {
902
1134
  if (e instanceof OfflineError || e instanceof ApiError && e.status === 401) {
903
1135
  this.offline = e.message;
1136
+ 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
1137
  return;
905
1138
  }
906
1139
  throw e;
@@ -913,10 +1146,21 @@ var Engine = class {
913
1146
  if (!cur || cur.mode === "orphaned") return;
914
1147
  if (r.status === "gone") return this.orphan(cur, r.reason);
915
1148
  if (r.folderDeleted || r.state?.deleted) return this.orphan(cur, "deleted");
916
- const patch = { role: r.role, canDelete: r.canDelete, remoteVersion: r.version };
1149
+ const patch = {
1150
+ role: r.role,
1151
+ canDelete: r.canDelete,
1152
+ remoteVersion: r.version,
1153
+ remoteRealtime: r.realtime && !r.realtime.mine ? JSON.stringify({ email: r.realtime.email, since: r.realtime.since }) : null
1154
+ };
917
1155
  if (cur.role && cur.role !== "viewer" && r.role === "viewer" && cur.mode === "editing") {
918
1156
  patch.mode = "normal";
1157
+ patch.realtime = false;
919
1158
  this.log(`${cur.path}: you are now a viewer; editing ended and local changes will be reverted`);
1159
+ 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 });
1160
+ }
1161
+ if (cur.gitMode === "follow" && r.role === "viewer") {
1162
+ patch.gitMode = "keep";
1163
+ 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
1164
  }
921
1165
  const now = this.db.update(cur.instanceId, patch);
922
1166
  if (r.status !== "changed") return;
@@ -938,7 +1182,7 @@ var Engine = class {
938
1182
  if (e instanceof OfflineError) return this.offline = e.message;
939
1183
  throw e;
940
1184
  }
941
- for (const { inst, state } of incoming) this.applyIncoming(inst, state);
1185
+ for (const { inst, state } of incoming) if (!this.isGitBusy(inst)) this.applyIncoming(inst, state);
942
1186
  for (const { folder, child } of newChildren) {
943
1187
  this.db.insert({
944
1188
  instanceId: newInstanceId(),
@@ -976,16 +1220,32 @@ var Engine = class {
976
1220
  this.log(`${inst.path} -> ${to}: moved by the server`);
977
1221
  }
978
1222
  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)) {
1223
+ const localEdit = !this.matchesBase(inst, p) && readLocal(p, inst.eolMode)?.hash !== state.blob;
1224
+ if (state.blob !== inst.baseHash || localEdit) {
980
1225
  Object.assign(patch, this.writeInstance(inst, p, state.blob, state.eol, state.exec));
981
- this.log(`${p}: updated to version ${state.version}`);
1226
+ this.log(`${p}: updated to version ${state.version}${localEdit ? " (local change replaced)" : ""}`);
1227
+ if (localEdit) {
1228
+ const name = path4.basename(p);
1229
+ this.notify({
1230
+ kind: "revert",
1231
+ level: "info",
1232
+ short: name,
1233
+ artifactId: inst.artifactId,
1234
+ title: inst.gitMode === "keep" ? "Tether undid a git change" : "Tether undid your change",
1235
+ 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.`
1236
+ });
1237
+ }
982
1238
  }
983
1239
  this.db.update(inst.instanceId, patch);
984
1240
  } catch (e) {
985
1241
  if (!(e instanceof LockedError)) throw e;
986
- this.db.update(inst.instanceId, { lastError: e.message });
1242
+ this.lockedNotice(inst, e);
987
1243
  }
988
1244
  }
1245
+ lockedNotice(inst, e) {
1246
+ 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) });
1247
+ this.db.update(inst.instanceId, { lastError: e.message });
1248
+ }
989
1249
  /** If `to` is occupied by something that isn't this instance, move it aside (keep both). */
990
1250
  makeRoom(to, inst) {
991
1251
  const st = statOrNull(to);
@@ -1014,6 +1274,7 @@ var Engine = class {
1014
1274
  }
1015
1275
  for (const inst of this.db.all()) {
1016
1276
  if (inst.mode !== "normal" || inst.kind !== "file" || inst.detached) continue;
1277
+ if (this.isGitBusy(inst)) continue;
1017
1278
  try {
1018
1279
  if (inst.needsRestore) {
1019
1280
  if (this.cache.has(inst.baseHash)) this.restore(inst);
@@ -1034,9 +1295,18 @@ var Engine = class {
1034
1295
  if (!this.cache.has(cur.baseHash)) continue;
1035
1296
  this.db.update(cur.instanceId, { ...this.writeInstance(cur, cur.path, cur.baseHash), lastError: null });
1036
1297
  this.log(`${cur.path}: local change reverted (use \`tether edit\` to change it)`);
1298
+ const byGit = cur.gitMode === "keep";
1299
+ this.notify({
1300
+ kind: "revert",
1301
+ level: "info",
1302
+ short: path4.basename(cur.path),
1303
+ artifactId: cur.artifactId,
1304
+ title: byGit ? "Tether undid a git change" : "Tether undid your change",
1305
+ 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' : ""}.`
1306
+ });
1037
1307
  } catch (e) {
1038
1308
  if (!(e instanceof LockedError)) throw e;
1039
- this.db.update(inst.instanceId, { lastError: e.message });
1309
+ this.lockedNotice(inst, e);
1040
1310
  }
1041
1311
  }
1042
1312
  }
@@ -1094,7 +1364,13 @@ var Engine = class {
1094
1364
  return { abs, bytes: canonical, sha256: sha256(canonical), size: canonical.length, eol: eolMode, exec: process.platform !== "win32" && !!(st.mode & 73), stat: st };
1095
1365
  }
1096
1366
  /** `tether manage <path> --repo <repo>`: upload; the caller becomes the owner. */
1097
- async manage({ path: target, repo: repoRef, eol }) {
1367
+ /** `tether manage`, then apply the git mode chosen for a path inside a git repository. */
1368
+ async manage({ gitMode, ...opts }) {
1369
+ const r = await this.manageCore(opts);
1370
+ if (gitMode) this.applyGitMode(this.instanceAt(r.path), gitMode);
1371
+ return r;
1372
+ }
1373
+ async manageCore({ path: target, repo: repoRef, eol }) {
1098
1374
  const abs = canonicalPath(path4.resolve(target));
1099
1375
  const st = statOrNull(abs);
1100
1376
  if (!st) throw new UserError(`${abs} does not exist`);
@@ -1234,11 +1510,25 @@ var Engine = class {
1234
1510
  return { artifactId: child.artifactId, relPath: rel, folder: folder.path };
1235
1511
  }
1236
1512
  /** `tether pull <shareId> [dest]`. Never overwrites existing user files. */
1237
- async pull({ shareId, dest, cwd }) {
1513
+ /** `tether pull`; with peek, only report where it would go and the caller's role. */
1514
+ async pull({ gitMode, peek, ...opts }) {
1515
+ if (peek) {
1516
+ const r2 = await this.api.call("POST", "/pull", { shareId: opts.shareId, peek: true });
1517
+ return { role: r2.role, kind: r2.artifact.kind, path: this.pullTarget(r2.artifact, opts) };
1518
+ }
1519
+ const r = await this.pullCore(opts, gitMode);
1520
+ if (gitMode) this.applyGitMode(this.instanceAt(r.path), gitMode);
1521
+ return r;
1522
+ }
1523
+ pullTarget(a, { dest, cwd }) {
1524
+ const target = path4.resolve(cwd, dest ?? a.name);
1525
+ return dest && statOrNull(target)?.isDirectory() && a.kind === "file" ? path4.join(target, a.name) : target;
1526
+ }
1527
+ async pullCore({ shareId, dest, cwd }, gitMode) {
1238
1528
  const r = await this.api.call("POST", "/pull", { shareId });
1239
1529
  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);
1530
+ if (gitMode === "follow" && r.role === "viewer") throw new UserError("git follow mode needs editor access; you are a viewer (use --git keep)");
1531
+ const target = this.pullTarget(a, { dest, cwd });
1242
1532
  if (this.instanceAt(target)) throw new UserError(`${target} is already a managed copy`);
1243
1533
  const base = { repoId: a.repoId, role: r.role, canDelete: r.canDelete };
1244
1534
  if (a.kind === "file") {
@@ -1331,13 +1621,54 @@ var Engine = class {
1331
1621
  }
1332
1622
  return changed;
1333
1623
  }
1334
- edit({ path: p }) {
1624
+ /**
1625
+ * `tether edit [--realtime]`. Realtime claims a live session per file (editors only, one
1626
+ * holder per file). A plain edit warns about files someone else is editing live.
1627
+ */
1628
+ async edit({ path: p, realtime = false }) {
1335
1629
  const ts = this.targets(p);
1336
1630
  const unmanaged = ts.filter((i) => i.mode === "unmanaged" && !i.detached);
1337
1631
  if (unmanaged.length === ts.length) throw new UserError("these files are unmanaged; use `tether track` first");
1338
- return { changed: this.setMode(p, "editing", ["normal"]) };
1632
+ const files2 = ts.filter((i) => i.kind === "file" && ["normal", "editing"].includes(i.mode) && !i.detached);
1633
+ if (realtime) {
1634
+ if (!files2.length) throw new UserError("nothing here to edit");
1635
+ const viewerOf = files2.find((i) => i.role === "viewer");
1636
+ if (viewerOf) throw new UserError(`realtime editing needs editor access; you are a viewer of ${viewerOf.path}`);
1637
+ const claimed = [];
1638
+ for (const f of files2) {
1639
+ try {
1640
+ await this.api.call("POST", `/artifacts/${f.artifactId}/realtime`, { action: "start" });
1641
+ claimed.push(f);
1642
+ } catch (e) {
1643
+ for (const c of claimed) await this.releaseRealtime(c);
1644
+ if (e instanceof ApiError && e.status === 409 && e.body?.realtime) {
1645
+ const s = e.body.realtime;
1646
+ throw new UserError(`${f.path} is already being edited in realtime by ${s.email} (since ${new Date(s.since).toLocaleTimeString()}).
1647
+ You can still run \`tether edit\` without --realtime, but expect merge conflicts.`);
1648
+ }
1649
+ throw e;
1650
+ }
1651
+ }
1652
+ this.setMode(p, "editing", ["normal", "editing"]);
1653
+ for (const f of files2) this.db.update(f.instanceId, { mode: "editing", realtime: true, lastError: null });
1654
+ return { changed: files2.map((f) => f.path), realtime: true };
1655
+ }
1656
+ const changed = this.setMode(p, "editing", ["normal"]);
1657
+ const warnings = [];
1658
+ if (files2.length) {
1659
+ try {
1660
+ const { results } = await this.api.call("POST", "/sync/check", { items: files2.map((i) => ({ artifactId: i.artifactId, version: i.baseVersion })) });
1661
+ results.forEach((r, k) => {
1662
+ if (r.realtime && !r.realtime.mine) warnings.push({ path: files2[k].path, email: r.realtime.email, since: r.realtime.since });
1663
+ });
1664
+ } catch (e) {
1665
+ if (!(e instanceof OfflineError)) throw e;
1666
+ }
1667
+ }
1668
+ return { changed, warnings };
1339
1669
  }
1340
- unmanage({ path: p }) {
1670
+ async unmanage({ path: p }) {
1671
+ for (const i of this.targets(p).filter((x) => x.realtime)) await this.releaseRealtime(i);
1341
1672
  return { changed: this.setMode(p, "unmanaged", ["normal", "editing"]) };
1342
1673
  }
1343
1674
  /**
@@ -1420,6 +1751,7 @@ var Engine = class {
1420
1751
  for (const f of this.targets(p).filter((i) => i.kind === "folder" && i.mode === from)) {
1421
1752
  if (!this.db.childrenOf(f.instanceId).some((c) => c.mode === from && !c.detached)) this.db.update(f.instanceId, { mode: "normal" });
1422
1753
  }
1754
+ for (const i of this.db.all().filter((x) => x.realtime && x.mode !== "editing")) await this.releaseRealtime(i);
1423
1755
  await this.tick();
1424
1756
  return { results: out };
1425
1757
  }
@@ -1544,6 +1876,9 @@ var Engine = class {
1544
1876
  }
1545
1877
  }
1546
1878
  if (i.orphanReason) flags.push(i.orphanReason);
1879
+ if (i.realtime && i.mode === "editing") flags.push("realtime: your saves are pushed live");
1880
+ if (i.gitMode) flags.push(`git: ${i.gitMode}`);
1881
+ if (i.remoteRealtime && i.mode !== "orphaned") flags.push(`realtime edits by ${JSON.parse(i.remoteRealtime).email}`);
1547
1882
  return {
1548
1883
  instanceId: i.instanceId,
1549
1884
  artifactId: i.artifactId,
@@ -1592,15 +1927,94 @@ var Engine = class {
1592
1927
  }
1593
1928
  };
1594
1929
 
1930
+ // packages/client/src/notifier.js
1931
+ import fs7 from "node:fs";
1932
+ import { spawn } from "node:child_process";
1933
+ var AUMID = "Tether.Sync";
1934
+ var FLUSH_MS = 3e3;
1935
+ var LEVELS = { info: 0, warn: 1, error: 2 };
1936
+ var Notifier = class {
1937
+ /** setting: 'all' | 'errors' | 'off' (config key `notifications`). */
1938
+ constructor({ setting = () => "all", consoleUrl = () => "", log = () => {
1939
+ } } = {}) {
1940
+ this.setting = setting;
1941
+ this.consoleUrl = consoleUrl;
1942
+ this.log = log;
1943
+ this.pending = /* @__PURE__ */ new Map();
1944
+ this.cooldown = /* @__PURE__ */ new Map();
1945
+ this.timer = null;
1946
+ this.registered = false;
1947
+ }
1948
+ /**
1949
+ * event: { kind, level: 'info'|'warn'|'error', title, body, path?, artifactId?, key?, cooldownMs? }
1950
+ * Events sharing `key` are suppressed for cooldownMs (default: no cooldown).
1951
+ */
1952
+ notify(event) {
1953
+ const s = this.setting();
1954
+ if (s === "off" || s === "errors" && LEVELS[event.level] < LEVELS.warn) return;
1955
+ if (event.key) {
1956
+ if ((this.cooldown.get(event.key) ?? 0) > Date.now()) return;
1957
+ if (event.cooldownMs) this.cooldown.set(event.key, Date.now() + event.cooldownMs);
1958
+ }
1959
+ if (!this.pending.has(event.kind)) this.pending.set(event.kind, []);
1960
+ this.pending.get(event.kind).push(event);
1961
+ this.timer ??= setTimeout(() => this.flush(), FLUSH_MS);
1962
+ }
1963
+ flush() {
1964
+ this.timer = null;
1965
+ for (const events of this.pending.values()) {
1966
+ const first = events[0];
1967
+ const more = events.length - 1;
1968
+ const title = more ? `${first.title} (+${more} more)` : first.title;
1969
+ const body = more ? `${first.body}
1970
+ Also: ${events.slice(1, 4).map((e) => e.short ?? e.title).join("; ")}${more > 3 ? "\u2026" : ""}` : first.body;
1971
+ const base = this.consoleUrl().replace(/\/$/, "");
1972
+ const url = base ? first.artifactId && !more ? `${base}/artifacts/${first.artifactId}` : base : "";
1973
+ this.show({ title, body, url, level: first.level });
1974
+ }
1975
+ this.pending.clear();
1976
+ }
1977
+ show(n) {
1978
+ this.log(`notify [${n.level}] ${n.title}: ${n.body.replace(/\n/g, " ")}`);
1979
+ if (process.env.TETHER_NOTIFY_LOG) {
1980
+ fs7.appendFileSync(process.env.TETHER_NOTIFY_LOG, JSON.stringify(n) + "\n");
1981
+ return;
1982
+ }
1983
+ try {
1984
+ if (isWindows) this.toast(n);
1985
+ else spawn("notify-send", ["--app-name=Tether", n.level === "info" ? "--urgency=low" : "--urgency=normal", n.title, n.body], { stdio: "ignore" }).on("error", () => {
1986
+ });
1987
+ } catch (e) {
1988
+ this.log(`notification failed: ${e.message}`);
1989
+ }
1990
+ }
1991
+ toast({ title, body, url }) {
1992
+ const esc = (s) => s.replace(/[<>&"']/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", '"': "&quot;", "'": "&apos;" })[c]);
1993
+ const launch = url ? ` activationType="protocol" launch="${esc(url)}"` : "";
1994
+ const xml = `<toast${launch}><visual><binding template="ToastGeneric"><text>${esc(title)}</text><text>${esc(body)}</text></binding></visual></toast>`;
1995
+ 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';`;
1996
+ this.registered = true;
1997
+ const script = `${register}
1998
+ [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
1999
+ [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null
2000
+ $x = New-Object Windows.Data.Xml.Dom.XmlDocument; $x.LoadXml('${xml.replace(/'/g, "''")}')
2001
+ [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('${AUMID}').Show((New-Object Windows.UI.Notifications.ToastNotification $x))`;
2002
+ const child = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], { stdio: "ignore", windowsHide: true });
2003
+ child.on("error", (e) => this.log(`notification failed: ${e.message}`));
2004
+ }
2005
+ };
2006
+
1595
2007
  // packages/client/src/daemon.js
1596
- var VERSION = "0.1.0";
2008
+ import { fileURLToPath } from "node:url";
2009
+ var REALTIME_TICK_SECONDS = 3;
2010
+ var UPDATE_CHECK_MS = 24 * 60 * 60 * 1e3;
1597
2011
  async function runDaemon() {
1598
2012
  const home = tetherHome();
1599
- fs7.mkdirSync(home, { recursive: true });
2013
+ fs8.mkdirSync(home, { recursive: true });
1600
2014
  process.chdir(home);
1601
2015
  const f = files(home);
1602
2016
  const recent = [];
1603
- const logStream = fs7.createWriteStream(f.log, { flags: "a" });
2017
+ const logStream = fs8.createWriteStream(f.log, { flags: "a" });
1604
2018
  const log = (msg) => {
1605
2019
  const line = `${(/* @__PURE__ */ new Date()).toISOString()} ${msg}`;
1606
2020
  recent.push(line);
@@ -1613,26 +2027,62 @@ async function runDaemon() {
1613
2027
  const api = new Api({ config, credentials });
1614
2028
  const cache = new BlobCache(f.blobs);
1615
2029
  const tracker = await createTracker(config);
1616
- const engine = new Engine({ db, api, cache, tracker, config, log });
2030
+ const notifier = new Notifier({ setting: () => config.get("notifications"), consoleUrl: () => config.consoleUrl, log });
2031
+ const engine = new Engine({
2032
+ db,
2033
+ api,
2034
+ cache,
2035
+ tracker,
2036
+ config,
2037
+ log,
2038
+ home,
2039
+ notify: (e) => notifier.notify(e),
2040
+ cliPath: fileURLToPath(new URL("../bin/tether.js", import.meta.url))
2041
+ });
1617
2042
  let timer = null;
1618
2043
  let quickTimer = null;
2044
+ let realtimeTimer = null;
1619
2045
  let stopping = false;
2046
+ let latest = null;
1620
2047
  async function runTick(opts) {
1621
2048
  try {
1622
2049
  await engine.locked(() => engine.tick(opts));
1623
2050
  } catch (e) {
1624
2051
  log(`tick failed: ${e.stack || e.message}`);
2052
+ 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
2053
  }
1626
2054
  refreshWatchers();
1627
2055
  scheduleGrace();
2056
+ schedule();
1628
2057
  }
1629
2058
  function schedule() {
1630
2059
  clearTimeout(timer);
1631
2060
  if (stopping) return;
1632
- timer = setTimeout(async () => {
1633
- await runTick();
1634
- schedule();
1635
- }, config.get("tickSeconds") * 1e3);
2061
+ const seconds = engine.realtimeActive ? Math.min(config.get("tickSeconds"), REALTIME_TICK_SECONDS) : config.get("tickSeconds");
2062
+ timer = setTimeout(() => runTick(), seconds * 1e3);
2063
+ }
2064
+ function realtimePass(delay = 1e3) {
2065
+ if (stopping || !engine.realtimeInstances().length) return;
2066
+ clearTimeout(realtimeTimer);
2067
+ realtimeTimer = setTimeout(async () => {
2068
+ realtimeTimer = null;
2069
+ try {
2070
+ await engine.locked(async () => {
2071
+ await engine.locateAll();
2072
+ await engine.syncRealtime();
2073
+ });
2074
+ } catch (e) {
2075
+ log(`realtime push failed: ${e.message}`);
2076
+ }
2077
+ }, delay);
2078
+ }
2079
+ let notifiedVersion = null;
2080
+ async function checkForUpdate() {
2081
+ latest = await latestVersion() ?? latest;
2082
+ if (isNewer(latest, VERSION) && notifiedVersion !== latest) {
2083
+ notifiedVersion = latest;
2084
+ notifier.notify({ kind: "update", level: "info", title: "Tether update available", body: `Version ${latest} is available (you have ${VERSION}). Run "tether update".` });
2085
+ }
1636
2086
  }
1637
2087
  function quickPass(delay) {
1638
2088
  if (quickTimer || stopping) return;
@@ -1667,7 +2117,10 @@ async function runDaemon() {
1667
2117
  for (const [dir, recursive] of want) {
1668
2118
  if (watchers.has(dir)) continue;
1669
2119
  try {
1670
- const w = fs7.watch(dir, { recursive, persistent: false }, () => quickPass(250));
2120
+ const w = fs8.watch(dir, { recursive, persistent: false }, () => {
2121
+ quickPass(250);
2122
+ realtimePass();
2123
+ });
1671
2124
  w.on("error", () => {
1672
2125
  w.close();
1673
2126
  watchers.delete(dir);
@@ -1714,7 +2167,15 @@ async function runDaemon() {
1714
2167
  ping: () => ({ pid: process.pid, version: VERSION, home, tracker: tracker.name, server: config.get("server") }),
1715
2168
  async status(p) {
1716
2169
  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) };
2170
+ return {
2171
+ user: creds?.user ?? null,
2172
+ server: config.get("server"),
2173
+ offline: engine.offline,
2174
+ lastTick: engine.lastTick,
2175
+ version: VERSION,
2176
+ latest,
2177
+ instances: await L((x) => engine.status(x))(p)
2178
+ };
1718
2179
  },
1719
2180
  sync: async () => {
1720
2181
  await runTick();
@@ -1742,8 +2203,18 @@ async function runDaemon() {
1742
2203
  manage: L((p) => engine.manage(p)),
1743
2204
  add: L((p) => engine.add(p)),
1744
2205
  pull: L((p) => engine.pull(p)),
1745
- edit: L((p) => engine.edit(p)),
2206
+ edit: async (p) => {
2207
+ const r = await L((x) => engine.edit(x))(p);
2208
+ schedule();
2209
+ return r;
2210
+ },
1746
2211
  unmanage: L((p) => engine.unmanage(p)),
2212
+ "git.mode": L((p) => engine.gitMode(p)),
2213
+ // Called by git hooks: answer at once so git is not held up; reconcile in the background.
2214
+ "git.sync": ({ repo }) => {
2215
+ engine.locked(() => engine.gitReconcile(path5.resolve(repo))).catch((e) => log(`git sync failed: ${e.message}`));
2216
+ return { queued: true };
2217
+ },
1747
2218
  plan: L((p) => engine.plan(p)),
1748
2219
  resolve: L((p) => engine.resolve(p)),
1749
2220
  diff: L((p) => engine.diff(p)),
@@ -1809,25 +2280,29 @@ async function runDaemon() {
1809
2280
  }
1810
2281
  }
1811
2282
  const server = await listen(ipcEndpoint(home), handle);
1812
- fs7.writeFileSync(f.pid, String(process.pid));
2283
+ fs8.writeFileSync(f.pid, String(process.pid));
1813
2284
  log(`tetherd ${VERSION} started (pid ${process.pid}, tracker ${tracker.name}, server ${config.get("server")})`);
1814
2285
  function shutdown(code) {
1815
2286
  if (stopping) return;
1816
2287
  stopping = true;
1817
2288
  clearTimeout(timer);
1818
2289
  clearTimeout(quickTimer);
2290
+ clearTimeout(realtimeTimer);
2291
+ clearInterval(updateTimer);
1819
2292
  for (const w of watchers.values()) w.close();
1820
2293
  server.close();
1821
2294
  engine.locked(() => db.close()).finally(() => {
1822
- fs7.rmSync(f.pid, { force: true });
2295
+ fs8.rmSync(f.pid, { force: true });
1823
2296
  log("tetherd stopped");
1824
2297
  logStream.end(() => process.exit(code));
1825
2298
  });
1826
2299
  }
1827
2300
  process.on("SIGINT", () => shutdown(0));
1828
2301
  process.on("SIGTERM", () => shutdown(0));
2302
+ await engine.locked(() => engine.syncGitHooks()).catch((e) => log(`git hooks: ${e.message}`));
2303
+ const updateTimer = setInterval(checkForUpdate, UPDATE_CHECK_MS);
2304
+ setTimeout(checkForUpdate, 5e3);
1829
2305
  await runTick();
1830
- schedule();
1831
2306
  }
1832
2307
  export {
1833
2308
  VERSION,