@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 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.4",
4
4
  "description": "BitPub CLI — local-first shared memory for AI agents. Six daily verbs (save/load/list/find/sync/delete), zero-config private namespace, encrypted client-side.",
5
5
  "bin": {
6
6
  "bitpub": "./bin/bitpub.js"
@@ -14,7 +14,7 @@
14
14
  ],
15
15
  "scripts": {
16
16
  "test": "jest --testPathPattern=tests/ --forceExit",
17
- "prepack": "mkdir -p static && cp ../backend/static/console.html static/console.html",
17
+ "prepack": "mkdir -p static/assets && cp ../backend/static/console.html static/console.html && cp ../backend/static/welcome.html static/welcome.html && cp ../backend/static/assets/tollbit-icon.png ../backend/static/assets/BITPUB-dark.png static/assets/",
18
18
  "prepublishOnly": "npm test"
19
19
  },
20
20
  "engines": {
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 };
@@ -46,27 +46,29 @@ function decryptSlice(slice, apiKey) {
46
46
  }
47
47
 
48
48
  function resolveBrowserHtmlPath() {
49
- // Order matters. We want production installs to always serve the HTML
50
- // that ships with the *current* CLI version, not whatever got cached in
51
- // ~/.bitpub/ from a one-time tarball install months ago.
49
+ // Resolution order:
52
50
  //
53
- // 1. cli/static/console.html — populated by `prepack` from the
54
- // workspace `backend/static/console.html` and shipped inside the
55
- // CLI tarball. This is the canonical location at runtime.
56
- // 2. backend/static/console.html only resolves when running from a
57
- // checked-out workspace (e.g., `node cli/bin/bitpub.js browser`
58
- // during development). Convenient but not the real install path.
51
+ // 1. backend/static/console.html — the workspace file, only present
52
+ // in a checked-out clone. Preferred when present so iterating on
53
+ // the UI doesn't require a `prepack` round-trip between edits.
54
+ // Production installs (npm tarball) don't ship the `backend/`
55
+ // folder, so this candidate is absent there and the resolver
56
+ // falls through.
57
+ // 2. cli/static/console.html — populated by `prepack` from
58
+ // `backend/static/console.html` and shipped inside the CLI
59
+ // tarball. This is the canonical location at runtime for
60
+ // installed CLIs.
59
61
  // 3. ~/.bitpub/console.html — last-resort cache, mainly for the
60
- // original install instructions that fetched a single file via curl.
61
- // We never write here ourselves; it exists only when a user
62
- // manually populated it.
62
+ // original install instructions that fetched a single file via
63
+ // curl. We never write here ourselves; it exists only when a
64
+ // user manually populated it.
63
65
  //
64
66
  // Filename is still console.html on disk — keeping the filename stable
65
67
  // avoids churning the prepack step and lets old `~/.bitpub/console.html`
66
68
  // caches keep working. The user-facing command is `browser`.
67
69
  const candidates = [
68
- path.join(__dirname, '../../static/console.html'),
69
70
  path.join(__dirname, '../../../backend/static/console.html'),
71
+ path.join(__dirname, '../../static/console.html'),
70
72
  path.join(BITPUB_DIR, 'console.html'),
71
73
  ];
72
74
  for (const p of candidates) {
@@ -75,6 +77,37 @@ function resolveBrowserHtmlPath() {
75
77
  return null;
76
78
  }
77
79
 
80
+ // Mirror of `resolveBrowserHtmlPath` for the bundled asset directory.
81
+ // Both the dev clone and the npm tarball place assets under
82
+ // `static/assets/` next to `console.html`, so we look in the same order:
83
+ // workspace first (so the browser picks up edits to PNGs without a
84
+ // `prepack` round-trip), then the prepacked CLI copy.
85
+ function resolveAssetsDir() {
86
+ const candidates = [
87
+ path.join(__dirname, '../../../backend/static/assets'),
88
+ path.join(__dirname, '../../static/assets'),
89
+ ];
90
+ for (const p of candidates) {
91
+ if (fs.existsSync(p)) return p;
92
+ }
93
+ return null;
94
+ }
95
+
96
+ // Tiny extension → Content-Type table covering the asset types the
97
+ // console actually uses. Falls back to application/octet-stream so an
98
+ // unknown extension downloads instead of being interpreted.
99
+ const ASSET_MIME = {
100
+ '.png': 'image/png',
101
+ '.jpg': 'image/jpeg',
102
+ '.jpeg': 'image/jpeg',
103
+ '.gif': 'image/gif',
104
+ '.webp': 'image/webp',
105
+ '.svg': 'image/svg+xml',
106
+ '.ico': 'image/x-icon',
107
+ '.mp4': 'video/mp4',
108
+ '.webm': 'video/webm',
109
+ };
110
+
78
111
  function openInBrowser(url) {
79
112
  const cmd = process.platform === 'darwin' ? 'open' :
80
113
  process.platform === 'win32' ? 'start' : 'xdg-open';
@@ -110,7 +143,21 @@ function startBrowserServer(opts = {}) {
110
143
  return reject(err);
111
144
  }
112
145
 
113
- const htmlTemplate = fs.readFileSync(htmlPath, 'utf-8');
146
+ // Cache the HTML at startup, but if the resolver picked the workspace
147
+ // copy (developer iterating on the UI), re-read on every request so
148
+ // edits show up on a simple browser refresh — no server restart
149
+ // needed. The installed-tarball path stays cached because its file
150
+ // never changes between sessions.
151
+ const workspaceHtml = path.join(__dirname, '../../../backend/static/console.html');
152
+ const isWorkspaceCopy = path.resolve(htmlPath) === path.resolve(workspaceHtml);
153
+ const cachedHtml = fs.readFileSync(htmlPath, 'utf-8');
154
+ function loadHtml() {
155
+ if (!isWorkspaceCopy) return cachedHtml;
156
+ try { return fs.readFileSync(htmlPath, 'utf-8'); }
157
+ catch { return cachedHtml; }
158
+ }
159
+
160
+ const assetsDir = resolveAssetsDir();
114
161
 
115
162
  const server = http.createServer((req, res) => {
116
163
  const url = new URL(req.url, `http://localhost:${port}`);
@@ -132,7 +179,34 @@ function startBrowserServer(opts = {}) {
132
179
 
133
180
  if (url.pathname === '/' || url.pathname === '/index.html') {
134
181
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
135
- res.end(htmlTemplate);
182
+ res.end(loadHtml());
183
+ return;
184
+ }
185
+
186
+ // Static assets: serve files from `static/assets/` for any
187
+ // `/assets/<name>` request. We resolve+contain inside the assets
188
+ // dir to defend against `../`-style traversal, fail closed if the
189
+ // asset dir doesn't exist, and only serve known image/video MIME
190
+ // types so this can't accidentally double as a generic file
191
+ // server.
192
+ if (url.pathname.startsWith('/assets/')) {
193
+ if (!assetsDir) { res.statusCode = 404; res.end('Not found'); return; }
194
+ const rel = url.pathname.replace(/^\/assets\//, '');
195
+ const full = path.resolve(assetsDir, rel);
196
+ if (!full.startsWith(path.resolve(assetsDir) + path.sep)) {
197
+ res.statusCode = 403; res.end('Forbidden'); return;
198
+ }
199
+ const ext = path.extname(full).toLowerCase();
200
+ const mime = ASSET_MIME[ext];
201
+ if (!mime) { res.statusCode = 415; res.end('Unsupported Media Type'); return; }
202
+ fs.readFile(full, (err, data) => {
203
+ if (err) { res.statusCode = 404; res.end('Not found'); return; }
204
+ res.setHeader('Content-Type', mime);
205
+ // Short cache so iterating on PNGs (which we re-read from
206
+ // the workspace in dev) doesn't get pinned to a stale copy.
207
+ res.setHeader('Cache-Control', 'public, max-age=60');
208
+ res.end(data);
209
+ });
136
210
  return;
137
211
  }
138
212