@bitpub/cli 2.1.1 → 2.1.3
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 +229 -229
- package/src/commands/sync.js +18 -13
- package/static/console.html +310 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bitpub/cli",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.3",
|
|
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
|
@@ -21,6 +21,7 @@ const { exec, spawn } = require('child_process');
|
|
|
21
21
|
const { readConfig, BITPUB_DIR } = require('../config');
|
|
22
22
|
const { getSyncedNamespaces, initCache } = require('../db/cache');
|
|
23
23
|
const { isPrivateHcu, decrypt, isEncrypted } = require('../crypto');
|
|
24
|
+
const { runOneShot: runSyncOneShot } = require('./sync');
|
|
24
25
|
|
|
25
26
|
const Database = require('better-sqlite3');
|
|
26
27
|
const DB_PATH = path.join(os.homedir(), '.bitpub', 'cache.db');
|
|
@@ -110,258 +111,126 @@ const ASSET_MIME = {
|
|
|
110
111
|
|
|
111
112
|
/* ── Bridge (apps → local CLI) ──────────────────────────────────────
|
|
112
113
|
*
|
|
113
|
-
* Sandboxed app iframes
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
* this CLI process, which spawns a child and streams stdout/stderr
|
|
117
|
-
* back over Server-Sent Events.
|
|
114
|
+
* Sandboxed app iframes can postMessage to the parent BitPub Browser
|
|
115
|
+
* shell, which forwards approved calls to this CLI process. The CLI
|
|
116
|
+
* spawns a child and streams stdout/stderr back over SSE.
|
|
118
117
|
*
|
|
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.
|
|
118
|
+
* SUBSTRATE PRIMITIVES (this file):
|
|
119
|
+
* - script.run load a slice as a script + run with an interpreter
|
|
120
|
+
* - os.open open a URL in the default browser, or a path locally
|
|
121
|
+
* - identity.me who is this BitPub install (separate /bridge/identity endpoint)
|
|
129
122
|
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
123
|
+
* APP-SPECIFIC FUNCTIONS (live in the namespace as manifest.json slices):
|
|
124
|
+
* Apps declare their own named functions in a manifest sibling slice,
|
|
125
|
+
* e.g. bitpub://group:tollbit.com/Apps/github/manifest.json. The
|
|
126
|
+
* Browser shell (console.html) reads that manifest, resolves each
|
|
127
|
+
* function call to a script.run or os.open invocation, and forwards
|
|
128
|
+
* to the bridge. This means no CLI release is needed to publish a
|
|
129
|
+
* new app — anyone in a group can save HTML + script + manifest and
|
|
130
|
+
* ship it.
|
|
131
|
+
*
|
|
132
|
+
* Trust model: same as installing an npm package. Anything the script
|
|
133
|
+
* can do, the orchestrator can do — we don't sandbox the child process.
|
|
134
|
+
* The browser shell is responsible for the install consent UI; the CLI
|
|
135
|
+
* only enforces input shape on the primitives.
|
|
134
136
|
*/
|
|
135
137
|
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
138
|
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
'
|
|
157
|
-
description: '
|
|
139
|
+
// ── Substrate primitive 1: load + run a script slice ───────
|
|
140
|
+
// Everything else (ingest scripts, generators, summarizers) is
|
|
141
|
+
// implementable on top of this. Args are deliberately flat so a
|
|
142
|
+
// declarative app manifest can construct them without a build step.
|
|
143
|
+
'script.run': {
|
|
144
|
+
description: 'Load a slice as a script and run it with the given interpreter.',
|
|
158
145
|
build(args) {
|
|
159
146
|
const a = args || {};
|
|
160
|
-
const
|
|
161
|
-
const
|
|
162
|
-
const
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
if (
|
|
168
|
-
throw new Error('
|
|
147
|
+
const hcu = String(a.script_hcu || a.script || '').trim();
|
|
148
|
+
const runtime = String(a.runtime || 'python3').trim();
|
|
149
|
+
const env = (a.env && typeof a.env === 'object') ? a.env : {};
|
|
150
|
+
const cliArgs = Array.isArray(a.cli_args) ? a.cli_args : [];
|
|
151
|
+
|
|
152
|
+
// HCU must be a real bitpub:// address and free of shell metachars.
|
|
153
|
+
// (We re-embed it into the bash -lc string below, so be paranoid.)
|
|
154
|
+
if (!/^bitpub:\/\/(private|group|public):[A-Za-z0-9._-]+\/[A-Za-z0-9._\/\-]+$/.test(hcu)) {
|
|
155
|
+
throw new Error('invalid script_hcu');
|
|
169
156
|
}
|
|
157
|
+
if (/[\s"'`$;&|<>\\]/.test(hcu)) throw new Error('script_hcu contains unsafe characters');
|
|
170
158
|
|
|
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
|
-
},
|
|
159
|
+
const ALLOWED_RUNTIMES = ['python3', 'python', 'node', 'bash', 'sh'];
|
|
160
|
+
if (!ALLOWED_RUNTIMES.includes(runtime)) {
|
|
161
|
+
throw new Error(`runtime "${runtime}" not allowed (use one of ${ALLOWED_RUNTIMES.join(', ')})`);
|
|
162
|
+
}
|
|
195
163
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
},
|
|
164
|
+
// Sanitize env: only POSIX-ish var names, stringify values.
|
|
165
|
+
const cleanEnv = {};
|
|
166
|
+
for (const [k, v] of Object.entries(env)) {
|
|
167
|
+
if (!/^[A-Z_][A-Z0-9_]{0,63}$/i.test(k)) continue;
|
|
168
|
+
if (v == null) continue;
|
|
169
|
+
cleanEnv[k] = (typeof v === 'string') ? v : JSON.stringify(v);
|
|
170
|
+
}
|
|
204
171
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
172
|
+
// Sanitize CLI args: stringify, reject anything with shell metachars.
|
|
173
|
+
const cleanCliArgs = [];
|
|
174
|
+
for (const x of cliArgs) {
|
|
175
|
+
const s = String(x);
|
|
176
|
+
if (s.length > 256) throw new Error('cli arg too long');
|
|
177
|
+
if (/[`$;&|<>\\\n]/.test(s)) throw new Error('cli arg contains unsafe characters');
|
|
178
|
+
cleanCliArgs.push(s);
|
|
179
|
+
}
|
|
213
180
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
build() {
|
|
228
|
-
return {
|
|
229
|
-
cmd: 'bash',
|
|
230
|
-
args: ['-lc',
|
|
231
|
-
'set -e; ' +
|
|
232
|
-
'bitpub load bitpub://group:tollbit.com/Apps/github/orchestrator > "$T" && ' +
|
|
233
|
-
'python3 "$T"',
|
|
234
|
-
],
|
|
235
|
-
env: {
|
|
236
|
-
T: path.join(os.tmpdir(), '.bitpub-fn-github-authcheck.py'),
|
|
237
|
-
BITPUB_GH_ACTION: 'auth_check',
|
|
238
|
-
BITPUB_OWNER: (readConfig() || {}).owner || '',
|
|
239
|
-
},
|
|
240
|
-
};
|
|
181
|
+
// Unique temp file per invocation so two concurrent runs of the
|
|
182
|
+
// same script can't trample each other.
|
|
183
|
+
const tag = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
184
|
+
const T = path.join(os.tmpdir(), `.bitpub-script-${tag}`);
|
|
185
|
+
cleanEnv.T = T;
|
|
186
|
+
|
|
187
|
+
// Re-shell-quote CLI args by passing them as positional args to bash
|
|
188
|
+
// via the "_" sentinel, then "$@" preserves quoting.
|
|
189
|
+
const cliPart = cleanCliArgs.length
|
|
190
|
+
? ' ' + cleanCliArgs.map(a => `"${a.replace(/"/g, '\\"')}"`).join(' ')
|
|
191
|
+
: '';
|
|
192
|
+
const script = `set -e; bitpub load "${hcu}" > "$T" && ${runtime} "$T"${cliPart}`;
|
|
193
|
+
return { cmd: 'bash', args: ['-lc', script], env: cleanEnv };
|
|
241
194
|
},
|
|
242
195
|
},
|
|
243
196
|
|
|
244
|
-
|
|
245
|
-
|
|
197
|
+
// ── Substrate primitive 2: open a URL or a local path ──────
|
|
198
|
+
// Wraps macOS `open` (the only OS-level capability apps reach for
|
|
199
|
+
// often enough to belong in the substrate). Manifests can declare
|
|
200
|
+
// url_pattern allowlists for extra hygiene — enforced at resolver
|
|
201
|
+
// time in the shell, not here.
|
|
202
|
+
'os.open': {
|
|
203
|
+
description: 'Open a URL in the default browser, or a path in Finder / a specific app.',
|
|
246
204
|
build(args) {
|
|
247
205
|
const a = args || {};
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
'python3 "$T"',
|
|
257
|
-
],
|
|
258
|
-
env: {
|
|
259
|
-
T: path.join(os.tmpdir(), '.bitpub-fn-github-ingest.py'),
|
|
260
|
-
BITPUB_GH_RECIPES: recipes.join(','),
|
|
261
|
-
BITPUB_OWNER: (readConfig() || {}).owner || '',
|
|
262
|
-
},
|
|
263
|
-
};
|
|
264
|
-
},
|
|
265
|
-
},
|
|
266
|
-
|
|
267
|
-
'github.open': {
|
|
268
|
-
description: 'Open a GitHub URL in the default browser.',
|
|
269
|
-
build(args) {
|
|
270
|
-
const url = String((args && args.url) || '');
|
|
271
|
-
if (!/^https:\/\/github\.com\//.test(url) || url.includes('"')) {
|
|
272
|
-
throw new Error('invalid github url');
|
|
206
|
+
const url = a.url ? String(a.url) : null;
|
|
207
|
+
const filePath = a.path ? String(a.path) : null;
|
|
208
|
+
const reveal = !!a.reveal;
|
|
209
|
+
const inApp = a.in ? String(a.in) : null;
|
|
210
|
+
|
|
211
|
+
if (url) {
|
|
212
|
+
if (!/^https?:\/\//.test(url) || /["`$;&|<>\\]/.test(url)) throw new Error('invalid url');
|
|
213
|
+
return { cmd: 'bash', args: ['-lc', `open "${url}"`] };
|
|
273
214
|
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
if (!/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(repo)) {
|
|
284
|
-
throw new Error('invalid repo "owner/name"');
|
|
215
|
+
if (filePath) {
|
|
216
|
+
if (!filePath.startsWith('/') || /["`$;&|<>\\]/.test(filePath)) throw new Error('invalid path');
|
|
217
|
+
let flag = '';
|
|
218
|
+
if (reveal) flag = '-R ';
|
|
219
|
+
else if (inApp) {
|
|
220
|
+
if (!/^[A-Za-z0-9. _-]{1,64}$/.test(inApp)) throw new Error('invalid app name');
|
|
221
|
+
flag = `-a "${inApp}" `;
|
|
222
|
+
}
|
|
223
|
+
return { cmd: 'bash', args: ['-lc', `open ${flag}"${filePath}"`] };
|
|
285
224
|
}
|
|
286
|
-
|
|
287
|
-
where === 'iterm' ? `open -a iTerm` :
|
|
288
|
-
'open';
|
|
289
|
-
// Search common project roots for a directory whose `git remote -v`
|
|
290
|
-
// points at this repo. Local-first move: we surface the user's
|
|
291
|
-
// clone if they have one, instead of dumping them at github.com.
|
|
292
|
-
const script = `
|
|
293
|
-
set -e
|
|
294
|
-
REPO='${repo}'
|
|
295
|
-
ROOTS=(~/projects ~/code ~/dev ~/src ~/Documents/projects ~/work)
|
|
296
|
-
for root in "\${ROOTS[@]}"; do
|
|
297
|
-
[ -d "$root" ] || continue
|
|
298
|
-
while IFS= read -r dir; do
|
|
299
|
-
if (cd "$dir" && git remote -v 2>/dev/null) | grep -qiE "[:/]\${REPO}(\\.git)?( |\\\$)"; then
|
|
300
|
-
echo "found clone: $dir"
|
|
301
|
-
${opener} "$dir"
|
|
302
|
-
exit 0
|
|
303
|
-
fi
|
|
304
|
-
done < <(find "$root" -mindepth 1 -maxdepth 3 -type d -name .git -prune -exec dirname {} \\; 2>/dev/null)
|
|
305
|
-
done
|
|
306
|
-
echo "no local clone of $REPO found in common roots — opening on github.com instead"
|
|
307
|
-
open "https://github.com/$REPO"
|
|
308
|
-
`;
|
|
309
|
-
return { cmd: 'bash', args: ['-lc', script] };
|
|
225
|
+
throw new Error('os.open needs either url or path');
|
|
310
226
|
},
|
|
311
227
|
},
|
|
312
228
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
// Any private namespace, as long as it's pointed at GitHub/PRs/.
|
|
318
|
-
// We constrain the shape so an app can't trick the bridge into
|
|
319
|
-
// pulling arbitrary slices through claude.
|
|
320
|
-
if (!/^bitpub:\/\/private:[a-z0-9_-]+\/GitHub\/PRs\//i.test(hcu) || hcu.includes('"')) {
|
|
321
|
-
throw new Error('invalid PR hcu');
|
|
322
|
-
}
|
|
323
|
-
// Deterministic read (bitpub load) -> single LLM call (claude -p) ->
|
|
324
|
-
// structured text back to the UI. No MCP, no network round-trip for
|
|
325
|
-
// the data — only for the synthesis.
|
|
326
|
-
const script =
|
|
327
|
-
'set -e; ' +
|
|
328
|
-
`bitpub load "${hcu}" > "$T" && ` +
|
|
329
|
-
'claude -p "$(printf \'%s\\n\\nSummarize this GitHub PR in 3 short bullets: what it does, who it affects, anything risky. Be concrete.\' "$(cat "$T")")" ' +
|
|
330
|
-
'--output-format text --permission-mode bypassPermissions';
|
|
331
|
-
return {
|
|
332
|
-
cmd: 'bash',
|
|
333
|
-
args: ['-lc', script],
|
|
334
|
-
env: { T: path.join(os.tmpdir(), `.bitpub-fn-github-summarize.md`) },
|
|
335
|
-
};
|
|
336
|
-
},
|
|
337
|
-
},
|
|
229
|
+
// ───────────────────────────────────────────────────────────
|
|
230
|
+
// (Apps now declare their functions in manifest.json slices —
|
|
231
|
+
// see backend/static/console.html#resolveManifestFunction.)
|
|
232
|
+
// ───────────────────────────────────────────────────────────
|
|
338
233
|
|
|
339
|
-
'github.action.approve': {
|
|
340
|
-
description: 'Approve a pull request via the GitHub API (uses inherited credentials).',
|
|
341
|
-
build(args) {
|
|
342
|
-
const repo = String((args && args.repo) || '');
|
|
343
|
-
const number = parseInt((args && args.number), 10);
|
|
344
|
-
const body = String((args && args.body) || '');
|
|
345
|
-
if (!/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(repo)) throw new Error('invalid repo');
|
|
346
|
-
if (!Number.isFinite(number) || number <= 0) throw new Error('invalid PR number');
|
|
347
|
-
return {
|
|
348
|
-
cmd: 'bash',
|
|
349
|
-
args: ['-lc',
|
|
350
|
-
'set -e; ' +
|
|
351
|
-
'bitpub load bitpub://group:tollbit.com/Apps/github/orchestrator > "$T" && ' +
|
|
352
|
-
'python3 "$T"',
|
|
353
|
-
],
|
|
354
|
-
env: {
|
|
355
|
-
T: path.join(os.tmpdir(), '.bitpub-fn-github-action.py'),
|
|
356
|
-
BITPUB_GH_ACTION: 'approve',
|
|
357
|
-
BITPUB_GH_REPO: repo,
|
|
358
|
-
BITPUB_GH_PR: String(number),
|
|
359
|
-
BITPUB_GH_BODY: body,
|
|
360
|
-
BITPUB_OWNER: (readConfig() || {}).owner || '',
|
|
361
|
-
},
|
|
362
|
-
};
|
|
363
|
-
},
|
|
364
|
-
},
|
|
365
234
|
};
|
|
366
235
|
|
|
367
236
|
function clampInt(value, lo, hi, dflt) {
|
|
@@ -498,6 +367,128 @@ function handleBridgeRun(req, res, url) {
|
|
|
498
367
|
});
|
|
499
368
|
}
|
|
500
369
|
|
|
370
|
+
/**
|
|
371
|
+
* Resolve the set of namespaces to sync for "sync everything visible
|
|
372
|
+
* to the current user." This is the same shape an attentive user would
|
|
373
|
+
* type by hand: their private root + every joined group.
|
|
374
|
+
*
|
|
375
|
+
* Returns an array of `{ pattern, label, config }` triples. The per-
|
|
376
|
+
* scope `config` is the user's primary config with `api_key` swapped
|
|
377
|
+
* out when a group entry pins its own key (multi-backend memberships).
|
|
378
|
+
*/
|
|
379
|
+
function resolveSyncTargets(baseConfig) {
|
|
380
|
+
const targets = [];
|
|
381
|
+
if (baseConfig && baseConfig.owner) {
|
|
382
|
+
targets.push({
|
|
383
|
+
pattern: `bitpub://private:${baseConfig.owner}/**`,
|
|
384
|
+
label: `private:${baseConfig.owner}`,
|
|
385
|
+
config: baseConfig,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
const groups = Array.isArray(baseConfig && baseConfig.groups) ? baseConfig.groups : [];
|
|
389
|
+
for (const g of groups) {
|
|
390
|
+
if (!g || !g.slug) continue;
|
|
391
|
+
targets.push({
|
|
392
|
+
pattern: `bitpub://group:${g.slug}/**`,
|
|
393
|
+
label: `group:${g.slug}`,
|
|
394
|
+
// Per-group key/URL is only relevant on multi-backend setups; for
|
|
395
|
+
// the common single-backend case these fields equal the primary
|
|
396
|
+
// config and the fallback is a no-op.
|
|
397
|
+
config: {
|
|
398
|
+
...baseConfig,
|
|
399
|
+
api_key: g.key || baseConfig.api_key,
|
|
400
|
+
api_url: g.api_url || baseConfig.api_url,
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
return targets;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Stream `bitpub sync` across every scope visible to the user as SSE.
|
|
409
|
+
*
|
|
410
|
+
* Wire format:
|
|
411
|
+
* event: started data: { targets: [{label, pattern}, ...] }
|
|
412
|
+
* event: scope data: { label, pattern, status: 'syncing' }
|
|
413
|
+
* event: scope data: { label, pattern, status: 'done', count, hitLimit }
|
|
414
|
+
* event: scope data: { label, pattern, status: 'error', message }
|
|
415
|
+
* event: done data: { total, scopes } ← terminal; connection closes
|
|
416
|
+
*
|
|
417
|
+
* This is browser-chrome plumbing, not an app function — it calls
|
|
418
|
+
* sync.js#runOneShot directly inside this process rather than spawning
|
|
419
|
+
* `bitpub sync` as a subprocess via the script.run primitive. Reasons:
|
|
420
|
+
* • No shell-out cost, no quoting hazard, no env propagation issues.
|
|
421
|
+
* • Per-scope progress is structured (event stream), not parsed stdout.
|
|
422
|
+
* • Manifests are for user-published apps; the Sync button is part of
|
|
423
|
+
* the browser shell and shouldn't go through that machinery.
|
|
424
|
+
*/
|
|
425
|
+
function handleBridgeSync(req, res) {
|
|
426
|
+
res.statusCode = 200;
|
|
427
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
428
|
+
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
|
429
|
+
res.setHeader('Connection', 'keep-alive');
|
|
430
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
431
|
+
|
|
432
|
+
function send(event, payload) {
|
|
433
|
+
if (res.writableEnded) return;
|
|
434
|
+
res.write(`event: ${event}\n`);
|
|
435
|
+
res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const cfg = readConfig();
|
|
439
|
+
if (!cfg || !cfg.owner) {
|
|
440
|
+
send('error', { message: 'No bitpub identity configured. Run `bitpub setup` first.' });
|
|
441
|
+
send('done', { total: 0, scopes: 0 });
|
|
442
|
+
res.end();
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const targets = resolveSyncTargets(cfg);
|
|
447
|
+
send('started', { targets: targets.map(t => ({ label: t.label, pattern: t.pattern })) });
|
|
448
|
+
|
|
449
|
+
let cancelled = false;
|
|
450
|
+
req.on('close', () => { cancelled = true; });
|
|
451
|
+
|
|
452
|
+
// Sync scopes sequentially so the progress events tell a single story
|
|
453
|
+
// and we don't slam the same backend with N concurrent pulls.
|
|
454
|
+
(async () => {
|
|
455
|
+
let total = 0;
|
|
456
|
+
let okScopes = 0;
|
|
457
|
+
for (const t of targets) {
|
|
458
|
+
if (cancelled) return;
|
|
459
|
+
send('scope', { label: t.label, pattern: t.pattern, status: 'syncing' });
|
|
460
|
+
try {
|
|
461
|
+
const r = await runSyncOneShot(
|
|
462
|
+
{ pattern: t.pattern, label: t.label, limit: 500, includeDeleted: false, quiet: true },
|
|
463
|
+
t.config
|
|
464
|
+
);
|
|
465
|
+
total += r.count;
|
|
466
|
+
okScopes += 1;
|
|
467
|
+
send('scope', {
|
|
468
|
+
label: t.label,
|
|
469
|
+
pattern: t.pattern,
|
|
470
|
+
status: 'done',
|
|
471
|
+
count: r.count,
|
|
472
|
+
hitLimit: !!r.hitLimit,
|
|
473
|
+
});
|
|
474
|
+
} catch (err) {
|
|
475
|
+
send('scope', {
|
|
476
|
+
label: t.label,
|
|
477
|
+
pattern: t.pattern,
|
|
478
|
+
status: 'error',
|
|
479
|
+
message: err && err.message ? err.message : String(err),
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
send('done', { total, scopes: okScopes });
|
|
484
|
+
res.end();
|
|
485
|
+
})().catch((err) => {
|
|
486
|
+
send('error', { message: err && err.message ? err.message : String(err) });
|
|
487
|
+
send('done', { total: 0, scopes: 0 });
|
|
488
|
+
res.end();
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
501
492
|
function openInBrowser(url) {
|
|
502
493
|
const cmd = process.platform === 'darwin' ? 'open' :
|
|
503
494
|
process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
@@ -601,6 +592,15 @@ function startBrowserServer(opts = {}) {
|
|
|
601
592
|
return;
|
|
602
593
|
}
|
|
603
594
|
|
|
595
|
+
// Bridge: sync every namespace visible to this user (private +
|
|
596
|
+
// each joined group) and stream per-scope progress as SSE. This
|
|
597
|
+
// backs the sidebar "Sync" button — it's browser chrome, not an
|
|
598
|
+
// app function, and so doesn't go through the manifest system.
|
|
599
|
+
if (url.pathname === '/bridge/sync') {
|
|
600
|
+
handleBridgeSync(req, res);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
604
|
// Bridge: deterministic namespace read. Apps call this to fetch
|
|
605
605
|
// any slices that match an HCU pattern (exact or prefix-with-/*).
|
|
606
606
|
// No LLM, no MCP, no auth in the loop — this is the read path
|
package/src/commands/sync.js
CHANGED
|
@@ -60,7 +60,7 @@ function resolveSyncPattern(input, config) {
|
|
|
60
60
|
return { pattern: active.namespace + resolved, label: input };
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
async function runOneShot({ pattern, label, limit, includeDeleted }, config) {
|
|
63
|
+
async function runOneShot({ pattern, label, limit, includeDeleted, quiet }, config) {
|
|
64
64
|
const api = createApiClient(config);
|
|
65
65
|
const limitNum = parseInt(limit, 10) || 500;
|
|
66
66
|
|
|
@@ -70,19 +70,24 @@ async function runOneShot({ pattern, label, limit, includeDeleted }, config) {
|
|
|
70
70
|
for (const slice of slices) upsertSlice(slice);
|
|
71
71
|
recordNamespaceSync(pattern, slices.length);
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
73
|
+
const hitLimit = slices.length >= Math.min(limitNum, 500);
|
|
74
|
+
|
|
75
|
+
if (!quiet) {
|
|
76
|
+
console.log(`Synced ${slices.length} slice(s) from ${label}`);
|
|
77
|
+
// Surface page-cap warning so the user knows to narrow their pattern.
|
|
78
|
+
if (hitLimit) {
|
|
79
|
+
console.log(
|
|
80
|
+
`\n Hit the page limit (${slices.length} rows). Server caps single sync at 500.` +
|
|
81
|
+
` If you expect more, sync sub-namespaces individually:`
|
|
82
|
+
);
|
|
83
|
+
const base = pattern.replace(/\/\*+$/, '');
|
|
84
|
+
console.log(` bitpub sync '${base}/Projects/**'`);
|
|
85
|
+
console.log(` bitpub sync '${base}/Memory/**'`);
|
|
86
|
+
console.log(` bitpub sync '${base}/Sessions/**'`);
|
|
87
|
+
}
|
|
85
88
|
}
|
|
89
|
+
|
|
90
|
+
return { count: slices.length, hitLimit, pattern, label };
|
|
86
91
|
}
|
|
87
92
|
|
|
88
93
|
function runWatch({ pattern, label }, config) {
|
package/static/console.html
CHANGED
|
@@ -217,9 +217,27 @@ svg { flex-shrink: 0; }
|
|
|
217
217
|
#main:hover::-webkit-scrollbar-thumb { background: var(--border); background-clip: content-box; }
|
|
218
218
|
|
|
219
219
|
/* ── Sidebar / Tree ─────────────────────────────────── */
|
|
220
|
-
.sidebar-header { padding: 12px 14px 8px; font-size: 10.5px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--text-subtle); display: flex; align-items: center; justify-content: space-between; }
|
|
220
|
+
.sidebar-header { padding: 12px 14px 8px; font-size: 10.5px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--text-subtle); display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
221
221
|
.sidebar-header .clear-btn { font-size: 10.5px; color: var(--text-subtle); cursor: pointer; font-weight: 500; letter-spacing: 0; text-transform: none; }
|
|
222
222
|
.sidebar-header .clear-btn:hover { color: var(--accent); }
|
|
223
|
+
|
|
224
|
+
/* Sync button — icon-only, ghost style, lives in the sidebar header.
|
|
225
|
+
Spins while a /bridge/sync request is in flight; pulses green for a
|
|
226
|
+
moment after a successful sync so the user gets feedback even if no
|
|
227
|
+
new slices landed. */
|
|
228
|
+
.sync-btn { display: inline-flex; align-items: center; gap: 4px; padding: 3px 6px; border: 0; background: transparent; color: var(--text-subtle); cursor: pointer; border-radius: var(--radius-sm); font: inherit; font-size: 10.5px; font-weight: 500; text-transform: none; letter-spacing: 0; }
|
|
229
|
+
.sync-btn:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); }
|
|
230
|
+
.sync-btn:disabled { opacity: .6; cursor: default; }
|
|
231
|
+
.sync-btn svg { width: 12px; height: 12px; transition: transform .2s ease-out; }
|
|
232
|
+
.sync-btn.is-syncing svg { animation: bp-spin 1s linear infinite; }
|
|
233
|
+
.sync-btn.is-done { color: #10b981; }
|
|
234
|
+
@keyframes bp-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
|
235
|
+
.sync-status { padding: 4px 14px 8px; font-size: 11px; color: var(--text-subtle); line-height: 1.4; max-height: 120px; overflow-y: auto; }
|
|
236
|
+
.sync-status.hidden { display: none; }
|
|
237
|
+
.sync-status .row { display: flex; justify-content: space-between; gap: 8px; padding: 2px 0; }
|
|
238
|
+
.sync-status .row .label { color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
239
|
+
.sync-status .row .count { color: var(--text-subtle); flex-shrink: 0; font-variant-numeric: tabular-nums; }
|
|
240
|
+
.sync-status .row.error .count { color: var(--scope-group); }
|
|
223
241
|
.tree-wrap { flex: 1; overflow-y: auto; padding: 0 6px 12px; }
|
|
224
242
|
.tree-wrap::-webkit-scrollbar { width: 8px; }
|
|
225
243
|
.tree-wrap::-webkit-scrollbar-thumb { background: transparent; border-radius: 4px; }
|
|
@@ -972,8 +990,15 @@ svg { flex-shrink: 0; }
|
|
|
972
990
|
<aside id="sidebar">
|
|
973
991
|
<div class="sidebar-header">
|
|
974
992
|
<span>Namespaces</span>
|
|
975
|
-
<span
|
|
993
|
+
<span style="display:inline-flex; align-items:center; gap:6px;">
|
|
994
|
+
<span id="clear-filter-btn" class="clear-btn hidden" onclick="clearFilter()">Clear</span>
|
|
995
|
+
<button id="sync-btn" class="sync-btn" type="button" onclick="runFullSync()" title="Sync all namespaces from the cloud into your local cache">
|
|
996
|
+
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 3a5 5 0 0 1 4.546 2.914.5.5 0 0 0 .908-.418A6 6 0 1 0 2 8a.5.5 0 1 0 1 0 5 5 0 0 1 5-5Z"/><path d="M8 13a5 5 0 0 1-4.546-2.914.5.5 0 0 0-.908.418A6 6 0 1 0 14 8a.5.5 0 1 0-1 0 5 5 0 0 1-5 5Z"/><path d="M11.5 1.5a.5.5 0 0 1 .5.5v3a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1 0-1H11V2a.5.5 0 0 1 .5-.5ZM4.5 10.5a.5.5 0 0 1 .5.5v2.5h2.5a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5v-3a.5.5 0 0 1 .5-.5Z"/></svg>
|
|
997
|
+
<span class="sync-btn-label">Sync</span>
|
|
998
|
+
</button>
|
|
999
|
+
</span>
|
|
976
1000
|
</div>
|
|
1001
|
+
<div id="sync-status" class="sync-status hidden"></div>
|
|
977
1002
|
<div id="tree" class="tree-wrap"></div>
|
|
978
1003
|
</aside>
|
|
979
1004
|
<main id="main"></main>
|
|
@@ -1456,6 +1481,135 @@ function clearFilter() {
|
|
|
1456
1481
|
renderAll();
|
|
1457
1482
|
}
|
|
1458
1483
|
|
|
1484
|
+
/* ── Sidebar Sync button ────────────────────────────────
|
|
1485
|
+
*
|
|
1486
|
+
* Streams /bridge/sync (SSE) and renders per-scope progress under the
|
|
1487
|
+
* header. On `done`, force a fresh /api/data poll so the tree picks up
|
|
1488
|
+
* any new arrivals immediately (without waiting for the 30s tick).
|
|
1489
|
+
*
|
|
1490
|
+
* The button is browser chrome — not an app function — so it talks
|
|
1491
|
+
* directly to /bridge/sync rather than going through window.bitpub.run
|
|
1492
|
+
* and the manifest dispatcher.
|
|
1493
|
+
*
|
|
1494
|
+
* Reentrancy: a second click while a sync is in flight is a no-op
|
|
1495
|
+
* (we disable the button), so we don't need to track an EventSource
|
|
1496
|
+
* handle for cancellation.
|
|
1497
|
+
*/
|
|
1498
|
+
async function runFullSync() {
|
|
1499
|
+
const btn = $('sync-btn');
|
|
1500
|
+
const status = $('sync-status');
|
|
1501
|
+
if (!btn || btn.disabled) return;
|
|
1502
|
+
|
|
1503
|
+
btn.disabled = true;
|
|
1504
|
+
btn.classList.add('is-syncing');
|
|
1505
|
+
btn.classList.remove('is-done');
|
|
1506
|
+
status.classList.remove('hidden');
|
|
1507
|
+
status.innerHTML = '';
|
|
1508
|
+
|
|
1509
|
+
// Track rows so 'syncing' → 'done' overwrites in place rather than
|
|
1510
|
+
// appending a second line per scope.
|
|
1511
|
+
const rows = new Map();
|
|
1512
|
+
|
|
1513
|
+
function setRow(label, pattern, statusText, klass) {
|
|
1514
|
+
let row = rows.get(label);
|
|
1515
|
+
if (!row) {
|
|
1516
|
+
row = document.createElement('div');
|
|
1517
|
+
row.className = 'row';
|
|
1518
|
+
const a = document.createElement('span'); a.className = 'label';
|
|
1519
|
+
const b = document.createElement('span'); b.className = 'count';
|
|
1520
|
+
row.append(a, b);
|
|
1521
|
+
status.appendChild(row);
|
|
1522
|
+
rows.set(label, row);
|
|
1523
|
+
}
|
|
1524
|
+
row.className = 'row' + (klass ? ' ' + klass : '');
|
|
1525
|
+
row.title = pattern;
|
|
1526
|
+
row.children[0].textContent = label;
|
|
1527
|
+
row.children[1].textContent = statusText;
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
const base = location.pathname.endsWith('/') ? location.pathname : location.pathname + '/';
|
|
1531
|
+
const es = new EventSource(base + 'bridge/sync');
|
|
1532
|
+
|
|
1533
|
+
function cleanup() {
|
|
1534
|
+
es.close();
|
|
1535
|
+
btn.disabled = false;
|
|
1536
|
+
btn.classList.remove('is-syncing');
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
es.addEventListener('started', (e) => {
|
|
1540
|
+
let payload = {};
|
|
1541
|
+
try { payload = JSON.parse(e.data); } catch {}
|
|
1542
|
+
for (const t of (payload.targets || [])) setRow(t.label, t.pattern, 'queued', '');
|
|
1543
|
+
});
|
|
1544
|
+
|
|
1545
|
+
es.addEventListener('scope', (e) => {
|
|
1546
|
+
let payload = {};
|
|
1547
|
+
try { payload = JSON.parse(e.data); } catch {}
|
|
1548
|
+
if (payload.status === 'syncing') {
|
|
1549
|
+
setRow(payload.label, payload.pattern, 'syncing…', '');
|
|
1550
|
+
} else if (payload.status === 'done') {
|
|
1551
|
+
const cnt = (payload.count ?? 0) + ' slices';
|
|
1552
|
+
const suffix = payload.hitLimit ? ' (capped)' : '';
|
|
1553
|
+
setRow(payload.label, payload.pattern, cnt + suffix, '');
|
|
1554
|
+
} else if (payload.status === 'error') {
|
|
1555
|
+
setRow(payload.label, payload.pattern, 'error', 'error');
|
|
1556
|
+
console.error('[sync]', payload.label, payload.message);
|
|
1557
|
+
}
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
es.addEventListener('error', (e) => {
|
|
1561
|
+
// Server-sent `event: error` (CLI-reported); EventSource also fires
|
|
1562
|
+
// 'error' on connection drops with no payload. Treat both the same.
|
|
1563
|
+
let payload = {};
|
|
1564
|
+
if (e && e.data) { try { payload = JSON.parse(e.data); } catch {} }
|
|
1565
|
+
if (payload.message) {
|
|
1566
|
+
const row = document.createElement('div');
|
|
1567
|
+
row.className = 'row error';
|
|
1568
|
+
row.innerHTML = '<span class="label">sync failed</span><span class="count"></span>';
|
|
1569
|
+
row.children[1].textContent = payload.message.slice(0, 60);
|
|
1570
|
+
status.appendChild(row);
|
|
1571
|
+
}
|
|
1572
|
+
// Don't cleanup here — wait for `done`, or for the connection to
|
|
1573
|
+
// close on its own. Real errors will be followed by `done`.
|
|
1574
|
+
});
|
|
1575
|
+
|
|
1576
|
+
es.addEventListener('done', async (e) => {
|
|
1577
|
+
let payload = {};
|
|
1578
|
+
try { payload = JSON.parse(e.data); } catch {}
|
|
1579
|
+
cleanup();
|
|
1580
|
+
btn.classList.add('is-done');
|
|
1581
|
+
setTimeout(() => btn.classList.remove('is-done'), 2000);
|
|
1582
|
+
|
|
1583
|
+
// Force-refresh the data so any newly-synced slices show up in
|
|
1584
|
+
// the tree immediately instead of on the next 30s poll tick.
|
|
1585
|
+
const data = await fetchData({ silent: true });
|
|
1586
|
+
if (data) {
|
|
1587
|
+
const arrivals = new Set();
|
|
1588
|
+
const oldKey = new Set(S.slices.map(s => s.hcu + '|' + (s.last_synced || '')));
|
|
1589
|
+
const nextSlices = data.slices.map(parseSlice);
|
|
1590
|
+
for (const s of nextSlices) {
|
|
1591
|
+
const k = s.hcu + '|' + (s.last_synced || '');
|
|
1592
|
+
if (!oldKey.has(k)) arrivals.add(s.hcu);
|
|
1593
|
+
}
|
|
1594
|
+
S.slices = nextSlices;
|
|
1595
|
+
S.tree = buildTree(S.slices);
|
|
1596
|
+
S.stats = computeStats(S.slices);
|
|
1597
|
+
S.lastUpdated = maxTs(S.slices);
|
|
1598
|
+
S.newArrivals = arrivals;
|
|
1599
|
+
renderAll();
|
|
1600
|
+
setTimeout(() => { S.newArrivals = new Set(); }, 2500);
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
// Auto-hide the status panel after a beat if everything succeeded.
|
|
1604
|
+
const anyErrors = Array.from(rows.values()).some(r => r.classList.contains('error'));
|
|
1605
|
+
if (!anyErrors) {
|
|
1606
|
+
setTimeout(() => {
|
|
1607
|
+
if (!btn.classList.contains('is-syncing')) status.classList.add('hidden');
|
|
1608
|
+
}, 4000);
|
|
1609
|
+
}
|
|
1610
|
+
});
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1459
1613
|
/* ══════════════════════════════════════════════════════
|
|
1460
1614
|
Top-level render
|
|
1461
1615
|
══════════════════════════════════════════════════════ */
|
|
@@ -2490,7 +2644,7 @@ function renderSlice(sl) {
|
|
|
2490
2644
|
if (S.raw) {
|
|
2491
2645
|
html += `<pre class="content-raw">${esc(content)}</pre>`;
|
|
2492
2646
|
} else if (canRenderAsApp) {
|
|
2493
|
-
html += renderHtmlAppFrame(content);
|
|
2647
|
+
html += renderHtmlAppFrame(content, sl.hcu);
|
|
2494
2648
|
} else if (kind === 'html') {
|
|
2495
2649
|
html += `<div class="app-public-note">${ICON.info}
|
|
2496
2650
|
<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>
|
|
@@ -2555,14 +2709,21 @@ function detectContentKind(sl, content) {
|
|
|
2555
2709
|
* - The iframe never reaches the network itself — only the parent
|
|
2556
2710
|
* does. CSP can stay locked down (default-src 'none').
|
|
2557
2711
|
*/
|
|
2558
|
-
function renderHtmlAppFrame(html) {
|
|
2712
|
+
function renderHtmlAppFrame(html, appHcu) {
|
|
2559
2713
|
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'">`;
|
|
2560
2714
|
const baseTag = `<base target="_blank">`;
|
|
2715
|
+
// Inject the app's own HCU into the iframe so the bridge shim can tag
|
|
2716
|
+
// every postMessage. The parent uses this to look up the calling app's
|
|
2717
|
+
// manifest and resolve short-named functions to script.run / os.open
|
|
2718
|
+
// invocations. (Apps could lie about this in v1 — we trust app code
|
|
2719
|
+
// the same way you trust an npm package.)
|
|
2720
|
+
const safeHcu = String(appHcu || '').replace(/['"\\]/g, '');
|
|
2721
|
+
const hcuConst = `<\x73cript>window.__BITPUB_APP_HCU__='${safeHcu}';<\/script>`;
|
|
2561
2722
|
// The shim tag uses \x73 + escaped slash so the closing pattern can't
|
|
2562
2723
|
// be detected by the outer HTML parser. The escapes are transparent to
|
|
2563
2724
|
// the JS parser but defeat the HTML scanner's greedy script-end match.
|
|
2564
2725
|
const shim = `<\x73cript>${BRIDGE_SHIM_SOURCE}<\/script>`;
|
|
2565
|
-
const headInject = `${csp}${baseTag}${shim}`;
|
|
2726
|
+
const headInject = `${csp}${baseTag}${hcuConst}${shim}`;
|
|
2566
2727
|
|
|
2567
2728
|
let doc;
|
|
2568
2729
|
if (/<head[\s>]/i.test(html)) {
|
|
@@ -2648,6 +2809,7 @@ const BRIDGE_SHIM_SOURCE = String.raw`
|
|
|
2648
2809
|
type: 'bitpub.run',
|
|
2649
2810
|
requestId: requestId,
|
|
2650
2811
|
functionId: String(functionId),
|
|
2812
|
+
appHcu: window.__BITPUB_APP_HCU__ || '',
|
|
2651
2813
|
args: args || {}
|
|
2652
2814
|
}, '*');
|
|
2653
2815
|
return requestId;
|
|
@@ -2784,13 +2946,151 @@ function bridgeForward(source, requestId, event, payload) {
|
|
|
2784
2946
|
} catch (_) { /* iframe gone */ }
|
|
2785
2947
|
}
|
|
2786
2948
|
|
|
2787
|
-
function
|
|
2788
|
-
|
|
2949
|
+
/* ── Manifest-driven app function dispatch ─────────────────────────
|
|
2950
|
+
*
|
|
2951
|
+
* Apps declare their callable functions in a sibling slice named
|
|
2952
|
+
* `manifest.json`. The parent browser shell (this file) reads that
|
|
2953
|
+
* manifest the first time the app calls bitpub.run(), then resolves
|
|
2954
|
+
* every subsequent call to a CLI substrate primitive — without the
|
|
2955
|
+
* CLI needing to know anything about the app.
|
|
2956
|
+
*
|
|
2957
|
+
* Manifest shape:
|
|
2958
|
+
* {
|
|
2959
|
+
* "id": "tollbit.github",
|
|
2960
|
+
* "name": "GitHub Pack",
|
|
2961
|
+
* "version": "0.1.0",
|
|
2962
|
+
* "functions": {
|
|
2963
|
+
* "ingest": {
|
|
2964
|
+
* "primitive": "script.run",
|
|
2965
|
+
* "params": {
|
|
2966
|
+
* "script_hcu": "bitpub://group:tollbit.com/Apps/github/orchestrator",
|
|
2967
|
+
* "runtime": "python3"
|
|
2968
|
+
* },
|
|
2969
|
+
* "env": {
|
|
2970
|
+
* "BITPUB_GH_ACTION": "ingest",
|
|
2971
|
+
* "BITPUB_GH_RECIPES": { "from": "args", "key": "recipes", "join": "," },
|
|
2972
|
+
* "BITPUB_OWNER": { "from": "identity", "key": "owner" }
|
|
2973
|
+
* }
|
|
2974
|
+
* }
|
|
2975
|
+
* }
|
|
2976
|
+
* }
|
|
2977
|
+
*
|
|
2978
|
+
* Field values can be:
|
|
2979
|
+
* - a literal string → used as-is
|
|
2980
|
+
* - { from: "args", key: "...", join?: "," } → arg-derived
|
|
2981
|
+
* - { from: "identity", key: "..." } → from identity.me()
|
|
2982
|
+
*/
|
|
2983
|
+
|
|
2984
|
+
const MANIFEST_CACHE = new Map(); // appHcu → Promise<manifest|null>
|
|
2985
|
+
const PRIMITIVE_IDS = new Set(['script.run', 'os.open']);
|
|
2986
|
+
|
|
2987
|
+
async function loadAppManifest(appHcu) {
|
|
2988
|
+
if (!appHcu) return null;
|
|
2989
|
+
if (MANIFEST_CACHE.has(appHcu)) return MANIFEST_CACHE.get(appHcu);
|
|
2990
|
+
const p = (async () => {
|
|
2991
|
+
const manifestHcu = appHcu + '/manifest.json';
|
|
2992
|
+
try {
|
|
2993
|
+
const u = new URL('/bridge/namespace/list', location.origin);
|
|
2994
|
+
u.searchParams.set('pattern', manifestHcu);
|
|
2995
|
+
u.searchParams.set('fields', 'full');
|
|
2996
|
+
u.searchParams.set('limit', '1');
|
|
2997
|
+
const r = await fetch(u.toString());
|
|
2998
|
+
const body = await r.json();
|
|
2999
|
+
const slice = (body.slices || [])[0];
|
|
3000
|
+
if (!slice || !slice.content) return null;
|
|
3001
|
+
return JSON.parse(slice.content);
|
|
3002
|
+
} catch (_) {
|
|
3003
|
+
return null;
|
|
3004
|
+
}
|
|
3005
|
+
})();
|
|
3006
|
+
MANIFEST_CACHE.set(appHcu, p);
|
|
3007
|
+
return p;
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
let CACHED_IDENTITY = null;
|
|
3011
|
+
async function getIdentityCached() {
|
|
3012
|
+
if (CACHED_IDENTITY) return CACHED_IDENTITY;
|
|
3013
|
+
try {
|
|
3014
|
+
const r = await fetch('/bridge/identity');
|
|
3015
|
+
CACHED_IDENTITY = await r.json();
|
|
3016
|
+
} catch (_) {
|
|
3017
|
+
CACHED_IDENTITY = {};
|
|
3018
|
+
}
|
|
3019
|
+
return CACHED_IDENTITY;
|
|
3020
|
+
}
|
|
3021
|
+
|
|
3022
|
+
function resolveManifestValue(v, ctx) {
|
|
3023
|
+
if (v == null) return null;
|
|
3024
|
+
// Arrays: resolve each element (used for cli_args).
|
|
3025
|
+
if (Array.isArray(v)) return v.map(x => resolveManifestValue(x, ctx)).filter(x => x != null);
|
|
3026
|
+
if (typeof v !== 'object') return v; // literal string / number
|
|
3027
|
+
const src = v.from === 'args' ? ctx.args : v.from === 'identity' ? ctx.identity : null;
|
|
3028
|
+
if (!src) return null;
|
|
3029
|
+
// Whole-object reference: { from: "args" } with no key → the entire
|
|
3030
|
+
// object. Useful for shipping all caller args as one JSON env var
|
|
3031
|
+
// (e.g. BRIEF_PAYLOAD). The env serializer downstream auto-stringifies
|
|
3032
|
+
// non-string values, so this Just Works.
|
|
3033
|
+
if (!v.key) return src;
|
|
3034
|
+
let val = src[v.key];
|
|
3035
|
+
if (val == null) return v.default != null ? v.default : null;
|
|
3036
|
+
if (v.join && Array.isArray(val)) val = val.join(v.join);
|
|
3037
|
+
return val;
|
|
3038
|
+
}
|
|
3039
|
+
|
|
3040
|
+
function resolveManifestFunction(manifest, funcName, args, identity) {
|
|
3041
|
+
if (!manifest || !manifest.functions) throw new Error('app has no manifest');
|
|
3042
|
+
const decl = manifest.functions[funcName];
|
|
3043
|
+
if (!decl) throw new Error(`function "${funcName}" not declared in app manifest`);
|
|
3044
|
+
if (!PRIMITIVE_IDS.has(decl.primitive)) throw new Error(`unknown primitive "${decl.primitive}"`);
|
|
3045
|
+
|
|
3046
|
+
const ctx = { args: args || {}, identity: identity || {} };
|
|
3047
|
+
const params = {};
|
|
3048
|
+
for (const [k, v] of Object.entries(decl.params || {})) {
|
|
3049
|
+
const resolved = resolveManifestValue(v, ctx);
|
|
3050
|
+
if (resolved != null) params[k] = resolved;
|
|
3051
|
+
}
|
|
3052
|
+
if (decl.primitive === 'script.run' && decl.env) {
|
|
3053
|
+
params.env = params.env || {};
|
|
3054
|
+
for (const [k, v] of Object.entries(decl.env)) {
|
|
3055
|
+
const resolved = resolveManifestValue(v, ctx);
|
|
3056
|
+
if (resolved != null) params.env[k] = resolved;
|
|
3057
|
+
}
|
|
3058
|
+
}
|
|
3059
|
+
return { functionId: decl.primitive, args: params };
|
|
3060
|
+
}
|
|
3061
|
+
|
|
3062
|
+
async function handleBridgeRunMessage(source, msg) {
|
|
3063
|
+
const { requestId, functionId, args, appHcu } = msg;
|
|
2789
3064
|
if (typeof requestId !== 'number' || !functionId) return;
|
|
2790
3065
|
|
|
3066
|
+
// Three paths:
|
|
3067
|
+
// 1. Calling a primitive directly → passthrough (power users + manifests)
|
|
3068
|
+
// 2. Calling a manifest-declared name → resolve, then call primitive
|
|
3069
|
+
// 3. Calling a legacy hardcoded name → passthrough (deprecation path)
|
|
3070
|
+
let resolvedFunctionId = String(functionId);
|
|
3071
|
+
let resolvedArgs = args || {};
|
|
3072
|
+
|
|
3073
|
+
if (!PRIMITIVE_IDS.has(resolvedFunctionId)) {
|
|
3074
|
+
const manifest = await loadAppManifest(appHcu);
|
|
3075
|
+
if (manifest && manifest.functions && manifest.functions[resolvedFunctionId]) {
|
|
3076
|
+
try {
|
|
3077
|
+
const identity = await getIdentityCached();
|
|
3078
|
+
const resolved = resolveManifestFunction(manifest, resolvedFunctionId, resolvedArgs, identity);
|
|
3079
|
+
resolvedFunctionId = resolved.functionId;
|
|
3080
|
+
resolvedArgs = resolved.args;
|
|
3081
|
+
} catch (err) {
|
|
3082
|
+
bridgeForward(source, requestId, 'error', { message: 'manifest resolve: ' + err.message });
|
|
3083
|
+
bridgeForward(source, requestId, 'exit', { code: 2 });
|
|
3084
|
+
return;
|
|
3085
|
+
}
|
|
3086
|
+
}
|
|
3087
|
+
// else: fall through to legacy CLI passthrough (no manifest, name might
|
|
3088
|
+
// still resolve in the CLI's hardcoded FUNCTIONS list).
|
|
3089
|
+
}
|
|
3090
|
+
|
|
2791
3091
|
const qs = new URLSearchParams({
|
|
2792
|
-
functionId:
|
|
2793
|
-
args: JSON.stringify(
|
|
3092
|
+
functionId: resolvedFunctionId,
|
|
3093
|
+
args: JSON.stringify(resolvedArgs),
|
|
2794
3094
|
});
|
|
2795
3095
|
|
|
2796
3096
|
let es;
|
|
@@ -2802,7 +3102,7 @@ function handleBridgeRunMessage(source, msg) {
|
|
|
2802
3102
|
return;
|
|
2803
3103
|
}
|
|
2804
3104
|
|
|
2805
|
-
BRIDGE_RUNS.set(requestId, { es, source, functionId });
|
|
3105
|
+
BRIDGE_RUNS.set(requestId, { es, source, functionId: resolvedFunctionId });
|
|
2806
3106
|
BRIDGE_RUN_COUNT++;
|
|
2807
3107
|
updateBridgeBadge();
|
|
2808
3108
|
|