parawatch 0.2.1 → 0.3.2
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 +38 -5
- package/dist/bin/cli.js +69 -22
- package/dist/core/doctor.js +33 -10
- package/dist/core/follow.js +36 -49
- package/dist/core/format.js +28 -2
- package/dist/core/github.js +19 -2
- package/dist/core/integrate.js +13 -9
- package/dist/core/ops.js +170 -48
- package/dist/core/runs.js +109 -15
- package/dist/core/snapshot.js +5 -5
- package/dist/core/state.js +40 -9
- package/dist/core/store.js +204 -24
- package/dist/core/sync.js +317 -0
- package/dist/core/syncthing.js +121 -0
- package/dist/core/types.js +7 -0
- package/package.json +1 -1
- package/skills/parawatch/SKILL.md +10 -3
package/dist/core/store.js
CHANGED
|
@@ -2,8 +2,8 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { UserError } from "./errors.js";
|
|
5
|
-
import {
|
|
6
|
-
/** Directory holding all parawatch state for this user. */
|
|
5
|
+
import { STORE_FORMAT } from "./types.js";
|
|
6
|
+
/** Directory holding all parawatch state for this user on this machine. */
|
|
7
7
|
export function homeDir() {
|
|
8
8
|
const explicit = process.env.PARAWATCH_HOME;
|
|
9
9
|
if (explicit)
|
|
@@ -11,20 +11,109 @@ export function homeDir() {
|
|
|
11
11
|
const xdg = process.env.XDG_STATE_HOME || path.join(os.homedir(), ".local", "state");
|
|
12
12
|
return path.join(xdg, "parawatch");
|
|
13
13
|
}
|
|
14
|
-
export function
|
|
15
|
-
return path.join(homeDir(), "
|
|
14
|
+
export function configPath() {
|
|
15
|
+
return path.join(homeDir(), "config.json");
|
|
16
|
+
}
|
|
17
|
+
export function readConfig() {
|
|
18
|
+
try {
|
|
19
|
+
const raw = JSON.parse(fs.readFileSync(configPath(), "utf8"));
|
|
20
|
+
return raw && typeof raw === "object" ? raw : {};
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
16
25
|
}
|
|
17
|
-
function
|
|
26
|
+
export function writeConfig(c) {
|
|
18
27
|
fs.mkdirSync(homeDir(), { recursive: true });
|
|
28
|
+
const tmp = `${configPath()}.${process.pid}.tmp`;
|
|
29
|
+
fs.writeFileSync(tmp, JSON.stringify(c, null, 2) + "\n");
|
|
30
|
+
fs.renameSync(tmp, configPath());
|
|
31
|
+
}
|
|
32
|
+
export function expandHome(p) {
|
|
33
|
+
return p === "~" ? os.homedir() : p.startsWith("~/") || p.startsWith("~\\") ? path.join(os.homedir(), p.slice(2)) : p;
|
|
34
|
+
}
|
|
35
|
+
export const DEVICE_NAME = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
36
|
+
/** A device name made from the host name: lower case, letters, digits and dashes. */
|
|
37
|
+
export function defaultDevice(host = os.hostname()) {
|
|
38
|
+
const short = host.split(".")[0] ?? "";
|
|
39
|
+
const clean = short.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32);
|
|
40
|
+
return DEVICE_NAME.test(clean) ? clean : "this";
|
|
41
|
+
}
|
|
42
|
+
/** The name this machine writes under. */
|
|
43
|
+
export function deviceName() {
|
|
44
|
+
const env = process.env.PARAWATCH_DEVICE?.trim();
|
|
45
|
+
if (env)
|
|
46
|
+
return env;
|
|
47
|
+
const c = readConfig().device;
|
|
48
|
+
return typeof c === "string" && DEVICE_NAME.test(c) ? c : defaultDevice();
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The shared folder this machine writes its own log into, when set. Something outside parawatch
|
|
52
|
+
* (Syncthing, a cloud folder) carries that folder between machines.
|
|
53
|
+
*/
|
|
54
|
+
export function sharedDir() {
|
|
55
|
+
const env = process.env.PARAWATCH_SHARED?.trim();
|
|
56
|
+
if (env)
|
|
57
|
+
return path.resolve(expandHome(env));
|
|
58
|
+
const d = readConfig().sync?.dir;
|
|
59
|
+
return typeof d === "string" && d.trim() ? path.resolve(expandHome(d.trim())) : undefined;
|
|
60
|
+
}
|
|
61
|
+
// ------------------------------------------------------------------ files
|
|
62
|
+
/** Where the event logs live: the home alone, or `events/` in the shared folder. */
|
|
63
|
+
export function eventsDir() {
|
|
64
|
+
const shared = sharedDir();
|
|
65
|
+
return shared ? path.join(shared, "events") : homeDir();
|
|
66
|
+
}
|
|
67
|
+
/** The one file this machine appends to. */
|
|
68
|
+
export function eventsPath() {
|
|
69
|
+
const shared = sharedDir();
|
|
70
|
+
return shared ? path.join(shared, "events", `${deviceName()}.jsonl`) : path.join(homeDir(), "events.jsonl");
|
|
71
|
+
}
|
|
72
|
+
/** A device log: `<device>.jsonl`. Sync tools' temp and conflict copies never match. */
|
|
73
|
+
const DEVICE_FILE = /^([a-z0-9][a-z0-9-]{0,31})\.jsonl$/;
|
|
74
|
+
/** Every log to read: one per device in a shared folder, or the single local file. */
|
|
75
|
+
export function eventFiles() {
|
|
76
|
+
const shared = sharedDir();
|
|
77
|
+
if (!shared)
|
|
78
|
+
return [{ device: deviceName(), file: eventsPath() }];
|
|
79
|
+
const dir = path.join(shared, "events");
|
|
80
|
+
const me = deviceName();
|
|
81
|
+
const out = [];
|
|
82
|
+
let names = [];
|
|
83
|
+
try {
|
|
84
|
+
names = fs.readdirSync(dir);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// nothing shared yet
|
|
88
|
+
}
|
|
89
|
+
for (const n of names.sort()) {
|
|
90
|
+
const m = DEVICE_FILE.exec(n);
|
|
91
|
+
if (m)
|
|
92
|
+
out.push({ device: m[1], file: path.join(dir, n) });
|
|
93
|
+
}
|
|
94
|
+
if (!out.some((f) => f.device === me))
|
|
95
|
+
out.push({ device: me, file: eventsPath() });
|
|
96
|
+
return out.sort((a, b) => a.device.localeCompare(b.device));
|
|
97
|
+
}
|
|
98
|
+
/** Where pasted attachments go: inside the shared folder when there is one, so every machine has them. */
|
|
99
|
+
export function attachmentsDir() {
|
|
100
|
+
return path.join(sharedDir() ?? homeDir(), "attachments");
|
|
101
|
+
}
|
|
102
|
+
function ensureDir(dir) {
|
|
103
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
19
104
|
}
|
|
105
|
+
// ------------------------------------------------------------------ lock
|
|
20
106
|
const LOCK_STALE_MS = 30_000;
|
|
21
107
|
const LOCK_WAIT_MS = 10_000;
|
|
22
108
|
function sleepSync(ms) {
|
|
23
109
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
24
110
|
}
|
|
25
|
-
/**
|
|
111
|
+
/**
|
|
112
|
+
* Run fn while holding an exclusive lock file. Several agents on this machine may write at once.
|
|
113
|
+
* The lock is local: each machine only ever writes its own log, so machines never share one.
|
|
114
|
+
*/
|
|
26
115
|
export function withLock(fn) {
|
|
27
|
-
|
|
116
|
+
ensureDir(homeDir());
|
|
28
117
|
const lock = path.join(homeDir(), "lock");
|
|
29
118
|
const started = Date.now();
|
|
30
119
|
let fd;
|
|
@@ -60,28 +149,64 @@ export function withLock(fn) {
|
|
|
60
149
|
fs.rmSync(lock, { force: true });
|
|
61
150
|
}
|
|
62
151
|
}
|
|
63
|
-
|
|
64
|
-
|
|
152
|
+
// ------------------------------------------------------------------ reading
|
|
153
|
+
function readFileEvents(f) {
|
|
65
154
|
let text;
|
|
66
155
|
try {
|
|
67
|
-
text = fs.readFileSync(
|
|
156
|
+
text = fs.readFileSync(f.file, "utf8");
|
|
68
157
|
}
|
|
69
158
|
catch (err) {
|
|
70
159
|
if (err.code === "ENOENT")
|
|
71
160
|
return [];
|
|
72
161
|
throw err;
|
|
73
162
|
}
|
|
74
|
-
return parseEventLines(text);
|
|
163
|
+
return parseEventLines(text, f.device);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Every event, from every machine, in time order. Each machine's own order is kept as written;
|
|
167
|
+
* between machines the clock decides, then the device name, so every machine sees the same order.
|
|
168
|
+
*/
|
|
169
|
+
export function readEvents() {
|
|
170
|
+
const lists = eventFiles().map(readFileEvents).filter((l) => l.length);
|
|
171
|
+
if (lists.length <= 1)
|
|
172
|
+
return lists[0] ?? [];
|
|
173
|
+
return mergeLogs(lists);
|
|
75
174
|
}
|
|
76
|
-
|
|
175
|
+
/** Merge per-device logs, each already in its own order, into one. */
|
|
176
|
+
export function mergeLogs(lists) {
|
|
177
|
+
const at = lists.map(() => 0);
|
|
178
|
+
const out = [];
|
|
179
|
+
for (;;) {
|
|
180
|
+
let pick = -1;
|
|
181
|
+
for (let i = 0; i < lists.length; i++) {
|
|
182
|
+
const ev = lists[i][at[i]];
|
|
183
|
+
if (!ev)
|
|
184
|
+
continue;
|
|
185
|
+
if (pick < 0) {
|
|
186
|
+
pick = i;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const best = lists[pick][at[pick]];
|
|
190
|
+
if (ev.ts < best.ts || (ev.ts === best.ts && (ev.dev ?? "") < (best.dev ?? "")))
|
|
191
|
+
pick = i;
|
|
192
|
+
}
|
|
193
|
+
if (pick < 0)
|
|
194
|
+
return out;
|
|
195
|
+
out.push(lists[pick][at[pick]++]);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
export function parseEventLines(text, device) {
|
|
77
199
|
const out = [];
|
|
78
200
|
for (const line of text.split("\n")) {
|
|
79
201
|
if (!line.trim())
|
|
80
202
|
continue;
|
|
81
203
|
try {
|
|
82
204
|
const ev = JSON.parse(line);
|
|
83
|
-
if (typeof ev.seq
|
|
84
|
-
|
|
205
|
+
if (typeof ev.seq !== "number" || typeof ev.type !== "string")
|
|
206
|
+
continue;
|
|
207
|
+
if (device)
|
|
208
|
+
ev.dev = device;
|
|
209
|
+
out.push(ev);
|
|
85
210
|
}
|
|
86
211
|
catch {
|
|
87
212
|
// a torn or hand-edited line; keep going
|
|
@@ -89,6 +214,7 @@ export function parseEventLines(text) {
|
|
|
89
214
|
}
|
|
90
215
|
return out;
|
|
91
216
|
}
|
|
217
|
+
// ------------------------------------------------------------------ writing
|
|
92
218
|
/** Append one event. `build` sees the current events so it can pick ids under the lock. */
|
|
93
219
|
export function appendEvent(build) {
|
|
94
220
|
return withLock(() => appendLocked((events) => [build(events)])[0]);
|
|
@@ -101,40 +227,94 @@ export function storeFormat(events) {
|
|
|
101
227
|
v = e.v;
|
|
102
228
|
return v;
|
|
103
229
|
}
|
|
230
|
+
/**
|
|
231
|
+
* The format this machine writes. A log kept on one machine stays at format 1, which every
|
|
232
|
+
* earlier parawatch reads and writes. A shared folder writes format 2.
|
|
233
|
+
*/
|
|
234
|
+
export function writeFormat() {
|
|
235
|
+
return sharedDir() ? 2 : 1;
|
|
236
|
+
}
|
|
104
237
|
/** Append events while already holding the lock (inside `withLock`). */
|
|
105
238
|
export function appendLocked(build) {
|
|
106
239
|
const events = readEvents();
|
|
107
240
|
const format = storeFormat(events);
|
|
108
|
-
if (format >
|
|
109
|
-
throw new UserError(`${
|
|
241
|
+
if (format > STORE_FORMAT) {
|
|
242
|
+
throw new UserError(`${eventsDir()} was written by a newer parawatch (format ${format}; this one knows ${STORE_FORMAT}). Update this copy before it writes: npm i -g parawatch@latest, or update the app.`);
|
|
110
243
|
}
|
|
111
|
-
|
|
244
|
+
const me = deviceName();
|
|
245
|
+
const own = sharedDir() ? events.filter((e) => e.dev === me) : events;
|
|
246
|
+
let seq = 0;
|
|
247
|
+
for (const e of own)
|
|
248
|
+
if (e.seq > seq)
|
|
249
|
+
seq = e.seq;
|
|
112
250
|
const ts = new Date().toISOString();
|
|
113
|
-
const
|
|
114
|
-
|
|
251
|
+
const v = writeFormat();
|
|
252
|
+
const out = build(events).map((body) => ({ v, seq: ++seq, ts, ...body }));
|
|
253
|
+
if (out.length) {
|
|
254
|
+
ensureDir(path.dirname(eventsPath()));
|
|
115
255
|
fs.appendFileSync(eventsPath(), out.map((e) => JSON.stringify(e) + "\n").join(""));
|
|
256
|
+
for (const e of out)
|
|
257
|
+
e.dev = me;
|
|
258
|
+
}
|
|
116
259
|
return out;
|
|
117
260
|
}
|
|
261
|
+
/** Whether an event comes after a position. */
|
|
262
|
+
export function isAfter(ev, pos) {
|
|
263
|
+
return ev.seq > (pos[ev.dev ?? deviceName()] ?? 0);
|
|
264
|
+
}
|
|
265
|
+
/** A position moved past these events. */
|
|
266
|
+
export function advance(pos, events) {
|
|
267
|
+
const next = { ...pos };
|
|
268
|
+
const me = deviceName();
|
|
269
|
+
for (const e of events) {
|
|
270
|
+
const d = e.dev ?? me;
|
|
271
|
+
if (e.seq > (next[d] ?? 0))
|
|
272
|
+
next[d] = e.seq;
|
|
273
|
+
}
|
|
274
|
+
return next;
|
|
275
|
+
}
|
|
276
|
+
/** The position at the end of these events. */
|
|
277
|
+
export function endOf(events) {
|
|
278
|
+
return advance({}, events);
|
|
279
|
+
}
|
|
280
|
+
/** A number given on the command line: the same seq in every device's log. */
|
|
281
|
+
export function everywhere(seq, events) {
|
|
282
|
+
const pos = { [deviceName()]: seq };
|
|
283
|
+
for (const e of events)
|
|
284
|
+
if (e.dev)
|
|
285
|
+
pos[e.dev] = seq;
|
|
286
|
+
return pos;
|
|
287
|
+
}
|
|
118
288
|
const CURSOR_NAME = /^[A-Za-z0-9._-]+$/;
|
|
119
289
|
function cursorPath(name) {
|
|
120
290
|
if (!CURSOR_NAME.test(name))
|
|
121
291
|
throw new Error(`parawatch: bad consumer name "${name}"`);
|
|
122
292
|
return path.join(homeDir(), "cursors", `${name}.json`);
|
|
123
293
|
}
|
|
294
|
+
/**
|
|
295
|
+
* A cursor file holds `seq` (this machine's log, as every version wrote it) and `pos` (every
|
|
296
|
+
* device). Cursors stay on the machine: two machines never share a reader.
|
|
297
|
+
*/
|
|
124
298
|
export function readCursor(name) {
|
|
125
299
|
try {
|
|
126
300
|
const data = JSON.parse(fs.readFileSync(cursorPath(name), "utf8"));
|
|
127
|
-
|
|
301
|
+
const me = deviceName();
|
|
302
|
+
const pos = data.pos && typeof data.pos === "object" && sharedDir() ? { ...data.pos } : {};
|
|
303
|
+
if (typeof data.seq === "number" && pos[me] === undefined)
|
|
304
|
+
pos[me] = data.seq;
|
|
305
|
+
return pos;
|
|
128
306
|
}
|
|
129
307
|
catch {
|
|
130
|
-
return
|
|
308
|
+
return {};
|
|
131
309
|
}
|
|
132
310
|
}
|
|
133
|
-
export function writeCursor(name,
|
|
311
|
+
export function writeCursor(name, pos) {
|
|
134
312
|
const file = cursorPath(name);
|
|
135
|
-
|
|
313
|
+
ensureDir(path.dirname(file));
|
|
136
314
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
137
|
-
|
|
315
|
+
const seq = pos[deviceName()] ?? 0;
|
|
316
|
+
const body = sharedDir() ? { seq, pos, at: new Date().toISOString() } : { seq, at: new Date().toISOString() };
|
|
317
|
+
fs.writeFileSync(tmp, JSON.stringify(body) + "\n");
|
|
138
318
|
fs.renameSync(tmp, file);
|
|
139
319
|
}
|
|
140
320
|
//# sourceMappingURL=store.js.map
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sharing the log between machines. Parawatch only reads and writes files in a shared folder:
|
|
3
|
+
* each machine appends to `events/<device>.jsonl` and reads them all. Moving that folder between
|
|
4
|
+
* machines is the job of a sync tool, and a sync adapter only reports how that tool is doing.
|
|
5
|
+
*
|
|
6
|
+
* Layout of the shared folder:
|
|
7
|
+
* events/<device>.jsonl each machine's own log; only that machine writes it
|
|
8
|
+
* devices/<device>.json each machine's paths, version and last sign of life
|
|
9
|
+
* listeners/ waits running on any machine
|
|
10
|
+
* attachments/ pasted images, so every machine can open them
|
|
11
|
+
*/
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { UserError } from "./errors.js";
|
|
16
|
+
import { DEVICE_NAME, configPath, deviceName, eventFiles, expandHome, homeDir, parseEventLines, readConfig, sharedDir, withLock, writeConfig, } from "./store.js";
|
|
17
|
+
import { syncthingAdapter } from "./syncthing.js";
|
|
18
|
+
function devicesDir(shared) {
|
|
19
|
+
return path.join(shared, "devices");
|
|
20
|
+
}
|
|
21
|
+
function hostPrint() {
|
|
22
|
+
return `${os.hostname()}:${os.homedir()}`;
|
|
23
|
+
}
|
|
24
|
+
export function readDevice(name, shared = sharedDir()) {
|
|
25
|
+
if (!shared)
|
|
26
|
+
return undefined;
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(fs.readFileSync(path.join(devicesDir(shared), `${name}.json`), "utf8"));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function readDevices(shared = sharedDir()) {
|
|
35
|
+
if (!shared)
|
|
36
|
+
return [];
|
|
37
|
+
let names = [];
|
|
38
|
+
try {
|
|
39
|
+
names = fs.readdirSync(devicesDir(shared)).filter((n) => /^[a-z0-9][a-z0-9-]*\.json$/.test(n));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
const out = [];
|
|
45
|
+
for (const n of names) {
|
|
46
|
+
const d = readDevice(n.slice(0, -5), shared);
|
|
47
|
+
if (d)
|
|
48
|
+
out.push(d);
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
const DEVICE_REFRESH_MS = 10 * 60_000;
|
|
53
|
+
/** Write this machine's device file when something changed or it is getting old. */
|
|
54
|
+
export function touchDevice(version, opts = {}) {
|
|
55
|
+
const shared = sharedDir();
|
|
56
|
+
if (!shared)
|
|
57
|
+
return undefined;
|
|
58
|
+
const name = deviceName();
|
|
59
|
+
const before = readDevice(name, shared);
|
|
60
|
+
const me = {
|
|
61
|
+
name,
|
|
62
|
+
platform: process.platform,
|
|
63
|
+
host: hostPrint(),
|
|
64
|
+
home: os.homedir(),
|
|
65
|
+
shared,
|
|
66
|
+
roots: { ...(before?.host === hostPrint() ? before.roots : {}), ...(opts.roots ?? {}) },
|
|
67
|
+
version: version ?? before?.version,
|
|
68
|
+
at: new Date().toISOString(),
|
|
69
|
+
};
|
|
70
|
+
if (!Object.keys(me.roots).length)
|
|
71
|
+
delete me.roots;
|
|
72
|
+
const same = before &&
|
|
73
|
+
before.host === me.host &&
|
|
74
|
+
before.shared === me.shared &&
|
|
75
|
+
before.version === me.version &&
|
|
76
|
+
JSON.stringify(before.roots ?? {}) === JSON.stringify(me.roots ?? {}) &&
|
|
77
|
+
Date.now() - Date.parse(before.at) < DEVICE_REFRESH_MS;
|
|
78
|
+
if (same && !opts.force)
|
|
79
|
+
return before;
|
|
80
|
+
try {
|
|
81
|
+
const dir = devicesDir(shared);
|
|
82
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
83
|
+
const file = path.join(dir, `${name}.json`);
|
|
84
|
+
const tmp = path.join(dir, `.${name}.${process.pid}.tmp`);
|
|
85
|
+
fs.writeFileSync(tmp, JSON.stringify(me, null, 2) + "\n");
|
|
86
|
+
fs.renameSync(tmp, file);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// the shared folder may be read only for a moment; try again next time
|
|
90
|
+
}
|
|
91
|
+
return me;
|
|
92
|
+
}
|
|
93
|
+
// ------------------------------------------------------------------ paths
|
|
94
|
+
function splitPath(p) {
|
|
95
|
+
return p.split(/[\\/]+/).filter(Boolean);
|
|
96
|
+
}
|
|
97
|
+
function startsWithPath(p, root, caseless) {
|
|
98
|
+
const a = splitPath(p);
|
|
99
|
+
const b = splitPath(root);
|
|
100
|
+
if (!b.length || a.length < b.length)
|
|
101
|
+
return undefined;
|
|
102
|
+
for (let i = 0; i < b.length; i++) {
|
|
103
|
+
const x = caseless ? a[i].toLowerCase() : a[i];
|
|
104
|
+
const y = caseless ? b[i].toLowerCase() : b[i];
|
|
105
|
+
if (x !== y)
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
return a.slice(b.length);
|
|
109
|
+
}
|
|
110
|
+
function joinOn(platform, root, rest) {
|
|
111
|
+
const p = platform === "win32" ? path.win32 : path.posix;
|
|
112
|
+
return rest.length ? p.join(root, ...rest) : root;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* A path from another machine, as the same place on this one: under that machine's shared
|
|
116
|
+
* folder, a named root, or its home. The longest match wins; anything else is left as it is.
|
|
117
|
+
*/
|
|
118
|
+
export function mapPath(p, from, me) {
|
|
119
|
+
if (!from || !me || from.name === me.name || /^[a-z]+:\/\//i.test(p))
|
|
120
|
+
return p;
|
|
121
|
+
const caseless = from.platform === "win32";
|
|
122
|
+
const pairs = [[from.shared, me.shared]];
|
|
123
|
+
for (const [k, v] of Object.entries(from.roots ?? {})) {
|
|
124
|
+
const mine = me.roots?.[k];
|
|
125
|
+
if (mine)
|
|
126
|
+
pairs.push([v, mine]);
|
|
127
|
+
}
|
|
128
|
+
pairs.push([from.home, me.home]);
|
|
129
|
+
let best;
|
|
130
|
+
for (const [theirs, mine] of pairs) {
|
|
131
|
+
const rest = startsWithPath(p, theirs, caseless);
|
|
132
|
+
const len = splitPath(theirs).length;
|
|
133
|
+
if (rest && (!best || len > best.len))
|
|
134
|
+
best = { rest, root: mine, len };
|
|
135
|
+
}
|
|
136
|
+
return best ? joinOn(me.platform, best.root, best.rest) : p;
|
|
137
|
+
}
|
|
138
|
+
/** The path map for reading the shared log on this machine, or undefined when nothing is shared. */
|
|
139
|
+
export function pathMap() {
|
|
140
|
+
const shared = sharedDir();
|
|
141
|
+
if (!shared)
|
|
142
|
+
return undefined;
|
|
143
|
+
const devices = new Map(readDevices(shared).map((d) => [d.name, d]));
|
|
144
|
+
const name = deviceName();
|
|
145
|
+
const me = devices.get(name) ?? { name, platform: process.platform, host: hostPrint(), home: os.homedir(), shared, at: "" };
|
|
146
|
+
if (devices.size <= 1)
|
|
147
|
+
return undefined;
|
|
148
|
+
return (p, from) => (from ? mapPath(p, devices.get(from), me) : p);
|
|
149
|
+
}
|
|
150
|
+
const CONFLICT = /\.sync-conflict-|conflicted copy|\(conflict\)|\.conflict\d*$/i;
|
|
151
|
+
/** Conflict copies anywhere in the shared folder (sync tools name them in their own ways). */
|
|
152
|
+
export function findConflicts(dir, depth = 3) {
|
|
153
|
+
const out = [];
|
|
154
|
+
const walk = (d, left) => {
|
|
155
|
+
let names = [];
|
|
156
|
+
try {
|
|
157
|
+
names = fs.readdirSync(d, { withFileTypes: true });
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
for (const n of names) {
|
|
163
|
+
const p = path.join(d, n.name);
|
|
164
|
+
if (CONFLICT.test(n.name))
|
|
165
|
+
out.push(p);
|
|
166
|
+
else if (n.isDirectory() && left > 0 && !n.name.startsWith(".st"))
|
|
167
|
+
walk(p, left - 1);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
walk(dir, depth);
|
|
171
|
+
return out.sort();
|
|
172
|
+
}
|
|
173
|
+
/** Other machines as the shared folder shows them: their device files and their logs. */
|
|
174
|
+
export function folderPeers(dir, me) {
|
|
175
|
+
const peers = new Map();
|
|
176
|
+
for (const d of readDevices(dir))
|
|
177
|
+
if (d.name !== me)
|
|
178
|
+
peers.set(d.name, { name: d.name, lastSeen: d.at, version: d.version });
|
|
179
|
+
try {
|
|
180
|
+
for (const n of fs.readdirSync(path.join(dir, "events"))) {
|
|
181
|
+
const m = /^([a-z0-9][a-z0-9-]*)\.jsonl$/.exec(n);
|
|
182
|
+
if (!m || m[1] === me)
|
|
183
|
+
continue;
|
|
184
|
+
const at = fs.statSync(path.join(dir, "events", n)).mtime.toISOString();
|
|
185
|
+
const p = peers.get(m[1]) ?? { name: m[1] };
|
|
186
|
+
if (!p.lastSeen || at > p.lastSeen)
|
|
187
|
+
p.lastSeen = at;
|
|
188
|
+
peers.set(m[1], p);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
// no logs yet
|
|
193
|
+
}
|
|
194
|
+
return [...peers.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
195
|
+
}
|
|
196
|
+
/** Any folder something else keeps in step: a cloud drive, a network share, a git checkout. */
|
|
197
|
+
export const folderAdapter = {
|
|
198
|
+
name: "folder",
|
|
199
|
+
about: "a folder kept in step by something else (a cloud drive, a network share)",
|
|
200
|
+
async status(ctx) {
|
|
201
|
+
const peers = folderPeers(ctx.dir, ctx.device);
|
|
202
|
+
const newest = peers.map((p) => p.lastSeen ?? "").sort().pop() || undefined;
|
|
203
|
+
return { adapter: "folder", dir: ctx.dir, device: ctx.device, ok: fs.existsSync(ctx.dir), lastSync: newest, peers, conflicts: findConflicts(ctx.dir) };
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
let registry;
|
|
207
|
+
// built on first use: the Syncthing module imports helpers from this one
|
|
208
|
+
function adapters() {
|
|
209
|
+
registry ??= new Map([
|
|
210
|
+
[folderAdapter.name, folderAdapter],
|
|
211
|
+
[syncthingAdapter.name, syncthingAdapter],
|
|
212
|
+
]);
|
|
213
|
+
return registry;
|
|
214
|
+
}
|
|
215
|
+
/** Add an adapter (another sync tool). The name is what `sync.adapter` in config.json picks. */
|
|
216
|
+
export function registerAdapter(a) {
|
|
217
|
+
adapters().set(a.name, a);
|
|
218
|
+
}
|
|
219
|
+
export function adapterNames() {
|
|
220
|
+
return [...adapters().keys()];
|
|
221
|
+
}
|
|
222
|
+
export function syncAdapter(name) {
|
|
223
|
+
return adapters().get(name ?? "folder") ?? folderAdapter;
|
|
224
|
+
}
|
|
225
|
+
/** How sharing is doing, or undefined when this machine keeps its log to itself. */
|
|
226
|
+
export async function syncStatus(opts = {}) {
|
|
227
|
+
const dir = sharedDir();
|
|
228
|
+
if (!dir)
|
|
229
|
+
return undefined;
|
|
230
|
+
const cfg = readConfig().sync ?? {};
|
|
231
|
+
const a = syncAdapter(typeof cfg.adapter === "string" ? cfg.adapter : undefined);
|
|
232
|
+
const own = cfg[a.name];
|
|
233
|
+
const ctx = { dir, device: deviceName(), config: own && typeof own === "object" ? own : {}, fetch: opts.fetch, timeoutMs: opts.timeoutMs };
|
|
234
|
+
try {
|
|
235
|
+
return await a.status(ctx);
|
|
236
|
+
}
|
|
237
|
+
catch (err) {
|
|
238
|
+
return { adapter: a.name, dir, device: ctx.device, ok: false, peers: folderPeers(dir, ctx.device), conflicts: findConflicts(dir), error: err.message };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
/** The line left in the old single-machine log, so an older parawatch refuses to write there. */
|
|
242
|
+
export function movedMarker(to, seq) {
|
|
243
|
+
return JSON.stringify({ v: 2, seq, ts: new Date().toISOString(), type: "store.moved", to }) + "\n";
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Start writing to a shared folder. This machine's log moves there as `events/<device>.jsonl`,
|
|
247
|
+
* keeping every event and seq as it was, so ids, cursors and answers stay valid. The old file is
|
|
248
|
+
* kept as a backup and replaced by a marker that makes older copies stop writing to it.
|
|
249
|
+
* Running it again with the same folder and name changes nothing.
|
|
250
|
+
*/
|
|
251
|
+
export function setupSync(input) {
|
|
252
|
+
const dir = path.resolve(expandHome(input.dir.trim()));
|
|
253
|
+
const cfg = readConfig();
|
|
254
|
+
const device = (input.device ?? (typeof cfg.device === "string" ? cfg.device : undefined) ?? deviceName()).trim();
|
|
255
|
+
if (!DEVICE_NAME.test(device))
|
|
256
|
+
throw new UserError(`device name "${device}" may only use a-z, 0-9 and -, up to 32 characters`);
|
|
257
|
+
const adapter = input.adapter ?? (typeof cfg.sync?.adapter === "string" ? cfg.sync.adapter : "folder");
|
|
258
|
+
if (!adapters().has(adapter))
|
|
259
|
+
throw new UserError(`unknown sync adapter "${adapter}" (known: ${adapterNames().join(", ")})`);
|
|
260
|
+
const current = sharedDir();
|
|
261
|
+
if (current && current !== dir)
|
|
262
|
+
throw new UserError(`this machine already shares ${current}. Moving to another folder is not supported yet.`);
|
|
263
|
+
return withLock(() => {
|
|
264
|
+
fs.mkdirSync(path.join(dir, "events"), { recursive: true });
|
|
265
|
+
const target = path.join(dir, "events", `${device}.jsonl`);
|
|
266
|
+
const theirs = readDevice(device, dir);
|
|
267
|
+
if (theirs && theirs.host !== hostPrint()) {
|
|
268
|
+
throw new UserError(`another machine (${theirs.host}) already writes as "${device}" in ${dir}. Pick another name with --device.`);
|
|
269
|
+
}
|
|
270
|
+
const legacy = path.join(homeDir(), "events.jsonl");
|
|
271
|
+
let moved = 0;
|
|
272
|
+
let backup;
|
|
273
|
+
const text = fs.existsSync(legacy) ? fs.readFileSync(legacy, "utf8") : "";
|
|
274
|
+
const events = parseEventLines(text).filter((e) => e.type !== "store.moved");
|
|
275
|
+
if (events.length) {
|
|
276
|
+
const already = fs.existsSync(target) ? parseEventLines(fs.readFileSync(target, "utf8")) : [];
|
|
277
|
+
if (already.length)
|
|
278
|
+
throw new UserError(`${target} already has ${already.length} events and this machine has its own log too. Pick another --device name.`);
|
|
279
|
+
const tmp = path.join(dir, "events", `.${device}.${process.pid}.tmp`);
|
|
280
|
+
fs.writeFileSync(tmp, events.map((e) => JSON.stringify(e) + "\n").join(""));
|
|
281
|
+
fs.renameSync(tmp, target);
|
|
282
|
+
backup = `${legacy}.before-sync`;
|
|
283
|
+
fs.copyFileSync(legacy, backup);
|
|
284
|
+
fs.writeFileSync(legacy, movedMarker(target, (events[events.length - 1]?.seq ?? 0) + 1));
|
|
285
|
+
moved = events.length;
|
|
286
|
+
}
|
|
287
|
+
const sync = { ...(cfg.sync ?? {}), dir: input.dir.trim().startsWith("~") ? input.dir.trim() : dir, adapter };
|
|
288
|
+
writeConfig({ ...cfg, device, sync });
|
|
289
|
+
touchDevice(input.version, { force: true });
|
|
290
|
+
const others = eventFiles()
|
|
291
|
+
.map((f) => f.device)
|
|
292
|
+
.filter((d) => d !== device);
|
|
293
|
+
return { dir, device, adapter, moved, backup, others };
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
/** Name a folder on this machine so paths under it map to the same name on other machines. */
|
|
297
|
+
export function setRoot(name, folder, version) {
|
|
298
|
+
if (!sharedDir())
|
|
299
|
+
throw new UserError("this machine does not share its log yet: run `parawatch sync setup <folder>` first");
|
|
300
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,31}$/i.test(name) || name === "home" || name === "shared")
|
|
301
|
+
throw new UserError(`root name "${name}" may use letters, digits, _ and -, and cannot be home or shared`);
|
|
302
|
+
const before = readDevice(deviceName());
|
|
303
|
+
const roots = { ...(before?.roots ?? {}) };
|
|
304
|
+
if (folder)
|
|
305
|
+
roots[name] = path.resolve(expandHome(folder));
|
|
306
|
+
else
|
|
307
|
+
delete roots[name];
|
|
308
|
+
const shared = sharedDir();
|
|
309
|
+
const me = { name: deviceName(), platform: process.platform, host: hostPrint(), home: os.homedir(), shared, roots, version: version ?? before?.version, at: new Date().toISOString() };
|
|
310
|
+
if (!Object.keys(roots).length)
|
|
311
|
+
delete me.roots;
|
|
312
|
+
fs.mkdirSync(devicesDir(shared), { recursive: true });
|
|
313
|
+
fs.writeFileSync(path.join(devicesDir(shared), `${me.name}.json`), JSON.stringify(me, null, 2) + "\n");
|
|
314
|
+
return me;
|
|
315
|
+
}
|
|
316
|
+
export { configPath };
|
|
317
|
+
//# sourceMappingURL=sync.js.map
|