tether-sync 0.1.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -21
- package/bin/chunk-NMLHD2KU.js +382 -0
- package/bin/{daemon-WGNVBSEJ.js → daemon-5IFWP5O3.js} +1026 -72
- package/bin/tether.js +88 -9
- package/bin/tetherd.js +1 -1
- package/package.json +1 -1
- package/bin/chunk-MGMU33LE.js +0 -109
package/bin/tether.js
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
IpcError,
|
|
4
|
+
PACKAGE,
|
|
5
|
+
VERSION,
|
|
4
6
|
call,
|
|
7
|
+
findRepo,
|
|
8
|
+
installDir,
|
|
5
9
|
ipcEndpoint,
|
|
10
|
+
isNewer,
|
|
6
11
|
isWindows,
|
|
12
|
+
latestVersion,
|
|
13
|
+
npmInstall,
|
|
14
|
+
prefixOf,
|
|
7
15
|
tetherHome
|
|
8
|
-
} from "./chunk-
|
|
16
|
+
} from "./chunk-NMLHD2KU.js";
|
|
9
17
|
|
|
10
18
|
// packages/client/src/cli.js
|
|
11
19
|
import path2 from "node:path";
|
|
@@ -164,13 +172,35 @@ async function resolveFlow(p, from, opts) {
|
|
|
164
172
|
}
|
|
165
173
|
const { results } = await daemon("resolve", { path: target, from, items: plan.items, choices });
|
|
166
174
|
for (const r of results) {
|
|
167
|
-
const extra = r.error ? ` ERROR: ${r.error}` : r.version ? ` -> v${r.version}` : r.keptAs ? ` (your copy: ${r.keptAs})` : r.released ? ` (left at ${r.released})` : "";
|
|
175
|
+
const extra = r.error ? ` ERROR: ${r.error}` : r.version ? ` -> v${r.version}${r.sent ? ` (${r.sent})` : ""}` : r.keptAs ? ` (your copy: ${r.keptAs})` : r.released ? ` (left at ${r.released})` : "";
|
|
168
176
|
out(`${r.action.padEnd(9)} ${r.path}${extra}`);
|
|
169
177
|
}
|
|
170
178
|
if (results.some((r) => r.error)) process.exitCode = 1;
|
|
171
179
|
}
|
|
180
|
+
async function chooseGitMode(target, flag, canFollow) {
|
|
181
|
+
const repo = findRepo(target);
|
|
182
|
+
if (!repo) return void 0;
|
|
183
|
+
if (flag) {
|
|
184
|
+
if (flag === "follow" && !canFollow) throw new Error("--git follow needs editor access (you are a viewer); use --git keep or --git skip");
|
|
185
|
+
return flag;
|
|
186
|
+
}
|
|
187
|
+
if (!process.stdin.isTTY) {
|
|
188
|
+
out(`note: ${target} is inside the git repository ${repo.root}; Tether will revert git's changes to it. Choose with --git keep|follow|skip.`);
|
|
189
|
+
return "keep";
|
|
190
|
+
}
|
|
191
|
+
out(`${target} is inside the git repository ${repo.root}.`);
|
|
192
|
+
out("How should Tether treat it when git changes it?");
|
|
193
|
+
out(" [1] keep managing it: git's changes (pull, checkout, switch, merge) may get reverted to Tether's version");
|
|
194
|
+
out(" (git is told to ignore Tether's copy, so git status stays clean and pulls are not blocked)");
|
|
195
|
+
out(" [2] do not let Tether manage it");
|
|
196
|
+
if (canFollow) out(" [3] manage it, and after a git pull, checkout, switch or merge accept git's version and publish it to Tether");
|
|
197
|
+
else out(" (option 3, accepting git's changes, needs editor access)");
|
|
198
|
+
const choice = await ask(" > ", canFollow ? ["1", "2", "3"] : ["1", "2"]);
|
|
199
|
+
return { 1: "keep", 2: "skip", 3: "follow" }[choice];
|
|
200
|
+
}
|
|
172
201
|
function printStatus(s, verbose) {
|
|
173
202
|
out(`server: ${s.server} user: ${s.user ? s.user.email : "(not signed in)"}${s.offline ? ` OFFLINE: ${s.offline}` : ""}`);
|
|
203
|
+
if (isNewer(s.latest, s.version)) out(`update available: ${s.version} -> ${s.latest} (run \`tether update\`)`);
|
|
174
204
|
if (!s.instances.length) return out("nothing managed yet");
|
|
175
205
|
const folders = new Map(s.instances.filter((i) => i.kind === "folder").map((i) => [i.instanceId, i]));
|
|
176
206
|
for (const i of s.instances) {
|
|
@@ -197,7 +227,7 @@ async function main(argv) {
|
|
|
197
227
|
if (e.code === "EPIPE") process.exit(0);
|
|
198
228
|
throw e;
|
|
199
229
|
});
|
|
200
|
-
const program = new Command("tether").description("Keep files in sync with a central source of truth, wherever they live.").version(
|
|
230
|
+
const program = new Command("tether").description("Keep files in sync with a central source of truth, wherever they live.").version(VERSION);
|
|
201
231
|
program.showHelpAfterError();
|
|
202
232
|
program.command("login").description("sign in (opens the browser)").option("--email <email>", "sign in with email and password instead (local development)").option("--password <password>").option("--signup", "create the account (with --email)").action(async (o) => {
|
|
203
233
|
if (o.email) {
|
|
@@ -252,8 +282,10 @@ async function main(argv) {
|
|
|
252
282
|
const r = await daemon("repo.eol", { ref, glob, mode });
|
|
253
283
|
out(`eol rules: ${JSON.stringify(r.eolDefaults)}`);
|
|
254
284
|
});
|
|
255
|
-
program.command("manage <path>").description("upload a file or folder into a repo; you become its owner").requiredOption("--repo <repo>").addOption(new Option("--eol <mode>").choices(["auto", "lf", "crlf", "binary"])).action(async (p, o) => {
|
|
256
|
-
const
|
|
285
|
+
program.command("manage <path>").description("upload a file or folder into a repo; you become its owner").requiredOption("--repo <repo>").addOption(new Option("--eol <mode>").choices(["auto", "lf", "crlf", "binary"])).addOption(new Option("--git <mode>", "inside a git repo: keep (Tether reverts git changes), follow (publish git changes), skip").choices(["keep", "follow", "skip"])).action(async (p, o) => {
|
|
286
|
+
const gitMode = await chooseGitMode(path2.resolve(p), o.git, true);
|
|
287
|
+
if (gitMode === "skip") return out("not managed");
|
|
288
|
+
const r = await daemon("manage", { path: path2.resolve(p), repo: o.repo, eol: o.eol, gitMode });
|
|
257
289
|
out(`managing ${r.path} (${r.kind}${r.kind === "folder" ? `, ${r.files} files` : ""})`);
|
|
258
290
|
if (r.skippedSymlinks?.length) out(`skipped symlinks: ${r.skippedSymlinks.join(", ")}`);
|
|
259
291
|
out(`share ID: ${r.shareId}`);
|
|
@@ -262,18 +294,31 @@ async function main(argv) {
|
|
|
262
294
|
const r = await daemon("add", { path: path2.resolve(p) });
|
|
263
295
|
out(`added ${r.relPath} to ${r.folder}`);
|
|
264
296
|
});
|
|
265
|
-
program.command("pull <shareId> [dest]").description("get a managed copy").action(async (shareId, dest) => {
|
|
266
|
-
const
|
|
297
|
+
program.command("pull <shareId> [dest]").description("get a managed copy").addOption(new Option("--git <mode>", "inside a git repo: keep (Tether reverts git changes), follow (publish git changes; editors), skip").choices(["keep", "follow", "skip"])).action(async (shareId, dest, o) => {
|
|
298
|
+
const peek = await daemon("pull", { shareId, dest, cwd: process.cwd(), peek: true });
|
|
299
|
+
const gitMode = await chooseGitMode(peek.path, o.git, peek.role !== "viewer");
|
|
300
|
+
if (gitMode === "skip") return out("not pulled");
|
|
301
|
+
const r = await daemon("pull", { shareId, dest, cwd: process.cwd(), gitMode });
|
|
267
302
|
out(`pulled ${r.path} (${r.kind}${r.kind === "folder" ? `, ${r.files} files` : ""}, v${r.version}) as ${r.role}`);
|
|
303
|
+
if (r.note) out(`note: ${r.note}`);
|
|
268
304
|
});
|
|
269
305
|
program.command("status [path]").description("modes, pending changes, problems").option("-v, --verbose", "show every file and instance IDs").option("--json").action(async (p, o) => {
|
|
270
306
|
const s = await daemon("status", { path: p ? path2.resolve(p) : void 0 });
|
|
271
307
|
if (o.json) return json(s);
|
|
272
308
|
printStatus(s, o.verbose);
|
|
273
309
|
});
|
|
274
|
-
program.command("edit <path>").description("start editing: local changes are kept until `tether done`").action(async (p) => {
|
|
275
|
-
const r = await daemon("edit", { path: path2.resolve(p) });
|
|
310
|
+
program.command("edit <path>").description("start editing: local changes are kept until `tether done`").option("--realtime", "push every save immediately (editors only, one realtime editor per file)").action(async (p, o) => {
|
|
311
|
+
const r = await daemon("edit", { path: path2.resolve(p), realtime: !!o.realtime });
|
|
312
|
+
if (r.realtime) {
|
|
313
|
+
out(`editing ${r.changed.length} file(s) in realtime: every save is pushed to everyone within seconds.`);
|
|
314
|
+
out(`Other editors cannot start realtime on them until you run \`tether done ${p}\`.`);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
276
317
|
out(r.changed.length ? `editing ${r.changed.length} file(s); run \`tether done ${p}\` to finish` : "nothing to change (already editing?)");
|
|
318
|
+
for (const w of r.warnings ?? []) {
|
|
319
|
+
out(`warning: ${w.path} is being edited in realtime by ${w.email} (since ${new Date(w.since).toLocaleTimeString()}).`);
|
|
320
|
+
out(" Their changes keep arriving on the server; expect merge conflicts when you run `tether done`.");
|
|
321
|
+
}
|
|
277
322
|
});
|
|
278
323
|
program.command("done [path]").description("finish editing: push changes and resolve conflicts").addOption(new Option("--resolve <choice>", "answer every question with this choice").choices(["push", "accept", "keep-both", "cancel", "delete", "stop"])).action((p, o) => resolveFlow(p, "editing", o));
|
|
279
324
|
program.command("unmanage <path>").description("stop syncing (location is still followed)").action(async (p) => {
|
|
@@ -359,6 +404,40 @@ async function main(argv) {
|
|
|
359
404
|
uninstall();
|
|
360
405
|
out("removed from login startup");
|
|
361
406
|
});
|
|
407
|
+
program.command("update").description(`update tether to the latest version on npm (${PACKAGE})`).option("--check", "only check whether an update is available").action(async (o) => {
|
|
408
|
+
const latest = await latestVersion();
|
|
409
|
+
if (!latest) throw new Error("could not reach the npm registry");
|
|
410
|
+
if (!isNewer(latest, VERSION)) return out(`tether ${VERSION} is up to date`);
|
|
411
|
+
if (o.check) return out(`update available: ${VERSION} -> ${latest} (run \`tether update\`)`);
|
|
412
|
+
const pkgDir = installDir();
|
|
413
|
+
if (!pkgDir) throw new Error("this tether is a development copy (not installed from npm); update it with `git pull` and `npm install`");
|
|
414
|
+
out(`updating tether ${VERSION} -> ${latest}`);
|
|
415
|
+
let wasRunning = false;
|
|
416
|
+
try {
|
|
417
|
+
await call(endpoint(), "shutdown", {}, 3e3);
|
|
418
|
+
wasRunning = true;
|
|
419
|
+
for (let i = 0; i < 50; i++) {
|
|
420
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
421
|
+
await call(endpoint(), "ping", {}, 500);
|
|
422
|
+
}
|
|
423
|
+
} catch {
|
|
424
|
+
}
|
|
425
|
+
const code = npmInstall(process.env.TETHER_UPDATE_SPEC || `${PACKAGE}@${latest}`, prefixOf(pkgDir));
|
|
426
|
+
if (code !== 0) {
|
|
427
|
+
if (wasRunning) spawnDaemon();
|
|
428
|
+
throw new Error("npm install failed; tether was not updated. If another Tether daemon from this install is running (another TETHER_HOME), stop it and try again.");
|
|
429
|
+
}
|
|
430
|
+
if (wasRunning) spawnDaemon();
|
|
431
|
+
out(`updated to ${latest}`);
|
|
432
|
+
});
|
|
433
|
+
program.command("git <path>").description("show or change how a managed file shares its git repository").addOption(new Option("--mode <mode>", "keep: Tether reverts git's changes; follow: git's changes are published (editors)").choices(["keep", "follow"])).action(async (p, o) => {
|
|
434
|
+
for (const i of await daemon("git.mode", { path: path2.resolve(p), mode: o.mode })) {
|
|
435
|
+
out(`${(i.gitMode ?? (i.gitRoot ? "keep" : "-")).padEnd(7)} ${(i.role ?? "").padEnd(6)} ${i.path}${i.gitRoot ? "" : " (not in a git repo)"}`);
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
program.command("git-sync", { hidden: true }).description("used by git hooks").requiredOption("--repo <dir>").option("--hook <name>").action(async (o) => {
|
|
439
|
+
await daemon("git.sync", { repo: path2.resolve(o.repo), hook: o.hook });
|
|
440
|
+
});
|
|
362
441
|
program.command("sync").description("run a sync pass now").action(async () => {
|
|
363
442
|
const r = await daemon("sync");
|
|
364
443
|
out(r.offline ? `offline: ${r.offline}` : "synced");
|
package/bin/tetherd.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
process.removeAllListeners("warning");
|
|
5
5
|
var i = process.argv.indexOf("--home");
|
|
6
6
|
if (i > 0 && process.argv[i + 1]) process.env.TETHER_HOME = process.argv[i + 1];
|
|
7
|
-
var { runDaemon } = await import("./daemon-
|
|
7
|
+
var { runDaemon } = await import("./daemon-5IFWP5O3.js");
|
|
8
8
|
runDaemon().catch((e) => {
|
|
9
9
|
if (e.code === "EADDRINUSE") {
|
|
10
10
|
console.error("tetherd is already running");
|
package/package.json
CHANGED
package/bin/chunk-MGMU33LE.js
DELETED
|
@@ -1,109 +0,0 @@
|
|
|
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
|
-
export {
|
|
102
|
-
isWindows,
|
|
103
|
-
tetherHome,
|
|
104
|
-
ipcEndpoint,
|
|
105
|
-
files,
|
|
106
|
-
IpcError,
|
|
107
|
-
listen,
|
|
108
|
-
call
|
|
109
|
-
};
|