tether-sync 0.0.0-stage → 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.
@@ -0,0 +1,2310 @@
1
+ import {
2
+ GIT_MODES,
3
+ VERSION,
4
+ files,
5
+ findRepo,
6
+ gitBusy,
7
+ installHooks,
8
+ ipcEndpoint,
9
+ isNewer,
10
+ isWindows,
11
+ latestVersion,
12
+ listen,
13
+ removeHooks,
14
+ tetherHome
15
+ } from "./chunk-NDKBAHAG.js";
16
+
17
+ // packages/client/src/daemon.js
18
+ import fs8 from "node:fs";
19
+ import path5 from "node:path";
20
+ import http from "node:http";
21
+ import crypto4 from "node:crypto";
22
+
23
+ // packages/client/src/config.js
24
+ import fs from "node:fs";
25
+ import os from "node:os";
26
+ var DEFAULTS = {
27
+ server: "https://tether-b10cf.web.app",
28
+ consoleUrl: "",
29
+ // defaults to the server URL
30
+ tickSeconds: 30,
31
+ graceMs: 2e3,
32
+ scanRoots: [os.homedir()],
33
+ notifications: "all"
34
+ // all | errors | off
35
+ };
36
+ var NUMERIC = /* @__PURE__ */ new Set(["tickSeconds", "graceMs"]);
37
+ var Config = class {
38
+ constructor(file) {
39
+ this.file = file;
40
+ this.values = { ...DEFAULTS };
41
+ try {
42
+ Object.assign(this.values, JSON.parse(fs.readFileSync(file, "utf8")));
43
+ } catch {
44
+ }
45
+ if (process.env.TETHER_SERVER) this.values.server = process.env.TETHER_SERVER;
46
+ }
47
+ get(key) {
48
+ return this.values[key];
49
+ }
50
+ set(key, value) {
51
+ if (!(key in DEFAULTS)) throw new Error(`unknown config key "${key}" (known: ${Object.keys(DEFAULTS).join(", ")})`);
52
+ if (NUMERIC.has(key)) {
53
+ value = Number(value);
54
+ if (!Number.isFinite(value) || value <= 0) throw new Error(`${key} must be a positive number`);
55
+ }
56
+ if (key === "notifications" && !["all", "errors", "off"].includes(value)) throw new Error("notifications must be all, errors or off");
57
+ if (key === "scanRoots" && typeof value === "string") value = value.split(/[;,]/).map((s) => s.trim()).filter(Boolean);
58
+ this.values[key] = value;
59
+ fs.writeFileSync(this.file, JSON.stringify(this.values, null, 2));
60
+ }
61
+ get consoleUrl() {
62
+ return this.values.consoleUrl || this.values.server;
63
+ }
64
+ };
65
+
66
+ // packages/client/src/db.js
67
+ import { DatabaseSync } from "node:sqlite";
68
+ var COLUMNS = {
69
+ instanceId: "TEXT PRIMARY KEY",
70
+ artifactId: "TEXT NOT NULL",
71
+ kind: "TEXT NOT NULL DEFAULT 'file'",
72
+ folderInstanceId: "TEXT",
73
+ repoId: "TEXT",
74
+ name: "TEXT",
75
+ relPath: "TEXT",
76
+ // server layout path inside the folder, at baseVersion
77
+ path: "TEXT NOT NULL",
78
+ // last known absolute path
79
+ volumeId: "TEXT",
80
+ fileId: "TEXT",
81
+ mode: "TEXT NOT NULL DEFAULT 'normal'",
82
+ // normal | editing | unmanaged | lost | orphaned
83
+ prevMode: "TEXT",
84
+ // mode to return to after `tether locate`
85
+ baseVersion: "INTEGER NOT NULL DEFAULT 0",
86
+ baseHash: "TEXT",
87
+ // canonical sha256 at baseVersion (= server blob)
88
+ size: "INTEGER",
89
+ // on-disk size at the last sync, for the quick change check
90
+ mtimeMs: "REAL",
91
+ eolMode: "TEXT DEFAULT 'auto'",
92
+ eolStyle: "TEXT",
93
+ // line endings this copy uses: lf | crlf (null = platform default)
94
+ hasBom: "INTEGER DEFAULT 0",
95
+ exec: "INTEGER DEFAULT 0",
96
+ role: "TEXT",
97
+ canDelete: "INTEGER DEFAULT 0",
98
+ remoteVersion: "INTEGER",
99
+ // latest server version seen (pending incoming if > baseVersion)
100
+ missingSince: "REAL",
101
+ // start of the atomic-save grace window
102
+ detached: "INTEGER DEFAULT 0",
103
+ // file in a folder that the user deleted; restored by `tether track`
104
+ needsRestore: "INTEGER DEFAULT 0",
105
+ // write a fresh copy at the expected path on the next tick
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",
116
+ lastError: "TEXT",
117
+ createdAt: "REAL",
118
+ updatedAt: "REAL"
119
+ };
120
+ var BOOL = /* @__PURE__ */ new Set(["realtime", "hasBom", "exec", "canDelete", "detached", "needsRestore"]);
121
+ var LocalDb = class {
122
+ constructor(file) {
123
+ this.db = new DatabaseSync(file);
124
+ this.db.exec("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;");
125
+ const cols = Object.entries(COLUMNS).map(([k, t]) => `"${k}" ${t}`).join(", ");
126
+ this.db.exec(`CREATE TABLE IF NOT EXISTS instances (${cols})`);
127
+ const have = new Set(this.db.prepare("PRAGMA table_info(instances)").all().map((c) => c.name));
128
+ for (const [k, t] of Object.entries(COLUMNS)) {
129
+ if (!have.has(k)) this.db.exec(`ALTER TABLE instances ADD COLUMN "${k}" ${t.replace("PRIMARY KEY", "").replace("NOT NULL", "")}`);
130
+ }
131
+ this.db.exec("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)");
132
+ this.db.exec("CREATE INDEX IF NOT EXISTS instances_folder ON instances(folderInstanceId)");
133
+ }
134
+ row(r) {
135
+ if (!r) return null;
136
+ const o = { ...r };
137
+ for (const k of BOOL) o[k] = !!o[k];
138
+ return o;
139
+ }
140
+ get(instanceId) {
141
+ return this.row(this.db.prepare("SELECT * FROM instances WHERE instanceId = ?").get(instanceId));
142
+ }
143
+ all() {
144
+ return this.db.prepare("SELECT * FROM instances ORDER BY createdAt").all().map((r) => this.row(r));
145
+ }
146
+ childrenOf(folderInstanceId) {
147
+ return this.db.prepare("SELECT * FROM instances WHERE folderInstanceId = ? ORDER BY relPath").all(folderInstanceId).map((r) => this.row(r));
148
+ }
149
+ insert(inst) {
150
+ const now = Date.now();
151
+ const o = { createdAt: now, updatedAt: now, ...inst };
152
+ const keys = Object.keys(o).filter((k) => k in COLUMNS);
153
+ this.db.prepare(`INSERT INTO instances (${keys.map((k) => `"${k}"`).join(",")}) VALUES (${keys.map(() => "?").join(",")})`).run(...keys.map((k) => this.val(o[k])));
154
+ return this.get(o.instanceId);
155
+ }
156
+ update(instanceId, patch) {
157
+ const keys = Object.keys(patch).filter((k) => k in COLUMNS && k !== "instanceId");
158
+ if (!keys.length) return this.get(instanceId);
159
+ keys.push("updatedAt");
160
+ patch = { ...patch, updatedAt: Date.now() };
161
+ this.db.prepare(`UPDATE instances SET ${keys.map((k) => `"${k}" = ?`).join(", ")} WHERE instanceId = ?`).run(...keys.map((k) => this.val(patch[k])), instanceId);
162
+ return this.get(instanceId);
163
+ }
164
+ remove(instanceId) {
165
+ this.db.prepare("DELETE FROM instances WHERE instanceId = ?").run(instanceId);
166
+ }
167
+ val(v) {
168
+ if (v === void 0) return null;
169
+ if (typeof v === "boolean") return v ? 1 : 0;
170
+ return v;
171
+ }
172
+ getMeta(key) {
173
+ const r = this.db.prepare("SELECT value FROM meta WHERE key = ?").get(key);
174
+ return r ? JSON.parse(r.value) : null;
175
+ }
176
+ setMeta(key, value) {
177
+ if (value === null) this.db.prepare("DELETE FROM meta WHERE key = ?").run(key);
178
+ else this.db.prepare("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, JSON.stringify(value));
179
+ }
180
+ close() {
181
+ this.db.close();
182
+ }
183
+ };
184
+
185
+ // packages/client/src/credentials.js
186
+ import fs2 from "node:fs";
187
+ var Credentials = class {
188
+ constructor(file) {
189
+ this.file = file;
190
+ }
191
+ async load() {
192
+ try {
193
+ const raw = fs2.readFileSync(this.file);
194
+ const json = isWindows ? (await import("./win32-DF53YR5N.js")).unprotect(raw).toString() : raw.toString();
195
+ return JSON.parse(json);
196
+ } catch {
197
+ return null;
198
+ }
199
+ }
200
+ async save(creds) {
201
+ const json = Buffer.from(JSON.stringify(creds));
202
+ const data = isWindows ? (await import("./win32-DF53YR5N.js")).protect(json) : json;
203
+ fs2.writeFileSync(this.file, data, { mode: 384 });
204
+ }
205
+ clear() {
206
+ fs2.rmSync(this.file, { force: true });
207
+ }
208
+ };
209
+
210
+ // packages/client/src/api.js
211
+ import fs3 from "node:fs";
212
+ import path from "node:path";
213
+ import crypto3 from "node:crypto";
214
+
215
+ // packages/shared/src/content.js
216
+ import crypto from "node:crypto";
217
+ var BOM = Buffer.from([239, 187, 191]);
218
+ function isBinary(buf) {
219
+ const n = Math.min(buf.length, 8192);
220
+ for (let i = 0; i < n; i++) if (buf[i] === 0) return true;
221
+ return false;
222
+ }
223
+ function detectStyle(buf) {
224
+ const bom = buf.length >= 3 && buf[0] === 239 && buf[1] === 187 && buf[2] === 191;
225
+ let eol = null;
226
+ const i = buf.indexOf(10);
227
+ if (i >= 0) eol = i > 0 && buf[i - 1] === 13 ? "crlf" : "lf";
228
+ return { eol, bom };
229
+ }
230
+ function canonicalize(buf, eolMode = "auto") {
231
+ if (eolMode === "binary" || isBinary(buf)) return { bytes: buf, text: false };
232
+ let start = 0;
233
+ if (buf.length >= 3 && buf[0] === 239 && buf[1] === 187 && buf[2] === 191) start = 3;
234
+ const out = Buffer.allocUnsafe(buf.length - start);
235
+ let j = 0;
236
+ for (let i = start; i < buf.length; i++) {
237
+ if (buf[i] === 13 && buf[i + 1] === 10) continue;
238
+ out[j++] = buf[i];
239
+ }
240
+ return { bytes: out.subarray(0, j), text: true };
241
+ }
242
+ function sha256(buf) {
243
+ return crypto.createHash("sha256").update(buf).digest("hex");
244
+ }
245
+ function renderForLocal(canonical, eolMode, style, platformEol2) {
246
+ if (eolMode === "binary" || isBinary(canonical)) return canonical;
247
+ let eol;
248
+ if (eolMode === "lf" || eolMode === "crlf") eol = eolMode;
249
+ else eol = style?.eol ?? platformEol2;
250
+ let body = canonical;
251
+ if (eol === "crlf") {
252
+ let count = 0;
253
+ for (const b of canonical) if (b === 10) count++;
254
+ body = Buffer.allocUnsafe(canonical.length + count);
255
+ let j = 0;
256
+ for (const b of canonical) {
257
+ if (b === 10) body[j++] = 13;
258
+ body[j++] = b;
259
+ }
260
+ }
261
+ return style?.bom ? Buffer.concat([BOM, body]) : body;
262
+ }
263
+ function defaultEolFor(name) {
264
+ const lower = name.toLowerCase();
265
+ if (/\.(sh|bash|zsh)$/.test(lower)) return "lf";
266
+ if (/\.(bat|cmd)$/.test(lower)) return "crlf";
267
+ return "auto";
268
+ }
269
+ function globMatch(glob, relPath) {
270
+ let re = "";
271
+ for (let i = 0; i < glob.length; i++) {
272
+ const c = glob[i];
273
+ if (c === "*" && glob[i + 1] === "*") {
274
+ re += ".*";
275
+ i++;
276
+ } else if (c === "*") re += "[^/]*";
277
+ else if (c === "?") re += "[^/]";
278
+ else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
279
+ }
280
+ const target = glob.includes("/") ? relPath : relPath.split("/").pop();
281
+ return new RegExp("^" + re + "$", "i").test(target);
282
+ }
283
+ function resolveEol(relPath, repoRules = {}, explicit) {
284
+ if (explicit) return explicit;
285
+ for (const [glob, mode] of Object.entries(repoRules)) if (globMatch(glob, relPath)) return mode;
286
+ return defaultEolFor(relPath);
287
+ }
288
+
289
+ // packages/shared/src/names.js
290
+ var RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i;
291
+ var ILLEGAL = /[<>:"/\\|?*\u0000-\u001f]/;
292
+ var MAX_REL_PATH = 200;
293
+ function nameError(name) {
294
+ if (!name || name === "." || name === "..") return `invalid name "${name}"`;
295
+ if (ILLEGAL.test(name)) return `"${name}" contains a character not allowed on Windows`;
296
+ if (/[. ]$/.test(name)) return `"${name}" ends with a dot or space`;
297
+ if (RESERVED.test(name)) return `"${name}" is a reserved name on Windows`;
298
+ if (Buffer.byteLength(name) > 255) return `"${name}" is too long`;
299
+ return null;
300
+ }
301
+ function relPathError(relPath) {
302
+ if (typeof relPath !== "string" || !relPath) return "empty path";
303
+ if (relPath.length > MAX_REL_PATH) return `path "${relPath}" is longer than ${MAX_REL_PATH} characters`;
304
+ if (relPath.startsWith("/")) return `path "${relPath}" must be relative`;
305
+ for (const seg of relPath.split("/")) {
306
+ const e = nameError(seg);
307
+ if (e) return e;
308
+ }
309
+ return null;
310
+ }
311
+ function caseCollisionError(relPaths) {
312
+ const seen = /* @__PURE__ */ new Map();
313
+ for (const p of relPaths) {
314
+ const segs = p.split("/");
315
+ for (let i = 1; i <= segs.length; i++) {
316
+ const prefix = segs.slice(0, i).join("/");
317
+ const key = prefix.toLowerCase();
318
+ const prev = seen.get(key);
319
+ if (prev !== void 0 && prev !== prefix) return `"${prev}" and "${prefix}" differ only by case`;
320
+ seen.set(key, prefix);
321
+ }
322
+ }
323
+ const files2 = new Set(relPaths.map((p) => p.toLowerCase()));
324
+ for (const p of relPaths) {
325
+ const segs = p.toLowerCase().split("/");
326
+ for (let i = 1; i < segs.length; i++) {
327
+ const prefix = segs.slice(0, i).join("/");
328
+ if (files2.has(prefix)) return `"${prefix}" is both a file and a folder`;
329
+ }
330
+ }
331
+ return null;
332
+ }
333
+
334
+ // packages/shared/src/ids.js
335
+ import crypto2 from "node:crypto";
336
+ var ALPHABET = "abcdefghijkmnpqrstuvwxyz23456789";
337
+ function randomString(len) {
338
+ const bytes = crypto2.randomBytes(len);
339
+ let out = "";
340
+ for (let i = 0; i < len; i++) out += ALPHABET[bytes[i] % ALPHABET.length];
341
+ return out;
342
+ }
343
+ var newInstanceId = () => "i_" + randomString(10);
344
+
345
+ // packages/client/src/api.js
346
+ var ApiError = class extends Error {
347
+ constructor(status, body) {
348
+ super(body?.message ?? `HTTP ${status}`);
349
+ this.status = status;
350
+ this.code = body?.code;
351
+ this.body = body;
352
+ }
353
+ };
354
+ var OfflineError = class extends Error {
355
+ };
356
+ var FIREBASE_ERRORS = {
357
+ EMAIL_EXISTS: "an account with that email already exists",
358
+ INVALID_LOGIN_CREDENTIALS: "wrong email or password",
359
+ INVALID_PASSWORD: "wrong email or password",
360
+ EMAIL_NOT_FOUND: "wrong email or password",
361
+ USER_DISABLED: "this account has been disabled",
362
+ TOKEN_EXPIRED: "session expired, run `tether login`",
363
+ INVALID_REFRESH_TOKEN: "session expired, run `tether login`",
364
+ USER_NOT_FOUND: "account no longer exists, run `tether login`",
365
+ INVALID_EMAIL: "a valid email is required"
366
+ };
367
+ function firebaseMessage(code = "") {
368
+ const key = code.split(":")[0].split(" ")[0].trim();
369
+ if (key.startsWith("WEAK_PASSWORD")) return "password must be at least 6 characters";
370
+ if (key.startsWith("TOO_MANY_ATTEMPTS")) return "too many attempts; try again later";
371
+ return FIREBASE_ERRORS[key] ?? `sign-in failed (${code || "unknown error"})`;
372
+ }
373
+ var Api = class {
374
+ constructor({ config, credentials }) {
375
+ this.config = config;
376
+ this.credentials = credentials;
377
+ this.accessToken = null;
378
+ }
379
+ get base() {
380
+ return this.config.get("server").replace(/\/$/, "");
381
+ }
382
+ async raw(method, p, body, token) {
383
+ let res;
384
+ try {
385
+ res = await fetch(this.base + "/v1" + p, {
386
+ method,
387
+ headers: { "content-type": "application/json", ...token ? { authorization: `Bearer ${token}` } : {} },
388
+ body: body === void 0 ? void 0 : JSON.stringify(body),
389
+ signal: AbortSignal.timeout(3e4)
390
+ });
391
+ } catch (e) {
392
+ throw new OfflineError(`cannot reach the Tether server at ${this.base} (${e.cause?.code ?? e.message})`);
393
+ }
394
+ const json = await res.json().catch(() => ({}));
395
+ if (!res.ok) throw new ApiError(res.status, json.error);
396
+ return json;
397
+ }
398
+ /** The server's auth setup: { auth: 'local' } or { auth: 'firebase', apiKey, emulatorHost? }. */
399
+ async serverConfig() {
400
+ if (this.cfg?.base === this.base) return this.cfg.value;
401
+ const value = await this.raw("GET", "/config");
402
+ this.cfg = { base: this.base, value };
403
+ return value;
404
+ }
405
+ /** Google endpoints (or the Auth emulator) for Firebase sign-in. */
406
+ googleUrl(service, method, cfg) {
407
+ const root = cfg.emulatorHost ? `http://${cfg.emulatorHost}/${service}.googleapis.com/v1` : `https://${service}.googleapis.com/v1`;
408
+ return `${root}/${method}?key=${encodeURIComponent(cfg.apiKey)}`;
409
+ }
410
+ async google(service, method, body, form = false) {
411
+ const cfg = await this.serverConfig();
412
+ let res;
413
+ try {
414
+ res = await fetch(this.googleUrl(service, method, cfg), {
415
+ method: "POST",
416
+ headers: { "content-type": form ? "application/x-www-form-urlencoded" : "application/json" },
417
+ body: form ? new URLSearchParams(body).toString() : JSON.stringify(body),
418
+ signal: AbortSignal.timeout(3e4)
419
+ });
420
+ } catch (e) {
421
+ throw new OfflineError(`cannot reach Firebase Auth (${e.cause?.code ?? e.message})`);
422
+ }
423
+ const json = await res.json().catch(() => ({}));
424
+ if (!res.ok) throw new ApiError(res.status === 400 ? 401 : res.status, { code: "unauthorized", message: firebaseMessage(json.error?.message) });
425
+ return json;
426
+ }
427
+ async token() {
428
+ if (this.accessToken && (!this.accessExp || Date.now() < this.accessExp)) return this.accessToken;
429
+ const creds = await this.credentials.load();
430
+ if (!creds?.refreshToken) throw new ApiError(401, { code: "unauthorized", message: "not signed in, run `tether login`" });
431
+ if (creds.firebase) {
432
+ const r2 = await this.google("securetoken", "token", { grant_type: "refresh_token", refresh_token: creds.refreshToken }, true);
433
+ this.accessToken = r2.id_token;
434
+ this.accessExp = Date.now() + (Number(r2.expires_in) - 120) * 1e3;
435
+ return this.accessToken;
436
+ }
437
+ const r = await this.raw("POST", "/auth/refresh", { refreshToken: creds.refreshToken });
438
+ this.accessToken = r.accessToken;
439
+ this.accessExp = null;
440
+ return this.accessToken;
441
+ }
442
+ /** Email + password sign-in (or sign-up) against whichever auth system the server uses. */
443
+ async passwordLogin({ email, password, signup }) {
444
+ const cfg = await this.serverConfig();
445
+ if (cfg.auth !== "firebase") return this.signedIn(await this.raw("POST", signup ? "/auth/signup" : "/auth/login", { email, password }));
446
+ const r = await this.google("identitytoolkit", signup ? "accounts:signUp" : "accounts:signInWithPassword", { email, password, returnSecureToken: true });
447
+ return this.firebaseSignedIn(r.idToken, r.refreshToken, r.expiresIn);
448
+ }
449
+ /** Completes the browser login: the console's one-time code becomes a session. */
450
+ async exchangeCliCode(code) {
451
+ const r = await this.raw("POST", "/auth/cli-exchange", { code });
452
+ if (!r.customToken) return this.signedIn(r);
453
+ const t = await this.google("identitytoolkit", "accounts:signInWithCustomToken", { token: r.customToken, returnSecureToken: true });
454
+ return this.firebaseSignedIn(t.idToken, t.refreshToken, t.expiresIn);
455
+ }
456
+ async firebaseSignedIn(idToken, refreshToken, expiresIn) {
457
+ this.accessToken = idToken;
458
+ this.accessExp = Date.now() + (Number(expiresIn) - 120) * 1e3;
459
+ const user = await this.raw("GET", "/me", void 0, idToken);
460
+ await this.credentials.save({ firebase: true, refreshToken, user, server: this.base });
461
+ return user;
462
+ }
463
+ /** Authenticated call; refreshes the access token once on 401. */
464
+ async call(method, p, body) {
465
+ try {
466
+ return await this.raw(method, p, body, await this.token());
467
+ } catch (e) {
468
+ if (e instanceof ApiError && e.status === 401 && this.accessToken) {
469
+ this.accessToken = null;
470
+ return this.raw(method, p, body, await this.token());
471
+ }
472
+ throw e;
473
+ }
474
+ }
475
+ async signedIn(tokens) {
476
+ await this.credentials.save({ refreshToken: tokens.refreshToken, user: tokens.user, server: this.base });
477
+ this.accessToken = tokens.accessToken;
478
+ this.accessExp = null;
479
+ return tokens.user;
480
+ }
481
+ async logout() {
482
+ const creds = await this.credentials.load();
483
+ if (creds?.refreshToken && !creds.firebase) await this.raw("POST", "/auth/logout", { refreshToken: creds.refreshToken }).catch(() => {
484
+ });
485
+ this.credentials.clear();
486
+ this.accessToken = null;
487
+ }
488
+ /** Uploads canonical blobs the server doesn't have yet. blobs: [{ sha256, bytes }] */
489
+ async uploadBlobs(blobs) {
490
+ const unique = [...new Map(blobs.map((b) => [b.sha256, b])).values()];
491
+ if (!unique.length) return;
492
+ const { upload } = await this.call("POST", "/blobs/upload", { blobs: unique.map((b) => ({ sha256: b.sha256, size: b.bytes.length })) });
493
+ const bySha = new Map(unique.map((b) => [b.sha256, b]));
494
+ for (const u of upload) {
495
+ const res = await fetch(u.url, { method: "PUT", headers: u.headers ?? {}, body: bySha.get(u.sha256).bytes });
496
+ if (!res.ok && res.status !== 412) throw new ApiError(res.status, { message: `upload failed (${res.status}): ${(await res.text().catch(() => "")).slice(0, 200)}` });
497
+ }
498
+ }
499
+ /** Downloads blobs for [{ artifactId, version? }] into the cache; skips blobs already cached. */
500
+ async fetchBlobs(cache, items) {
501
+ if (!items.length) return [];
502
+ const { downloads } = await this.call("POST", "/blobs/download", { items });
503
+ for (const d of downloads) {
504
+ if (cache.has(d.sha256)) continue;
505
+ const res = await fetch(d.url);
506
+ if (!res.ok) throw new ApiError(res.status, { message: `download failed (${res.status})` });
507
+ const bytes = Buffer.from(await res.arrayBuffer());
508
+ if (sha256(bytes) !== d.sha256) throw new Error("downloaded content does not match its hash");
509
+ cache.put(d.sha256, bytes);
510
+ }
511
+ return downloads;
512
+ }
513
+ };
514
+ var BlobCache = class {
515
+ constructor(dir) {
516
+ this.dir = dir;
517
+ fs3.mkdirSync(dir, { recursive: true });
518
+ }
519
+ file(sha) {
520
+ return path.join(this.dir, sha.slice(0, 2), sha);
521
+ }
522
+ has(sha) {
523
+ return !!sha && fs3.existsSync(this.file(sha));
524
+ }
525
+ get(sha) {
526
+ return fs3.readFileSync(this.file(sha));
527
+ }
528
+ put(sha, bytes) {
529
+ const f = this.file(sha);
530
+ if (fs3.existsSync(f)) return;
531
+ fs3.mkdirSync(path.dirname(f), { recursive: true });
532
+ const tmp = f + "." + crypto3.randomBytes(4).toString("hex");
533
+ fs3.writeFileSync(tmp, bytes);
534
+ fs3.renameSync(tmp, f);
535
+ }
536
+ };
537
+
538
+ // packages/client/src/platform/tracker.js
539
+ import fs4 from "node:fs";
540
+ import path2 from "node:path";
541
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", ".cache", ".npm", ".cargo", ".rustup", "proc", "sys"]);
542
+ var MAX_SCAN_ENTRIES = 3e5;
543
+ var WindowsTracker = class {
544
+ constructor(win32) {
545
+ this.w = win32;
546
+ this.name = "windows-fileid";
547
+ }
548
+ identify(p) {
549
+ return this.w.fileIdentity(p);
550
+ }
551
+ isTrash(p) {
552
+ return this.w.isTrashPath(p);
553
+ }
554
+ async findAll(instances) {
555
+ const out = /* @__PURE__ */ new Map();
556
+ for (const inst of instances) {
557
+ if (!inst.fileId) {
558
+ out.set(inst.instanceId, { status: "gone" });
559
+ continue;
560
+ }
561
+ const here = this.identify(inst.path);
562
+ if (here && here.fileId === inst.fileId && here.volumeId === inst.volumeId) {
563
+ let real = inst.path;
564
+ try {
565
+ real = fs4.realpathSync.native(inst.path);
566
+ } catch {
567
+ }
568
+ out.set(inst.instanceId, { status: "found", path: real });
569
+ continue;
570
+ }
571
+ const root = path2.parse(inst.path).root;
572
+ if (!fs4.existsSync(root)) {
573
+ out.set(inst.instanceId, { status: "unavailable" });
574
+ continue;
575
+ }
576
+ const p = this.w.pathById(inst.volumeId, inst.fileId, inst.path);
577
+ if (p === null && !this.w.fileIdentity(root)) out.set(inst.instanceId, { status: "unavailable" });
578
+ else if (p === null || this.isTrash(p)) out.set(inst.instanceId, { status: "gone" });
579
+ else out.set(inst.instanceId, { status: "found", path: p });
580
+ }
581
+ return out;
582
+ }
583
+ };
584
+ var ScanTracker = class {
585
+ constructor({ scanRoots }) {
586
+ this.scanRoots = scanRoots;
587
+ this.name = "scan";
588
+ }
589
+ identify(p) {
590
+ try {
591
+ const s = fs4.statSync(p, { bigint: true });
592
+ return { volumeId: s.dev.toString(16), fileId: s.ino.toString(16) };
593
+ } catch {
594
+ return null;
595
+ }
596
+ }
597
+ isTrash(p) {
598
+ return /[\\/](\.local[\\/]share[\\/]Trash|\.Trash(-\d+)?|\$Recycle\.Bin)[\\/]/i.test(p);
599
+ }
600
+ async findAll(instances, hintDirs = []) {
601
+ const out = /* @__PURE__ */ new Map();
602
+ const want = /* @__PURE__ */ new Map();
603
+ for (const inst of instances) {
604
+ const here = this.identify(inst.path);
605
+ if (here && here.fileId === inst.fileId && here.volumeId === inst.volumeId) out.set(inst.instanceId, { status: "found", path: inst.path });
606
+ else if (inst.fileId) want.set(`${inst.volumeId}:${inst.fileId}`, inst);
607
+ else out.set(inst.instanceId, { status: "gone" });
608
+ }
609
+ if (want.size) {
610
+ const dirs = [.../* @__PURE__ */ new Set([...hintDirs, ...[...want.values()].map((i) => path2.dirname(i.path))])];
611
+ this.scan(dirs, want, out, 1);
612
+ if (want.size) this.scan(this.scanRoots, want, out, Infinity);
613
+ }
614
+ for (const inst of want.values()) out.set(inst.instanceId, { status: "gone" });
615
+ return out;
616
+ }
617
+ scan(roots, want, out, maxDepth) {
618
+ let budget = MAX_SCAN_ENTRIES;
619
+ const queue = roots.filter((r) => fs4.existsSync(r)).map((r) => [r, 0]);
620
+ const seen = /* @__PURE__ */ new Set();
621
+ while (queue.length && want.size && budget > 0) {
622
+ const [dir, depth] = queue.shift();
623
+ if (seen.has(dir) || this.isTrash(dir + path2.sep)) continue;
624
+ seen.add(dir);
625
+ let entries;
626
+ try {
627
+ entries = fs4.readdirSync(dir, { withFileTypes: true });
628
+ } catch {
629
+ continue;
630
+ }
631
+ for (const e of entries) {
632
+ if (--budget <= 0) break;
633
+ const p = path2.join(dir, e.name);
634
+ if (e.isSymbolicLink()) continue;
635
+ const id = this.identify(p);
636
+ const key = id && `${id.volumeId}:${id.fileId}`;
637
+ if (key && want.has(key)) {
638
+ out.set(want.get(key).instanceId, { status: "found", path: p });
639
+ want.delete(key);
640
+ }
641
+ if (e.isDirectory() && depth + 1 < maxDepth && !SKIP_DIRS.has(e.name)) queue.push([p, depth + 1]);
642
+ }
643
+ }
644
+ }
645
+ };
646
+ async function createTracker(config) {
647
+ const choice = process.env.TETHER_TRACKER || (isWindows ? "windows" : "scan");
648
+ if (choice === "windows") return new WindowsTracker(await import("./win32-DF53YR5N.js"));
649
+ return new ScanTracker({ scanRoots: config.get("scanRoots") });
650
+ }
651
+
652
+ // packages/client/src/engine.js
653
+ import fs6 from "node:fs";
654
+ import path4 from "node:path";
655
+ import { createTwoFilesPatch } from "diff";
656
+
657
+ // packages/client/src/fsops.js
658
+ import fs5 from "node:fs";
659
+ import path3 from "node:path";
660
+ var platformEol = isWindows ? "crlf" : "lf";
661
+ var LockedError = class extends Error {
662
+ };
663
+ var samePath = (a, b) => isWindows ? a.toLowerCase() === b.toLowerCase() : a === b;
664
+ function isUnder(child, dir) {
665
+ const rel = path3.relative(dir, child);
666
+ return rel === "" || !!rel && !rel.startsWith("..") && !path3.isAbsolute(rel);
667
+ }
668
+ function canonicalPath(p) {
669
+ try {
670
+ return fs5.realpathSync.native(p);
671
+ } catch {
672
+ return path3.resolve(p);
673
+ }
674
+ }
675
+ function statOrNull(p) {
676
+ try {
677
+ return fs5.statSync(p);
678
+ } catch {
679
+ return null;
680
+ }
681
+ }
682
+ function readLocal(p, eolMode) {
683
+ let bytes, st;
684
+ try {
685
+ bytes = fs5.readFileSync(p);
686
+ st = fs5.statSync(p);
687
+ } catch (e) {
688
+ if (e.code === "ENOENT" || e.code === "EISDIR") return null;
689
+ if (["EBUSY", "EPERM", "EACCES"].includes(e.code)) throw new LockedError(`${p} is locked by another program`);
690
+ throw e;
691
+ }
692
+ const canonical = canonicalize(bytes, eolMode).bytes;
693
+ return { bytes, canonical, hash: sha256(canonical), style: detectStyle(bytes), size: st.size, mtimeMs: st.mtimeMs };
694
+ }
695
+ function writeLocal(p, canonical, eolMode, { exec = false, style = null } = {}) {
696
+ let existing = null;
697
+ try {
698
+ existing = fs5.readFileSync(p);
699
+ } catch (e) {
700
+ if (e.code !== "ENOENT") throw toLocked(e, p);
701
+ }
702
+ const useStyle = style ?? (existing ? detectStyle(existing) : null);
703
+ const bytes = renderForLocal(canonical, eolMode, useStyle, platformEol);
704
+ try {
705
+ if (existing) {
706
+ const fd = fs5.openSync(p, "r+");
707
+ try {
708
+ fs5.ftruncateSync(fd, 0);
709
+ fs5.writeSync(fd, bytes, 0, bytes.length, 0);
710
+ } finally {
711
+ fs5.closeSync(fd);
712
+ }
713
+ } else {
714
+ fs5.mkdirSync(path3.dirname(p), { recursive: true });
715
+ fs5.writeFileSync(p, bytes, { flag: "wx" });
716
+ }
717
+ if (!isWindows) fs5.chmodSync(p, exec ? 493 : fs5.statSync(p).mode & ~73 & 511);
718
+ } catch (e) {
719
+ throw toLocked(e, p);
720
+ }
721
+ const st = fs5.statSync(p);
722
+ const written = detectStyle(bytes);
723
+ return { size: st.size, mtimeMs: st.mtimeMs, eolStyle: written.eol ?? useStyle?.eol ?? null, hasBom: written.bom };
724
+ }
725
+ var styleOf = (local) => ({ eolStyle: local.style.eol, hasBom: local.style.bom });
726
+ function toLocked(e, p) {
727
+ if (["EBUSY", "EPERM", "EACCES"].includes(e.code)) return new LockedError(`${p} is locked or read-only; will retry`);
728
+ return e;
729
+ }
730
+ function keepBothName(p) {
731
+ const dir = path3.dirname(p);
732
+ const ext = path3.extname(p);
733
+ const stem = path3.basename(p, ext);
734
+ for (let n = 1; ; n++) {
735
+ const candidate = path3.join(dir, `${stem} (local copy${n > 1 ? " " + n : ""})${ext}`);
736
+ if (!fs5.existsSync(candidate)) return candidate;
737
+ }
738
+ }
739
+ function moveFile(from, to) {
740
+ fs5.mkdirSync(path3.dirname(to), { recursive: true });
741
+ fs5.renameSync(from, to);
742
+ }
743
+ function pruneEmptyDirs(dir, stopAt) {
744
+ while (isUnder(dir, stopAt) && !samePath(dir, stopAt)) {
745
+ try {
746
+ fs5.rmdirSync(dir);
747
+ } catch {
748
+ return;
749
+ }
750
+ dir = path3.dirname(dir);
751
+ }
752
+ }
753
+ function walkFiles(root) {
754
+ const files2 = [];
755
+ const symlinks = [];
756
+ const stack = [root];
757
+ while (stack.length) {
758
+ const dir = stack.pop();
759
+ for (const e of fs5.readdirSync(dir, { withFileTypes: true })) {
760
+ const abs = path3.join(dir, e.name);
761
+ const rel = path3.relative(root, abs).split(path3.sep).join("/");
762
+ if (e.isSymbolicLink()) symlinks.push(rel);
763
+ else if (e.isDirectory()) stack.push(abs);
764
+ else if (e.isFile()) files2.push({ abs, rel });
765
+ }
766
+ }
767
+ return { files: files2.sort((a, b) => a.rel.localeCompare(b.rel)), symlinks };
768
+ }
769
+
770
+ // packages/client/src/engine.js
771
+ var UserError = class extends Error {
772
+ };
773
+ var ACTIVE = (i) => i.mode !== "orphaned";
774
+ var Engine = class {
775
+ constructor({ db, api, cache, tracker, config, log = () => {
776
+ }, notify = () => {
777
+ }, home = null, cliPath = null }) {
778
+ this.db = db;
779
+ this.api = api;
780
+ this.cache = cache;
781
+ this.tracker = tracker;
782
+ this.config = config;
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;
789
+ this.offline = null;
790
+ this.lastTick = null;
791
+ this.graceDue = null;
792
+ this.queue = Promise.resolve();
793
+ }
794
+ /** Serializes everything that touches instances (ticks, watcher passes, commands). */
795
+ locked(fn) {
796
+ const run = this.queue.then(fn, fn);
797
+ this.queue = run.catch(() => {
798
+ });
799
+ return run;
800
+ }
801
+ // ---------- helpers ----------
802
+ folderOf(inst) {
803
+ return inst.folderInstanceId ? this.db.get(inst.folderInstanceId) : null;
804
+ }
805
+ expectedPath(child, folder = this.folderOf(child)) {
806
+ return path4.join(folder.path, ...child.relPath.split("/"));
807
+ }
808
+ /** Current relative path of a child inside its folder, or null if it was moved out. */
809
+ localRel(child, folder = this.folderOf(child)) {
810
+ if (!isUnder(child.path, folder.path) || samePath(child.path, folder.path)) return null;
811
+ return path4.relative(folder.path, child.path).split(path4.sep).join("/");
812
+ }
813
+ identity(p) {
814
+ const id = this.tracker.identify(p);
815
+ if (!id) throw new Error(`cannot read the file ID of ${p}`);
816
+ return id;
817
+ }
818
+ blobFor(sha) {
819
+ if (!this.cache.has(sha)) throw new Error(`content ${sha.slice(0, 12)} is not in the local cache`);
820
+ return this.cache.get(sha);
821
+ }
822
+ async ensureBlobs(pairs) {
823
+ const missing = pairs.filter((p) => p.sha && !this.cache.has(p.sha));
824
+ if (missing.length) await this.api.fetchBlobs(this.cache, missing.map(({ artifactId, version }) => ({ artifactId, version })));
825
+ }
826
+ /** Writes content for an instance at `p`; records the result for the quick change check. */
827
+ writeInstance(inst, p, sha, eolMode = inst.eolMode, exec = inst.exec) {
828
+ const style = inst.eolStyle ? { eol: inst.eolStyle, bom: !!inst.hasBom } : null;
829
+ return writeLocal(p, this.blobFor(sha), eolMode, { exec, style });
830
+ }
831
+ // ---------- tick ----------
832
+ async tick({ remote = true } = {}) {
833
+ this.busyCache = /* @__PURE__ */ new Map();
834
+ await this.locateAll();
835
+ if (remote) await this.gitTransitions();
836
+ if (remote) {
837
+ await this.checkRemote();
838
+ await this.syncRealtime();
839
+ }
840
+ await this.enforceLocal();
841
+ this.lastTick = Date.now();
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
+ }
1039
+ /** Refresh path and file ID of every tracked instance (DESIGN §5). */
1040
+ async locateAll() {
1041
+ const graceMs = this.config.get("graceMs");
1042
+ const all = this.db.all().filter((i) => ACTIVE(i) && !i.detached && !i.needsRestore && i.fileId);
1043
+ all.sort((a, b) => (a.kind === "folder" ? -1 : 0) - (b.kind === "folder" ? -1 : 0));
1044
+ const hints = [...new Set(all.map((i) => path4.dirname(i.path)))];
1045
+ const found = await this.tracker.findAll(all, hints);
1046
+ const now = Date.now();
1047
+ this.graceDue = null;
1048
+ for (const inst of all) {
1049
+ const cur = this.db.get(inst.instanceId);
1050
+ if (!cur || cur.mode === "orphaned" || cur.detached) continue;
1051
+ const r = found.get(inst.instanceId);
1052
+ if (r.status === "found") {
1053
+ if (cur.mode === "lost") {
1054
+ this.db.update(cur.instanceId, { mode: cur.prevMode || "normal", prevMode: null, missingSince: null, path: r.path });
1055
+ this.log(`${r.path}: found again`);
1056
+ continue;
1057
+ }
1058
+ if (!samePath(r.path, cur.path) || r.path !== cur.path) {
1059
+ const idAtOld = cur.kind === "file" && path4.dirname(r.path) === path4.dirname(cur.path) && !samePath(r.path, cur.path) ? this.tracker.identify(cur.path) : null;
1060
+ if (idAtOld && idAtOld.fileId !== cur.fileId && statOrNull(cur.path)?.isFile()) {
1061
+ this.db.update(cur.instanceId, { ...idAtOld, missingSince: null });
1062
+ this.log(`${cur.path}: re-linked after an atomic save`);
1063
+ } else {
1064
+ this.db.update(cur.instanceId, { path: r.path, missingSince: null });
1065
+ if (!samePath(r.path, cur.path)) this.log(`${cur.path} -> ${r.path}: followed move`);
1066
+ }
1067
+ } else if (cur.missingSince) this.db.update(cur.instanceId, { missingSince: null });
1068
+ continue;
1069
+ }
1070
+ if (r.status === "unavailable") {
1071
+ if (cur.mode !== "lost") {
1072
+ this.db.update(cur.instanceId, { mode: "lost", prevMode: cur.mode });
1073
+ this.log(`${cur.path}: volume unavailable, instance is lost`);
1074
+ }
1075
+ continue;
1076
+ }
1077
+ const st = statOrNull(cur.path);
1078
+ if (st && (cur.kind === "folder" ? st.isDirectory() : st.isFile())) {
1079
+ this.db.update(cur.instanceId, { ...this.identity(cur.path), missingSince: null });
1080
+ this.log(`${cur.path}: re-linked after an atomic save`);
1081
+ continue;
1082
+ }
1083
+ if (!cur.missingSince) {
1084
+ this.db.update(cur.instanceId, { missingSince: now });
1085
+ this.graceDue = Math.min(this.graceDue ?? Infinity, now + graceMs);
1086
+ } else if (now - cur.missingSince >= graceMs) {
1087
+ this.disappeared(cur);
1088
+ } else {
1089
+ this.graceDue = Math.min(this.graceDue ?? Infinity, cur.missingSince + graceMs);
1090
+ }
1091
+ }
1092
+ }
1093
+ /** The file is gone for good (after the grace window). DESIGN §7. */
1094
+ disappeared(inst) {
1095
+ if (inst.kind === "folder") {
1096
+ for (const c of this.db.childrenOf(inst.instanceId)) this.db.remove(c.instanceId);
1097
+ this.db.remove(inst.instanceId);
1098
+ this.log(`${inst.path}: folder deleted locally; no longer managed here`);
1099
+ return;
1100
+ }
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") {
1105
+ this.db.update(inst.instanceId, { detached: true, missingSince: null });
1106
+ this.log(`${inst.path}: deleted while editing; resolve with \`tether done\``);
1107
+ } else if (inst.folderInstanceId) {
1108
+ this.db.update(inst.instanceId, { detached: true, mode: "unmanaged", missingSince: null });
1109
+ this.log(`${inst.path}: deleted locally; stopped managing it (\`tether track\` restores it)`);
1110
+ } else {
1111
+ this.db.remove(inst.instanceId);
1112
+ this.log(`${inst.path}: deleted locally; forgotten (pull again to get it back)`);
1113
+ }
1114
+ }
1115
+ orphan(inst, reason) {
1116
+ const targets = inst.kind === "folder" ? [inst, ...this.db.childrenOf(inst.instanceId)] : [inst];
1117
+ for (const t of targets) this.db.update(t.instanceId, { mode: "orphaned", orphanReason: reason });
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) });
1121
+ }
1122
+ /** One batched call for all instances (DESIGN §6.1 step 2). */
1123
+ async checkRemote() {
1124
+ const insts = this.db.all().filter(ACTIVE);
1125
+ if (!insts.length) {
1126
+ this.offline = null;
1127
+ return;
1128
+ }
1129
+ let results;
1130
+ try {
1131
+ ({ results } = await this.api.call("POST", "/sync/check", { items: insts.map((i) => ({ artifactId: i.artifactId, version: i.baseVersion })) }));
1132
+ this.offline = null;
1133
+ } catch (e) {
1134
+ if (e instanceof OfflineError || e instanceof ApiError && e.status === 401) {
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.` });
1137
+ return;
1138
+ }
1139
+ throw e;
1140
+ }
1141
+ const incoming = [];
1142
+ const folders = [];
1143
+ insts.forEach((inst, idx) => {
1144
+ const r = results[idx];
1145
+ const cur = this.db.get(inst.instanceId);
1146
+ if (!cur || cur.mode === "orphaned") return;
1147
+ if (r.status === "gone") return this.orphan(cur, r.reason);
1148
+ if (r.folderDeleted || r.state?.deleted) return this.orphan(cur, "deleted");
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
+ };
1155
+ if (cur.role && cur.role !== "viewer" && r.role === "viewer" && cur.mode === "editing") {
1156
+ patch.mode = "normal";
1157
+ patch.realtime = false;
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 });
1164
+ }
1165
+ const now = this.db.update(cur.instanceId, patch);
1166
+ if (r.status !== "changed") return;
1167
+ if (now.mode !== "normal") return;
1168
+ if (now.kind === "folder") folders.push({ inst: now, state: r.state, children: r.children });
1169
+ else if (!now.detached) incoming.push({ inst: now, state: r.state });
1170
+ });
1171
+ const newChildren = [];
1172
+ for (const f of folders) {
1173
+ const have = new Set(this.db.childrenOf(f.inst.instanceId).map((c) => c.artifactId));
1174
+ for (const c of f.children) if (!c.deleted && !have.has(c.artifactId)) newChildren.push({ folder: f.inst, child: c });
1175
+ }
1176
+ try {
1177
+ await this.ensureBlobs([
1178
+ ...incoming.map(({ state }) => ({ artifactId: state.artifactId, version: state.version, sha: state.blob })),
1179
+ ...newChildren.map(({ child }) => ({ artifactId: child.artifactId, version: child.version, sha: child.blob }))
1180
+ ]);
1181
+ } catch (e) {
1182
+ if (e instanceof OfflineError) return this.offline = e.message;
1183
+ throw e;
1184
+ }
1185
+ for (const { inst, state } of incoming) if (!this.isGitBusy(inst)) this.applyIncoming(inst, state);
1186
+ for (const { folder, child } of newChildren) {
1187
+ this.db.insert({
1188
+ instanceId: newInstanceId(),
1189
+ artifactId: child.artifactId,
1190
+ kind: "file",
1191
+ folderInstanceId: folder.instanceId,
1192
+ repoId: child.repoId,
1193
+ name: child.name,
1194
+ relPath: child.relPath,
1195
+ path: path4.join(folder.path, ...child.relPath.split("/")),
1196
+ mode: "normal",
1197
+ baseVersion: child.version,
1198
+ baseHash: child.blob,
1199
+ eolMode: child.eol,
1200
+ exec: child.exec,
1201
+ role: folder.role,
1202
+ canDelete: folder.canDelete,
1203
+ needsRestore: true
1204
+ });
1205
+ this.log(`${folder.path}: new file ${child.relPath}`);
1206
+ }
1207
+ for (const f of folders) this.db.update(f.inst.instanceId, { baseVersion: f.state.version });
1208
+ }
1209
+ /** Normal mode: write an incoming version (content and/or layout). */
1210
+ applyIncoming(inst, state) {
1211
+ try {
1212
+ let p = inst.path;
1213
+ if (inst.folderInstanceId && state.relPath !== inst.relPath) {
1214
+ const folder = this.folderOf(inst);
1215
+ const to = path4.join(folder.path, ...state.relPath.split("/"));
1216
+ this.makeRoom(to, inst);
1217
+ moveFile(inst.path, to);
1218
+ pruneEmptyDirs(path4.dirname(inst.path), folder.path);
1219
+ p = to;
1220
+ this.log(`${inst.path} -> ${to}: moved by the server`);
1221
+ }
1222
+ const patch = { path: p, relPath: state.relPath, baseVersion: state.version, baseHash: state.blob, eolMode: state.eol, exec: state.exec, lastError: null };
1223
+ const localEdit = !this.matchesBase(inst, p) && readLocal(p, inst.eolMode)?.hash !== state.blob;
1224
+ if (state.blob !== inst.baseHash || localEdit) {
1225
+ Object.assign(patch, this.writeInstance(inst, p, state.blob, state.eol, state.exec));
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
+ }
1238
+ }
1239
+ this.db.update(inst.instanceId, patch);
1240
+ } catch (e) {
1241
+ if (!(e instanceof LockedError)) throw e;
1242
+ this.lockedNotice(inst, e);
1243
+ }
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
+ }
1249
+ /** If `to` is occupied by something that isn't this instance, move it aside (keep both). */
1250
+ makeRoom(to, inst) {
1251
+ const st = statOrNull(to);
1252
+ if (!st) return;
1253
+ const id = this.tracker.identify(to);
1254
+ if (id && id.fileId === inst.fileId) return;
1255
+ const aside = keepBothName(to);
1256
+ fs6.renameSync(to, aside);
1257
+ this.log(`${to}: existing file kept as ${path4.basename(aside)}`);
1258
+ }
1259
+ matchesBase(inst, p) {
1260
+ const st = statOrNull(p);
1261
+ if (!st) return false;
1262
+ if (st.size === inst.size && st.mtimeMs === inst.mtimeMs) return true;
1263
+ const local = readLocal(p, inst.eolMode);
1264
+ return local?.hash === inst.baseHash;
1265
+ }
1266
+ /** Normal mode: restore missing files, put moved files back, revert local edits. */
1267
+ async enforceLocal() {
1268
+ const restores = this.db.all().filter((i) => ACTIVE(i) && i.needsRestore && i.mode === "normal");
1269
+ try {
1270
+ await this.ensureBlobs(restores.map((i) => ({ artifactId: i.artifactId, version: i.baseVersion, sha: i.baseHash })));
1271
+ } catch (e) {
1272
+ if (!(e instanceof OfflineError)) throw e;
1273
+ this.offline = e.message;
1274
+ }
1275
+ for (const inst of this.db.all()) {
1276
+ if (inst.mode !== "normal" || inst.kind !== "file" || inst.detached) continue;
1277
+ if (this.isGitBusy(inst)) continue;
1278
+ try {
1279
+ if (inst.needsRestore) {
1280
+ if (this.cache.has(inst.baseHash)) this.restore(inst);
1281
+ continue;
1282
+ }
1283
+ let cur = inst;
1284
+ if (cur.folderInstanceId) cur = this.enforceLayout(cur);
1285
+ if (!cur) continue;
1286
+ const st = statOrNull(cur.path);
1287
+ if (!st) continue;
1288
+ if (st.size === cur.size && st.mtimeMs === cur.mtimeMs) continue;
1289
+ const local = readLocal(cur.path, cur.eolMode);
1290
+ if (!local) continue;
1291
+ if (local.hash === cur.baseHash) {
1292
+ this.db.update(cur.instanceId, { size: local.size, mtimeMs: local.mtimeMs, lastError: null });
1293
+ continue;
1294
+ }
1295
+ if (!this.cache.has(cur.baseHash)) continue;
1296
+ this.db.update(cur.instanceId, { ...this.writeInstance(cur, cur.path, cur.baseHash), lastError: null });
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
+ });
1307
+ } catch (e) {
1308
+ if (!(e instanceof LockedError)) throw e;
1309
+ this.lockedNotice(inst, e);
1310
+ }
1311
+ }
1312
+ }
1313
+ /** Normal-mode layout inside a folder: moves inside are reverted; a file moved out is left
1314
+ * where it is as an ordinary file and the folder gets a fresh copy. */
1315
+ enforceLayout(inst) {
1316
+ const folder = this.folderOf(inst);
1317
+ if (!folder || folder.mode === "orphaned") return inst;
1318
+ const rel = this.localRel(inst, folder);
1319
+ const expected = this.expectedPath(inst, folder);
1320
+ if (rel === null) {
1321
+ this.log(`${inst.path}: moved out of ${folder.path}; left there unmanaged, restoring the folder copy`);
1322
+ const next = this.db.update(inst.instanceId, { path: expected, fileId: null, volumeId: null, needsRestore: true });
1323
+ if (this.cache.has(next.baseHash)) this.restore(next);
1324
+ return null;
1325
+ }
1326
+ if (rel === inst.relPath) return inst;
1327
+ this.makeRoom(expected, inst);
1328
+ moveFile(inst.path, expected);
1329
+ pruneEmptyDirs(path4.dirname(inst.path), folder.path);
1330
+ this.log(`${inst.path}: move reverted to ${inst.relPath}`);
1331
+ return this.db.update(inst.instanceId, { path: expected });
1332
+ }
1333
+ /** Writes a fresh copy at the instance's expected path (new folder files, restores). */
1334
+ restore(inst) {
1335
+ const p = inst.folderInstanceId ? this.expectedPath(inst) : inst.path;
1336
+ const existing = readLocal(p, inst.eolMode);
1337
+ if (existing && existing.hash !== inst.baseHash) this.makeRoom(p, inst);
1338
+ const st = existing && existing.hash === inst.baseHash ? { size: existing.size, mtimeMs: existing.mtimeMs, ...styleOf(existing) } : this.writeInstance(inst, p, inst.baseHash);
1339
+ this.db.update(inst.instanceId, { ...st, ...this.identity(p), path: canonicalPath(p), needsRestore: false, detached: false, missingSince: null, lastError: null });
1340
+ }
1341
+ // ---------- commands ----------
1342
+ async findRepo(ref) {
1343
+ const repos = await this.api.call("GET", "/repos");
1344
+ const r = repos.find((x) => x.repoId === ref || x.name === ref);
1345
+ if (!r) throw new UserError(`no repo "${ref}" (create it with \`tether repo create ${ref}\`)`);
1346
+ return r;
1347
+ }
1348
+ instanceAt(p) {
1349
+ return this.db.all().find((i) => ACTIVE(i) && samePath(i.path, p));
1350
+ }
1351
+ checkOverlap(p) {
1352
+ for (const i of this.db.all().filter(ACTIVE)) {
1353
+ if (samePath(i.path, p)) throw new UserError(`${p} is already managed`);
1354
+ if (i.kind === "folder" && isUnder(p, i.path)) throw new UserError(`${p} is inside the managed folder ${i.path}; use \`tether add\``);
1355
+ if (isUnder(i.path, p)) throw new UserError(`${p} contains the managed path ${i.path}`);
1356
+ }
1357
+ }
1358
+ prepareFile(abs, relForEol, rules, eol) {
1359
+ const st = fs6.lstatSync(abs);
1360
+ if (st.isSymbolicLink()) throw new UserError(`${abs} is a symlink; symlinks are not supported`);
1361
+ const bytes = fs6.readFileSync(abs);
1362
+ const eolMode = resolveEol(relForEol, rules, eol);
1363
+ const canonical = canonicalize(bytes, eolMode).bytes;
1364
+ return { abs, bytes: canonical, sha256: sha256(canonical), size: canonical.length, eol: eolMode, exec: process.platform !== "win32" && !!(st.mode & 73), stat: st };
1365
+ }
1366
+ /** `tether manage <path> --repo <repo>`: upload; the caller becomes the owner. */
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 }) {
1374
+ const abs = canonicalPath(path4.resolve(target));
1375
+ const st = statOrNull(abs);
1376
+ if (!st) throw new UserError(`${abs} does not exist`);
1377
+ this.checkOverlap(abs);
1378
+ const repo = await this.findRepo(repoRef);
1379
+ const rules = repo.eolDefaults ?? {};
1380
+ const name = path4.basename(abs);
1381
+ const nErr = nameError(name);
1382
+ if (nErr) throw new UserError(nErr);
1383
+ if (st.isFile()) {
1384
+ const f = this.prepareFile(abs, name, rules, eol);
1385
+ await this.api.uploadBlobs([f]);
1386
+ const r2 = await this.api.call("POST", "/artifacts", { repoId: repo.repoId, kind: "file", name, blob: f.sha256, size: f.size, eol: f.eol, exec: f.exec });
1387
+ this.cache.put(f.sha256, f.bytes);
1388
+ const local = readLocal(abs, f.eol);
1389
+ this.db.insert({
1390
+ instanceId: newInstanceId(),
1391
+ artifactId: r2.artifact.artifactId,
1392
+ kind: "file",
1393
+ repoId: repo.repoId,
1394
+ name,
1395
+ path: abs,
1396
+ ...this.identity(abs),
1397
+ mode: "normal",
1398
+ baseVersion: 1,
1399
+ baseHash: f.sha256,
1400
+ size: local.size,
1401
+ mtimeMs: local.mtimeMs,
1402
+ ...styleOf(local),
1403
+ eolMode: f.eol,
1404
+ exec: f.exec,
1405
+ role: "owner",
1406
+ canDelete: true,
1407
+ remoteVersion: 1
1408
+ });
1409
+ return { artifactId: r2.artifact.artifactId, shareId: r2.artifact.shareId, kind: "file", path: abs, files: 1 };
1410
+ }
1411
+ const { files: files2, symlinks } = walkFiles(abs);
1412
+ if (!files2.length) throw new UserError(`${abs} has no files`);
1413
+ for (const f of files2) {
1414
+ const e = relPathError(f.rel);
1415
+ if (e) throw new UserError(e);
1416
+ }
1417
+ const c = caseCollisionError(files2.map((f) => f.rel));
1418
+ if (c) throw new UserError(c);
1419
+ const prepared = files2.map((f) => ({ ...this.prepareFile(f.abs, f.rel, rules), rel: f.rel }));
1420
+ await this.api.uploadBlobs(prepared);
1421
+ const r = await this.api.call("POST", "/artifacts", {
1422
+ repoId: repo.repoId,
1423
+ kind: "folder",
1424
+ name,
1425
+ files: prepared.map((f) => ({ relPath: f.rel, blob: f.sha256, size: f.size, eol: f.eol, exec: f.exec }))
1426
+ });
1427
+ const folderInst = this.db.insert({
1428
+ instanceId: newInstanceId(),
1429
+ artifactId: r.artifact.artifactId,
1430
+ kind: "folder",
1431
+ repoId: repo.repoId,
1432
+ name,
1433
+ path: abs,
1434
+ ...this.identity(abs),
1435
+ mode: "normal",
1436
+ baseVersion: 1,
1437
+ role: "owner",
1438
+ canDelete: true,
1439
+ remoteVersion: 1
1440
+ });
1441
+ const byRel = new Map(r.children.map((ch) => [ch.relPath, ch]));
1442
+ for (const f of prepared) {
1443
+ this.cache.put(f.sha256, f.bytes);
1444
+ const ch = byRel.get(f.rel);
1445
+ const local = readLocal(f.abs, f.eol);
1446
+ this.db.insert({
1447
+ instanceId: newInstanceId(),
1448
+ artifactId: ch.artifactId,
1449
+ kind: "file",
1450
+ folderInstanceId: folderInst.instanceId,
1451
+ repoId: repo.repoId,
1452
+ name: ch.name,
1453
+ relPath: f.rel,
1454
+ path: canonicalPath(f.abs),
1455
+ ...this.identity(f.abs),
1456
+ mode: "normal",
1457
+ baseVersion: 1,
1458
+ baseHash: f.sha256,
1459
+ size: local.size,
1460
+ mtimeMs: local.mtimeMs,
1461
+ ...styleOf(local),
1462
+ eolMode: f.eol,
1463
+ exec: f.exec,
1464
+ role: "owner",
1465
+ canDelete: true,
1466
+ remoteVersion: 1
1467
+ });
1468
+ }
1469
+ return { artifactId: r.artifact.artifactId, shareId: r.artifact.shareId, kind: "folder", path: abs, files: files2.length, skippedSymlinks: symlinks };
1470
+ }
1471
+ /** `tether add <path>`: owner adds a new file inside a managed folder. */
1472
+ async add({ path: target }) {
1473
+ const abs = canonicalPath(path4.resolve(target));
1474
+ if (!statOrNull(abs)?.isFile()) throw new UserError(`${abs} is not a file`);
1475
+ if (this.instanceAt(abs)) throw new UserError(`${abs} is already managed`);
1476
+ const folder = this.db.all().find((i) => ACTIVE(i) && i.kind === "folder" && isUnder(abs, i.path));
1477
+ if (!folder) throw new UserError(`${abs} is not inside a managed folder`);
1478
+ if (folder.role !== "owner") throw new UserError("only the owner can add files to a folder");
1479
+ const rel = path4.relative(folder.path, abs).split(path4.sep).join("/");
1480
+ const e = relPathError(rel);
1481
+ if (e) throw new UserError(e);
1482
+ const repo = (await this.api.call("GET", "/repos")).find((r) => r.repoId === folder.repoId);
1483
+ const f = this.prepareFile(abs, rel, repo?.eolDefaults ?? {});
1484
+ await this.api.uploadBlobs([f]);
1485
+ const child = await this.api.call("POST", `/artifacts/${folder.artifactId}/children`, { relPath: rel, blob: f.sha256, size: f.size, eol: f.eol, exec: f.exec });
1486
+ this.cache.put(f.sha256, f.bytes);
1487
+ const local = readLocal(abs, f.eol);
1488
+ this.db.insert({
1489
+ instanceId: newInstanceId(),
1490
+ artifactId: child.artifactId,
1491
+ kind: "file",
1492
+ folderInstanceId: folder.instanceId,
1493
+ repoId: folder.repoId,
1494
+ name: child.name,
1495
+ relPath: rel,
1496
+ path: abs,
1497
+ ...this.identity(abs),
1498
+ mode: "normal",
1499
+ baseVersion: 1,
1500
+ baseHash: f.sha256,
1501
+ size: local.size,
1502
+ mtimeMs: local.mtimeMs,
1503
+ ...styleOf(local),
1504
+ eolMode: f.eol,
1505
+ exec: f.exec,
1506
+ role: "owner",
1507
+ canDelete: true,
1508
+ remoteVersion: 1
1509
+ });
1510
+ return { artifactId: child.artifactId, relPath: rel, folder: folder.path };
1511
+ }
1512
+ /** `tether pull <shareId> [dest]`. Never overwrites existing user files. */
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) {
1528
+ const r = await this.api.call("POST", "/pull", { shareId });
1529
+ const a = r.artifact;
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 });
1532
+ if (this.instanceAt(target)) throw new UserError(`${target} is already a managed copy`);
1533
+ const base = { repoId: a.repoId, role: r.role, canDelete: r.canDelete };
1534
+ if (a.kind === "file") {
1535
+ await this.ensureBlobs([{ artifactId: a.artifactId, version: a.version, sha: a.blob }]);
1536
+ const existing = readLocal(target, a.eol);
1537
+ if (existing && existing.hash !== a.blob) throw new UserError(`${target} already exists with different content; choose another destination`);
1538
+ const st2 = existing ? { size: existing.size, mtimeMs: existing.mtimeMs, ...styleOf(existing) } : writeLocal(target, this.blobFor(a.blob), a.eol, { exec: a.exec });
1539
+ this.db.insert({
1540
+ ...base,
1541
+ instanceId: newInstanceId(),
1542
+ artifactId: a.artifactId,
1543
+ kind: "file",
1544
+ name: a.name,
1545
+ path: canonicalPath(target),
1546
+ ...this.identity(target),
1547
+ mode: "normal",
1548
+ baseVersion: a.version,
1549
+ baseHash: a.blob,
1550
+ ...st2,
1551
+ eolMode: a.eol,
1552
+ exec: a.exec,
1553
+ remoteVersion: a.version
1554
+ });
1555
+ return { kind: "file", path: canonicalPath(target), role: r.role, version: a.version };
1556
+ }
1557
+ const st = statOrNull(target);
1558
+ if (st && !st.isDirectory()) throw new UserError(`${target} exists and is not a folder`);
1559
+ await this.ensureBlobs(r.children.map((c) => ({ artifactId: c.artifactId, version: c.version, sha: c.blob })));
1560
+ const clashes = [];
1561
+ for (const c of r.children) {
1562
+ const p = path4.join(target, ...c.relPath.split("/"));
1563
+ const ex = readLocal(p, c.eol);
1564
+ if (ex && ex.hash !== c.blob) clashes.push(c.relPath);
1565
+ }
1566
+ if (clashes.length) throw new UserError(`${target} already has different files: ${clashes.join(", ")}; choose another destination`);
1567
+ fs6.mkdirSync(target, { recursive: true });
1568
+ const folderInst = this.db.insert({
1569
+ ...base,
1570
+ instanceId: newInstanceId(),
1571
+ artifactId: a.artifactId,
1572
+ kind: "folder",
1573
+ name: a.name,
1574
+ path: canonicalPath(target),
1575
+ ...this.identity(target),
1576
+ mode: "normal",
1577
+ baseVersion: a.version,
1578
+ remoteVersion: a.version
1579
+ });
1580
+ for (const c of r.children) {
1581
+ const p = path4.join(folderInst.path, ...c.relPath.split("/"));
1582
+ const ex = readLocal(p, c.eol);
1583
+ const cst = ex ? { size: ex.size, mtimeMs: ex.mtimeMs, ...styleOf(ex) } : writeLocal(p, this.blobFor(c.blob), c.eol, { exec: c.exec });
1584
+ this.db.insert({
1585
+ ...base,
1586
+ instanceId: newInstanceId(),
1587
+ artifactId: c.artifactId,
1588
+ kind: "file",
1589
+ folderInstanceId: folderInst.instanceId,
1590
+ name: c.name,
1591
+ relPath: c.relPath,
1592
+ path: canonicalPath(p),
1593
+ ...this.identity(p),
1594
+ mode: "normal",
1595
+ baseVersion: c.version,
1596
+ baseHash: c.blob,
1597
+ ...cst,
1598
+ eolMode: c.eol,
1599
+ exec: c.exec,
1600
+ remoteVersion: c.version
1601
+ });
1602
+ }
1603
+ return { kind: "folder", path: folderInst.path, role: r.role, files: r.children.length, version: a.version };
1604
+ }
1605
+ /** Instances addressed by a path: a file, a managed folder (with its files), or any directory. */
1606
+ targets(p) {
1607
+ const abs = canonicalPath(path4.resolve(p));
1608
+ const all = this.db.all().filter(ACTIVE);
1609
+ let hits = all.filter((i) => samePath(i.path, abs) || isUnder(i.path, abs));
1610
+ for (const f of hits.filter((i) => i.kind === "folder")) hits.push(...this.db.childrenOf(f.instanceId).filter(ACTIVE));
1611
+ hits = [...new Map(hits.map((i) => [i.instanceId, i])).values()];
1612
+ if (!hits.length) throw new UserError(`nothing managed at ${abs}`);
1613
+ return hits;
1614
+ }
1615
+ setMode(p, mode, from) {
1616
+ const changed = [];
1617
+ for (const i of this.targets(p)) {
1618
+ if (!from.includes(i.mode)) continue;
1619
+ this.db.update(i.instanceId, { mode, ...mode === "editing" ? {} : {} });
1620
+ changed.push(i.path);
1621
+ }
1622
+ return changed;
1623
+ }
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 }) {
1629
+ const ts = this.targets(p);
1630
+ const unmanaged = ts.filter((i) => i.mode === "unmanaged" && !i.detached);
1631
+ if (unmanaged.length === ts.length) throw new UserError("these files are unmanaged; use `tether track` first");
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 };
1669
+ }
1670
+ async unmanage({ path: p }) {
1671
+ for (const i of this.targets(p).filter((x) => x.realtime)) await this.releaseRealtime(i);
1672
+ return { changed: this.setMode(p, "unmanaged", ["normal", "editing"]) };
1673
+ }
1674
+ /**
1675
+ * Plans `tether done` (editing -> normal) or `tether track` (unmanaged -> normal).
1676
+ * Returns items needing a decision (options) or with an automatic action (auto).
1677
+ */
1678
+ async plan({ path: p, from }) {
1679
+ await this.locateAll();
1680
+ const ts = this.targets(p).filter((i) => i.mode === from || from === "unmanaged" && i.mode === "normal" && i.kind === "folder");
1681
+ if (!ts.length) throw new UserError(from === "editing" ? "nothing here is in editing mode" : "nothing here is unmanaged");
1682
+ const files2 = ts.filter((i) => i.kind === "file");
1683
+ const { results } = files2.length ? await this.api.call("POST", "/sync/check", { items: files2.map((i) => ({ artifactId: i.artifactId, version: i.baseVersion })) }) : { results: [] };
1684
+ const items = [];
1685
+ files2.forEach((inst, idx) => {
1686
+ const r = results[idx];
1687
+ const item = { instanceId: inst.instanceId, path: inst.path, relPath: inst.relPath, folder: inst.folderInstanceId ? this.folderOf(inst).path : null };
1688
+ if (r.status === "gone" || r.state?.deleted || r.folderDeleted) {
1689
+ items.push({ ...item, auto: "orphan", reason: r.reason ?? "deleted" });
1690
+ return;
1691
+ }
1692
+ const role = r.role;
1693
+ const canPush = role === "owner" || role === "editor";
1694
+ const incoming = r.status === "changed";
1695
+ item.serverVersion = r.version;
1696
+ item.incoming = incoming;
1697
+ item.role = role;
1698
+ if (inst.detached) {
1699
+ if (from === "unmanaged") items.push({ ...item, change: "missing", auto: "restore" });
1700
+ else if (canPush && r.canDelete) items.push({ ...item, change: "deleted", options: ["delete", "stop"] });
1701
+ else items.push({ ...item, change: "deleted", auto: "stop" });
1702
+ return;
1703
+ }
1704
+ const folder = this.folderOf(inst);
1705
+ const rel = folder ? this.localRel(inst, folder) : void 0;
1706
+ if (folder && rel === null) {
1707
+ if (canPush && r.canDelete) items.push({ ...item, change: "moved-out", options: ["delete", "stop"] });
1708
+ else if (canPush) items.push({ ...item, change: "moved-out", auto: "stop" });
1709
+ else items.push({ ...item, change: "moved-out", options: ["accept", "keep-both", "cancel"] });
1710
+ return;
1711
+ }
1712
+ const local = readLocal(inst.path, inst.eolMode);
1713
+ const contentChanged = !!local && local.hash !== inst.baseHash;
1714
+ const moved = folder ? rel !== inst.relPath : false;
1715
+ const changes = [contentChanged && "content", moved && `moved to ${rel}`].filter(Boolean);
1716
+ if (!changes.length) {
1717
+ items.push({ ...item, auto: incoming ? "accept" : "clean" });
1718
+ return;
1719
+ }
1720
+ item.change = changes.join(", ");
1721
+ if (local && r.state && local.hash === r.state.blob && (!folder || rel === r.state.relPath)) {
1722
+ items.push({ ...item, auto: "adopt", state: r.state });
1723
+ } else if (!canPush) {
1724
+ items.push({ ...item, options: ["accept", "keep-both", "diff", "cancel"] });
1725
+ } else if (!incoming && from === "editing") {
1726
+ items.push({ ...item, auto: "push" });
1727
+ } else {
1728
+ items.push({ ...item, conflict: incoming, options: ["push", "accept", "keep-both", "diff", "cancel"] });
1729
+ }
1730
+ });
1731
+ return { from, items };
1732
+ }
1733
+ /** Applies a plan with the user's choices: { instanceId: option }. */
1734
+ async resolve({ path: p, from, items, choices = {} }) {
1735
+ const out = [];
1736
+ for (const item of items) {
1737
+ const choice = item.auto ?? choices[item.instanceId];
1738
+ if (!choice) throw new UserError(`no choice for ${item.path}`);
1739
+ const inst = this.db.get(item.instanceId);
1740
+ if (!inst) continue;
1741
+ try {
1742
+ out.push({ path: item.path, action: choice, ...await this.applyChoice(inst, item, choice) });
1743
+ } catch (e) {
1744
+ if (e instanceof ApiError && e.status === 409) {
1745
+ out.push({ path: item.path, action: choice, error: "the server changed again; run the command again to resolve" });
1746
+ } else if (e instanceof ApiError || e instanceof LockedError || e instanceof UserError) {
1747
+ out.push({ path: item.path, action: choice, error: e.message });
1748
+ } else throw e;
1749
+ }
1750
+ }
1751
+ for (const f of this.targets(p).filter((i) => i.kind === "folder" && i.mode === from)) {
1752
+ if (!this.db.childrenOf(f.instanceId).some((c) => c.mode === from && !c.detached)) this.db.update(f.instanceId, { mode: "normal" });
1753
+ }
1754
+ for (const i of this.db.all().filter((x) => x.realtime && x.mode !== "editing")) await this.releaseRealtime(i);
1755
+ await this.tick();
1756
+ return { results: out };
1757
+ }
1758
+ async applyChoice(inst, item, choice) {
1759
+ const folder = this.folderOf(inst);
1760
+ switch (choice) {
1761
+ case "clean":
1762
+ this.db.update(inst.instanceId, { mode: "normal" });
1763
+ return {};
1764
+ case "orphan":
1765
+ this.orphan(inst, item.reason);
1766
+ return {};
1767
+ case "adopt":
1768
+ this.db.update(inst.instanceId, { mode: "normal", baseVersion: item.state.version, baseHash: item.state.blob, relPath: item.state.relPath ?? inst.relPath, size: null, mtimeMs: null });
1769
+ return {};
1770
+ case "restore":
1771
+ this.db.update(inst.instanceId, { mode: "normal", detached: false, needsRestore: true });
1772
+ return {};
1773
+ case "cancel":
1774
+ return { kept: inst.mode };
1775
+ case "accept": {
1776
+ if (folder && item.change === "moved-out") {
1777
+ const to = this.expectedPath(inst, folder);
1778
+ this.makeRoom(to, inst);
1779
+ moveFile(inst.path, to);
1780
+ this.db.update(inst.instanceId, { path: to, mode: "normal" });
1781
+ return {};
1782
+ }
1783
+ this.db.update(inst.instanceId, { mode: "normal", size: null, mtimeMs: null });
1784
+ return {};
1785
+ }
1786
+ case "keep-both": {
1787
+ if (folder && item.change === "moved-out") {
1788
+ this.db.update(inst.instanceId, { mode: "normal", path: this.expectedPath(inst, folder), fileId: null, volumeId: null, needsRestore: true });
1789
+ return { keptAs: inst.path };
1790
+ }
1791
+ const copy = keepBothName(inst.path);
1792
+ fs6.copyFileSync(inst.path, copy);
1793
+ this.db.update(inst.instanceId, { mode: "normal", size: null, mtimeMs: null });
1794
+ return { keptAs: copy };
1795
+ }
1796
+ case "push": {
1797
+ const local = readLocal(inst.path, inst.eolMode);
1798
+ const change = {};
1799
+ if (local && local.hash !== inst.baseHash) {
1800
+ await this.api.uploadBlobs([{ sha256: local.hash, bytes: local.canonical }]);
1801
+ this.cache.put(local.hash, local.canonical);
1802
+ Object.assign(change, { blob: local.hash, size: local.canonical.length });
1803
+ }
1804
+ if (folder) {
1805
+ const rel = this.localRel(inst, folder);
1806
+ if (rel && rel !== inst.relPath) change.relPath = rel;
1807
+ }
1808
+ const baseVersion = item.incoming ? item.serverVersion : inst.baseVersion;
1809
+ const r = await this.api.call("POST", `/artifacts/${inst.artifactId}/commit`, { baseVersion, change });
1810
+ const state = r.state;
1811
+ this.db.update(inst.instanceId, {
1812
+ mode: "normal",
1813
+ baseVersion: r.version,
1814
+ remoteVersion: r.version,
1815
+ baseHash: state?.blob ?? inst.baseHash,
1816
+ relPath: state?.relPath ?? inst.relPath,
1817
+ size: local?.size ?? null,
1818
+ mtimeMs: local?.mtimeMs ?? null,
1819
+ ...local ? styleOf(local) : {}
1820
+ });
1821
+ return { version: r.version };
1822
+ }
1823
+ case "delete": {
1824
+ await this.api.call("POST", `/artifacts/${inst.artifactId}/commit`, { baseVersion: item.serverVersion, change: { deleted: true } });
1825
+ this.db.remove(inst.instanceId);
1826
+ return { deletedForEveryone: true };
1827
+ }
1828
+ case "stop": {
1829
+ if (item.change === "moved-out") {
1830
+ this.db.update(inst.instanceId, { mode: "normal", path: this.expectedPath(inst, folder), fileId: null, volumeId: null, needsRestore: true });
1831
+ return { released: inst.path };
1832
+ }
1833
+ if (folder) this.db.update(inst.instanceId, { mode: "unmanaged", detached: true });
1834
+ else this.db.remove(inst.instanceId);
1835
+ return {};
1836
+ }
1837
+ default:
1838
+ throw new UserError(`unknown choice "${choice}"`);
1839
+ }
1840
+ }
1841
+ /** Unified diff between the server's current version and the local file. */
1842
+ async diff({ path: p, instanceId }) {
1843
+ const inst = instanceId ? this.db.get(instanceId) : this.targets(p).find((i) => i.kind === "file" && samePath(i.path, canonicalPath(path4.resolve(p))));
1844
+ if (!inst || inst.kind !== "file") throw new UserError("diff needs a managed file");
1845
+ const { artifact } = await this.api.call("GET", `/artifacts/${inst.artifactId}`);
1846
+ if (artifact.deleted) throw new UserError("the artifact has been deleted on the server");
1847
+ await this.ensureBlobs([{ artifactId: inst.artifactId, version: artifact.version, sha: artifact.blob }]);
1848
+ const server = this.cache.get(artifact.blob);
1849
+ const local = inst.detached ? null : readLocal(inst.path, inst.eolMode);
1850
+ const label = path4.basename(inst.path);
1851
+ if (isBinary(server) || local && isBinary(local.canonical)) {
1852
+ return { text: local?.hash === artifact.blob ? "" : `Binary files differ (server v${artifact.version} vs local)
1853
+ ` };
1854
+ }
1855
+ return {
1856
+ text: createTwoFilesPatch(`${label} (server v${artifact.version})`, `${label} (local)`, server.toString("utf8"), local ? local.canonical.toString("utf8") : "", "", "", { context: 3 }),
1857
+ serverVersion: artifact.version
1858
+ };
1859
+ }
1860
+ status({ path: p } = {}) {
1861
+ let list = this.db.all();
1862
+ if (p) list = this.targets(p).concat(list.filter((i) => i.mode === "orphaned" && isUnder(i.path, canonicalPath(path4.resolve(p)))));
1863
+ return list.map((i) => {
1864
+ const flags = [];
1865
+ if (i.remoteVersion && i.remoteVersion > i.baseVersion && i.kind === "file") flags.push(`incoming v${i.remoteVersion}`);
1866
+ if (i.detached) flags.push("deleted locally");
1867
+ if (i.needsRestore) flags.push("restoring");
1868
+ if (i.missingSince) flags.push("missing");
1869
+ if (i.lastError) flags.push(i.lastError);
1870
+ if (i.kind === "file" && ["editing", "unmanaged"].includes(i.mode) && !i.detached) {
1871
+ try {
1872
+ const local = readLocal(i.path, i.eolMode);
1873
+ if (local && local.hash !== i.baseHash) flags.push("modified");
1874
+ } catch {
1875
+ flags.push("locked");
1876
+ }
1877
+ }
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}`);
1882
+ return {
1883
+ instanceId: i.instanceId,
1884
+ artifactId: i.artifactId,
1885
+ kind: i.kind,
1886
+ path: i.path,
1887
+ relPath: i.relPath,
1888
+ mode: i.mode,
1889
+ role: i.role,
1890
+ version: i.baseVersion,
1891
+ folderInstanceId: i.folderInstanceId,
1892
+ flags
1893
+ };
1894
+ });
1895
+ }
1896
+ /** `tether locate`: re-link a lost instance by explicit path, or search folders by content. */
1897
+ async locate({ instanceId, dirs = [], file }) {
1898
+ const inst = this.db.get(instanceId) ?? this.db.all().find((i) => i.instanceId.startsWith(instanceId));
1899
+ if (!inst) throw new UserError(`no instance ${instanceId}`);
1900
+ if (file) {
1901
+ const abs = canonicalPath(path4.resolve(file));
1902
+ const st = statOrNull(abs);
1903
+ if (!st || inst.kind === "folder" !== st.isDirectory()) throw new UserError(`${abs} is not a ${inst.kind}`);
1904
+ this.db.update(inst.instanceId, { path: abs, ...this.identity(abs), mode: inst.mode === "lost" ? inst.prevMode || "normal" : inst.mode, prevMode: null, missingSince: null, detached: false });
1905
+ return { linked: abs };
1906
+ }
1907
+ if (inst.kind !== "file") throw new UserError("pass the folder with --file");
1908
+ const candidates = [];
1909
+ for (const dir of dirs) {
1910
+ if (!statOrNull(dir)?.isDirectory()) continue;
1911
+ for (const f of walkFiles(path4.resolve(dir)).files) {
1912
+ try {
1913
+ if (readLocal(f.abs, inst.eolMode)?.hash === inst.baseHash) candidates.push(f.abs);
1914
+ } catch {
1915
+ }
1916
+ if (candidates.length >= 20) break;
1917
+ }
1918
+ }
1919
+ return { candidates };
1920
+ }
1921
+ async resolveArtifact(ref) {
1922
+ if (/^a_/.test(ref)) return ref;
1923
+ const abs = canonicalPath(path4.resolve(ref));
1924
+ const inst = this.instanceAt(abs) ?? this.db.all().find((i) => samePath(i.path, abs));
1925
+ if (inst) return inst.artifactId;
1926
+ throw new UserError(`"${ref}" is not a managed path or an artifact ID`);
1927
+ }
1928
+ };
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) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", '"': "&quot;", "'": "&apos;" })[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
+
2007
+ // packages/client/src/daemon.js
2008
+ import { fileURLToPath } from "node:url";
2009
+ var REALTIME_TICK_SECONDS = 3;
2010
+ var UPDATE_CHECK_MS = 24 * 60 * 60 * 1e3;
2011
+ async function runDaemon() {
2012
+ const home = tetherHome();
2013
+ fs8.mkdirSync(home, { recursive: true });
2014
+ process.chdir(home);
2015
+ const f = files(home);
2016
+ const recent = [];
2017
+ const logStream = fs8.createWriteStream(f.log, { flags: "a" });
2018
+ const log = (msg) => {
2019
+ const line = `${(/* @__PURE__ */ new Date()).toISOString()} ${msg}`;
2020
+ recent.push(line);
2021
+ if (recent.length > 500) recent.shift();
2022
+ logStream.write(line + "\n");
2023
+ };
2024
+ const config = new Config(f.config);
2025
+ const db = new LocalDb(f.db);
2026
+ const credentials = new Credentials(f.credentials);
2027
+ const api = new Api({ config, credentials });
2028
+ const cache = new BlobCache(f.blobs);
2029
+ const tracker = await createTracker(config);
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
+ });
2042
+ let timer = null;
2043
+ let quickTimer = null;
2044
+ let realtimeTimer = null;
2045
+ let stopping = false;
2046
+ let latest = null;
2047
+ async function runTick(opts) {
2048
+ try {
2049
+ await engine.locked(() => engine.tick(opts));
2050
+ } catch (e) {
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.` });
2053
+ }
2054
+ refreshWatchers();
2055
+ scheduleGrace();
2056
+ schedule();
2057
+ }
2058
+ function schedule() {
2059
+ clearTimeout(timer);
2060
+ if (stopping) return;
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
+ }
2086
+ }
2087
+ function quickPass(delay) {
2088
+ if (quickTimer || stopping) return;
2089
+ quickTimer = setTimeout(async () => {
2090
+ quickTimer = null;
2091
+ try {
2092
+ await engine.locked(() => engine.locateAll());
2093
+ } catch (e) {
2094
+ log(`locate failed: ${e.message}`);
2095
+ }
2096
+ refreshWatchers();
2097
+ scheduleGrace();
2098
+ }, delay);
2099
+ }
2100
+ function scheduleGrace() {
2101
+ if (engine.graceDue) quickPass(Math.max(50, engine.graceDue - Date.now() + 50));
2102
+ }
2103
+ const watchers = /* @__PURE__ */ new Map();
2104
+ function refreshWatchers() {
2105
+ const want = /* @__PURE__ */ new Map();
2106
+ for (const i of db.all()) {
2107
+ if (i.mode === "orphaned" || i.detached) continue;
2108
+ if (i.kind === "folder") want.set(i.path, true);
2109
+ else if (!i.folderInstanceId) want.set(path5.dirname(i.path), false);
2110
+ }
2111
+ for (const [dir, w] of watchers) {
2112
+ if (!want.has(dir)) {
2113
+ w.close();
2114
+ watchers.delete(dir);
2115
+ }
2116
+ }
2117
+ for (const [dir, recursive] of want) {
2118
+ if (watchers.has(dir)) continue;
2119
+ try {
2120
+ const w = fs8.watch(dir, { recursive, persistent: false }, () => {
2121
+ quickPass(250);
2122
+ realtimePass();
2123
+ });
2124
+ w.on("error", () => {
2125
+ w.close();
2126
+ watchers.delete(dir);
2127
+ });
2128
+ watchers.set(dir, w);
2129
+ } catch {
2130
+ }
2131
+ }
2132
+ }
2133
+ const logins = /* @__PURE__ */ new Map();
2134
+ async function startLogin() {
2135
+ const state = crypto4.randomBytes(16).toString("hex");
2136
+ let settle;
2137
+ const done = new Promise((resolve, reject) => settle = { resolve, reject });
2138
+ const srv = http.createServer(async (req, res) => {
2139
+ const u = new URL(req.url, "http://127.0.0.1");
2140
+ if (u.pathname !== "/callback") return res.writeHead(404).end();
2141
+ if (u.searchParams.get("state") !== state) return res.writeHead(400).end("state mismatch");
2142
+ try {
2143
+ const user = await api.exchangeCliCode(u.searchParams.get("code"));
2144
+ res.writeHead(200, { "content-type": "text/html" }).end('<!doctype html><title>Tether</title><body style="font-family:sans-serif;padding:2rem"><h2>Tether CLI signed in</h2><p>You can close this tab.</p>');
2145
+ settle.resolve(user);
2146
+ } catch (e) {
2147
+ res.writeHead(400).end(e.message);
2148
+ settle.reject(e);
2149
+ } finally {
2150
+ setTimeout(() => srv.close(), 100);
2151
+ }
2152
+ });
2153
+ await new Promise((r) => srv.listen(0, "127.0.0.1", r));
2154
+ const port = srv.address().port;
2155
+ const timeout = setTimeout(() => {
2156
+ srv.close();
2157
+ settle.reject(new UserError("login timed out"));
2158
+ }, 5 * 60 * 1e3);
2159
+ done.finally(() => clearTimeout(timeout)).catch(() => {
2160
+ });
2161
+ logins.set(state, done);
2162
+ return { state, url: `${config.consoleUrl.replace(/\/$/, "")}/cli-login?port=${port}&state=${state}` };
2163
+ }
2164
+ const L = (fn) => (params) => engine.locked(() => fn(params));
2165
+ const soon = () => setTimeout(() => runTick(), 10);
2166
+ const handlers = {
2167
+ ping: () => ({ pid: process.pid, version: VERSION, home, tracker: tracker.name, server: config.get("server") }),
2168
+ async status(p) {
2169
+ const creds = await credentials.load();
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
+ };
2179
+ },
2180
+ sync: async () => {
2181
+ await runTick();
2182
+ return { offline: engine.offline };
2183
+ },
2184
+ async "login.password"({ email, password, signup }) {
2185
+ const user = await api.passwordLogin({ email, password, signup });
2186
+ soon();
2187
+ return user;
2188
+ },
2189
+ "login.start": () => startLogin(),
2190
+ async "login.wait"({ state }) {
2191
+ const p = logins.get(state);
2192
+ if (!p) throw new UserError("no login in progress");
2193
+ try {
2194
+ const user = await p;
2195
+ soon();
2196
+ return user;
2197
+ } finally {
2198
+ logins.delete(state);
2199
+ }
2200
+ },
2201
+ logout: async () => api.logout(),
2202
+ whoami: () => api.call("GET", "/me"),
2203
+ manage: L((p) => engine.manage(p)),
2204
+ add: L((p) => engine.add(p)),
2205
+ pull: L((p) => engine.pull(p)),
2206
+ edit: async (p) => {
2207
+ const r = await L((x) => engine.edit(x))(p);
2208
+ schedule();
2209
+ return r;
2210
+ },
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
+ },
2218
+ plan: L((p) => engine.plan(p)),
2219
+ resolve: L((p) => engine.resolve(p)),
2220
+ diff: L((p) => engine.diff(p)),
2221
+ locate: L((p) => engine.locate(p)),
2222
+ async history({ ref }) {
2223
+ return api.call("GET", `/artifacts/${await engine.resolveArtifact(ref)}/history`);
2224
+ },
2225
+ async rollback({ ref, toVersion }) {
2226
+ const r = await api.call("POST", `/artifacts/${await engine.resolveArtifact(ref)}/rollback`, { toVersion });
2227
+ soon();
2228
+ return r;
2229
+ },
2230
+ async delete({ ref }) {
2231
+ const r = await api.call("POST", `/artifacts/${await engine.resolveArtifact(ref)}/delete`);
2232
+ soon();
2233
+ return r;
2234
+ },
2235
+ async share({ ref, op }) {
2236
+ const id = await engine.resolveArtifact(ref);
2237
+ return op ? api.call("POST", `/artifacts/${id}/share`, op) : api.call("GET", `/artifacts/${id}/sharing`);
2238
+ },
2239
+ async info({ ref }) {
2240
+ return api.call("GET", `/artifacts/${await engine.resolveArtifact(ref)}`);
2241
+ },
2242
+ "repo.create": ({ name }) => api.call("POST", "/repos", { name }),
2243
+ "repo.list": () => api.call("GET", "/repos"),
2244
+ "repo.show": async ({ ref }) => api.call("GET", `/repos/${(await engine.findRepo(ref)).repoId}`),
2245
+ "repo.delete": async ({ ref }) => {
2246
+ const r = await api.call("DELETE", `/repos/${(await engine.findRepo(ref)).repoId}`);
2247
+ soon();
2248
+ return r;
2249
+ },
2250
+ "repo.transfer": async ({ ref, to }) => api.call("POST", `/repos/${(await engine.findRepo(ref)).repoId}/transfer`, { to }),
2251
+ "repo.eol": async ({ ref, glob, mode }) => {
2252
+ const repo = await engine.findRepo(ref);
2253
+ const rules = { ...repo.eolDefaults };
2254
+ if (mode === "unset") delete rules[glob];
2255
+ else rules[glob] = mode;
2256
+ return api.call("PATCH", `/repos/${repo.repoId}`, { eolDefaults: rules });
2257
+ },
2258
+ "config.get": ({ key }) => key ? { [key]: config.get(key) } : config.values,
2259
+ "config.set": ({ key, value }) => {
2260
+ config.set(key, value);
2261
+ if (key === "tickSeconds") schedule();
2262
+ return { [key]: config.get(key) };
2263
+ },
2264
+ log: ({ lines = 50 }) => recent.slice(-lines),
2265
+ shutdown: () => {
2266
+ setTimeout(() => shutdown(0), 50);
2267
+ return { stopping: true };
2268
+ }
2269
+ };
2270
+ async function handle(method, params) {
2271
+ const h = handlers[method];
2272
+ if (!h) throw new UserError(`unknown command ${method}`);
2273
+ try {
2274
+ return await h(params);
2275
+ } catch (e) {
2276
+ if (e instanceof ApiError || e instanceof UserError) throw e;
2277
+ if (e.name === "OfflineError") throw e;
2278
+ log(`${method} failed: ${e.stack || e.message}`);
2279
+ throw e;
2280
+ }
2281
+ }
2282
+ const server = await listen(ipcEndpoint(home), handle);
2283
+ fs8.writeFileSync(f.pid, String(process.pid));
2284
+ log(`tetherd ${VERSION} started (pid ${process.pid}, tracker ${tracker.name}, server ${config.get("server")})`);
2285
+ function shutdown(code) {
2286
+ if (stopping) return;
2287
+ stopping = true;
2288
+ clearTimeout(timer);
2289
+ clearTimeout(quickTimer);
2290
+ clearTimeout(realtimeTimer);
2291
+ clearInterval(updateTimer);
2292
+ for (const w of watchers.values()) w.close();
2293
+ server.close();
2294
+ engine.locked(() => db.close()).finally(() => {
2295
+ fs8.rmSync(f.pid, { force: true });
2296
+ log("tetherd stopped");
2297
+ logStream.end(() => process.exit(code));
2298
+ });
2299
+ }
2300
+ process.on("SIGINT", () => shutdown(0));
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);
2305
+ await runTick();
2306
+ }
2307
+ export {
2308
+ VERSION,
2309
+ runDaemon
2310
+ };