@bitpub/cli 2.0.2 → 2.0.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/bin/bitpub.js +1 -0
- package/package.json +2 -2
- package/src/api.js +97 -102
- package/src/commands/browser.js +89 -15
- package/src/commands/group.js +333 -0
- package/src/commands/setup.js +33 -9
- package/src/commands/welcome.js +60 -7
- package/src/config.js +89 -7
- package/static/assets/BITPUB-dark.png +0 -0
- package/static/assets/tollbit-icon.png +0 -0
- package/static/console.html +604 -129
- package/static/welcome.html +574 -0
|
@@ -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/setup.js
CHANGED
|
@@ -271,23 +271,36 @@ module.exports = function registerSetup(program) {
|
|
|
271
271
|
});
|
|
272
272
|
|
|
273
273
|
// ── setup team … ────────────────────────────────────────────────────
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
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
|
|
280
|
-
.requiredOption('--key <string>', '
|
|
281
|
-
.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')
|
|
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(
|
|
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
|
-
|
|
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
|
|
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/commands/welcome.js
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
* address (`bitpub://private:<owner>/Welcome`). This is proof to a
|
|
9
9
|
* non-technical user that the round trip works: an identity got
|
|
10
10
|
* provisioned, encryption is set up, and reads/writes hit the cloud.
|
|
11
|
+
* The slice payload is an HTML app (a "Pack" entry-point); the
|
|
12
|
+
* browser renders it in a sandboxed iframe. The whole welcome
|
|
13
|
+
* experience is the slice — no separate panel in the chrome.
|
|
11
14
|
*
|
|
12
15
|
* 2. With `--serve`, start the local browser UI and open a tab to the
|
|
13
16
|
* welcome slice. This is what install.sh calls so the very first thing
|
|
@@ -24,7 +27,9 @@
|
|
|
24
27
|
* once, the first time the user sets up BitPub on this machine.
|
|
25
28
|
*/
|
|
26
29
|
|
|
27
|
-
const
|
|
30
|
+
const fs = require('fs');
|
|
31
|
+
const path = require('path');
|
|
32
|
+
const { requireConfig, readConfig, writeConfig, authorIdFor, BITPUB_DIR } = require('../config');
|
|
28
33
|
const { createApiClient } = require('../api');
|
|
29
34
|
const { upsertSlice } = require('../db/cache');
|
|
30
35
|
const { encrypt, decryptSlices } = require('../crypto');
|
|
@@ -36,13 +41,60 @@ function welcomeAddressFor(owner) {
|
|
|
36
41
|
return `bitpub://private:${owner}/${WELCOME_SLICE_NAME}`;
|
|
37
42
|
}
|
|
38
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Resolve the welcome.html template path. Mirrors `resolveBrowserHtmlPath`
|
|
46
|
+
* in commands/browser.js — workspace first (so edits to the template apply
|
|
47
|
+
* without a `prepack` round-trip), then the prepacked CLI copy, then the
|
|
48
|
+
* last-resort ~/.bitpub cache. Returns null if no template is on disk
|
|
49
|
+
* (in which case we fall back to a markdown welcome — see below).
|
|
50
|
+
*/
|
|
51
|
+
function resolveWelcomeTemplatePath() {
|
|
52
|
+
const candidates = [
|
|
53
|
+
path.join(__dirname, '../../../backend/static/welcome.html'),
|
|
54
|
+
path.join(__dirname, '../../static/welcome.html'),
|
|
55
|
+
path.join(BITPUB_DIR, 'welcome.html'),
|
|
56
|
+
];
|
|
57
|
+
for (const p of candidates) {
|
|
58
|
+
if (fs.existsSync(p)) return p;
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Build the welcome slice payload. Returns `{ content, format }`.
|
|
65
|
+
*
|
|
66
|
+
* Prefers the HTML app template at `backend/static/welcome.html`. That
|
|
67
|
+
* template contains a single substitution token, `__OWNER__`, which is
|
|
68
|
+
* replaced with the user's owner ID so the address shown in the page
|
|
69
|
+
* matches the slice's actual address. The HTML is rendered by the
|
|
70
|
+
* browser inside a sandboxed iframe (no network, no storage).
|
|
71
|
+
*
|
|
72
|
+
* If the template is not on disk (older installs, partial unpacks), falls
|
|
73
|
+
* back to a short markdown welcome so the round-trip proof still works.
|
|
74
|
+
*/
|
|
39
75
|
function buildWelcomeContent(config) {
|
|
40
76
|
const owner = config.owner || '<your-owner>';
|
|
77
|
+
|
|
78
|
+
const templatePath = resolveWelcomeTemplatePath();
|
|
79
|
+
if (templatePath) {
|
|
80
|
+
try {
|
|
81
|
+
const html = fs.readFileSync(templatePath, 'utf-8');
|
|
82
|
+
// Replace the single substitution token. We use a placeholder rather
|
|
83
|
+
// than a string template so the template stays a syntactically valid
|
|
84
|
+
// standalone HTML file you can open directly in a browser for design
|
|
85
|
+
// iteration.
|
|
86
|
+
const content = html.replace(/__OWNER__/g, owner);
|
|
87
|
+
return { content, format: 'text/html' };
|
|
88
|
+
} catch (_) {
|
|
89
|
+
// fall through to markdown fallback
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Fallback markdown content. Shorter than the HTML app and not as
|
|
94
|
+
// visually polished, but works in every renderer and still proves the
|
|
95
|
+
// round trip.
|
|
41
96
|
const address = welcomeAddressFor(owner);
|
|
42
|
-
|
|
43
|
-
// Keep it short, friendly, and oriented toward the three workflows we
|
|
44
|
-
// want non-technical users to discover next: skills, sharing, building.
|
|
45
|
-
return [
|
|
97
|
+
const content = [
|
|
46
98
|
'# Welcome to BitPub',
|
|
47
99
|
'',
|
|
48
100
|
'Your install worked. **This page is your first saved memory** — a slice',
|
|
@@ -102,6 +154,7 @@ function buildWelcomeContent(config) {
|
|
|
102
154
|
'own. Close this tab whenever you\'re ready.',
|
|
103
155
|
'',
|
|
104
156
|
].join('\n');
|
|
157
|
+
return { content, format: 'text/markdown' };
|
|
105
158
|
}
|
|
106
159
|
|
|
107
160
|
async function saveWelcomeSlice(config, { force } = {}) {
|
|
@@ -111,7 +164,7 @@ async function saveWelcomeSlice(config, { force } = {}) {
|
|
|
111
164
|
}
|
|
112
165
|
|
|
113
166
|
const address = welcomeAddressFor(config.owner);
|
|
114
|
-
const content = buildWelcomeContent(config);
|
|
167
|
+
const { content, format } = buildWelcomeContent(config);
|
|
115
168
|
const api = createApiClient(config);
|
|
116
169
|
|
|
117
170
|
const body = {
|
|
@@ -122,7 +175,7 @@ async function saveWelcomeSlice(config, { force } = {}) {
|
|
|
122
175
|
tags: ['welcome', 'onboarding'],
|
|
123
176
|
},
|
|
124
177
|
payload: {
|
|
125
|
-
format
|
|
178
|
+
format,
|
|
126
179
|
content: encrypt(content, config.api_key),
|
|
127
180
|
},
|
|
128
181
|
};
|
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,
|
|
Binary file
|
|
Binary file
|