@bitpub/cli 2.1.2 → 2.1.4

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.
@@ -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>
@@ -992,6 +1017,7 @@ const S = {
992
1017
  stats: {},
993
1018
  mode: 'remote',
994
1019
  domain: '',
1020
+ knownGroups: [],
995
1021
  apiKey: sessionStorage.getItem('bitpub_key'),
996
1022
  raw: false,
997
1023
  searchQuery: '',
@@ -1180,7 +1206,8 @@ function ingestData(data) {
1180
1206
  S.slices = data.slices.map(parseSlice);
1181
1207
  S.mode = data.mode || 'remote';
1182
1208
  S.domain = data.domain || '';
1183
- S.tree = buildTree(S.slices);
1209
+ S.knownGroups = Array.isArray(data.known_groups) ? data.known_groups : [];
1210
+ S.tree = buildTree(S.slices, S.knownGroups);
1184
1211
  S.stats = computeStats(S.slices);
1185
1212
  S.lastUpdated = maxTs(S.slices);
1186
1213
  }
@@ -1229,7 +1256,8 @@ async function pollData() {
1229
1256
 
1230
1257
  S.slices = nextSlices;
1231
1258
  S.mode = data.mode || S.mode;
1232
- S.tree = buildTree(S.slices);
1259
+ S.knownGroups = Array.isArray(data.known_groups) ? data.known_groups : S.knownGroups;
1260
+ S.tree = buildTree(S.slices, S.knownGroups);
1233
1261
  S.stats = computeStats(S.slices);
1234
1262
  S.lastUpdated = maxTs(S.slices);
1235
1263
  S.newArrivals = arrivals;
@@ -1320,7 +1348,7 @@ function pathOf(hcu) {
1320
1348
  return (m && m[1]) ? m[1].replace(/^\//, '').split('/') : [];
1321
1349
  }
1322
1350
 
1323
- function buildTree(slices) {
1351
+ function buildTree(slices, knownGroups) {
1324
1352
  const tree = {};
1325
1353
  for (const s of slices) {
1326
1354
  const { scope, type } = scopeOf(s.hcu);
@@ -1333,6 +1361,17 @@ function buildTree(slices) {
1333
1361
  if (i === segs.length - 1) node.slice = s;
1334
1362
  }
1335
1363
  }
1364
+ // Seed empty top-level nodes for groups the user belongs to but
1365
+ // hasn't put any slices in yet. The tree is otherwise slice-driven,
1366
+ // so brand-new groups would be invisible in the sidebar until the
1367
+ // first save — confusing right after `bitpub group create`.
1368
+ if (Array.isArray(knownGroups)) {
1369
+ for (const g of knownGroups) {
1370
+ if (!g || !g.slug) continue;
1371
+ const scope = `group:${g.slug}`;
1372
+ if (!tree[scope]) tree[scope] = { type: 'group', children: {}, count: 0 };
1373
+ }
1374
+ }
1336
1375
  return tree;
1337
1376
  }
1338
1377
 
@@ -1456,6 +1495,136 @@ function clearFilter() {
1456
1495
  renderAll();
1457
1496
  }
1458
1497
 
1498
+ /* ── Sidebar Sync button ────────────────────────────────
1499
+ *
1500
+ * Streams /bridge/sync (SSE) and renders per-scope progress under the
1501
+ * header. On `done`, force a fresh /api/data poll so the tree picks up
1502
+ * any new arrivals immediately (without waiting for the 30s tick).
1503
+ *
1504
+ * The button is browser chrome — not an app function — so it talks
1505
+ * directly to /bridge/sync rather than going through window.bitpub.run
1506
+ * and the manifest dispatcher.
1507
+ *
1508
+ * Reentrancy: a second click while a sync is in flight is a no-op
1509
+ * (we disable the button), so we don't need to track an EventSource
1510
+ * handle for cancellation.
1511
+ */
1512
+ async function runFullSync() {
1513
+ const btn = $('sync-btn');
1514
+ const status = $('sync-status');
1515
+ if (!btn || btn.disabled) return;
1516
+
1517
+ btn.disabled = true;
1518
+ btn.classList.add('is-syncing');
1519
+ btn.classList.remove('is-done');
1520
+ status.classList.remove('hidden');
1521
+ status.innerHTML = '';
1522
+
1523
+ // Track rows so 'syncing' → 'done' overwrites in place rather than
1524
+ // appending a second line per scope.
1525
+ const rows = new Map();
1526
+
1527
+ function setRow(label, pattern, statusText, klass) {
1528
+ let row = rows.get(label);
1529
+ if (!row) {
1530
+ row = document.createElement('div');
1531
+ row.className = 'row';
1532
+ const a = document.createElement('span'); a.className = 'label';
1533
+ const b = document.createElement('span'); b.className = 'count';
1534
+ row.append(a, b);
1535
+ status.appendChild(row);
1536
+ rows.set(label, row);
1537
+ }
1538
+ row.className = 'row' + (klass ? ' ' + klass : '');
1539
+ row.title = pattern;
1540
+ row.children[0].textContent = label;
1541
+ row.children[1].textContent = statusText;
1542
+ }
1543
+
1544
+ const base = location.pathname.endsWith('/') ? location.pathname : location.pathname + '/';
1545
+ const es = new EventSource(base + 'bridge/sync');
1546
+
1547
+ function cleanup() {
1548
+ es.close();
1549
+ btn.disabled = false;
1550
+ btn.classList.remove('is-syncing');
1551
+ }
1552
+
1553
+ es.addEventListener('started', (e) => {
1554
+ let payload = {};
1555
+ try { payload = JSON.parse(e.data); } catch {}
1556
+ for (const t of (payload.targets || [])) setRow(t.label, t.pattern, 'queued', '');
1557
+ });
1558
+
1559
+ es.addEventListener('scope', (e) => {
1560
+ let payload = {};
1561
+ try { payload = JSON.parse(e.data); } catch {}
1562
+ if (payload.status === 'syncing') {
1563
+ setRow(payload.label, payload.pattern, 'syncing…', '');
1564
+ } else if (payload.status === 'done') {
1565
+ const cnt = (payload.count ?? 0) + ' slices';
1566
+ const suffix = payload.hitLimit ? ' (capped)' : '';
1567
+ setRow(payload.label, payload.pattern, cnt + suffix, '');
1568
+ } else if (payload.status === 'error') {
1569
+ setRow(payload.label, payload.pattern, 'error', 'error');
1570
+ console.error('[sync]', payload.label, payload.message);
1571
+ }
1572
+ });
1573
+
1574
+ es.addEventListener('error', (e) => {
1575
+ // Server-sent `event: error` (CLI-reported); EventSource also fires
1576
+ // 'error' on connection drops with no payload. Treat both the same.
1577
+ let payload = {};
1578
+ if (e && e.data) { try { payload = JSON.parse(e.data); } catch {} }
1579
+ if (payload.message) {
1580
+ const row = document.createElement('div');
1581
+ row.className = 'row error';
1582
+ row.innerHTML = '<span class="label">sync failed</span><span class="count"></span>';
1583
+ row.children[1].textContent = payload.message.slice(0, 60);
1584
+ status.appendChild(row);
1585
+ }
1586
+ // Don't cleanup here — wait for `done`, or for the connection to
1587
+ // close on its own. Real errors will be followed by `done`.
1588
+ });
1589
+
1590
+ es.addEventListener('done', async (e) => {
1591
+ let payload = {};
1592
+ try { payload = JSON.parse(e.data); } catch {}
1593
+ cleanup();
1594
+ btn.classList.add('is-done');
1595
+ setTimeout(() => btn.classList.remove('is-done'), 2000);
1596
+
1597
+ // Force-refresh the data so any newly-synced slices show up in
1598
+ // the tree immediately instead of on the next 30s poll tick.
1599
+ const data = await fetchData({ silent: true });
1600
+ if (data) {
1601
+ const arrivals = new Set();
1602
+ const oldKey = new Set(S.slices.map(s => s.hcu + '|' + (s.last_synced || '')));
1603
+ const nextSlices = data.slices.map(parseSlice);
1604
+ for (const s of nextSlices) {
1605
+ const k = s.hcu + '|' + (s.last_synced || '');
1606
+ if (!oldKey.has(k)) arrivals.add(s.hcu);
1607
+ }
1608
+ S.slices = nextSlices;
1609
+ S.knownGroups = Array.isArray(data.known_groups) ? data.known_groups : S.knownGroups;
1610
+ S.tree = buildTree(S.slices, S.knownGroups);
1611
+ S.stats = computeStats(S.slices);
1612
+ S.lastUpdated = maxTs(S.slices);
1613
+ S.newArrivals = arrivals;
1614
+ renderAll();
1615
+ setTimeout(() => { S.newArrivals = new Set(); }, 2500);
1616
+ }
1617
+
1618
+ // Auto-hide the status panel after a beat if everything succeeded.
1619
+ const anyErrors = Array.from(rows.values()).some(r => r.classList.contains('error'));
1620
+ if (!anyErrors) {
1621
+ setTimeout(() => {
1622
+ if (!btn.classList.contains('is-syncing')) status.classList.add('hidden');
1623
+ }, 4000);
1624
+ }
1625
+ });
1626
+ }
1627
+
1459
1628
  /* ══════════════════════════════════════════════════════
1460
1629
  Top-level render
1461
1630
  ══════════════════════════════════════════════════════ */
@@ -2644,6 +2813,23 @@ const BRIDGE_SHIM_SOURCE = String.raw`
2644
2813
  private_prefix: d.private_prefix,
2645
2814
  });
2646
2815
  else ih.reject(new Error(d.error || 'identity lookup failed'));
2816
+ } else if (d.type === 'bitpub.groups.result') {
2817
+ var gh = readHandlers.get(d.requestId);
2818
+ if (!gh) return;
2819
+ readHandlers.delete(d.requestId);
2820
+ if (d.ok) {
2821
+ // Three response shapes share one event for symmetry with how
2822
+ // the parent router replies. Callers just see their op's keys.
2823
+ var payload = {};
2824
+ if (typeof d.group !== 'undefined') payload.group = d.group;
2825
+ if (typeof d.invite_url!== 'undefined') payload.invite_url = d.invite_url;
2826
+ if (typeof d.groups !== 'undefined') payload.groups = d.groups;
2827
+ if (typeof d.rotated !== 'undefined') payload.rotated = d.rotated;
2828
+ if (typeof d.needs_rotate !== 'undefined') payload.needs_rotate = d.needs_rotate;
2829
+ gh.resolve(payload);
2830
+ } else {
2831
+ gh.reject(new Error(d.error || 'groups op failed'));
2832
+ }
2647
2833
  }
2648
2834
  });
2649
2835
  window.bitpub = {
@@ -2725,6 +2911,69 @@ const BRIDGE_SHIM_SOURCE = String.raw`
2725
2911
  }, 5000);
2726
2912
  });
2727
2913
  }
2914
+ },
2915
+ groups: {
2916
+ // Group management (browser chrome — backed by the local CLI's
2917
+ // bridge endpoints, not the manifest/apps system). Available to
2918
+ // any slice the parent has agreed to render; the user already
2919
+ // trusts these endpoints by running 'bitpub browser'.
2920
+ //
2921
+ // create(display) -> Promise of { group, invite_url }
2922
+ // list() -> Promise of { groups }
2923
+ // invite(slug, opts) -> Promise of { invite_url, rotated, needs_rotate }
2924
+ // opts.rotate=true rotates server-side
2925
+ // (invalidates any link teammates hold).
2926
+ create: function (display) {
2927
+ return new Promise(function (resolve, reject) {
2928
+ var requestId = ++nextId;
2929
+ readHandlers.set(requestId, { resolve: resolve, reject: reject });
2930
+ window.parent.postMessage({
2931
+ __bitpub: true,
2932
+ type: 'bitpub.groups.create',
2933
+ requestId: requestId,
2934
+ display: String(display || ''),
2935
+ }, '*');
2936
+ setTimeout(function () {
2937
+ if (!readHandlers.has(requestId)) return;
2938
+ readHandlers.delete(requestId);
2939
+ reject(new Error('groups.create timed out'));
2940
+ }, 30000);
2941
+ });
2942
+ },
2943
+ list: function () {
2944
+ return new Promise(function (resolve, reject) {
2945
+ var requestId = ++nextId;
2946
+ readHandlers.set(requestId, { resolve: resolve, reject: reject });
2947
+ window.parent.postMessage({
2948
+ __bitpub: true,
2949
+ type: 'bitpub.groups.list',
2950
+ requestId: requestId,
2951
+ }, '*');
2952
+ setTimeout(function () {
2953
+ if (!readHandlers.has(requestId)) return;
2954
+ readHandlers.delete(requestId);
2955
+ reject(new Error('groups.list timed out'));
2956
+ }, 5000);
2957
+ });
2958
+ },
2959
+ invite: function (slug, opts) {
2960
+ return new Promise(function (resolve, reject) {
2961
+ var requestId = ++nextId;
2962
+ readHandlers.set(requestId, { resolve: resolve, reject: reject });
2963
+ window.parent.postMessage({
2964
+ __bitpub: true,
2965
+ type: 'bitpub.groups.invite',
2966
+ requestId: requestId,
2967
+ slug: String(slug || ''),
2968
+ rotate: !!(opts && opts.rotate),
2969
+ }, '*');
2970
+ setTimeout(function () {
2971
+ if (!readHandlers.has(requestId)) return;
2972
+ readHandlers.delete(requestId);
2973
+ reject(new Error('groups.invite timed out'));
2974
+ }, 30000);
2975
+ });
2976
+ }
2728
2977
  }
2729
2978
  };
2730
2979
  })();
@@ -3019,6 +3268,89 @@ async function handleBridgeNamespaceListMessage(source, msg) {
3019
3268
  }
3020
3269
  }
3021
3270
 
3271
+ async function handleBridgeGroupsMessage(source, msg) {
3272
+ // Three ops share one reply event for symmetry with the iframe-side
3273
+ // dispatcher. Each op posts whatever subset of keys the caller asked
3274
+ // for; the shim unpacks them.
3275
+ const { requestId, type } = msg;
3276
+ if (typeof requestId !== 'number') return;
3277
+ const reply = (extra) => {
3278
+ try {
3279
+ source.postMessage({
3280
+ __bitpub: true,
3281
+ type: 'bitpub.groups.result',
3282
+ requestId,
3283
+ ...extra,
3284
+ }, '*');
3285
+ } catch (_) { /* iframe gone */ }
3286
+ };
3287
+
3288
+ try {
3289
+ if (type === 'bitpub.groups.create') {
3290
+ const r = await fetch('/bridge/group/create', {
3291
+ method: 'POST',
3292
+ headers: { 'Content-Type': 'application/json' },
3293
+ body: JSON.stringify({ display: msg.display || '' }),
3294
+ });
3295
+ const body = await r.json();
3296
+ if (!r.ok || !body.ok) {
3297
+ reply({ ok: false, error: (body && body.error) || `http ${r.status}` });
3298
+ return;
3299
+ }
3300
+ // The new group will appear in the local config; refresh the
3301
+ // sidebar tree so it shows up alongside any existing namespaces.
3302
+ // We refetch /api/data immediately (rather than waiting for the
3303
+ // 30s poll) and re-run buildTree so the empty group node lands
3304
+ // in the sidebar.
3305
+ (async () => {
3306
+ try {
3307
+ const fresh = await fetchData({ silent: true });
3308
+ if (!fresh) return;
3309
+ S.slices = fresh.slices.map(parseSlice);
3310
+ S.knownGroups = Array.isArray(fresh.known_groups) ? fresh.known_groups : S.knownGroups;
3311
+ S.tree = buildTree(S.slices, S.knownGroups);
3312
+ S.stats = computeStats(S.slices);
3313
+ renderAll();
3314
+ } catch (_) { /* poll will catch up */ }
3315
+ })();
3316
+ reply({ ok: true, group: body.group, invite_url: body.invite_url });
3317
+ return;
3318
+ }
3319
+ if (type === 'bitpub.groups.list') {
3320
+ const r = await fetch('/bridge/group/list');
3321
+ const body = await r.json();
3322
+ if (!r.ok || !body.ok) {
3323
+ reply({ ok: false, error: (body && body.error) || `http ${r.status}` });
3324
+ return;
3325
+ }
3326
+ reply({ ok: true, groups: body.groups || [] });
3327
+ return;
3328
+ }
3329
+ if (type === 'bitpub.groups.invite') {
3330
+ const r = await fetch('/bridge/group/invite', {
3331
+ method: 'POST',
3332
+ headers: { 'Content-Type': 'application/json' },
3333
+ body: JSON.stringify({ slug: msg.slug || '', rotate: !!msg.rotate }),
3334
+ });
3335
+ const body = await r.json();
3336
+ if (!r.ok || !body.ok) {
3337
+ reply({ ok: false, error: (body && body.error) || `http ${r.status}` });
3338
+ return;
3339
+ }
3340
+ reply({
3341
+ ok: true,
3342
+ invite_url: body.invite_url || null,
3343
+ rotated: !!body.rotated,
3344
+ needs_rotate: !!body.needs_rotate,
3345
+ });
3346
+ return;
3347
+ }
3348
+ reply({ ok: false, error: `unknown groups op: ${type}` });
3349
+ } catch (err) {
3350
+ reply({ ok: false, error: (err && err.message) || 'fetch failed' });
3351
+ }
3352
+ }
3353
+
3022
3354
  async function handleBridgeIdentityMessage(source, msg) {
3023
3355
  const { requestId } = msg;
3024
3356
  if (typeof requestId !== 'number') return;
@@ -3058,6 +3390,9 @@ window.addEventListener('message', (e) => {
3058
3390
  if (d.type === 'bitpub.run') handleBridgeRunMessage(e.source, d);
3059
3391
  else if (d.type === 'bitpub.namespace.list') handleBridgeNamespaceListMessage(e.source, d);
3060
3392
  else if (d.type === 'bitpub.identity.me') handleBridgeIdentityMessage(e.source, d);
3393
+ else if (d.type === 'bitpub.groups.create' ||
3394
+ d.type === 'bitpub.groups.list' ||
3395
+ d.type === 'bitpub.groups.invite') handleBridgeGroupsMessage(e.source, d);
3061
3396
  else if (d.type === 'bitpub.navigate' || d.type === 'navigate') handleBridgeNavigateMessage(e.source, d);
3062
3397
  });
3063
3398