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/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 };
@@ -923,7 +923,15 @@ const engine = new WispyEngine({ workstream: ACTIVE_WORKSTREAM });
923
923
  const initResult = await engine.init();
924
924
 
925
925
  if (!initResult) {
926
- await runOnboarding();
926
+ // Delegate to unified onboarding wizard
927
+ try {
928
+ const { OnboardingWizard } = await import("../core/onboarding.mjs");
929
+ const wizard = new OnboardingWizard();
930
+ await wizard.run();
931
+ } catch {
932
+ // Fallback to legacy inline onboarding
933
+ await runOnboarding();
934
+ }
927
935
  // Try again after onboarding
928
936
  const initResult2 = await engine.init();
929
937
  if (!initResult2) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wispy-cli",
3
- "version": "1.2.2",
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",