wispy-cli 1.2.3 → 1.3.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 +130 -1
- package/core/engine.mjs +11 -0
- package/core/index.mjs +1 -0
- package/core/memory.mjs +10 -1
- package/core/server.mjs +152 -2
- package/core/session.mjs +8 -0
- package/core/sync.mjs +682 -0
- package/package.json +1 -1
package/bin/wispy.mjs
CHANGED
|
@@ -156,6 +156,135 @@ if (args[0] === "disconnect") {
|
|
|
156
156
|
process.exit(0);
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
// ── sync sub-command ──────────────────────────────────────────────────────────
|
|
160
|
+
if (args[0] === "sync") {
|
|
161
|
+
const { SyncManager } = await import(
|
|
162
|
+
path.join(__dirname, "..", "core", "sync.mjs")
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const sub = args[1]; // push | pull | status | auto | (undefined = bidirectional)
|
|
166
|
+
|
|
167
|
+
const green = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
168
|
+
const red = (s) => `\x1b[31m${s}\x1b[0m`;
|
|
169
|
+
const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
|
|
170
|
+
const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
|
|
171
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
172
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
173
|
+
|
|
174
|
+
// Parse flags
|
|
175
|
+
const strategyIdx = args.indexOf("--strategy");
|
|
176
|
+
const strategy = strategyIdx !== -1 ? args[strategyIdx + 1] : null;
|
|
177
|
+
const memoryOnly = args.includes("--memory-only");
|
|
178
|
+
const sessionsOnly = args.includes("--sessions-only");
|
|
179
|
+
const remoteUrlIdx = args.indexOf("--remote");
|
|
180
|
+
const remoteUrlArg = remoteUrlIdx !== -1 ? args[remoteUrlIdx + 1] : null;
|
|
181
|
+
const tokenIdx = args.indexOf("--token");
|
|
182
|
+
const tokenArg = tokenIdx !== -1 ? args[tokenIdx + 1] : null;
|
|
183
|
+
|
|
184
|
+
// Load config (or use overrides)
|
|
185
|
+
const cfg = await SyncManager.loadConfig();
|
|
186
|
+
const remoteUrl = remoteUrlArg ?? cfg.remoteUrl;
|
|
187
|
+
const token = tokenArg ?? cfg.token;
|
|
188
|
+
|
|
189
|
+
// ── auto enable/disable ──
|
|
190
|
+
if (sub === "auto") {
|
|
191
|
+
const enable = !args.includes("--off");
|
|
192
|
+
const disable = args.includes("--off");
|
|
193
|
+
if (disable) {
|
|
194
|
+
await SyncManager.disableAuto();
|
|
195
|
+
console.log(`\n${yellow("⏸")} Auto-sync ${bold("disabled")}.\n`);
|
|
196
|
+
} else if (!remoteUrl) {
|
|
197
|
+
console.log(`\n${red("✗")} No remote URL configured.`);
|
|
198
|
+
console.log(dim(` Use: wispy sync auto --remote https://vps.com:18790 --token <token>\n`));
|
|
199
|
+
process.exit(1);
|
|
200
|
+
} else {
|
|
201
|
+
await SyncManager.enableAuto(remoteUrl, token ?? "");
|
|
202
|
+
console.log(`\n${green("✓")} Auto-sync ${bold("enabled")}.`);
|
|
203
|
+
console.log(` Remote: ${cyan(remoteUrl)}`);
|
|
204
|
+
console.log(dim(" Sessions and memory will be synced automatically.\n"));
|
|
205
|
+
}
|
|
206
|
+
process.exit(0);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── status ──
|
|
210
|
+
if (sub === "status") {
|
|
211
|
+
if (!remoteUrl) {
|
|
212
|
+
console.log(`\n${red("✗")} No remote configured. Set via sync.json or --remote flag.\n`);
|
|
213
|
+
process.exit(1);
|
|
214
|
+
}
|
|
215
|
+
console.log(`\n🔄 ${bold("Sync Status")}\n`);
|
|
216
|
+
console.log(` Remote: ${cyan(remoteUrl)}`);
|
|
217
|
+
const mgr = new SyncManager({ remoteUrl, token, strategy: strategy ?? "newer-wins" });
|
|
218
|
+
const s = await mgr.status(remoteUrl, token);
|
|
219
|
+
console.log(` Connection: ${s.reachable ? green("✅ reachable") : red("✗ unreachable")}\n`);
|
|
220
|
+
|
|
221
|
+
const fmt = (label, info) => {
|
|
222
|
+
const parts = [];
|
|
223
|
+
if (info.localOnly > 0) parts.push(`${yellow(info.localOnly + " local only")}`);
|
|
224
|
+
if (info.remoteOnly > 0) parts.push(`${cyan(info.remoteOnly + " remote only")}`);
|
|
225
|
+
if (info.inSync > 0) parts.push(`${green(info.inSync + " in sync")}`);
|
|
226
|
+
console.log(` ${bold(label.padEnd(14))} ${parts.join(", ") || dim("(empty)")}`);
|
|
227
|
+
};
|
|
228
|
+
fmt("Memory:", s.memory);
|
|
229
|
+
fmt("Sessions:", s.sessions);
|
|
230
|
+
fmt("Cron:", s.cron);
|
|
231
|
+
fmt("Workstreams:",s.workstreams);
|
|
232
|
+
fmt("Permissions:",s.permissions);
|
|
233
|
+
|
|
234
|
+
const needPull = (s.memory.remoteOnly + s.sessions.remoteOnly + s.cron.remoteOnly + s.workstreams.remoteOnly + s.permissions.remoteOnly);
|
|
235
|
+
const needPush = (s.memory.localOnly + s.sessions.localOnly + s.cron.localOnly + s.workstreams.localOnly + s.permissions.localOnly);
|
|
236
|
+
if (needPull > 0 || needPush > 0) {
|
|
237
|
+
console.log(`\n Action needed: ${needPull > 0 ? `pull ${yellow(needPull)} file(s)` : ""} ${needPush > 0 ? `push ${yellow(needPush)} file(s)` : ""}`.trimEnd());
|
|
238
|
+
console.log(dim(" Run 'wispy sync' to synchronize."));
|
|
239
|
+
} else {
|
|
240
|
+
console.log(`\n ${green("✓")} Everything in sync!`);
|
|
241
|
+
}
|
|
242
|
+
console.log("");
|
|
243
|
+
process.exit(0);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// For push/pull/sync we need a remote URL
|
|
247
|
+
if (!remoteUrl) {
|
|
248
|
+
console.log(`\n${red("✗")} No remote configured.`);
|
|
249
|
+
console.log(dim(" Set remoteUrl in ~/.wispy/sync.json or use --remote <url>\n"));
|
|
250
|
+
process.exit(1);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const opts = { strategy: strategy ?? cfg.strategy ?? "newer-wins", memoryOnly, sessionsOnly };
|
|
254
|
+
const mgr = new SyncManager({ remoteUrl, token, ...opts });
|
|
255
|
+
|
|
256
|
+
if (sub === "push") {
|
|
257
|
+
console.log(`\n📤 ${bold("Pushing")} to ${cyan(remoteUrl)}...`);
|
|
258
|
+
const result = await mgr.push(remoteUrl, token, opts);
|
|
259
|
+
console.log(` Pushed: ${green(result.pushed)}`);
|
|
260
|
+
console.log(` Skipped: ${dim(result.skipped)}`);
|
|
261
|
+
if (result.errors.length) result.errors.forEach(e => console.log(` ${red("✗")} ${e}`));
|
|
262
|
+
console.log(`\n${result.errors.length === 0 ? green("✓ Done") : yellow("⚠ Done with errors")}.\n`);
|
|
263
|
+
|
|
264
|
+
} else if (sub === "pull") {
|
|
265
|
+
console.log(`\n📥 ${bold("Pulling")} from ${cyan(remoteUrl)}...`);
|
|
266
|
+
const result = await mgr.pull(remoteUrl, token, opts);
|
|
267
|
+
console.log(` Pulled: ${green(result.pulled)}`);
|
|
268
|
+
console.log(` Skipped: ${dim(result.skipped)}`);
|
|
269
|
+
if (result.conflicts) console.log(` Conflicts: ${yellow(result.conflicts)} (saved as .conflict-* files)`);
|
|
270
|
+
if (result.errors.length) result.errors.forEach(e => console.log(` ${red("✗")} ${e}`));
|
|
271
|
+
console.log(`\n${result.errors.length === 0 ? green("✓ Done") : yellow("⚠ Done with errors")}.\n`);
|
|
272
|
+
|
|
273
|
+
} else {
|
|
274
|
+
// Bidirectional sync (default)
|
|
275
|
+
console.log(`\n🔄 ${bold("Syncing")} with ${cyan(remoteUrl)}...`);
|
|
276
|
+
const result = await mgr.sync(remoteUrl, token, opts);
|
|
277
|
+
console.log(` Pushed: ${green(result.pushed)}`);
|
|
278
|
+
console.log(` Pulled: ${green(result.pulled)}`);
|
|
279
|
+
console.log(` Skipped: ${dim(result.skipped)}`);
|
|
280
|
+
if (result.conflicts) console.log(` Conflicts: ${yellow(result.conflicts)} (saved as .conflict-* files)`);
|
|
281
|
+
if (result.errors.length) result.errors.forEach(e => console.log(` ${red("✗")} ${e}`));
|
|
282
|
+
console.log(`\n${result.errors.length === 0 ? green("✓ Done") : yellow("⚠ Done with errors")}.\n`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
process.exit(0);
|
|
286
|
+
}
|
|
287
|
+
|
|
159
288
|
// ── deploy sub-command ────────────────────────────────────────────────────────
|
|
160
289
|
if (args[0] === "deploy") {
|
|
161
290
|
const { DeployManager } = await import(
|
|
@@ -805,7 +934,7 @@ if (serveMode || telegramMode || discordMode || slackMode) {
|
|
|
805
934
|
const isInteractiveStart = !args.some(a =>
|
|
806
935
|
["--serve", "--telegram", "--discord", "--slack", "--server",
|
|
807
936
|
"status", "setup", "init", "connect", "disconnect", "deploy",
|
|
808
|
-
"cron", "audit", "log", "server", "node", "channel"].includes(a)
|
|
937
|
+
"cron", "audit", "log", "server", "node", "channel", "sync"].includes(a)
|
|
809
938
|
);
|
|
810
939
|
|
|
811
940
|
if (isInteractiveStart) {
|
package/core/engine.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import { SubAgentManager } from "./subagents.mjs";
|
|
|
24
24
|
import { PermissionManager } from "./permissions.mjs";
|
|
25
25
|
import { AuditLog, EVENT_TYPES } from "./audit.mjs";
|
|
26
26
|
import { Harness } from "./harness.mjs";
|
|
27
|
+
import { SyncManager, getSyncManager } from "./sync.mjs";
|
|
27
28
|
|
|
28
29
|
const MAX_TOOL_ROUNDS = 10;
|
|
29
30
|
const MAX_CONTEXT_CHARS = 40_000;
|
|
@@ -40,6 +41,7 @@ export class WispyEngine {
|
|
|
40
41
|
this.permissions = new PermissionManager(config.permissions ?? {});
|
|
41
42
|
this.audit = new AuditLog(WISPY_DIR);
|
|
42
43
|
this.harness = new Harness(this.tools, this.permissions, this.audit, config);
|
|
44
|
+
this.sync = null; // SyncManager, initialized lazily
|
|
43
45
|
this._initialized = false;
|
|
44
46
|
this._workMdContent = null;
|
|
45
47
|
this._workMdLoaded = false;
|
|
@@ -93,6 +95,15 @@ export class WispyEngine {
|
|
|
93
95
|
this.tools.registerMCP(this.mcpManager);
|
|
94
96
|
}
|
|
95
97
|
|
|
98
|
+
// Initialize sync manager (if configured)
|
|
99
|
+
try {
|
|
100
|
+
this.sync = await getSyncManager();
|
|
101
|
+
// Auto-sync on startup: pull any new data from remote
|
|
102
|
+
if (this.sync?.auto && this.sync.remoteUrl) {
|
|
103
|
+
this.sync.pull(this.sync.remoteUrl, this.sync.token).catch(() => {});
|
|
104
|
+
}
|
|
105
|
+
} catch { /* sync is optional */ }
|
|
106
|
+
|
|
96
107
|
this._initialized = true;
|
|
97
108
|
return this;
|
|
98
109
|
}
|
package/core/index.mjs
CHANGED
|
@@ -20,3 +20,4 @@ export { WispyServer } from "./server.mjs";
|
|
|
20
20
|
export { NodeManager, CAPABILITIES } from "./nodes.mjs";
|
|
21
21
|
export { Harness, Receipt, HarnessResult, computeUnifiedDiff } from "./harness.mjs";
|
|
22
22
|
export { DeployManager } from "./deploy.mjs";
|
|
23
|
+
export { SyncManager, getSyncManager, sha256 } from "./sync.mjs";
|
package/core/memory.mjs
CHANGED
|
@@ -39,7 +39,8 @@ export class MemoryManager {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
|
-
* Save (overwrite) a memory file
|
|
42
|
+
* Save (overwrite) a memory file.
|
|
43
|
+
* If auto-sync is enabled, pushes the file to remote (non-blocking).
|
|
43
44
|
*/
|
|
44
45
|
async save(key, content, metadata = {}) {
|
|
45
46
|
await this._ensureDir();
|
|
@@ -55,6 +56,14 @@ export class MemoryManager {
|
|
|
55
56
|
: `_Last updated: ${ts}_\n\n`;
|
|
56
57
|
|
|
57
58
|
await writeFile(filePath, header + content, "utf8");
|
|
59
|
+
|
|
60
|
+
// Auto-sync: push to remote if configured (fire-and-forget)
|
|
61
|
+
try {
|
|
62
|
+
const { getSyncManager } = await import("./sync.mjs");
|
|
63
|
+
const syncMgr = await getSyncManager();
|
|
64
|
+
if (syncMgr?.auto) syncMgr.pushFile(filePath);
|
|
65
|
+
} catch { /* sync is optional */ }
|
|
66
|
+
|
|
58
67
|
return { key, path: filePath };
|
|
59
68
|
}
|
|
60
69
|
|
package/core/server.mjs
CHANGED
|
@@ -7,11 +7,71 @@
|
|
|
7
7
|
|
|
8
8
|
import { createServer } from "node:http";
|
|
9
9
|
import path from "node:path";
|
|
10
|
-
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
11
|
-
import { randomBytes, createHmac } from "node:crypto";
|
|
10
|
+
import { readFile, writeFile, mkdir, readdir, stat } from "node:fs/promises";
|
|
11
|
+
import { randomBytes, createHmac, createHash } from "node:crypto";
|
|
12
12
|
|
|
13
13
|
import { WISPY_DIR } from "./config.mjs";
|
|
14
14
|
|
|
15
|
+
// ── Sync helpers ──────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
const SYNC_PATTERNS = [
|
|
18
|
+
(f) => f.startsWith("memory/") && f.endsWith(".md"),
|
|
19
|
+
(f) => f.startsWith("sessions/") && f.endsWith(".json"),
|
|
20
|
+
(f) => f === "cron/jobs.json",
|
|
21
|
+
(f) => /^workstreams\/[^/]+\/work\.md$/.test(f),
|
|
22
|
+
(f) => f === "permissions.json",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
function isSyncable(relPath) {
|
|
26
|
+
return SYNC_PATTERNS.some(fn => fn(relPath));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sha256(content) {
|
|
30
|
+
return createHash("sha256").update(content).digest("hex");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isPathSafe(relPath) {
|
|
34
|
+
// Reject absolute paths and path traversal
|
|
35
|
+
if (path.isAbsolute(relPath)) return false;
|
|
36
|
+
if (relPath.includes("..")) return false;
|
|
37
|
+
if (relPath.includes("\0")) return false;
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function scanSyncableFiles(baseDir) {
|
|
42
|
+
const entries = [];
|
|
43
|
+
await _scanDir(baseDir, baseDir, entries);
|
|
44
|
+
return entries;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function _scanDir(baseDir, dir, entries) {
|
|
48
|
+
let items;
|
|
49
|
+
try {
|
|
50
|
+
items = await readdir(dir, { withFileTypes: true });
|
|
51
|
+
} catch {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
for (const item of items) {
|
|
55
|
+
const fullPath = path.join(dir, item.name);
|
|
56
|
+
const relPath = path.relative(baseDir, fullPath).replace(/\\/g, "/");
|
|
57
|
+
if (item.isDirectory()) {
|
|
58
|
+
if (["node_modules", ".git", ".npm"].includes(item.name)) continue;
|
|
59
|
+
await _scanDir(baseDir, fullPath, entries);
|
|
60
|
+
} else if (item.isFile() && isSyncable(relPath)) {
|
|
61
|
+
try {
|
|
62
|
+
const content = await readFile(fullPath);
|
|
63
|
+
const fileStat = await stat(fullPath);
|
|
64
|
+
entries.push({
|
|
65
|
+
path: relPath,
|
|
66
|
+
hash: sha256(content),
|
|
67
|
+
modifiedAt: fileStat.mtime.toISOString(),
|
|
68
|
+
size: content.length,
|
|
69
|
+
});
|
|
70
|
+
} catch { /* skip */ }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
15
75
|
const SERVER_CONFIG_FILE = path.join(WISPY_DIR, "server.json");
|
|
16
76
|
const DEFAULT_PORT = 18790;
|
|
17
77
|
const DEFAULT_HOST = "127.0.0.1";
|
|
@@ -335,6 +395,96 @@ export class WispyServer {
|
|
|
335
395
|
}
|
|
336
396
|
}
|
|
337
397
|
|
|
398
|
+
// ── Sync ────────────────────────────────────────────────────────────────
|
|
399
|
+
|
|
400
|
+
// GET /api/sync/manifest — list all syncable files with hash + mtime
|
|
401
|
+
if (pathname === "/api/sync/manifest" && method === "GET") {
|
|
402
|
+
const files = await scanSyncableFiles(WISPY_DIR);
|
|
403
|
+
return jsonResponse(res, 200, { files });
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// GET /api/sync/file?path=<relPath> — download a file
|
|
407
|
+
if (pathname === "/api/sync/file" && method === "GET") {
|
|
408
|
+
const relPath = url.searchParams.get("path");
|
|
409
|
+
if (!relPath) return errorResponse(res, 400, "path required");
|
|
410
|
+
if (!isPathSafe(relPath)) return errorResponse(res, 400, "invalid path");
|
|
411
|
+
if (!isSyncable(relPath)) return errorResponse(res, 400, "path not syncable");
|
|
412
|
+
|
|
413
|
+
const fullPath = path.join(WISPY_DIR, relPath);
|
|
414
|
+
try {
|
|
415
|
+
const content = await readFile(fullPath);
|
|
416
|
+
const fileStat = await stat(fullPath);
|
|
417
|
+
return jsonResponse(res, 200, {
|
|
418
|
+
path: relPath,
|
|
419
|
+
content: content.toString("base64"),
|
|
420
|
+
encoding: "base64",
|
|
421
|
+
modifiedAt: fileStat.mtime.toISOString(),
|
|
422
|
+
hash: sha256(content),
|
|
423
|
+
size: content.length,
|
|
424
|
+
});
|
|
425
|
+
} catch {
|
|
426
|
+
return errorResponse(res, 404, "File not found");
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// POST /api/sync/file — upload a file
|
|
431
|
+
if (pathname === "/api/sync/file" && method === "POST") {
|
|
432
|
+
const body = await readBody(req);
|
|
433
|
+
const { path: relPath, content, encoding = "base64", modifiedAt } = body;
|
|
434
|
+
if (!relPath || content == null) return errorResponse(res, 400, "path and content required");
|
|
435
|
+
if (!isPathSafe(relPath)) return errorResponse(res, 400, "invalid path");
|
|
436
|
+
if (!isSyncable(relPath)) return errorResponse(res, 400, "path not syncable");
|
|
437
|
+
|
|
438
|
+
const fullPath = path.join(WISPY_DIR, relPath);
|
|
439
|
+
await mkdir(path.dirname(fullPath), { recursive: true });
|
|
440
|
+
const buf = Buffer.from(content, encoding === "base64" ? "base64" : "utf8");
|
|
441
|
+
await writeFile(fullPath, buf);
|
|
442
|
+
if (modifiedAt) {
|
|
443
|
+
const mtime = new Date(modifiedAt);
|
|
444
|
+
const { utimes } = await import("node:fs/promises");
|
|
445
|
+
await utimes(fullPath, mtime, mtime).catch(() => {});
|
|
446
|
+
}
|
|
447
|
+
return jsonResponse(res, 200, { success: true, path: relPath, size: buf.length });
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// POST /api/sync/bulk — upload multiple files at once
|
|
451
|
+
if (pathname === "/api/sync/bulk" && method === "POST") {
|
|
452
|
+
const body = await readBody(req);
|
|
453
|
+
const files = body.files ?? [];
|
|
454
|
+
if (!Array.isArray(files)) return errorResponse(res, 400, "files must be an array");
|
|
455
|
+
|
|
456
|
+
const results = [];
|
|
457
|
+
for (const file of files) {
|
|
458
|
+
const { path: relPath, content, encoding = "base64", modifiedAt } = file;
|
|
459
|
+
if (!relPath || content == null) {
|
|
460
|
+
results.push({ path: relPath, success: false, error: "missing path or content" });
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
if (!isPathSafe(relPath) || !isSyncable(relPath)) {
|
|
464
|
+
results.push({ path: relPath, success: false, error: "invalid or non-syncable path" });
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
try {
|
|
468
|
+
const fullPath = path.join(WISPY_DIR, relPath);
|
|
469
|
+
await mkdir(path.dirname(fullPath), { recursive: true });
|
|
470
|
+
const buf = Buffer.from(content, encoding === "base64" ? "base64" : "utf8");
|
|
471
|
+
await writeFile(fullPath, buf);
|
|
472
|
+
if (modifiedAt) {
|
|
473
|
+
const mtime = new Date(modifiedAt);
|
|
474
|
+
const { utimes } = await import("node:fs/promises");
|
|
475
|
+
await utimes(fullPath, mtime, mtime).catch(() => {});
|
|
476
|
+
}
|
|
477
|
+
results.push({ path: relPath, success: true, size: buf.length });
|
|
478
|
+
} catch (err) {
|
|
479
|
+
results.push({ path: relPath, success: false, error: err.message });
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const succeeded = results.filter(r => r.success).length;
|
|
484
|
+
const failed = results.filter(r => !r.success).length;
|
|
485
|
+
return jsonResponse(res, 200, { success: failed === 0, received: files.length, succeeded, failed, results });
|
|
486
|
+
}
|
|
487
|
+
|
|
338
488
|
// ── Audit ───────────────────────────────────────────────────────────────
|
|
339
489
|
if (pathname === "/api/audit" && method === "GET") {
|
|
340
490
|
const filter = {};
|
package/core/session.mjs
CHANGED
|
@@ -116,6 +116,7 @@ export class SessionManager {
|
|
|
116
116
|
|
|
117
117
|
/**
|
|
118
118
|
* Save a session to disk.
|
|
119
|
+
* If auto-sync is enabled, pushes the file to remote (non-blocking).
|
|
119
120
|
*/
|
|
120
121
|
async save(id) {
|
|
121
122
|
const session = this._sessions.get(id);
|
|
@@ -123,6 +124,13 @@ export class SessionManager {
|
|
|
123
124
|
await mkdir(SESSIONS_DIR, { recursive: true });
|
|
124
125
|
const filePath = path.join(SESSIONS_DIR, `${id}.json`);
|
|
125
126
|
await writeFile(filePath, JSON.stringify(session.toJSON(), null, 2) + "\n", "utf8");
|
|
127
|
+
|
|
128
|
+
// Auto-sync: push to remote if configured (fire-and-forget)
|
|
129
|
+
try {
|
|
130
|
+
const { getSyncManager } = await import("./sync.mjs");
|
|
131
|
+
const syncMgr = await getSyncManager();
|
|
132
|
+
if (syncMgr?.auto) syncMgr.pushFile(filePath);
|
|
133
|
+
} catch { /* sync is optional */ }
|
|
126
134
|
}
|
|
127
135
|
|
|
128
136
|
/**
|
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 };
|