@bitpub/cli 2.0.2 → 2.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/bitpub.js CHANGED
@@ -39,6 +39,7 @@ require('../src/commands/delete')(program);
39
39
 
40
40
  // ── Setup (rare, agent-invoked) ─────────────────────────────────────────────
41
41
  require('../src/commands/setup')(program);
42
+ require('../src/commands/group')(program);
42
43
  require('../src/commands/alias')(program);
43
44
  require('../src/commands/seed')(program);
44
45
  require('../src/commands/browser')(program);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitpub/cli",
3
- "version": "2.0.2",
3
+ "version": "2.0.3",
4
4
  "description": "BitPub CLI — local-first shared memory for AI agents. Six daily verbs (save/load/list/find/sync/delete), zero-config private namespace, encrypted client-side.",
5
5
  "bin": {
6
6
  "bitpub": "./bin/bitpub.js"
package/src/api.js CHANGED
@@ -2,74 +2,106 @@
2
2
 
3
3
  const axios = require('axios');
4
4
 
5
+ const { groupEntryFor } = require('./config');
6
+
5
7
  /**
6
- * Create an API client bound to a specific config.
7
- * All methods throw on non-2xx responses (axios default behavior).
8
- */
9
- /**
10
- * Pick the right API key for a given HCU.
11
- * Private namespaces use the personal api_key provisioned by `bitpub init`.
12
- * Group/public namespaces use group_key set by `bitpub auth login`, falling
13
- * back to api_key so single-key setups keep working.
8
+ * Resolve the backend (`url` + `key`) to use for a given HCU.
9
+ *
10
+ * private:<owner>/... → primary api_url + api_key from config
11
+ * group:<slug>/... → matching entry in config.groups[]
12
+ * (falls back to legacy top-level group_key/domain
13
+ * for the single-domain enterprise install)
14
+ * public/... → primary api_url + api_key
15
+ * bare patterns → same as primary (used for sync of mixed scopes)
16
+ *
17
+ * Throws if the HCU points at a group the caller isn't a member of —
18
+ * surfacing this at request time means the user gets a clear message
19
+ * ("Not a member of bitpub://group:foo") instead of an opaque 401.
14
20
  */
15
- function keyForHcu(config, hcu) {
16
- if (typeof hcu === 'string' && hcu.startsWith('bitpub://private:')) {
17
- return config.api_key;
21
+ function resolveBackend(config, hcu) {
22
+ const baseUrl = (config.api_url || 'http://localhost:8080').replace(/\/$/, '');
23
+ const fallback = { url: baseUrl, key: config.api_key, source: 'primary' };
24
+
25
+ if (typeof hcu !== 'string') return fallback;
26
+
27
+ // Extract the group slug if this is a group-scoped HCU.
28
+ const m = hcu.match(/^bitpub:\/\/group:([^/]+)/);
29
+ if (!m) return fallback;
30
+
31
+ const slug = m[1];
32
+
33
+ // New flow: look in config.groups[] for a matching entry.
34
+ const entry = groupEntryFor(config, slug);
35
+ if (entry && entry.key) {
36
+ return {
37
+ url: (entry.api_url || baseUrl).replace(/\/$/, ''),
38
+ key: entry.key,
39
+ source: 'group',
40
+ slug,
41
+ };
42
+ }
43
+
44
+ // Legacy single-domain enterprise flow: pre-v2 deploys had a top-level
45
+ // group_key + domain. The lazy migration in config.js mirrors these
46
+ // into config.groups[], so this branch should rarely fire after the
47
+ // first read — kept for completeness.
48
+ if (config.group_key && config.domain && config.domain === slug) {
49
+ return { url: baseUrl, key: config.group_key, source: 'legacy-group', slug };
18
50
  }
19
- return config.group_key || config.api_key;
51
+
52
+ // No matching key. Two reasonable behaviors:
53
+ // - fall through to primary (will get a 403 from the server)
54
+ // - throw here with a clearer message
55
+ // We pick the second so the user gets actionable feedback.
56
+ const err = new Error(
57
+ `Not a member of bitpub://group:${slug}/. Ask the group owner for an invite link, then run: bitpub join <link>`
58
+ );
59
+ err.code = 'NOT_A_MEMBER';
60
+ throw err;
20
61
  }
21
62
 
63
+ /**
64
+ * Create an API client bound to a specific config.
65
+ *
66
+ * Each method resolves the backend (url + key) from the HCU it's about
67
+ * to operate on. Group-scoped HCUs route through `config.groups[]` to
68
+ * the host backend with the per-group key; private/public HCUs use the
69
+ * top-level `api_url` + `api_key`.
70
+ *
71
+ * All methods throw on non-2xx responses (axios default behavior, wrapped
72
+ * by the response interceptor so errors carry the server-side message).
73
+ */
22
74
  function createApiClient(config) {
23
- const baseURL = (config.api_url || 'http://localhost:8080').replace(/\/$/, '');
24
-
25
- const http = axios.create({
26
- baseURL,
27
- timeout: 30_000,
28
- });
29
-
30
- // Surface server-side error messages cleanly, preserving response for callers
31
- http.interceptors.response.use(
32
- res => res,
33
- err => {
34
- const msg = err.response?.data?.error || err.message;
35
- const status = err.response?.status;
36
- const wrapped = new Error(status ? `[${status}] ${msg}` : msg);
37
- wrapped.status = status;
38
- wrapped.response = err.response;
39
- return Promise.reject(wrapped);
40
- }
41
- );
75
+ function http(backend) {
76
+ const inst = axios.create({
77
+ baseURL: backend.url,
78
+ timeout: 30_000,
79
+ headers: { 'x-api-key': backend.key },
80
+ });
81
+ inst.interceptors.response.use(
82
+ (res) => res,
83
+ (err) => {
84
+ const msg = err.response?.data?.error || err.message;
85
+ const status = err.response?.status;
86
+ const wrapped = new Error(status ? `[${status}] ${msg}` : msg);
87
+ wrapped.status = status;
88
+ wrapped.response = err.response;
89
+ return Promise.reject(wrapped);
90
+ }
91
+ );
92
+ return inst;
93
+ }
42
94
 
43
95
  return {
44
- /**
45
- * Pull context slices from the remote ledger.
46
- * @param {string} hcu
47
- * @param {number} [limit]
48
- * @param {object} [opts]
49
- * @param {boolean} [opts.includeDeleted] surface tombstoned slices
50
- * @param {boolean} [opts.mine] filter to slices written by this key
51
- * @returns {Promise<Array>} array of slice DTOs
52
- */
53
96
  async pull(hcu, limit = 50, opts = {}) {
97
+ const backend = resolveBackend(config, hcu);
54
98
  const params = { hcu, limit };
55
99
  if (opts.includeDeleted) params.include_deleted = 'true';
56
100
  if (opts.mine) params.mine = 'true';
57
- const res = await http.get('/v1/context/pull', {
58
- params,
59
- headers: { 'x-api-key': keyForHcu(config, hcu) },
60
- });
101
+ const res = await http(backend).get('/v1/context/pull', { params });
61
102
  return res.data;
62
103
  },
63
104
 
64
- /**
65
- * Push a context slice to the remote ledger.
66
- * @param {object} body Full ContextSlice payload
67
- * @param {object} [opts]
68
- * @param {boolean} [opts.append] append instead of overwrite
69
- * @param {number} [opts.expectVersion] reject with 409 on version mismatch
70
- * @param {boolean} [opts.force] un-tombstone a deleted slice with new content
71
- * @returns {Promise<{success: boolean, slice: object}>}
72
- */
73
105
  async push(body, opts = {}) {
74
106
  // Back-compat: older callers passed (body, append, expectVersion) as
75
107
  // positional args. Detect that shape and translate to the opts object.
@@ -81,79 +113,42 @@ function createApiClient(config) {
81
113
  if (append) params.append = 'true';
82
114
  if (force) params.force = 'true';
83
115
  if (expectVersion != null) params.expect_version = String(expectVersion);
84
- const res = await http.post('/v1/context/push', body, {
85
- params,
86
- headers: { 'x-api-key': keyForHcu(config, body?.hcu) },
87
- });
116
+ const backend = resolveBackend(config, body?.hcu);
117
+ const res = await http(backend).post('/v1/context/push', body, { params });
88
118
  return res.data;
89
119
  },
90
120
 
91
- /**
92
- * List immediate children of an HCU path.
93
- * @param {string} hcu
94
- * @param {object} [opts]
95
- * @param {boolean} [opts.includeDeleted] return tombstone provenance
96
- * @returns {Promise<{children: string[] | Array<{hcu: string, deleted_at: string|null, deleted_by: string|null}>}>}
97
- */
98
121
  async list(hcu, opts = {}) {
122
+ const backend = resolveBackend(config, hcu);
99
123
  const params = { hcu };
100
124
  if (opts.includeDeleted) params.include_deleted = 'true';
101
- const res = await http.get('/v1/context/list', {
102
- params,
103
- headers: { 'x-api-key': keyForHcu(config, hcu) },
104
- });
125
+ const res = await http(backend).get('/v1/context/list', { params });
105
126
  return res.data;
106
127
  },
107
128
 
108
- /**
109
- * Soft-delete an exact context slice. Bumps version (active(N) →
110
- * deleted(N+1)). The server preserves the row's payload so a no-content
111
- * restore can undelete it. Re-dropping an already-tombstoned slice is
112
- * idempotent.
113
- */
114
129
  async drop(hcu, opts = {}) {
130
+ const backend = resolveBackend(config, hcu);
115
131
  const params = { hcu };
116
132
  if (opts.expectVersion != null) params.expect_version = String(opts.expectVersion);
117
- const res = await http.delete('/v1/context/drop', {
118
- params,
119
- headers: { 'x-api-key': keyForHcu(config, hcu) },
120
- });
133
+ const res = await http(backend).delete('/v1/context/drop', { params });
121
134
  return res.data;
122
135
  },
123
136
 
124
- /**
125
- * Restore a tombstoned slice in place using the server's preserved
126
- * payload. Bumps version (deleted(N) → active(N+1)). Returns 409 if
127
- * the slice is already active; 404 if it never existed.
128
- */
129
137
  async restore(hcu, opts = {}) {
138
+ const backend = resolveBackend(config, hcu);
130
139
  const params = { hcu };
131
140
  if (opts.expectVersion != null) params.expect_version = String(opts.expectVersion);
132
- const res = await http.post('/v1/context/restore', null, {
133
- params,
134
- headers: { 'x-api-key': keyForHcu(config, hcu) },
135
- });
141
+ const res = await http(backend).post('/v1/context/restore', null, { params });
136
142
  return res.data;
137
143
  },
138
144
 
139
- /**
140
- * Open an SSE heartbeat stream and invoke onSync(evt) for each sync event.
141
- * Each `evt` is a `{ hcu, deleted? }` object — the optional `deleted: true`
142
- * hint tells callers the slice was tombstoned (so they can evict from
143
- * any local cache without a refetch).
144
- *
145
- * For backward compatibility with any caller that expected just an HCU
146
- * string, the `evt` argument is documented as the new shape and callers
147
- * should detect `evt.deleted` to short-circuit refetch.
148
- *
149
- * Returns a cleanup function that closes the connection.
150
- */
151
145
  watch(hcuPattern, onSync, onError) {
152
146
  const EventSource = require('eventsource');
153
- const url = `${baseURL}/v1/context/heartbeat?hcu_pattern=${encodeURIComponent(hcuPattern)}`;
147
+ const backend = resolveBackend(config, hcuPattern);
148
+ const url = `${backend.url}/v1/context/heartbeat?hcu_pattern=${encodeURIComponent(hcuPattern)}`;
154
149
 
155
150
  const es = new EventSource(url, {
156
- headers: { 'x-api-key': keyForHcu(config, hcuPattern) },
151
+ headers: { 'x-api-key': backend.key },
157
152
  });
158
153
 
159
154
  es.addEventListener('sync', (event) => {
@@ -174,4 +169,4 @@ function createApiClient(config) {
174
169
  };
175
170
  }
176
171
 
177
- module.exports = { createApiClient };
172
+ module.exports = { createApiClient, resolveBackend };
@@ -0,0 +1,333 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `bitpub group` — manage groups (shared namespaces).
5
+ *
6
+ * bitpub group create "Acme Engineering" create a group, print invite link
7
+ * bitpub group list show groups I'm in
8
+ * bitpub group invite <slug> rotate the share link (or --show to peek)
9
+ * bitpub group leave <slug> leave a group
10
+ *
11
+ * Plus the top-level shortcut for the joiner persona — see `bitpub join`,
12
+ * which is registered as its own top-level command in this file.
13
+ *
14
+ * Encryption: group content is plaintext on whatever server hosts the
15
+ * group (only `private:` slices are client-side encrypted). This is by
16
+ * design — full-text search, ltree queries, and BYOC audit all need the
17
+ * server to be able to read group content.
18
+ */
19
+
20
+ const axios = require('axios');
21
+ const {
22
+ readConfig,
23
+ requireConfig,
24
+ upsertGroupEntry,
25
+ removeGroupEntry,
26
+ groupEntryFor,
27
+ DEFAULT_CLOUD_URL,
28
+ } = require('../config');
29
+
30
+ function trimSlash(s) {
31
+ return String(s || '').replace(/\/+$/, '');
32
+ }
33
+
34
+ function primaryApiUrl(config) {
35
+ return trimSlash(config.api_url || DEFAULT_CLOUD_URL);
36
+ }
37
+
38
+ /**
39
+ * Parse a join input that may be either a raw invite token or a full
40
+ * invite URL (e.g. https://bitpub.io/j/9k3lp2-...). Returns:
41
+ * { token, baseUrl|null }
42
+ * where baseUrl is the backend the invite lives on (so cross-backend
43
+ * joins talk to the right host without any local config tweak).
44
+ */
45
+ function parseJoinInput(input) {
46
+ if (!input || typeof input !== 'string') {
47
+ throw new Error('join: token or invite URL is required');
48
+ }
49
+ const trimmed = input.trim();
50
+
51
+ if (/^https?:\/\//i.test(trimmed)) {
52
+ const u = new URL(trimmed);
53
+ const parts = u.pathname.split('/').filter(Boolean);
54
+ // Accept both /join/<token> (canonical) and the legacy /j/<token> form
55
+ // so older invite links keep working during the rename.
56
+ if ((parts[0] !== 'join' && parts[0] !== 'j') || !parts[1]) {
57
+ throw new Error(`join: not a recognized invite URL: ${trimmed}`);
58
+ }
59
+ return { token: parts[1], baseUrl: `${u.protocol}//${u.host}` };
60
+ }
61
+ return { token: trimmed, baseUrl: null };
62
+ }
63
+
64
+ async function httpJson(method, url, { headers, body } = {}) {
65
+ try {
66
+ const res = await axios.request({
67
+ method,
68
+ url,
69
+ timeout: 30_000,
70
+ headers: { 'content-type': 'application/json', ...(headers || {}) },
71
+ data: body,
72
+ });
73
+ return res.data;
74
+ } catch (err) {
75
+ const status = err.response?.status;
76
+ const msg = err.response?.data?.error || err.message;
77
+ const wrapped = new Error(status ? `[${status}] ${msg}` : msg);
78
+ wrapped.status = status;
79
+ throw wrapped;
80
+ }
81
+ }
82
+
83
+ // ── group create ───────────────────────────────────────────────────────────
84
+
85
+ async function createGroup(display) {
86
+ const config = requireConfig();
87
+ if (!config.api_key) {
88
+ throw new Error('Group creation requires a personal identity. Run `bitpub setup` first.');
89
+ }
90
+ const baseUrl = primaryApiUrl(config);
91
+
92
+ const data = await httpJson('POST', `${baseUrl}/v1/groups`, {
93
+ headers: { 'x-api-key': config.api_key },
94
+ body: { display },
95
+ });
96
+
97
+ // Creators are auto-added to memberships server-side. Mirror the entry
98
+ // locally so subsequent `bitpub save bitpub://group:.../...` calls hit
99
+ // the right backend with the same personal key (creator stays on
100
+ // their own key; the per-group-key mint happens at *accept* time, not
101
+ // create time, since the creator already has a key on this backend).
102
+ upsertGroupEntry({
103
+ slug: data.slug,
104
+ display: data.display,
105
+ api_url: baseUrl,
106
+ key: config.api_key,
107
+ owner: config.owner || null,
108
+ role: 'owner',
109
+ joined_at: new Date().toISOString(),
110
+ });
111
+
112
+ return { ...data, invite_url: data.invite_url, base_url: baseUrl };
113
+ }
114
+
115
+ // ── group list ─────────────────────────────────────────────────────────────
116
+
117
+ async function listGroupsLocal() {
118
+ const config = readConfig() || {};
119
+ return Array.isArray(config.groups) ? config.groups : [];
120
+ }
121
+
122
+ // ── group invite (rotate / show) ───────────────────────────────────────────
123
+
124
+ async function rotateInvite(slug, { show } = {}) {
125
+ const config = requireConfig();
126
+ const entry = groupEntryFor(config, slug);
127
+ if (!entry) {
128
+ throw new Error(`Not a member of group "${slug}". Run \`bitpub group list\` to see your groups.`);
129
+ }
130
+ 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.');
132
+ }
133
+ const baseUrl = trimSlash(entry.api_url) || primaryApiUrl(config);
134
+ const data = await httpJson('POST', `${baseUrl}/v1/groups/${encodeURIComponent(slug)}/invites`, {
135
+ headers: { 'x-api-key': entry.key },
136
+ });
137
+ return data;
138
+ }
139
+
140
+ // ── group leave ────────────────────────────────────────────────────────────
141
+
142
+ async function leaveGroup(slug) {
143
+ const config = requireConfig();
144
+ const entry = groupEntryFor(config, slug);
145
+ if (!entry) {
146
+ // Already not in this group locally. Treat as a no-op (idempotent).
147
+ return { slug, left: false, reason: 'not-in-local-config' };
148
+ }
149
+ const baseUrl = trimSlash(entry.api_url) || primaryApiUrl(config);
150
+ try {
151
+ await httpJson('DELETE', `${baseUrl}/v1/groups/${encodeURIComponent(slug)}/members/me`, {
152
+ headers: { 'x-api-key': entry.key },
153
+ });
154
+ } catch (err) {
155
+ // 404 = server already considers us gone; proceed to clean up locally.
156
+ if (err.status !== 404) throw err;
157
+ }
158
+ removeGroupEntry(slug);
159
+ return { slug, left: true };
160
+ }
161
+
162
+ // ── bitpub join <token-or-url> ─────────────────────────────────────────────
163
+ //
164
+ // The recipient-side verb. Public, doesn't require a pre-existing identity
165
+ // on the host backend — the invite token is the capability. After accept,
166
+ // stores a new entry in config.groups[] with the fresh per-group key
167
+ // minted by the backend.
168
+
169
+ async function joinFromInvite(input) {
170
+ const { token, baseUrl: baseFromUrl } = parseJoinInput(input);
171
+ const config = readConfig() || {};
172
+ const baseUrl = baseFromUrl
173
+ ? trimSlash(baseFromUrl)
174
+ : primaryApiUrl(config);
175
+
176
+ // Optionally include the existing personal key — this lets same-backend
177
+ // joins inherit the existing owner_id so the user looks like one person
178
+ // across all their groups on that backend.
179
+ const headers = {};
180
+ if (config.api_key && (!baseFromUrl || trimSlash(baseFromUrl) === primaryApiUrl(config))) {
181
+ headers['x-api-key'] = config.api_key;
182
+ }
183
+
184
+ const data = await httpJson('POST', `${baseUrl}/v1/groups/invites/${encodeURIComponent(token)}/accept`, {
185
+ headers,
186
+ });
187
+
188
+ upsertGroupEntry({
189
+ slug: data.group.slug,
190
+ display: data.group.display,
191
+ api_url: baseUrl,
192
+ key: data.api_key,
193
+ owner: data.owner_id,
194
+ role: 'member',
195
+ joined_at: new Date().toISOString(),
196
+ });
197
+
198
+ return { ...data, base_url: baseUrl };
199
+ }
200
+
201
+ // ── command registration ──────────────────────────────────────────────────
202
+
203
+ module.exports = function registerGroup(program) {
204
+ const group = program
205
+ .command('group')
206
+ .description('Create, manage, and leave shared groups');
207
+
208
+ group
209
+ .command('create <name...>')
210
+ .description('Create a new group; prints a shareable invite link')
211
+ .action(async (nameParts) => {
212
+ const display = nameParts.join(' ').trim();
213
+ if (!display) {
214
+ console.error('Usage: bitpub group create "Acme Engineering"');
215
+ process.exit(1);
216
+ }
217
+ try {
218
+ const out = await createGroup(display);
219
+ console.log(`\n✓ Created group: ${out.display}`);
220
+ console.log(` Slug : ${out.slug}`);
221
+ console.log(` Hosted on : ${out.hosted_on}`);
222
+ console.log(` Address : bitpub://group:${out.slug}/`);
223
+ console.log(`\n Share this link with teammates:`);
224
+ console.log(` ${out.invite_url}`);
225
+ console.log(`\n Anyone who clicks it lands on a friendly install + join page —`);
226
+ console.log(` technical or not, they're ready to collaborate in a minute.`);
227
+ } catch (err) {
228
+ console.error(`\n✗ ${err.message}`);
229
+ process.exit(1);
230
+ }
231
+ });
232
+
233
+ group
234
+ .command('list')
235
+ .description('Show the groups you are a member of (local view)')
236
+ .action(async () => {
237
+ try {
238
+ const entries = await listGroupsLocal();
239
+ if (entries.length === 0) {
240
+ console.log('You are not in any groups yet.');
241
+ console.log('\n Create one: bitpub group create "My Team"');
242
+ console.log(' Join one: bitpub join <invite-link>');
243
+ return;
244
+ }
245
+ for (const e of entries) {
246
+ const label = e.display || e.slug;
247
+ const tag = e.legacy ? ' [legacy]' : '';
248
+ console.log(` ${label}${tag}`);
249
+ console.log(` slug : ${e.slug}`);
250
+ console.log(` address : bitpub://group:${e.slug}/`);
251
+ console.log(` backend : ${e.api_url || '(default)'}`);
252
+ if (e.role) console.log(` role : ${e.role}`);
253
+ }
254
+ } catch (err) {
255
+ console.error(`✗ ${err.message}`);
256
+ process.exit(1);
257
+ }
258
+ });
259
+
260
+ group
261
+ .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')
264
+ .action(async (slug, opts) => {
265
+ try {
266
+ const out = await rotateInvite(slug, opts);
267
+ console.log(`\n✓ Rotated invite for ${out.display || out.slug}`);
268
+ console.log(`\n New share link:`);
269
+ console.log(` ${out.invite_url}`);
270
+ console.log(`\n Old links (if any) are now invalid.`);
271
+ } catch (err) {
272
+ console.error(`\n✗ ${err.message}`);
273
+ process.exit(1);
274
+ }
275
+ });
276
+
277
+ group
278
+ .command('leave <slug>')
279
+ .description('Leave a group (revokes your per-group key on the host)')
280
+ .action(async (slug) => {
281
+ try {
282
+ const out = await leaveGroup(slug);
283
+ if (out.left) console.log(`✓ Left group: ${slug}`);
284
+ else console.log(`(Not in group "${slug}" locally — nothing to do.)`);
285
+ } catch (err) {
286
+ console.error(`\n✗ ${err.message}`);
287
+ process.exit(1);
288
+ }
289
+ });
290
+
291
+ // ── group join — longhand alias of top-level `bitpub join` ─────────────
292
+ group
293
+ .command('join <tokenOrUrl>')
294
+ .description('Accept an invite (longhand for `bitpub join`)')
295
+ .action(async (tokenOrUrl) => {
296
+ try {
297
+ const out = await joinFromInvite(tokenOrUrl);
298
+ console.log(`\n✓ Joined group: ${out.group.display}`);
299
+ console.log(` Address : bitpub://group:${out.group.slug}/`);
300
+ console.log(` Backend : ${out.base_url}`);
301
+ } catch (err) {
302
+ console.error(`\n✗ ${err.message}`);
303
+ process.exit(1);
304
+ }
305
+ });
306
+
307
+ // ── top-level `bitpub join` — the joiner one-liner ─────────────────────
308
+ program
309
+ .command('join <tokenOrUrl>')
310
+ .description('Accept a group invite link (or token) and start collaborating')
311
+ .action(async (tokenOrUrl) => {
312
+ try {
313
+ const out = await joinFromInvite(tokenOrUrl);
314
+ console.log(`\n✓ Joined group: ${out.group.display}`);
315
+ console.log(` Address : bitpub://group:${out.group.slug}/`);
316
+ console.log(` Backend : ${out.base_url}`);
317
+ console.log(`\nTry it:`);
318
+ console.log(` bitpub save bitpub://group:${out.group.slug}/hello "first message"`);
319
+ console.log(` bitpub list bitpub://group:${out.group.slug}/`);
320
+ } catch (err) {
321
+ console.error(`\n✗ ${err.message}`);
322
+ process.exit(1);
323
+ }
324
+ });
325
+ };
326
+
327
+ module.exports._internals = {
328
+ parseJoinInput,
329
+ createGroup,
330
+ joinFromInvite,
331
+ rotateInvite,
332
+ leaveGroup,
333
+ };
@@ -271,23 +271,36 @@ module.exports = function registerSetup(program) {
271
271
  });
272
272
 
273
273
  // ── setup team … ────────────────────────────────────────────────────
274
- // Joins (or rejoins) a team namespace. Today: requires --key + --domain
275
- // from a team admin. Future: --invite <token> in lieu of those, once
276
- // server-side invite generation lands.
274
+ // Legacy: join a tenant by pre-shared key + domain. Kept working for
275
+ // existing enterprise / BYOC deployments. New users use `bitpub join
276
+ // <invite-link>` instead that flow mints a fresh per-user key
277
+ // instead of sharing one.
277
278
  setup
278
279
  .command('team')
279
- .description('Join a team namespace (key + domain from your team admin)')
280
- .requiredOption('--key <string>', 'Team API key provided by your team admin')
281
- .requiredOption('--domain <string>', 'Your organization domain (e.g. acme.com)')
280
+ .description('[legacy] Join a group by shared key + domain prefer `bitpub join <invite-link>`')
281
+ .requiredOption('--key <string>', 'Group API key provided by your group admin')
282
+ .requiredOption('--domain <string>', 'Group domain (e.g. acme.com) — used as the group slug')
282
283
  .option('--url <string>', 'Backend URL (defaults to current api_url or https://bitpub.io)')
283
284
  .option('--verify', 'Ping the backend to verify the key before saving')
284
285
  .action(async ({ key, domain, url, verify }) => {
285
286
  const existing = readConfig() || {};
286
287
  const apiUrl = url || existing.api_url || DEFAULT_CLOUD_URL;
287
288
 
289
+ // Build a temporary config the API client will see, with this group
290
+ // already mirrored into config.groups[] so the verify-pull picks the
291
+ // right per-group key+url.
292
+ const probeConfig = {
293
+ ...existing,
294
+ api_url: apiUrl,
295
+ groups: [
296
+ ...(Array.isArray(existing.groups) ? existing.groups : []).filter((g) => g.slug !== domain),
297
+ { slug: domain, display: domain, api_url: apiUrl, key, legacy: true },
298
+ ],
299
+ };
300
+
288
301
  if (verify) {
289
302
  try {
290
- const api = createApiClient({ group_key: key, api_url: apiUrl });
303
+ const api = createApiClient(probeConfig);
291
304
  await api.pull(`bitpub://group:${domain}/**`, 1);
292
305
  console.log('✓ Key verified against backend');
293
306
  } catch (err) {
@@ -296,14 +309,25 @@ module.exports = function registerSetup(program) {
296
309
  }
297
310
  }
298
311
 
299
- writeConfig({ ...existing, group_key: key, domain, api_url: apiUrl });
312
+ // Persist into the new groups[] shape as well as the legacy top-level
313
+ // fields. Keeping the top-level fields around for one release means
314
+ // any external script that reads config.group_key keeps working; the
315
+ // next release can drop them.
316
+ writeConfig({
317
+ ...existing,
318
+ group_key: key,
319
+ domain,
320
+ api_url: apiUrl,
321
+ groups: probeConfig.groups,
322
+ });
300
323
  initCache();
301
324
 
302
- console.log(`✓ Joined team ${domain}`);
325
+ console.log(`✓ Joined group ${domain}`);
303
326
  console.log(` Backend : ${apiUrl}`);
304
327
  if (existing.owner) {
305
328
  console.log(` Private : agent_${existing.owner} (preserved)`);
306
329
  }
330
+ console.error('warning: `bitpub setup team` is the legacy flow. New invites use `bitpub join <link>` instead.');
307
331
  console.log('\nNext: bitpub sync "bitpub://group:' + domain + '/**"');
308
332
  });
309
333
 
package/src/config.js CHANGED
@@ -21,11 +21,51 @@ function ensureDir() {
21
21
  }
22
22
 
23
23
  function readConfig() {
24
+ let parsed;
24
25
  try {
25
- return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
26
+ parsed = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
26
27
  } catch {
27
28
  return null;
28
29
  }
30
+ return _migrateLegacyGroupFieldsInPlace(parsed);
31
+ }
32
+
33
+ /**
34
+ * Lazy one-shot migration of the pre-v2 top-level `group_key` + `domain`
35
+ * fields into a single entry in the new `groups` array. We keep the
36
+ * top-level fields for one release as a safety net so any caller that
37
+ * still reads them keeps working — they'll be dropped in a follow-up.
38
+ *
39
+ * Idempotent: if a matching entry already exists in `groups`, we do
40
+ * nothing. Only persists back to disk when an actual change was made.
41
+ */
42
+ function _migrateLegacyGroupFieldsInPlace(config) {
43
+ if (!config || typeof config !== 'object') return config;
44
+ if (!Array.isArray(config.groups)) config.groups = [];
45
+
46
+ const hasLegacyTopLevel = config.group_key && config.domain;
47
+ if (!hasLegacyTopLevel) return config;
48
+
49
+ const alreadyMigrated = config.groups.some((g) => g && g.slug === config.domain);
50
+ if (alreadyMigrated) return config;
51
+
52
+ config.groups.push({
53
+ slug: config.domain,
54
+ display: config.domain,
55
+ api_url: config.api_url,
56
+ key: config.group_key,
57
+ owner: config.owner || null,
58
+ joined_at: new Date().toISOString(),
59
+ legacy: true,
60
+ });
61
+
62
+ try {
63
+ ensureDir();
64
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
65
+ } catch {
66
+ // Non-fatal: in-memory copy is still correct for this process.
67
+ }
68
+ return config;
29
69
  }
30
70
 
31
71
  function writeConfig(config) {
@@ -35,16 +75,55 @@ function writeConfig(config) {
35
75
 
36
76
  /**
37
77
  * Identity is valid if api_url is present and at least one of these is true:
38
- * - private identity: api_key + owner (provisioned by `bitpub init`)
39
- * - group identity: group_key + domain (set by `bitpub auth login`)
40
- * - legacy single-key: api_key + domain (old config shape, still supported)
78
+ * - private identity: api_key + owner (provisioned by `bitpub setup`)
79
+ * - group membership: any entry in groups[] (added by `bitpub join`)
80
+ * - legacy group key: group_key + domain (pre-v2 top-level fields)
81
+ * - legacy single-key: api_key + domain (oldest single-tenant shape)
41
82
  */
42
83
  function isConfigured(config) {
43
84
  if (!config || !config.api_url) return false;
44
85
  const hasPrivate = Boolean(config.api_key && config.owner);
45
- const hasGroup = Boolean(config.group_key && config.domain);
46
- const hasLegacy = Boolean(config.api_key && config.domain);
47
- return hasPrivate || hasGroup || hasLegacy;
86
+ const hasGroup = Array.isArray(config.groups) && config.groups.length > 0;
87
+ const hasLegacyG = Boolean(config.group_key && config.domain);
88
+ const hasLegacyS = Boolean(config.api_key && config.domain);
89
+ return hasPrivate || hasGroup || hasLegacyG || hasLegacyS;
90
+ }
91
+
92
+ /**
93
+ * Look up the local config entry for a given group slug. Returns null if
94
+ * the user isn't a member. Used by the API client to route writes to the
95
+ * correct backend with the correct per-group key.
96
+ */
97
+ function groupEntryFor(config, slug) {
98
+ if (!config || !Array.isArray(config.groups)) return null;
99
+ return config.groups.find((g) => g && g.slug === slug) || null;
100
+ }
101
+
102
+ /**
103
+ * Add (or replace) a group entry in the config. Idempotent — re-joining
104
+ * an existing group overwrites the prior entry (which is what happens
105
+ * server-side too: accept-invite mints a fresh key, the old one is left
106
+ * orphaned but still works).
107
+ */
108
+ function upsertGroupEntry(entry) {
109
+ if (!entry || !entry.slug) throw new Error('upsertGroupEntry: slug is required');
110
+ const config = readConfig() || {};
111
+ if (!Array.isArray(config.groups)) config.groups = [];
112
+ const idx = config.groups.findIndex((g) => g && g.slug === entry.slug);
113
+ if (idx === -1) config.groups.push(entry);
114
+ else config.groups[idx] = { ...config.groups[idx], ...entry };
115
+ writeConfig(config);
116
+ return config;
117
+ }
118
+
119
+ function removeGroupEntry(slug) {
120
+ const config = readConfig() || {};
121
+ if (!Array.isArray(config.groups)) return false;
122
+ const idx = config.groups.findIndex((g) => g && g.slug === slug);
123
+ if (idx === -1) return false;
124
+ config.groups.splice(idx, 1);
125
+ writeConfig(config);
126
+ return true;
48
127
  }
49
128
 
50
129
  /**
@@ -79,6 +158,9 @@ module.exports = {
79
158
  requireConfig,
80
159
  isConfigured,
81
160
  authorIdFor,
161
+ groupEntryFor,
162
+ upsertGroupEntry,
163
+ removeGroupEntry,
82
164
  BITPUB_DIR,
83
165
  CONFIG_FILE,
84
166
  DEFAULT_CLOUD_URL,