@bitpub/cli 2.1.0 → 2.1.2
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 +1 -1
- package/skills/bitpub/SKILL.md +84 -0
- package/src/commands/browser.js +122 -84
- package/static/console.html +271 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bitpub/cli",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.2",
|
|
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/skills/bitpub/SKILL.md
CHANGED
|
@@ -241,6 +241,90 @@ valid pattern wherever a scope is accepted.
|
|
|
241
241
|
|
|
242
242
|
---
|
|
243
243
|
|
|
244
|
+
## Publishing apps
|
|
245
|
+
|
|
246
|
+
BitPub can render HTML apps directly in its browser (`bitpub browser`,
|
|
247
|
+
served at `http://localhost:4141`). An "app" is just three slices saved
|
|
248
|
+
at the same prefix — no CLI release, no plugin install, no extension API.
|
|
249
|
+
Save the slices and the app appears in every member's BitPub Browser the
|
|
250
|
+
moment their next `bitpub sync` lands.
|
|
251
|
+
|
|
252
|
+
| Slice | What it is | Format |
|
|
253
|
+
|---|---|---|
|
|
254
|
+
| `<prefix>` | The app shell — rendered in a sandboxed iframe | `text/html` |
|
|
255
|
+
| `<prefix>/manifest.json` | Declarative list of functions the app may call | `application/json` |
|
|
256
|
+
| `<prefix>/<orchestrator>` | (Optional) script the app's functions execute | `text/plain` (or `text/x-python`, etc.) |
|
|
257
|
+
|
|
258
|
+
Inside the iframe an app calls:
|
|
259
|
+
|
|
260
|
+
```js
|
|
261
|
+
window.bitpub.identity.me() // → { owner, domain, private_prefix }
|
|
262
|
+
window.bitpub.namespace.list({ pattern }) // local SQLite read; no network, no LLM
|
|
263
|
+
window.bitpub.run(functionId, args) // dispatched through the manifest
|
|
264
|
+
window.bitpub.navigate(hcu) // open another slice in the browser
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
`window.bitpub.run` does not execute arbitrary commands. It looks up
|
|
268
|
+
`functionId` in the app's manifest and dispatches to one of two primitives
|
|
269
|
+
the CLI bridge exposes:
|
|
270
|
+
|
|
271
|
+
- **`script.run`** — `bitpub load <script_hcu>` to a temp file, then run
|
|
272
|
+
it with the named interpreter (`python3`, `node`, `bash`, `sh`). Reads
|
|
273
|
+
`env` and `cli_args` from the manifest, with values resolved from the
|
|
274
|
+
caller's `args`, the running user's `identity`, or literals.
|
|
275
|
+
- **`os.open`** — `open` a URL or local path (Finder reveal, or
|
|
276
|
+
open-in-app).
|
|
277
|
+
|
|
278
|
+
A minimal manifest:
|
|
279
|
+
|
|
280
|
+
```json
|
|
281
|
+
{
|
|
282
|
+
"id": "team.example",
|
|
283
|
+
"name": "Example",
|
|
284
|
+
"version": "0.1.0",
|
|
285
|
+
"functions": {
|
|
286
|
+
"ingest": {
|
|
287
|
+
"primitive": "script.run",
|
|
288
|
+
"params": {
|
|
289
|
+
"script_hcu": "bitpub://group:<domain>/Apps/example/orchestrator",
|
|
290
|
+
"runtime": "python3"
|
|
291
|
+
},
|
|
292
|
+
"env": {
|
|
293
|
+
"BITPUB_OWNER": { "from": "identity", "key": "owner" },
|
|
294
|
+
"TARGET": { "from": "args", "key": "target" }
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
"open_docs": {
|
|
298
|
+
"primitive": "os.open",
|
|
299
|
+
"params": { "url": "https://example.com/docs" }
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
Two rules of thumb that keep apps portable across teammates:
|
|
306
|
+
|
|
307
|
+
- **Read user identity at runtime** in both the orchestrator and the
|
|
308
|
+
manifest (via `{ "from": "identity", "key": "owner" }`). Never hardcode
|
|
309
|
+
an `agent_xxx` id — it breaks the moment a different teammate runs the
|
|
310
|
+
app.
|
|
311
|
+
- **Split ingest from read.** Ingest (write to BitPub) is the slow,
|
|
312
|
+
credentialed step. Once data is local, every read should be a
|
|
313
|
+
deterministic `namespace.list` / `bitpub load` — no LLM, no API call.
|
|
314
|
+
|
|
315
|
+
Before writing a new app, read the full publishing guide. It's hosted
|
|
316
|
+
alongside this skill so it's always reachable from a fresh install:
|
|
317
|
+
|
|
318
|
+
```bash
|
|
319
|
+
curl -fsSL {{API_BASE_URL}}/publishing-apps.md
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
If the team has shipped its own copy at
|
|
323
|
+
`bitpub://group:<domain>/Docs/how-to-publish-a-bitpub-app`, prefer that
|
|
324
|
+
— it usually links to in-tree example apps you can fork from.
|
|
325
|
+
|
|
326
|
+
---
|
|
327
|
+
|
|
244
328
|
## Writing scripts that use BitPub
|
|
245
329
|
|
|
246
330
|
Scripts should read `owner` and `domain` from `~/.bitpub/config.json` at
|
package/src/commands/browser.js
CHANGED
|
@@ -110,106 +110,126 @@ const ASSET_MIME = {
|
|
|
110
110
|
|
|
111
111
|
/* ── Bridge (apps → local CLI) ──────────────────────────────────────
|
|
112
112
|
*
|
|
113
|
-
* Sandboxed app iframes
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
* this CLI process, which spawns a child and streams stdout/stderr
|
|
117
|
-
* back over Server-Sent Events.
|
|
113
|
+
* Sandboxed app iframes can postMessage to the parent BitPub Browser
|
|
114
|
+
* shell, which forwards approved calls to this CLI process. The CLI
|
|
115
|
+
* spawns a child and streams stdout/stderr back over SSE.
|
|
118
116
|
*
|
|
119
|
-
*
|
|
120
|
-
* -
|
|
121
|
-
*
|
|
122
|
-
* -
|
|
123
|
-
* interpolation into the shell.
|
|
124
|
-
* - The HTTP server binds to localhost only (default port 4141),
|
|
125
|
-
* so the surface is the same trust boundary as the CLI itself:
|
|
126
|
-
* anyone with shell access already has more power than the bridge.
|
|
127
|
-
* - The next layer (permission grants per app + audit log) is a
|
|
128
|
-
* console.html responsibility, not the CLI's.
|
|
117
|
+
* SUBSTRATE PRIMITIVES (this file):
|
|
118
|
+
* - script.run load a slice as a script + run with an interpreter
|
|
119
|
+
* - os.open open a URL in the default browser, or a path locally
|
|
120
|
+
* - identity.me who is this BitPub install (separate /bridge/identity endpoint)
|
|
129
121
|
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
122
|
+
* APP-SPECIFIC FUNCTIONS (live in the namespace as manifest.json slices):
|
|
123
|
+
* Apps declare their own named functions in a manifest sibling slice,
|
|
124
|
+
* e.g. bitpub://group:tollbit.com/Apps/github/manifest.json. The
|
|
125
|
+
* Browser shell (console.html) reads that manifest, resolves each
|
|
126
|
+
* function call to a script.run or os.open invocation, and forwards
|
|
127
|
+
* to the bridge. This means no CLI release is needed to publish a
|
|
128
|
+
* new app — anyone in a group can save HTML + script + manifest and
|
|
129
|
+
* ship it.
|
|
130
|
+
*
|
|
131
|
+
* Trust model: same as installing an npm package. Anything the script
|
|
132
|
+
* can do, the orchestrator can do — we don't sandbox the child process.
|
|
133
|
+
* The browser shell is responsible for the install consent UI; the CLI
|
|
134
|
+
* only enforces input shape on the primitives.
|
|
134
135
|
*/
|
|
135
136
|
const FUNCTIONS = {
|
|
136
|
-
'granola.ingest': {
|
|
137
|
-
description: 'Pull new Granola meetings into your private namespace.',
|
|
138
|
-
build(args) {
|
|
139
|
-
const limit = clampInt(args && args.limit, 1, 200, 5);
|
|
140
|
-
const script =
|
|
141
|
-
'set -e; ' +
|
|
142
|
-
'bitpub load bitpub://group:tollbit.com/Agents/granola-ingest/script > "$T" && ' +
|
|
143
|
-
`python3 "$T" --limit ${limit}`;
|
|
144
|
-
return {
|
|
145
|
-
cmd: 'bash',
|
|
146
|
-
args: ['-lc', script],
|
|
147
|
-
env: { T: path.join(os.tmpdir(), `.bitpub-fn-granola-ingest.py`) },
|
|
148
|
-
};
|
|
149
|
-
},
|
|
150
|
-
},
|
|
151
137
|
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
'
|
|
157
|
-
description: '
|
|
138
|
+
// ── Substrate primitive 1: load + run a script slice ───────
|
|
139
|
+
// Everything else (ingest scripts, generators, summarizers) is
|
|
140
|
+
// implementable on top of this. Args are deliberately flat so a
|
|
141
|
+
// declarative app manifest can construct them without a build step.
|
|
142
|
+
'script.run': {
|
|
143
|
+
description: 'Load a slice as a script and run it with the given interpreter.',
|
|
158
144
|
build(args) {
|
|
159
145
|
const a = args || {};
|
|
160
|
-
const
|
|
161
|
-
const
|
|
162
|
-
const
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
if (
|
|
168
|
-
throw new Error('
|
|
146
|
+
const hcu = String(a.script_hcu || a.script || '').trim();
|
|
147
|
+
const runtime = String(a.runtime || 'python3').trim();
|
|
148
|
+
const env = (a.env && typeof a.env === 'object') ? a.env : {};
|
|
149
|
+
const cliArgs = Array.isArray(a.cli_args) ? a.cli_args : [];
|
|
150
|
+
|
|
151
|
+
// HCU must be a real bitpub:// address and free of shell metachars.
|
|
152
|
+
// (We re-embed it into the bash -lc string below, so be paranoid.)
|
|
153
|
+
if (!/^bitpub:\/\/(private|group|public):[A-Za-z0-9._-]+\/[A-Za-z0-9._\/\-]+$/.test(hcu)) {
|
|
154
|
+
throw new Error('invalid script_hcu');
|
|
169
155
|
}
|
|
156
|
+
if (/[\s"'`$;&|<>\\]/.test(hcu)) throw new Error('script_hcu contains unsafe characters');
|
|
170
157
|
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
deal_stage: dealStage,
|
|
176
|
-
extra_notes: extraNotes,
|
|
177
|
-
output_dir: outputDir,
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
const script =
|
|
181
|
-
'set -e; ' +
|
|
182
|
-
'bitpub load bitpub://private:agent_ewim9gf01nq3/Apps/sales-brief-generator/orchestrator > "$ORCH" && ' +
|
|
183
|
-
'python3 "$ORCH"';
|
|
184
|
-
|
|
185
|
-
return {
|
|
186
|
-
cmd: 'bash',
|
|
187
|
-
args: ['-lc', script],
|
|
188
|
-
env: {
|
|
189
|
-
ORCH: path.join(os.tmpdir(), '.bitpub-fn-sales-brief.py'),
|
|
190
|
-
BRIEF_PAYLOAD: payload,
|
|
191
|
-
},
|
|
192
|
-
};
|
|
193
|
-
},
|
|
194
|
-
},
|
|
158
|
+
const ALLOWED_RUNTIMES = ['python3', 'python', 'node', 'bash', 'sh'];
|
|
159
|
+
if (!ALLOWED_RUNTIMES.includes(runtime)) {
|
|
160
|
+
throw new Error(`runtime "${runtime}" not allowed (use one of ${ALLOWED_RUNTIMES.join(', ')})`);
|
|
161
|
+
}
|
|
195
162
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
163
|
+
// Sanitize env: only POSIX-ish var names, stringify values.
|
|
164
|
+
const cleanEnv = {};
|
|
165
|
+
for (const [k, v] of Object.entries(env)) {
|
|
166
|
+
if (!/^[A-Z_][A-Z0-9_]{0,63}$/i.test(k)) continue;
|
|
167
|
+
if (v == null) continue;
|
|
168
|
+
cleanEnv[k] = (typeof v === 'string') ? v : JSON.stringify(v);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Sanitize CLI args: stringify, reject anything with shell metachars.
|
|
172
|
+
const cleanCliArgs = [];
|
|
173
|
+
for (const x of cliArgs) {
|
|
174
|
+
const s = String(x);
|
|
175
|
+
if (s.length > 256) throw new Error('cli arg too long');
|
|
176
|
+
if (/[`$;&|<>\\\n]/.test(s)) throw new Error('cli arg contains unsafe characters');
|
|
177
|
+
cleanCliArgs.push(s);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Unique temp file per invocation so two concurrent runs of the
|
|
181
|
+
// same script can't trample each other.
|
|
182
|
+
const tag = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
183
|
+
const T = path.join(os.tmpdir(), `.bitpub-script-${tag}`);
|
|
184
|
+
cleanEnv.T = T;
|
|
185
|
+
|
|
186
|
+
// Re-shell-quote CLI args by passing them as positional args to bash
|
|
187
|
+
// via the "_" sentinel, then "$@" preserves quoting.
|
|
188
|
+
const cliPart = cleanCliArgs.length
|
|
189
|
+
? ' ' + cleanCliArgs.map(a => `"${a.replace(/"/g, '\\"')}"`).join(' ')
|
|
190
|
+
: '';
|
|
191
|
+
const script = `set -e; bitpub load "${hcu}" > "$T" && ${runtime} "$T"${cliPart}`;
|
|
192
|
+
return { cmd: 'bash', args: ['-lc', script], env: cleanEnv };
|
|
202
193
|
},
|
|
203
194
|
},
|
|
204
195
|
|
|
205
|
-
|
|
206
|
-
|
|
196
|
+
// ── Substrate primitive 2: open a URL or a local path ──────
|
|
197
|
+
// Wraps macOS `open` (the only OS-level capability apps reach for
|
|
198
|
+
// often enough to belong in the substrate). Manifests can declare
|
|
199
|
+
// url_pattern allowlists for extra hygiene — enforced at resolver
|
|
200
|
+
// time in the shell, not here.
|
|
201
|
+
'os.open': {
|
|
202
|
+
description: 'Open a URL in the default browser, or a path in Finder / a specific app.',
|
|
207
203
|
build(args) {
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
204
|
+
const a = args || {};
|
|
205
|
+
const url = a.url ? String(a.url) : null;
|
|
206
|
+
const filePath = a.path ? String(a.path) : null;
|
|
207
|
+
const reveal = !!a.reveal;
|
|
208
|
+
const inApp = a.in ? String(a.in) : null;
|
|
209
|
+
|
|
210
|
+
if (url) {
|
|
211
|
+
if (!/^https?:\/\//.test(url) || /["`$;&|<>\\]/.test(url)) throw new Error('invalid url');
|
|
212
|
+
return { cmd: 'bash', args: ['-lc', `open "${url}"`] };
|
|
213
|
+
}
|
|
214
|
+
if (filePath) {
|
|
215
|
+
if (!filePath.startsWith('/') || /["`$;&|<>\\]/.test(filePath)) throw new Error('invalid path');
|
|
216
|
+
let flag = '';
|
|
217
|
+
if (reveal) flag = '-R ';
|
|
218
|
+
else if (inApp) {
|
|
219
|
+
if (!/^[A-Za-z0-9. _-]{1,64}$/.test(inApp)) throw new Error('invalid app name');
|
|
220
|
+
flag = `-a "${inApp}" `;
|
|
221
|
+
}
|
|
222
|
+
return { cmd: 'bash', args: ['-lc', `open ${flag}"${filePath}"`] };
|
|
223
|
+
}
|
|
224
|
+
throw new Error('os.open needs either url or path');
|
|
211
225
|
},
|
|
212
226
|
},
|
|
227
|
+
|
|
228
|
+
// ───────────────────────────────────────────────────────────
|
|
229
|
+
// (Apps now declare their functions in manifest.json slices —
|
|
230
|
+
// see backend/static/console.html#resolveManifestFunction.)
|
|
231
|
+
// ───────────────────────────────────────────────────────────
|
|
232
|
+
|
|
213
233
|
};
|
|
214
234
|
|
|
215
235
|
function clampInt(value, lo, hi, dflt) {
|
|
@@ -415,6 +435,24 @@ function startBrowserServer(opts = {}) {
|
|
|
415
435
|
return;
|
|
416
436
|
}
|
|
417
437
|
|
|
438
|
+
// Bridge: who is this BitPub installation? Apps that ship via a
|
|
439
|
+
// group/public namespace can't hardcode an owner ID — they need to
|
|
440
|
+
// know "what is the current user's private prefix" at runtime so
|
|
441
|
+
// their namespace.list() / save() calls hit the right cache.
|
|
442
|
+
// Returns the minimum info needed: owner id, the domain (for
|
|
443
|
+
// display), and the canonical private namespace prefix.
|
|
444
|
+
if (url.pathname === '/bridge/identity') {
|
|
445
|
+
res.setHeader('Content-Type', 'application/json');
|
|
446
|
+
const owner = (cfg && cfg.owner) || null;
|
|
447
|
+
const domain = (cfg && cfg.domain) || null;
|
|
448
|
+
res.end(JSON.stringify({
|
|
449
|
+
owner,
|
|
450
|
+
domain,
|
|
451
|
+
private_prefix: owner ? `bitpub://private:${owner}` : null,
|
|
452
|
+
}));
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
418
456
|
// Bridge: list registered functions (for the permission UI to
|
|
419
457
|
// describe what an app is about to run).
|
|
420
458
|
if (url.pathname === '/bridge/functions') {
|
package/static/console.html
CHANGED
|
@@ -1613,8 +1613,18 @@ function renderBranch(scope, children, path) {
|
|
|
1613
1613
|
|
|
1614
1614
|
let html = '';
|
|
1615
1615
|
|
|
1616
|
-
//
|
|
1617
|
-
|
|
1616
|
+
// Direct leaves: render by name when small enough to be useful as a
|
|
1617
|
+
// package contents listing (e.g. an Apps/github folder showing its
|
|
1618
|
+
// `orchestrator` child by name). Aggregate into a single "N context
|
|
1619
|
+
// slices" summary above a threshold so very large folders (282
|
|
1620
|
+
// transcripts, hundreds of PRs) don't blow up the tree.
|
|
1621
|
+
const LEAF_NAME_THRESHOLD = 8;
|
|
1622
|
+
if (directLeaves.length > 0 && directLeaves.length <= LEAF_NAME_THRESHOLD) {
|
|
1623
|
+
const sorted = [...directLeaves].sort((a, b) => a.name.localeCompare(b.name));
|
|
1624
|
+
for (const { name, slice } of sorted) {
|
|
1625
|
+
html += renderLeaf(slice, name);
|
|
1626
|
+
}
|
|
1627
|
+
} else if (directLeaves.length > LEAF_NAME_THRESHOLD) {
|
|
1618
1628
|
const mostRecent = directLeaves.reduce(
|
|
1619
1629
|
(a, b) => ((a.slice.last_synced || '') > (b.slice.last_synced || '') ? a : b)
|
|
1620
1630
|
);
|
|
@@ -1623,7 +1633,11 @@ function renderBranch(scope, children, path) {
|
|
|
1623
1633
|
const pathArg = JSON.stringify(path).replace(/"/g, '"');
|
|
1624
1634
|
const isActiveSummary = S.view === 'namespace' && S.currentScope === scope && arrayEq(S.currentPath, path);
|
|
1625
1635
|
const when = mostRecent.slice.last_synced ? timeAgo(mostRecent.slice.last_synced) : '';
|
|
1626
|
-
|
|
1636
|
+
// Aggregated summary goes to the namespace listing (not the self-slice).
|
|
1637
|
+
// Calls navigateToNamespace to bypass the directory-index resolution in
|
|
1638
|
+
// navigateTo() — clicking "47 PRs" should show the listing, not whatever
|
|
1639
|
+
// happens to live at the parent address.
|
|
1640
|
+
html += `<div class="tree-leaf tree-summary${isActiveSummary ? ' active' : ''}" onclick="navigateToNamespace('${escAttr(scope)}', ${pathArg})" title="View ${directLeaves.length} slice${directLeaves.length !== 1 ? 's' : ''} at this level">
|
|
1627
1641
|
<span class="fresh-dot ${freshClass}"></span>
|
|
1628
1642
|
<span class="tree-label">${directLeaves.length} context slice${directLeaves.length !== 1 ? 's' : ''}</span>
|
|
1629
1643
|
<span class="tree-badge">${esc(when)}</span>
|
|
@@ -1934,6 +1948,24 @@ function goRoot() {
|
|
|
1934
1948
|
}
|
|
1935
1949
|
|
|
1936
1950
|
function navigateTo(scope, path) {
|
|
1951
|
+
const segs = Array.isArray(path) ? path : [];
|
|
1952
|
+
// Directory-index semantics: if a slice exists AT this folder address
|
|
1953
|
+
// (e.g. an HTML app at `Apps/github` that also has `Apps/github/orchestrator`
|
|
1954
|
+
// as a child), render the slice as the primary view — same as a
|
|
1955
|
+
// browser serving /foo/index.html when you visit /foo/. Children stay
|
|
1956
|
+
// reachable via the tree chevron and named-leaf entries underneath.
|
|
1957
|
+
const selfHcu = `bitpub://${scope}` + (segs.length ? '/' + segs.join('/') : '');
|
|
1958
|
+
const self = S.slices.find(s => s.hcu === selfHcu);
|
|
1959
|
+
if (self) { selectSlice(selfHcu); return; }
|
|
1960
|
+
|
|
1961
|
+
navigateToNamespace(scope, segs);
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
// Explicit "show me the folder listing" navigation. Used by the
|
|
1965
|
+
// aggregated "N context slices" summary in the tree, and anywhere
|
|
1966
|
+
// else we want to skip the directory-index check (e.g. "see all in
|
|
1967
|
+
// this namespace" links).
|
|
1968
|
+
function navigateToNamespace(scope, path) {
|
|
1937
1969
|
S.view = 'namespace';
|
|
1938
1970
|
S.currentScope = scope;
|
|
1939
1971
|
S.currentPath = Array.isArray(path) ? path : [];
|
|
@@ -2458,7 +2490,7 @@ function renderSlice(sl) {
|
|
|
2458
2490
|
if (S.raw) {
|
|
2459
2491
|
html += `<pre class="content-raw">${esc(content)}</pre>`;
|
|
2460
2492
|
} else if (canRenderAsApp) {
|
|
2461
|
-
html += renderHtmlAppFrame(content);
|
|
2493
|
+
html += renderHtmlAppFrame(content, sl.hcu);
|
|
2462
2494
|
} else if (kind === 'html') {
|
|
2463
2495
|
html += `<div class="app-public-note">${ICON.info}
|
|
2464
2496
|
<div>HTML apps from <code>public:</code> addresses are shown as source until the public sandbox model lands. Apps in <code>private:</code> and <code>group:</code> scopes render inline.</div>
|
|
@@ -2523,14 +2555,21 @@ function detectContentKind(sl, content) {
|
|
|
2523
2555
|
* - The iframe never reaches the network itself — only the parent
|
|
2524
2556
|
* does. CSP can stay locked down (default-src 'none').
|
|
2525
2557
|
*/
|
|
2526
|
-
function renderHtmlAppFrame(html) {
|
|
2558
|
+
function renderHtmlAppFrame(html, appHcu) {
|
|
2527
2559
|
const csp = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; base-uri 'none'; form-action 'none'">`;
|
|
2528
2560
|
const baseTag = `<base target="_blank">`;
|
|
2561
|
+
// Inject the app's own HCU into the iframe so the bridge shim can tag
|
|
2562
|
+
// every postMessage. The parent uses this to look up the calling app's
|
|
2563
|
+
// manifest and resolve short-named functions to script.run / os.open
|
|
2564
|
+
// invocations. (Apps could lie about this in v1 — we trust app code
|
|
2565
|
+
// the same way you trust an npm package.)
|
|
2566
|
+
const safeHcu = String(appHcu || '').replace(/['"\\]/g, '');
|
|
2567
|
+
const hcuConst = `<\x73cript>window.__BITPUB_APP_HCU__='${safeHcu}';<\/script>`;
|
|
2529
2568
|
// The shim tag uses \x73 + escaped slash so the closing pattern can't
|
|
2530
2569
|
// be detected by the outer HTML parser. The escapes are transparent to
|
|
2531
2570
|
// the JS parser but defeat the HTML scanner's greedy script-end match.
|
|
2532
2571
|
const shim = `<\x73cript>${BRIDGE_SHIM_SOURCE}<\/script>`;
|
|
2533
|
-
const headInject = `${csp}${baseTag}${shim}`;
|
|
2572
|
+
const headInject = `${csp}${baseTag}${hcuConst}${shim}`;
|
|
2534
2573
|
|
|
2535
2574
|
let doc;
|
|
2536
2575
|
if (/<head[\s>]/i.test(html)) {
|
|
@@ -2595,6 +2634,16 @@ const BRIDGE_SHIM_SOURCE = String.raw`
|
|
|
2595
2634
|
readHandlers.delete(d.requestId);
|
|
2596
2635
|
if (d.ok) rh.resolve({ slices: d.slices || [], count: d.count || 0, pattern: d.pattern || '' });
|
|
2597
2636
|
else rh.reject(new Error(d.error || 'namespace read failed'));
|
|
2637
|
+
} else if (d.type === 'bitpub.identity.result') {
|
|
2638
|
+
var ih = readHandlers.get(d.requestId);
|
|
2639
|
+
if (!ih) return;
|
|
2640
|
+
readHandlers.delete(d.requestId);
|
|
2641
|
+
if (d.ok) ih.resolve({
|
|
2642
|
+
owner: d.owner,
|
|
2643
|
+
domain: d.domain,
|
|
2644
|
+
private_prefix: d.private_prefix,
|
|
2645
|
+
});
|
|
2646
|
+
else ih.reject(new Error(d.error || 'identity lookup failed'));
|
|
2598
2647
|
}
|
|
2599
2648
|
});
|
|
2600
2649
|
window.bitpub = {
|
|
@@ -2606,6 +2655,7 @@ const BRIDGE_SHIM_SOURCE = String.raw`
|
|
|
2606
2655
|
type: 'bitpub.run',
|
|
2607
2656
|
requestId: requestId,
|
|
2608
2657
|
functionId: String(functionId),
|
|
2658
|
+
appHcu: window.__BITPUB_APP_HCU__ || '',
|
|
2609
2659
|
args: args || {}
|
|
2610
2660
|
}, '*');
|
|
2611
2661
|
return requestId;
|
|
@@ -2638,6 +2688,43 @@ const BRIDGE_SHIM_SOURCE = String.raw`
|
|
|
2638
2688
|
}, 10000);
|
|
2639
2689
|
});
|
|
2640
2690
|
}
|
|
2691
|
+
},
|
|
2692
|
+
navigate: function (hcu) {
|
|
2693
|
+
// Hand off to another slice in the parent BitPub Browser
|
|
2694
|
+
// (e.g. discovery page → installed app). HCU must be a
|
|
2695
|
+
// bitpub:// address; everything else is dropped silently.
|
|
2696
|
+
window.parent.postMessage({
|
|
2697
|
+
__bitpub: true,
|
|
2698
|
+
type: 'bitpub.navigate',
|
|
2699
|
+
hcu: String(hcu || ''),
|
|
2700
|
+
}, '*');
|
|
2701
|
+
},
|
|
2702
|
+
identity: {
|
|
2703
|
+
// me() -> Promise<{ owner, domain, private_prefix }>
|
|
2704
|
+
// owner e.g. "agent_ewim9gf01nq3"
|
|
2705
|
+
// domain e.g. "tollbit.com"
|
|
2706
|
+
// private_prefix e.g. "bitpub://private:agent_ewim9gf01nq3"
|
|
2707
|
+
//
|
|
2708
|
+
// Lets apps that are distributed via a group/public namespace
|
|
2709
|
+
// construct addresses that point at the *running user's* private
|
|
2710
|
+
// data without hardcoding an owner ID. Use this if your app ships
|
|
2711
|
+
// to other people.
|
|
2712
|
+
me: function () {
|
|
2713
|
+
return new Promise(function (resolve, reject) {
|
|
2714
|
+
var requestId = ++nextId;
|
|
2715
|
+
readHandlers.set(requestId, { resolve: resolve, reject: reject });
|
|
2716
|
+
window.parent.postMessage({
|
|
2717
|
+
__bitpub: true,
|
|
2718
|
+
type: 'bitpub.identity.me',
|
|
2719
|
+
requestId: requestId,
|
|
2720
|
+
}, '*');
|
|
2721
|
+
setTimeout(function () {
|
|
2722
|
+
if (!readHandlers.has(requestId)) return;
|
|
2723
|
+
readHandlers.delete(requestId);
|
|
2724
|
+
reject(new Error('identity.me timed out'));
|
|
2725
|
+
}, 5000);
|
|
2726
|
+
});
|
|
2727
|
+
}
|
|
2641
2728
|
}
|
|
2642
2729
|
};
|
|
2643
2730
|
})();
|
|
@@ -2705,13 +2792,151 @@ function bridgeForward(source, requestId, event, payload) {
|
|
|
2705
2792
|
} catch (_) { /* iframe gone */ }
|
|
2706
2793
|
}
|
|
2707
2794
|
|
|
2708
|
-
function
|
|
2709
|
-
|
|
2795
|
+
/* ── Manifest-driven app function dispatch ─────────────────────────
|
|
2796
|
+
*
|
|
2797
|
+
* Apps declare their callable functions in a sibling slice named
|
|
2798
|
+
* `manifest.json`. The parent browser shell (this file) reads that
|
|
2799
|
+
* manifest the first time the app calls bitpub.run(), then resolves
|
|
2800
|
+
* every subsequent call to a CLI substrate primitive — without the
|
|
2801
|
+
* CLI needing to know anything about the app.
|
|
2802
|
+
*
|
|
2803
|
+
* Manifest shape:
|
|
2804
|
+
* {
|
|
2805
|
+
* "id": "tollbit.github",
|
|
2806
|
+
* "name": "GitHub Pack",
|
|
2807
|
+
* "version": "0.1.0",
|
|
2808
|
+
* "functions": {
|
|
2809
|
+
* "ingest": {
|
|
2810
|
+
* "primitive": "script.run",
|
|
2811
|
+
* "params": {
|
|
2812
|
+
* "script_hcu": "bitpub://group:tollbit.com/Apps/github/orchestrator",
|
|
2813
|
+
* "runtime": "python3"
|
|
2814
|
+
* },
|
|
2815
|
+
* "env": {
|
|
2816
|
+
* "BITPUB_GH_ACTION": "ingest",
|
|
2817
|
+
* "BITPUB_GH_RECIPES": { "from": "args", "key": "recipes", "join": "," },
|
|
2818
|
+
* "BITPUB_OWNER": { "from": "identity", "key": "owner" }
|
|
2819
|
+
* }
|
|
2820
|
+
* }
|
|
2821
|
+
* }
|
|
2822
|
+
* }
|
|
2823
|
+
*
|
|
2824
|
+
* Field values can be:
|
|
2825
|
+
* - a literal string → used as-is
|
|
2826
|
+
* - { from: "args", key: "...", join?: "," } → arg-derived
|
|
2827
|
+
* - { from: "identity", key: "..." } → from identity.me()
|
|
2828
|
+
*/
|
|
2829
|
+
|
|
2830
|
+
const MANIFEST_CACHE = new Map(); // appHcu → Promise<manifest|null>
|
|
2831
|
+
const PRIMITIVE_IDS = new Set(['script.run', 'os.open']);
|
|
2832
|
+
|
|
2833
|
+
async function loadAppManifest(appHcu) {
|
|
2834
|
+
if (!appHcu) return null;
|
|
2835
|
+
if (MANIFEST_CACHE.has(appHcu)) return MANIFEST_CACHE.get(appHcu);
|
|
2836
|
+
const p = (async () => {
|
|
2837
|
+
const manifestHcu = appHcu + '/manifest.json';
|
|
2838
|
+
try {
|
|
2839
|
+
const u = new URL('/bridge/namespace/list', location.origin);
|
|
2840
|
+
u.searchParams.set('pattern', manifestHcu);
|
|
2841
|
+
u.searchParams.set('fields', 'full');
|
|
2842
|
+
u.searchParams.set('limit', '1');
|
|
2843
|
+
const r = await fetch(u.toString());
|
|
2844
|
+
const body = await r.json();
|
|
2845
|
+
const slice = (body.slices || [])[0];
|
|
2846
|
+
if (!slice || !slice.content) return null;
|
|
2847
|
+
return JSON.parse(slice.content);
|
|
2848
|
+
} catch (_) {
|
|
2849
|
+
return null;
|
|
2850
|
+
}
|
|
2851
|
+
})();
|
|
2852
|
+
MANIFEST_CACHE.set(appHcu, p);
|
|
2853
|
+
return p;
|
|
2854
|
+
}
|
|
2855
|
+
|
|
2856
|
+
let CACHED_IDENTITY = null;
|
|
2857
|
+
async function getIdentityCached() {
|
|
2858
|
+
if (CACHED_IDENTITY) return CACHED_IDENTITY;
|
|
2859
|
+
try {
|
|
2860
|
+
const r = await fetch('/bridge/identity');
|
|
2861
|
+
CACHED_IDENTITY = await r.json();
|
|
2862
|
+
} catch (_) {
|
|
2863
|
+
CACHED_IDENTITY = {};
|
|
2864
|
+
}
|
|
2865
|
+
return CACHED_IDENTITY;
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2868
|
+
function resolveManifestValue(v, ctx) {
|
|
2869
|
+
if (v == null) return null;
|
|
2870
|
+
// Arrays: resolve each element (used for cli_args).
|
|
2871
|
+
if (Array.isArray(v)) return v.map(x => resolveManifestValue(x, ctx)).filter(x => x != null);
|
|
2872
|
+
if (typeof v !== 'object') return v; // literal string / number
|
|
2873
|
+
const src = v.from === 'args' ? ctx.args : v.from === 'identity' ? ctx.identity : null;
|
|
2874
|
+
if (!src) return null;
|
|
2875
|
+
// Whole-object reference: { from: "args" } with no key → the entire
|
|
2876
|
+
// object. Useful for shipping all caller args as one JSON env var
|
|
2877
|
+
// (e.g. BRIEF_PAYLOAD). The env serializer downstream auto-stringifies
|
|
2878
|
+
// non-string values, so this Just Works.
|
|
2879
|
+
if (!v.key) return src;
|
|
2880
|
+
let val = src[v.key];
|
|
2881
|
+
if (val == null) return v.default != null ? v.default : null;
|
|
2882
|
+
if (v.join && Array.isArray(val)) val = val.join(v.join);
|
|
2883
|
+
return val;
|
|
2884
|
+
}
|
|
2885
|
+
|
|
2886
|
+
function resolveManifestFunction(manifest, funcName, args, identity) {
|
|
2887
|
+
if (!manifest || !manifest.functions) throw new Error('app has no manifest');
|
|
2888
|
+
const decl = manifest.functions[funcName];
|
|
2889
|
+
if (!decl) throw new Error(`function "${funcName}" not declared in app manifest`);
|
|
2890
|
+
if (!PRIMITIVE_IDS.has(decl.primitive)) throw new Error(`unknown primitive "${decl.primitive}"`);
|
|
2891
|
+
|
|
2892
|
+
const ctx = { args: args || {}, identity: identity || {} };
|
|
2893
|
+
const params = {};
|
|
2894
|
+
for (const [k, v] of Object.entries(decl.params || {})) {
|
|
2895
|
+
const resolved = resolveManifestValue(v, ctx);
|
|
2896
|
+
if (resolved != null) params[k] = resolved;
|
|
2897
|
+
}
|
|
2898
|
+
if (decl.primitive === 'script.run' && decl.env) {
|
|
2899
|
+
params.env = params.env || {};
|
|
2900
|
+
for (const [k, v] of Object.entries(decl.env)) {
|
|
2901
|
+
const resolved = resolveManifestValue(v, ctx);
|
|
2902
|
+
if (resolved != null) params.env[k] = resolved;
|
|
2903
|
+
}
|
|
2904
|
+
}
|
|
2905
|
+
return { functionId: decl.primitive, args: params };
|
|
2906
|
+
}
|
|
2907
|
+
|
|
2908
|
+
async function handleBridgeRunMessage(source, msg) {
|
|
2909
|
+
const { requestId, functionId, args, appHcu } = msg;
|
|
2710
2910
|
if (typeof requestId !== 'number' || !functionId) return;
|
|
2711
2911
|
|
|
2912
|
+
// Three paths:
|
|
2913
|
+
// 1. Calling a primitive directly → passthrough (power users + manifests)
|
|
2914
|
+
// 2. Calling a manifest-declared name → resolve, then call primitive
|
|
2915
|
+
// 3. Calling a legacy hardcoded name → passthrough (deprecation path)
|
|
2916
|
+
let resolvedFunctionId = String(functionId);
|
|
2917
|
+
let resolvedArgs = args || {};
|
|
2918
|
+
|
|
2919
|
+
if (!PRIMITIVE_IDS.has(resolvedFunctionId)) {
|
|
2920
|
+
const manifest = await loadAppManifest(appHcu);
|
|
2921
|
+
if (manifest && manifest.functions && manifest.functions[resolvedFunctionId]) {
|
|
2922
|
+
try {
|
|
2923
|
+
const identity = await getIdentityCached();
|
|
2924
|
+
const resolved = resolveManifestFunction(manifest, resolvedFunctionId, resolvedArgs, identity);
|
|
2925
|
+
resolvedFunctionId = resolved.functionId;
|
|
2926
|
+
resolvedArgs = resolved.args;
|
|
2927
|
+
} catch (err) {
|
|
2928
|
+
bridgeForward(source, requestId, 'error', { message: 'manifest resolve: ' + err.message });
|
|
2929
|
+
bridgeForward(source, requestId, 'exit', { code: 2 });
|
|
2930
|
+
return;
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2933
|
+
// else: fall through to legacy CLI passthrough (no manifest, name might
|
|
2934
|
+
// still resolve in the CLI's hardcoded FUNCTIONS list).
|
|
2935
|
+
}
|
|
2936
|
+
|
|
2712
2937
|
const qs = new URLSearchParams({
|
|
2713
|
-
functionId:
|
|
2714
|
-
args: JSON.stringify(
|
|
2938
|
+
functionId: resolvedFunctionId,
|
|
2939
|
+
args: JSON.stringify(resolvedArgs),
|
|
2715
2940
|
});
|
|
2716
2941
|
|
|
2717
2942
|
let es;
|
|
@@ -2723,7 +2948,7 @@ function handleBridgeRunMessage(source, msg) {
|
|
|
2723
2948
|
return;
|
|
2724
2949
|
}
|
|
2725
2950
|
|
|
2726
|
-
BRIDGE_RUNS.set(requestId, { es, source, functionId });
|
|
2951
|
+
BRIDGE_RUNS.set(requestId, { es, source, functionId: resolvedFunctionId });
|
|
2727
2952
|
BRIDGE_RUN_COUNT++;
|
|
2728
2953
|
updateBridgeBadge();
|
|
2729
2954
|
|
|
@@ -2794,11 +3019,46 @@ async function handleBridgeNamespaceListMessage(source, msg) {
|
|
|
2794
3019
|
}
|
|
2795
3020
|
}
|
|
2796
3021
|
|
|
3022
|
+
async function handleBridgeIdentityMessage(source, msg) {
|
|
3023
|
+
const { requestId } = msg;
|
|
3024
|
+
if (typeof requestId !== 'number') return;
|
|
3025
|
+
const reply = (extra) => {
|
|
3026
|
+
try {
|
|
3027
|
+
source.postMessage({
|
|
3028
|
+
__bitpub: true,
|
|
3029
|
+
type: 'bitpub.identity.result',
|
|
3030
|
+
requestId,
|
|
3031
|
+
...extra,
|
|
3032
|
+
}, '*');
|
|
3033
|
+
} catch (_) { /* iframe gone */ }
|
|
3034
|
+
};
|
|
3035
|
+
try {
|
|
3036
|
+
const r = await fetch('/bridge/identity');
|
|
3037
|
+
const body = await r.json();
|
|
3038
|
+
if (!r.ok) { reply({ ok: false, error: `http ${r.status}` }); return; }
|
|
3039
|
+
reply({ ok: true, owner: body.owner, domain: body.domain, private_prefix: body.private_prefix });
|
|
3040
|
+
} catch (err) {
|
|
3041
|
+
reply({ ok: false, error: err && err.message || 'fetch failed' });
|
|
3042
|
+
}
|
|
3043
|
+
}
|
|
3044
|
+
|
|
3045
|
+
function handleBridgeNavigateMessage(_source, msg) {
|
|
3046
|
+
// Apps can hand off to another slice in the running user's Browser.
|
|
3047
|
+
// We only allow bitpub:// addresses (no http:, no javascript:), and
|
|
3048
|
+
// we route through submitAddress() which already does all the
|
|
3049
|
+
// existing scope/path parsing.
|
|
3050
|
+
const hcu = String((msg && msg.hcu) || '').trim();
|
|
3051
|
+
if (!/^bitpub:\/\/[^\s]+$/.test(hcu)) return;
|
|
3052
|
+
try { submitAddress(hcu); } catch (_) { /* ignore */ }
|
|
3053
|
+
}
|
|
3054
|
+
|
|
2797
3055
|
window.addEventListener('message', (e) => {
|
|
2798
3056
|
const d = e.data;
|
|
2799
3057
|
if (!d || typeof d !== 'object' || d.__bitpub !== true) return;
|
|
2800
3058
|
if (d.type === 'bitpub.run') handleBridgeRunMessage(e.source, d);
|
|
2801
3059
|
else if (d.type === 'bitpub.namespace.list') handleBridgeNamespaceListMessage(e.source, d);
|
|
3060
|
+
else if (d.type === 'bitpub.identity.me') handleBridgeIdentityMessage(e.source, d);
|
|
3061
|
+
else if (d.type === 'bitpub.navigate' || d.type === 'navigate') handleBridgeNavigateMessage(e.source, d);
|
|
2802
3062
|
});
|
|
2803
3063
|
|
|
2804
3064
|
function renderWriteGuide(sl) {
|