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/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-MGMU33LE.js";
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("0.1.0");
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 r = await daemon("manage", { path: path2.resolve(p), repo: o.repo, eol: o.eol });
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 r = await daemon("pull", { shareId, dest, cwd: process.cwd() });
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-WGNVBSEJ.js");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tether-sync",
3
- "version": "0.1.0",
3
+ "version": "0.4.0",
4
4
  "description": "Keep files identical to a central source of truth, wherever they live on your machine.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- };