@bitpub/cli 2.0.1 → 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 +1 -0
- package/package.json +1 -1
- package/src/api.js +97 -102
- package/src/commands/group.js +333 -0
- package/src/commands/init.js +5 -0
- package/src/commands/setup.js +104 -9
- package/src/commands/welcome.js +3 -3
- package/src/config.js +89 -7
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.
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
85
|
-
|
|
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
|
|
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':
|
|
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
|
+
};
|
package/src/commands/init.js
CHANGED
|
@@ -15,6 +15,7 @@ const {
|
|
|
15
15
|
ensureIdentity,
|
|
16
16
|
ensureWorkspace,
|
|
17
17
|
ensureSkill,
|
|
18
|
+
runFirstRunWelcome,
|
|
18
19
|
} = require('./setup');
|
|
19
20
|
const { DEFAULT_CLOUD_URL } = require('../config');
|
|
20
21
|
|
|
@@ -27,6 +28,7 @@ module.exports = function registerInit(program) {
|
|
|
27
28
|
.option('--no-workspace', 'Skip creating a .bitpub/workspace.json in the current folder')
|
|
28
29
|
.option('--workspace-label <string>', 'Override the workspace label (defaults to folder name)')
|
|
29
30
|
.option('--force', 'Overwrite an existing identity (the old key is unrecoverable)')
|
|
31
|
+
.option('--no-welcome', 'Skip the post-install welcome (save first memory + open browser)')
|
|
30
32
|
.option('--import-from <path>', 'Import an existing TollBit config')
|
|
31
33
|
.option('--no-import', 'Skip auto-import')
|
|
32
34
|
.action(async (opts) => {
|
|
@@ -41,6 +43,9 @@ module.exports = function registerInit(program) {
|
|
|
41
43
|
ensureWorkspace({ owner: config.owner, label: opts.workspaceLabel });
|
|
42
44
|
}
|
|
43
45
|
await ensureSkill({});
|
|
46
|
+
if (config && !opts.localOnly && opts.welcome !== false) {
|
|
47
|
+
await runFirstRunWelcome(config);
|
|
48
|
+
}
|
|
44
49
|
} catch (err) {
|
|
45
50
|
console.error(`\n✗ ${err.message}`);
|
|
46
51
|
process.exit(1);
|
package/src/commands/setup.js
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
const path = require('path');
|
|
24
24
|
const fs = require('fs');
|
|
25
25
|
const os = require('os');
|
|
26
|
+
const { spawn } = require('child_process');
|
|
26
27
|
const axios = require('axios');
|
|
27
28
|
const {
|
|
28
29
|
readConfig,
|
|
@@ -169,6 +170,65 @@ async function ensureSkill({ quiet = false } = {}) {
|
|
|
169
170
|
}
|
|
170
171
|
}
|
|
171
172
|
|
|
173
|
+
/**
|
|
174
|
+
* First-run welcome: save the Welcome slice and (if interactive) open the
|
|
175
|
+
* browser to view it. Shared by `bitpub setup` and the deprecated
|
|
176
|
+
* `bitpub init` so any install path lands the user in the same place.
|
|
177
|
+
*
|
|
178
|
+
* Idempotent through welcome.js's `welcome_saved_at` config flag. The
|
|
179
|
+
* browser tab only opens on a *fresh* save (so re-running setup doesn't
|
|
180
|
+
* pop new tabs forever); when the slice already exists we just print a
|
|
181
|
+
* pointer to `bitpub browser`.
|
|
182
|
+
*
|
|
183
|
+
* Auto-open is gated:
|
|
184
|
+
* - `BITPUB_SKIP_WELCOME=1` → skip
|
|
185
|
+
* - non-TTY stdout (CI, headless agents w/o pty) → skip with a pointer
|
|
186
|
+
* - otherwise → spawn `bitpub welcome --serve` detached so the server
|
|
187
|
+
* outlives this process and the user gets their shell back.
|
|
188
|
+
*/
|
|
189
|
+
async function runFirstRunWelcome(config) {
|
|
190
|
+
const { saveWelcomeSlice } = require('./welcome');
|
|
191
|
+
|
|
192
|
+
let result;
|
|
193
|
+
try {
|
|
194
|
+
result = await saveWelcomeSlice(config, {});
|
|
195
|
+
} catch (err) {
|
|
196
|
+
console.error(`\n(welcome: could not save first memory — ${err.message})`);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Re-run on an already-welcomed machine — quietly skip the browser
|
|
201
|
+
// open so a `bitpub setup` re-invocation isn't disruptive.
|
|
202
|
+
if (!result.saved) return;
|
|
203
|
+
|
|
204
|
+
console.log(`\n✓ Saved your first memory → ${result.address} (v${result.version})`);
|
|
205
|
+
|
|
206
|
+
const skipReason = welcomeSkipReason();
|
|
207
|
+
if (skipReason) {
|
|
208
|
+
console.log(` (skipping auto browser open: ${skipReason})`);
|
|
209
|
+
console.log(' Run `bitpub welcome --serve` to open it when you\'re ready.');
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
const child = spawn(process.execPath, [process.argv[1], 'welcome', '--serve'], {
|
|
215
|
+
detached: true,
|
|
216
|
+
stdio: 'ignore',
|
|
217
|
+
});
|
|
218
|
+
child.unref();
|
|
219
|
+
console.log(' Opening your browser at http://localhost:4141 …');
|
|
220
|
+
} catch (err) {
|
|
221
|
+
console.error(` (could not auto-open browser: ${err.message})`);
|
|
222
|
+
console.error(' Run `bitpub welcome --serve` manually.');
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function welcomeSkipReason() {
|
|
227
|
+
if (process.env.BITPUB_SKIP_WELCOME === '1') return 'BITPUB_SKIP_WELCOME=1';
|
|
228
|
+
if (!process.stdout.isTTY) return 'non-interactive shell';
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
172
232
|
module.exports = function registerSetup(program) {
|
|
173
233
|
const setup = program
|
|
174
234
|
.command('setup')
|
|
@@ -178,6 +238,7 @@ module.exports = function registerSetup(program) {
|
|
|
178
238
|
.option('--no-anchor', 'Skip anchoring this folder as a project')
|
|
179
239
|
.option('--label <string>', 'Override the project label (defaults to folder name)')
|
|
180
240
|
.option('--force', 'Overwrite an existing identity (the old key is unrecoverable after this)')
|
|
241
|
+
.option('--no-welcome', 'Skip the post-install welcome (save first memory + open browser)')
|
|
181
242
|
.option('--import-from <path>', 'Import an existing TollBit config (defaults to ~/.tollbit/config.json if present)')
|
|
182
243
|
.option('--no-import', 'Skip auto-import even if a legacy ~/.tollbit/ config is present')
|
|
183
244
|
.action(async (opts) => {
|
|
@@ -191,6 +252,15 @@ module.exports = function registerSetup(program) {
|
|
|
191
252
|
ensureWorkspace({ owner: config.owner, label: opts.label });
|
|
192
253
|
}
|
|
193
254
|
await ensureSkill({});
|
|
255
|
+
|
|
256
|
+
// Welcome flow: save a first memory + open the browser. Idempotent
|
|
257
|
+
// and gated — re-running `bitpub setup` doesn't keep popping tabs.
|
|
258
|
+
// Skipped in --local-only (no cloud identity to write to) and when
|
|
259
|
+
// the user opted out via --no-welcome or BITPUB_SKIP_WELCOME=1.
|
|
260
|
+
if (config && !opts.localOnly && opts.welcome !== false) {
|
|
261
|
+
await runFirstRunWelcome(config);
|
|
262
|
+
}
|
|
263
|
+
|
|
194
264
|
console.log('\nTry it:');
|
|
195
265
|
console.log(' bitpub save notes "first slice"');
|
|
196
266
|
console.log(' bitpub list # see what\'s in this project');
|
|
@@ -201,23 +271,36 @@ module.exports = function registerSetup(program) {
|
|
|
201
271
|
});
|
|
202
272
|
|
|
203
273
|
// ── setup team … ────────────────────────────────────────────────────
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
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.
|
|
207
278
|
setup
|
|
208
279
|
.command('team')
|
|
209
|
-
.description('Join a
|
|
210
|
-
.requiredOption('--key <string>', '
|
|
211
|
-
.requiredOption('--domain <string>', '
|
|
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')
|
|
212
283
|
.option('--url <string>', 'Backend URL (defaults to current api_url or https://bitpub.io)')
|
|
213
284
|
.option('--verify', 'Ping the backend to verify the key before saving')
|
|
214
285
|
.action(async ({ key, domain, url, verify }) => {
|
|
215
286
|
const existing = readConfig() || {};
|
|
216
287
|
const apiUrl = url || existing.api_url || DEFAULT_CLOUD_URL;
|
|
217
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
|
+
|
|
218
301
|
if (verify) {
|
|
219
302
|
try {
|
|
220
|
-
const api = createApiClient(
|
|
303
|
+
const api = createApiClient(probeConfig);
|
|
221
304
|
await api.pull(`bitpub://group:${domain}/**`, 1);
|
|
222
305
|
console.log('✓ Key verified against backend');
|
|
223
306
|
} catch (err) {
|
|
@@ -226,14 +309,25 @@ module.exports = function registerSetup(program) {
|
|
|
226
309
|
}
|
|
227
310
|
}
|
|
228
311
|
|
|
229
|
-
|
|
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
|
+
});
|
|
230
323
|
initCache();
|
|
231
324
|
|
|
232
|
-
console.log(`✓ Joined
|
|
325
|
+
console.log(`✓ Joined group ${domain}`);
|
|
233
326
|
console.log(` Backend : ${apiUrl}`);
|
|
234
327
|
if (existing.owner) {
|
|
235
328
|
console.log(` Private : agent_${existing.owner} (preserved)`);
|
|
236
329
|
}
|
|
330
|
+
console.error('warning: `bitpub setup team` is the legacy flow. New invites use `bitpub join <link>` instead.');
|
|
237
331
|
console.log('\nNext: bitpub sync "bitpub://group:' + domain + '/**"');
|
|
238
332
|
});
|
|
239
333
|
|
|
@@ -310,3 +404,4 @@ function maybeImportLegacyConfig({ quiet = false } = {}) {
|
|
|
310
404
|
module.exports.ensureIdentity = ensureIdentity;
|
|
311
405
|
module.exports.ensureWorkspace = ensureWorkspace;
|
|
312
406
|
module.exports.ensureSkill = ensureSkill;
|
|
407
|
+
module.exports.runFirstRunWelcome = runFirstRunWelcome;
|
package/src/commands/welcome.js
CHANGED
|
@@ -80,8 +80,8 @@ function buildWelcomeContent(config) {
|
|
|
80
80
|
'Generate a link teammates can click to join your group namespace and',
|
|
81
81
|
'read shared context. No GitHub invites, no repo access, no PRs.',
|
|
82
82
|
'',
|
|
83
|
-
'> *One-click sharing coming soon. Today: `bitpub
|
|
84
|
-
'> yourcompany.com` joins an existing team namespace.*',
|
|
83
|
+
'> *One-click sharing coming soon. Today: `bitpub setup team --key K',
|
|
84
|
+
'> --domain yourcompany.com` joins an existing team namespace.*',
|
|
85
85
|
'',
|
|
86
86
|
'### 3. See what you can build',
|
|
87
87
|
'',
|
|
@@ -89,7 +89,7 @@ function buildWelcomeContent(config) {
|
|
|
89
89
|
'can put here — research notes, prompts, drafts, agent configs, job',
|
|
90
90
|
'queues. Multiple agents can read and write the same slices.',
|
|
91
91
|
'',
|
|
92
|
-
'> Read the [Cookbook](https://github.com/tollbit/
|
|
92
|
+
'> Read the [Cookbook](https://github.com/tollbit/bitpub/blob/main/COOKBOOK.md)',
|
|
93
93
|
'> for real patterns teams have built on top of these primitives.',
|
|
94
94
|
'',
|
|
95
95
|
'---',
|
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
|
-
|
|
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:
|
|
39
|
-
* - group
|
|
40
|
-
* - legacy
|
|
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 =
|
|
46
|
-
const
|
|
47
|
-
|
|
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,
|