tether-sync 0.0.0-stage → 0.1.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 +20 -2
- package/bin/chunk-MGMU33LE.js +109 -0
- package/bin/daemon-WGNVBSEJ.js +1835 -0
- package/bin/tether.js +379 -0
- package/bin/tetherd.js +15 -0
- package/bin/win32-DF53YR5N.js +124 -0
- package/package.json +37 -4
package/bin/tether.js
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
IpcError,
|
|
4
|
+
call,
|
|
5
|
+
ipcEndpoint,
|
|
6
|
+
isWindows,
|
|
7
|
+
tetherHome
|
|
8
|
+
} from "./chunk-MGMU33LE.js";
|
|
9
|
+
|
|
10
|
+
// packages/client/src/cli.js
|
|
11
|
+
import path2 from "node:path";
|
|
12
|
+
import readline from "node:readline/promises";
|
|
13
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
14
|
+
import { Command, Option } from "commander";
|
|
15
|
+
|
|
16
|
+
// packages/client/src/autostart.js
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import os from "node:os";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { spawn, execFileSync } from "node:child_process";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
var daemonScript = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../bin/tetherd.js");
|
|
23
|
+
var RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
|
|
24
|
+
var runValue = () => process.env.TETHER_RUN_VALUE || "Tether";
|
|
25
|
+
var unitFile = () => path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "systemd", "user", "tetherd.service");
|
|
26
|
+
function spawnDaemon() {
|
|
27
|
+
fs.mkdirSync(tetherHome(), { recursive: true });
|
|
28
|
+
const child = spawn(process.execPath, [daemonScript, "--home", tetherHome()], {
|
|
29
|
+
detached: true,
|
|
30
|
+
stdio: "ignore",
|
|
31
|
+
windowsHide: true,
|
|
32
|
+
cwd: tetherHome(),
|
|
33
|
+
// never hold the caller's folder open
|
|
34
|
+
env: process.env
|
|
35
|
+
});
|
|
36
|
+
child.on("error", (e) => console.error(`error: could not start tetherd: ${e.message}`));
|
|
37
|
+
child.unref();
|
|
38
|
+
}
|
|
39
|
+
function install() {
|
|
40
|
+
const home = tetherHome();
|
|
41
|
+
if (isWindows) {
|
|
42
|
+
const conhost = path.join(process.env.SystemRoot || "C:\\Windows", "System32", "conhost.exe");
|
|
43
|
+
const cmd = `"${conhost}" --headless "${process.execPath}" "${daemonScript}" --home "${home}"`;
|
|
44
|
+
execFileSync("reg.exe", ["add", RUN_KEY, "/v", runValue(), "/t", "REG_SZ", "/d", cmd, "/f"], { stdio: "pipe" });
|
|
45
|
+
return { method: "registry Run key", command: cmd };
|
|
46
|
+
}
|
|
47
|
+
const unit = `[Unit]
|
|
48
|
+
Description=Tether sync daemon
|
|
49
|
+
|
|
50
|
+
[Service]
|
|
51
|
+
ExecStart=${process.execPath} ${daemonScript} --home ${home}
|
|
52
|
+
Restart=on-failure
|
|
53
|
+
Environment=TETHER_SERVER=${process.env.TETHER_SERVER ?? ""}
|
|
54
|
+
|
|
55
|
+
[Install]
|
|
56
|
+
WantedBy=default.target
|
|
57
|
+
`;
|
|
58
|
+
fs.mkdirSync(path.dirname(unitFile()), { recursive: true });
|
|
59
|
+
fs.writeFileSync(unitFile(), unit);
|
|
60
|
+
execFileSync("systemctl", ["--user", "daemon-reload"]);
|
|
61
|
+
execFileSync("systemctl", ["--user", "enable", "--now", "tetherd.service"]);
|
|
62
|
+
return { method: "systemd --user", unit: unitFile() };
|
|
63
|
+
}
|
|
64
|
+
function uninstall() {
|
|
65
|
+
if (isWindows) {
|
|
66
|
+
try {
|
|
67
|
+
execFileSync("reg.exe", ["delete", RUN_KEY, "/v", runValue(), "/f"], { stdio: "pipe" });
|
|
68
|
+
} catch {
|
|
69
|
+
}
|
|
70
|
+
return { removed: true };
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
execFileSync("systemctl", ["--user", "disable", "--now", "tetherd.service"], { stdio: "pipe" });
|
|
74
|
+
} catch {
|
|
75
|
+
}
|
|
76
|
+
fs.rmSync(unitFile(), { force: true });
|
|
77
|
+
return { removed: true };
|
|
78
|
+
}
|
|
79
|
+
function installed() {
|
|
80
|
+
return !!installedCommand();
|
|
81
|
+
}
|
|
82
|
+
function installedCommand() {
|
|
83
|
+
if (isWindows) {
|
|
84
|
+
try {
|
|
85
|
+
const out2 = execFileSync("reg.exe", ["query", RUN_KEY, "/v", runValue()], { stdio: "pipe" }).toString();
|
|
86
|
+
const m = out2.match(/REG_SZ\s+(.+)/);
|
|
87
|
+
return m ? m[1].trim() : null;
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return fs.existsSync(unitFile()) ? fs.readFileSync(unitFile(), "utf8").match(/ExecStart=(.+)/)?.[1] ?? null : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// packages/client/src/cli.js
|
|
96
|
+
var endpoint = () => ipcEndpoint();
|
|
97
|
+
async function daemon(method, params = {}) {
|
|
98
|
+
try {
|
|
99
|
+
return await call(endpoint(), method, params);
|
|
100
|
+
} catch (e) {
|
|
101
|
+
if (!(e instanceof IpcError) || e.code !== "ENODAEMON") throw e;
|
|
102
|
+
}
|
|
103
|
+
spawnDaemon();
|
|
104
|
+
for (let i = 0; i < 100; i++) {
|
|
105
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
106
|
+
try {
|
|
107
|
+
await call(endpoint(), "ping", {}, 2e3);
|
|
108
|
+
return await call(endpoint(), method, params);
|
|
109
|
+
} catch (e) {
|
|
110
|
+
if (!(e instanceof IpcError) || e.code !== "ENODAEMON") throw e;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
throw new Error("could not start the Tether daemon; see `tether daemon status`");
|
|
114
|
+
}
|
|
115
|
+
var out = (s = "") => process.stdout.write(s + "\n");
|
|
116
|
+
var json = (v) => out(JSON.stringify(v, null, 2));
|
|
117
|
+
var artifactRef = (r) => /^a_[a-z0-9]+$/.test(r) ? r : path2.resolve(r);
|
|
118
|
+
async function ask(question, valid) {
|
|
119
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
120
|
+
try {
|
|
121
|
+
for (; ; ) {
|
|
122
|
+
const a = (await rl.question(question)).trim().toLowerCase();
|
|
123
|
+
const hit = valid.find((v) => v === a || v[0] === a);
|
|
124
|
+
if (hit) return hit;
|
|
125
|
+
out(` choose one of: ${valid.join(", ")}`);
|
|
126
|
+
}
|
|
127
|
+
} finally {
|
|
128
|
+
rl.close();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
var DESCRIBE = {
|
|
132
|
+
push: "push your version (the server version stays in history)",
|
|
133
|
+
accept: "accept the server version (discard local changes)",
|
|
134
|
+
"keep-both": "keep your copy as an unmanaged file and accept the server version",
|
|
135
|
+
diff: "show the diff",
|
|
136
|
+
cancel: "leave it as it is for now",
|
|
137
|
+
delete: "delete it for everyone",
|
|
138
|
+
stop: "just stop managing it locally"
|
|
139
|
+
};
|
|
140
|
+
async function resolveFlow(p, from, opts) {
|
|
141
|
+
const target = path2.resolve(p ?? ".");
|
|
142
|
+
const plan = await daemon("plan", { path: target, from });
|
|
143
|
+
const choices = {};
|
|
144
|
+
for (const item of plan.items.filter((i) => i.options)) {
|
|
145
|
+
const where = item.relPath ?? item.path;
|
|
146
|
+
if (opts.resolve) {
|
|
147
|
+
if (!item.options.includes(opts.resolve)) throw new Error(`${where}: "${opts.resolve}" is not possible here (options: ${item.options.filter((o) => o !== "diff").join(", ")})`);
|
|
148
|
+
choices[item.instanceId] = opts.resolve;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (!process.stdin.isTTY) throw new Error(`${where} needs a decision; rerun with --resolve <${item.options.filter((o) => o !== "diff").join("|")}>`);
|
|
152
|
+
out();
|
|
153
|
+
out(`${item.path}`);
|
|
154
|
+
out(` local: ${item.change}${item.incoming ? `; server has a newer version (v${item.serverVersion})` : ""}${item.role === "viewer" ? "; you are a viewer, so you cannot push" : ""}`);
|
|
155
|
+
for (const o of item.options) out(` [${o[0]}] ${o.padEnd(10)} ${DESCRIBE[o]}`);
|
|
156
|
+
for (; ; ) {
|
|
157
|
+
const choice = await ask(" > ", item.options);
|
|
158
|
+
if (choice !== "diff") {
|
|
159
|
+
choices[item.instanceId] = choice;
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
out((await daemon("diff", { instanceId: item.instanceId })).text || " (no content difference)");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const { results } = await daemon("resolve", { path: target, from, items: plan.items, choices });
|
|
166
|
+
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})` : "";
|
|
168
|
+
out(`${r.action.padEnd(9)} ${r.path}${extra}`);
|
|
169
|
+
}
|
|
170
|
+
if (results.some((r) => r.error)) process.exitCode = 1;
|
|
171
|
+
}
|
|
172
|
+
function printStatus(s, verbose) {
|
|
173
|
+
out(`server: ${s.server} user: ${s.user ? s.user.email : "(not signed in)"}${s.offline ? ` OFFLINE: ${s.offline}` : ""}`);
|
|
174
|
+
if (!s.instances.length) return out("nothing managed yet");
|
|
175
|
+
const folders = new Map(s.instances.filter((i) => i.kind === "folder").map((i) => [i.instanceId, i]));
|
|
176
|
+
for (const i of s.instances) {
|
|
177
|
+
if (i.folderInstanceId && folders.has(i.folderInstanceId) && !verbose && i.mode === folders.get(i.folderInstanceId).mode && !i.flags.length) continue;
|
|
178
|
+
const indent = i.folderInstanceId ? " " : "";
|
|
179
|
+
const label = i.kind === "folder" ? i.path + path2.sep : i.folderInstanceId ? i.relPath : i.path;
|
|
180
|
+
out(`${i.mode.padEnd(9)} ${(i.role ?? "").padEnd(6)} v${String(i.version).padEnd(4)} ${indent}${label}${i.flags.length ? ` [${i.flags.join("; ")}]` : ""}${verbose ? ` (${i.instanceId})` : ""}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function browserCommand(url, platform = process.platform) {
|
|
184
|
+
if (platform === "win32") return ["rundll32.exe", ["url.dll,FileProtocolHandler", url]];
|
|
185
|
+
if (platform === "darwin") return ["open", [url]];
|
|
186
|
+
return ["xdg-open", [url]];
|
|
187
|
+
}
|
|
188
|
+
function openBrowser(url) {
|
|
189
|
+
const [cmd, args] = browserCommand(url);
|
|
190
|
+
try {
|
|
191
|
+
spawn2(cmd, args, { stdio: "ignore", detached: true, windowsHide: true }).unref();
|
|
192
|
+
} catch {
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
async function main(argv) {
|
|
196
|
+
process.stdout.on("error", (e) => {
|
|
197
|
+
if (e.code === "EPIPE") process.exit(0);
|
|
198
|
+
throw e;
|
|
199
|
+
});
|
|
200
|
+
const program = new Command("tether").description("Keep files in sync with a central source of truth, wherever they live.").version("0.1.0");
|
|
201
|
+
program.showHelpAfterError();
|
|
202
|
+
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
|
+
if (o.email) {
|
|
204
|
+
const u2 = await daemon("login.password", { email: o.email, password: o.password, signup: !!o.signup });
|
|
205
|
+
return out(`signed in as ${u2.email}`);
|
|
206
|
+
}
|
|
207
|
+
const { url, state } = await daemon("login.start");
|
|
208
|
+
out(`Opening ${url}`);
|
|
209
|
+
out("If the browser does not open, copy the link above.");
|
|
210
|
+
openBrowser(url);
|
|
211
|
+
const u = await daemon("login.wait", { state });
|
|
212
|
+
out(`signed in as ${u.email}`);
|
|
213
|
+
});
|
|
214
|
+
program.command("logout").description("sign out on this machine").action(async () => {
|
|
215
|
+
await daemon("logout");
|
|
216
|
+
out("signed out");
|
|
217
|
+
});
|
|
218
|
+
program.command("whoami").description("show the signed-in account").action(async () => {
|
|
219
|
+
const u = await daemon("whoami");
|
|
220
|
+
out(`${u.email} (${u.uid})`);
|
|
221
|
+
});
|
|
222
|
+
const repo = program.command("repo").description("manage repositories");
|
|
223
|
+
repo.command("create <name>").action(async (name) => {
|
|
224
|
+
const r = await daemon("repo.create", { name });
|
|
225
|
+
out(`created repo ${r.name} (${r.repoId})`);
|
|
226
|
+
});
|
|
227
|
+
repo.command("list").action(async () => {
|
|
228
|
+
const list = await daemon("repo.list");
|
|
229
|
+
if (!list.length) out("no repos yet");
|
|
230
|
+
for (const r of list) out(`${r.name.padEnd(24)} ${r.repoId}`);
|
|
231
|
+
});
|
|
232
|
+
repo.command("show <repo>").description("artifacts and share IDs in a repo").action(async (ref) => {
|
|
233
|
+
const r = await daemon("repo.show", { ref });
|
|
234
|
+
out(`${r.name} (${r.repoId}) eol rules: ${JSON.stringify(r.eolDefaults)}`);
|
|
235
|
+
for (const a of r.artifacts) {
|
|
236
|
+
out(` ${a.kind.padEnd(6)} ${a.name.padEnd(28)} share ${a.shareId} v${a.version} ${a.artifactId}${a.deleted ? " [deleted]" : ""}${a.kind === "folder" ? ` (${a.fileCount} files)` : ""}`);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
repo.command("delete <repo>").description("permanently delete a repo on the server (local copies are kept)").option("-y, --yes", "do not ask for confirmation").action(async (ref, o) => {
|
|
240
|
+
if (!o.yes) {
|
|
241
|
+
if (!process.stdin.isTTY) throw new Error("confirm with --yes");
|
|
242
|
+
if (await ask(`Permanently delete repo "${ref}" and its history for everyone? [y/n] `, ["yes", "no"]) !== "yes") return;
|
|
243
|
+
}
|
|
244
|
+
const r = await daemon("repo.delete", { ref });
|
|
245
|
+
out(`deleted; ${r.purgedArtifacts} artifacts purged. Everyone's local copies stay on disk, untracked.`);
|
|
246
|
+
});
|
|
247
|
+
repo.command("transfer <repo>").requiredOption("--to <email>").action(async (ref, o) => {
|
|
248
|
+
const r = await daemon("repo.transfer", { ref, to: o.to });
|
|
249
|
+
out(`transferred ${r.name} to ${o.to}; you stay on as an editor`);
|
|
250
|
+
});
|
|
251
|
+
repo.command("eol <repo> <glob> <mode>").description("line-ending rule for new files: auto|lf|crlf|binary|unset").action(async (ref, glob, mode) => {
|
|
252
|
+
const r = await daemon("repo.eol", { ref, glob, mode });
|
|
253
|
+
out(`eol rules: ${JSON.stringify(r.eolDefaults)}`);
|
|
254
|
+
});
|
|
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 });
|
|
257
|
+
out(`managing ${r.path} (${r.kind}${r.kind === "folder" ? `, ${r.files} files` : ""})`);
|
|
258
|
+
if (r.skippedSymlinks?.length) out(`skipped symlinks: ${r.skippedSymlinks.join(", ")}`);
|
|
259
|
+
out(`share ID: ${r.shareId}`);
|
|
260
|
+
});
|
|
261
|
+
program.command("add <path>").description("add a new file inside a managed folder (owner only)").action(async (p) => {
|
|
262
|
+
const r = await daemon("add", { path: path2.resolve(p) });
|
|
263
|
+
out(`added ${r.relPath} to ${r.folder}`);
|
|
264
|
+
});
|
|
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() });
|
|
267
|
+
out(`pulled ${r.path} (${r.kind}${r.kind === "folder" ? `, ${r.files} files` : ""}, v${r.version}) as ${r.role}`);
|
|
268
|
+
});
|
|
269
|
+
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
|
+
const s = await daemon("status", { path: p ? path2.resolve(p) : void 0 });
|
|
271
|
+
if (o.json) return json(s);
|
|
272
|
+
printStatus(s, o.verbose);
|
|
273
|
+
});
|
|
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) });
|
|
276
|
+
out(r.changed.length ? `editing ${r.changed.length} file(s); run \`tether done ${p}\` to finish` : "nothing to change (already editing?)");
|
|
277
|
+
});
|
|
278
|
+
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
|
+
program.command("unmanage <path>").description("stop syncing (location is still followed)").action(async (p) => {
|
|
280
|
+
const r = await daemon("unmanage", { path: path2.resolve(p) });
|
|
281
|
+
out(`unmanaged ${r.changed.length} file(s)`);
|
|
282
|
+
});
|
|
283
|
+
program.command("track [path]").description("resume syncing unmanaged files").addOption(new Option("--resolve <choice>", "answer every question with this choice").choices(["push", "accept", "keep-both", "cancel"])).action((p, o) => resolveFlow(p, "unmanaged", o));
|
|
284
|
+
program.command("locate <instance>").description("re-link a lost instance").option("--in <dir...>", "folders to search by content").option("--file <path>", "link to this file directly").option("-y, --yes", "link the only match without asking").action(async (id, o) => {
|
|
285
|
+
if (o.file) return out(`linked to ${(await daemon("locate", { instanceId: id, file: path2.resolve(o.file) })).linked}`);
|
|
286
|
+
const { candidates } = await daemon("locate", { instanceId: id, dirs: (o.in ?? []).map((d2) => path2.resolve(d2)) });
|
|
287
|
+
if (!candidates.length) return out("no file with matching content found");
|
|
288
|
+
candidates.forEach((c, n) => out(` ${n + 1}. ${c}`));
|
|
289
|
+
if (candidates.length === 1 && (o.yes || process.stdin.isTTY && await ask("link it? [y/n] ", ["yes", "no"]) === "yes")) {
|
|
290
|
+
out(`linked to ${(await daemon("locate", { instanceId: id, file: candidates[0] })).linked}`);
|
|
291
|
+
} else out("run again with --file <path> to choose");
|
|
292
|
+
});
|
|
293
|
+
program.command("diff <path>").description("server version vs your local file").action(async (p) => {
|
|
294
|
+
const r = await daemon("diff", { path: path2.resolve(p) });
|
|
295
|
+
out(r.text || "no differences");
|
|
296
|
+
});
|
|
297
|
+
program.command("history <artifact>").description("versions of a path or artifact ID").action(async (ref) => {
|
|
298
|
+
for (const v of await daemon("history", { ref: artifactRef(ref) })) {
|
|
299
|
+
const what = v.type + (v.rolledBackTo ? ` (to v${v.rolledBackTo})` : "") + (v.relPath && v.type !== "content" ? ` ${v.relPath}` : "");
|
|
300
|
+
out(`v${String(v.version).padEnd(4)} ${new Date(v.createdAt).toLocaleString().padEnd(22)} ${v.author.padEnd(26)} ${what}`);
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
program.command("rollback <artifact>").description("restore an earlier version (owner)").requiredOption("--to <version>").action(async (ref, o) => {
|
|
304
|
+
const r = await daemon("rollback", { ref: artifactRef(ref), toVersion: Number(o.to) });
|
|
305
|
+
out(`rolled back; now v${r.version}`);
|
|
306
|
+
});
|
|
307
|
+
program.command("delete <artifact>").description("delete for everyone (owner, or editor with delete permission)").option("-y, --yes").action(async (ref, o) => {
|
|
308
|
+
if (!o.yes) {
|
|
309
|
+
if (!process.stdin.isTTY) throw new Error("confirm with --yes");
|
|
310
|
+
if (await ask(`Delete ${ref} for everyone? Local copies are kept but stop syncing. [y/n] `, ["yes", "no"]) !== "yes") return;
|
|
311
|
+
}
|
|
312
|
+
await daemon("delete", { ref: artifactRef(ref) });
|
|
313
|
+
out("deleted (the owner can restore it with `tether rollback`)");
|
|
314
|
+
});
|
|
315
|
+
program.command("info <artifact>").description("server details of a path or artifact ID").action(async (ref) => json(await daemon("info", { ref: artifactRef(ref) })));
|
|
316
|
+
program.command("share <artifact>").description("show or change sharing (owner)").option("--editor <email>", "make someone an editor").option("--can-delete", "with --editor: allow deleting for everyone").option("--demote <email>", "turn an editor into a viewer").option("--revoke <email>", "remove someone").addOption(new Option("--visibility <v>").choices(["link", "allowlist"])).option("--allow <email...>", "add people to the allowlist").option("--rotate-id", "issue a new share ID; link-only users lose access").action(async (ref, o) => {
|
|
317
|
+
let op = null;
|
|
318
|
+
if (o.editor) op = { addEditor: o.editor, canDelete: !!o.canDelete };
|
|
319
|
+
else if (o.demote) op = { demote: o.demote };
|
|
320
|
+
else if (o.revoke) op = { revoke: o.revoke };
|
|
321
|
+
else if (o.visibility) op = { visibility: o.visibility, allow: o.allow };
|
|
322
|
+
else if (o.allow) op = { allow: o.allow };
|
|
323
|
+
else if (o.rotateId) op = { rotate: true };
|
|
324
|
+
const s = await daemon("share", { ref: artifactRef(ref), op });
|
|
325
|
+
out(`share ID: ${s.shareId}`);
|
|
326
|
+
out(`visibility: ${s.visibility}`);
|
|
327
|
+
out(`editors: ${s.editors.map((e) => e.email + (e.canDelete ? " (can delete)" : "")).join(", ") || "-"}`);
|
|
328
|
+
out(`allowlist: ${s.allowlist.map((e) => e.email).join(", ") || "-"}`);
|
|
329
|
+
});
|
|
330
|
+
const d = program.command("daemon").description("control the background daemon");
|
|
331
|
+
d.command("start").action(async () => {
|
|
332
|
+
const r = await daemon("ping");
|
|
333
|
+
out(`tetherd running (pid ${r.pid}, tracker ${r.tracker})`);
|
|
334
|
+
});
|
|
335
|
+
d.command("stop").action(async () => {
|
|
336
|
+
try {
|
|
337
|
+
await call(endpoint(), "shutdown");
|
|
338
|
+
out("tetherd stopped");
|
|
339
|
+
} catch (e) {
|
|
340
|
+
if (e.code === "ENODAEMON") out("tetherd is not running");
|
|
341
|
+
else throw e;
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
d.command("status").action(async () => {
|
|
345
|
+
try {
|
|
346
|
+
const r = await call(endpoint(), "ping", {}, 3e3);
|
|
347
|
+
out(`running: pid ${r.pid}, version ${r.version}, tracker ${r.tracker}, server ${r.server}, state ${r.home}`);
|
|
348
|
+
} catch {
|
|
349
|
+
out("not running");
|
|
350
|
+
}
|
|
351
|
+
out(`start at login: ${installed() ? "yes" : "no"}`);
|
|
352
|
+
});
|
|
353
|
+
d.command("install").description("start the daemon at login").action(async () => {
|
|
354
|
+
const r = install();
|
|
355
|
+
out(`installed (${r.method})`);
|
|
356
|
+
await daemon("ping");
|
|
357
|
+
});
|
|
358
|
+
d.command("uninstall").description("stop starting the daemon at login").action(() => {
|
|
359
|
+
uninstall();
|
|
360
|
+
out("removed from login startup");
|
|
361
|
+
});
|
|
362
|
+
program.command("sync").description("run a sync pass now").action(async () => {
|
|
363
|
+
const r = await daemon("sync");
|
|
364
|
+
out(r.offline ? `offline: ${r.offline}` : "synced");
|
|
365
|
+
});
|
|
366
|
+
program.command("log").description("recent daemon activity").option("-n <lines>", "lines", "30").action(async (o) => {
|
|
367
|
+
for (const l of await daemon("log", { lines: Number(o.n) })) out(l);
|
|
368
|
+
});
|
|
369
|
+
const cfg = program.command("config").description("client settings: server, consoleUrl, tickSeconds, graceMs, scanRoots");
|
|
370
|
+
cfg.command("get [key]").action(async (key) => json(await daemon("config.get", { key })));
|
|
371
|
+
cfg.command("set <key> <value>").action(async (key, value) => json(await daemon("config.set", { key, value })));
|
|
372
|
+
await program.parseAsync(argv);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// packages/client/bin/tether.js
|
|
376
|
+
main(process.argv).catch((e) => {
|
|
377
|
+
console.error(`error: ${e.message}`);
|
|
378
|
+
process.exit(1);
|
|
379
|
+
});
|
package/bin/tetherd.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// packages/client/bin/tetherd.js
|
|
4
|
+
process.removeAllListeners("warning");
|
|
5
|
+
var i = process.argv.indexOf("--home");
|
|
6
|
+
if (i > 0 && process.argv[i + 1]) process.env.TETHER_HOME = process.argv[i + 1];
|
|
7
|
+
var { runDaemon } = await import("./daemon-WGNVBSEJ.js");
|
|
8
|
+
runDaemon().catch((e) => {
|
|
9
|
+
if (e.code === "EADDRINUSE") {
|
|
10
|
+
console.error("tetherd is already running");
|
|
11
|
+
process.exit(0);
|
|
12
|
+
}
|
|
13
|
+
console.error(e);
|
|
14
|
+
process.exit(1);
|
|
15
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// packages/client/src/platform/win32.js
|
|
2
|
+
import koffi from "koffi";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
var kernel32 = koffi.load("kernel32.dll");
|
|
5
|
+
var crypt32 = koffi.load("crypt32.dll");
|
|
6
|
+
var HANDLE = "intptr";
|
|
7
|
+
var INVALID_HANDLE = -1;
|
|
8
|
+
var FILE_READ_ATTRIBUTES = 128;
|
|
9
|
+
var FILE_SHARE_ALL = 1 | 2 | 4;
|
|
10
|
+
var OPEN_EXISTING = 3;
|
|
11
|
+
var FILE_FLAG_BACKUP_SEMANTICS = 33554432;
|
|
12
|
+
var FILE_ID_INFO_CLASS = 18;
|
|
13
|
+
var EXTENDED_FILE_ID_TYPE = 2;
|
|
14
|
+
var CreateFileW = kernel32.func("CreateFileW", HANDLE, ["str16", "uint32", "uint32", "void *", "uint32", "uint32", HANDLE]);
|
|
15
|
+
var CloseHandle = kernel32.func("CloseHandle", "bool", [HANDLE]);
|
|
16
|
+
var GetLastError = kernel32.func("GetLastError", "uint32", []);
|
|
17
|
+
var GetFileInformationByHandleEx = kernel32.func("GetFileInformationByHandleEx", "bool", [HANDLE, "int", "void *", "uint32"]);
|
|
18
|
+
var OpenFileById = kernel32.func("OpenFileById", HANDLE, [HANDLE, "void *", "uint32", "uint32", "void *", "uint32"]);
|
|
19
|
+
var GetFinalPathNameByHandleW = kernel32.func("GetFinalPathNameByHandleW", "uint32", [HANDLE, "void *", "uint32", "uint32"]);
|
|
20
|
+
var LocalFree = kernel32.func("LocalFree", "void *", ["void *"]);
|
|
21
|
+
var DATA_BLOB = koffi.struct("DATA_BLOB", { cbData: "uint32", pbData: "void *" });
|
|
22
|
+
var CryptProtectData = crypt32.func("CryptProtectData", "bool", [koffi.pointer(DATA_BLOB), "str16", "void *", "void *", "void *", "uint32", koffi.out(koffi.pointer(DATA_BLOB))]);
|
|
23
|
+
var CryptUnprotectData = crypt32.func("CryptUnprotectData", "bool", [koffi.pointer(DATA_BLOB), "void *", "void *", "void *", "void *", "uint32", koffi.out(koffi.pointer(DATA_BLOB))]);
|
|
24
|
+
function openPath(p) {
|
|
25
|
+
const h = CreateFileW(toLong(p), FILE_READ_ATTRIBUTES, FILE_SHARE_ALL, null, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, 0);
|
|
26
|
+
if (Number(h) === INVALID_HANDLE) return null;
|
|
27
|
+
return h;
|
|
28
|
+
}
|
|
29
|
+
function toLong(p) {
|
|
30
|
+
const abs = path.resolve(p);
|
|
31
|
+
return abs.startsWith("\\\\?\\") ? abs : "\\\\?\\" + abs;
|
|
32
|
+
}
|
|
33
|
+
function stripLong(p) {
|
|
34
|
+
if (p.startsWith("\\\\?\\UNC\\")) return "\\\\" + p.slice(8);
|
|
35
|
+
if (p.startsWith("\\\\?\\")) return p.slice(4);
|
|
36
|
+
return p;
|
|
37
|
+
}
|
|
38
|
+
function idOfHandle(h) {
|
|
39
|
+
const buf = Buffer.alloc(24);
|
|
40
|
+
if (!GetFileInformationByHandleEx(h, FILE_ID_INFO_CLASS, buf, buf.length)) return null;
|
|
41
|
+
return {
|
|
42
|
+
volumeId: buf.readBigUInt64LE(0).toString(16).padStart(16, "0"),
|
|
43
|
+
fileId: buf.subarray(8, 24).toString("hex")
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function finalPath(h) {
|
|
47
|
+
let cap = 512;
|
|
48
|
+
for (; ; ) {
|
|
49
|
+
const buf = Buffer.alloc(cap * 2);
|
|
50
|
+
const n = GetFinalPathNameByHandleW(h, buf, cap, 0);
|
|
51
|
+
if (n === 0) return null;
|
|
52
|
+
if (n < cap) return stripLong(buf.toString("utf16le", 0, n * 2));
|
|
53
|
+
cap = n + 1;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function fileIdentity(p) {
|
|
57
|
+
const h = openPath(p);
|
|
58
|
+
if (h === null) return null;
|
|
59
|
+
try {
|
|
60
|
+
return idOfHandle(h);
|
|
61
|
+
} finally {
|
|
62
|
+
CloseHandle(h);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
var volumeHints = /* @__PURE__ */ new Map();
|
|
66
|
+
function volumeHint(volumeId, nearPath) {
|
|
67
|
+
let h = volumeHints.get(volumeId);
|
|
68
|
+
if (h !== void 0) return h;
|
|
69
|
+
const root = path.parse(path.resolve(nearPath)).root;
|
|
70
|
+
h = openPath(root);
|
|
71
|
+
if (h === null) return null;
|
|
72
|
+
const id = idOfHandle(h);
|
|
73
|
+
if (!id || id.volumeId !== volumeId) {
|
|
74
|
+
CloseHandle(h);
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
volumeHints.set(volumeId, h);
|
|
78
|
+
return h;
|
|
79
|
+
}
|
|
80
|
+
function pathById(volumeId, fileId, nearPath) {
|
|
81
|
+
const hint = volumeHint(volumeId, nearPath);
|
|
82
|
+
if (hint === null) return null;
|
|
83
|
+
const desc = Buffer.alloc(24);
|
|
84
|
+
desc.writeUInt32LE(24, 0);
|
|
85
|
+
desc.writeInt32LE(EXTENDED_FILE_ID_TYPE, 4);
|
|
86
|
+
Buffer.from(fileId, "hex").copy(desc, 8);
|
|
87
|
+
const h = OpenFileById(hint, desc, FILE_READ_ATTRIBUTES, FILE_SHARE_ALL, null, FILE_FLAG_BACKUP_SEMANTICS);
|
|
88
|
+
if (Number(h) === INVALID_HANDLE) {
|
|
89
|
+
GetLastError();
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
return finalPath(h);
|
|
94
|
+
} finally {
|
|
95
|
+
CloseHandle(h);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function isTrashPath(p) {
|
|
99
|
+
return /[\\/]\$Recycle\.Bin[\\/]/i.test(p);
|
|
100
|
+
}
|
|
101
|
+
function protect(data) {
|
|
102
|
+
const input = { cbData: data.length, pbData: data };
|
|
103
|
+
const output = {};
|
|
104
|
+
if (!CryptProtectData(input, "tether", null, null, null, 0, output)) throw new Error("CryptProtectData failed");
|
|
105
|
+
return copyBlob(output);
|
|
106
|
+
}
|
|
107
|
+
function unprotect(data) {
|
|
108
|
+
const input = { cbData: data.length, pbData: data };
|
|
109
|
+
const output = {};
|
|
110
|
+
if (!CryptUnprotectData(input, null, null, null, null, 0, output)) throw new Error("CryptUnprotectData failed");
|
|
111
|
+
return copyBlob(output);
|
|
112
|
+
}
|
|
113
|
+
function copyBlob(blob) {
|
|
114
|
+
const bytes = Buffer.from(koffi.decode(blob.pbData, "uint8", blob.cbData));
|
|
115
|
+
LocalFree(blob.pbData);
|
|
116
|
+
return bytes;
|
|
117
|
+
}
|
|
118
|
+
export {
|
|
119
|
+
fileIdentity,
|
|
120
|
+
isTrashPath,
|
|
121
|
+
pathById,
|
|
122
|
+
protect,
|
|
123
|
+
unprotect
|
|
124
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tether-sync",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Keep files identical to a central source of truth, wherever they live on your machine.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"tether": "bin/tether.js",
|
|
8
|
+
"tetherd": "bin/tetherd.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=22.13"
|
|
16
|
+
},
|
|
17
|
+
"os": [
|
|
18
|
+
"win32",
|
|
19
|
+
"linux"
|
|
20
|
+
],
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"koffi": "^3.3.3",
|
|
23
|
+
"commander": "^14.0.1",
|
|
24
|
+
"diff": "^8.0.2"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"sync",
|
|
28
|
+
"config",
|
|
29
|
+
"dotfiles",
|
|
30
|
+
"cli",
|
|
31
|
+
"files"
|
|
32
|
+
],
|
|
33
|
+
"homepage": "https://tether-b10cf.web.app",
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/SaiEthihasChanda/Tether.git"
|
|
37
|
+
},
|
|
38
|
+
"license": "UNLICENSED"
|
|
39
|
+
}
|