@openthink/ui-leaf 0.3.0
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/LICENSE +21 -0
- package/README.md +301 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +672 -0
- package/dist/cli.js.map +1 -0
- package/dist/dev-server-DapOoULX.d.ts +5 -0
- package/dist/index.d.ts +170 -0
- package/dist/index.js +503 -0
- package/dist/index.js.map +1 -0
- package/dist/view.d.ts +16 -0
- package/dist/view.js +1 -0
- package/dist/view.js.map +1 -0
- package/package.json +66 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matt Pardini
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# ui-leaf
|
|
2
|
+
|
|
3
|
+
Customizable browser views, on demand, for any CLI.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install @openthink/ui-leaf
|
|
7
|
+
# or: bun add @openthink/ui-leaf / pnpm add @openthink/ui-leaf / yarn add @openthink/ui-leaf
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Type declarations (`dist/*.d.ts`) are emitted by TypeScript 6.x; consuming projects on TypeScript 5.x should generally work but are not exercised in CI.
|
|
11
|
+
|
|
12
|
+
## What it is
|
|
13
|
+
|
|
14
|
+
`ui-leaf` lets any CLI mount a local browser view from a single function call. The CLI pipes structured data in; the view renders it; user-driven mutations (button clicks, edits, deletes) flow **back through the CLI** as plain function calls — never directly to whatever backing API the CLI uses.
|
|
15
|
+
|
|
16
|
+
The view is your code, in your project's `views/` folder. Customize it, regenerate it with an LLM, fork the defaults — it's a regular `.tsx` file. That's the **bring-your-own-view** part.
|
|
17
|
+
|
|
18
|
+
## Quickstart
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
// my-cli/src/commands/spend.ts
|
|
22
|
+
import { mount } from "@openthink/ui-leaf";
|
|
23
|
+
|
|
24
|
+
const view = await mount({
|
|
25
|
+
view: "spend",
|
|
26
|
+
data: { items: [/* ... */], totals: {/* ... */} },
|
|
27
|
+
mutations: {
|
|
28
|
+
recategorize: async (args: { id: string; category: string }) => {
|
|
29
|
+
await db.recategorize(args.id, args.category);
|
|
30
|
+
return { ok: true };
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
console.log(`view at ${view.url} — close the tab to exit`);
|
|
36
|
+
await view.closed;
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```tsx
|
|
40
|
+
// my-cli/views/spend.tsx
|
|
41
|
+
import type { ViewProps } from "@openthink/ui-leaf/view";
|
|
42
|
+
|
|
43
|
+
interface Spend {
|
|
44
|
+
items: { id: string; amount: number; category: string }[];
|
|
45
|
+
totals: { total: number };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export default function Spend({ data, mutate }: ViewProps<Spend>) {
|
|
49
|
+
return (
|
|
50
|
+
<main>
|
|
51
|
+
<h1>${data.totals.total}</h1>
|
|
52
|
+
<ul>
|
|
53
|
+
{data.items.map((item) => (
|
|
54
|
+
<li key={item.id}>
|
|
55
|
+
{item.amount} — {item.category}
|
|
56
|
+
<button
|
|
57
|
+
onClick={() => mutate("recategorize", { id: item.id, category: "food" })}
|
|
58
|
+
>
|
|
59
|
+
→ food
|
|
60
|
+
</button>
|
|
61
|
+
</li>
|
|
62
|
+
))}
|
|
63
|
+
</ul>
|
|
64
|
+
</main>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Run `my-cli spend` (or whatever `--ui` flag your CLI uses) and a browser tab opens with the view rendering your data. Click a button, the CLI's `recategorize` handler runs, the result flows back to the view as a resolved promise.
|
|
70
|
+
|
|
71
|
+
## Why route mutations through the CLI?
|
|
72
|
+
|
|
73
|
+
Three reasons:
|
|
74
|
+
|
|
75
|
+
1. **The CLI already has the credentials.** Your view never sees auth tokens, never knows the API endpoint, never has to deal with refresh logic. The CLI handles all of that and exposes a constrained set of named operations.
|
|
76
|
+
2. **The CLI can do work the view can't.** Read local files, shell out, check the user's git state, write to a SQLite file, anything Node can do.
|
|
77
|
+
3. **The view is replaceable, the contract isn't.** Users can fork and rewrite the view freely; what they can't do is reach around the CLI to call your API directly.
|
|
78
|
+
|
|
79
|
+
## How it works
|
|
80
|
+
|
|
81
|
+
`mount()` spins up a local dev server (rsbuild + React under the hood), bundles your view file, injects the data into `window.__UI_LEAF__.data`, and opens the user's default browser. Mutations from the view POST back to a localhost endpoint with a per-launch random token; the runtime dispatches them to the handlers you registered. Browser tab close → heartbeat stops → server shuts down → `view.closed` resolves and your CLI continues.
|
|
82
|
+
|
|
83
|
+
The transport is HTTP + JSON over loopback. The token is in `window.__UI_LEAF__.token`, and it's served inline in the HTML at `/index.html` — so the token only protects against drive-by cross-origin requests in the user's browser, not against other processes on the same machine. Any local process that can reach `127.0.0.1:<port>` can fetch the page, grep the token out, and call `/mutate` with it; treat any local process you don't trust as having the same access as the view. View bundling resolves React from `ui-leaf`'s installed location, so your project doesn't need to install React.
|
|
84
|
+
|
|
85
|
+
## API surface
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import { mount } from "@openthink/ui-leaf";
|
|
89
|
+
import type { ViewProps, MutationHandler } from "@openthink/ui-leaf/view";
|
|
90
|
+
|
|
91
|
+
await mount({
|
|
92
|
+
view, // resolves <viewsRoot>/<view>.tsx
|
|
93
|
+
data, // JSON-serializable, becomes data prop
|
|
94
|
+
mutations, // Record<string, MutationHandler> (optional)
|
|
95
|
+
viewsRoot, // optional, default: <cwd>/views
|
|
96
|
+
title, // optional, default: "ui-leaf"
|
|
97
|
+
port, // optional, default: 5810 (auto-bumps if busy)
|
|
98
|
+
openBrowser, // optional, default: true
|
|
99
|
+
shell, // optional, "tab" | "app", default: "tab"
|
|
100
|
+
csp, // optional, default: "off" (see Hardening)
|
|
101
|
+
silent, // optional, default: false (see Programmatic use)
|
|
102
|
+
signal, // optional AbortSignal
|
|
103
|
+
heartbeatTimeoutMs, // optional, default: 75000
|
|
104
|
+
startupGraceMs, // optional, default: 30000
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
(This is a summary — see the JSDoc on `MountOptions` for the full TypeScript shape and per-field rationale.)
|
|
109
|
+
|
|
110
|
+
Returns `{ url, port, closed, close }`.
|
|
111
|
+
|
|
112
|
+
## Hardening: locking the data/mutation contract with CSP
|
|
113
|
+
|
|
114
|
+
By default, the data/mutation routing is **convention, not enforcement** — a view file is JavaScript in a browser tab and can `fetch()` anywhere it likes. Most consumers don't need more than that.
|
|
115
|
+
|
|
116
|
+
When you do want to enforce it (typically: views handle data sensitive enough that you don't want a forked view to be able to exfiltrate it), opt in via `csp`:
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
mount({
|
|
120
|
+
view: "report",
|
|
121
|
+
data: { ... },
|
|
122
|
+
csp: "strict", // or a custom CSP string for full control
|
|
123
|
+
});
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`csp: "strict"` ships a balanced preset that:
|
|
127
|
+
|
|
128
|
+
- **Locks `connect-src` to same-origin** — the architectural lock. Views cannot fetch external APIs; all data flows through `data` and `mutations`.
|
|
129
|
+
- **Permits HTTPS images and fonts** so views can load CDN assets normally.
|
|
130
|
+
- **Allows inline styles, eval, and inline scripts** for React + rsbuild's HMR.
|
|
131
|
+
|
|
132
|
+
Because the policy is sent as an HTTP response header, views cannot relax it at runtime. The only way to weaken the policy is to change the `mount()` call (i.e. fork the consumer CLI, not the view).
|
|
133
|
+
|
|
134
|
+
If the preset is too strict for your case (e.g. you need to allow Sentry telemetry), pass a raw CSP string:
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
csp: "default-src 'self'; connect-src 'self' https://sentry.io; img-src 'self' https:;"
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### DNS-rebinding defence
|
|
141
|
+
|
|
142
|
+
The dev server only accepts requests whose `Host` (and `Origin`, when sent) header points at a loopback name — `localhost`, `127.0.0.1`, or `[::1]`. Anything else gets a 403. This blocks DNS-rebinding attacks where a malicious page swings its A-record to `127.0.0.1` and tries to talk to your dev server with the per-launch auth token it can read out of `/index.html`.
|
|
143
|
+
|
|
144
|
+
If you reach the dev server through a custom `/etc/hosts` alias (e.g. `my-app.local → 127.0.0.1`), pass it through `allowedHosts`:
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
mount({
|
|
148
|
+
view: "report",
|
|
149
|
+
data: { ... },
|
|
150
|
+
allowedHosts: ["my-app.local"],
|
|
151
|
+
});
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Be deliberate — every name you add becomes a viable rebinding target. Don't add public DNS names or LAN hostnames you don't fully control.
|
|
155
|
+
|
|
156
|
+
### Data-at-rest in the temp directory
|
|
157
|
+
|
|
158
|
+
ui-leaf serialises the `data` you pass to `mount()` into `<tmpdir()>/ui-leaf-XXXXXX/index.html` so the dev server can serve it. The directory is created with `mode 0700` (readable only by the same UID), and ui-leaf removes it on `close()`, on `SIGINT`/`SIGTERM`, on uncaught throws via a `process.on('exit')` fallback, and opportunistically sweeps `ui-leaf-*` siblings older than 24h on every startup to catch anything that still slipped through.
|
|
159
|
+
|
|
160
|
+
What still leaks: `SIGKILL`, OOM-kill, and abrupt power loss skip every Node hook, so the directory stays on disk until the next `mount()` runs (the startup sweep) or the OS rotates `tmpdir()` (on macOS, only across reboots; on many Linux systems, only via `tmpfiles.d` age policies). If the data is sensitive enough that even that bounded window is too long, keep it in memory in your CLI and inject it into the view via an authenticated `connect-src 'self'` fetch on boot rather than passing it through `data`.
|
|
161
|
+
|
|
162
|
+
## Sharing views across users
|
|
163
|
+
|
|
164
|
+
ui-leaf views run on `127.0.0.1`, so the URL in the address bar isn't shareable — a coworker can't paste `http://127.0.0.1:5810/...` into Slack and have it open on their machine. Browsers also can't be made to *display* a custom protocol like `mycli://...` for an HTTP-served page (browser security: any HTTP page could spoof itself otherwise).
|
|
165
|
+
|
|
166
|
+
The pattern that works: **the consumer CLI generates a deep-link URL and passes it through `data`. The view renders a "copy share link" button that puts that deep-link URL on the clipboard.**
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
// in the consumer CLI:
|
|
170
|
+
await mount({
|
|
171
|
+
view: "spec",
|
|
172
|
+
data: {
|
|
173
|
+
spec: specContent,
|
|
174
|
+
shareUrl: `mycli://spec/${specId}`,
|
|
175
|
+
},
|
|
176
|
+
mutations: { /* … */ },
|
|
177
|
+
});
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
```tsx
|
|
181
|
+
// in the consumer's views/spec.tsx:
|
|
182
|
+
import type { ViewProps } from "@openthink/ui-leaf/view";
|
|
183
|
+
|
|
184
|
+
export default function Spec({ data }: ViewProps<{ spec: string; shareUrl: string }>) {
|
|
185
|
+
return (
|
|
186
|
+
<>
|
|
187
|
+
{/* render the spec */}
|
|
188
|
+
<button onClick={() => navigator.clipboard.writeText(data.shareUrl)}>
|
|
189
|
+
Copy share link
|
|
190
|
+
</button>
|
|
191
|
+
</>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Pair with `shell: "app"` (Chromium's chromeless window mode) to hide the localhost URL bar entirely on Chrome/Edge/Brave — the share button becomes the *only* way to copy a link from the view. (Safari and Firefox fall back to a regular tab.)
|
|
197
|
+
|
|
198
|
+
User A clicks the button → `mycli://spec/abc123` is on their clipboard. User B clicks the link → their browser hands off to the OS → OS launches `mycli` (because it's registered as the `mycli://` handler) → the consumer parses the URL, fetches the spec on their machine, calls `mount(...)` again on User B's side. Two independent ui-leaf invocations, same view, same data, no localhost URL ever leaves either machine.
|
|
199
|
+
|
|
200
|
+
What the consumer CLI is responsible for (out of ui-leaf's scope):
|
|
201
|
+
|
|
202
|
+
- **Registering the URL scheme with the OS** at install time. Per-OS:
|
|
203
|
+
- macOS: `.app` bundle with `CFBundleURLTypes` in `Info.plist`
|
|
204
|
+
- Windows: registry entries under `HKEY_CLASSES_ROOT\<scheme>`
|
|
205
|
+
- Linux: `.desktop` file with `MimeType=x-scheme-handler/<scheme>;`
|
|
206
|
+
- **Parsing the URL on launch** — when the OS invokes `mycli mycli://spec/abc123`, parse it, look up `abc123`, build the data, call `mount`.
|
|
207
|
+
- **Generating share URLs that are stable IDs**, not raw payloads — URLs land in browser history, screenshots, and copy-paste; treat them accordingly.
|
|
208
|
+
- **Handling "not installed" UX** in the originating web app (if the link gets shared with someone who doesn't have `mycli`) — typical pattern is to set `window.location` to the deep-link URL, then after a short timeout fall back to "looks like you don't have mycli installed, here's how to get it."
|
|
209
|
+
|
|
210
|
+
## Driving ui-leaf from a non-Node CLI (Rust / Go / Python / shell)
|
|
211
|
+
|
|
212
|
+
`ui-leaf mount` is a language-neutral binary. Any CLI that can spawn a subprocess and read/write JSON lines on stdio can drive ui-leaf with no Node code of its own — install ui-leaf via `npm i -g @openthink/ui-leaf` (or bundle it), and shell out to `ui-leaf mount`.
|
|
213
|
+
|
|
214
|
+
### Protocol
|
|
215
|
+
|
|
216
|
+
- **stdin (line-delimited JSON):**
|
|
217
|
+
- **Line 1** — config:
|
|
218
|
+
```json
|
|
219
|
+
{"view":"spec","viewsRoot":"/abs/path","data":{},"mutations":["refresh","dismiss"],"port":0,"openBrowser":true,"heartbeatTimeoutMs":5000}
|
|
220
|
+
```
|
|
221
|
+
- **Subsequent lines** — mutation responses (paired by `id`):
|
|
222
|
+
```json
|
|
223
|
+
{"type":"result","id":1,"value":{"ok":true}}
|
|
224
|
+
{"type":"error","id":2,"message":"…"}
|
|
225
|
+
```
|
|
226
|
+
- **stdout (line-delimited JSON):**
|
|
227
|
+
- `{"type":"ready","url":"http://127.0.0.1:54321","port":54321}` — emitted once when the dev server is up
|
|
228
|
+
- `{"type":"mutate","id":1,"name":"refresh","args":{}}` — emitted when a view triggers a mutation; respond on stdin
|
|
229
|
+
- `{"type":"closed"}` — emitted on natural close (browser tab closed, heartbeat timeout)
|
|
230
|
+
- `{"type":"error","message":"…"}` — emitted on internal failure
|
|
231
|
+
- **Lifecycle:** binary exits 0 on natural close, 1 on internal error; closing stdin from the parent triggers shutdown.
|
|
232
|
+
|
|
233
|
+
### Minimal Bash example (read-only view, no mutations)
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
CONFIG='{"view":"spec","viewsRoot":"/abs/path/to/views","data":{"markdown":"# hi"},"port":0}'
|
|
237
|
+
echo "$CONFIG" | ui-leaf mount
|
|
238
|
+
# → {"type":"ready","url":"http://127.0.0.1:54321","port":54321}
|
|
239
|
+
# (browser opens; user closes tab)
|
|
240
|
+
# → {"type":"closed"}
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### Worked example with mutations
|
|
244
|
+
|
|
245
|
+
When the view calls `mutate("name", args)`, the binary emits a `mutate` event on stdout and waits for the parent to write back a `result` (or `error`) on stdin, paired by `id`. The runnable script in [`examples/bash/counter.sh`](./examples/bash/counter.sh) demonstrates the full cycle. Sketch:
|
|
246
|
+
|
|
247
|
+
```
|
|
248
|
+
Parent → child stdin:
|
|
249
|
+
{"view":"demo","viewsRoot":"/abs/path","data":{"initialCount":0},"mutations":["increment"]}
|
|
250
|
+
|
|
251
|
+
Child → parent stdout:
|
|
252
|
+
{"type":"ready","url":"http://127.0.0.1:54321","port":54321}
|
|
253
|
+
|
|
254
|
+
(user clicks "+1" in the view)
|
|
255
|
+
|
|
256
|
+
Child → parent stdout:
|
|
257
|
+
{"type":"mutate","id":1,"name":"increment","args":{"by":1}}
|
|
258
|
+
|
|
259
|
+
Parent → child stdin (after handling the mutation):
|
|
260
|
+
{"type":"result","id":1,"value":{"count":1}}
|
|
261
|
+
|
|
262
|
+
(user closes tab)
|
|
263
|
+
|
|
264
|
+
Child → parent stdout:
|
|
265
|
+
{"type":"closed"}
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Each pending mutation has a unique `id`. Multiple mutations can be in flight concurrently — match `result`/`error` responses by id.
|
|
269
|
+
|
|
270
|
+
### Tips for non-Node consumers
|
|
271
|
+
|
|
272
|
+
- **Pass `viewsRoot` as an absolute path.** No `cwd/views` default games when invoked from another process.
|
|
273
|
+
- **Pass `port: 0`.** ui-leaf asks the OS for a free port and reports it back in the `ready` event. Lets you run concurrent views without collision.
|
|
274
|
+
- **Lower `heartbeatTimeoutMs`** (e.g. 5000) so orphaned ui-leaf children exit fast if your parent process dies. The default 75000 is tuned for human-direct use (survives one browser background-tab throttle) and is too long when a parent process is supervising.
|
|
275
|
+
- **Kill the child on parent shutdown** rather than relying on heartbeat — `kill <pid>` from the parent. Closing stdin also triggers a clean shutdown.
|
|
276
|
+
- **Declare every mutation name** the view will call in the `mutations: []` array. The binary only routes mutations whose names appear in the list; calls to undeclared names get a 404 from `/mutate` with the standard "no mutation handler registered for X" error, and the view's `mutate()` promise rejects.
|
|
277
|
+
|
|
278
|
+
### Driving from Node via `mount()` directly
|
|
279
|
+
|
|
280
|
+
If your consumer is itself Node (or you want a thin in-process integration), use the SDK directly. Pass `silent: true` to suppress rsbuild output so you can keep stdout clean for your own protocol (capture `process.stdout.write` *before* calling `mount()`, since the option redirects stdout to stderr for the lifetime of the dev server):
|
|
281
|
+
|
|
282
|
+
```ts
|
|
283
|
+
const realStdoutWrite = process.stdout.write.bind(process.stdout);
|
|
284
|
+
const view = await mount({
|
|
285
|
+
view: "spec",
|
|
286
|
+
viewsRoot: "/abs/path/to/views",
|
|
287
|
+
data: { /* ... */ },
|
|
288
|
+
openBrowser: false,
|
|
289
|
+
silent: true,
|
|
290
|
+
port: 0,
|
|
291
|
+
});
|
|
292
|
+
realStdoutWrite(JSON.stringify({ type: "ready", url: view.url, port: view.port }) + "\n");
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
## Status
|
|
296
|
+
|
|
297
|
+
`0.2.x` — pre-1.0, expect churn. The Node SDK and the `ui-leaf mount` binary are settling but not frozen.
|
|
298
|
+
|
|
299
|
+
## License
|
|
300
|
+
|
|
301
|
+
[MIT](./LICENSE)
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|