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 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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wispy-cli",
3
- "version": "1.2.3",
3
+ "version": "1.3.0",
4
4
  "description": "🌿 Wispy — AI workspace assistant with trustworthy execution (harness, receipts, approvals, diffs)",
5
5
  "license": "MIT",
6
6
  "author": "Minseo & Poropo",