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.
- package/README.md +27 -21
- package/bin/chunk-NDKBAHAG.js +248 -0
- package/bin/{daemon-WGNVBSEJ.js → daemon-KQ4ZTBYG.js} +510 -35
- package/bin/tether.js +85 -8
- package/bin/tetherd.js +1 -1
- package/package.json +1 -1
- package/bin/chunk-MGMU33LE.js +0 -109
|
@@ -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-
|
|
15
|
+
} from "./chunk-NDKBAHAG.js";
|
|
8
16
|
|
|
9
17
|
// packages/client/src/daemon.js
|
|
10
|
-
import
|
|
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.
|
|
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 === "
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1241
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) => ({ "<": "<", ">": ">", "&": "&", '"': """, "'": "'" })[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
|
-
|
|
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
|
-
|
|
2013
|
+
fs8.mkdirSync(home, { recursive: true });
|
|
1600
2014
|
process.chdir(home);
|
|
1601
2015
|
const f = files(home);
|
|
1602
2016
|
const recent = [];
|
|
1603
|
-
const logStream =
|
|
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
|
|
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
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
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 =
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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,
|