@bitpub/cli 2.0.3 → 2.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitpub/cli",
3
- "version": "2.0.3",
3
+ "version": "2.0.5",
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": {
@@ -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
 
@@ -12,7 +12,23 @@
12
12
  * "did you mean…" search across the user's full private namespace.
13
13
  * With exactly one match, auto-load and print a transparency note to
14
14
  * stderr saying where it actually came from. With multiple, list
15
- * them and exit. With zero, print an actionable not-found error.
15
+ * them and exit. With zero, fall through.
16
+ * 4. If still empty, check whether the address is a *folder* — i.e. it
17
+ * has descendants under it — and if so, auto-load them in one shot.
18
+ * Capped at `--limit` (default 20) so a stray `load` on a deep
19
+ * prefix can't blow up output. A stderr breadcrumb tells the caller
20
+ * what we did and (if truncated) how to get the rest. This catches
21
+ * the common mistake of `load`-ing a prefix like
22
+ * `bitpub://group:foo/Agents/x` when the real slices live at
23
+ * `.../Agents/x/README`, `.../script`, etc. A single wildcard
24
+ * remote pull is attempted on cache miss so this works without a
25
+ * separate `sync` step.
26
+ * 5. Otherwise: actionable not-found error.
27
+ *
28
+ * Multi-slice raw output (from explicit wildcards or from the folder
29
+ * auto-load in step 4) is prefixed with a `=== <address> ===` header
30
+ * per slice so the caller can tell which content came from which slice.
31
+ * Single-slice loads keep their clean stdout-only output (no header).
16
32
  *
17
33
  * The DWIM step exists because agents holding a fully-qualified URL will
18
34
  * sometimes (incorrectly) strip it down to a short name before calling
@@ -33,6 +49,11 @@ module.exports = function registerLoad(program) {
33
49
  .description('Load a slice by short name (this project) or full bitpub:// address')
34
50
  .option('--no-fetch', 'Do not fetch from cloud if missing locally')
35
51
  .option('--format <raw|json>', 'Output format', 'raw')
52
+ .option(
53
+ '--limit <n>',
54
+ 'Max slices to return when the address resolves to a folder (default 20)',
55
+ '20'
56
+ )
36
57
  .action(async (name, opts) => {
37
58
  const config = requireConfig();
38
59
  const wasShortName = !name.startsWith('bitpub://') && !isAliasRef(name);
@@ -84,7 +105,58 @@ module.exports = function registerLoad(program) {
84
105
  console.error(` bitpub load <one-of-the-addresses-above>`);
85
106
  process.exit(1);
86
107
  }
87
- // Zero candidates: fall through to the not-found block below.
108
+ // Zero candidates: fall through to the folder/not-found block below.
109
+ }
110
+
111
+ // Folder check: the exact address didn't resolve to a slice, but it
112
+ // may be a *prefix* with children under it (e.g. user typed
113
+ // `bitpub://group:foo/Agents/x` when the real slices live at
114
+ // `.../Agents/x/README`, `.../script`, etc). Auto-load up to
115
+ // --limit children so the caller doesn't have to round-trip.
116
+ // Skip if the user already passed a wildcard — querySlices already
117
+ // handles that path and a 0-result wildcard is a real not-found.
118
+ if (slices.length === 0 && !hcu.includes('*')) {
119
+ const cleanHcu = hcu.replace(/\/$/, '');
120
+ const folderPattern = cleanHcu + '/**';
121
+ // Cap raised slightly above the user-facing default so a folder
122
+ // with N=21 items doesn't get silently truncated below the cap
123
+ // the user asked for; we still slice() to `limit` for display.
124
+ const limit = Math.max(1, parseInt(opts.limit, 10) || 20);
125
+ let descendants = querySlices(folderPattern);
126
+
127
+ if (descendants.length === 0 && opts.fetch !== false) {
128
+ try {
129
+ const api = createApiClient(config);
130
+ const remote = await api.pull(folderPattern, Math.max(limit, 50));
131
+ decryptSlices(remote, config.api_key);
132
+ remote.forEach(upsertSlice);
133
+ descendants = querySlices(folderPattern);
134
+ } catch {
135
+ // Non-fatal — if there's nothing remote either, we'll fall
136
+ // through to the regular not-found message below.
137
+ }
138
+ }
139
+
140
+ if (descendants.length > 0) {
141
+ const total = descendants.length;
142
+ const truncated = total > limit;
143
+ slices = descendants.slice(0, limit);
144
+
145
+ // Transparency breadcrumb → stderr (keeps stdout pure for pipes).
146
+ // The agent gets a single useful message that tells it (a) what
147
+ // we did and (b) how to escape the cap if 20 wasn't enough.
148
+ if (truncated) {
149
+ console.error(
150
+ `(loaded ${slices.length} of ${total} descendants of "${cleanHcu}" — ` +
151
+ `it's a folder, not a slice; pass --limit ${total} for all of them)`
152
+ );
153
+ } else {
154
+ console.error(
155
+ `(loaded ${slices.length} descendant${slices.length === 1 ? '' : 's'} of ` +
156
+ `"${cleanHcu}" — it's a folder, not a slice)`
157
+ );
158
+ }
159
+ }
88
160
  }
89
161
 
90
162
  if (slices.length === 0) {
@@ -113,10 +185,15 @@ module.exports = function registerLoad(program) {
113
185
  return;
114
186
  }
115
187
 
188
+ // Multi-slice raw output (wildcard input or folder auto-load) gets
189
+ // a `=== <address> ===` header per slice so the caller can attribute
190
+ // each chunk. Single-slice loads keep their clean header-free output.
116
191
  const parts = slices.map(s => {
117
192
  const payload = JSON.parse(s.payload);
118
- return payload.content ?? '';
193
+ const content = payload.content ?? '';
194
+ if (slices.length === 1) return content;
195
+ return `=== ${s.hcu} ===\n${content}`;
119
196
  });
120
- console.log(parts.join('\n---\n'));
197
+ console.log(parts.join(slices.length === 1 ? '' : '\n\n'));
121
198
  });
122
199
  };
@@ -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 { requireConfig, readConfig, writeConfig, authorIdFor } = require('../config');
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
- // Plain markdown — the browser UI renders the slice content directly.
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: 'text/markdown',
178
+ format,
126
179
  content: encrypt(content, config.api_key),
127
180
  },
128
181
  };
Binary file
Binary file