wispy-cli 1.2.3 → 1.4.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/bin/wispy.mjs +219 -1
- package/core/deploy.mjs +51 -0
- package/core/engine.mjs +132 -0
- package/core/index.mjs +4 -0
- package/core/memory.mjs +10 -1
- package/core/migrate.mjs +357 -0
- package/core/server.mjs +152 -2
- package/core/session.mjs +8 -0
- package/core/skills.mjs +339 -0
- package/core/sync.mjs +682 -0
- package/core/user-model.mjs +302 -0
- package/lib/channels/email.mjs +187 -0
- package/lib/channels/index.mjs +66 -9
- package/lib/channels/signal.mjs +151 -0
- package/lib/channels/whatsapp.mjs +141 -0
- package/lib/wispy-repl.mjs +102 -24
- package/lib/wispy-tui.mjs +964 -380
- package/package.json +18 -2
package/core/sync.mjs
ADDED
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core/sync.mjs — Sync manager for Wispy
|
|
3
|
+
*
|
|
4
|
+
* Syncs sessions, memory, cron jobs, workstreams, and permissions
|
|
5
|
+
* between local and remote wispy instances via HTTP API.
|
|
6
|
+
*
|
|
7
|
+
* Protocol:
|
|
8
|
+
* 1. Build local manifest (path → { hash, modifiedAt, size })
|
|
9
|
+
* 2. Fetch remote manifest from GET /api/sync/manifest
|
|
10
|
+
* 3. Compare → compute what to push / pull
|
|
11
|
+
* 4. Transfer only changed files
|
|
12
|
+
* 5. Report summary
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { readFile, writeFile, mkdir, readdir, stat } from "node:fs/promises";
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
|
|
19
|
+
import { WISPY_DIR } from "./config.mjs";
|
|
20
|
+
|
|
21
|
+
const SYNC_CONFIG_FILE = path.join(WISPY_DIR, "sync.json");
|
|
22
|
+
|
|
23
|
+
// File patterns that are syncable (relative to WISPY_DIR)
|
|
24
|
+
const SYNC_PATTERNS = [
|
|
25
|
+
{ glob: "memory", match: (f) => f.startsWith("memory/") && f.endsWith(".md") },
|
|
26
|
+
{ glob: "sessions", match: (f) => f.startsWith("sessions/") && f.endsWith(".json") },
|
|
27
|
+
{ glob: "cron/jobs.json", match: (f) => f === "cron/jobs.json" },
|
|
28
|
+
{ glob: "workstreams", match: (f) => f.match(/^workstreams\/[^/]+\/work\.md$/) },
|
|
29
|
+
{ glob: "permissions.json", match: (f) => f === "permissions.json" },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Compute SHA-256 hash of a string or Buffer
|
|
34
|
+
*/
|
|
35
|
+
function sha256(content) {
|
|
36
|
+
return createHash("sha256").update(content).digest("hex");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @typedef {Object} FileEntry
|
|
41
|
+
* @property {string} path - relative path within WISPY_DIR
|
|
42
|
+
* @property {string} hash - SHA-256 of content
|
|
43
|
+
* @property {string} modifiedAt - ISO timestamp
|
|
44
|
+
* @property {number} size - byte size
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @typedef {Object} SyncResult
|
|
49
|
+
* @property {number} pushed
|
|
50
|
+
* @property {number} pulled
|
|
51
|
+
* @property {number} skipped
|
|
52
|
+
* @property {number} conflicts
|
|
53
|
+
* @property {string[]} errors
|
|
54
|
+
* @property {Object} details
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
export class SyncManager {
|
|
58
|
+
/**
|
|
59
|
+
* @param {object} config
|
|
60
|
+
* @param {string} [config.remoteUrl]
|
|
61
|
+
* @param {string} [config.token]
|
|
62
|
+
* @param {'newer-wins'|'local-wins'|'remote-wins'} [config.strategy]
|
|
63
|
+
* @param {boolean} [config.auto]
|
|
64
|
+
*/
|
|
65
|
+
constructor(config = {}) {
|
|
66
|
+
this.remoteUrl = config.remoteUrl ?? null;
|
|
67
|
+
this.token = config.token ?? null;
|
|
68
|
+
this.strategy = config.strategy ?? "newer-wins";
|
|
69
|
+
this.auto = config.auto ?? false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Config persistence ──────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
static async loadConfig() {
|
|
75
|
+
try {
|
|
76
|
+
const raw = await readFile(SYNC_CONFIG_FILE, "utf8");
|
|
77
|
+
return JSON.parse(raw);
|
|
78
|
+
} catch {
|
|
79
|
+
return {};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
static async saveConfig(cfg) {
|
|
84
|
+
await mkdir(WISPY_DIR, { recursive: true });
|
|
85
|
+
await writeFile(SYNC_CONFIG_FILE, JSON.stringify(cfg, null, 2) + "\n", "utf8");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
static async fromConfig() {
|
|
89
|
+
const cfg = await SyncManager.loadConfig();
|
|
90
|
+
return new SyncManager(cfg);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── Local manifest ──────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Scan WISPY_DIR and build manifest for syncable files.
|
|
97
|
+
* @param {object} [opts]
|
|
98
|
+
* @param {boolean} [opts.memoryOnly]
|
|
99
|
+
* @param {boolean} [opts.sessionsOnly]
|
|
100
|
+
* @returns {Promise<FileEntry[]>}
|
|
101
|
+
*/
|
|
102
|
+
async buildLocalManifest(opts = {}) {
|
|
103
|
+
const entries = [];
|
|
104
|
+
await this._scanDir(WISPY_DIR, WISPY_DIR, entries, opts);
|
|
105
|
+
return entries;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async _scanDir(baseDir, dir, entries, opts = {}) {
|
|
109
|
+
let items;
|
|
110
|
+
try {
|
|
111
|
+
items = await readdir(dir, { withFileTypes: true });
|
|
112
|
+
} catch {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (const item of items) {
|
|
117
|
+
const fullPath = path.join(dir, item.name);
|
|
118
|
+
const relPath = path.relative(baseDir, fullPath).replace(/\\/g, "/");
|
|
119
|
+
|
|
120
|
+
if (item.isDirectory()) {
|
|
121
|
+
// Skip node_modules, .git, etc.
|
|
122
|
+
if (["node_modules", ".git", ".npm"].includes(item.name)) continue;
|
|
123
|
+
await this._scanDir(baseDir, fullPath, entries, opts);
|
|
124
|
+
} else if (item.isFile()) {
|
|
125
|
+
// Check if syncable
|
|
126
|
+
const syncable = SYNC_PATTERNS.some(p => p.match(relPath));
|
|
127
|
+
if (!syncable) continue;
|
|
128
|
+
|
|
129
|
+
// Apply filters
|
|
130
|
+
if (opts.memoryOnly && !relPath.startsWith("memory/")) continue;
|
|
131
|
+
if (opts.sessionsOnly && !relPath.startsWith("sessions/")) continue;
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const content = await readFile(fullPath);
|
|
135
|
+
const fileStat = await stat(fullPath);
|
|
136
|
+
entries.push({
|
|
137
|
+
path: relPath,
|
|
138
|
+
hash: sha256(content),
|
|
139
|
+
modifiedAt: fileStat.mtime.toISOString(),
|
|
140
|
+
size: content.length,
|
|
141
|
+
});
|
|
142
|
+
} catch {
|
|
143
|
+
// Skip unreadable files
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── HTTP helpers ────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
async _fetch(url, opts = {}) {
|
|
152
|
+
const { default: fetch } = await import("node:fetch").catch(async () => {
|
|
153
|
+
// Node 18+ has built-in fetch
|
|
154
|
+
return { default: globalThis.fetch };
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const headers = {
|
|
158
|
+
"Content-Type": "application/json",
|
|
159
|
+
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
|
|
160
|
+
...opts.headers,
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const response = await fetch(url, { ...opts, headers });
|
|
164
|
+
return response;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async _getRemoteManifest(remoteUrl, token) {
|
|
168
|
+
const url = `${remoteUrl}/api/sync/manifest`;
|
|
169
|
+
const res = await this._fetchWithAuth(url, {}, token);
|
|
170
|
+
if (!res.ok) {
|
|
171
|
+
throw new Error(`Failed to get remote manifest: ${res.status} ${res.statusText}`);
|
|
172
|
+
}
|
|
173
|
+
const data = await res.json();
|
|
174
|
+
return data.files ?? [];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async _downloadFile(remoteUrl, token, relPath) {
|
|
178
|
+
const url = `${remoteUrl}/api/sync/file?path=${encodeURIComponent(relPath)}`;
|
|
179
|
+
const res = await this._fetchWithAuth(url, {}, token);
|
|
180
|
+
if (!res.ok) {
|
|
181
|
+
throw new Error(`Failed to download ${relPath}: ${res.status}`);
|
|
182
|
+
}
|
|
183
|
+
return res.json(); // { path, content, modifiedAt, hash, encoding }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async _uploadFile(remoteUrl, token, entry) {
|
|
187
|
+
const url = `${remoteUrl}/api/sync/file`;
|
|
188
|
+
const res = await this._fetchWithAuth(url, {
|
|
189
|
+
method: "POST",
|
|
190
|
+
body: JSON.stringify(entry),
|
|
191
|
+
}, token);
|
|
192
|
+
if (!res.ok) {
|
|
193
|
+
const text = await res.text().catch(() => "");
|
|
194
|
+
throw new Error(`Failed to upload ${entry.path}: ${res.status} ${text}`);
|
|
195
|
+
}
|
|
196
|
+
return res.json();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async _uploadBulk(remoteUrl, token, files) {
|
|
200
|
+
const url = `${remoteUrl}/api/sync/bulk`;
|
|
201
|
+
const res = await this._fetchWithAuth(url, {
|
|
202
|
+
method: "POST",
|
|
203
|
+
body: JSON.stringify({ files }),
|
|
204
|
+
}, token);
|
|
205
|
+
if (!res.ok) {
|
|
206
|
+
const text = await res.text().catch(() => "");
|
|
207
|
+
throw new Error(`Bulk upload failed: ${res.status} ${text}`);
|
|
208
|
+
}
|
|
209
|
+
return res.json();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async _fetchWithAuth(url, opts = {}, tokenOverride = null) {
|
|
213
|
+
const token = tokenOverride ?? this.token;
|
|
214
|
+
const headers = {
|
|
215
|
+
"Content-Type": "application/json",
|
|
216
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
217
|
+
...(opts.headers ?? {}),
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
// Use native fetch (Node 18+) or fallback
|
|
221
|
+
const fetchFn = globalThis.fetch;
|
|
222
|
+
if (!fetchFn) {
|
|
223
|
+
throw new Error("fetch not available. Node 18+ required.");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return fetchFn(url, { ...opts, headers });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── Core operations ─────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Push local state to remote.
|
|
233
|
+
* @param {string} remoteUrl
|
|
234
|
+
* @param {string} token
|
|
235
|
+
* @param {object} [opts]
|
|
236
|
+
* @returns {Promise<SyncResult>}
|
|
237
|
+
*/
|
|
238
|
+
async push(remoteUrl, token, opts = {}) {
|
|
239
|
+
const local = await this.buildLocalManifest(opts);
|
|
240
|
+
const remote = await this._getRemoteManifest(remoteUrl, token);
|
|
241
|
+
const remoteMap = new Map(remote.map(f => [f.path, f]));
|
|
242
|
+
|
|
243
|
+
const toUpload = [];
|
|
244
|
+
let skipped = 0;
|
|
245
|
+
|
|
246
|
+
for (const localFile of local) {
|
|
247
|
+
const remoteFile = remoteMap.get(localFile.path);
|
|
248
|
+
if (!remoteFile) {
|
|
249
|
+
toUpload.push(localFile);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (localFile.hash === remoteFile.hash) {
|
|
253
|
+
skipped++;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
// Both exist, different content
|
|
257
|
+
const strategy = opts.strategy ?? this.strategy;
|
|
258
|
+
if (strategy === "local-wins") {
|
|
259
|
+
toUpload.push(localFile);
|
|
260
|
+
} else if (strategy === "remote-wins") {
|
|
261
|
+
skipped++;
|
|
262
|
+
} else {
|
|
263
|
+
// newer-wins: push only if local is newer
|
|
264
|
+
const localMtime = new Date(localFile.modifiedAt).getTime();
|
|
265
|
+
const remoteMtime = new Date(remoteFile.modifiedAt).getTime();
|
|
266
|
+
if (localMtime > remoteMtime) {
|
|
267
|
+
toUpload.push(localFile);
|
|
268
|
+
} else {
|
|
269
|
+
skipped++;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Upload files
|
|
275
|
+
const errors = [];
|
|
276
|
+
let pushed = 0;
|
|
277
|
+
|
|
278
|
+
if (toUpload.length > 0) {
|
|
279
|
+
// Prepare payload with content
|
|
280
|
+
const files = [];
|
|
281
|
+
for (const f of toUpload) {
|
|
282
|
+
try {
|
|
283
|
+
const content = await readFile(path.join(WISPY_DIR, f.path));
|
|
284
|
+
files.push({
|
|
285
|
+
path: f.path,
|
|
286
|
+
content: content.toString("base64"),
|
|
287
|
+
encoding: "base64",
|
|
288
|
+
modifiedAt: f.modifiedAt,
|
|
289
|
+
hash: f.hash,
|
|
290
|
+
});
|
|
291
|
+
} catch (err) {
|
|
292
|
+
errors.push(`Read failed for ${f.path}: ${err.message}`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (files.length > 0) {
|
|
297
|
+
try {
|
|
298
|
+
await this._uploadBulk(remoteUrl, token, files);
|
|
299
|
+
pushed = files.length;
|
|
300
|
+
} catch (err) {
|
|
301
|
+
// Fallback: upload one by one
|
|
302
|
+
for (const file of files) {
|
|
303
|
+
try {
|
|
304
|
+
await this._uploadFile(remoteUrl, token, file);
|
|
305
|
+
pushed++;
|
|
306
|
+
} catch (e) {
|
|
307
|
+
errors.push(`Upload failed for ${file.path}: ${e.message}`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
pushed,
|
|
316
|
+
pulled: 0,
|
|
317
|
+
skipped,
|
|
318
|
+
conflicts: 0,
|
|
319
|
+
errors,
|
|
320
|
+
details: { uploaded: toUpload.map(f => f.path) },
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Pull remote state to local.
|
|
326
|
+
* @param {string} remoteUrl
|
|
327
|
+
* @param {string} token
|
|
328
|
+
* @param {object} [opts]
|
|
329
|
+
* @returns {Promise<SyncResult>}
|
|
330
|
+
*/
|
|
331
|
+
async pull(remoteUrl, token, opts = {}) {
|
|
332
|
+
const local = await this.buildLocalManifest(opts);
|
|
333
|
+
const remote = await this._getRemoteManifest(remoteUrl, token);
|
|
334
|
+
const localMap = new Map(local.map(f => [f.path, f]));
|
|
335
|
+
|
|
336
|
+
const toDownload = [];
|
|
337
|
+
let skipped = 0;
|
|
338
|
+
let conflicts = 0;
|
|
339
|
+
|
|
340
|
+
for (const remoteFile of remote) {
|
|
341
|
+
const localFile = localMap.get(remoteFile.path);
|
|
342
|
+
if (!localFile) {
|
|
343
|
+
toDownload.push(remoteFile);
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (localFile.hash === remoteFile.hash) {
|
|
347
|
+
skipped++;
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
// Both exist, different content
|
|
351
|
+
const strategy = opts.strategy ?? this.strategy;
|
|
352
|
+
if (strategy === "remote-wins") {
|
|
353
|
+
toDownload.push(remoteFile);
|
|
354
|
+
} else if (strategy === "local-wins") {
|
|
355
|
+
skipped++;
|
|
356
|
+
} else {
|
|
357
|
+
// newer-wins
|
|
358
|
+
const localMtime = new Date(localFile.modifiedAt).getTime();
|
|
359
|
+
const remoteMtime = new Date(remoteFile.modifiedAt).getTime();
|
|
360
|
+
if (remoteMtime > localMtime) {
|
|
361
|
+
toDownload.push(remoteFile);
|
|
362
|
+
} else if (localMtime === remoteMtime) {
|
|
363
|
+
// Same timestamp, different content → conflict
|
|
364
|
+
conflicts++;
|
|
365
|
+
const conflictPath = path.join(WISPY_DIR, remoteFile.path + `.conflict-${Date.now()}`);
|
|
366
|
+
try {
|
|
367
|
+
const downloaded = await this._downloadFile(remoteUrl, token, remoteFile.path);
|
|
368
|
+
const content = Buffer.from(downloaded.content, downloaded.encoding === "base64" ? "base64" : "utf8");
|
|
369
|
+
await mkdir(path.dirname(conflictPath), { recursive: true });
|
|
370
|
+
await writeFile(conflictPath, content);
|
|
371
|
+
} catch {}
|
|
372
|
+
} else {
|
|
373
|
+
skipped++;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Download files
|
|
379
|
+
const errors = [];
|
|
380
|
+
let pulled = 0;
|
|
381
|
+
|
|
382
|
+
for (const remoteFile of toDownload) {
|
|
383
|
+
try {
|
|
384
|
+
const downloaded = await this._downloadFile(remoteUrl, token, remoteFile.path);
|
|
385
|
+
const content = Buffer.from(
|
|
386
|
+
downloaded.content,
|
|
387
|
+
downloaded.encoding === "base64" ? "base64" : "utf8"
|
|
388
|
+
);
|
|
389
|
+
const localPath = path.join(WISPY_DIR, remoteFile.path);
|
|
390
|
+
await mkdir(path.dirname(localPath), { recursive: true });
|
|
391
|
+
await writeFile(localPath, content);
|
|
392
|
+
// Restore mtime
|
|
393
|
+
const mtime = new Date(remoteFile.modifiedAt);
|
|
394
|
+
const { utimes } = await import("node:fs/promises");
|
|
395
|
+
await utimes(localPath, mtime, mtime).catch(() => {});
|
|
396
|
+
pulled++;
|
|
397
|
+
} catch (err) {
|
|
398
|
+
errors.push(`Download failed for ${remoteFile.path}: ${err.message}`);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return {
|
|
403
|
+
pushed: 0,
|
|
404
|
+
pulled,
|
|
405
|
+
skipped,
|
|
406
|
+
conflicts,
|
|
407
|
+
errors,
|
|
408
|
+
details: { downloaded: toDownload.map(f => f.path) },
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Bidirectional sync — push local-only/newer files, pull remote-only/newer files.
|
|
414
|
+
* @param {string} remoteUrl
|
|
415
|
+
* @param {string} token
|
|
416
|
+
* @param {object} [opts]
|
|
417
|
+
* @returns {Promise<SyncResult>}
|
|
418
|
+
*/
|
|
419
|
+
async sync(remoteUrl, token, opts = {}) {
|
|
420
|
+
const local = await this.buildLocalManifest(opts);
|
|
421
|
+
const remote = await this._getRemoteManifest(remoteUrl, token);
|
|
422
|
+
|
|
423
|
+
const localMap = new Map(local.map(f => [f.path, f]));
|
|
424
|
+
const remoteMap = new Map(remote.map(f => [f.path, f]));
|
|
425
|
+
|
|
426
|
+
const toUpload = [];
|
|
427
|
+
const toDownload = [];
|
|
428
|
+
let skipped = 0;
|
|
429
|
+
let conflicts = 0;
|
|
430
|
+
const errors = [];
|
|
431
|
+
const strategy = opts.strategy ?? this.strategy;
|
|
432
|
+
|
|
433
|
+
// Check local files
|
|
434
|
+
for (const localFile of local) {
|
|
435
|
+
const remoteFile = remoteMap.get(localFile.path);
|
|
436
|
+
if (!remoteFile) {
|
|
437
|
+
toUpload.push(localFile);
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
if (localFile.hash === remoteFile.hash) {
|
|
441
|
+
skipped++;
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
// Both exist, different content
|
|
445
|
+
const localMtime = new Date(localFile.modifiedAt).getTime();
|
|
446
|
+
const remoteMtime = new Date(remoteFile.modifiedAt).getTime();
|
|
447
|
+
|
|
448
|
+
if (strategy === "local-wins") {
|
|
449
|
+
toUpload.push(localFile);
|
|
450
|
+
} else if (strategy === "remote-wins") {
|
|
451
|
+
toDownload.push(remoteFile);
|
|
452
|
+
} else if (localMtime === remoteMtime) {
|
|
453
|
+
// Conflict
|
|
454
|
+
conflicts++;
|
|
455
|
+
// Keep both: rename remote as conflict copy
|
|
456
|
+
const conflictPath = remoteFile.path + `.conflict-${Date.now()}`;
|
|
457
|
+
toDownload.push({ ...remoteFile, path: conflictPath });
|
|
458
|
+
} else if (localMtime > remoteMtime) {
|
|
459
|
+
toUpload.push(localFile);
|
|
460
|
+
} else {
|
|
461
|
+
toDownload.push(remoteFile);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Check remote-only files
|
|
466
|
+
for (const remoteFile of remote) {
|
|
467
|
+
if (!localMap.has(remoteFile.path)) {
|
|
468
|
+
toDownload.push(remoteFile);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Upload
|
|
473
|
+
let pushed = 0;
|
|
474
|
+
if (toUpload.length > 0) {
|
|
475
|
+
const files = [];
|
|
476
|
+
for (const f of toUpload) {
|
|
477
|
+
try {
|
|
478
|
+
const content = await readFile(path.join(WISPY_DIR, f.path));
|
|
479
|
+
files.push({
|
|
480
|
+
path: f.path,
|
|
481
|
+
content: content.toString("base64"),
|
|
482
|
+
encoding: "base64",
|
|
483
|
+
modifiedAt: f.modifiedAt,
|
|
484
|
+
hash: f.hash,
|
|
485
|
+
});
|
|
486
|
+
} catch (err) {
|
|
487
|
+
errors.push(`Read failed for ${f.path}: ${err.message}`);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
if (files.length > 0) {
|
|
491
|
+
try {
|
|
492
|
+
await this._uploadBulk(remoteUrl, token, files);
|
|
493
|
+
pushed = files.length;
|
|
494
|
+
} catch (err) {
|
|
495
|
+
for (const file of files) {
|
|
496
|
+
try {
|
|
497
|
+
await this._uploadFile(remoteUrl, token, file);
|
|
498
|
+
pushed++;
|
|
499
|
+
} catch (e) {
|
|
500
|
+
errors.push(`Upload failed for ${file.path}: ${e.message}`);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Download
|
|
508
|
+
let pulled = 0;
|
|
509
|
+
for (const remoteFile of toDownload) {
|
|
510
|
+
try {
|
|
511
|
+
const downloaded = await this._downloadFile(remoteUrl, token, remoteFile.path);
|
|
512
|
+
const content = Buffer.from(
|
|
513
|
+
downloaded.content,
|
|
514
|
+
downloaded.encoding === "base64" ? "base64" : "utf8"
|
|
515
|
+
);
|
|
516
|
+
const localPath = path.join(WISPY_DIR, remoteFile.path);
|
|
517
|
+
await mkdir(path.dirname(localPath), { recursive: true });
|
|
518
|
+
await writeFile(localPath, content);
|
|
519
|
+
const mtime = new Date(remoteFile.modifiedAt);
|
|
520
|
+
const { utimes } = await import("node:fs/promises");
|
|
521
|
+
await utimes(localPath, mtime, mtime).catch(() => {});
|
|
522
|
+
pulled++;
|
|
523
|
+
} catch (err) {
|
|
524
|
+
errors.push(`Download failed for ${remoteFile.path}: ${err.message}`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return {
|
|
529
|
+
pushed,
|
|
530
|
+
pulled,
|
|
531
|
+
skipped,
|
|
532
|
+
conflicts,
|
|
533
|
+
errors,
|
|
534
|
+
details: {
|
|
535
|
+
uploaded: toUpload.map(f => f.path),
|
|
536
|
+
downloaded: toDownload.map(f => f.path),
|
|
537
|
+
},
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Dry-run: show what would sync without doing it.
|
|
543
|
+
* @param {string} remoteUrl
|
|
544
|
+
* @param {string} token
|
|
545
|
+
* @param {object} [opts]
|
|
546
|
+
* @returns {Promise<object>} status info
|
|
547
|
+
*/
|
|
548
|
+
async status(remoteUrl, token, opts = {}) {
|
|
549
|
+
let remote;
|
|
550
|
+
let reachable = true;
|
|
551
|
+
try {
|
|
552
|
+
remote = await this._getRemoteManifest(remoteUrl, token);
|
|
553
|
+
} catch (err) {
|
|
554
|
+
reachable = false;
|
|
555
|
+
remote = [];
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const local = await this.buildLocalManifest(opts);
|
|
559
|
+
const localMap = new Map(local.map(f => [f.path, f]));
|
|
560
|
+
const remoteMap = new Map(remote.map(f => [f.path, f]));
|
|
561
|
+
|
|
562
|
+
const byType = (prefix, checkFn) => {
|
|
563
|
+
const files = local.filter(f => checkFn ? checkFn(f.path) : f.path.startsWith(prefix));
|
|
564
|
+
const remoteFiles = remote.filter(f => checkFn ? checkFn(f.path) : f.path.startsWith(prefix));
|
|
565
|
+
|
|
566
|
+
const localOnly = files.filter(f => !remoteMap.has(f.path));
|
|
567
|
+
const remoteOnly = remoteFiles.filter(f => !localMap.has(f.path));
|
|
568
|
+
const inSync = files.filter(f => {
|
|
569
|
+
const r = remoteMap.get(f.path);
|
|
570
|
+
return r && r.hash === f.hash;
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
return { localOnly: localOnly.length, remoteOnly: remoteOnly.length, inSync: inSync.length };
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
return {
|
|
577
|
+
reachable,
|
|
578
|
+
remoteUrl,
|
|
579
|
+
memory: byType("memory/"),
|
|
580
|
+
sessions: byType("sessions/"),
|
|
581
|
+
cron: byType(null, f => f === "cron/jobs.json"),
|
|
582
|
+
workstreams: byType(null, f => /^workstreams\//.test(f)),
|
|
583
|
+
permissions: byType(null, f => f === "permissions.json"),
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// ── Selective sync helpers ──────────────────────────────────────────────────
|
|
588
|
+
|
|
589
|
+
async pushMemory(remoteUrl, token) {
|
|
590
|
+
return this.push(remoteUrl, token, { memoryOnly: true });
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async pullMemory(remoteUrl, token) {
|
|
594
|
+
return this.pull(remoteUrl, token, { memoryOnly: true });
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async pushSessions(remoteUrl, token) {
|
|
598
|
+
return this.push(remoteUrl, token, { sessionsOnly: true });
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
async pullSessions(remoteUrl, token) {
|
|
602
|
+
return this.pull(remoteUrl, token, { sessionsOnly: true });
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
async pushCron(remoteUrl, token) {
|
|
606
|
+
return this.push(remoteUrl, token, { cronOnly: true });
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
async pullCron(remoteUrl, token) {
|
|
610
|
+
return this.pull(remoteUrl, token, { cronOnly: true });
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// ── Single-file push (for auto-sync) ───────────────────────────────────────
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Push a single file to remote (used for auto-sync hooks).
|
|
617
|
+
* Non-blocking: fires and forgets.
|
|
618
|
+
*/
|
|
619
|
+
pushFile(absolutePath) {
|
|
620
|
+
if (!this.remoteUrl || !this.token) return;
|
|
621
|
+
const relPath = path.relative(WISPY_DIR, absolutePath).replace(/\\/g, "/");
|
|
622
|
+
|
|
623
|
+
// Check it's a syncable path
|
|
624
|
+
const syncable = SYNC_PATTERNS.some(p => p.match(relPath));
|
|
625
|
+
if (!syncable) return;
|
|
626
|
+
|
|
627
|
+
// Fire and forget
|
|
628
|
+
(async () => {
|
|
629
|
+
try {
|
|
630
|
+
const content = await readFile(absolutePath);
|
|
631
|
+
const fileStat = await stat(absolutePath);
|
|
632
|
+
await this._uploadFile(this.remoteUrl, this.token, {
|
|
633
|
+
path: relPath,
|
|
634
|
+
content: content.toString("base64"),
|
|
635
|
+
encoding: "base64",
|
|
636
|
+
modifiedAt: fileStat.mtime.toISOString(),
|
|
637
|
+
hash: sha256(content),
|
|
638
|
+
});
|
|
639
|
+
} catch {
|
|
640
|
+
// Silent fail for background auto-sync
|
|
641
|
+
}
|
|
642
|
+
})();
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Enable auto-sync: save config and set auto=true.
|
|
647
|
+
*/
|
|
648
|
+
static async enableAuto(remoteUrl, token) {
|
|
649
|
+
const cfg = await SyncManager.loadConfig();
|
|
650
|
+
cfg.auto = true;
|
|
651
|
+
cfg.remoteUrl = remoteUrl;
|
|
652
|
+
cfg.token = token;
|
|
653
|
+
await SyncManager.saveConfig(cfg);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Disable auto-sync.
|
|
658
|
+
*/
|
|
659
|
+
static async disableAuto() {
|
|
660
|
+
const cfg = await SyncManager.loadConfig();
|
|
661
|
+
cfg.auto = false;
|
|
662
|
+
await SyncManager.saveConfig(cfg);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Get or create a singleton SyncManager from ~/.wispy/sync.json
|
|
668
|
+
* Returns null if not configured.
|
|
669
|
+
*/
|
|
670
|
+
let _instance = null;
|
|
671
|
+
export async function getSyncManager() {
|
|
672
|
+
if (_instance) return _instance;
|
|
673
|
+
const cfg = await SyncManager.loadConfig();
|
|
674
|
+
if (!cfg.remoteUrl) return null;
|
|
675
|
+
_instance = new SyncManager(cfg);
|
|
676
|
+
return _instance;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Hash a file content Buffer
|
|
681
|
+
*/
|
|
682
|
+
export { sha256 };
|