parawatch 0.2.0 → 0.3.1
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 +55 -2
- package/dist/bin/cli.js +112 -21
- package/dist/core/clients.js +69 -0
- package/dist/core/doctor.js +165 -0
- package/dist/core/errors.js +4 -0
- package/dist/core/follow.js +36 -49
- package/dist/core/format.js +27 -1
- package/dist/core/integrate.js +41 -4
- package/dist/core/ops.js +119 -46
- package/dist/core/semver.js +22 -0
- package/dist/core/skill.js +56 -0
- package/dist/core/snapshot.js +5 -5
- package/dist/core/state.js +40 -9
- package/dist/core/store.js +215 -22
- package/dist/core/sync.js +317 -0
- package/dist/core/syncthing.js +121 -0
- package/dist/core/types.js +7 -0
- package/dist/core/update.js +87 -0
- package/dist/core/version.js +20 -0
- package/package.json +1 -1
- package/skills/parawatch/SKILL.md +4 -0
|
@@ -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
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Syncthing adapter: asks the local Syncthing (its REST API) how the shared folder is doing.
|
|
3
|
+
* The API key is read from Syncthing's own config.xml and never printed or stored by parawatch.
|
|
4
|
+
*/
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { findConflicts, folderPeers } from "./sync.js";
|
|
9
|
+
/** Where Syncthing keeps config.xml on this machine, newest layout first. */
|
|
10
|
+
export function syncthingHomes() {
|
|
11
|
+
const home = os.homedir();
|
|
12
|
+
if (process.platform === "win32") {
|
|
13
|
+
const local = process.env.LOCALAPPDATA || path.join(home, "AppData", "Local");
|
|
14
|
+
return [path.join(local, "Syncthing")];
|
|
15
|
+
}
|
|
16
|
+
const state = process.env.XDG_STATE_HOME || path.join(home, ".local", "state");
|
|
17
|
+
const out = [path.join(state, "syncthing")];
|
|
18
|
+
if (process.platform === "darwin")
|
|
19
|
+
out.push(path.join(home, "Library", "Application Support", "Syncthing"));
|
|
20
|
+
out.push(path.join(process.env.XDG_CONFIG_HOME || path.join(home, ".config"), "syncthing"));
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
/** The GUI address and API key from config.xml (`home` overrides where to look). */
|
|
24
|
+
export function readSyncthingGui(home) {
|
|
25
|
+
const dirs = home ? [home] : syncthingHomes();
|
|
26
|
+
for (const d of dirs) {
|
|
27
|
+
const file = path.join(d, "config.xml");
|
|
28
|
+
let xml;
|
|
29
|
+
try {
|
|
30
|
+
xml = fs.readFileSync(file, "utf8");
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const gui = /<gui\b([^>]*)>([\s\S]*?)<\/gui>/.exec(xml);
|
|
36
|
+
const attrs = gui?.[1] ?? "";
|
|
37
|
+
const body = gui?.[2] ?? "";
|
|
38
|
+
const address = /<address>([^<]+)<\/address>/.exec(body)?.[1]?.trim() || "127.0.0.1:8384";
|
|
39
|
+
const tls = /\btls="true"/.test(attrs);
|
|
40
|
+
const apiKey = /<apikey>([^<]+)<\/apikey>/.exec(body)?.[1]?.trim();
|
|
41
|
+
const host = address.replace(/^0\.0\.0\.0(?=:)/, "127.0.0.1").replace(/^\[::\](?=:)/, "[::1]");
|
|
42
|
+
return { url: `${tls ? "https" : "http"}://${host}`, apiKey, configFile: file };
|
|
43
|
+
}
|
|
44
|
+
return { url: "http://127.0.0.1:8384" };
|
|
45
|
+
}
|
|
46
|
+
function inside(child, parent) {
|
|
47
|
+
const rel = path.relative(path.resolve(parent), path.resolve(child));
|
|
48
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
49
|
+
}
|
|
50
|
+
export const syncthingAdapter = {
|
|
51
|
+
name: "syncthing",
|
|
52
|
+
about: "Syncthing on this machine (its REST API on localhost)",
|
|
53
|
+
async status(ctx) {
|
|
54
|
+
const base = { adapter: "syncthing", dir: ctx.dir, device: ctx.device, ok: false, peers: folderPeers(ctx.dir, ctx.device), conflicts: findConflicts(ctx.dir) };
|
|
55
|
+
const gui = readSyncthingGui(typeof ctx.config.home === "string" ? ctx.config.home : undefined);
|
|
56
|
+
const url = typeof ctx.config.url === "string" ? ctx.config.url : gui.url;
|
|
57
|
+
const key = process.env.PARAWATCH_SYNCTHING_KEY || gui.apiKey;
|
|
58
|
+
if (!key)
|
|
59
|
+
return { ...base, error: "no Syncthing API key found (is Syncthing set up on this machine?)" };
|
|
60
|
+
const f = ctx.fetch ?? fetch;
|
|
61
|
+
const timeout = ctx.timeoutMs ?? 2000;
|
|
62
|
+
const get = async (p) => {
|
|
63
|
+
const res = await f(`${url}${p}`, { headers: { "X-API-Key": key }, signal: AbortSignal.timeout(timeout) });
|
|
64
|
+
if (!res.ok)
|
|
65
|
+
throw new Error(`Syncthing answered ${res.status} for ${p.split("?")[0]}`);
|
|
66
|
+
return (await res.json());
|
|
67
|
+
};
|
|
68
|
+
let folders;
|
|
69
|
+
try {
|
|
70
|
+
folders = await get("/rest/config/folders");
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
const msg = err.message;
|
|
74
|
+
return { ...base, error: /fetch failed|ECONNREFUSED|abort|timeout/i.test(msg) ? `Syncthing is not running at ${url}` : msg };
|
|
75
|
+
}
|
|
76
|
+
const folder = folders
|
|
77
|
+
.map((x) => ({ ...x, path: x.path.replace(/^~(?=[\\/]|$)/, os.homedir()) }))
|
|
78
|
+
.filter((x) => inside(ctx.dir, x.path))
|
|
79
|
+
.sort((a, b) => b.path.length - a.path.length)[0];
|
|
80
|
+
if (!folder)
|
|
81
|
+
return { ...base, ok: true, error: `no Syncthing folder holds ${ctx.dir}` };
|
|
82
|
+
const [sys, devices, conns, stats, db, fstats] = await Promise.all([
|
|
83
|
+
get("/rest/system/status"),
|
|
84
|
+
get("/rest/config/devices"),
|
|
85
|
+
get("/rest/system/connections"),
|
|
86
|
+
get("/rest/stats/device"),
|
|
87
|
+
get(`/rest/db/status?folder=${encodeURIComponent(folder.id)}`),
|
|
88
|
+
get("/rest/stats/folder"),
|
|
89
|
+
]);
|
|
90
|
+
const names = new Map(devices.map((d) => [d.deviceID, d.name || d.deviceID.slice(0, 7)]));
|
|
91
|
+
const known = new Map(base.peers.map((p) => [p.name, p]));
|
|
92
|
+
const peers = [];
|
|
93
|
+
for (const d of folder.devices ?? []) {
|
|
94
|
+
if (d.deviceID === sys.myID)
|
|
95
|
+
continue;
|
|
96
|
+
const c = conns.connections[d.deviceID];
|
|
97
|
+
const seen = stats[d.deviceID]?.lastSeen;
|
|
98
|
+
const name = names.get(d.deviceID) ?? d.deviceID.slice(0, 7);
|
|
99
|
+
const fromFolder = known.get(name);
|
|
100
|
+
peers.push({
|
|
101
|
+
name,
|
|
102
|
+
connected: !!c?.connected,
|
|
103
|
+
lastSeen: c?.connected ? new Date().toISOString() : seen && !seen.startsWith("1970") ? seen : fromFolder?.lastSeen,
|
|
104
|
+
address: c?.connected ? c.address : undefined,
|
|
105
|
+
version: fromFolder?.version,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
const idle = db.state === "idle" && !db.needFiles;
|
|
109
|
+
const pending = db.needFiles ?? 0;
|
|
110
|
+
const errors = (db.errors ?? 0) + (db.pullErrors ?? 0);
|
|
111
|
+
return {
|
|
112
|
+
...base,
|
|
113
|
+
ok: true,
|
|
114
|
+
peers,
|
|
115
|
+
pending: pending || undefined,
|
|
116
|
+
lastSync: idle ? db.stateChanged : fstats[folder.id]?.lastFile?.at,
|
|
117
|
+
error: errors ? `${errors} file(s) could not be synced (see Syncthing)` : !peers.length ? `folder "${folder.label || folder.id}" is not shared with another device yet` : undefined,
|
|
118
|
+
};
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
//# sourceMappingURL=syncthing.js.map
|
package/dist/core/types.js
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
+
/** The version of the JSON that commands print (`--json`) and panels read. */
|
|
1
2
|
export const SCHEMA_VERSION = 1;
|
|
3
|
+
/**
|
|
4
|
+
* The newest event-log format this copy reads and writes. 1: one machine. 2: per-device logs in a
|
|
5
|
+
* shared folder (prefixed ids, answers that record what they saw). A copy refuses to write to a
|
|
6
|
+
* log with a newer format than it knows.
|
|
7
|
+
*/
|
|
8
|
+
export const STORE_FORMAT = 2;
|
|
2
9
|
export const CARD_KINDS = ["decide", "try", "block", "todo"];
|
|
3
10
|
export const PR_CHANGES = ["review", "approved", "changes_requested", "commented", "ci_failed", "ci_passed", "queued", "queue_removed", "conflict", "merged", "closed", "note"];
|
|
4
11
|
/** Who counts as a human when deciding what wakes an agent. */
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a newer parawatch is out, for the command line. It asks the npm registry at most once a
|
|
3
|
+
* day and remembers the answer; it never blocks a command for long and never fails one. Turn it off
|
|
4
|
+
* with `"updates": { "check": false }` in config.json or PARAWATCH_UPDATE_CHECK=0 (also off in CI).
|
|
5
|
+
*/
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { homeDir } from "./store.js";
|
|
9
|
+
import { compareVersions } from "./semver.js";
|
|
10
|
+
export const REGISTRY_URL = "https://registry.npmjs.org/parawatch/latest";
|
|
11
|
+
const DAY_MS = 24 * 3_600_000;
|
|
12
|
+
const RETRY_MS = 3_600_000;
|
|
13
|
+
const TIMEOUT_MS = 2_000;
|
|
14
|
+
function cachePath() {
|
|
15
|
+
return path.join(homeDir(), "update.json");
|
|
16
|
+
}
|
|
17
|
+
export function updateChecksOn() {
|
|
18
|
+
if (process.env.PARAWATCH_UPDATE_CHECK === "0")
|
|
19
|
+
return false;
|
|
20
|
+
if (process.env.PARAWATCH_UPDATE_CHECK !== "1" && process.env.CI)
|
|
21
|
+
return false;
|
|
22
|
+
try {
|
|
23
|
+
const raw = JSON.parse(fs.readFileSync(path.join(homeDir(), "config.json"), "utf8"));
|
|
24
|
+
return raw.updates?.check !== false;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function readUpdateCache() {
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(fs.readFileSync(cachePath(), "utf8"));
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function writeUpdateCache(c) {
|
|
39
|
+
try {
|
|
40
|
+
fs.mkdirSync(homeDir(), { recursive: true });
|
|
41
|
+
const tmp = `${cachePath()}.${process.pid}.tmp`;
|
|
42
|
+
fs.writeFileSync(tmp, JSON.stringify(c) + "\n");
|
|
43
|
+
fs.renameSync(tmp, cachePath());
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// the next run asks again
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function info(current, c) {
|
|
50
|
+
return { current, latest: c.latest, newer: !!c.latest && compareVersions(c.latest, current) > 0, checkedAt: c.checkedAt, error: c.error };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The newest published version, from the cache while it is fresh. `force` asks now. Returns
|
|
54
|
+
* undefined when update checks are off.
|
|
55
|
+
*/
|
|
56
|
+
export async function checkUpdate(current, opts = {}) {
|
|
57
|
+
if (!opts.force && !updateChecksOn())
|
|
58
|
+
return undefined;
|
|
59
|
+
const now = opts.now ?? Date.now();
|
|
60
|
+
const cache = readUpdateCache();
|
|
61
|
+
const age = cache.checkedAt ? now - Date.parse(cache.checkedAt) : Infinity;
|
|
62
|
+
if (!opts.force && age < (cache.error ? RETRY_MS : DAY_MS))
|
|
63
|
+
return info(current, cache);
|
|
64
|
+
const get = opts.fetch ?? globalThis.fetch;
|
|
65
|
+
let next;
|
|
66
|
+
try {
|
|
67
|
+
const res = await get(REGISTRY_URL, { signal: AbortSignal.timeout(TIMEOUT_MS), headers: { accept: "application/json" } });
|
|
68
|
+
if (!res.ok)
|
|
69
|
+
throw new Error(`the npm registry answered ${res.status}`);
|
|
70
|
+
const body = (await res.json());
|
|
71
|
+
if (typeof body.version !== "string")
|
|
72
|
+
throw new Error("the npm registry gave no version");
|
|
73
|
+
next = { checkedAt: new Date(now).toISOString(), latest: body.version };
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
const msg = err instanceof Error ? (err.name === "TimeoutError" ? "the npm registry did not answer in time" : err.message) : String(err);
|
|
77
|
+
next = { checkedAt: new Date(now).toISOString(), latest: cache.latest, error: msg };
|
|
78
|
+
}
|
|
79
|
+
writeUpdateCache(next);
|
|
80
|
+
return info(current, next);
|
|
81
|
+
}
|
|
82
|
+
export function updateLine(u) {
|
|
83
|
+
if (!u?.newer)
|
|
84
|
+
return undefined;
|
|
85
|
+
return `parawatch ${u.latest} is out (this is ${u.current}): npm i -g parawatch@latest`;
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=update.js.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** The package's own version, read from package.json so it is written in one place. */
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
let cached;
|
|
6
|
+
/** The version of this package (works from src/ and from dist/). */
|
|
7
|
+
export function packageVersion() {
|
|
8
|
+
if (cached)
|
|
9
|
+
return cached;
|
|
10
|
+
try {
|
|
11
|
+
const file = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
|
|
12
|
+
cached = String(JSON.parse(fs.readFileSync(file, "utf8")).version ?? "0.0.0");
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
cached = "0.0.0";
|
|
16
|
+
}
|
|
17
|
+
return cached;
|
|
18
|
+
}
|
|
19
|
+
export { compareVersions } from "./semver.js";
|
|
20
|
+
//# sourceMappingURL=version.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "parawatch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "A control tower for people running many AI coding agents in parallel: see what is running, what needs you, and wake the right agent when you answer.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
|
@@ -123,6 +123,10 @@ The session then shows as done, not as idle, and the line goes into the human's
|
|
|
123
123
|
- Tell workers in their brief what to return to you, so the lead can turn it into one clear card.
|
|
124
124
|
- Before ending a session, close cards you handled, end runs you started, and claim `--done`.
|
|
125
125
|
|
|
126
|
+
## On more than one machine
|
|
127
|
+
|
|
128
|
+
When `parawatch sync` shows other machines, the human may answer from any of them. Nothing changes in how you ask or wait. Ids then carry the machine (`pc-c3`); use the id the command printed. Attach files under your home folder or the shared folder (or a folder named with `parawatch sync root`), so the human's machine can open them. An app path given to `try --open` only opens on the machine that has it, so say in the body which machine to try it on.
|
|
129
|
+
|
|
126
130
|
## Quick reference
|
|
127
131
|
|
|
128
132
|
```
|