@bitpub/cli 2.1.2 → 2.1.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitpub/cli",
3
- "version": "2.1.2",
3
+ "version": "2.1.3",
4
4
  "description": "BitPub CLI — local-first shared memory for AI agents. Six daily verbs (save/load/list/find/sync/delete), zero-config private namespace, encrypted client-side.",
5
5
  "bin": {
6
6
  "bitpub": "./bin/bitpub.js"
@@ -21,6 +21,7 @@ const { exec, spawn } = require('child_process');
21
21
  const { readConfig, BITPUB_DIR } = require('../config');
22
22
  const { getSyncedNamespaces, initCache } = require('../db/cache');
23
23
  const { isPrivateHcu, decrypt, isEncrypted } = require('../crypto');
24
+ const { runOneShot: runSyncOneShot } = require('./sync');
24
25
 
25
26
  const Database = require('better-sqlite3');
26
27
  const DB_PATH = path.join(os.homedir(), '.bitpub', 'cache.db');
@@ -366,6 +367,128 @@ function handleBridgeRun(req, res, url) {
366
367
  });
367
368
  }
368
369
 
370
+ /**
371
+ * Resolve the set of namespaces to sync for "sync everything visible
372
+ * to the current user." This is the same shape an attentive user would
373
+ * type by hand: their private root + every joined group.
374
+ *
375
+ * Returns an array of `{ pattern, label, config }` triples. The per-
376
+ * scope `config` is the user's primary config with `api_key` swapped
377
+ * out when a group entry pins its own key (multi-backend memberships).
378
+ */
379
+ function resolveSyncTargets(baseConfig) {
380
+ const targets = [];
381
+ if (baseConfig && baseConfig.owner) {
382
+ targets.push({
383
+ pattern: `bitpub://private:${baseConfig.owner}/**`,
384
+ label: `private:${baseConfig.owner}`,
385
+ config: baseConfig,
386
+ });
387
+ }
388
+ const groups = Array.isArray(baseConfig && baseConfig.groups) ? baseConfig.groups : [];
389
+ for (const g of groups) {
390
+ if (!g || !g.slug) continue;
391
+ targets.push({
392
+ pattern: `bitpub://group:${g.slug}/**`,
393
+ label: `group:${g.slug}`,
394
+ // Per-group key/URL is only relevant on multi-backend setups; for
395
+ // the common single-backend case these fields equal the primary
396
+ // config and the fallback is a no-op.
397
+ config: {
398
+ ...baseConfig,
399
+ api_key: g.key || baseConfig.api_key,
400
+ api_url: g.api_url || baseConfig.api_url,
401
+ },
402
+ });
403
+ }
404
+ return targets;
405
+ }
406
+
407
+ /**
408
+ * Stream `bitpub sync` across every scope visible to the user as SSE.
409
+ *
410
+ * Wire format:
411
+ * event: started data: { targets: [{label, pattern}, ...] }
412
+ * event: scope data: { label, pattern, status: 'syncing' }
413
+ * event: scope data: { label, pattern, status: 'done', count, hitLimit }
414
+ * event: scope data: { label, pattern, status: 'error', message }
415
+ * event: done data: { total, scopes } ← terminal; connection closes
416
+ *
417
+ * This is browser-chrome plumbing, not an app function — it calls
418
+ * sync.js#runOneShot directly inside this process rather than spawning
419
+ * `bitpub sync` as a subprocess via the script.run primitive. Reasons:
420
+ * • No shell-out cost, no quoting hazard, no env propagation issues.
421
+ * • Per-scope progress is structured (event stream), not parsed stdout.
422
+ * • Manifests are for user-published apps; the Sync button is part of
423
+ * the browser shell and shouldn't go through that machinery.
424
+ */
425
+ function handleBridgeSync(req, res) {
426
+ res.statusCode = 200;
427
+ res.setHeader('Content-Type', 'text/event-stream');
428
+ res.setHeader('Cache-Control', 'no-cache, no-transform');
429
+ res.setHeader('Connection', 'keep-alive');
430
+ res.setHeader('X-Accel-Buffering', 'no');
431
+
432
+ function send(event, payload) {
433
+ if (res.writableEnded) return;
434
+ res.write(`event: ${event}\n`);
435
+ res.write(`data: ${JSON.stringify(payload)}\n\n`);
436
+ }
437
+
438
+ const cfg = readConfig();
439
+ if (!cfg || !cfg.owner) {
440
+ send('error', { message: 'No bitpub identity configured. Run `bitpub setup` first.' });
441
+ send('done', { total: 0, scopes: 0 });
442
+ res.end();
443
+ return;
444
+ }
445
+
446
+ const targets = resolveSyncTargets(cfg);
447
+ send('started', { targets: targets.map(t => ({ label: t.label, pattern: t.pattern })) });
448
+
449
+ let cancelled = false;
450
+ req.on('close', () => { cancelled = true; });
451
+
452
+ // Sync scopes sequentially so the progress events tell a single story
453
+ // and we don't slam the same backend with N concurrent pulls.
454
+ (async () => {
455
+ let total = 0;
456
+ let okScopes = 0;
457
+ for (const t of targets) {
458
+ if (cancelled) return;
459
+ send('scope', { label: t.label, pattern: t.pattern, status: 'syncing' });
460
+ try {
461
+ const r = await runSyncOneShot(
462
+ { pattern: t.pattern, label: t.label, limit: 500, includeDeleted: false, quiet: true },
463
+ t.config
464
+ );
465
+ total += r.count;
466
+ okScopes += 1;
467
+ send('scope', {
468
+ label: t.label,
469
+ pattern: t.pattern,
470
+ status: 'done',
471
+ count: r.count,
472
+ hitLimit: !!r.hitLimit,
473
+ });
474
+ } catch (err) {
475
+ send('scope', {
476
+ label: t.label,
477
+ pattern: t.pattern,
478
+ status: 'error',
479
+ message: err && err.message ? err.message : String(err),
480
+ });
481
+ }
482
+ }
483
+ send('done', { total, scopes: okScopes });
484
+ res.end();
485
+ })().catch((err) => {
486
+ send('error', { message: err && err.message ? err.message : String(err) });
487
+ send('done', { total: 0, scopes: 0 });
488
+ res.end();
489
+ });
490
+ }
491
+
369
492
  function openInBrowser(url) {
370
493
  const cmd = process.platform === 'darwin' ? 'open' :
371
494
  process.platform === 'win32' ? 'start' : 'xdg-open';
@@ -469,6 +592,15 @@ function startBrowserServer(opts = {}) {
469
592
  return;
470
593
  }
471
594
 
595
+ // Bridge: sync every namespace visible to this user (private +
596
+ // each joined group) and stream per-scope progress as SSE. This
597
+ // backs the sidebar "Sync" button — it's browser chrome, not an
598
+ // app function, and so doesn't go through the manifest system.
599
+ if (url.pathname === '/bridge/sync') {
600
+ handleBridgeSync(req, res);
601
+ return;
602
+ }
603
+
472
604
  // Bridge: deterministic namespace read. Apps call this to fetch
473
605
  // any slices that match an HCU pattern (exact or prefix-with-/*).
474
606
  // No LLM, no MCP, no auth in the loop — this is the read path
@@ -60,7 +60,7 @@ function resolveSyncPattern(input, config) {
60
60
  return { pattern: active.namespace + resolved, label: input };
61
61
  }
62
62
 
63
- async function runOneShot({ pattern, label, limit, includeDeleted }, config) {
63
+ async function runOneShot({ pattern, label, limit, includeDeleted, quiet }, config) {
64
64
  const api = createApiClient(config);
65
65
  const limitNum = parseInt(limit, 10) || 500;
66
66
 
@@ -70,19 +70,24 @@ async function runOneShot({ pattern, label, limit, includeDeleted }, config) {
70
70
  for (const slice of slices) upsertSlice(slice);
71
71
  recordNamespaceSync(pattern, slices.length);
72
72
 
73
- console.log(`Synced ${slices.length} slice(s) from ${label}`);
74
-
75
- // Surface page-cap warning so the user knows to narrow their pattern.
76
- if (slices.length >= Math.min(limitNum, 500)) {
77
- console.log(
78
- `\n Hit the page limit (${slices.length} rows). Server caps single sync at 500.` +
79
- ` If you expect more, sync sub-namespaces individually:`
80
- );
81
- const base = pattern.replace(/\/\*+$/, '');
82
- console.log(` bitpub sync '${base}/Projects/**'`);
83
- console.log(` bitpub sync '${base}/Memory/**'`);
84
- console.log(` bitpub sync '${base}/Sessions/**'`);
73
+ const hitLimit = slices.length >= Math.min(limitNum, 500);
74
+
75
+ if (!quiet) {
76
+ console.log(`Synced ${slices.length} slice(s) from ${label}`);
77
+ // Surface page-cap warning so the user knows to narrow their pattern.
78
+ if (hitLimit) {
79
+ console.log(
80
+ `\n Hit the page limit (${slices.length} rows). Server caps single sync at 500.` +
81
+ ` If you expect more, sync sub-namespaces individually:`
82
+ );
83
+ const base = pattern.replace(/\/\*+$/, '');
84
+ console.log(` bitpub sync '${base}/Projects/**'`);
85
+ console.log(` bitpub sync '${base}/Memory/**'`);
86
+ console.log(` bitpub sync '${base}/Sessions/**'`);
87
+ }
85
88
  }
89
+
90
+ return { count: slices.length, hitLimit, pattern, label };
86
91
  }
87
92
 
88
93
  function runWatch({ pattern, label }, config) {
@@ -217,9 +217,27 @@ svg { flex-shrink: 0; }
217
217
  #main:hover::-webkit-scrollbar-thumb { background: var(--border); background-clip: content-box; }
218
218
 
219
219
  /* ── Sidebar / Tree ─────────────────────────────────── */
220
- .sidebar-header { padding: 12px 14px 8px; font-size: 10.5px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--text-subtle); display: flex; align-items: center; justify-content: space-between; }
220
+ .sidebar-header { padding: 12px 14px 8px; font-size: 10.5px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--text-subtle); display: flex; align-items: center; justify-content: space-between; gap: 8px; }
221
221
  .sidebar-header .clear-btn { font-size: 10.5px; color: var(--text-subtle); cursor: pointer; font-weight: 500; letter-spacing: 0; text-transform: none; }
222
222
  .sidebar-header .clear-btn:hover { color: var(--accent); }
223
+
224
+ /* Sync button — icon-only, ghost style, lives in the sidebar header.
225
+ Spins while a /bridge/sync request is in flight; pulses green for a
226
+ moment after a successful sync so the user gets feedback even if no
227
+ new slices landed. */
228
+ .sync-btn { display: inline-flex; align-items: center; gap: 4px; padding: 3px 6px; border: 0; background: transparent; color: var(--text-subtle); cursor: pointer; border-radius: var(--radius-sm); font: inherit; font-size: 10.5px; font-weight: 500; text-transform: none; letter-spacing: 0; }
229
+ .sync-btn:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); }
230
+ .sync-btn:disabled { opacity: .6; cursor: default; }
231
+ .sync-btn svg { width: 12px; height: 12px; transition: transform .2s ease-out; }
232
+ .sync-btn.is-syncing svg { animation: bp-spin 1s linear infinite; }
233
+ .sync-btn.is-done { color: #10b981; }
234
+ @keyframes bp-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
235
+ .sync-status { padding: 4px 14px 8px; font-size: 11px; color: var(--text-subtle); line-height: 1.4; max-height: 120px; overflow-y: auto; }
236
+ .sync-status.hidden { display: none; }
237
+ .sync-status .row { display: flex; justify-content: space-between; gap: 8px; padding: 2px 0; }
238
+ .sync-status .row .label { color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
239
+ .sync-status .row .count { color: var(--text-subtle); flex-shrink: 0; font-variant-numeric: tabular-nums; }
240
+ .sync-status .row.error .count { color: var(--scope-group); }
223
241
  .tree-wrap { flex: 1; overflow-y: auto; padding: 0 6px 12px; }
224
242
  .tree-wrap::-webkit-scrollbar { width: 8px; }
225
243
  .tree-wrap::-webkit-scrollbar-thumb { background: transparent; border-radius: 4px; }
@@ -972,8 +990,15 @@ svg { flex-shrink: 0; }
972
990
  <aside id="sidebar">
973
991
  <div class="sidebar-header">
974
992
  <span>Namespaces</span>
975
- <span id="clear-filter-btn" class="clear-btn hidden" onclick="clearFilter()">Clear</span>
993
+ <span style="display:inline-flex; align-items:center; gap:6px;">
994
+ <span id="clear-filter-btn" class="clear-btn hidden" onclick="clearFilter()">Clear</span>
995
+ <button id="sync-btn" class="sync-btn" type="button" onclick="runFullSync()" title="Sync all namespaces from the cloud into your local cache">
996
+ <svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 3a5 5 0 0 1 4.546 2.914.5.5 0 0 0 .908-.418A6 6 0 1 0 2 8a.5.5 0 1 0 1 0 5 5 0 0 1 5-5Z"/><path d="M8 13a5 5 0 0 1-4.546-2.914.5.5 0 0 0-.908.418A6 6 0 1 0 14 8a.5.5 0 1 0-1 0 5 5 0 0 1-5 5Z"/><path d="M11.5 1.5a.5.5 0 0 1 .5.5v3a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1 0-1H11V2a.5.5 0 0 1 .5-.5ZM4.5 10.5a.5.5 0 0 1 .5.5v2.5h2.5a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5v-3a.5.5 0 0 1 .5-.5Z"/></svg>
997
+ <span class="sync-btn-label">Sync</span>
998
+ </button>
999
+ </span>
976
1000
  </div>
1001
+ <div id="sync-status" class="sync-status hidden"></div>
977
1002
  <div id="tree" class="tree-wrap"></div>
978
1003
  </aside>
979
1004
  <main id="main"></main>
@@ -1456,6 +1481,135 @@ function clearFilter() {
1456
1481
  renderAll();
1457
1482
  }
1458
1483
 
1484
+ /* ── Sidebar Sync button ────────────────────────────────
1485
+ *
1486
+ * Streams /bridge/sync (SSE) and renders per-scope progress under the
1487
+ * header. On `done`, force a fresh /api/data poll so the tree picks up
1488
+ * any new arrivals immediately (without waiting for the 30s tick).
1489
+ *
1490
+ * The button is browser chrome — not an app function — so it talks
1491
+ * directly to /bridge/sync rather than going through window.bitpub.run
1492
+ * and the manifest dispatcher.
1493
+ *
1494
+ * Reentrancy: a second click while a sync is in flight is a no-op
1495
+ * (we disable the button), so we don't need to track an EventSource
1496
+ * handle for cancellation.
1497
+ */
1498
+ async function runFullSync() {
1499
+ const btn = $('sync-btn');
1500
+ const status = $('sync-status');
1501
+ if (!btn || btn.disabled) return;
1502
+
1503
+ btn.disabled = true;
1504
+ btn.classList.add('is-syncing');
1505
+ btn.classList.remove('is-done');
1506
+ status.classList.remove('hidden');
1507
+ status.innerHTML = '';
1508
+
1509
+ // Track rows so 'syncing' → 'done' overwrites in place rather than
1510
+ // appending a second line per scope.
1511
+ const rows = new Map();
1512
+
1513
+ function setRow(label, pattern, statusText, klass) {
1514
+ let row = rows.get(label);
1515
+ if (!row) {
1516
+ row = document.createElement('div');
1517
+ row.className = 'row';
1518
+ const a = document.createElement('span'); a.className = 'label';
1519
+ const b = document.createElement('span'); b.className = 'count';
1520
+ row.append(a, b);
1521
+ status.appendChild(row);
1522
+ rows.set(label, row);
1523
+ }
1524
+ row.className = 'row' + (klass ? ' ' + klass : '');
1525
+ row.title = pattern;
1526
+ row.children[0].textContent = label;
1527
+ row.children[1].textContent = statusText;
1528
+ }
1529
+
1530
+ const base = location.pathname.endsWith('/') ? location.pathname : location.pathname + '/';
1531
+ const es = new EventSource(base + 'bridge/sync');
1532
+
1533
+ function cleanup() {
1534
+ es.close();
1535
+ btn.disabled = false;
1536
+ btn.classList.remove('is-syncing');
1537
+ }
1538
+
1539
+ es.addEventListener('started', (e) => {
1540
+ let payload = {};
1541
+ try { payload = JSON.parse(e.data); } catch {}
1542
+ for (const t of (payload.targets || [])) setRow(t.label, t.pattern, 'queued', '');
1543
+ });
1544
+
1545
+ es.addEventListener('scope', (e) => {
1546
+ let payload = {};
1547
+ try { payload = JSON.parse(e.data); } catch {}
1548
+ if (payload.status === 'syncing') {
1549
+ setRow(payload.label, payload.pattern, 'syncing…', '');
1550
+ } else if (payload.status === 'done') {
1551
+ const cnt = (payload.count ?? 0) + ' slices';
1552
+ const suffix = payload.hitLimit ? ' (capped)' : '';
1553
+ setRow(payload.label, payload.pattern, cnt + suffix, '');
1554
+ } else if (payload.status === 'error') {
1555
+ setRow(payload.label, payload.pattern, 'error', 'error');
1556
+ console.error('[sync]', payload.label, payload.message);
1557
+ }
1558
+ });
1559
+
1560
+ es.addEventListener('error', (e) => {
1561
+ // Server-sent `event: error` (CLI-reported); EventSource also fires
1562
+ // 'error' on connection drops with no payload. Treat both the same.
1563
+ let payload = {};
1564
+ if (e && e.data) { try { payload = JSON.parse(e.data); } catch {} }
1565
+ if (payload.message) {
1566
+ const row = document.createElement('div');
1567
+ row.className = 'row error';
1568
+ row.innerHTML = '<span class="label">sync failed</span><span class="count"></span>';
1569
+ row.children[1].textContent = payload.message.slice(0, 60);
1570
+ status.appendChild(row);
1571
+ }
1572
+ // Don't cleanup here — wait for `done`, or for the connection to
1573
+ // close on its own. Real errors will be followed by `done`.
1574
+ });
1575
+
1576
+ es.addEventListener('done', async (e) => {
1577
+ let payload = {};
1578
+ try { payload = JSON.parse(e.data); } catch {}
1579
+ cleanup();
1580
+ btn.classList.add('is-done');
1581
+ setTimeout(() => btn.classList.remove('is-done'), 2000);
1582
+
1583
+ // Force-refresh the data so any newly-synced slices show up in
1584
+ // the tree immediately instead of on the next 30s poll tick.
1585
+ const data = await fetchData({ silent: true });
1586
+ if (data) {
1587
+ const arrivals = new Set();
1588
+ const oldKey = new Set(S.slices.map(s => s.hcu + '|' + (s.last_synced || '')));
1589
+ const nextSlices = data.slices.map(parseSlice);
1590
+ for (const s of nextSlices) {
1591
+ const k = s.hcu + '|' + (s.last_synced || '');
1592
+ if (!oldKey.has(k)) arrivals.add(s.hcu);
1593
+ }
1594
+ S.slices = nextSlices;
1595
+ S.tree = buildTree(S.slices);
1596
+ S.stats = computeStats(S.slices);
1597
+ S.lastUpdated = maxTs(S.slices);
1598
+ S.newArrivals = arrivals;
1599
+ renderAll();
1600
+ setTimeout(() => { S.newArrivals = new Set(); }, 2500);
1601
+ }
1602
+
1603
+ // Auto-hide the status panel after a beat if everything succeeded.
1604
+ const anyErrors = Array.from(rows.values()).some(r => r.classList.contains('error'));
1605
+ if (!anyErrors) {
1606
+ setTimeout(() => {
1607
+ if (!btn.classList.contains('is-syncing')) status.classList.add('hidden');
1608
+ }, 4000);
1609
+ }
1610
+ });
1611
+ }
1612
+
1459
1613
  /* ══════════════════════════════════════════════════════
1460
1614
  Top-level render
1461
1615
  ══════════════════════════════════════════════════════ */