tether-sync 0.1.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,21 +1,27 @@
1
- # tether-sync
2
-
3
- Keep files and folders identical to a central source of truth, wherever they live on your machine.
4
-
5
- ```sh
6
- npm install -g tether-sync
7
- tether login # opens the browser to sign in
8
- tether pull <share-id> # get a managed copy of a file or folder
9
- tether status
10
- ```
11
-
12
- Owners publish files with `tether manage <path> --repo <repo>` and share the printed share ID. A managed copy:
13
- - follows the file when you move or rename it
14
- - reverts local edits until you run `tether edit <path>`
15
- - pushes your changes with `tether done <path>` (if you are an editor)
16
-
17
- Run `tether --help` for every command. The web console is at https://tether-b10cf.web.app.
18
-
19
- Requires Node.js 22.13 or newer. Windows (NTFS/ReFS) is fully supported; Linux support is experimental.
20
-
21
- Source: https://github.com/SaiEthihasChanda/Tether
1
+ # tether-sync
2
+
3
+ Keep files and folders identical to a central source of truth, wherever they live on your machine.
4
+
5
+ ```sh
6
+ npm install -g tether-sync
7
+ tether login # opens the browser to sign in
8
+ tether pull <share-id> # get a managed copy of a file or folder
9
+ tether status
10
+ ```
11
+
12
+ Owners publish files with `tether manage <path> --repo <repo>` and share the printed share ID. A managed copy:
13
+ - follows the file when you move or rename it
14
+ - reverts local edits until you run `tether edit <path>`
15
+ - pushes your changes with `tether done <path>` (if you are an editor)
16
+
17
+ Editors can also run `tether edit <path> --realtime` to push every save live, one live editor per file. `tether update` installs the latest version.
18
+
19
+ Inside a git repository, Tether asks whether git's changes should be reverted (keep), or accepted and published after pull, checkout and merge (follow, for editors). Change it with `tether git <path> --mode`.
20
+
21
+ Desktop notifications cover reverted changes, conflicts, lost live sessions and revoked access. Set them with `tether config set notifications all|errors|off`.
22
+
23
+ Run `tether --help` for every command. The web console is at https://tether-b10cf.web.app.
24
+
25
+ Requires Node.js 22.13 or newer. Windows (NTFS/ReFS) is fully supported; Linux support is experimental.
26
+
27
+ Source: https://github.com/SaiEthihasChanda/Tether
@@ -0,0 +1,382 @@
1
+ // packages/client/src/paths.js
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import crypto from "node:crypto";
5
+ var isWindows = process.platform === "win32";
6
+ function tetherHome() {
7
+ if (process.env.TETHER_HOME) return path.resolve(process.env.TETHER_HOME);
8
+ if (isWindows) return path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"), "Tether");
9
+ return path.join(process.env.XDG_STATE_HOME || path.join(os.homedir(), ".local", "state"), "tether");
10
+ }
11
+ function ipcEndpoint(home = tetherHome()) {
12
+ const tag = crypto.createHash("sha1").update(home.toLowerCase()).digest("hex").slice(0, 12);
13
+ if (isWindows) return `\\\\.\\pipe\\tether-${os.userInfo().username}-${tag}`;
14
+ return path.join(home, "tetherd.sock");
15
+ }
16
+ var files = (home = tetherHome()) => ({
17
+ db: path.join(home, "tether.db"),
18
+ blobs: path.join(home, "blobs"),
19
+ config: path.join(home, "config.json"),
20
+ credentials: path.join(home, isWindows ? "credentials.bin" : "credentials.json"),
21
+ log: path.join(home, "tetherd.log"),
22
+ pid: path.join(home, "tetherd.pid")
23
+ });
24
+
25
+ // packages/client/src/ipc.js
26
+ import net from "node:net";
27
+ import fs from "node:fs";
28
+ var IpcError = class extends Error {
29
+ constructor(message, code) {
30
+ super(message);
31
+ this.code = code;
32
+ }
33
+ };
34
+ async function listen(endpoint, handler) {
35
+ if (!isWindows && fs.existsSync(endpoint)) {
36
+ const alive = await call(endpoint, "ping", {}, 1e3).then(() => true, () => false);
37
+ if (alive) throw new IpcError("another tetherd is already running", "EADDRINUSE");
38
+ fs.rmSync(endpoint, { force: true });
39
+ }
40
+ const server = net.createServer((socket) => {
41
+ let buf = "";
42
+ socket.setEncoding("utf8");
43
+ socket.on("data", async (chunk) => {
44
+ buf += chunk;
45
+ const nl = buf.indexOf("\n");
46
+ if (nl < 0) return;
47
+ let req;
48
+ try {
49
+ req = JSON.parse(buf.slice(0, nl));
50
+ } catch {
51
+ socket.end(JSON.stringify({ error: { message: "bad request" } }) + "\n");
52
+ return;
53
+ }
54
+ buf = "";
55
+ let res;
56
+ try {
57
+ res = { result: await handler(req.method, req.params ?? {}) ?? null };
58
+ } catch (e) {
59
+ res = { error: { message: e.message, code: e.code ?? e.name, status: e.status } };
60
+ }
61
+ socket.end(JSON.stringify(res) + "\n");
62
+ });
63
+ socket.on("error", () => {
64
+ });
65
+ });
66
+ await new Promise((resolve, reject) => {
67
+ server.once("error", (e) => reject(e.code === "EADDRINUSE" ? new IpcError("another tetherd is already running", "EADDRINUSE") : e));
68
+ server.listen(endpoint, resolve);
69
+ });
70
+ if (!isWindows) fs.chmodSync(endpoint, 384);
71
+ return server;
72
+ }
73
+ function call(endpoint, method, params = {}, timeoutMs = 10 * 60 * 1e3) {
74
+ return new Promise((resolve, reject) => {
75
+ const socket = net.connect(endpoint);
76
+ let buf = "";
77
+ const timer = setTimeout(() => {
78
+ socket.destroy();
79
+ reject(new IpcError("the daemon did not answer in time", "ETIMEDOUT"));
80
+ }, timeoutMs);
81
+ socket.setEncoding("utf8");
82
+ socket.on("connect", () => socket.write(JSON.stringify({ method, params }) + "\n"));
83
+ socket.on("data", (c) => buf += c);
84
+ socket.on("error", (e) => {
85
+ clearTimeout(timer);
86
+ reject(new IpcError("the Tether daemon is not running", e.code === "ENOENT" || e.code === "ECONNREFUSED" ? "ENODAEMON" : e.code));
87
+ });
88
+ socket.on("end", () => {
89
+ clearTimeout(timer);
90
+ try {
91
+ const res = JSON.parse(buf);
92
+ if (res.error) reject(new IpcError(res.error.message, res.error.code));
93
+ else resolve(res.result);
94
+ } catch {
95
+ reject(new IpcError("invalid response from the daemon"));
96
+ }
97
+ });
98
+ });
99
+ }
100
+
101
+ // packages/client/src/version.js
102
+ var VERSION = "0.4.0";
103
+
104
+ // packages/client/src/update.js
105
+ import fs2 from "node:fs";
106
+ import path2 from "node:path";
107
+ import { spawnSync } from "node:child_process";
108
+ import { fileURLToPath } from "node:url";
109
+ var PACKAGE = "tether-sync";
110
+ var registry = () => (process.env.TETHER_UPDATE_REGISTRY || "https://registry.npmjs.org").replace(/\/$/, "");
111
+ async function latestVersion() {
112
+ try {
113
+ const res = await fetch(`${registry()}/${PACKAGE}/latest`, { signal: AbortSignal.timeout(1e4) });
114
+ if (!res.ok) return null;
115
+ return (await res.json()).version ?? null;
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+ function isNewer(a, b) {
121
+ if (!a || !b) return false;
122
+ const parse = (v) => {
123
+ const [core, pre] = v.split("-");
124
+ return { nums: core.split(".").map((n) => Number(n) || 0), pre: pre ?? null };
125
+ };
126
+ const x = parse(a);
127
+ const y = parse(b);
128
+ for (let i = 0; i < 3; i++) if ((x.nums[i] ?? 0) !== (y.nums[i] ?? 0)) return (x.nums[i] ?? 0) > (y.nums[i] ?? 0);
129
+ if (x.pre === y.pre) return false;
130
+ if (x.pre === null) return true;
131
+ if (y.pre === null) return false;
132
+ return x.pre > y.pre;
133
+ }
134
+ function installDir(from = fileURLToPath(import.meta.url)) {
135
+ const dir = path2.resolve(path2.dirname(from), "..");
136
+ try {
137
+ return JSON.parse(fs2.readFileSync(path2.join(dir, "package.json"), "utf8")).name === PACKAGE ? dir : null;
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+ function prefixOf(pkgDir, windows = isWindows) {
143
+ const parent = path2.dirname(path2.dirname(pkgDir));
144
+ return !windows && path2.basename(parent) === "lib" ? path2.dirname(parent) : parent;
145
+ }
146
+ function npmInstall(spec, prefix) {
147
+ const args = ["install", "-g", "--prefix", prefix, spec];
148
+ const bin = path2.dirname(process.execPath);
149
+ const npmCli = [path2.join(bin, "node_modules", "npm", "bin", "npm-cli.js"), path2.join(bin, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js")].find((p) => fs2.existsSync(p));
150
+ const r = npmCli ? spawnSync(process.execPath, [npmCli, ...args], { stdio: "inherit" }) : spawnSync(isWindows ? "npm.cmd" : "npm", args.map((a) => isWindows ? `"${a}"` : a), { stdio: "inherit", shell: isWindows });
151
+ return r.status ?? 1;
152
+ }
153
+
154
+ // packages/client/src/git.js
155
+ import fs3 from "node:fs";
156
+ import path3 from "node:path";
157
+ import { execFileSync, spawnSync as spawnSync2 } from "node:child_process";
158
+ var GIT_MODES = ["keep", "follow"];
159
+ var gitExe;
160
+ function gitBinary() {
161
+ if (gitExe !== void 0) return gitExe;
162
+ const candidates = ["git"];
163
+ if (isWindows) {
164
+ const pf = [process.env.ProgramFiles, process.env["ProgramFiles(x86)"], process.env.LOCALAPPDATA && path3.join(process.env.LOCALAPPDATA, "Programs")].filter(Boolean);
165
+ for (const p of pf) candidates.push(path3.join(p, "Git", "cmd", "git.exe"));
166
+ const desktop = process.env.LOCALAPPDATA && path3.join(process.env.LOCALAPPDATA, "GitHubDesktop");
167
+ if (desktop && fs3.existsSync(desktop)) {
168
+ for (const app of fs3.readdirSync(desktop).filter((d) => d.startsWith("app-")).sort().reverse()) {
169
+ candidates.push(path3.join(desktop, app, "resources", "app", "git", "cmd", "git.exe"));
170
+ }
171
+ }
172
+ } else candidates.push("/usr/bin/git", "/usr/local/bin/git");
173
+ gitExe = candidates.find((c) => spawnSync2(c, ["--version"], { stdio: "ignore", windowsHide: true }).status === 0) ?? null;
174
+ return gitExe;
175
+ }
176
+ function git(cwd, args) {
177
+ const exe = gitBinary();
178
+ if (!exe) throw new Error("git is not installed");
179
+ return execFileSync(exe, ["-C", cwd, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], windowsHide: true, maxBuffer: 16 * 1024 * 1024 }).replace(/\r?\n$/, "");
180
+ }
181
+ function gitOk(cwd, args) {
182
+ const exe = gitBinary();
183
+ return !!exe && spawnSync2(exe, ["-C", cwd, ...args], { stdio: "ignore", windowsHide: true }).status === 0;
184
+ }
185
+ function findRepo(p) {
186
+ let dir = path3.resolve(p);
187
+ while (!fs3.existsSync(dir) || !fs3.statSync(dir).isDirectory()) {
188
+ const up = path3.dirname(dir);
189
+ if (up === dir) return null;
190
+ dir = up;
191
+ }
192
+ if (gitBinary()) {
193
+ try {
194
+ const [root, gitDir] = git(dir, ["rev-parse", "--show-toplevel", "--absolute-git-dir"]).split(/\r?\n/);
195
+ return { root: path3.resolve(root), gitDir: path3.resolve(gitDir) };
196
+ } catch {
197
+ return null;
198
+ }
199
+ }
200
+ for (let d = dir; ; d = path3.dirname(d)) {
201
+ const dot = path3.join(d, ".git");
202
+ const st = fs3.existsSync(dot) ? fs3.statSync(dot) : null;
203
+ if (st?.isDirectory()) return { root: d, gitDir: dot };
204
+ if (st?.isFile()) {
205
+ const m = fs3.readFileSync(dot, "utf8").match(/^gitdir:\s*(.+)$/m);
206
+ if (m) return { root: d, gitDir: path3.resolve(d, m[1].trim()) };
207
+ }
208
+ if (path3.dirname(d) === d) return null;
209
+ }
210
+ }
211
+ function gitBusy(gitDir) {
212
+ if (!gitDir) return null;
213
+ const has = (f) => fs3.existsSync(path3.join(gitDir, f));
214
+ if (has("index.lock")) return "git is running";
215
+ if (has("MERGE_HEAD")) return "merge in progress";
216
+ if (has("rebase-merge") || has("rebase-apply")) return "rebase in progress";
217
+ if (has("CHERRY_PICK_HEAD")) return "cherry-pick in progress";
218
+ if (has("REVERT_HEAD")) return "revert in progress";
219
+ return null;
220
+ }
221
+ var rel = (root, abs) => path3.relative(root, abs).split(path3.sep).join("/");
222
+ var head = (root) => {
223
+ try {
224
+ return git(root, ["rev-parse", "--verify", "-q", "HEAD"]);
225
+ } catch {
226
+ return null;
227
+ }
228
+ };
229
+ function fileBlob(root, abs) {
230
+ try {
231
+ return git(root, ["-c", "filter.tether-keep.clean=", "hash-object", `--path=${rel(root, abs)}`, "--", abs]);
232
+ } catch {
233
+ return null;
234
+ }
235
+ }
236
+ function blobAt(root, rev, abs) {
237
+ try {
238
+ return git(root, ["rev-parse", "--verify", "-q", `${rev}:${rel(root, abs)}`]);
239
+ } catch {
240
+ return null;
241
+ }
242
+ }
243
+ function indexBlob(root, abs) {
244
+ try {
245
+ const line = git(root, ["ls-files", "-s", "--", rel(root, abs)]);
246
+ return line ? line.split(/\s+/)[1] : null;
247
+ } catch {
248
+ return null;
249
+ }
250
+ }
251
+ function changeFromGit(root, abs) {
252
+ const blob = fileBlob(root, abs);
253
+ if (!blob) return { fromGit: false };
254
+ const h = head(root);
255
+ if (h && blobAt(root, "HEAD", abs) === blob) return { fromGit: true, commit: h };
256
+ if (indexBlob(root, abs) === blob) return { fromGit: true, commit: null };
257
+ return { fromGit: false };
258
+ }
259
+ var isTracked = (root, abs) => gitOk(root, ["ls-files", "--error-unmatch", "--", rel(root, abs)]);
260
+ var FILTER = "tether-keep";
261
+ var CLEAN = `sh -c 'git cat-file blob ":$1" 2>/dev/null || cat' -- %f`;
262
+ function attributesFile(root) {
263
+ return path3.resolve(root, git(root, ["rev-parse", "--git-path", "info/attributes"]));
264
+ }
265
+ function attrLine(relPath) {
266
+ const pattern = /[\s"\\]/.test(relPath) ? `"/${relPath.replace(/["\\]/g, "\\$&")}"` : `/${relPath.replace(/[*?[\]!]/g, "\\$&")}`;
267
+ return `${pattern} filter=${FILTER}`;
268
+ }
269
+ var readLines = (file) => fs3.existsSync(file) ? fs3.readFileSync(file, "utf8").split(/\r?\n/).filter((l) => l !== "") : [];
270
+ function setKeep(root, abs, on) {
271
+ if (!gitBinary()) return false;
272
+ try {
273
+ const file = attributesFile(root);
274
+ const line = attrLine(rel(root, abs));
275
+ const lines = readLines(file);
276
+ const has = lines.includes(line);
277
+ if (on) {
278
+ git(root, ["config", `filter.${FILTER}.clean`, CLEAN]);
279
+ git(root, ["config", `filter.${FILTER}.required`, "false"]);
280
+ if (!has) {
281
+ fs3.mkdirSync(path3.dirname(file), { recursive: true });
282
+ fs3.writeFileSync(file, [...lines, line].join("\n") + "\n");
283
+ }
284
+ restage(root, abs);
285
+ } else if (has) {
286
+ const rest = lines.filter((l) => l !== line);
287
+ fs3.writeFileSync(file, rest.length ? rest.join("\n") + "\n" : "");
288
+ }
289
+ return true;
290
+ } catch {
291
+ return false;
292
+ }
293
+ }
294
+ function isKept(root, abs) {
295
+ try {
296
+ return readLines(attributesFile(root)).includes(attrLine(rel(root, abs)));
297
+ } catch {
298
+ return false;
299
+ }
300
+ }
301
+ function restage(root, abs) {
302
+ if (isTracked(root, abs)) gitOk(root, ["add", "--", rel(root, abs)]);
303
+ }
304
+ var isAncestor = (root, commit) => !!commit && gitOk(root, ["merge-base", "--is-ancestor", commit, "HEAD"]);
305
+ function changesBetween(root, from, to) {
306
+ try {
307
+ const out = git(root, ["-c", "core.quotePath=false", "log", "--reverse", "--first-parent", "--format=%x01", "--name-status", "-M", `${from}..${to}`]);
308
+ const renames = /* @__PURE__ */ new Map();
309
+ const added = /* @__PURE__ */ new Set();
310
+ for (const line of out.split(/\r?\n/)) {
311
+ const parts = line.split(" ");
312
+ if (parts.length < 2) continue;
313
+ const status = parts[0][0];
314
+ if (status === "R") {
315
+ const [, oldP, newP] = parts;
316
+ const origin = [...renames.entries()].find(([, cur]) => cur === oldP)?.[0] ?? oldP;
317
+ if (added.delete(oldP)) added.add(newP);
318
+ else renames.set(origin, newP);
319
+ } else if (status === "A") added.add(parts[1]);
320
+ else if (status === "D") {
321
+ added.delete(parts[1]);
322
+ for (const [o, cur] of renames) if (cur === parts[1]) renames.delete(o);
323
+ }
324
+ }
325
+ const abs = (p) => path3.join(root, ...p.split("/"));
326
+ return [
327
+ ...[...renames].map(([o, n]) => ({ status: "R", from: abs(o), to: abs(n) })),
328
+ ...[...added].map((p) => ({ status: "A", from: abs(p), to: abs(p) }))
329
+ ];
330
+ } catch {
331
+ return [];
332
+ }
333
+ }
334
+ var HOOKS = ["post-merge", "post-checkout", "post-rewrite"];
335
+ var begin = (home) => `# >>> tether (${home.replace(/\\/g, "/")}) >>>`;
336
+ var end = (home) => `# <<< tether (${home.replace(/\\/g, "/")}) <<<`;
337
+ function removeHooks(hooksDir, home) {
338
+ for (const hook of HOOKS) {
339
+ const file = path3.join(hooksDir, hook);
340
+ if (!fs3.existsSync(file)) continue;
341
+ let text = fs3.readFileSync(file, "utf8");
342
+ const b = text.indexOf(begin(home));
343
+ if (b < 0) continue;
344
+ const e = text.indexOf(end(home), b);
345
+ text = (text.slice(0, b) + (e < 0 ? "" : text.slice(e + end(home).length))).replace(/\n{3,}/g, "\n\n");
346
+ if (/^(#!.*\n)?\s*$/.test(text)) fs3.rmSync(file);
347
+ else fs3.writeFileSync(file, text.replace(/\s*$/, "\n"));
348
+ }
349
+ }
350
+
351
+ export {
352
+ isWindows,
353
+ tetherHome,
354
+ ipcEndpoint,
355
+ files,
356
+ IpcError,
357
+ listen,
358
+ call,
359
+ VERSION,
360
+ PACKAGE,
361
+ latestVersion,
362
+ isNewer,
363
+ installDir,
364
+ prefixOf,
365
+ npmInstall,
366
+ GIT_MODES,
367
+ gitBinary,
368
+ findRepo,
369
+ gitBusy,
370
+ head,
371
+ fileBlob,
372
+ blobAt,
373
+ indexBlob,
374
+ changeFromGit,
375
+ isTracked,
376
+ setKeep,
377
+ isKept,
378
+ restage,
379
+ isAncestor,
380
+ changesBetween,
381
+ removeHooks
382
+ };