@bitpub/cli 2.1.3 → 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.3",
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"
@@ -22,6 +22,7 @@ const { readConfig, BITPUB_DIR } = require('../config');
22
22
  const { getSyncedNamespaces, initCache } = require('../db/cache');
23
23
  const { isPrivateHcu, decrypt, isEncrypted } = require('../crypto');
24
24
  const { runOneShot: runSyncOneShot } = require('./sync');
25
+ const { _internals: groupInternals } = require('./group');
25
26
 
26
27
  const Database = require('better-sqlite3');
27
28
  const DB_PATH = path.join(os.homedir(), '.bitpub', 'cache.db');
@@ -489,6 +490,149 @@ function handleBridgeSync(req, res) {
489
490
  });
490
491
  }
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
+
492
636
  function openInBrowser(url) {
493
637
  const cmd = process.platform === 'darwin' ? 'open' :
494
638
  process.platform === 'win32' ? 'start' : 'xdg-open';
@@ -545,15 +689,34 @@ function startBrowserServer(opts = {}) {
545
689
 
546
690
  if (url.pathname === '/api/data') {
547
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 || {};
548
701
  const slices = getAllSlices().map(s => {
549
702
  if (cfg && cfg.api_key) return decryptSlice(s, cfg.api_key);
550
703
  return s;
551
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
+ : [];
552
714
  res.end(JSON.stringify({
553
715
  mode: 'local',
554
716
  domain: (cfg && cfg.domain) || '',
555
717
  slices,
556
718
  synced_namespaces: getSyncedNamespaces(),
719
+ known_groups: knownGroups,
557
720
  }));
558
721
  return;
559
722
  }
@@ -601,6 +764,21 @@ function startBrowserServer(opts = {}) {
601
764
  return;
602
765
  }
603
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
+
604
782
  // Bridge: deterministic namespace read. Apps call this to fetch
605
783
  // any slices that match an HCU pattern (exact or prefix-with-/*).
606
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
  };
@@ -1017,6 +1017,7 @@ const S = {
1017
1017
  stats: {},
1018
1018
  mode: 'remote',
1019
1019
  domain: '',
1020
+ knownGroups: [],
1020
1021
  apiKey: sessionStorage.getItem('bitpub_key'),
1021
1022
  raw: false,
1022
1023
  searchQuery: '',
@@ -1205,7 +1206,8 @@ function ingestData(data) {
1205
1206
  S.slices = data.slices.map(parseSlice);
1206
1207
  S.mode = data.mode || 'remote';
1207
1208
  S.domain = data.domain || '';
1208
- S.tree = buildTree(S.slices);
1209
+ S.knownGroups = Array.isArray(data.known_groups) ? data.known_groups : [];
1210
+ S.tree = buildTree(S.slices, S.knownGroups);
1209
1211
  S.stats = computeStats(S.slices);
1210
1212
  S.lastUpdated = maxTs(S.slices);
1211
1213
  }
@@ -1254,7 +1256,8 @@ async function pollData() {
1254
1256
 
1255
1257
  S.slices = nextSlices;
1256
1258
  S.mode = data.mode || S.mode;
1257
- 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);
1258
1261
  S.stats = computeStats(S.slices);
1259
1262
  S.lastUpdated = maxTs(S.slices);
1260
1263
  S.newArrivals = arrivals;
@@ -1345,7 +1348,7 @@ function pathOf(hcu) {
1345
1348
  return (m && m[1]) ? m[1].replace(/^\//, '').split('/') : [];
1346
1349
  }
1347
1350
 
1348
- function buildTree(slices) {
1351
+ function buildTree(slices, knownGroups) {
1349
1352
  const tree = {};
1350
1353
  for (const s of slices) {
1351
1354
  const { scope, type } = scopeOf(s.hcu);
@@ -1358,6 +1361,17 @@ function buildTree(slices) {
1358
1361
  if (i === segs.length - 1) node.slice = s;
1359
1362
  }
1360
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
+ }
1361
1375
  return tree;
1362
1376
  }
1363
1377
 
@@ -1592,7 +1606,8 @@ async function runFullSync() {
1592
1606
  if (!oldKey.has(k)) arrivals.add(s.hcu);
1593
1607
  }
1594
1608
  S.slices = nextSlices;
1595
- S.tree = buildTree(S.slices);
1609
+ S.knownGroups = Array.isArray(data.known_groups) ? data.known_groups : S.knownGroups;
1610
+ S.tree = buildTree(S.slices, S.knownGroups);
1596
1611
  S.stats = computeStats(S.slices);
1597
1612
  S.lastUpdated = maxTs(S.slices);
1598
1613
  S.newArrivals = arrivals;
@@ -2798,6 +2813,23 @@ const BRIDGE_SHIM_SOURCE = String.raw`
2798
2813
  private_prefix: d.private_prefix,
2799
2814
  });
2800
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
+ }
2801
2833
  }
2802
2834
  });
2803
2835
  window.bitpub = {
@@ -2879,6 +2911,69 @@ const BRIDGE_SHIM_SOURCE = String.raw`
2879
2911
  }, 5000);
2880
2912
  });
2881
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
+ }
2882
2977
  }
2883
2978
  };
2884
2979
  })();
@@ -3173,6 +3268,89 @@ async function handleBridgeNamespaceListMessage(source, msg) {
3173
3268
  }
3174
3269
  }
3175
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
+
3176
3354
  async function handleBridgeIdentityMessage(source, msg) {
3177
3355
  const { requestId } = msg;
3178
3356
  if (typeof requestId !== 'number') return;
@@ -3212,6 +3390,9 @@ window.addEventListener('message', (e) => {
3212
3390
  if (d.type === 'bitpub.run') handleBridgeRunMessage(e.source, d);
3213
3391
  else if (d.type === 'bitpub.namespace.list') handleBridgeNamespaceListMessage(e.source, d);
3214
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);
3215
3396
  else if (d.type === 'bitpub.navigate' || d.type === 'navigate') handleBridgeNavigateMessage(e.source, d);
3216
3397
  });
3217
3398
 
@@ -340,6 +340,331 @@
340
340
  details.recipe pre .p { color: #7c9d7a; }
341
341
  details.recipe pre .n { color: #b5a5ff; }
342
342
 
343
+ /* ─── Team / Groups section ─────────────────────────────
344
+ Dedicated component that lets the user create a group and
345
+ see the ones they're already in — no terminal commands.
346
+ The card mirrors the visual language of .meta-card so it
347
+ reads as "a serious thing, not a hint." Form + list live
348
+ inside the same card because the empty state ("no groups
349
+ yet") is itself a teaching moment about why groups exist. */
350
+ .team-card {
351
+ margin: 8px 0 52px;
352
+ background: var(--card);
353
+ border: 1px solid var(--line);
354
+ border-radius: var(--radius);
355
+ padding: 28px 30px 24px;
356
+ box-shadow: var(--shadow-1);
357
+ }
358
+ .team-card .team-hd { margin-bottom: 18px; }
359
+ .team-card .team-eyebrow {
360
+ display: inline-block;
361
+ font-size: 11px;
362
+ font-weight: 600;
363
+ letter-spacing: .06em;
364
+ text-transform: uppercase;
365
+ color: var(--accent);
366
+ background: var(--accent-bg);
367
+ padding: 3px 9px;
368
+ border-radius: 999px;
369
+ margin-bottom: 12px;
370
+ }
371
+ .team-card h2 {
372
+ font-family: var(--display);
373
+ font-size: 26px;
374
+ font-weight: 600;
375
+ letter-spacing: -.02em;
376
+ line-height: 1.2;
377
+ color: var(--text);
378
+ margin-bottom: 10px;
379
+ }
380
+ .team-card .lede-2 {
381
+ font-size: 14.5px;
382
+ color: var(--muted);
383
+ line-height: 1.6;
384
+ max-width: 64ch;
385
+ }
386
+ .team-card .lede-2 b { color: var(--text); font-weight: 600; }
387
+
388
+ /* Create form */
389
+ .team-form {
390
+ display: flex;
391
+ gap: 10px;
392
+ margin-top: 22px;
393
+ padding: 14px;
394
+ background: var(--inset-2);
395
+ border: 1px solid var(--line);
396
+ border-radius: var(--radius-sm);
397
+ }
398
+ .team-form input[type="text"] {
399
+ flex: 1;
400
+ min-width: 0;
401
+ padding: 10px 12px;
402
+ background: var(--card);
403
+ border: 1px solid var(--line-2);
404
+ border-radius: var(--radius-xs);
405
+ font: inherit;
406
+ font-size: 14px;
407
+ color: var(--text);
408
+ transition: border-color .15s ease, box-shadow .15s ease;
409
+ }
410
+ .team-form input[type="text"]:focus {
411
+ outline: none;
412
+ border-color: var(--accent);
413
+ box-shadow: 0 0 0 3px var(--accent-bg);
414
+ }
415
+ .team-form input[type="text"]::placeholder { color: var(--faint); }
416
+ .team-form input[type="text"]:disabled {
417
+ opacity: .6; cursor: not-allowed;
418
+ background: var(--inset);
419
+ }
420
+ .btn-primary {
421
+ padding: 10px 18px;
422
+ background: var(--accent);
423
+ color: white;
424
+ border: 0;
425
+ border-radius: var(--radius-xs);
426
+ font: inherit;
427
+ font-size: 13.5px;
428
+ font-weight: 600;
429
+ cursor: pointer;
430
+ transition: background .15s ease, transform .1s ease;
431
+ white-space: nowrap;
432
+ display: inline-flex;
433
+ align-items: center;
434
+ gap: 8px;
435
+ }
436
+ .btn-primary:hover { background: var(--accent-2); }
437
+ .btn-primary:active { transform: translateY(1px); }
438
+ .btn-primary:disabled {
439
+ opacity: .55; cursor: not-allowed; transform: none; background: var(--accent);
440
+ }
441
+ .btn-primary .spinner {
442
+ width: 12px; height: 12px;
443
+ border: 2px solid rgba(255,255,255,.35);
444
+ border-top-color: white;
445
+ border-radius: 50%;
446
+ animation: spin .6s linear infinite;
447
+ display: none;
448
+ }
449
+ .btn-primary.busy .spinner { display: inline-block; }
450
+ @keyframes spin { to { transform: rotate(360deg); } }
451
+
452
+ .team-error {
453
+ margin-top: 12px;
454
+ padding: 10px 12px;
455
+ background: rgba(229, 87, 70, .08);
456
+ border: 1px solid rgba(229, 87, 70, .25);
457
+ color: #b3392a;
458
+ border-radius: var(--radius-xs);
459
+ font-size: 13px;
460
+ line-height: 1.5;
461
+ }
462
+ .team-error.hidden { display: none; }
463
+
464
+ /* Groups list */
465
+ .team-list-h {
466
+ display: flex;
467
+ align-items: baseline;
468
+ gap: 10px;
469
+ margin: 28px 0 12px;
470
+ padding-top: 22px;
471
+ border-top: 1px solid var(--line);
472
+ }
473
+ .team-list-h h3 {
474
+ font-family: var(--display);
475
+ font-size: 15px;
476
+ font-weight: 600;
477
+ color: var(--text);
478
+ letter-spacing: -.005em;
479
+ }
480
+ .team-list-h .count {
481
+ font-size: 12px;
482
+ color: var(--subtle);
483
+ }
484
+ .team-list { display: flex; flex-direction: column; gap: 10px; }
485
+ .team-empty {
486
+ padding: 18px 16px;
487
+ background: var(--inset-2);
488
+ border: 1px dashed var(--line-2);
489
+ border-radius: var(--radius-sm);
490
+ color: var(--muted);
491
+ font-size: 13px;
492
+ line-height: 1.55;
493
+ text-align: center;
494
+ }
495
+ .group-row {
496
+ display: grid;
497
+ grid-template-columns: minmax(0, 1fr) auto;
498
+ gap: 14px;
499
+ align-items: start;
500
+ padding: 14px 16px;
501
+ background: var(--inset-2);
502
+ border: 1px solid var(--line);
503
+ border-radius: var(--radius-sm);
504
+ transition: background .15s ease;
505
+ }
506
+ .group-row.is-new {
507
+ background: var(--accent-bg);
508
+ border-color: rgba(255, 71, 26, .25);
509
+ animation: groupArrive .8s ease both;
510
+ }
511
+ @keyframes groupArrive { from { transform: scale(.98); opacity: 0; } to { transform: none; opacity: 1; } }
512
+ .group-row .info { min-width: 0; }
513
+ .group-row .name {
514
+ font-family: var(--display);
515
+ font-size: 15px;
516
+ font-weight: 600;
517
+ color: var(--text);
518
+ margin-bottom: 2px;
519
+ }
520
+ .group-row .role {
521
+ display: inline-block;
522
+ margin-left: 8px;
523
+ font-size: 10.5px;
524
+ font-weight: 600;
525
+ text-transform: uppercase;
526
+ letter-spacing: .04em;
527
+ padding: 1px 6px;
528
+ border-radius: 4px;
529
+ background: var(--inset);
530
+ color: var(--subtle);
531
+ vertical-align: 2px;
532
+ }
533
+ .group-row .role.owner {
534
+ background: var(--accent-bg);
535
+ color: var(--accent);
536
+ }
537
+ .group-row .addr {
538
+ font-family: var(--mono);
539
+ font-size: 12px;
540
+ color: var(--muted);
541
+ word-break: break-all;
542
+ line-height: 1.5;
543
+ }
544
+ .group-row .actions-row {
545
+ display: flex;
546
+ gap: 6px;
547
+ align-items: center;
548
+ }
549
+ .btn-ghost {
550
+ padding: 6px 10px;
551
+ background: var(--card);
552
+ border: 1px solid var(--line-2);
553
+ border-radius: var(--radius-xs);
554
+ font: inherit;
555
+ font-size: 12px;
556
+ font-weight: 500;
557
+ color: var(--muted);
558
+ cursor: pointer;
559
+ transition: background .15s ease, color .15s ease, border-color .15s ease;
560
+ display: inline-flex;
561
+ align-items: center;
562
+ gap: 6px;
563
+ }
564
+ .btn-ghost:hover {
565
+ background: var(--inset);
566
+ color: var(--text);
567
+ border-color: var(--line-2);
568
+ }
569
+ .btn-ghost.copied {
570
+ background: var(--ok-bg);
571
+ color: var(--ok);
572
+ border-color: rgba(42,157,110,.3);
573
+ }
574
+ .btn-ghost svg { width: 12px; height: 12px; }
575
+ .btn-ghost.danger:hover {
576
+ background: rgba(229, 87, 70, .08);
577
+ color: #b3392a;
578
+ border-color: rgba(229, 87, 70, .25);
579
+ }
580
+
581
+ .invite-banner {
582
+ grid-column: 1 / -1;
583
+ margin-top: 10px;
584
+ padding: 12px;
585
+ background: var(--card);
586
+ border: 1px solid var(--line);
587
+ border-radius: var(--radius-xs);
588
+ display: flex;
589
+ flex-direction: column;
590
+ gap: 8px;
591
+ }
592
+ .invite-banner .invite-url {
593
+ flex: 1;
594
+ min-width: 0;
595
+ font-family: var(--mono);
596
+ font-size: 12px;
597
+ color: var(--text);
598
+ background: var(--inset-2);
599
+ padding: 8px 10px;
600
+ border-radius: var(--radius-xs);
601
+ border: 1px solid var(--line);
602
+ overflow-x: auto;
603
+ white-space: nowrap;
604
+ }
605
+ .invite-banner .row {
606
+ display: flex;
607
+ gap: 10px;
608
+ align-items: center;
609
+ }
610
+ .invite-banner .row .invite-url { margin: 0; }
611
+ .invite-banner .rotate-link {
612
+ align-self: flex-start;
613
+ background: none;
614
+ border: 0;
615
+ padding: 0;
616
+ font: inherit;
617
+ font-size: 11.5px;
618
+ color: var(--subtle);
619
+ cursor: pointer;
620
+ text-decoration: underline dotted;
621
+ text-underline-offset: 3px;
622
+ }
623
+ .invite-banner .rotate-link:hover { color: var(--text); }
624
+
625
+ /* Terminal-style ephemeral "fetching..." indicator that flashes
626
+ in place of the invite button while the bridge does its server
627
+ round-trip. Makes the network step visible without being noisy. */
628
+ .term-pill {
629
+ display: inline-flex;
630
+ align-items: center;
631
+ gap: 8px;
632
+ padding: 6px 10px;
633
+ background: #1e1a19;
634
+ color: #e8e2dc;
635
+ border-radius: var(--radius-xs);
636
+ font-family: var(--mono);
637
+ font-size: 11.5px;
638
+ line-height: 1;
639
+ box-shadow: var(--shadow-1);
640
+ white-space: nowrap;
641
+ overflow: hidden;
642
+ max-width: 100%;
643
+ }
644
+ .term-pill .term-prompt { color: var(--accent); }
645
+ .term-pill .term-cmd { color: #e8e2dc; }
646
+ .term-pill .term-result {
647
+ color: #7c9d7a; /* green for success */
648
+ }
649
+ .term-pill.is-error { background: #3a1816; }
650
+ .term-pill.is-error .term-result { color: #ff8266; }
651
+ .term-pill .term-dots {
652
+ display: inline-flex; gap: 3px;
653
+ }
654
+ .term-pill .term-dots span {
655
+ width: 4px; height: 4px;
656
+ background: #e8e2dc;
657
+ border-radius: 50%;
658
+ opacity: .3;
659
+ animation: termDot 1.1s infinite;
660
+ }
661
+ .term-pill .term-dots span:nth-child(2) { animation-delay: .15s; }
662
+ .term-pill .term-dots span:nth-child(3) { animation-delay: .3s; }
663
+ @keyframes termDot {
664
+ 0%,80%,100% { opacity: .3; }
665
+ 40% { opacity: 1; }
666
+ }
667
+
343
668
  /* ─── Footnote ─────────────────────────────────────────── */
344
669
  .footnote {
345
670
  margin-top: 44px;
@@ -395,13 +720,51 @@
395
720
  </div>
396
721
  </div>
397
722
 
723
+ <!-- ═══════ Team / Groups ═══════
724
+ The group creation surface is its own dedicated component
725
+ rather than another copy-the-command card. The button below
726
+ is wired to window.bitpub.groups.create() (browser-chrome
727
+ bridge — deterministic, no LLM in the loop). The "Your
728
+ groups" list updates on success and persists across reloads. -->
729
+ <div class="team-card" id="team-card">
730
+ <div class="team-hd">
731
+ <span class="team-eyebrow">Your team workspace</span>
732
+ <h2>Create a group with your team</h2>
733
+ <p class="lede-2">
734
+ A group is a <b>shared namespace</b> &mdash; one address everyone on your team can read and write. No GitHub repos, no PRs, no email invites. You'll get a single link to share; teammates click it and they're in. Use a group for <b>team transcripts, shared apps, agents you want everyone to run, the running thread of context your team works against.</b>
735
+ </p>
736
+ </div>
737
+
738
+ <!-- Not a real <form>: the iframe sandbox is "allow-scripts" only
739
+ (no allow-forms), so form submission is blocked by the browser
740
+ and the submit event never fires. We listen on button-click +
741
+ Enter-in-input directly. -->
742
+ <div class="team-form" id="team-form">
743
+ <input type="text" id="team-name" placeholder="Name your team (e.g. Acme Engineering)"
744
+ maxlength="80" autocomplete="off">
745
+ <button class="btn-primary" type="button" id="team-create-btn">
746
+ <span class="spinner" aria-hidden="true"></span>
747
+ <span class="btn-label">Create group</span>
748
+ </button>
749
+ </div>
750
+ <div class="team-error hidden" id="team-error" role="alert"></div>
751
+
752
+ <div class="team-list-h">
753
+ <h3>Your groups</h3>
754
+ <span class="count" id="team-count">&mdash;</span>
755
+ </div>
756
+ <div class="team-list" id="team-list">
757
+ <div class="team-empty">Loading your groups&hellip;</div>
758
+ </div>
759
+ </div>
760
+
398
761
  <!-- ═══════ Actions ═══════ -->
399
762
  <div class="section-h">
400
- <h2>Three things to try</h2>
763
+ <h2>Two things to try</h2>
401
764
  <span class="meta">each takes under a minute &middot; click to copy</span>
402
765
  </div>
403
766
 
404
- <div class="actions">
767
+ <div class="actions" style="grid-template-columns: 1fr 1fr;">
405
768
 
406
769
  <!-- ── Card 1: look at your namespace ── -->
407
770
  <div class="action">
@@ -425,31 +788,9 @@
425
788
  </p>
426
789
  </div>
427
790
 
428
- <!-- ── Card 2: create a group ── -->
791
+ <!-- ── Card 2: build an app with the agent you already have ── -->
429
792
  <div class="action">
430
793
  <div class="action-num">2</div>
431
- <h3>Create a group with your team</h3>
432
- <p>
433
- Groups are shared namespaces. Members can read and write together. You'll get a shareable invite link to send your teammates &mdash; no GitHub, no email-invites, no PRs.
434
- </p>
435
- <div class="copy-block">
436
- <pre class="cmd"><span class="prompt">$ </span>bitpub group create yourcompany</pre>
437
- <button class="copy-btn" type="button" data-copy="bitpub group create yourcompany">
438
- <span class="label">
439
- <svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="7" height="7" rx="1"/><path d="M3 2h6"/></svg>
440
- Copy command
441
- </span>
442
- <span class="hint" aria-hidden="true">⌘V in terminal</span>
443
- </button>
444
- </div>
445
- <p class="followup">
446
- Then share the printed link. Teammates run <code>bitpub join &lt;link&gt;</code> and are in.
447
- </p>
448
- </div>
449
-
450
- <!-- ── Card 3: build an app with the agent you already have ── -->
451
- <div class="action">
452
- <div class="action-num">3</div>
453
794
  <h3>Build an app with the agent you already have</h3>
454
795
  <p>
455
796
  Paste the prompt below into Claude Code, Cursor, or Codex. Your agent will write you a small HTML app and save it to your namespace. It'll render here, in this same window.
@@ -569,6 +910,345 @@
569
910
  labelEl.innerHTML = original;
570
911
  }, 1600);
571
912
  }
913
+
914
+ /* ─── Team / Groups wiring ─────────────────────────────────────
915
+ Uses the browser-chrome bridge (window.bitpub.groups.*) — same
916
+ trust boundary as the sidebar Sync button. The create button is
917
+ deterministic: one HTTP POST to /bridge/group/create.
918
+
919
+ Invite reads are LAZY — clicking "Copy invite link" hits the
920
+ server fresh every time via GET /v1/groups/:slug/invites (server
921
+ is the source of truth). No local caching, no stale-link
922
+ hazards. The intermediate "fetching..." pill is intentional UX:
923
+ group ops require network and we want that visible. */
924
+ const groupsApi = (window.bitpub && window.bitpub.groups) || null;
925
+
926
+ // Tracks the slug of the group just created in this page session so
927
+ // we can anchor its row at the top and highlight it. Cleared on
928
+ // reload — purely cosmetic, no data lives here.
929
+ let lastCreatedSlug = null;
930
+
931
+ function el(html) {
932
+ const t = document.createElement('template');
933
+ t.innerHTML = html.trim();
934
+ return t.content.firstChild;
935
+ }
936
+
937
+ function escapeHtml(s) {
938
+ return String(s == null ? '' : s)
939
+ .replace(/&/g, '&amp;')
940
+ .replace(/</g, '&lt;')
941
+ .replace(/>/g, '&gt;')
942
+ .replace(/"/g, '&quot;')
943
+ .replace(/'/g, '&#39;');
944
+ }
945
+
946
+ function showTeamError(msg) {
947
+ const e = document.getElementById('team-error');
948
+ if (!e) return;
949
+ e.textContent = msg || '';
950
+ e.classList.toggle('hidden', !msg);
951
+ }
952
+
953
+ async function loadGroups() {
954
+ const list = document.getElementById('team-list');
955
+ const count = document.getElementById('team-count');
956
+ if (!groupsApi) {
957
+ list.innerHTML = '<div class="team-empty">Group management isn\'t available in this context. Open this page inside the BitPub Browser to manage groups visually.</div>';
958
+ count.textContent = '';
959
+ return;
960
+ }
961
+ try {
962
+ const { groups = [] } = await groupsApi.list();
963
+ renderGroups(groups);
964
+ } catch (err) {
965
+ list.innerHTML = `<div class="team-empty">Could not load your groups: ${escapeHtml(err.message || String(err))}</div>`;
966
+ count.textContent = '';
967
+ }
968
+ }
969
+
970
+ function renderGroups(groups) {
971
+ const list = document.getElementById('team-list');
972
+ const count = document.getElementById('team-count');
973
+ count.textContent = groups.length === 0
974
+ ? 'none yet'
975
+ : (groups.length === 1 ? '1 group' : `${groups.length} groups`);
976
+
977
+ if (groups.length === 0) {
978
+ list.innerHTML = '<div class="team-empty">You\'re not in any groups yet. Create one above and you\'ll see it here, along with the invite link to send your teammates.</div>';
979
+ return;
980
+ }
981
+
982
+ // Sort: most-recently-joined first, freshly-created group pinned
983
+ // to the top so it lands in the user's first glance.
984
+ const sorted = groups.slice().sort((a, b) => {
985
+ if (a.slug === lastCreatedSlug) return -1;
986
+ if (b.slug === lastCreatedSlug) return 1;
987
+ return String(b.joined_at || '').localeCompare(String(a.joined_at || ''));
988
+ });
989
+
990
+ list.innerHTML = '';
991
+ for (const g of sorted) {
992
+ const isNew = g.slug === lastCreatedSlug;
993
+ const roleBadge = g.role === 'owner'
994
+ ? '<span class="role owner">owner</span>'
995
+ : (g.role ? `<span class="role">${escapeHtml(g.role)}</span>` : '');
996
+
997
+ // Same affordance for every row: "Copy invite link". Clicking
998
+ // does a lazy server GET, copies to clipboard, then shows the
999
+ // URL in a banner so the user can confirm what got copied.
1000
+ const row = el(`<div class="group-row${isNew ? ' is-new' : ''}" data-slug="${escapeHtml(g.slug)}">
1001
+ <div class="info">
1002
+ <div class="name">${escapeHtml(g.display || g.slug)}${roleBadge}</div>
1003
+ <div class="addr">${escapeHtml(g.address || `bitpub://group:${g.slug}/`)}</div>
1004
+ </div>
1005
+ <div class="actions-row">
1006
+ <button class="btn-ghost open-btn" type="button" data-slug="${escapeHtml(g.slug)}" title="Open this group's namespace in the sidebar">
1007
+ <svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 2.5h5.5L9.5 3.5V9a.5.5 0 0 1-.5.5H3a.5.5 0 0 1-.5-.5V3a.5.5 0 0 1 .5-.5z"/></svg>
1008
+ Open
1009
+ </button>
1010
+ <button class="btn-ghost copy-invite-btn" type="button" data-slug="${escapeHtml(g.slug)}" title="Fetch and copy this group's invite link (one network call)">
1011
+ <svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="7" height="7" rx="1"/><path d="M3 2h6"/></svg>
1012
+ Copy invite link
1013
+ </button>
1014
+ </div>
1015
+ </div>`);
1016
+ list.appendChild(row);
1017
+ }
1018
+
1019
+ list.querySelectorAll('.copy-invite-btn').forEach(btn => {
1020
+ btn.addEventListener('click', () => copyInviteLazy(btn));
1021
+ });
1022
+ list.querySelectorAll('.open-btn').forEach(btn => {
1023
+ btn.addEventListener('click', () => {
1024
+ const slug = btn.getAttribute('data-slug');
1025
+ if (!slug) return;
1026
+ // Hand off to the parent BitPub Browser via the navigate
1027
+ // bridge — same path the sidebar uses.
1028
+ window.parent.postMessage({
1029
+ __bitpub: true,
1030
+ type: 'bitpub.navigate',
1031
+ hcu: `bitpub://group:${slug}/`,
1032
+ }, '*');
1033
+ });
1034
+ });
1035
+ }
1036
+
1037
+ /* Replace the button with a small terminal-style pill that shows
1038
+ the command being run, hold there until the bridge resolves,
1039
+ then either:
1040
+ - success: drop the URL banner under the row, copy to clipboard,
1041
+ swap the pill back to a "Copy invite link" button
1042
+ so a second copy is one click away
1043
+ - failure: turn the pill red with the error, restore the button
1044
+ after a few seconds */
1045
+ async function copyInviteLazy(btn) {
1046
+ const slug = btn.getAttribute('data-slug');
1047
+ if (!slug || !groupsApi) return;
1048
+ const row = btn.closest('.group-row');
1049
+ if (!row) return;
1050
+
1051
+ // Drop any prior banner from a previous click — single source of
1052
+ // truth per row.
1053
+ const prior = row.querySelector('.invite-banner');
1054
+ if (prior) prior.remove();
1055
+
1056
+ const cmd = `bitpub group invite ${slug} --show`;
1057
+ const pill = el(`<span class="term-pill" role="status">
1058
+ <span class="term-prompt">$</span>
1059
+ <span class="term-cmd">${escapeHtml(cmd)}</span>
1060
+ <span class="term-dots"><span></span><span></span><span></span></span>
1061
+ </span>`);
1062
+ btn.replaceWith(pill);
1063
+
1064
+ let result;
1065
+ try {
1066
+ result = await groupsApi.invite(slug, { rotate: false });
1067
+ } catch (err) {
1068
+ renderInviteError(pill, row, slug, err.message || 'fetch failed');
1069
+ return;
1070
+ }
1071
+ if (!result || !result.invite_url) {
1072
+ // Every invite has been revoked — surface as a non-fatal note
1073
+ // and offer to mint a new one in the same gesture.
1074
+ renderInviteEmpty(pill, row, slug);
1075
+ return;
1076
+ }
1077
+
1078
+ // Replace pill with the URL banner; copy to clipboard immediately.
1079
+ const banner = renderInviteBanner(row, slug, result.invite_url);
1080
+ pill.replaceWith(makeCopyAgainButton(slug));
1081
+ const ok = await copyToClipboard(result.invite_url);
1082
+ flashBannerCopied(banner, ok);
1083
+ }
1084
+
1085
+ function makeCopyAgainButton(slug) {
1086
+ const btn = el(`<button class="btn-ghost copy-invite-btn copied" type="button" data-slug="${escapeHtml(slug)}" title="Copy this invite link again">
1087
+ <svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M2 6.5L5 9.5l5-6"/></svg>
1088
+ Copied · click to re-fetch
1089
+ </button>`);
1090
+ btn.addEventListener('click', () => {
1091
+ // Strip the "copied" state so a fresh click starts a fresh fetch.
1092
+ btn.classList.remove('copied');
1093
+ btn.innerHTML = '<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="7" height="7" rx="1"/><path d="M3 2h6"/></svg> Copy invite link';
1094
+ copyInviteLazy(btn);
1095
+ });
1096
+ return btn;
1097
+ }
1098
+
1099
+ function renderInviteBanner(row, slug, url) {
1100
+ const banner = el(`<div class="invite-banner">
1101
+ <div class="row">
1102
+ <div class="invite-url" title="${escapeHtml(url)}">${escapeHtml(url)}</div>
1103
+ </div>
1104
+ <button class="rotate-link" type="button">Rotate this link (invalidates the URL above)</button>
1105
+ </div>`);
1106
+ banner.querySelector('.rotate-link').addEventListener('click', () => rotateLazy(slug, row));
1107
+ row.appendChild(banner);
1108
+ return banner;
1109
+ }
1110
+
1111
+ function renderInviteEmpty(pill, row, slug) {
1112
+ const note = el(`<div class="invite-banner">
1113
+ <div class="row">
1114
+ <div class="invite-url" style="color:var(--muted)">No active invite for this group — every prior link has been revoked.</div>
1115
+ </div>
1116
+ <button class="rotate-link" type="button">Mint a fresh invite link</button>
1117
+ </div>`);
1118
+ note.querySelector('.rotate-link').addEventListener('click', () => rotateLazy(slug, row));
1119
+ pill.replaceWith(el(`<button class="btn-ghost copy-invite-btn" type="button" data-slug="${escapeHtml(slug)}"><svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="7" height="7" rx="1"/><path d="M3 2h6"/></svg> Copy invite link</button>`));
1120
+ // Re-wire the click on the restored button.
1121
+ row.querySelector('.copy-invite-btn').addEventListener('click', (e) => copyInviteLazy(e.currentTarget));
1122
+ row.appendChild(note);
1123
+ }
1124
+
1125
+ function renderInviteError(pill, row, slug, message) {
1126
+ pill.classList.add('is-error');
1127
+ pill.innerHTML = `<span class="term-prompt">$</span><span class="term-cmd">bitpub group invite ${escapeHtml(slug)} --show</span><span class="term-result">→ ${escapeHtml(message)}</span>`;
1128
+ // After a beat, restore the button so the user can retry.
1129
+ setTimeout(() => {
1130
+ const btn = el(`<button class="btn-ghost copy-invite-btn" type="button" data-slug="${escapeHtml(slug)}" title="Try fetching again"><svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="7" height="7" rx="1"/><path d="M3 2h6"/></svg> Copy invite link</button>`);
1131
+ btn.addEventListener('click', () => copyInviteLazy(btn));
1132
+ pill.replaceWith(btn);
1133
+ }, 4000);
1134
+ }
1135
+
1136
+ function flashBannerCopied(banner, ok) {
1137
+ const urlEl = banner.querySelector('.invite-url');
1138
+ if (!urlEl) return;
1139
+ const original = urlEl.style.borderColor;
1140
+ urlEl.style.transition = 'border-color .2s ease, background .2s ease';
1141
+ urlEl.style.borderColor = ok ? 'rgba(42,157,110,.5)' : 'rgba(229,87,70,.5)';
1142
+ urlEl.style.background = ok ? 'var(--ok-bg)' : 'rgba(229,87,70,.08)';
1143
+ setTimeout(() => {
1144
+ urlEl.style.borderColor = original;
1145
+ urlEl.style.background = '';
1146
+ }, 900);
1147
+ }
1148
+
1149
+ async function rotateLazy(slug, row) {
1150
+ const ok = window.confirm(
1151
+ 'Generate a fresh invite link?\n\nThe link currently shown will stop working — anyone you haven\'t given the new one to will need it re-sent.'
1152
+ );
1153
+ if (!ok) return;
1154
+ const banner = row.querySelector('.invite-banner');
1155
+ // Show a terminal pill inside the banner while we rotate.
1156
+ const note = banner.querySelector('.invite-url');
1157
+ const original = note.outerHTML;
1158
+ const pill = el(`<span class="term-pill" role="status">
1159
+ <span class="term-prompt">$</span>
1160
+ <span class="term-cmd">bitpub group invite ${escapeHtml(slug)}</span>
1161
+ <span class="term-dots"><span></span><span></span><span></span></span>
1162
+ </span>`);
1163
+ note.replaceWith(pill);
1164
+ try {
1165
+ const r = await groupsApi.invite(slug, { rotate: true });
1166
+ if (!r || !r.invite_url) throw new Error('server returned no invite_url');
1167
+ const fresh = el(`<div class="invite-url" title="${escapeHtml(r.invite_url)}">${escapeHtml(r.invite_url)}</div>`);
1168
+ pill.replaceWith(fresh);
1169
+ await copyToClipboard(r.invite_url);
1170
+ flashBannerCopied(banner, true);
1171
+ } catch (err) {
1172
+ pill.classList.add('is-error');
1173
+ pill.innerHTML = `<span class="term-prompt">$</span><span class="term-cmd">bitpub group invite ${escapeHtml(slug)}</span><span class="term-result">→ ${escapeHtml(err.message || 'rotation failed')}</span>`;
1174
+ setTimeout(() => {
1175
+ const restored = el(original);
1176
+ pill.replaceWith(restored);
1177
+ }, 4000);
1178
+ }
1179
+ }
1180
+
1181
+ function setCreatingBusy(busy) {
1182
+ const btn = document.getElementById('team-create-btn');
1183
+ const input = document.getElementById('team-name');
1184
+ if (busy) {
1185
+ btn.classList.add('busy');
1186
+ btn.disabled = true;
1187
+ input.disabled = true;
1188
+ btn.querySelector('.btn-label').textContent = 'Creating…';
1189
+ } else {
1190
+ btn.classList.remove('busy');
1191
+ btn.disabled = false;
1192
+ input.disabled = false;
1193
+ btn.querySelector('.btn-label').textContent = 'Create group';
1194
+ }
1195
+ }
1196
+
1197
+ async function submitCreateGroup() {
1198
+ showTeamError('');
1199
+ const input = document.getElementById('team-name');
1200
+ const display = (input.value || '').trim();
1201
+ if (!display) {
1202
+ showTeamError('Give your group a name first.');
1203
+ input.focus();
1204
+ return;
1205
+ }
1206
+ if (!groupsApi) {
1207
+ showTeamError('Group management isn\'t available in this context.');
1208
+ return;
1209
+ }
1210
+ setCreatingBusy(true);
1211
+ try {
1212
+ const { group, invite_url } = await groupsApi.create(display);
1213
+ if (group && group.slug) lastCreatedSlug = group.slug;
1214
+ input.value = '';
1215
+ await loadGroups();
1216
+ // The create response already includes the invite URL — show
1217
+ // it under the new row without a second round-trip and copy
1218
+ // it to clipboard so the user can paste it immediately.
1219
+ if (group && group.slug && invite_url) {
1220
+ const row = document.querySelector(
1221
+ `.group-row[data-slug="${cssEscape(group.slug)}"]`
1222
+ );
1223
+ if (row) {
1224
+ renderInviteBanner(row, group.slug, invite_url);
1225
+ const ok = await copyToClipboard(invite_url);
1226
+ flashBannerCopied(row.querySelector('.invite-banner'), ok);
1227
+ }
1228
+ }
1229
+ } catch (err) {
1230
+ showTeamError(err.message || 'Could not create group');
1231
+ } finally {
1232
+ setCreatingBusy(false);
1233
+ }
1234
+ }
1235
+
1236
+ function cssEscape(s) {
1237
+ // Minimal CSS attribute-value escaper for our slug shape
1238
+ // ([a-z0-9-]+). Real CSS.escape() exists in modern browsers but
1239
+ // we keep this lightweight for older sandboxes.
1240
+ return String(s).replace(/["\\]/g, '\\$&');
1241
+ }
1242
+
1243
+ document.getElementById('team-create-btn').addEventListener('click', submitCreateGroup);
1244
+ document.getElementById('team-name').addEventListener('keydown', (e) => {
1245
+ if (e.key === 'Enter') { e.preventDefault(); submitCreateGroup(); }
1246
+ });
1247
+
1248
+ // Auto-load on mount. Tiny delay so the parent's bridge router
1249
+ // has wired up before we start postMessage-ing — defensive only;
1250
+ // the shim queues internally.
1251
+ setTimeout(loadGroups, 50);
572
1252
  </script>
573
1253
  </body>
574
1254
  </html>