@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 +2 -2
- package/src/commands/browser.js +89 -15
- package/src/commands/load.js +81 -4
- package/src/commands/welcome.js +60 -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bitpub/cli",
|
|
3
|
-
"version": "2.0.
|
|
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": {
|
package/src/commands/browser.js
CHANGED
|
@@ -46,27 +46,29 @@ function decryptSlice(slice, apiKey) {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
function resolveBrowserHtmlPath() {
|
|
49
|
-
//
|
|
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.
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
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
|
|
61
|
-
// We never write here ourselves; it exists only when a
|
|
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
|
-
|
|
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(
|
|
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
|
|
package/src/commands/load.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
197
|
+
console.log(parts.join(slices.length === 1 ? '' : '\n\n'));
|
|
121
198
|
});
|
|
122
199
|
};
|
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
|
};
|
|
Binary file
|
|
Binary file
|