wispy-cli 1.2.2 → 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/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
  /**