@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitpub/cli",
3
- "version": "2.1.2",
3
+ "version": "2.1.4",
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,8 @@ 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');
25
+ const { _internals: groupInternals } = require('./group');
24
26
 
25
27
  const Database = require('better-sqlite3');
26
28
  const DB_PATH = path.join(os.homedir(), '.bitpub', 'cache.db');
@@ -366,6 +368,271 @@ function handleBridgeRun(req, res, url) {
366
368
  });
367
369
  }
368
370
 
371
+ /**
372
+ * Resolve the set of namespaces to sync for "sync everything visible
373
+ * to the current user." This is the same shape an attentive user would
374
+ * type by hand: their private root + every joined group.
375
+ *
376
+ * Returns an array of `{ pattern, label, config }` triples. The per-
377
+ * scope `config` is the user's primary config with `api_key` swapped
378
+ * out when a group entry pins its own key (multi-backend memberships).
379
+ */
380
+ function resolveSyncTargets(baseConfig) {
381
+ const targets = [];
382
+ if (baseConfig && baseConfig.owner) {
383
+ targets.push({
384
+ pattern: `bitpub://private:${baseConfig.owner}/**`,
385
+ label: `private:${baseConfig.owner}`,
386
+ config: baseConfig,
387
+ });
388
+ }
389
+ const groups = Array.isArray(baseConfig && baseConfig.groups) ? baseConfig.groups : [];
390
+ for (const g of groups) {
391
+ if (!g || !g.slug) continue;
392
+ targets.push({
393
+ pattern: `bitpub://group:${g.slug}/**`,
394
+ label: `group:${g.slug}`,
395
+ // Per-group key/URL is only relevant on multi-backend setups; for
396
+ // the common single-backend case these fields equal the primary
397
+ // config and the fallback is a no-op.
398
+ config: {
399
+ ...baseConfig,
400
+ api_key: g.key || baseConfig.api_key,
401
+ api_url: g.api_url || baseConfig.api_url,
402
+ },
403
+ });
404
+ }
405
+ return targets;
406
+ }
407
+
408
+ /**
409
+ * Stream `bitpub sync` across every scope visible to the user as SSE.
410
+ *
411
+ * Wire format:
412
+ * event: started data: { targets: [{label, pattern}, ...] }
413
+ * event: scope data: { label, pattern, status: 'syncing' }
414
+ * event: scope data: { label, pattern, status: 'done', count, hitLimit }
415
+ * event: scope data: { label, pattern, status: 'error', message }
416
+ * event: done data: { total, scopes } ← terminal; connection closes
417
+ *
418
+ * This is browser-chrome plumbing, not an app function — it calls
419
+ * sync.js#runOneShot directly inside this process rather than spawning
420
+ * `bitpub sync` as a subprocess via the script.run primitive. Reasons:
421
+ * • No shell-out cost, no quoting hazard, no env propagation issues.
422
+ * • Per-scope progress is structured (event stream), not parsed stdout.
423
+ * • Manifests are for user-published apps; the Sync button is part of
424
+ * the browser shell and shouldn't go through that machinery.
425
+ */
426
+ function handleBridgeSync(req, res) {
427
+ res.statusCode = 200;
428
+ res.setHeader('Content-Type', 'text/event-stream');
429
+ res.setHeader('Cache-Control', 'no-cache, no-transform');
430
+ res.setHeader('Connection', 'keep-alive');
431
+ res.setHeader('X-Accel-Buffering', 'no');
432
+
433
+ function send(event, payload) {
434
+ if (res.writableEnded) return;
435
+ res.write(`event: ${event}\n`);
436
+ res.write(`data: ${JSON.stringify(payload)}\n\n`);
437
+ }
438
+
439
+ const cfg = readConfig();
440
+ if (!cfg || !cfg.owner) {
441
+ send('error', { message: 'No bitpub identity configured. Run `bitpub setup` first.' });
442
+ send('done', { total: 0, scopes: 0 });
443
+ res.end();
444
+ return;
445
+ }
446
+
447
+ const targets = resolveSyncTargets(cfg);
448
+ send('started', { targets: targets.map(t => ({ label: t.label, pattern: t.pattern })) });
449
+
450
+ let cancelled = false;
451
+ req.on('close', () => { cancelled = true; });
452
+
453
+ // Sync scopes sequentially so the progress events tell a single story
454
+ // and we don't slam the same backend with N concurrent pulls.
455
+ (async () => {
456
+ let total = 0;
457
+ let okScopes = 0;
458
+ for (const t of targets) {
459
+ if (cancelled) return;
460
+ send('scope', { label: t.label, pattern: t.pattern, status: 'syncing' });
461
+ try {
462
+ const r = await runSyncOneShot(
463
+ { pattern: t.pattern, label: t.label, limit: 500, includeDeleted: false, quiet: true },
464
+ t.config
465
+ );
466
+ total += r.count;
467
+ okScopes += 1;
468
+ send('scope', {
469
+ label: t.label,
470
+ pattern: t.pattern,
471
+ status: 'done',
472
+ count: r.count,
473
+ hitLimit: !!r.hitLimit,
474
+ });
475
+ } catch (err) {
476
+ send('scope', {
477
+ label: t.label,
478
+ pattern: t.pattern,
479
+ status: 'error',
480
+ message: err && err.message ? err.message : String(err),
481
+ });
482
+ }
483
+ }
484
+ send('done', { total, scopes: okScopes });
485
+ res.end();
486
+ })().catch((err) => {
487
+ send('error', { message: err && err.message ? err.message : String(err) });
488
+ send('done', { total: 0, scopes: 0 });
489
+ res.end();
490
+ });
491
+ }
492
+
493
+ /* ── Groups (browser chrome) ─────────────────────────────────────
494
+ *
495
+ * Three small JSON endpoints that back the "Make a group" / "Your
496
+ * groups" UI in the Welcome slice (and anywhere else in the browser
497
+ * chrome that wants to read/write group membership).
498
+ *
499
+ * Like /bridge/sync, this lives outside the manifest/apps system —
500
+ * group management is part of the browser shell itself, not something
501
+ * a user-published app should be able to do unprompted.
502
+ *
503
+ * Wire format:
504
+ * POST /bridge/group/create { display }
505
+ * → { ok, group: { slug, display, hosted_on, base_url, role, joined_at }, invite_url }
506
+ *
507
+ * GET /bridge/group/list
508
+ * → { ok, groups: [{ slug, display, address, role, joined_at, api_url }] }
509
+ *
510
+ * POST /bridge/group/invite { slug, rotate }
511
+ * rotate=false (default) → GET the current active invite from the
512
+ * server (server is source of truth);
513
+ * returns { invite_url, rotated: false }.
514
+ * invite_url is null if every invite has
515
+ * been revoked and not yet replaced.
516
+ * rotate=true → POST a new invite (invalidates the prior one);
517
+ * returns { invite_url, rotated: true }.
518
+ *
519
+ * No local invite-URL caching: every read is a live server call. Group
520
+ * operations require network connectivity by design (create/join/leave
521
+ * all hit the server), so making the read path also network-bound is
522
+ * consistent and avoids sync hazards (rotate from machine A, stale
523
+ * cache on machine B).
524
+ */
525
+ function jsonBody(req) {
526
+ return new Promise((resolve, reject) => {
527
+ let buf = '';
528
+ req.setEncoding('utf-8');
529
+ req.on('data', (c) => {
530
+ buf += c;
531
+ // Reject oversized bodies early — group endpoints take small JSON only.
532
+ if (buf.length > 16 * 1024) { reject(new Error('body too large')); req.destroy(); }
533
+ });
534
+ req.on('end', () => {
535
+ if (!buf) return resolve({});
536
+ try { resolve(JSON.parse(buf)); }
537
+ catch (err) { reject(new Error(`invalid JSON: ${err.message}`)); }
538
+ });
539
+ req.on('error', reject);
540
+ });
541
+ }
542
+
543
+ function sendJson(res, status, payload) {
544
+ res.statusCode = status;
545
+ res.setHeader('Content-Type', 'application/json');
546
+ res.end(JSON.stringify(payload));
547
+ }
548
+
549
+ async function handleBridgeGroupCreate(req, res) {
550
+ let body;
551
+ try { body = await jsonBody(req); }
552
+ catch (err) { return sendJson(res, 400, { ok: false, error: err.message }); }
553
+
554
+ const display = String((body && body.display) || '').trim();
555
+ if (!display) return sendJson(res, 400, { ok: false, error: 'display name required' });
556
+ if (display.length > 80) return sendJson(res, 400, { ok: false, error: 'display name too long (max 80 chars)' });
557
+
558
+ try {
559
+ // createGroup itself caches invite_url locally (see group.js) so
560
+ // we don't need to do it here too.
561
+ const out = await groupInternals.createGroup(display);
562
+ sendJson(res, 200, {
563
+ ok: true,
564
+ group: {
565
+ slug: out.slug,
566
+ display: out.display,
567
+ hosted_on: out.hosted_on || null,
568
+ base_url: out.base_url || null,
569
+ role: 'owner',
570
+ },
571
+ invite_url: out.invite_url || null,
572
+ });
573
+ } catch (err) {
574
+ const msg = (err && err.message) || String(err);
575
+ sendJson(res, 500, { ok: false, error: msg });
576
+ }
577
+ }
578
+
579
+ function handleBridgeGroupList(_req, res) {
580
+ const cfg = readConfig() || {};
581
+ const groups = Array.isArray(cfg.groups) ? cfg.groups : [];
582
+ const out = groups.map((g) => ({
583
+ slug: g.slug,
584
+ display: g.display || g.slug,
585
+ address: `bitpub://group:${g.slug}/`,
586
+ role: g.role || null,
587
+ joined_at: g.joined_at || null,
588
+ api_url: g.api_url || null,
589
+ }));
590
+ sendJson(res, 200, { ok: true, groups: out });
591
+ }
592
+
593
+ async function handleBridgeGroupInvite(req, res) {
594
+ let body;
595
+ try { body = await jsonBody(req); }
596
+ catch (err) { return sendJson(res, 400, { ok: false, error: err.message }); }
597
+
598
+ const slug = String((body && body.slug) || '').trim();
599
+ const rotate = !!(body && body.rotate);
600
+ if (!slug) return sendJson(res, 400, { ok: false, error: 'slug required' });
601
+
602
+ try {
603
+ if (rotate) {
604
+ const out = await groupInternals.rotateInvite(slug);
605
+ return sendJson(res, 200, {
606
+ ok: true,
607
+ invite_url: out.invite_url,
608
+ rotated: true,
609
+ });
610
+ }
611
+ // Read path — server is the source of truth (GET /v1/groups/:slug/invites).
612
+ // Returns { invite_url: null } if every invite has been revoked.
613
+ const out = await groupInternals.showInvite(slug);
614
+ sendJson(res, 200, {
615
+ ok: true,
616
+ invite_url: out.invite_url || null,
617
+ rotated: false,
618
+ });
619
+ } catch (err) {
620
+ const raw = (err && err.message) || String(err);
621
+ // httpJson wraps upstream errors as "[STATUS] message". Pluck the
622
+ // status so we can return a matching HTTP code, and strip the
623
+ // prefix from the message so the UI renders just the human part.
624
+ const m = /^\[(\d{3})\]\s*(.*)$/.exec(raw);
625
+ const status = m ? Number(m[1]) : 500;
626
+ let message = m ? m[2] : raw;
627
+ if (status === 404 && /not found/i.test(message)) {
628
+ // Most common reason today: the running backend hasn't shipped
629
+ // GET /v1/groups/:slug/invites yet. Make that diagnosable.
630
+ message = 'This backend does not support reading invites (upgrade required).';
631
+ }
632
+ sendJson(res, status, { ok: false, error: message });
633
+ }
634
+ }
635
+
369
636
  function openInBrowser(url) {
370
637
  const cmd = process.platform === 'darwin' ? 'open' :
371
638
  process.platform === 'win32' ? 'start' : 'xdg-open';
@@ -422,15 +689,34 @@ function startBrowserServer(opts = {}) {
422
689
 
423
690
  if (url.pathname === '/api/data') {
424
691
  res.setHeader('Content-Type', 'application/json');
692
+ // Re-read the config on every /api/data hit. The captured
693
+ // `cfg` above is set once at server start, but groups (and
694
+ // other config) can change mid-session — e.g. after the
695
+ // Welcome UI calls /bridge/group/create. Without this we'd
696
+ // serve a stale known_groups list and the new group wouldn't
697
+ // show in the sidebar until the user restarted `bitpub
698
+ // browser`. We fall back to the boot cfg for the key/domain
699
+ // fields, which don't change at runtime.
700
+ const liveCfg = readConfig() || cfg || {};
425
701
  const slices = getAllSlices().map(s => {
426
702
  if (cfg && cfg.api_key) return decryptSlice(s, cfg.api_key);
427
703
  return s;
428
704
  });
705
+ // known_groups: every group in the user's local config, even
706
+ // groups that contain zero slices yet. Lets the sidebar tree
707
+ // render a freshly-created group address as an empty top-level
708
+ // node — without this, `bitpub group create` would succeed
709
+ // but nothing visible would change until the first slice
710
+ // landed under that namespace.
711
+ const knownGroups = Array.isArray(liveCfg.groups)
712
+ ? liveCfg.groups.map((g) => ({ slug: g.slug, display: g.display || g.slug }))
713
+ : [];
429
714
  res.end(JSON.stringify({
430
715
  mode: 'local',
431
716
  domain: (cfg && cfg.domain) || '',
432
717
  slices,
433
718
  synced_namespaces: getSyncedNamespaces(),
719
+ known_groups: knownGroups,
434
720
  }));
435
721
  return;
436
722
  }
@@ -469,6 +755,30 @@ function startBrowserServer(opts = {}) {
469
755
  return;
470
756
  }
471
757
 
758
+ // Bridge: sync every namespace visible to this user (private +
759
+ // each joined group) and stream per-scope progress as SSE. This
760
+ // backs the sidebar "Sync" button — it's browser chrome, not an
761
+ // app function, and so doesn't go through the manifest system.
762
+ if (url.pathname === '/bridge/sync') {
763
+ handleBridgeSync(req, res);
764
+ return;
765
+ }
766
+
767
+ // Bridge: group management for the Welcome UI (create, list,
768
+ // invite). Same chrome-not-app reasoning as /bridge/sync.
769
+ if (url.pathname === '/bridge/group/create' && req.method === 'POST') {
770
+ handleBridgeGroupCreate(req, res);
771
+ return;
772
+ }
773
+ if (url.pathname === '/bridge/group/list' && req.method === 'GET') {
774
+ handleBridgeGroupList(req, res);
775
+ return;
776
+ }
777
+ if (url.pathname === '/bridge/group/invite' && req.method === 'POST') {
778
+ handleBridgeGroupInvite(req, res);
779
+ return;
780
+ }
781
+
472
782
  // Bridge: deterministic namespace read. Apps call this to fetch
473
783
  // any slices that match an HCU pattern (exact or prefix-with-/*).
474
784
  // No LLM, no MCP, no auth in the loop — this is the read path
@@ -99,6 +99,12 @@ async function createGroup(display) {
99
99
  // the right backend with the same personal key (creator stays on
100
100
  // their own key; the per-group-key mint happens at *accept* time, not
101
101
  // create time, since the creator already has a key on this backend).
102
+ //
103
+ // We deliberately do NOT cache invite_url here. The server is the
104
+ // source of truth — `bitpub group invite <slug> --show` (and the
105
+ // browser-chrome `/bridge/group/invite` endpoint) re-fetch it via
106
+ // `listInvites` below whenever needed. Caching it would just be a
107
+ // sync hazard (rotate from another machine, lose the cache, etc.).
102
108
  upsertGroupEntry({
103
109
  slug: data.slug,
104
110
  display: data.display,
@@ -112,6 +118,23 @@ async function createGroup(display) {
112
118
  return { ...data, invite_url: data.invite_url, base_url: baseUrl };
113
119
  }
114
120
 
121
+ // ── group invite show (read current invite from the server) ────────────────
122
+
123
+ async function showInvite(slug) {
124
+ const config = requireConfig();
125
+ const entry = groupEntryFor(config, slug);
126
+ if (!entry) {
127
+ throw new Error(`Not a member of group "${slug}". Run \`bitpub group list\` to see your groups.`);
128
+ }
129
+ const baseUrl = trimSlash(entry.api_url) || primaryApiUrl(config);
130
+ // GET /v1/groups/:slug/invites returns the current active invite for
131
+ // members. Server is the source of truth — we don't cache.
132
+ const data = await httpJson('GET', `${baseUrl}/v1/groups/${encodeURIComponent(slug)}/invites`, {
133
+ headers: { 'x-api-key': entry.key },
134
+ });
135
+ return data; // { slug, display, invite_url, created_at }
136
+ }
137
+
115
138
  // ── group list ─────────────────────────────────────────────────────────────
116
139
 
117
140
  async function listGroupsLocal() {
@@ -128,7 +151,10 @@ async function rotateInvite(slug, { show } = {}) {
128
151
  throw new Error(`Not a member of group "${slug}". Run \`bitpub group list\` to see your groups.`);
129
152
  }
130
153
  if (show) {
131
- throw new Error('--show is not yet implemented (server does not expose the current token to non-creators by design). Use `bitpub group invite <slug>` to rotate and get a fresh URL.');
154
+ // `--show` reads the current invite without rotating. Implemented
155
+ // by GET /v1/groups/:slug/invites (members-only) above; same
156
+ // endpoint the Welcome UI's lazy "Copy invite link" calls.
157
+ return showInvite(slug);
132
158
  }
133
159
  const baseUrl = trimSlash(entry.api_url) || primaryApiUrl(config);
134
160
  const data = await httpJson('POST', `${baseUrl}/v1/groups/${encodeURIComponent(slug)}/invites`, {
@@ -259,11 +285,21 @@ module.exports = function registerGroup(program) {
259
285
 
260
286
  group
261
287
  .command('invite <slug>')
262
- .description('Rotate the shareable invite link for a group')
263
- .option('--show', '[not yet implemented] Show the current link without rotating it')
288
+ .description('Show or rotate the shareable invite link for a group')
289
+ .option('--show', 'Show the current link without rotating it (read-only)')
264
290
  .action(async (slug, opts) => {
265
291
  try {
266
292
  const out = await rotateInvite(slug, opts);
293
+ if (opts.show) {
294
+ if (out.invite_url) {
295
+ console.log(`\n Share link for ${out.display || out.slug}:`);
296
+ console.log(` ${out.invite_url}`);
297
+ } else {
298
+ console.log(`\n No active invite link for ${out.display || out.slug}.`);
299
+ console.log(` Mint one: bitpub group invite ${out.slug}`);
300
+ }
301
+ return;
302
+ }
267
303
  console.log(`\n✓ Rotated invite for ${out.display || out.slug}`);
268
304
  console.log(`\n New share link:`);
269
305
  console.log(` ${out.invite_url}`);
@@ -329,5 +365,6 @@ module.exports._internals = {
329
365
  createGroup,
330
366
  joinFromInvite,
331
367
  rotateInvite,
368
+ showInvite,
332
369
  leaveGroup,
333
370
  };
@@ -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) {