tether-sync 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -2
- package/bin/chunk-MGMU33LE.js +109 -0
- package/bin/daemon-WGNVBSEJ.js +1835 -0
- package/bin/tether.js +379 -0
- package/bin/tetherd.js +15 -0
- package/bin/win32-DF53YR5N.js +124 -0
- package/package.json +37 -4
|
@@ -0,0 +1,1835 @@
|
|
|
1
|
+
import {
|
|
2
|
+
files,
|
|
3
|
+
ipcEndpoint,
|
|
4
|
+
isWindows,
|
|
5
|
+
listen,
|
|
6
|
+
tetherHome
|
|
7
|
+
} from "./chunk-MGMU33LE.js";
|
|
8
|
+
|
|
9
|
+
// packages/client/src/daemon.js
|
|
10
|
+
import fs7 from "node:fs";
|
|
11
|
+
import path5 from "node:path";
|
|
12
|
+
import http from "node:http";
|
|
13
|
+
import crypto4 from "node:crypto";
|
|
14
|
+
|
|
15
|
+
// packages/client/src/config.js
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
var DEFAULTS = {
|
|
19
|
+
server: "https://tether-b10cf.web.app",
|
|
20
|
+
consoleUrl: "",
|
|
21
|
+
// defaults to the server URL
|
|
22
|
+
tickSeconds: 30,
|
|
23
|
+
graceMs: 2e3,
|
|
24
|
+
scanRoots: [os.homedir()]
|
|
25
|
+
};
|
|
26
|
+
var NUMERIC = /* @__PURE__ */ new Set(["tickSeconds", "graceMs"]);
|
|
27
|
+
var Config = class {
|
|
28
|
+
constructor(file) {
|
|
29
|
+
this.file = file;
|
|
30
|
+
this.values = { ...DEFAULTS };
|
|
31
|
+
try {
|
|
32
|
+
Object.assign(this.values, JSON.parse(fs.readFileSync(file, "utf8")));
|
|
33
|
+
} catch {
|
|
34
|
+
}
|
|
35
|
+
if (process.env.TETHER_SERVER) this.values.server = process.env.TETHER_SERVER;
|
|
36
|
+
}
|
|
37
|
+
get(key) {
|
|
38
|
+
return this.values[key];
|
|
39
|
+
}
|
|
40
|
+
set(key, value) {
|
|
41
|
+
if (!(key in DEFAULTS)) throw new Error(`unknown config key "${key}" (known: ${Object.keys(DEFAULTS).join(", ")})`);
|
|
42
|
+
if (NUMERIC.has(key)) {
|
|
43
|
+
value = Number(value);
|
|
44
|
+
if (!Number.isFinite(value) || value <= 0) throw new Error(`${key} must be a positive number`);
|
|
45
|
+
}
|
|
46
|
+
if (key === "scanRoots" && typeof value === "string") value = value.split(/[;,]/).map((s) => s.trim()).filter(Boolean);
|
|
47
|
+
this.values[key] = value;
|
|
48
|
+
fs.writeFileSync(this.file, JSON.stringify(this.values, null, 2));
|
|
49
|
+
}
|
|
50
|
+
get consoleUrl() {
|
|
51
|
+
return this.values.consoleUrl || this.values.server;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// packages/client/src/db.js
|
|
56
|
+
import { DatabaseSync } from "node:sqlite";
|
|
57
|
+
var COLUMNS = {
|
|
58
|
+
instanceId: "TEXT PRIMARY KEY",
|
|
59
|
+
artifactId: "TEXT NOT NULL",
|
|
60
|
+
kind: "TEXT NOT NULL DEFAULT 'file'",
|
|
61
|
+
folderInstanceId: "TEXT",
|
|
62
|
+
repoId: "TEXT",
|
|
63
|
+
name: "TEXT",
|
|
64
|
+
relPath: "TEXT",
|
|
65
|
+
// server layout path inside the folder, at baseVersion
|
|
66
|
+
path: "TEXT NOT NULL",
|
|
67
|
+
// last known absolute path
|
|
68
|
+
volumeId: "TEXT",
|
|
69
|
+
fileId: "TEXT",
|
|
70
|
+
mode: "TEXT NOT NULL DEFAULT 'normal'",
|
|
71
|
+
// normal | editing | unmanaged | lost | orphaned
|
|
72
|
+
prevMode: "TEXT",
|
|
73
|
+
// mode to return to after `tether locate`
|
|
74
|
+
baseVersion: "INTEGER NOT NULL DEFAULT 0",
|
|
75
|
+
baseHash: "TEXT",
|
|
76
|
+
// canonical sha256 at baseVersion (= server blob)
|
|
77
|
+
size: "INTEGER",
|
|
78
|
+
// on-disk size at the last sync, for the quick change check
|
|
79
|
+
mtimeMs: "REAL",
|
|
80
|
+
eolMode: "TEXT DEFAULT 'auto'",
|
|
81
|
+
eolStyle: "TEXT",
|
|
82
|
+
// line endings this copy uses: lf | crlf (null = platform default)
|
|
83
|
+
hasBom: "INTEGER DEFAULT 0",
|
|
84
|
+
exec: "INTEGER DEFAULT 0",
|
|
85
|
+
role: "TEXT",
|
|
86
|
+
canDelete: "INTEGER DEFAULT 0",
|
|
87
|
+
remoteVersion: "INTEGER",
|
|
88
|
+
// latest server version seen (pending incoming if > baseVersion)
|
|
89
|
+
missingSince: "REAL",
|
|
90
|
+
// start of the atomic-save grace window
|
|
91
|
+
detached: "INTEGER DEFAULT 0",
|
|
92
|
+
// file in a folder that the user deleted; restored by `tether track`
|
|
93
|
+
needsRestore: "INTEGER DEFAULT 0",
|
|
94
|
+
// write a fresh copy at the expected path on the next tick
|
|
95
|
+
orphanReason: "TEXT",
|
|
96
|
+
lastError: "TEXT",
|
|
97
|
+
createdAt: "REAL",
|
|
98
|
+
updatedAt: "REAL"
|
|
99
|
+
};
|
|
100
|
+
var BOOL = /* @__PURE__ */ new Set(["hasBom", "exec", "canDelete", "detached", "needsRestore"]);
|
|
101
|
+
var LocalDb = class {
|
|
102
|
+
constructor(file) {
|
|
103
|
+
this.db = new DatabaseSync(file);
|
|
104
|
+
this.db.exec("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;");
|
|
105
|
+
const cols = Object.entries(COLUMNS).map(([k, t]) => `"${k}" ${t}`).join(", ");
|
|
106
|
+
this.db.exec(`CREATE TABLE IF NOT EXISTS instances (${cols})`);
|
|
107
|
+
const have = new Set(this.db.prepare("PRAGMA table_info(instances)").all().map((c) => c.name));
|
|
108
|
+
for (const [k, t] of Object.entries(COLUMNS)) {
|
|
109
|
+
if (!have.has(k)) this.db.exec(`ALTER TABLE instances ADD COLUMN "${k}" ${t.replace("PRIMARY KEY", "").replace("NOT NULL", "")}`);
|
|
110
|
+
}
|
|
111
|
+
this.db.exec("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)");
|
|
112
|
+
this.db.exec("CREATE INDEX IF NOT EXISTS instances_folder ON instances(folderInstanceId)");
|
|
113
|
+
}
|
|
114
|
+
row(r) {
|
|
115
|
+
if (!r) return null;
|
|
116
|
+
const o = { ...r };
|
|
117
|
+
for (const k of BOOL) o[k] = !!o[k];
|
|
118
|
+
return o;
|
|
119
|
+
}
|
|
120
|
+
get(instanceId) {
|
|
121
|
+
return this.row(this.db.prepare("SELECT * FROM instances WHERE instanceId = ?").get(instanceId));
|
|
122
|
+
}
|
|
123
|
+
all() {
|
|
124
|
+
return this.db.prepare("SELECT * FROM instances ORDER BY createdAt").all().map((r) => this.row(r));
|
|
125
|
+
}
|
|
126
|
+
childrenOf(folderInstanceId) {
|
|
127
|
+
return this.db.prepare("SELECT * FROM instances WHERE folderInstanceId = ? ORDER BY relPath").all(folderInstanceId).map((r) => this.row(r));
|
|
128
|
+
}
|
|
129
|
+
insert(inst) {
|
|
130
|
+
const now = Date.now();
|
|
131
|
+
const o = { createdAt: now, updatedAt: now, ...inst };
|
|
132
|
+
const keys = Object.keys(o).filter((k) => k in COLUMNS);
|
|
133
|
+
this.db.prepare(`INSERT INTO instances (${keys.map((k) => `"${k}"`).join(",")}) VALUES (${keys.map(() => "?").join(",")})`).run(...keys.map((k) => this.val(o[k])));
|
|
134
|
+
return this.get(o.instanceId);
|
|
135
|
+
}
|
|
136
|
+
update(instanceId, patch) {
|
|
137
|
+
const keys = Object.keys(patch).filter((k) => k in COLUMNS && k !== "instanceId");
|
|
138
|
+
if (!keys.length) return this.get(instanceId);
|
|
139
|
+
keys.push("updatedAt");
|
|
140
|
+
patch = { ...patch, updatedAt: Date.now() };
|
|
141
|
+
this.db.prepare(`UPDATE instances SET ${keys.map((k) => `"${k}" = ?`).join(", ")} WHERE instanceId = ?`).run(...keys.map((k) => this.val(patch[k])), instanceId);
|
|
142
|
+
return this.get(instanceId);
|
|
143
|
+
}
|
|
144
|
+
remove(instanceId) {
|
|
145
|
+
this.db.prepare("DELETE FROM instances WHERE instanceId = ?").run(instanceId);
|
|
146
|
+
}
|
|
147
|
+
val(v) {
|
|
148
|
+
if (v === void 0) return null;
|
|
149
|
+
if (typeof v === "boolean") return v ? 1 : 0;
|
|
150
|
+
return v;
|
|
151
|
+
}
|
|
152
|
+
getMeta(key) {
|
|
153
|
+
const r = this.db.prepare("SELECT value FROM meta WHERE key = ?").get(key);
|
|
154
|
+
return r ? JSON.parse(r.value) : null;
|
|
155
|
+
}
|
|
156
|
+
setMeta(key, value) {
|
|
157
|
+
if (value === null) this.db.prepare("DELETE FROM meta WHERE key = ?").run(key);
|
|
158
|
+
else this.db.prepare("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, JSON.stringify(value));
|
|
159
|
+
}
|
|
160
|
+
close() {
|
|
161
|
+
this.db.close();
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// packages/client/src/credentials.js
|
|
166
|
+
import fs2 from "node:fs";
|
|
167
|
+
var Credentials = class {
|
|
168
|
+
constructor(file) {
|
|
169
|
+
this.file = file;
|
|
170
|
+
}
|
|
171
|
+
async load() {
|
|
172
|
+
try {
|
|
173
|
+
const raw = fs2.readFileSync(this.file);
|
|
174
|
+
const json = isWindows ? (await import("./win32-DF53YR5N.js")).unprotect(raw).toString() : raw.toString();
|
|
175
|
+
return JSON.parse(json);
|
|
176
|
+
} catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
async save(creds) {
|
|
181
|
+
const json = Buffer.from(JSON.stringify(creds));
|
|
182
|
+
const data = isWindows ? (await import("./win32-DF53YR5N.js")).protect(json) : json;
|
|
183
|
+
fs2.writeFileSync(this.file, data, { mode: 384 });
|
|
184
|
+
}
|
|
185
|
+
clear() {
|
|
186
|
+
fs2.rmSync(this.file, { force: true });
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// packages/client/src/api.js
|
|
191
|
+
import fs3 from "node:fs";
|
|
192
|
+
import path from "node:path";
|
|
193
|
+
import crypto3 from "node:crypto";
|
|
194
|
+
|
|
195
|
+
// packages/shared/src/content.js
|
|
196
|
+
import crypto from "node:crypto";
|
|
197
|
+
var BOM = Buffer.from([239, 187, 191]);
|
|
198
|
+
function isBinary(buf) {
|
|
199
|
+
const n = Math.min(buf.length, 8192);
|
|
200
|
+
for (let i = 0; i < n; i++) if (buf[i] === 0) return true;
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
function detectStyle(buf) {
|
|
204
|
+
const bom = buf.length >= 3 && buf[0] === 239 && buf[1] === 187 && buf[2] === 191;
|
|
205
|
+
let eol = null;
|
|
206
|
+
const i = buf.indexOf(10);
|
|
207
|
+
if (i >= 0) eol = i > 0 && buf[i - 1] === 13 ? "crlf" : "lf";
|
|
208
|
+
return { eol, bom };
|
|
209
|
+
}
|
|
210
|
+
function canonicalize(buf, eolMode = "auto") {
|
|
211
|
+
if (eolMode === "binary" || isBinary(buf)) return { bytes: buf, text: false };
|
|
212
|
+
let start = 0;
|
|
213
|
+
if (buf.length >= 3 && buf[0] === 239 && buf[1] === 187 && buf[2] === 191) start = 3;
|
|
214
|
+
const out = Buffer.allocUnsafe(buf.length - start);
|
|
215
|
+
let j = 0;
|
|
216
|
+
for (let i = start; i < buf.length; i++) {
|
|
217
|
+
if (buf[i] === 13 && buf[i + 1] === 10) continue;
|
|
218
|
+
out[j++] = buf[i];
|
|
219
|
+
}
|
|
220
|
+
return { bytes: out.subarray(0, j), text: true };
|
|
221
|
+
}
|
|
222
|
+
function sha256(buf) {
|
|
223
|
+
return crypto.createHash("sha256").update(buf).digest("hex");
|
|
224
|
+
}
|
|
225
|
+
function renderForLocal(canonical, eolMode, style, platformEol2) {
|
|
226
|
+
if (eolMode === "binary" || isBinary(canonical)) return canonical;
|
|
227
|
+
let eol;
|
|
228
|
+
if (eolMode === "lf" || eolMode === "crlf") eol = eolMode;
|
|
229
|
+
else eol = style?.eol ?? platformEol2;
|
|
230
|
+
let body = canonical;
|
|
231
|
+
if (eol === "crlf") {
|
|
232
|
+
let count = 0;
|
|
233
|
+
for (const b of canonical) if (b === 10) count++;
|
|
234
|
+
body = Buffer.allocUnsafe(canonical.length + count);
|
|
235
|
+
let j = 0;
|
|
236
|
+
for (const b of canonical) {
|
|
237
|
+
if (b === 10) body[j++] = 13;
|
|
238
|
+
body[j++] = b;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return style?.bom ? Buffer.concat([BOM, body]) : body;
|
|
242
|
+
}
|
|
243
|
+
function defaultEolFor(name) {
|
|
244
|
+
const lower = name.toLowerCase();
|
|
245
|
+
if (/\.(sh|bash|zsh)$/.test(lower)) return "lf";
|
|
246
|
+
if (/\.(bat|cmd)$/.test(lower)) return "crlf";
|
|
247
|
+
return "auto";
|
|
248
|
+
}
|
|
249
|
+
function globMatch(glob, relPath) {
|
|
250
|
+
let re = "";
|
|
251
|
+
for (let i = 0; i < glob.length; i++) {
|
|
252
|
+
const c = glob[i];
|
|
253
|
+
if (c === "*" && glob[i + 1] === "*") {
|
|
254
|
+
re += ".*";
|
|
255
|
+
i++;
|
|
256
|
+
} else if (c === "*") re += "[^/]*";
|
|
257
|
+
else if (c === "?") re += "[^/]";
|
|
258
|
+
else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
259
|
+
}
|
|
260
|
+
const target = glob.includes("/") ? relPath : relPath.split("/").pop();
|
|
261
|
+
return new RegExp("^" + re + "$", "i").test(target);
|
|
262
|
+
}
|
|
263
|
+
function resolveEol(relPath, repoRules = {}, explicit) {
|
|
264
|
+
if (explicit) return explicit;
|
|
265
|
+
for (const [glob, mode] of Object.entries(repoRules)) if (globMatch(glob, relPath)) return mode;
|
|
266
|
+
return defaultEolFor(relPath);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// packages/shared/src/names.js
|
|
270
|
+
var RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i;
|
|
271
|
+
var ILLEGAL = /[<>:"/\\|?*\u0000-\u001f]/;
|
|
272
|
+
var MAX_REL_PATH = 200;
|
|
273
|
+
function nameError(name) {
|
|
274
|
+
if (!name || name === "." || name === "..") return `invalid name "${name}"`;
|
|
275
|
+
if (ILLEGAL.test(name)) return `"${name}" contains a character not allowed on Windows`;
|
|
276
|
+
if (/[. ]$/.test(name)) return `"${name}" ends with a dot or space`;
|
|
277
|
+
if (RESERVED.test(name)) return `"${name}" is a reserved name on Windows`;
|
|
278
|
+
if (Buffer.byteLength(name) > 255) return `"${name}" is too long`;
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
function relPathError(relPath) {
|
|
282
|
+
if (typeof relPath !== "string" || !relPath) return "empty path";
|
|
283
|
+
if (relPath.length > MAX_REL_PATH) return `path "${relPath}" is longer than ${MAX_REL_PATH} characters`;
|
|
284
|
+
if (relPath.startsWith("/")) return `path "${relPath}" must be relative`;
|
|
285
|
+
for (const seg of relPath.split("/")) {
|
|
286
|
+
const e = nameError(seg);
|
|
287
|
+
if (e) return e;
|
|
288
|
+
}
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
function caseCollisionError(relPaths) {
|
|
292
|
+
const seen = /* @__PURE__ */ new Map();
|
|
293
|
+
for (const p of relPaths) {
|
|
294
|
+
const segs = p.split("/");
|
|
295
|
+
for (let i = 1; i <= segs.length; i++) {
|
|
296
|
+
const prefix = segs.slice(0, i).join("/");
|
|
297
|
+
const key = prefix.toLowerCase();
|
|
298
|
+
const prev = seen.get(key);
|
|
299
|
+
if (prev !== void 0 && prev !== prefix) return `"${prev}" and "${prefix}" differ only by case`;
|
|
300
|
+
seen.set(key, prefix);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const files2 = new Set(relPaths.map((p) => p.toLowerCase()));
|
|
304
|
+
for (const p of relPaths) {
|
|
305
|
+
const segs = p.toLowerCase().split("/");
|
|
306
|
+
for (let i = 1; i < segs.length; i++) {
|
|
307
|
+
const prefix = segs.slice(0, i).join("/");
|
|
308
|
+
if (files2.has(prefix)) return `"${prefix}" is both a file and a folder`;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// packages/shared/src/ids.js
|
|
315
|
+
import crypto2 from "node:crypto";
|
|
316
|
+
var ALPHABET = "abcdefghijkmnpqrstuvwxyz23456789";
|
|
317
|
+
function randomString(len) {
|
|
318
|
+
const bytes = crypto2.randomBytes(len);
|
|
319
|
+
let out = "";
|
|
320
|
+
for (let i = 0; i < len; i++) out += ALPHABET[bytes[i] % ALPHABET.length];
|
|
321
|
+
return out;
|
|
322
|
+
}
|
|
323
|
+
var newInstanceId = () => "i_" + randomString(10);
|
|
324
|
+
|
|
325
|
+
// packages/client/src/api.js
|
|
326
|
+
var ApiError = class extends Error {
|
|
327
|
+
constructor(status, body) {
|
|
328
|
+
super(body?.message ?? `HTTP ${status}`);
|
|
329
|
+
this.status = status;
|
|
330
|
+
this.code = body?.code;
|
|
331
|
+
this.body = body;
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
var OfflineError = class extends Error {
|
|
335
|
+
};
|
|
336
|
+
var FIREBASE_ERRORS = {
|
|
337
|
+
EMAIL_EXISTS: "an account with that email already exists",
|
|
338
|
+
INVALID_LOGIN_CREDENTIALS: "wrong email or password",
|
|
339
|
+
INVALID_PASSWORD: "wrong email or password",
|
|
340
|
+
EMAIL_NOT_FOUND: "wrong email or password",
|
|
341
|
+
USER_DISABLED: "this account has been disabled",
|
|
342
|
+
TOKEN_EXPIRED: "session expired, run `tether login`",
|
|
343
|
+
INVALID_REFRESH_TOKEN: "session expired, run `tether login`",
|
|
344
|
+
USER_NOT_FOUND: "account no longer exists, run `tether login`",
|
|
345
|
+
INVALID_EMAIL: "a valid email is required"
|
|
346
|
+
};
|
|
347
|
+
function firebaseMessage(code = "") {
|
|
348
|
+
const key = code.split(":")[0].split(" ")[0].trim();
|
|
349
|
+
if (key.startsWith("WEAK_PASSWORD")) return "password must be at least 6 characters";
|
|
350
|
+
if (key.startsWith("TOO_MANY_ATTEMPTS")) return "too many attempts; try again later";
|
|
351
|
+
return FIREBASE_ERRORS[key] ?? `sign-in failed (${code || "unknown error"})`;
|
|
352
|
+
}
|
|
353
|
+
var Api = class {
|
|
354
|
+
constructor({ config, credentials }) {
|
|
355
|
+
this.config = config;
|
|
356
|
+
this.credentials = credentials;
|
|
357
|
+
this.accessToken = null;
|
|
358
|
+
}
|
|
359
|
+
get base() {
|
|
360
|
+
return this.config.get("server").replace(/\/$/, "");
|
|
361
|
+
}
|
|
362
|
+
async raw(method, p, body, token) {
|
|
363
|
+
let res;
|
|
364
|
+
try {
|
|
365
|
+
res = await fetch(this.base + "/v1" + p, {
|
|
366
|
+
method,
|
|
367
|
+
headers: { "content-type": "application/json", ...token ? { authorization: `Bearer ${token}` } : {} },
|
|
368
|
+
body: body === void 0 ? void 0 : JSON.stringify(body),
|
|
369
|
+
signal: AbortSignal.timeout(3e4)
|
|
370
|
+
});
|
|
371
|
+
} catch (e) {
|
|
372
|
+
throw new OfflineError(`cannot reach the Tether server at ${this.base} (${e.cause?.code ?? e.message})`);
|
|
373
|
+
}
|
|
374
|
+
const json = await res.json().catch(() => ({}));
|
|
375
|
+
if (!res.ok) throw new ApiError(res.status, json.error);
|
|
376
|
+
return json;
|
|
377
|
+
}
|
|
378
|
+
/** The server's auth setup: { auth: 'local' } or { auth: 'firebase', apiKey, emulatorHost? }. */
|
|
379
|
+
async serverConfig() {
|
|
380
|
+
if (this.cfg?.base === this.base) return this.cfg.value;
|
|
381
|
+
const value = await this.raw("GET", "/config");
|
|
382
|
+
this.cfg = { base: this.base, value };
|
|
383
|
+
return value;
|
|
384
|
+
}
|
|
385
|
+
/** Google endpoints (or the Auth emulator) for Firebase sign-in. */
|
|
386
|
+
googleUrl(service, method, cfg) {
|
|
387
|
+
const root = cfg.emulatorHost ? `http://${cfg.emulatorHost}/${service}.googleapis.com/v1` : `https://${service}.googleapis.com/v1`;
|
|
388
|
+
return `${root}/${method}?key=${encodeURIComponent(cfg.apiKey)}`;
|
|
389
|
+
}
|
|
390
|
+
async google(service, method, body, form = false) {
|
|
391
|
+
const cfg = await this.serverConfig();
|
|
392
|
+
let res;
|
|
393
|
+
try {
|
|
394
|
+
res = await fetch(this.googleUrl(service, method, cfg), {
|
|
395
|
+
method: "POST",
|
|
396
|
+
headers: { "content-type": form ? "application/x-www-form-urlencoded" : "application/json" },
|
|
397
|
+
body: form ? new URLSearchParams(body).toString() : JSON.stringify(body),
|
|
398
|
+
signal: AbortSignal.timeout(3e4)
|
|
399
|
+
});
|
|
400
|
+
} catch (e) {
|
|
401
|
+
throw new OfflineError(`cannot reach Firebase Auth (${e.cause?.code ?? e.message})`);
|
|
402
|
+
}
|
|
403
|
+
const json = await res.json().catch(() => ({}));
|
|
404
|
+
if (!res.ok) throw new ApiError(res.status === 400 ? 401 : res.status, { code: "unauthorized", message: firebaseMessage(json.error?.message) });
|
|
405
|
+
return json;
|
|
406
|
+
}
|
|
407
|
+
async token() {
|
|
408
|
+
if (this.accessToken && (!this.accessExp || Date.now() < this.accessExp)) return this.accessToken;
|
|
409
|
+
const creds = await this.credentials.load();
|
|
410
|
+
if (!creds?.refreshToken) throw new ApiError(401, { code: "unauthorized", message: "not signed in, run `tether login`" });
|
|
411
|
+
if (creds.firebase) {
|
|
412
|
+
const r2 = await this.google("securetoken", "token", { grant_type: "refresh_token", refresh_token: creds.refreshToken }, true);
|
|
413
|
+
this.accessToken = r2.id_token;
|
|
414
|
+
this.accessExp = Date.now() + (Number(r2.expires_in) - 120) * 1e3;
|
|
415
|
+
return this.accessToken;
|
|
416
|
+
}
|
|
417
|
+
const r = await this.raw("POST", "/auth/refresh", { refreshToken: creds.refreshToken });
|
|
418
|
+
this.accessToken = r.accessToken;
|
|
419
|
+
this.accessExp = null;
|
|
420
|
+
return this.accessToken;
|
|
421
|
+
}
|
|
422
|
+
/** Email + password sign-in (or sign-up) against whichever auth system the server uses. */
|
|
423
|
+
async passwordLogin({ email, password, signup }) {
|
|
424
|
+
const cfg = await this.serverConfig();
|
|
425
|
+
if (cfg.auth !== "firebase") return this.signedIn(await this.raw("POST", signup ? "/auth/signup" : "/auth/login", { email, password }));
|
|
426
|
+
const r = await this.google("identitytoolkit", signup ? "accounts:signUp" : "accounts:signInWithPassword", { email, password, returnSecureToken: true });
|
|
427
|
+
return this.firebaseSignedIn(r.idToken, r.refreshToken, r.expiresIn);
|
|
428
|
+
}
|
|
429
|
+
/** Completes the browser login: the console's one-time code becomes a session. */
|
|
430
|
+
async exchangeCliCode(code) {
|
|
431
|
+
const r = await this.raw("POST", "/auth/cli-exchange", { code });
|
|
432
|
+
if (!r.customToken) return this.signedIn(r);
|
|
433
|
+
const t = await this.google("identitytoolkit", "accounts:signInWithCustomToken", { token: r.customToken, returnSecureToken: true });
|
|
434
|
+
return this.firebaseSignedIn(t.idToken, t.refreshToken, t.expiresIn);
|
|
435
|
+
}
|
|
436
|
+
async firebaseSignedIn(idToken, refreshToken, expiresIn) {
|
|
437
|
+
this.accessToken = idToken;
|
|
438
|
+
this.accessExp = Date.now() + (Number(expiresIn) - 120) * 1e3;
|
|
439
|
+
const user = await this.raw("GET", "/me", void 0, idToken);
|
|
440
|
+
await this.credentials.save({ firebase: true, refreshToken, user, server: this.base });
|
|
441
|
+
return user;
|
|
442
|
+
}
|
|
443
|
+
/** Authenticated call; refreshes the access token once on 401. */
|
|
444
|
+
async call(method, p, body) {
|
|
445
|
+
try {
|
|
446
|
+
return await this.raw(method, p, body, await this.token());
|
|
447
|
+
} catch (e) {
|
|
448
|
+
if (e instanceof ApiError && e.status === 401 && this.accessToken) {
|
|
449
|
+
this.accessToken = null;
|
|
450
|
+
return this.raw(method, p, body, await this.token());
|
|
451
|
+
}
|
|
452
|
+
throw e;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
async signedIn(tokens) {
|
|
456
|
+
await this.credentials.save({ refreshToken: tokens.refreshToken, user: tokens.user, server: this.base });
|
|
457
|
+
this.accessToken = tokens.accessToken;
|
|
458
|
+
this.accessExp = null;
|
|
459
|
+
return tokens.user;
|
|
460
|
+
}
|
|
461
|
+
async logout() {
|
|
462
|
+
const creds = await this.credentials.load();
|
|
463
|
+
if (creds?.refreshToken && !creds.firebase) await this.raw("POST", "/auth/logout", { refreshToken: creds.refreshToken }).catch(() => {
|
|
464
|
+
});
|
|
465
|
+
this.credentials.clear();
|
|
466
|
+
this.accessToken = null;
|
|
467
|
+
}
|
|
468
|
+
/** Uploads canonical blobs the server doesn't have yet. blobs: [{ sha256, bytes }] */
|
|
469
|
+
async uploadBlobs(blobs) {
|
|
470
|
+
const unique = [...new Map(blobs.map((b) => [b.sha256, b])).values()];
|
|
471
|
+
if (!unique.length) return;
|
|
472
|
+
const { upload } = await this.call("POST", "/blobs/upload", { blobs: unique.map((b) => ({ sha256: b.sha256, size: b.bytes.length })) });
|
|
473
|
+
const bySha = new Map(unique.map((b) => [b.sha256, b]));
|
|
474
|
+
for (const u of upload) {
|
|
475
|
+
const res = await fetch(u.url, { method: "PUT", headers: u.headers ?? {}, body: bySha.get(u.sha256).bytes });
|
|
476
|
+
if (!res.ok && res.status !== 412) throw new ApiError(res.status, { message: `upload failed (${res.status}): ${(await res.text().catch(() => "")).slice(0, 200)}` });
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
/** Downloads blobs for [{ artifactId, version? }] into the cache; skips blobs already cached. */
|
|
480
|
+
async fetchBlobs(cache, items) {
|
|
481
|
+
if (!items.length) return [];
|
|
482
|
+
const { downloads } = await this.call("POST", "/blobs/download", { items });
|
|
483
|
+
for (const d of downloads) {
|
|
484
|
+
if (cache.has(d.sha256)) continue;
|
|
485
|
+
const res = await fetch(d.url);
|
|
486
|
+
if (!res.ok) throw new ApiError(res.status, { message: `download failed (${res.status})` });
|
|
487
|
+
const bytes = Buffer.from(await res.arrayBuffer());
|
|
488
|
+
if (sha256(bytes) !== d.sha256) throw new Error("downloaded content does not match its hash");
|
|
489
|
+
cache.put(d.sha256, bytes);
|
|
490
|
+
}
|
|
491
|
+
return downloads;
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
var BlobCache = class {
|
|
495
|
+
constructor(dir) {
|
|
496
|
+
this.dir = dir;
|
|
497
|
+
fs3.mkdirSync(dir, { recursive: true });
|
|
498
|
+
}
|
|
499
|
+
file(sha) {
|
|
500
|
+
return path.join(this.dir, sha.slice(0, 2), sha);
|
|
501
|
+
}
|
|
502
|
+
has(sha) {
|
|
503
|
+
return !!sha && fs3.existsSync(this.file(sha));
|
|
504
|
+
}
|
|
505
|
+
get(sha) {
|
|
506
|
+
return fs3.readFileSync(this.file(sha));
|
|
507
|
+
}
|
|
508
|
+
put(sha, bytes) {
|
|
509
|
+
const f = this.file(sha);
|
|
510
|
+
if (fs3.existsSync(f)) return;
|
|
511
|
+
fs3.mkdirSync(path.dirname(f), { recursive: true });
|
|
512
|
+
const tmp = f + "." + crypto3.randomBytes(4).toString("hex");
|
|
513
|
+
fs3.writeFileSync(tmp, bytes);
|
|
514
|
+
fs3.renameSync(tmp, f);
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
// packages/client/src/platform/tracker.js
|
|
519
|
+
import fs4 from "node:fs";
|
|
520
|
+
import path2 from "node:path";
|
|
521
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", ".cache", ".npm", ".cargo", ".rustup", "proc", "sys"]);
|
|
522
|
+
var MAX_SCAN_ENTRIES = 3e5;
|
|
523
|
+
var WindowsTracker = class {
|
|
524
|
+
constructor(win32) {
|
|
525
|
+
this.w = win32;
|
|
526
|
+
this.name = "windows-fileid";
|
|
527
|
+
}
|
|
528
|
+
identify(p) {
|
|
529
|
+
return this.w.fileIdentity(p);
|
|
530
|
+
}
|
|
531
|
+
isTrash(p) {
|
|
532
|
+
return this.w.isTrashPath(p);
|
|
533
|
+
}
|
|
534
|
+
async findAll(instances) {
|
|
535
|
+
const out = /* @__PURE__ */ new Map();
|
|
536
|
+
for (const inst of instances) {
|
|
537
|
+
if (!inst.fileId) {
|
|
538
|
+
out.set(inst.instanceId, { status: "gone" });
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
const here = this.identify(inst.path);
|
|
542
|
+
if (here && here.fileId === inst.fileId && here.volumeId === inst.volumeId) {
|
|
543
|
+
let real = inst.path;
|
|
544
|
+
try {
|
|
545
|
+
real = fs4.realpathSync.native(inst.path);
|
|
546
|
+
} catch {
|
|
547
|
+
}
|
|
548
|
+
out.set(inst.instanceId, { status: "found", path: real });
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
const root = path2.parse(inst.path).root;
|
|
552
|
+
if (!fs4.existsSync(root)) {
|
|
553
|
+
out.set(inst.instanceId, { status: "unavailable" });
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
const p = this.w.pathById(inst.volumeId, inst.fileId, inst.path);
|
|
557
|
+
if (p === null && !this.w.fileIdentity(root)) out.set(inst.instanceId, { status: "unavailable" });
|
|
558
|
+
else if (p === null || this.isTrash(p)) out.set(inst.instanceId, { status: "gone" });
|
|
559
|
+
else out.set(inst.instanceId, { status: "found", path: p });
|
|
560
|
+
}
|
|
561
|
+
return out;
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
var ScanTracker = class {
|
|
565
|
+
constructor({ scanRoots }) {
|
|
566
|
+
this.scanRoots = scanRoots;
|
|
567
|
+
this.name = "scan";
|
|
568
|
+
}
|
|
569
|
+
identify(p) {
|
|
570
|
+
try {
|
|
571
|
+
const s = fs4.statSync(p, { bigint: true });
|
|
572
|
+
return { volumeId: s.dev.toString(16), fileId: s.ino.toString(16) };
|
|
573
|
+
} catch {
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
isTrash(p) {
|
|
578
|
+
return /[\\/](\.local[\\/]share[\\/]Trash|\.Trash(-\d+)?|\$Recycle\.Bin)[\\/]/i.test(p);
|
|
579
|
+
}
|
|
580
|
+
async findAll(instances, hintDirs = []) {
|
|
581
|
+
const out = /* @__PURE__ */ new Map();
|
|
582
|
+
const want = /* @__PURE__ */ new Map();
|
|
583
|
+
for (const inst of instances) {
|
|
584
|
+
const here = this.identify(inst.path);
|
|
585
|
+
if (here && here.fileId === inst.fileId && here.volumeId === inst.volumeId) out.set(inst.instanceId, { status: "found", path: inst.path });
|
|
586
|
+
else if (inst.fileId) want.set(`${inst.volumeId}:${inst.fileId}`, inst);
|
|
587
|
+
else out.set(inst.instanceId, { status: "gone" });
|
|
588
|
+
}
|
|
589
|
+
if (want.size) {
|
|
590
|
+
const dirs = [.../* @__PURE__ */ new Set([...hintDirs, ...[...want.values()].map((i) => path2.dirname(i.path))])];
|
|
591
|
+
this.scan(dirs, want, out, 1);
|
|
592
|
+
if (want.size) this.scan(this.scanRoots, want, out, Infinity);
|
|
593
|
+
}
|
|
594
|
+
for (const inst of want.values()) out.set(inst.instanceId, { status: "gone" });
|
|
595
|
+
return out;
|
|
596
|
+
}
|
|
597
|
+
scan(roots, want, out, maxDepth) {
|
|
598
|
+
let budget = MAX_SCAN_ENTRIES;
|
|
599
|
+
const queue = roots.filter((r) => fs4.existsSync(r)).map((r) => [r, 0]);
|
|
600
|
+
const seen = /* @__PURE__ */ new Set();
|
|
601
|
+
while (queue.length && want.size && budget > 0) {
|
|
602
|
+
const [dir, depth] = queue.shift();
|
|
603
|
+
if (seen.has(dir) || this.isTrash(dir + path2.sep)) continue;
|
|
604
|
+
seen.add(dir);
|
|
605
|
+
let entries;
|
|
606
|
+
try {
|
|
607
|
+
entries = fs4.readdirSync(dir, { withFileTypes: true });
|
|
608
|
+
} catch {
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
for (const e of entries) {
|
|
612
|
+
if (--budget <= 0) break;
|
|
613
|
+
const p = path2.join(dir, e.name);
|
|
614
|
+
if (e.isSymbolicLink()) continue;
|
|
615
|
+
const id = this.identify(p);
|
|
616
|
+
const key = id && `${id.volumeId}:${id.fileId}`;
|
|
617
|
+
if (key && want.has(key)) {
|
|
618
|
+
out.set(want.get(key).instanceId, { status: "found", path: p });
|
|
619
|
+
want.delete(key);
|
|
620
|
+
}
|
|
621
|
+
if (e.isDirectory() && depth + 1 < maxDepth && !SKIP_DIRS.has(e.name)) queue.push([p, depth + 1]);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
async function createTracker(config) {
|
|
627
|
+
const choice = process.env.TETHER_TRACKER || (isWindows ? "windows" : "scan");
|
|
628
|
+
if (choice === "windows") return new WindowsTracker(await import("./win32-DF53YR5N.js"));
|
|
629
|
+
return new ScanTracker({ scanRoots: config.get("scanRoots") });
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// packages/client/src/engine.js
|
|
633
|
+
import fs6 from "node:fs";
|
|
634
|
+
import path4 from "node:path";
|
|
635
|
+
import { createTwoFilesPatch } from "diff";
|
|
636
|
+
|
|
637
|
+
// packages/client/src/fsops.js
|
|
638
|
+
import fs5 from "node:fs";
|
|
639
|
+
import path3 from "node:path";
|
|
640
|
+
var platformEol = isWindows ? "crlf" : "lf";
|
|
641
|
+
var LockedError = class extends Error {
|
|
642
|
+
};
|
|
643
|
+
var samePath = (a, b) => isWindows ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
644
|
+
function isUnder(child, dir) {
|
|
645
|
+
const rel = path3.relative(dir, child);
|
|
646
|
+
return rel === "" || !!rel && !rel.startsWith("..") && !path3.isAbsolute(rel);
|
|
647
|
+
}
|
|
648
|
+
function canonicalPath(p) {
|
|
649
|
+
try {
|
|
650
|
+
return fs5.realpathSync.native(p);
|
|
651
|
+
} catch {
|
|
652
|
+
return path3.resolve(p);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
function statOrNull(p) {
|
|
656
|
+
try {
|
|
657
|
+
return fs5.statSync(p);
|
|
658
|
+
} catch {
|
|
659
|
+
return null;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
function readLocal(p, eolMode) {
|
|
663
|
+
let bytes, st;
|
|
664
|
+
try {
|
|
665
|
+
bytes = fs5.readFileSync(p);
|
|
666
|
+
st = fs5.statSync(p);
|
|
667
|
+
} catch (e) {
|
|
668
|
+
if (e.code === "ENOENT" || e.code === "EISDIR") return null;
|
|
669
|
+
if (["EBUSY", "EPERM", "EACCES"].includes(e.code)) throw new LockedError(`${p} is locked by another program`);
|
|
670
|
+
throw e;
|
|
671
|
+
}
|
|
672
|
+
const canonical = canonicalize(bytes, eolMode).bytes;
|
|
673
|
+
return { bytes, canonical, hash: sha256(canonical), style: detectStyle(bytes), size: st.size, mtimeMs: st.mtimeMs };
|
|
674
|
+
}
|
|
675
|
+
function writeLocal(p, canonical, eolMode, { exec = false, style = null } = {}) {
|
|
676
|
+
let existing = null;
|
|
677
|
+
try {
|
|
678
|
+
existing = fs5.readFileSync(p);
|
|
679
|
+
} catch (e) {
|
|
680
|
+
if (e.code !== "ENOENT") throw toLocked(e, p);
|
|
681
|
+
}
|
|
682
|
+
const useStyle = style ?? (existing ? detectStyle(existing) : null);
|
|
683
|
+
const bytes = renderForLocal(canonical, eolMode, useStyle, platformEol);
|
|
684
|
+
try {
|
|
685
|
+
if (existing) {
|
|
686
|
+
const fd = fs5.openSync(p, "r+");
|
|
687
|
+
try {
|
|
688
|
+
fs5.ftruncateSync(fd, 0);
|
|
689
|
+
fs5.writeSync(fd, bytes, 0, bytes.length, 0);
|
|
690
|
+
} finally {
|
|
691
|
+
fs5.closeSync(fd);
|
|
692
|
+
}
|
|
693
|
+
} else {
|
|
694
|
+
fs5.mkdirSync(path3.dirname(p), { recursive: true });
|
|
695
|
+
fs5.writeFileSync(p, bytes, { flag: "wx" });
|
|
696
|
+
}
|
|
697
|
+
if (!isWindows) fs5.chmodSync(p, exec ? 493 : fs5.statSync(p).mode & ~73 & 511);
|
|
698
|
+
} catch (e) {
|
|
699
|
+
throw toLocked(e, p);
|
|
700
|
+
}
|
|
701
|
+
const st = fs5.statSync(p);
|
|
702
|
+
const written = detectStyle(bytes);
|
|
703
|
+
return { size: st.size, mtimeMs: st.mtimeMs, eolStyle: written.eol ?? useStyle?.eol ?? null, hasBom: written.bom };
|
|
704
|
+
}
|
|
705
|
+
var styleOf = (local) => ({ eolStyle: local.style.eol, hasBom: local.style.bom });
|
|
706
|
+
function toLocked(e, p) {
|
|
707
|
+
if (["EBUSY", "EPERM", "EACCES"].includes(e.code)) return new LockedError(`${p} is locked or read-only; will retry`);
|
|
708
|
+
return e;
|
|
709
|
+
}
|
|
710
|
+
function keepBothName(p) {
|
|
711
|
+
const dir = path3.dirname(p);
|
|
712
|
+
const ext = path3.extname(p);
|
|
713
|
+
const stem = path3.basename(p, ext);
|
|
714
|
+
for (let n = 1; ; n++) {
|
|
715
|
+
const candidate = path3.join(dir, `${stem} (local copy${n > 1 ? " " + n : ""})${ext}`);
|
|
716
|
+
if (!fs5.existsSync(candidate)) return candidate;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
function moveFile(from, to) {
|
|
720
|
+
fs5.mkdirSync(path3.dirname(to), { recursive: true });
|
|
721
|
+
fs5.renameSync(from, to);
|
|
722
|
+
}
|
|
723
|
+
function pruneEmptyDirs(dir, stopAt) {
|
|
724
|
+
while (isUnder(dir, stopAt) && !samePath(dir, stopAt)) {
|
|
725
|
+
try {
|
|
726
|
+
fs5.rmdirSync(dir);
|
|
727
|
+
} catch {
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
dir = path3.dirname(dir);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
function walkFiles(root) {
|
|
734
|
+
const files2 = [];
|
|
735
|
+
const symlinks = [];
|
|
736
|
+
const stack = [root];
|
|
737
|
+
while (stack.length) {
|
|
738
|
+
const dir = stack.pop();
|
|
739
|
+
for (const e of fs5.readdirSync(dir, { withFileTypes: true })) {
|
|
740
|
+
const abs = path3.join(dir, e.name);
|
|
741
|
+
const rel = path3.relative(root, abs).split(path3.sep).join("/");
|
|
742
|
+
if (e.isSymbolicLink()) symlinks.push(rel);
|
|
743
|
+
else if (e.isDirectory()) stack.push(abs);
|
|
744
|
+
else if (e.isFile()) files2.push({ abs, rel });
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return { files: files2.sort((a, b) => a.rel.localeCompare(b.rel)), symlinks };
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// packages/client/src/engine.js
|
|
751
|
+
var UserError = class extends Error {
|
|
752
|
+
};
|
|
753
|
+
var ACTIVE = (i) => i.mode !== "orphaned";
|
|
754
|
+
var Engine = class {
|
|
755
|
+
constructor({ db, api, cache, tracker, config, log = () => {
|
|
756
|
+
} }) {
|
|
757
|
+
this.db = db;
|
|
758
|
+
this.api = api;
|
|
759
|
+
this.cache = cache;
|
|
760
|
+
this.tracker = tracker;
|
|
761
|
+
this.config = config;
|
|
762
|
+
this.log = log;
|
|
763
|
+
this.offline = null;
|
|
764
|
+
this.lastTick = null;
|
|
765
|
+
this.graceDue = null;
|
|
766
|
+
this.queue = Promise.resolve();
|
|
767
|
+
}
|
|
768
|
+
/** Serializes everything that touches instances (ticks, watcher passes, commands). */
|
|
769
|
+
locked(fn) {
|
|
770
|
+
const run = this.queue.then(fn, fn);
|
|
771
|
+
this.queue = run.catch(() => {
|
|
772
|
+
});
|
|
773
|
+
return run;
|
|
774
|
+
}
|
|
775
|
+
// ---------- helpers ----------
|
|
776
|
+
folderOf(inst) {
|
|
777
|
+
return inst.folderInstanceId ? this.db.get(inst.folderInstanceId) : null;
|
|
778
|
+
}
|
|
779
|
+
expectedPath(child, folder = this.folderOf(child)) {
|
|
780
|
+
return path4.join(folder.path, ...child.relPath.split("/"));
|
|
781
|
+
}
|
|
782
|
+
/** Current relative path of a child inside its folder, or null if it was moved out. */
|
|
783
|
+
localRel(child, folder = this.folderOf(child)) {
|
|
784
|
+
if (!isUnder(child.path, folder.path) || samePath(child.path, folder.path)) return null;
|
|
785
|
+
return path4.relative(folder.path, child.path).split(path4.sep).join("/");
|
|
786
|
+
}
|
|
787
|
+
identity(p) {
|
|
788
|
+
const id = this.tracker.identify(p);
|
|
789
|
+
if (!id) throw new Error(`cannot read the file ID of ${p}`);
|
|
790
|
+
return id;
|
|
791
|
+
}
|
|
792
|
+
blobFor(sha) {
|
|
793
|
+
if (!this.cache.has(sha)) throw new Error(`content ${sha.slice(0, 12)} is not in the local cache`);
|
|
794
|
+
return this.cache.get(sha);
|
|
795
|
+
}
|
|
796
|
+
async ensureBlobs(pairs) {
|
|
797
|
+
const missing = pairs.filter((p) => p.sha && !this.cache.has(p.sha));
|
|
798
|
+
if (missing.length) await this.api.fetchBlobs(this.cache, missing.map(({ artifactId, version }) => ({ artifactId, version })));
|
|
799
|
+
}
|
|
800
|
+
/** Writes content for an instance at `p`; records the result for the quick change check. */
|
|
801
|
+
writeInstance(inst, p, sha, eolMode = inst.eolMode, exec = inst.exec) {
|
|
802
|
+
const style = inst.eolStyle ? { eol: inst.eolStyle, bom: !!inst.hasBom } : null;
|
|
803
|
+
return writeLocal(p, this.blobFor(sha), eolMode, { exec, style });
|
|
804
|
+
}
|
|
805
|
+
// ---------- tick ----------
|
|
806
|
+
async tick({ remote = true } = {}) {
|
|
807
|
+
await this.locateAll();
|
|
808
|
+
if (remote) await this.checkRemote();
|
|
809
|
+
await this.enforceLocal();
|
|
810
|
+
this.lastTick = Date.now();
|
|
811
|
+
}
|
|
812
|
+
/** Refresh path and file ID of every tracked instance (DESIGN §5). */
|
|
813
|
+
async locateAll() {
|
|
814
|
+
const graceMs = this.config.get("graceMs");
|
|
815
|
+
const all = this.db.all().filter((i) => ACTIVE(i) && !i.detached && !i.needsRestore && i.fileId);
|
|
816
|
+
all.sort((a, b) => (a.kind === "folder" ? -1 : 0) - (b.kind === "folder" ? -1 : 0));
|
|
817
|
+
const hints = [...new Set(all.map((i) => path4.dirname(i.path)))];
|
|
818
|
+
const found = await this.tracker.findAll(all, hints);
|
|
819
|
+
const now = Date.now();
|
|
820
|
+
this.graceDue = null;
|
|
821
|
+
for (const inst of all) {
|
|
822
|
+
const cur = this.db.get(inst.instanceId);
|
|
823
|
+
if (!cur || cur.mode === "orphaned" || cur.detached) continue;
|
|
824
|
+
const r = found.get(inst.instanceId);
|
|
825
|
+
if (r.status === "found") {
|
|
826
|
+
if (cur.mode === "lost") {
|
|
827
|
+
this.db.update(cur.instanceId, { mode: cur.prevMode || "normal", prevMode: null, missingSince: null, path: r.path });
|
|
828
|
+
this.log(`${r.path}: found again`);
|
|
829
|
+
continue;
|
|
830
|
+
}
|
|
831
|
+
if (!samePath(r.path, cur.path) || r.path !== cur.path) {
|
|
832
|
+
const idAtOld = cur.kind === "file" && path4.dirname(r.path) === path4.dirname(cur.path) && !samePath(r.path, cur.path) ? this.tracker.identify(cur.path) : null;
|
|
833
|
+
if (idAtOld && idAtOld.fileId !== cur.fileId && statOrNull(cur.path)?.isFile()) {
|
|
834
|
+
this.db.update(cur.instanceId, { ...idAtOld, missingSince: null });
|
|
835
|
+
this.log(`${cur.path}: re-linked after an atomic save`);
|
|
836
|
+
} else {
|
|
837
|
+
this.db.update(cur.instanceId, { path: r.path, missingSince: null });
|
|
838
|
+
if (!samePath(r.path, cur.path)) this.log(`${cur.path} -> ${r.path}: followed move`);
|
|
839
|
+
}
|
|
840
|
+
} else if (cur.missingSince) this.db.update(cur.instanceId, { missingSince: null });
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
if (r.status === "unavailable") {
|
|
844
|
+
if (cur.mode !== "lost") {
|
|
845
|
+
this.db.update(cur.instanceId, { mode: "lost", prevMode: cur.mode });
|
|
846
|
+
this.log(`${cur.path}: volume unavailable, instance is lost`);
|
|
847
|
+
}
|
|
848
|
+
continue;
|
|
849
|
+
}
|
|
850
|
+
const st = statOrNull(cur.path);
|
|
851
|
+
if (st && (cur.kind === "folder" ? st.isDirectory() : st.isFile())) {
|
|
852
|
+
this.db.update(cur.instanceId, { ...this.identity(cur.path), missingSince: null });
|
|
853
|
+
this.log(`${cur.path}: re-linked after an atomic save`);
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
856
|
+
if (!cur.missingSince) {
|
|
857
|
+
this.db.update(cur.instanceId, { missingSince: now });
|
|
858
|
+
this.graceDue = Math.min(this.graceDue ?? Infinity, now + graceMs);
|
|
859
|
+
} else if (now - cur.missingSince >= graceMs) {
|
|
860
|
+
this.disappeared(cur);
|
|
861
|
+
} else {
|
|
862
|
+
this.graceDue = Math.min(this.graceDue ?? Infinity, cur.missingSince + graceMs);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
/** The file is gone for good (after the grace window). DESIGN §7. */
|
|
867
|
+
disappeared(inst) {
|
|
868
|
+
if (inst.kind === "folder") {
|
|
869
|
+
for (const c of this.db.childrenOf(inst.instanceId)) this.db.remove(c.instanceId);
|
|
870
|
+
this.db.remove(inst.instanceId);
|
|
871
|
+
this.log(`${inst.path}: folder deleted locally; no longer managed here`);
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
if (inst.mode === "editing") {
|
|
875
|
+
this.db.update(inst.instanceId, { detached: true, missingSince: null });
|
|
876
|
+
this.log(`${inst.path}: deleted while editing; resolve with \`tether done\``);
|
|
877
|
+
} else if (inst.folderInstanceId) {
|
|
878
|
+
this.db.update(inst.instanceId, { detached: true, mode: "unmanaged", missingSince: null });
|
|
879
|
+
this.log(`${inst.path}: deleted locally; stopped managing it (\`tether track\` restores it)`);
|
|
880
|
+
} else {
|
|
881
|
+
this.db.remove(inst.instanceId);
|
|
882
|
+
this.log(`${inst.path}: deleted locally; forgotten (pull again to get it back)`);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
orphan(inst, reason) {
|
|
886
|
+
const targets = inst.kind === "folder" ? [inst, ...this.db.childrenOf(inst.instanceId)] : [inst];
|
|
887
|
+
for (const t of targets) this.db.update(t.instanceId, { mode: "orphaned", orphanReason: reason });
|
|
888
|
+
this.log(`${inst.path}: ${reason === "revoked" ? "access revoked" : reason === "deleted" ? "deleted on the server" : "removed from the server"}; file kept, no longer tracked`);
|
|
889
|
+
}
|
|
890
|
+
/** One batched call for all instances (DESIGN §6.1 step 2). */
|
|
891
|
+
async checkRemote() {
|
|
892
|
+
const insts = this.db.all().filter(ACTIVE);
|
|
893
|
+
if (!insts.length) {
|
|
894
|
+
this.offline = null;
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
let results;
|
|
898
|
+
try {
|
|
899
|
+
({ results } = await this.api.call("POST", "/sync/check", { items: insts.map((i) => ({ artifactId: i.artifactId, version: i.baseVersion })) }));
|
|
900
|
+
this.offline = null;
|
|
901
|
+
} catch (e) {
|
|
902
|
+
if (e instanceof OfflineError || e instanceof ApiError && e.status === 401) {
|
|
903
|
+
this.offline = e.message;
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
throw e;
|
|
907
|
+
}
|
|
908
|
+
const incoming = [];
|
|
909
|
+
const folders = [];
|
|
910
|
+
insts.forEach((inst, idx) => {
|
|
911
|
+
const r = results[idx];
|
|
912
|
+
const cur = this.db.get(inst.instanceId);
|
|
913
|
+
if (!cur || cur.mode === "orphaned") return;
|
|
914
|
+
if (r.status === "gone") return this.orphan(cur, r.reason);
|
|
915
|
+
if (r.folderDeleted || r.state?.deleted) return this.orphan(cur, "deleted");
|
|
916
|
+
const patch = { role: r.role, canDelete: r.canDelete, remoteVersion: r.version };
|
|
917
|
+
if (cur.role && cur.role !== "viewer" && r.role === "viewer" && cur.mode === "editing") {
|
|
918
|
+
patch.mode = "normal";
|
|
919
|
+
this.log(`${cur.path}: you are now a viewer; editing ended and local changes will be reverted`);
|
|
920
|
+
}
|
|
921
|
+
const now = this.db.update(cur.instanceId, patch);
|
|
922
|
+
if (r.status !== "changed") return;
|
|
923
|
+
if (now.mode !== "normal") return;
|
|
924
|
+
if (now.kind === "folder") folders.push({ inst: now, state: r.state, children: r.children });
|
|
925
|
+
else if (!now.detached) incoming.push({ inst: now, state: r.state });
|
|
926
|
+
});
|
|
927
|
+
const newChildren = [];
|
|
928
|
+
for (const f of folders) {
|
|
929
|
+
const have = new Set(this.db.childrenOf(f.inst.instanceId).map((c) => c.artifactId));
|
|
930
|
+
for (const c of f.children) if (!c.deleted && !have.has(c.artifactId)) newChildren.push({ folder: f.inst, child: c });
|
|
931
|
+
}
|
|
932
|
+
try {
|
|
933
|
+
await this.ensureBlobs([
|
|
934
|
+
...incoming.map(({ state }) => ({ artifactId: state.artifactId, version: state.version, sha: state.blob })),
|
|
935
|
+
...newChildren.map(({ child }) => ({ artifactId: child.artifactId, version: child.version, sha: child.blob }))
|
|
936
|
+
]);
|
|
937
|
+
} catch (e) {
|
|
938
|
+
if (e instanceof OfflineError) return this.offline = e.message;
|
|
939
|
+
throw e;
|
|
940
|
+
}
|
|
941
|
+
for (const { inst, state } of incoming) this.applyIncoming(inst, state);
|
|
942
|
+
for (const { folder, child } of newChildren) {
|
|
943
|
+
this.db.insert({
|
|
944
|
+
instanceId: newInstanceId(),
|
|
945
|
+
artifactId: child.artifactId,
|
|
946
|
+
kind: "file",
|
|
947
|
+
folderInstanceId: folder.instanceId,
|
|
948
|
+
repoId: child.repoId,
|
|
949
|
+
name: child.name,
|
|
950
|
+
relPath: child.relPath,
|
|
951
|
+
path: path4.join(folder.path, ...child.relPath.split("/")),
|
|
952
|
+
mode: "normal",
|
|
953
|
+
baseVersion: child.version,
|
|
954
|
+
baseHash: child.blob,
|
|
955
|
+
eolMode: child.eol,
|
|
956
|
+
exec: child.exec,
|
|
957
|
+
role: folder.role,
|
|
958
|
+
canDelete: folder.canDelete,
|
|
959
|
+
needsRestore: true
|
|
960
|
+
});
|
|
961
|
+
this.log(`${folder.path}: new file ${child.relPath}`);
|
|
962
|
+
}
|
|
963
|
+
for (const f of folders) this.db.update(f.inst.instanceId, { baseVersion: f.state.version });
|
|
964
|
+
}
|
|
965
|
+
/** Normal mode: write an incoming version (content and/or layout). */
|
|
966
|
+
applyIncoming(inst, state) {
|
|
967
|
+
try {
|
|
968
|
+
let p = inst.path;
|
|
969
|
+
if (inst.folderInstanceId && state.relPath !== inst.relPath) {
|
|
970
|
+
const folder = this.folderOf(inst);
|
|
971
|
+
const to = path4.join(folder.path, ...state.relPath.split("/"));
|
|
972
|
+
this.makeRoom(to, inst);
|
|
973
|
+
moveFile(inst.path, to);
|
|
974
|
+
pruneEmptyDirs(path4.dirname(inst.path), folder.path);
|
|
975
|
+
p = to;
|
|
976
|
+
this.log(`${inst.path} -> ${to}: moved by the server`);
|
|
977
|
+
}
|
|
978
|
+
const patch = { path: p, relPath: state.relPath, baseVersion: state.version, baseHash: state.blob, eolMode: state.eol, exec: state.exec, lastError: null };
|
|
979
|
+
if (state.blob !== inst.baseHash || !this.matchesBase(inst, p)) {
|
|
980
|
+
Object.assign(patch, this.writeInstance(inst, p, state.blob, state.eol, state.exec));
|
|
981
|
+
this.log(`${p}: updated to version ${state.version}`);
|
|
982
|
+
}
|
|
983
|
+
this.db.update(inst.instanceId, patch);
|
|
984
|
+
} catch (e) {
|
|
985
|
+
if (!(e instanceof LockedError)) throw e;
|
|
986
|
+
this.db.update(inst.instanceId, { lastError: e.message });
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
/** If `to` is occupied by something that isn't this instance, move it aside (keep both). */
|
|
990
|
+
makeRoom(to, inst) {
|
|
991
|
+
const st = statOrNull(to);
|
|
992
|
+
if (!st) return;
|
|
993
|
+
const id = this.tracker.identify(to);
|
|
994
|
+
if (id && id.fileId === inst.fileId) return;
|
|
995
|
+
const aside = keepBothName(to);
|
|
996
|
+
fs6.renameSync(to, aside);
|
|
997
|
+
this.log(`${to}: existing file kept as ${path4.basename(aside)}`);
|
|
998
|
+
}
|
|
999
|
+
matchesBase(inst, p) {
|
|
1000
|
+
const st = statOrNull(p);
|
|
1001
|
+
if (!st) return false;
|
|
1002
|
+
if (st.size === inst.size && st.mtimeMs === inst.mtimeMs) return true;
|
|
1003
|
+
const local = readLocal(p, inst.eolMode);
|
|
1004
|
+
return local?.hash === inst.baseHash;
|
|
1005
|
+
}
|
|
1006
|
+
/** Normal mode: restore missing files, put moved files back, revert local edits. */
|
|
1007
|
+
async enforceLocal() {
|
|
1008
|
+
const restores = this.db.all().filter((i) => ACTIVE(i) && i.needsRestore && i.mode === "normal");
|
|
1009
|
+
try {
|
|
1010
|
+
await this.ensureBlobs(restores.map((i) => ({ artifactId: i.artifactId, version: i.baseVersion, sha: i.baseHash })));
|
|
1011
|
+
} catch (e) {
|
|
1012
|
+
if (!(e instanceof OfflineError)) throw e;
|
|
1013
|
+
this.offline = e.message;
|
|
1014
|
+
}
|
|
1015
|
+
for (const inst of this.db.all()) {
|
|
1016
|
+
if (inst.mode !== "normal" || inst.kind !== "file" || inst.detached) continue;
|
|
1017
|
+
try {
|
|
1018
|
+
if (inst.needsRestore) {
|
|
1019
|
+
if (this.cache.has(inst.baseHash)) this.restore(inst);
|
|
1020
|
+
continue;
|
|
1021
|
+
}
|
|
1022
|
+
let cur = inst;
|
|
1023
|
+
if (cur.folderInstanceId) cur = this.enforceLayout(cur);
|
|
1024
|
+
if (!cur) continue;
|
|
1025
|
+
const st = statOrNull(cur.path);
|
|
1026
|
+
if (!st) continue;
|
|
1027
|
+
if (st.size === cur.size && st.mtimeMs === cur.mtimeMs) continue;
|
|
1028
|
+
const local = readLocal(cur.path, cur.eolMode);
|
|
1029
|
+
if (!local) continue;
|
|
1030
|
+
if (local.hash === cur.baseHash) {
|
|
1031
|
+
this.db.update(cur.instanceId, { size: local.size, mtimeMs: local.mtimeMs, lastError: null });
|
|
1032
|
+
continue;
|
|
1033
|
+
}
|
|
1034
|
+
if (!this.cache.has(cur.baseHash)) continue;
|
|
1035
|
+
this.db.update(cur.instanceId, { ...this.writeInstance(cur, cur.path, cur.baseHash), lastError: null });
|
|
1036
|
+
this.log(`${cur.path}: local change reverted (use \`tether edit\` to change it)`);
|
|
1037
|
+
} catch (e) {
|
|
1038
|
+
if (!(e instanceof LockedError)) throw e;
|
|
1039
|
+
this.db.update(inst.instanceId, { lastError: e.message });
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
/** Normal-mode layout inside a folder: moves inside are reverted; a file moved out is left
|
|
1044
|
+
* where it is as an ordinary file and the folder gets a fresh copy. */
|
|
1045
|
+
enforceLayout(inst) {
|
|
1046
|
+
const folder = this.folderOf(inst);
|
|
1047
|
+
if (!folder || folder.mode === "orphaned") return inst;
|
|
1048
|
+
const rel = this.localRel(inst, folder);
|
|
1049
|
+
const expected = this.expectedPath(inst, folder);
|
|
1050
|
+
if (rel === null) {
|
|
1051
|
+
this.log(`${inst.path}: moved out of ${folder.path}; left there unmanaged, restoring the folder copy`);
|
|
1052
|
+
const next = this.db.update(inst.instanceId, { path: expected, fileId: null, volumeId: null, needsRestore: true });
|
|
1053
|
+
if (this.cache.has(next.baseHash)) this.restore(next);
|
|
1054
|
+
return null;
|
|
1055
|
+
}
|
|
1056
|
+
if (rel === inst.relPath) return inst;
|
|
1057
|
+
this.makeRoom(expected, inst);
|
|
1058
|
+
moveFile(inst.path, expected);
|
|
1059
|
+
pruneEmptyDirs(path4.dirname(inst.path), folder.path);
|
|
1060
|
+
this.log(`${inst.path}: move reverted to ${inst.relPath}`);
|
|
1061
|
+
return this.db.update(inst.instanceId, { path: expected });
|
|
1062
|
+
}
|
|
1063
|
+
/** Writes a fresh copy at the instance's expected path (new folder files, restores). */
|
|
1064
|
+
restore(inst) {
|
|
1065
|
+
const p = inst.folderInstanceId ? this.expectedPath(inst) : inst.path;
|
|
1066
|
+
const existing = readLocal(p, inst.eolMode);
|
|
1067
|
+
if (existing && existing.hash !== inst.baseHash) this.makeRoom(p, inst);
|
|
1068
|
+
const st = existing && existing.hash === inst.baseHash ? { size: existing.size, mtimeMs: existing.mtimeMs, ...styleOf(existing) } : this.writeInstance(inst, p, inst.baseHash);
|
|
1069
|
+
this.db.update(inst.instanceId, { ...st, ...this.identity(p), path: canonicalPath(p), needsRestore: false, detached: false, missingSince: null, lastError: null });
|
|
1070
|
+
}
|
|
1071
|
+
// ---------- commands ----------
|
|
1072
|
+
async findRepo(ref) {
|
|
1073
|
+
const repos = await this.api.call("GET", "/repos");
|
|
1074
|
+
const r = repos.find((x) => x.repoId === ref || x.name === ref);
|
|
1075
|
+
if (!r) throw new UserError(`no repo "${ref}" (create it with \`tether repo create ${ref}\`)`);
|
|
1076
|
+
return r;
|
|
1077
|
+
}
|
|
1078
|
+
instanceAt(p) {
|
|
1079
|
+
return this.db.all().find((i) => ACTIVE(i) && samePath(i.path, p));
|
|
1080
|
+
}
|
|
1081
|
+
checkOverlap(p) {
|
|
1082
|
+
for (const i of this.db.all().filter(ACTIVE)) {
|
|
1083
|
+
if (samePath(i.path, p)) throw new UserError(`${p} is already managed`);
|
|
1084
|
+
if (i.kind === "folder" && isUnder(p, i.path)) throw new UserError(`${p} is inside the managed folder ${i.path}; use \`tether add\``);
|
|
1085
|
+
if (isUnder(i.path, p)) throw new UserError(`${p} contains the managed path ${i.path}`);
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
prepareFile(abs, relForEol, rules, eol) {
|
|
1089
|
+
const st = fs6.lstatSync(abs);
|
|
1090
|
+
if (st.isSymbolicLink()) throw new UserError(`${abs} is a symlink; symlinks are not supported`);
|
|
1091
|
+
const bytes = fs6.readFileSync(abs);
|
|
1092
|
+
const eolMode = resolveEol(relForEol, rules, eol);
|
|
1093
|
+
const canonical = canonicalize(bytes, eolMode).bytes;
|
|
1094
|
+
return { abs, bytes: canonical, sha256: sha256(canonical), size: canonical.length, eol: eolMode, exec: process.platform !== "win32" && !!(st.mode & 73), stat: st };
|
|
1095
|
+
}
|
|
1096
|
+
/** `tether manage <path> --repo <repo>`: upload; the caller becomes the owner. */
|
|
1097
|
+
async manage({ path: target, repo: repoRef, eol }) {
|
|
1098
|
+
const abs = canonicalPath(path4.resolve(target));
|
|
1099
|
+
const st = statOrNull(abs);
|
|
1100
|
+
if (!st) throw new UserError(`${abs} does not exist`);
|
|
1101
|
+
this.checkOverlap(abs);
|
|
1102
|
+
const repo = await this.findRepo(repoRef);
|
|
1103
|
+
const rules = repo.eolDefaults ?? {};
|
|
1104
|
+
const name = path4.basename(abs);
|
|
1105
|
+
const nErr = nameError(name);
|
|
1106
|
+
if (nErr) throw new UserError(nErr);
|
|
1107
|
+
if (st.isFile()) {
|
|
1108
|
+
const f = this.prepareFile(abs, name, rules, eol);
|
|
1109
|
+
await this.api.uploadBlobs([f]);
|
|
1110
|
+
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 });
|
|
1111
|
+
this.cache.put(f.sha256, f.bytes);
|
|
1112
|
+
const local = readLocal(abs, f.eol);
|
|
1113
|
+
this.db.insert({
|
|
1114
|
+
instanceId: newInstanceId(),
|
|
1115
|
+
artifactId: r2.artifact.artifactId,
|
|
1116
|
+
kind: "file",
|
|
1117
|
+
repoId: repo.repoId,
|
|
1118
|
+
name,
|
|
1119
|
+
path: abs,
|
|
1120
|
+
...this.identity(abs),
|
|
1121
|
+
mode: "normal",
|
|
1122
|
+
baseVersion: 1,
|
|
1123
|
+
baseHash: f.sha256,
|
|
1124
|
+
size: local.size,
|
|
1125
|
+
mtimeMs: local.mtimeMs,
|
|
1126
|
+
...styleOf(local),
|
|
1127
|
+
eolMode: f.eol,
|
|
1128
|
+
exec: f.exec,
|
|
1129
|
+
role: "owner",
|
|
1130
|
+
canDelete: true,
|
|
1131
|
+
remoteVersion: 1
|
|
1132
|
+
});
|
|
1133
|
+
return { artifactId: r2.artifact.artifactId, shareId: r2.artifact.shareId, kind: "file", path: abs, files: 1 };
|
|
1134
|
+
}
|
|
1135
|
+
const { files: files2, symlinks } = walkFiles(abs);
|
|
1136
|
+
if (!files2.length) throw new UserError(`${abs} has no files`);
|
|
1137
|
+
for (const f of files2) {
|
|
1138
|
+
const e = relPathError(f.rel);
|
|
1139
|
+
if (e) throw new UserError(e);
|
|
1140
|
+
}
|
|
1141
|
+
const c = caseCollisionError(files2.map((f) => f.rel));
|
|
1142
|
+
if (c) throw new UserError(c);
|
|
1143
|
+
const prepared = files2.map((f) => ({ ...this.prepareFile(f.abs, f.rel, rules), rel: f.rel }));
|
|
1144
|
+
await this.api.uploadBlobs(prepared);
|
|
1145
|
+
const r = await this.api.call("POST", "/artifacts", {
|
|
1146
|
+
repoId: repo.repoId,
|
|
1147
|
+
kind: "folder",
|
|
1148
|
+
name,
|
|
1149
|
+
files: prepared.map((f) => ({ relPath: f.rel, blob: f.sha256, size: f.size, eol: f.eol, exec: f.exec }))
|
|
1150
|
+
});
|
|
1151
|
+
const folderInst = this.db.insert({
|
|
1152
|
+
instanceId: newInstanceId(),
|
|
1153
|
+
artifactId: r.artifact.artifactId,
|
|
1154
|
+
kind: "folder",
|
|
1155
|
+
repoId: repo.repoId,
|
|
1156
|
+
name,
|
|
1157
|
+
path: abs,
|
|
1158
|
+
...this.identity(abs),
|
|
1159
|
+
mode: "normal",
|
|
1160
|
+
baseVersion: 1,
|
|
1161
|
+
role: "owner",
|
|
1162
|
+
canDelete: true,
|
|
1163
|
+
remoteVersion: 1
|
|
1164
|
+
});
|
|
1165
|
+
const byRel = new Map(r.children.map((ch) => [ch.relPath, ch]));
|
|
1166
|
+
for (const f of prepared) {
|
|
1167
|
+
this.cache.put(f.sha256, f.bytes);
|
|
1168
|
+
const ch = byRel.get(f.rel);
|
|
1169
|
+
const local = readLocal(f.abs, f.eol);
|
|
1170
|
+
this.db.insert({
|
|
1171
|
+
instanceId: newInstanceId(),
|
|
1172
|
+
artifactId: ch.artifactId,
|
|
1173
|
+
kind: "file",
|
|
1174
|
+
folderInstanceId: folderInst.instanceId,
|
|
1175
|
+
repoId: repo.repoId,
|
|
1176
|
+
name: ch.name,
|
|
1177
|
+
relPath: f.rel,
|
|
1178
|
+
path: canonicalPath(f.abs),
|
|
1179
|
+
...this.identity(f.abs),
|
|
1180
|
+
mode: "normal",
|
|
1181
|
+
baseVersion: 1,
|
|
1182
|
+
baseHash: f.sha256,
|
|
1183
|
+
size: local.size,
|
|
1184
|
+
mtimeMs: local.mtimeMs,
|
|
1185
|
+
...styleOf(local),
|
|
1186
|
+
eolMode: f.eol,
|
|
1187
|
+
exec: f.exec,
|
|
1188
|
+
role: "owner",
|
|
1189
|
+
canDelete: true,
|
|
1190
|
+
remoteVersion: 1
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
return { artifactId: r.artifact.artifactId, shareId: r.artifact.shareId, kind: "folder", path: abs, files: files2.length, skippedSymlinks: symlinks };
|
|
1194
|
+
}
|
|
1195
|
+
/** `tether add <path>`: owner adds a new file inside a managed folder. */
|
|
1196
|
+
async add({ path: target }) {
|
|
1197
|
+
const abs = canonicalPath(path4.resolve(target));
|
|
1198
|
+
if (!statOrNull(abs)?.isFile()) throw new UserError(`${abs} is not a file`);
|
|
1199
|
+
if (this.instanceAt(abs)) throw new UserError(`${abs} is already managed`);
|
|
1200
|
+
const folder = this.db.all().find((i) => ACTIVE(i) && i.kind === "folder" && isUnder(abs, i.path));
|
|
1201
|
+
if (!folder) throw new UserError(`${abs} is not inside a managed folder`);
|
|
1202
|
+
if (folder.role !== "owner") throw new UserError("only the owner can add files to a folder");
|
|
1203
|
+
const rel = path4.relative(folder.path, abs).split(path4.sep).join("/");
|
|
1204
|
+
const e = relPathError(rel);
|
|
1205
|
+
if (e) throw new UserError(e);
|
|
1206
|
+
const repo = (await this.api.call("GET", "/repos")).find((r) => r.repoId === folder.repoId);
|
|
1207
|
+
const f = this.prepareFile(abs, rel, repo?.eolDefaults ?? {});
|
|
1208
|
+
await this.api.uploadBlobs([f]);
|
|
1209
|
+
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 });
|
|
1210
|
+
this.cache.put(f.sha256, f.bytes);
|
|
1211
|
+
const local = readLocal(abs, f.eol);
|
|
1212
|
+
this.db.insert({
|
|
1213
|
+
instanceId: newInstanceId(),
|
|
1214
|
+
artifactId: child.artifactId,
|
|
1215
|
+
kind: "file",
|
|
1216
|
+
folderInstanceId: folder.instanceId,
|
|
1217
|
+
repoId: folder.repoId,
|
|
1218
|
+
name: child.name,
|
|
1219
|
+
relPath: rel,
|
|
1220
|
+
path: abs,
|
|
1221
|
+
...this.identity(abs),
|
|
1222
|
+
mode: "normal",
|
|
1223
|
+
baseVersion: 1,
|
|
1224
|
+
baseHash: f.sha256,
|
|
1225
|
+
size: local.size,
|
|
1226
|
+
mtimeMs: local.mtimeMs,
|
|
1227
|
+
...styleOf(local),
|
|
1228
|
+
eolMode: f.eol,
|
|
1229
|
+
exec: f.exec,
|
|
1230
|
+
role: "owner",
|
|
1231
|
+
canDelete: true,
|
|
1232
|
+
remoteVersion: 1
|
|
1233
|
+
});
|
|
1234
|
+
return { artifactId: child.artifactId, relPath: rel, folder: folder.path };
|
|
1235
|
+
}
|
|
1236
|
+
/** `tether pull <shareId> [dest]`. Never overwrites existing user files. */
|
|
1237
|
+
async pull({ shareId, dest, cwd }) {
|
|
1238
|
+
const r = await this.api.call("POST", "/pull", { shareId });
|
|
1239
|
+
const a = r.artifact;
|
|
1240
|
+
let target = path4.resolve(cwd, dest ?? a.name);
|
|
1241
|
+
if (dest && statOrNull(target)?.isDirectory() && a.kind === "file") target = path4.join(target, a.name);
|
|
1242
|
+
if (this.instanceAt(target)) throw new UserError(`${target} is already a managed copy`);
|
|
1243
|
+
const base = { repoId: a.repoId, role: r.role, canDelete: r.canDelete };
|
|
1244
|
+
if (a.kind === "file") {
|
|
1245
|
+
await this.ensureBlobs([{ artifactId: a.artifactId, version: a.version, sha: a.blob }]);
|
|
1246
|
+
const existing = readLocal(target, a.eol);
|
|
1247
|
+
if (existing && existing.hash !== a.blob) throw new UserError(`${target} already exists with different content; choose another destination`);
|
|
1248
|
+
const st2 = existing ? { size: existing.size, mtimeMs: existing.mtimeMs, ...styleOf(existing) } : writeLocal(target, this.blobFor(a.blob), a.eol, { exec: a.exec });
|
|
1249
|
+
this.db.insert({
|
|
1250
|
+
...base,
|
|
1251
|
+
instanceId: newInstanceId(),
|
|
1252
|
+
artifactId: a.artifactId,
|
|
1253
|
+
kind: "file",
|
|
1254
|
+
name: a.name,
|
|
1255
|
+
path: canonicalPath(target),
|
|
1256
|
+
...this.identity(target),
|
|
1257
|
+
mode: "normal",
|
|
1258
|
+
baseVersion: a.version,
|
|
1259
|
+
baseHash: a.blob,
|
|
1260
|
+
...st2,
|
|
1261
|
+
eolMode: a.eol,
|
|
1262
|
+
exec: a.exec,
|
|
1263
|
+
remoteVersion: a.version
|
|
1264
|
+
});
|
|
1265
|
+
return { kind: "file", path: canonicalPath(target), role: r.role, version: a.version };
|
|
1266
|
+
}
|
|
1267
|
+
const st = statOrNull(target);
|
|
1268
|
+
if (st && !st.isDirectory()) throw new UserError(`${target} exists and is not a folder`);
|
|
1269
|
+
await this.ensureBlobs(r.children.map((c) => ({ artifactId: c.artifactId, version: c.version, sha: c.blob })));
|
|
1270
|
+
const clashes = [];
|
|
1271
|
+
for (const c of r.children) {
|
|
1272
|
+
const p = path4.join(target, ...c.relPath.split("/"));
|
|
1273
|
+
const ex = readLocal(p, c.eol);
|
|
1274
|
+
if (ex && ex.hash !== c.blob) clashes.push(c.relPath);
|
|
1275
|
+
}
|
|
1276
|
+
if (clashes.length) throw new UserError(`${target} already has different files: ${clashes.join(", ")}; choose another destination`);
|
|
1277
|
+
fs6.mkdirSync(target, { recursive: true });
|
|
1278
|
+
const folderInst = this.db.insert({
|
|
1279
|
+
...base,
|
|
1280
|
+
instanceId: newInstanceId(),
|
|
1281
|
+
artifactId: a.artifactId,
|
|
1282
|
+
kind: "folder",
|
|
1283
|
+
name: a.name,
|
|
1284
|
+
path: canonicalPath(target),
|
|
1285
|
+
...this.identity(target),
|
|
1286
|
+
mode: "normal",
|
|
1287
|
+
baseVersion: a.version,
|
|
1288
|
+
remoteVersion: a.version
|
|
1289
|
+
});
|
|
1290
|
+
for (const c of r.children) {
|
|
1291
|
+
const p = path4.join(folderInst.path, ...c.relPath.split("/"));
|
|
1292
|
+
const ex = readLocal(p, c.eol);
|
|
1293
|
+
const cst = ex ? { size: ex.size, mtimeMs: ex.mtimeMs, ...styleOf(ex) } : writeLocal(p, this.blobFor(c.blob), c.eol, { exec: c.exec });
|
|
1294
|
+
this.db.insert({
|
|
1295
|
+
...base,
|
|
1296
|
+
instanceId: newInstanceId(),
|
|
1297
|
+
artifactId: c.artifactId,
|
|
1298
|
+
kind: "file",
|
|
1299
|
+
folderInstanceId: folderInst.instanceId,
|
|
1300
|
+
name: c.name,
|
|
1301
|
+
relPath: c.relPath,
|
|
1302
|
+
path: canonicalPath(p),
|
|
1303
|
+
...this.identity(p),
|
|
1304
|
+
mode: "normal",
|
|
1305
|
+
baseVersion: c.version,
|
|
1306
|
+
baseHash: c.blob,
|
|
1307
|
+
...cst,
|
|
1308
|
+
eolMode: c.eol,
|
|
1309
|
+
exec: c.exec,
|
|
1310
|
+
remoteVersion: c.version
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1313
|
+
return { kind: "folder", path: folderInst.path, role: r.role, files: r.children.length, version: a.version };
|
|
1314
|
+
}
|
|
1315
|
+
/** Instances addressed by a path: a file, a managed folder (with its files), or any directory. */
|
|
1316
|
+
targets(p) {
|
|
1317
|
+
const abs = canonicalPath(path4.resolve(p));
|
|
1318
|
+
const all = this.db.all().filter(ACTIVE);
|
|
1319
|
+
let hits = all.filter((i) => samePath(i.path, abs) || isUnder(i.path, abs));
|
|
1320
|
+
for (const f of hits.filter((i) => i.kind === "folder")) hits.push(...this.db.childrenOf(f.instanceId).filter(ACTIVE));
|
|
1321
|
+
hits = [...new Map(hits.map((i) => [i.instanceId, i])).values()];
|
|
1322
|
+
if (!hits.length) throw new UserError(`nothing managed at ${abs}`);
|
|
1323
|
+
return hits;
|
|
1324
|
+
}
|
|
1325
|
+
setMode(p, mode, from) {
|
|
1326
|
+
const changed = [];
|
|
1327
|
+
for (const i of this.targets(p)) {
|
|
1328
|
+
if (!from.includes(i.mode)) continue;
|
|
1329
|
+
this.db.update(i.instanceId, { mode, ...mode === "editing" ? {} : {} });
|
|
1330
|
+
changed.push(i.path);
|
|
1331
|
+
}
|
|
1332
|
+
return changed;
|
|
1333
|
+
}
|
|
1334
|
+
edit({ path: p }) {
|
|
1335
|
+
const ts = this.targets(p);
|
|
1336
|
+
const unmanaged = ts.filter((i) => i.mode === "unmanaged" && !i.detached);
|
|
1337
|
+
if (unmanaged.length === ts.length) throw new UserError("these files are unmanaged; use `tether track` first");
|
|
1338
|
+
return { changed: this.setMode(p, "editing", ["normal"]) };
|
|
1339
|
+
}
|
|
1340
|
+
unmanage({ path: p }) {
|
|
1341
|
+
return { changed: this.setMode(p, "unmanaged", ["normal", "editing"]) };
|
|
1342
|
+
}
|
|
1343
|
+
/**
|
|
1344
|
+
* Plans `tether done` (editing -> normal) or `tether track` (unmanaged -> normal).
|
|
1345
|
+
* Returns items needing a decision (options) or with an automatic action (auto).
|
|
1346
|
+
*/
|
|
1347
|
+
async plan({ path: p, from }) {
|
|
1348
|
+
await this.locateAll();
|
|
1349
|
+
const ts = this.targets(p).filter((i) => i.mode === from || from === "unmanaged" && i.mode === "normal" && i.kind === "folder");
|
|
1350
|
+
if (!ts.length) throw new UserError(from === "editing" ? "nothing here is in editing mode" : "nothing here is unmanaged");
|
|
1351
|
+
const files2 = ts.filter((i) => i.kind === "file");
|
|
1352
|
+
const { results } = files2.length ? await this.api.call("POST", "/sync/check", { items: files2.map((i) => ({ artifactId: i.artifactId, version: i.baseVersion })) }) : { results: [] };
|
|
1353
|
+
const items = [];
|
|
1354
|
+
files2.forEach((inst, idx) => {
|
|
1355
|
+
const r = results[idx];
|
|
1356
|
+
const item = { instanceId: inst.instanceId, path: inst.path, relPath: inst.relPath, folder: inst.folderInstanceId ? this.folderOf(inst).path : null };
|
|
1357
|
+
if (r.status === "gone" || r.state?.deleted || r.folderDeleted) {
|
|
1358
|
+
items.push({ ...item, auto: "orphan", reason: r.reason ?? "deleted" });
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1361
|
+
const role = r.role;
|
|
1362
|
+
const canPush = role === "owner" || role === "editor";
|
|
1363
|
+
const incoming = r.status === "changed";
|
|
1364
|
+
item.serverVersion = r.version;
|
|
1365
|
+
item.incoming = incoming;
|
|
1366
|
+
item.role = role;
|
|
1367
|
+
if (inst.detached) {
|
|
1368
|
+
if (from === "unmanaged") items.push({ ...item, change: "missing", auto: "restore" });
|
|
1369
|
+
else if (canPush && r.canDelete) items.push({ ...item, change: "deleted", options: ["delete", "stop"] });
|
|
1370
|
+
else items.push({ ...item, change: "deleted", auto: "stop" });
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
const folder = this.folderOf(inst);
|
|
1374
|
+
const rel = folder ? this.localRel(inst, folder) : void 0;
|
|
1375
|
+
if (folder && rel === null) {
|
|
1376
|
+
if (canPush && r.canDelete) items.push({ ...item, change: "moved-out", options: ["delete", "stop"] });
|
|
1377
|
+
else if (canPush) items.push({ ...item, change: "moved-out", auto: "stop" });
|
|
1378
|
+
else items.push({ ...item, change: "moved-out", options: ["accept", "keep-both", "cancel"] });
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
const local = readLocal(inst.path, inst.eolMode);
|
|
1382
|
+
const contentChanged = !!local && local.hash !== inst.baseHash;
|
|
1383
|
+
const moved = folder ? rel !== inst.relPath : false;
|
|
1384
|
+
const changes = [contentChanged && "content", moved && `moved to ${rel}`].filter(Boolean);
|
|
1385
|
+
if (!changes.length) {
|
|
1386
|
+
items.push({ ...item, auto: incoming ? "accept" : "clean" });
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
item.change = changes.join(", ");
|
|
1390
|
+
if (local && r.state && local.hash === r.state.blob && (!folder || rel === r.state.relPath)) {
|
|
1391
|
+
items.push({ ...item, auto: "adopt", state: r.state });
|
|
1392
|
+
} else if (!canPush) {
|
|
1393
|
+
items.push({ ...item, options: ["accept", "keep-both", "diff", "cancel"] });
|
|
1394
|
+
} else if (!incoming && from === "editing") {
|
|
1395
|
+
items.push({ ...item, auto: "push" });
|
|
1396
|
+
} else {
|
|
1397
|
+
items.push({ ...item, conflict: incoming, options: ["push", "accept", "keep-both", "diff", "cancel"] });
|
|
1398
|
+
}
|
|
1399
|
+
});
|
|
1400
|
+
return { from, items };
|
|
1401
|
+
}
|
|
1402
|
+
/** Applies a plan with the user's choices: { instanceId: option }. */
|
|
1403
|
+
async resolve({ path: p, from, items, choices = {} }) {
|
|
1404
|
+
const out = [];
|
|
1405
|
+
for (const item of items) {
|
|
1406
|
+
const choice = item.auto ?? choices[item.instanceId];
|
|
1407
|
+
if (!choice) throw new UserError(`no choice for ${item.path}`);
|
|
1408
|
+
const inst = this.db.get(item.instanceId);
|
|
1409
|
+
if (!inst) continue;
|
|
1410
|
+
try {
|
|
1411
|
+
out.push({ path: item.path, action: choice, ...await this.applyChoice(inst, item, choice) });
|
|
1412
|
+
} catch (e) {
|
|
1413
|
+
if (e instanceof ApiError && e.status === 409) {
|
|
1414
|
+
out.push({ path: item.path, action: choice, error: "the server changed again; run the command again to resolve" });
|
|
1415
|
+
} else if (e instanceof ApiError || e instanceof LockedError || e instanceof UserError) {
|
|
1416
|
+
out.push({ path: item.path, action: choice, error: e.message });
|
|
1417
|
+
} else throw e;
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
for (const f of this.targets(p).filter((i) => i.kind === "folder" && i.mode === from)) {
|
|
1421
|
+
if (!this.db.childrenOf(f.instanceId).some((c) => c.mode === from && !c.detached)) this.db.update(f.instanceId, { mode: "normal" });
|
|
1422
|
+
}
|
|
1423
|
+
await this.tick();
|
|
1424
|
+
return { results: out };
|
|
1425
|
+
}
|
|
1426
|
+
async applyChoice(inst, item, choice) {
|
|
1427
|
+
const folder = this.folderOf(inst);
|
|
1428
|
+
switch (choice) {
|
|
1429
|
+
case "clean":
|
|
1430
|
+
this.db.update(inst.instanceId, { mode: "normal" });
|
|
1431
|
+
return {};
|
|
1432
|
+
case "orphan":
|
|
1433
|
+
this.orphan(inst, item.reason);
|
|
1434
|
+
return {};
|
|
1435
|
+
case "adopt":
|
|
1436
|
+
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 });
|
|
1437
|
+
return {};
|
|
1438
|
+
case "restore":
|
|
1439
|
+
this.db.update(inst.instanceId, { mode: "normal", detached: false, needsRestore: true });
|
|
1440
|
+
return {};
|
|
1441
|
+
case "cancel":
|
|
1442
|
+
return { kept: inst.mode };
|
|
1443
|
+
case "accept": {
|
|
1444
|
+
if (folder && item.change === "moved-out") {
|
|
1445
|
+
const to = this.expectedPath(inst, folder);
|
|
1446
|
+
this.makeRoom(to, inst);
|
|
1447
|
+
moveFile(inst.path, to);
|
|
1448
|
+
this.db.update(inst.instanceId, { path: to, mode: "normal" });
|
|
1449
|
+
return {};
|
|
1450
|
+
}
|
|
1451
|
+
this.db.update(inst.instanceId, { mode: "normal", size: null, mtimeMs: null });
|
|
1452
|
+
return {};
|
|
1453
|
+
}
|
|
1454
|
+
case "keep-both": {
|
|
1455
|
+
if (folder && item.change === "moved-out") {
|
|
1456
|
+
this.db.update(inst.instanceId, { mode: "normal", path: this.expectedPath(inst, folder), fileId: null, volumeId: null, needsRestore: true });
|
|
1457
|
+
return { keptAs: inst.path };
|
|
1458
|
+
}
|
|
1459
|
+
const copy = keepBothName(inst.path);
|
|
1460
|
+
fs6.copyFileSync(inst.path, copy);
|
|
1461
|
+
this.db.update(inst.instanceId, { mode: "normal", size: null, mtimeMs: null });
|
|
1462
|
+
return { keptAs: copy };
|
|
1463
|
+
}
|
|
1464
|
+
case "push": {
|
|
1465
|
+
const local = readLocal(inst.path, inst.eolMode);
|
|
1466
|
+
const change = {};
|
|
1467
|
+
if (local && local.hash !== inst.baseHash) {
|
|
1468
|
+
await this.api.uploadBlobs([{ sha256: local.hash, bytes: local.canonical }]);
|
|
1469
|
+
this.cache.put(local.hash, local.canonical);
|
|
1470
|
+
Object.assign(change, { blob: local.hash, size: local.canonical.length });
|
|
1471
|
+
}
|
|
1472
|
+
if (folder) {
|
|
1473
|
+
const rel = this.localRel(inst, folder);
|
|
1474
|
+
if (rel && rel !== inst.relPath) change.relPath = rel;
|
|
1475
|
+
}
|
|
1476
|
+
const baseVersion = item.incoming ? item.serverVersion : inst.baseVersion;
|
|
1477
|
+
const r = await this.api.call("POST", `/artifacts/${inst.artifactId}/commit`, { baseVersion, change });
|
|
1478
|
+
const state = r.state;
|
|
1479
|
+
this.db.update(inst.instanceId, {
|
|
1480
|
+
mode: "normal",
|
|
1481
|
+
baseVersion: r.version,
|
|
1482
|
+
remoteVersion: r.version,
|
|
1483
|
+
baseHash: state?.blob ?? inst.baseHash,
|
|
1484
|
+
relPath: state?.relPath ?? inst.relPath,
|
|
1485
|
+
size: local?.size ?? null,
|
|
1486
|
+
mtimeMs: local?.mtimeMs ?? null,
|
|
1487
|
+
...local ? styleOf(local) : {}
|
|
1488
|
+
});
|
|
1489
|
+
return { version: r.version };
|
|
1490
|
+
}
|
|
1491
|
+
case "delete": {
|
|
1492
|
+
await this.api.call("POST", `/artifacts/${inst.artifactId}/commit`, { baseVersion: item.serverVersion, change: { deleted: true } });
|
|
1493
|
+
this.db.remove(inst.instanceId);
|
|
1494
|
+
return { deletedForEveryone: true };
|
|
1495
|
+
}
|
|
1496
|
+
case "stop": {
|
|
1497
|
+
if (item.change === "moved-out") {
|
|
1498
|
+
this.db.update(inst.instanceId, { mode: "normal", path: this.expectedPath(inst, folder), fileId: null, volumeId: null, needsRestore: true });
|
|
1499
|
+
return { released: inst.path };
|
|
1500
|
+
}
|
|
1501
|
+
if (folder) this.db.update(inst.instanceId, { mode: "unmanaged", detached: true });
|
|
1502
|
+
else this.db.remove(inst.instanceId);
|
|
1503
|
+
return {};
|
|
1504
|
+
}
|
|
1505
|
+
default:
|
|
1506
|
+
throw new UserError(`unknown choice "${choice}"`);
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
/** Unified diff between the server's current version and the local file. */
|
|
1510
|
+
async diff({ path: p, instanceId }) {
|
|
1511
|
+
const inst = instanceId ? this.db.get(instanceId) : this.targets(p).find((i) => i.kind === "file" && samePath(i.path, canonicalPath(path4.resolve(p))));
|
|
1512
|
+
if (!inst || inst.kind !== "file") throw new UserError("diff needs a managed file");
|
|
1513
|
+
const { artifact } = await this.api.call("GET", `/artifacts/${inst.artifactId}`);
|
|
1514
|
+
if (artifact.deleted) throw new UserError("the artifact has been deleted on the server");
|
|
1515
|
+
await this.ensureBlobs([{ artifactId: inst.artifactId, version: artifact.version, sha: artifact.blob }]);
|
|
1516
|
+
const server = this.cache.get(artifact.blob);
|
|
1517
|
+
const local = inst.detached ? null : readLocal(inst.path, inst.eolMode);
|
|
1518
|
+
const label = path4.basename(inst.path);
|
|
1519
|
+
if (isBinary(server) || local && isBinary(local.canonical)) {
|
|
1520
|
+
return { text: local?.hash === artifact.blob ? "" : `Binary files differ (server v${artifact.version} vs local)
|
|
1521
|
+
` };
|
|
1522
|
+
}
|
|
1523
|
+
return {
|
|
1524
|
+
text: createTwoFilesPatch(`${label} (server v${artifact.version})`, `${label} (local)`, server.toString("utf8"), local ? local.canonical.toString("utf8") : "", "", "", { context: 3 }),
|
|
1525
|
+
serverVersion: artifact.version
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
1528
|
+
status({ path: p } = {}) {
|
|
1529
|
+
let list = this.db.all();
|
|
1530
|
+
if (p) list = this.targets(p).concat(list.filter((i) => i.mode === "orphaned" && isUnder(i.path, canonicalPath(path4.resolve(p)))));
|
|
1531
|
+
return list.map((i) => {
|
|
1532
|
+
const flags = [];
|
|
1533
|
+
if (i.remoteVersion && i.remoteVersion > i.baseVersion && i.kind === "file") flags.push(`incoming v${i.remoteVersion}`);
|
|
1534
|
+
if (i.detached) flags.push("deleted locally");
|
|
1535
|
+
if (i.needsRestore) flags.push("restoring");
|
|
1536
|
+
if (i.missingSince) flags.push("missing");
|
|
1537
|
+
if (i.lastError) flags.push(i.lastError);
|
|
1538
|
+
if (i.kind === "file" && ["editing", "unmanaged"].includes(i.mode) && !i.detached) {
|
|
1539
|
+
try {
|
|
1540
|
+
const local = readLocal(i.path, i.eolMode);
|
|
1541
|
+
if (local && local.hash !== i.baseHash) flags.push("modified");
|
|
1542
|
+
} catch {
|
|
1543
|
+
flags.push("locked");
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
if (i.orphanReason) flags.push(i.orphanReason);
|
|
1547
|
+
return {
|
|
1548
|
+
instanceId: i.instanceId,
|
|
1549
|
+
artifactId: i.artifactId,
|
|
1550
|
+
kind: i.kind,
|
|
1551
|
+
path: i.path,
|
|
1552
|
+
relPath: i.relPath,
|
|
1553
|
+
mode: i.mode,
|
|
1554
|
+
role: i.role,
|
|
1555
|
+
version: i.baseVersion,
|
|
1556
|
+
folderInstanceId: i.folderInstanceId,
|
|
1557
|
+
flags
|
|
1558
|
+
};
|
|
1559
|
+
});
|
|
1560
|
+
}
|
|
1561
|
+
/** `tether locate`: re-link a lost instance by explicit path, or search folders by content. */
|
|
1562
|
+
async locate({ instanceId, dirs = [], file }) {
|
|
1563
|
+
const inst = this.db.get(instanceId) ?? this.db.all().find((i) => i.instanceId.startsWith(instanceId));
|
|
1564
|
+
if (!inst) throw new UserError(`no instance ${instanceId}`);
|
|
1565
|
+
if (file) {
|
|
1566
|
+
const abs = canonicalPath(path4.resolve(file));
|
|
1567
|
+
const st = statOrNull(abs);
|
|
1568
|
+
if (!st || inst.kind === "folder" !== st.isDirectory()) throw new UserError(`${abs} is not a ${inst.kind}`);
|
|
1569
|
+
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 });
|
|
1570
|
+
return { linked: abs };
|
|
1571
|
+
}
|
|
1572
|
+
if (inst.kind !== "file") throw new UserError("pass the folder with --file");
|
|
1573
|
+
const candidates = [];
|
|
1574
|
+
for (const dir of dirs) {
|
|
1575
|
+
if (!statOrNull(dir)?.isDirectory()) continue;
|
|
1576
|
+
for (const f of walkFiles(path4.resolve(dir)).files) {
|
|
1577
|
+
try {
|
|
1578
|
+
if (readLocal(f.abs, inst.eolMode)?.hash === inst.baseHash) candidates.push(f.abs);
|
|
1579
|
+
} catch {
|
|
1580
|
+
}
|
|
1581
|
+
if (candidates.length >= 20) break;
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
return { candidates };
|
|
1585
|
+
}
|
|
1586
|
+
async resolveArtifact(ref) {
|
|
1587
|
+
if (/^a_/.test(ref)) return ref;
|
|
1588
|
+
const abs = canonicalPath(path4.resolve(ref));
|
|
1589
|
+
const inst = this.instanceAt(abs) ?? this.db.all().find((i) => samePath(i.path, abs));
|
|
1590
|
+
if (inst) return inst.artifactId;
|
|
1591
|
+
throw new UserError(`"${ref}" is not a managed path or an artifact ID`);
|
|
1592
|
+
}
|
|
1593
|
+
};
|
|
1594
|
+
|
|
1595
|
+
// packages/client/src/daemon.js
|
|
1596
|
+
var VERSION = "0.1.0";
|
|
1597
|
+
async function runDaemon() {
|
|
1598
|
+
const home = tetherHome();
|
|
1599
|
+
fs7.mkdirSync(home, { recursive: true });
|
|
1600
|
+
process.chdir(home);
|
|
1601
|
+
const f = files(home);
|
|
1602
|
+
const recent = [];
|
|
1603
|
+
const logStream = fs7.createWriteStream(f.log, { flags: "a" });
|
|
1604
|
+
const log = (msg) => {
|
|
1605
|
+
const line = `${(/* @__PURE__ */ new Date()).toISOString()} ${msg}`;
|
|
1606
|
+
recent.push(line);
|
|
1607
|
+
if (recent.length > 500) recent.shift();
|
|
1608
|
+
logStream.write(line + "\n");
|
|
1609
|
+
};
|
|
1610
|
+
const config = new Config(f.config);
|
|
1611
|
+
const db = new LocalDb(f.db);
|
|
1612
|
+
const credentials = new Credentials(f.credentials);
|
|
1613
|
+
const api = new Api({ config, credentials });
|
|
1614
|
+
const cache = new BlobCache(f.blobs);
|
|
1615
|
+
const tracker = await createTracker(config);
|
|
1616
|
+
const engine = new Engine({ db, api, cache, tracker, config, log });
|
|
1617
|
+
let timer = null;
|
|
1618
|
+
let quickTimer = null;
|
|
1619
|
+
let stopping = false;
|
|
1620
|
+
async function runTick(opts) {
|
|
1621
|
+
try {
|
|
1622
|
+
await engine.locked(() => engine.tick(opts));
|
|
1623
|
+
} catch (e) {
|
|
1624
|
+
log(`tick failed: ${e.stack || e.message}`);
|
|
1625
|
+
}
|
|
1626
|
+
refreshWatchers();
|
|
1627
|
+
scheduleGrace();
|
|
1628
|
+
}
|
|
1629
|
+
function schedule() {
|
|
1630
|
+
clearTimeout(timer);
|
|
1631
|
+
if (stopping) return;
|
|
1632
|
+
timer = setTimeout(async () => {
|
|
1633
|
+
await runTick();
|
|
1634
|
+
schedule();
|
|
1635
|
+
}, config.get("tickSeconds") * 1e3);
|
|
1636
|
+
}
|
|
1637
|
+
function quickPass(delay) {
|
|
1638
|
+
if (quickTimer || stopping) return;
|
|
1639
|
+
quickTimer = setTimeout(async () => {
|
|
1640
|
+
quickTimer = null;
|
|
1641
|
+
try {
|
|
1642
|
+
await engine.locked(() => engine.locateAll());
|
|
1643
|
+
} catch (e) {
|
|
1644
|
+
log(`locate failed: ${e.message}`);
|
|
1645
|
+
}
|
|
1646
|
+
refreshWatchers();
|
|
1647
|
+
scheduleGrace();
|
|
1648
|
+
}, delay);
|
|
1649
|
+
}
|
|
1650
|
+
function scheduleGrace() {
|
|
1651
|
+
if (engine.graceDue) quickPass(Math.max(50, engine.graceDue - Date.now() + 50));
|
|
1652
|
+
}
|
|
1653
|
+
const watchers = /* @__PURE__ */ new Map();
|
|
1654
|
+
function refreshWatchers() {
|
|
1655
|
+
const want = /* @__PURE__ */ new Map();
|
|
1656
|
+
for (const i of db.all()) {
|
|
1657
|
+
if (i.mode === "orphaned" || i.detached) continue;
|
|
1658
|
+
if (i.kind === "folder") want.set(i.path, true);
|
|
1659
|
+
else if (!i.folderInstanceId) want.set(path5.dirname(i.path), false);
|
|
1660
|
+
}
|
|
1661
|
+
for (const [dir, w] of watchers) {
|
|
1662
|
+
if (!want.has(dir)) {
|
|
1663
|
+
w.close();
|
|
1664
|
+
watchers.delete(dir);
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
for (const [dir, recursive] of want) {
|
|
1668
|
+
if (watchers.has(dir)) continue;
|
|
1669
|
+
try {
|
|
1670
|
+
const w = fs7.watch(dir, { recursive, persistent: false }, () => quickPass(250));
|
|
1671
|
+
w.on("error", () => {
|
|
1672
|
+
w.close();
|
|
1673
|
+
watchers.delete(dir);
|
|
1674
|
+
});
|
|
1675
|
+
watchers.set(dir, w);
|
|
1676
|
+
} catch {
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
const logins = /* @__PURE__ */ new Map();
|
|
1681
|
+
async function startLogin() {
|
|
1682
|
+
const state = crypto4.randomBytes(16).toString("hex");
|
|
1683
|
+
let settle;
|
|
1684
|
+
const done = new Promise((resolve, reject) => settle = { resolve, reject });
|
|
1685
|
+
const srv = http.createServer(async (req, res) => {
|
|
1686
|
+
const u = new URL(req.url, "http://127.0.0.1");
|
|
1687
|
+
if (u.pathname !== "/callback") return res.writeHead(404).end();
|
|
1688
|
+
if (u.searchParams.get("state") !== state) return res.writeHead(400).end("state mismatch");
|
|
1689
|
+
try {
|
|
1690
|
+
const user = await api.exchangeCliCode(u.searchParams.get("code"));
|
|
1691
|
+
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>');
|
|
1692
|
+
settle.resolve(user);
|
|
1693
|
+
} catch (e) {
|
|
1694
|
+
res.writeHead(400).end(e.message);
|
|
1695
|
+
settle.reject(e);
|
|
1696
|
+
} finally {
|
|
1697
|
+
setTimeout(() => srv.close(), 100);
|
|
1698
|
+
}
|
|
1699
|
+
});
|
|
1700
|
+
await new Promise((r) => srv.listen(0, "127.0.0.1", r));
|
|
1701
|
+
const port = srv.address().port;
|
|
1702
|
+
const timeout = setTimeout(() => {
|
|
1703
|
+
srv.close();
|
|
1704
|
+
settle.reject(new UserError("login timed out"));
|
|
1705
|
+
}, 5 * 60 * 1e3);
|
|
1706
|
+
done.finally(() => clearTimeout(timeout)).catch(() => {
|
|
1707
|
+
});
|
|
1708
|
+
logins.set(state, done);
|
|
1709
|
+
return { state, url: `${config.consoleUrl.replace(/\/$/, "")}/cli-login?port=${port}&state=${state}` };
|
|
1710
|
+
}
|
|
1711
|
+
const L = (fn) => (params) => engine.locked(() => fn(params));
|
|
1712
|
+
const soon = () => setTimeout(() => runTick(), 10);
|
|
1713
|
+
const handlers = {
|
|
1714
|
+
ping: () => ({ pid: process.pid, version: VERSION, home, tracker: tracker.name, server: config.get("server") }),
|
|
1715
|
+
async status(p) {
|
|
1716
|
+
const creds = await credentials.load();
|
|
1717
|
+
return { user: creds?.user ?? null, server: config.get("server"), offline: engine.offline, lastTick: engine.lastTick, instances: await L((x) => engine.status(x))(p) };
|
|
1718
|
+
},
|
|
1719
|
+
sync: async () => {
|
|
1720
|
+
await runTick();
|
|
1721
|
+
return { offline: engine.offline };
|
|
1722
|
+
},
|
|
1723
|
+
async "login.password"({ email, password, signup }) {
|
|
1724
|
+
const user = await api.passwordLogin({ email, password, signup });
|
|
1725
|
+
soon();
|
|
1726
|
+
return user;
|
|
1727
|
+
},
|
|
1728
|
+
"login.start": () => startLogin(),
|
|
1729
|
+
async "login.wait"({ state }) {
|
|
1730
|
+
const p = logins.get(state);
|
|
1731
|
+
if (!p) throw new UserError("no login in progress");
|
|
1732
|
+
try {
|
|
1733
|
+
const user = await p;
|
|
1734
|
+
soon();
|
|
1735
|
+
return user;
|
|
1736
|
+
} finally {
|
|
1737
|
+
logins.delete(state);
|
|
1738
|
+
}
|
|
1739
|
+
},
|
|
1740
|
+
logout: async () => api.logout(),
|
|
1741
|
+
whoami: () => api.call("GET", "/me"),
|
|
1742
|
+
manage: L((p) => engine.manage(p)),
|
|
1743
|
+
add: L((p) => engine.add(p)),
|
|
1744
|
+
pull: L((p) => engine.pull(p)),
|
|
1745
|
+
edit: L((p) => engine.edit(p)),
|
|
1746
|
+
unmanage: L((p) => engine.unmanage(p)),
|
|
1747
|
+
plan: L((p) => engine.plan(p)),
|
|
1748
|
+
resolve: L((p) => engine.resolve(p)),
|
|
1749
|
+
diff: L((p) => engine.diff(p)),
|
|
1750
|
+
locate: L((p) => engine.locate(p)),
|
|
1751
|
+
async history({ ref }) {
|
|
1752
|
+
return api.call("GET", `/artifacts/${await engine.resolveArtifact(ref)}/history`);
|
|
1753
|
+
},
|
|
1754
|
+
async rollback({ ref, toVersion }) {
|
|
1755
|
+
const r = await api.call("POST", `/artifacts/${await engine.resolveArtifact(ref)}/rollback`, { toVersion });
|
|
1756
|
+
soon();
|
|
1757
|
+
return r;
|
|
1758
|
+
},
|
|
1759
|
+
async delete({ ref }) {
|
|
1760
|
+
const r = await api.call("POST", `/artifacts/${await engine.resolveArtifact(ref)}/delete`);
|
|
1761
|
+
soon();
|
|
1762
|
+
return r;
|
|
1763
|
+
},
|
|
1764
|
+
async share({ ref, op }) {
|
|
1765
|
+
const id = await engine.resolveArtifact(ref);
|
|
1766
|
+
return op ? api.call("POST", `/artifacts/${id}/share`, op) : api.call("GET", `/artifacts/${id}/sharing`);
|
|
1767
|
+
},
|
|
1768
|
+
async info({ ref }) {
|
|
1769
|
+
return api.call("GET", `/artifacts/${await engine.resolveArtifact(ref)}`);
|
|
1770
|
+
},
|
|
1771
|
+
"repo.create": ({ name }) => api.call("POST", "/repos", { name }),
|
|
1772
|
+
"repo.list": () => api.call("GET", "/repos"),
|
|
1773
|
+
"repo.show": async ({ ref }) => api.call("GET", `/repos/${(await engine.findRepo(ref)).repoId}`),
|
|
1774
|
+
"repo.delete": async ({ ref }) => {
|
|
1775
|
+
const r = await api.call("DELETE", `/repos/${(await engine.findRepo(ref)).repoId}`);
|
|
1776
|
+
soon();
|
|
1777
|
+
return r;
|
|
1778
|
+
},
|
|
1779
|
+
"repo.transfer": async ({ ref, to }) => api.call("POST", `/repos/${(await engine.findRepo(ref)).repoId}/transfer`, { to }),
|
|
1780
|
+
"repo.eol": async ({ ref, glob, mode }) => {
|
|
1781
|
+
const repo = await engine.findRepo(ref);
|
|
1782
|
+
const rules = { ...repo.eolDefaults };
|
|
1783
|
+
if (mode === "unset") delete rules[glob];
|
|
1784
|
+
else rules[glob] = mode;
|
|
1785
|
+
return api.call("PATCH", `/repos/${repo.repoId}`, { eolDefaults: rules });
|
|
1786
|
+
},
|
|
1787
|
+
"config.get": ({ key }) => key ? { [key]: config.get(key) } : config.values,
|
|
1788
|
+
"config.set": ({ key, value }) => {
|
|
1789
|
+
config.set(key, value);
|
|
1790
|
+
if (key === "tickSeconds") schedule();
|
|
1791
|
+
return { [key]: config.get(key) };
|
|
1792
|
+
},
|
|
1793
|
+
log: ({ lines = 50 }) => recent.slice(-lines),
|
|
1794
|
+
shutdown: () => {
|
|
1795
|
+
setTimeout(() => shutdown(0), 50);
|
|
1796
|
+
return { stopping: true };
|
|
1797
|
+
}
|
|
1798
|
+
};
|
|
1799
|
+
async function handle(method, params) {
|
|
1800
|
+
const h = handlers[method];
|
|
1801
|
+
if (!h) throw new UserError(`unknown command ${method}`);
|
|
1802
|
+
try {
|
|
1803
|
+
return await h(params);
|
|
1804
|
+
} catch (e) {
|
|
1805
|
+
if (e instanceof ApiError || e instanceof UserError) throw e;
|
|
1806
|
+
if (e.name === "OfflineError") throw e;
|
|
1807
|
+
log(`${method} failed: ${e.stack || e.message}`);
|
|
1808
|
+
throw e;
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
const server = await listen(ipcEndpoint(home), handle);
|
|
1812
|
+
fs7.writeFileSync(f.pid, String(process.pid));
|
|
1813
|
+
log(`tetherd ${VERSION} started (pid ${process.pid}, tracker ${tracker.name}, server ${config.get("server")})`);
|
|
1814
|
+
function shutdown(code) {
|
|
1815
|
+
if (stopping) return;
|
|
1816
|
+
stopping = true;
|
|
1817
|
+
clearTimeout(timer);
|
|
1818
|
+
clearTimeout(quickTimer);
|
|
1819
|
+
for (const w of watchers.values()) w.close();
|
|
1820
|
+
server.close();
|
|
1821
|
+
engine.locked(() => db.close()).finally(() => {
|
|
1822
|
+
fs7.rmSync(f.pid, { force: true });
|
|
1823
|
+
log("tetherd stopped");
|
|
1824
|
+
logStream.end(() => process.exit(code));
|
|
1825
|
+
});
|
|
1826
|
+
}
|
|
1827
|
+
process.on("SIGINT", () => shutdown(0));
|
|
1828
|
+
process.on("SIGTERM", () => shutdown(0));
|
|
1829
|
+
await runTick();
|
|
1830
|
+
schedule();
|
|
1831
|
+
}
|
|
1832
|
+
export {
|
|
1833
|
+
VERSION,
|
|
1834
|
+
runDaemon
|
|
1835
|
+
};
|