letmeknow-cli 0.7.0 → 0.8.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/README.md +21 -97
- package/SKILL.md +125 -33
- package/bin/letmeknow.js +190 -64
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,129 +1,53 @@
|
|
|
1
1
|
# LetMeKnow
|
|
2
2
|
|
|
3
|
-
LetMeKnow gives an agent a temporary public browser surface
|
|
3
|
+
LetMeKnow gives an agent a temporary public browser surface for structured human feedback. The agent serves ordinary HTML and static assets, receives browser submissions, and can push ordered HTML replacements.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Quick start
|
|
6
6
|
|
|
7
|
-
Create a directory
|
|
7
|
+
Create a directory with an `index.html`, then run:
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
10
|
npx letmeknow-cli serve ./preview
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
The CLI prints one JSON line containing the public bearer URL:
|
|
14
14
|
|
|
15
15
|
```json
|
|
16
16
|
{"type":"ready","url":"https://0123456789abcdef0123.letmeknow.dev/","page_event":0,"page_hash":"…"}
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
Give the URL to the human. Anyone with the URL can view the page and submit its forms.
|
|
19
|
+
Give the URL to the human. Anyone with the URL can view the page and submit its forms.
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
## Agent workflow
|
|
24
|
-
|
|
25
|
-
Pull browser events, update the page, and push the resulting page:
|
|
26
|
-
|
|
27
|
-
```bash
|
|
28
|
-
batch=$(npx letmeknow-cli pull ./preview --wait 30)
|
|
29
|
-
token=$(printf '%s\n' "$batch" | jq -r .token)
|
|
30
|
-
# inspect events, edit index.html, then:
|
|
31
|
-
npx letmeknow-cli push ./preview --batch "$token" --page index.html
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
`pull` returns an opaque batch token, current-page metadata, and the browser events not yet committed by the agent. Pulling does not consume events. Events that arrive while the agent works remain for a later pull.
|
|
35
|
-
|
|
36
|
-
```json
|
|
37
|
-
{"token":"…","frontier":7,"page_event":5,"page_hash":"…","events":[{"type":"submit","id":"…","event_number":7,"page_event":5,"form_id":"decision","action":"/decide","trigger":{"name":"decision","value":"approve"},"values":{"comment":"Looks good","decision":"approve"}}]}
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
An event's `page_event` identifies the page the browser displayed when it submitted. Compare it with the batch's current `page_event` when deciding whether the input still applies.
|
|
41
|
-
|
|
42
|
-
A push with a page:
|
|
43
|
-
|
|
44
|
-
```bash
|
|
45
|
-
npx letmeknow-cli push ./preview --batch "$token" --page index.html
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
atomically commits the events represented by the token, replaces the canonical dynamic page with the complete HTML from `index.html`, appends one page-update event to the global event stream, and broadcasts that page to connected browsers. Browsers morph the page without navigating or reloading.
|
|
49
|
-
|
|
50
|
-
A push without `--page` only commits the pulled browser events:
|
|
51
|
-
|
|
52
|
-
```bash
|
|
53
|
-
npx letmeknow-cli push ./preview --batch "$token"
|
|
54
|
-
```
|
|
55
|
-
|
|
56
|
-
Use `--page -` to read the complete desired page from standard input:
|
|
57
|
-
|
|
58
|
-
```bash
|
|
59
|
-
npx letmeknow-cli push ./preview --batch "$token" --page - < updated.html
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
The update is all-or-nothing. If the token or page input is invalid, neither the browser events nor the page update is committed.
|
|
63
|
-
|
|
64
|
-
## Inspect the current page
|
|
65
|
-
|
|
66
|
-
`show` writes the canonical dynamic HTML held by `serve` to standard output:
|
|
67
|
-
|
|
68
|
-
```bash
|
|
69
|
-
npx letmeknow-cli show ./preview > current.html
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
It is read-only and does not create or commit an event. This is different from opening the public URL: `show` returns canonical HTML, while the URL shows a particular browser’s rendered DOM, including local focus, open/closed controls, unsent values, and JavaScript state.
|
|
73
|
-
|
|
74
|
-
## The event stream
|
|
75
|
-
|
|
76
|
-
Browser submissions and CLI page updates share one ordered, in-memory event stream:
|
|
21
|
+
## Commands
|
|
77
22
|
|
|
78
23
|
```text
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
24
|
+
serve <dir>
|
|
25
|
+
show <dir>
|
|
26
|
+
pull <dir> [--wait seconds]
|
|
27
|
+
push <dir> --batch TOKEN [--updates FILE|-]
|
|
82
28
|
```
|
|
83
29
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
The CLI assigns the event order. The number indicates acceptance order, not the physical time a person clicked. Submission IDs make retries distinguishable from new intentional submissions.
|
|
30
|
+
`serve` reads `index.html` once as the canonical page. Other files are served live from the directory. `show` prints the accepted canonical HTML. `pull` returns browser events without consuming them; `push` commits a pulled batch, optionally with ordered replacements. See [`SKILL.md`](SKILL.md) for the complete workflow and protocol.
|
|
87
31
|
|
|
88
|
-
##
|
|
32
|
+
## Page updates
|
|
89
33
|
|
|
90
|
-
Use
|
|
34
|
+
Use stable IDs as replacement boundaries:
|
|
91
35
|
|
|
92
36
|
```html
|
|
93
|
-
<
|
|
94
|
-
<label>Comment <textarea id="comment" name="comment"></textarea></label>
|
|
95
|
-
<button name="decision" value="approve">Approve</button>
|
|
96
|
-
<button name="decision" value="reject">Reject</button>
|
|
97
|
-
</form>
|
|
37
|
+
<output id="counter">41</output>
|
|
98
38
|
```
|
|
99
39
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
Form values are untrusted input and should be validated by the agent. File uploads are not supported.
|
|
103
|
-
|
|
104
|
-
## Authoring the dynamic page
|
|
105
|
-
|
|
106
|
-
Each page update supplies the complete desired HTML document. The browser morphs the current document toward it, so a small change such as a counter update need not recreate the whole DOM.
|
|
107
|
-
|
|
108
|
-
Give elements stable unique IDs. They help the morphing runtime retain unchanged elements, including controls whose local state should survive an update:
|
|
109
|
-
|
|
110
|
-
```html
|
|
111
|
-
<output id="count">0</output>
|
|
40
|
+
```json
|
|
41
|
+
{"updates":[{"target":"counter","html":"<output id=\"counter\">42</output>"}]}
|
|
112
42
|
```
|
|
113
43
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
The CLI owns rendered page content. The browser preserves focus, scrolling, dirty controls with stable IDs, and the open state of `<details id="…">`. Mark an element with a stable ID and `data-letmeknow-local` when its `hidden` state is browser-owned. Avoid having browser JavaScript and pushed HTML otherwise mutate the same state; a later morph may replace browser-created changes.
|
|
117
|
-
|
|
118
|
-
A page update is shared with all browsers. Keep private or browser-specific behavior local unless a future requirement introduces targeted updates.
|
|
119
|
-
|
|
120
|
-
## Static assets
|
|
44
|
+
Each replacement must contain one element with the target's ID and cannot contain scripts. Replacements in one push are applied in order and atomically. Replacing `letmeknow-root` is the broad page-update case and intentionally discards browser-local state inside that root.
|
|
121
45
|
|
|
122
|
-
|
|
46
|
+
## Session and security basics
|
|
123
47
|
|
|
124
|
-
|
|
48
|
+
Sessions are temporary and held in memory by the CLI and relay. The service may expire a session at any time. If the producer disconnects, reconnect is best-effort and may be available only for a limited period; clients should handle disconnects without assuming that reconnect will succeed.
|
|
125
49
|
|
|
126
|
-
The URL is a bearer capability
|
|
50
|
+
The public URL is a bearer capability: anyone who has it can view the page and submit forms. Treat browser values as untrusted input and escape them before placing them in HTML. Keep secrets and unrelated files outside the served directory.
|
|
127
51
|
|
|
128
52
|
## Development
|
|
129
53
|
|
|
@@ -135,4 +59,4 @@ npm run dev
|
|
|
135
59
|
npm run deploy
|
|
136
60
|
```
|
|
137
61
|
|
|
138
|
-
The browser test requires Firefox and geckodriver. `npm run dev` and `npm run deploy` operate the Cloudflare relay.
|
|
62
|
+
The browser smoke test requires Firefox and geckodriver. `npm run dev` and `npm run deploy` operate the Cloudflare relay. For the detailed agent workflow, forms, event causality, reconnect behavior, and static-asset guidance, read [`SKILL.md`](SKILL.md).
|
package/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: letmeknow
|
|
3
|
-
description: Serve a temporary live HTML page, collect structured human feedback, and push
|
|
3
|
+
description: Serve a temporary live HTML page, collect structured human feedback, and push ordered HTML replacements.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# LetMeKnow
|
|
@@ -9,29 +9,46 @@ Use LetMeKnow when a human should inspect or interact with an agent-authored pag
|
|
|
9
9
|
|
|
10
10
|
## Start
|
|
11
11
|
|
|
12
|
-
Create a directory containing the public files and an initial `index.html
|
|
12
|
+
Create a directory containing the public files and an initial `index.html`, then run:
|
|
13
13
|
|
|
14
14
|
```bash
|
|
15
15
|
npx letmeknow-cli serve ./preview
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
`serve` reads `index.html` once as the canonical dynamic
|
|
18
|
+
`serve` reads `index.html` once as the initial canonical dynamic page and serves it at `/`. Other files—such as CSS, JavaScript, images, and data—are served live from the directory. The first stdout JSON line contains the public bearer URL and initial page metadata:
|
|
19
19
|
|
|
20
20
|
```json
|
|
21
21
|
{"type":"ready","url":"https://0123456789abcdef0123.letmeknow.dev/","page_event":0,"page_hash":"…"}
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
The initial page should have a stable shell and a dynamic root:
|
|
25
|
+
|
|
26
|
+
```html
|
|
27
|
+
<body>
|
|
28
|
+
<main id="letmeknow-root">
|
|
29
|
+
...
|
|
30
|
+
</main>
|
|
31
|
+
<script type="module" src="/app.js"></script>
|
|
32
|
+
</body>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The runtime is injected into the page separately. Keep agent-authored scripts and the runtime outside normal replacement targets.
|
|
36
|
+
|
|
37
|
+
## Session lifetime and reconnect
|
|
38
|
+
|
|
39
|
+
A producer must successfully send `open` shortly after connecting. Sessions are temporary and may be expired by the service at any time.
|
|
40
|
+
|
|
41
|
+
If an opened producer disconnects, reconnect is best-effort and may be available only for a limited period. A reconnect uses the existing session credential, but clients must handle disconnects without assuming that reconnect will succeed. When the service expires a session, the session and its browser connections end.
|
|
25
42
|
|
|
26
43
|
## Agent loop
|
|
27
44
|
|
|
28
|
-
Pull browser events, update
|
|
45
|
+
Pull browser events, update one or more HTML fragments, and push the replacements:
|
|
29
46
|
|
|
30
47
|
```bash
|
|
31
48
|
batch=$(npx letmeknow-cli pull ./preview --wait 30)
|
|
32
49
|
token=$(printf '%s\n' "$batch" | jq -r .token)
|
|
33
|
-
# inspect
|
|
34
|
-
npx letmeknow-cli push ./preview --batch "$token" --
|
|
50
|
+
# inspect events and write fragments such as counter.html and status.html
|
|
51
|
+
npx letmeknow-cli push ./preview --batch "$token" --updates updates.json
|
|
35
52
|
```
|
|
36
53
|
|
|
37
54
|
Commands:
|
|
@@ -40,74 +57,149 @@ Commands:
|
|
|
40
57
|
serve <dir>
|
|
41
58
|
show <dir>
|
|
42
59
|
pull <dir> [--wait seconds]
|
|
43
|
-
push <dir> --batch TOKEN [--
|
|
60
|
+
push <dir> --batch TOKEN [--updates FILE|-]
|
|
44
61
|
```
|
|
45
62
|
|
|
46
|
-
`
|
|
63
|
+
`show` returns the current canonical page without changing the event stream:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
npx letmeknow-cli show ./preview > current.html
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The public URL is the visual preview. `show` returns canonical HTML, not a browser's local focus, open disclosures, unsent input, scroll position, or JavaScript state.
|
|
70
|
+
|
|
71
|
+
## Push updates
|
|
72
|
+
|
|
73
|
+
A push accepts one JSON document:
|
|
47
74
|
|
|
48
75
|
```json
|
|
49
|
-
{
|
|
76
|
+
{
|
|
77
|
+
"updates": [
|
|
78
|
+
{"target": "counter", "file": "counter.html"},
|
|
79
|
+
{"target": "status", "html": "<output id=\"status\">Saved</output>"}
|
|
80
|
+
]
|
|
81
|
+
}
|
|
50
82
|
```
|
|
51
83
|
|
|
52
|
-
|
|
84
|
+
Each update must contain exactly one of `html` or `file`. A file supplies the replacement HTML. The normal operation is direct replacement of one element identified by its unique stable `id`:
|
|
53
85
|
|
|
54
|
-
|
|
86
|
+
```html
|
|
87
|
+
<output id="counter">41</output>
|
|
88
|
+
```
|
|
55
89
|
|
|
56
|
-
```
|
|
57
|
-
|
|
90
|
+
```json
|
|
91
|
+
{"updates":[{"target":"counter","html":"<output id=\"counter\">42</output>"}]}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The replacement must contain exactly one element, and that element must have the same ID as the target. Replacement HTML cannot contain scripts. The target must exist when the replacement is applied.
|
|
95
|
+
|
|
96
|
+
Use small output regions for ordinary updates. The replacement is destructive inside its target but leaves the rest of the page alone, so a counter update does not disturb a form or button elsewhere.
|
|
97
|
+
|
|
98
|
+
The document root is just another target. To intentionally replace all dynamic content, target the root:
|
|
99
|
+
|
|
100
|
+
```html
|
|
101
|
+
<main id="letmeknow-root">
|
|
102
|
+
...
|
|
103
|
+
</main>
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
```json
|
|
107
|
+
{"updates":[{"target":"letmeknow-root","file":"root.html"}]}
|
|
58
108
|
```
|
|
59
109
|
|
|
60
|
-
|
|
110
|
+
Replacing the root intentionally discards browser-local state inside it. Use it when that is acceptable, not for every small change.
|
|
111
|
+
|
|
112
|
+
A single push may contain many replacements. They are applied in the order listed. Later replacements may target elements introduced by earlier replacements in the same push, so introduce a target before updating it. Conversely, a replacement that removes a later target makes a following update invalid. The CLI validates the complete ordered batch before committing anything.
|
|
113
|
+
|
|
114
|
+
Each replacement becomes its own `update_ui` event with its own global event number. The complete push is still atomic: either all replacements and the pulled browser-event batch commit, or none do. Connected browsers receive committed replacements in order.
|
|
115
|
+
|
|
116
|
+
A push without `--updates` commits the pulled browser events without changing the page:
|
|
61
117
|
|
|
62
118
|
```bash
|
|
63
119
|
npx letmeknow-cli push ./preview --batch "$token"
|
|
64
120
|
```
|
|
65
121
|
|
|
66
|
-
|
|
122
|
+
There is no separate acknowledgement command and no `--page` mode. Replacing `letmeknow-root` provides the broad page-update case.
|
|
67
123
|
|
|
68
|
-
|
|
69
|
-
|
|
124
|
+
## Event stream and page causality
|
|
125
|
+
|
|
126
|
+
Browser submissions and CLI page replacements share one ordered, in-memory event stream:
|
|
127
|
+
|
|
128
|
+
```text
|
|
129
|
+
submit browser
|
|
130
|
+
submit browser
|
|
131
|
+
update_ui CLI: replace #counter
|
|
132
|
+
update_ui CLI: replace #status
|
|
70
133
|
```
|
|
71
134
|
|
|
72
|
-
The
|
|
135
|
+
The CLI assigns event numbers in acceptance order. They do not claim to be the physical order in which people clicked. Browser submissions are delivered to the agent through `pull`; raw submissions are not broadcast to other browsers. Page replacements are broadcast to all connected browsers.
|
|
73
136
|
|
|
74
|
-
`
|
|
137
|
+
`pull` returns an opaque batch token, current-page metadata, and browser events not yet committed by the agent. Pulling does not consume events. Events arriving while the agent works remain for a later pull.
|
|
75
138
|
|
|
76
|
-
```
|
|
77
|
-
|
|
139
|
+
```json
|
|
140
|
+
{
|
|
141
|
+
"token": "…",
|
|
142
|
+
"frontier": 7,
|
|
143
|
+
"page_event": 5,
|
|
144
|
+
"page_hash": "…",
|
|
145
|
+
"events": [
|
|
146
|
+
{
|
|
147
|
+
"type": "submit",
|
|
148
|
+
"id": "…",
|
|
149
|
+
"event_number": 7,
|
|
150
|
+
"page_event": 5,
|
|
151
|
+
"form_id": "decision",
|
|
152
|
+
"action": "/decide",
|
|
153
|
+
"trigger": {"name": "decision", "value": "approve"},
|
|
154
|
+
"values": {"comment": "Looks good", "decision": "approve"}
|
|
155
|
+
}
|
|
156
|
+
]
|
|
157
|
+
}
|
|
78
158
|
```
|
|
79
159
|
|
|
80
|
-
|
|
160
|
+
`page_event` is the page-update event number displayed when the browser submitted. Compare each event's `page_event` with the batch's current page before applying old input to the current HTML. `frontier` is the latest global event number, including events that are not browser submissions.
|
|
161
|
+
|
|
162
|
+
The token identifies exactly the browser-event frontier the agent saw. A successful push commits that frontier and its ordered replacements together. Browser events accepted after the pull remain for the next batch.
|
|
81
163
|
|
|
82
164
|
## Forms
|
|
83
165
|
|
|
84
|
-
Use
|
|
166
|
+
Use ordinary same-origin forms with stable IDs and meaningful field names:
|
|
85
167
|
|
|
86
168
|
```html
|
|
87
|
-
<form id="
|
|
169
|
+
<form id="decision" action="/decide" method="post">
|
|
88
170
|
<label>Comment <textarea id="comment" name="comment"></textarea></label>
|
|
89
171
|
<button name="decision" value="approve">Approve</button>
|
|
90
172
|
<button name="decision" value="reject">Reject</button>
|
|
91
173
|
</form>
|
|
92
174
|
```
|
|
93
175
|
|
|
94
|
-
The
|
|
176
|
+
The runtime converts native form submission into a JSON `submit` event. It assigns an opaque UUID, stores each event in a durable browser outbox before delivery, retries after connection failures, and reuses the UUID on retry. The CLI deduplicates repeated delivery of the same event. Distinct intentional submissions remain distinct, including rapid repeated clicks. File uploads are not supported.
|
|
95
177
|
|
|
96
178
|
Treat pulled values as untrusted input. Validate them and escape them before putting them into HTML.
|
|
97
179
|
|
|
98
|
-
##
|
|
99
|
-
|
|
100
|
-
Every page push supplies the complete desired dynamic document. The browser uses HTML morphing, so unchanged DOM nodes can survive while changed content is updated.
|
|
180
|
+
## Browser and HTML rules
|
|
101
181
|
|
|
102
|
-
|
|
182
|
+
Normal updates are direct replacements, not DOM morphs. Stable IDs are therefore important for naming update boundaries, not for preserving DOM nodes.
|
|
103
183
|
|
|
104
|
-
The CLI owns
|
|
184
|
+
- The CLI owns content inside replacement targets.
|
|
185
|
+
- The browser owns local focus, open/closed disclosure state, and hide/show behavior outside deliberately replaced targets.
|
|
186
|
+
- A root replacement can destroy all local state inside the root.
|
|
187
|
+
- Load agent-authored JavaScript from the initial page as a static asset.
|
|
188
|
+
- Use delegated event listeners because replaced elements are new DOM nodes.
|
|
189
|
+
- Scripts in update fragments are not executed.
|
|
190
|
+
- Do not have browser JavaScript and pushed HTML independently own the same state.
|
|
105
191
|
|
|
106
|
-
|
|
192
|
+
When a session ends, the browser shows a permanent closed status and discards unsent submissions rather than retrying them.
|
|
107
193
|
|
|
108
194
|
## Static assets
|
|
109
195
|
|
|
110
|
-
CSS, JavaScript, images, and other non-`index.html` files are served live. Finish writing an asset before pushing HTML that references it. Write assets atomically, and use versioned filenames or cache-busting URLs when the browser must fetch a changed asset with the
|
|
196
|
+
CSS, JavaScript, images, and other non-`index.html` files are served live. Finish writing an asset before pushing HTML that references it. Write assets atomically, and use versioned filenames or cache-busting URLs when the browser must fetch a changed asset with the page update.
|
|
197
|
+
|
|
198
|
+
This design intentionally does not provide atomic publication of the whole directory. The dynamic page changes through ordered replacements; other files can change as soon as they are written.
|
|
199
|
+
|
|
200
|
+
## Security
|
|
201
|
+
|
|
202
|
+
The URL is a bearer capability. Anyone who has it can view the page and submit forms. Keep secrets and unrelated files outside the served directory. Browser values are untrusted input; escape them before placing them in HTML.
|
|
111
203
|
|
|
112
204
|
## Stop
|
|
113
205
|
|
package/bin/letmeknow.js
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { constants, existsSync, readFileSync, statSync, writeSync } from "node:fs";
|
|
3
|
+
import { constants, createReadStream, existsSync, readFileSync, statSync, writeSync } from "node:fs";
|
|
4
4
|
import { chmod, open, readFile, realpath, stat, unlink } from "node:fs/promises";
|
|
5
5
|
import { createHash, randomUUID } from "node:crypto";
|
|
6
6
|
import net from "node:net";
|
|
7
7
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
9
|
import { parseArgs } from "node:util";
|
|
10
|
+
import { parse, parseFragment, serialize } from "parse5";
|
|
10
11
|
import { lookup } from "mrmime";
|
|
11
12
|
|
|
12
13
|
const MAX_BODY_BYTES = 1024 * 1024;
|
|
13
|
-
const
|
|
14
|
-
const
|
|
14
|
+
const UPDATE_BATCH_MAX_BYTES = 16 * MAX_BODY_BYTES;
|
|
15
|
+
const MAX_BATCH_TOKENS = 100_000;
|
|
16
|
+
const MAX_UNIQUE_SUBMISSIONS = 100_000;
|
|
17
|
+
const MAX_RETAINED_SUBMISSION_BYTES = 256 * 1024 * 1024;
|
|
18
|
+
const CONTROL_MAX_BYTES = UPDATE_BATCH_MAX_BYTES * 2;
|
|
19
|
+
const RECONNECT_RETRY_SECONDS = 10 * 60;
|
|
15
20
|
const CONNECTION_TIMEOUT = 10_000;
|
|
16
21
|
const CONTROL_TIMEOUT = 35_000;
|
|
17
22
|
const CONTROL_PREFIX = "letmeknow-control-";
|
|
@@ -20,6 +25,7 @@ const credentialPattern = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
|
20
25
|
const privateNames = new Set([".env", ".git", ".ssh", "id_rsa", "id_ed25519", "id_ecdsa", "id_dsa"]);
|
|
21
26
|
const privateFilePattern = /^\.env\.|\.(?:key|pem|p12|ppk|p8|sqlite|sqlite3|db|db3)$|-(?:wal|shm|journal)$/i;
|
|
22
27
|
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
28
|
+
const forbiddenUpdateTargets = new Set(["html", "head", "body", "script"]);
|
|
23
29
|
|
|
24
30
|
function getMimeType(filename) {
|
|
25
31
|
const type = lookup(filename);
|
|
@@ -153,6 +159,119 @@ async function readInitialPage(root) {
|
|
|
153
159
|
return (await readFile(target)).toString("utf8");
|
|
154
160
|
}
|
|
155
161
|
|
|
162
|
+
function nodeId(node) {
|
|
163
|
+
return node.attrs?.find(attribute => attribute.name === "id")?.value;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function elementsWithId(node, id, matches = []) {
|
|
167
|
+
if (node.nodeName !== "#text" && node.nodeName !== "#comment" && nodeId(node) === id) matches.push(node);
|
|
168
|
+
for (const child of node.childNodes || []) elementsWithId(child, id, matches);
|
|
169
|
+
return matches;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function containsScript(node) {
|
|
173
|
+
if (node.nodeName === "script") return true;
|
|
174
|
+
if (node.content && containsScript(node.content)) return true;
|
|
175
|
+
return (node.childNodes || []).some(containsScript);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function updateFragment(target, html, context) {
|
|
179
|
+
const fragment = parseFragment(context, html);
|
|
180
|
+
const meaningful = (fragment.childNodes || []).filter(node => node.nodeName !== "#text" || node.value.trim() !== "");
|
|
181
|
+
if (meaningful.length !== 1 || meaningful[0].nodeName === "#comment" || meaningful[0].nodeName?.startsWith("#")) throw new Error(`update for ${target} must contain exactly one root element`);
|
|
182
|
+
const root = meaningful[0];
|
|
183
|
+
if (containsScript(root)) throw new Error(`update for ${target} cannot contain script elements`);
|
|
184
|
+
if (nodeId(root) !== target) throw new Error(`update root ID must be ${target}`);
|
|
185
|
+
return root;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function replaceElement(document, target, html) {
|
|
189
|
+
if (forbiddenUpdateTargets.has(target.nodeName)) throw new Error(`cannot update ${target.nodeName} element`);
|
|
190
|
+
const parent = target.parentNode;
|
|
191
|
+
const index = parent?.childNodes.indexOf(target);
|
|
192
|
+
if (!parent || index === undefined || index < 0) throw new Error("update target has no parent");
|
|
193
|
+
const replacement = updateFragment(nodeId(target), html, parent);
|
|
194
|
+
parent.childNodes[index] = replacement;
|
|
195
|
+
replacement.parentNode = parent;
|
|
196
|
+
target.parentNode = null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function validateUpdates(updates) {
|
|
200
|
+
if (!Array.isArray(updates)) throw new Error("updates must be an array");
|
|
201
|
+
let bytes = 0;
|
|
202
|
+
for (const update of updates) {
|
|
203
|
+
if (!update || typeof update !== "object" || Array.isArray(update)) throw new Error("each update must be an object");
|
|
204
|
+
if (typeof update.target !== "string" || update.target.trim() === "") throw new Error("update target must be a non-empty ID");
|
|
205
|
+
if (typeof update.html !== "string") throw new Error("update html must be text");
|
|
206
|
+
bytes += Buffer.byteLength(update.html, "utf8");
|
|
207
|
+
if (bytes > UPDATE_BATCH_MAX_BYTES) throw new Error("updates are too large");
|
|
208
|
+
}
|
|
209
|
+
return updates;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function applyUpdates(page, updates) {
|
|
213
|
+
validateUpdates(updates);
|
|
214
|
+
const document = parse(page);
|
|
215
|
+
for (const update of updates) {
|
|
216
|
+
const matches = elementsWithId(document, update.target);
|
|
217
|
+
if (matches.length !== 1) throw new Error(`update target ${update.target} must match exactly one element`);
|
|
218
|
+
replaceElement(document, matches[0], update.html);
|
|
219
|
+
}
|
|
220
|
+
const result = serialize(document);
|
|
221
|
+
if (Buffer.byteLength(result, "utf8") > MAX_BODY_BYTES) throw new Error("updated page is too large");
|
|
222
|
+
return result;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function readInput(filename) {
|
|
226
|
+
const chunks = [];
|
|
227
|
+
let length = 0;
|
|
228
|
+
const input = filename === "-" ? process.stdin : createReadStream(filename);
|
|
229
|
+
for await (const chunk of input) {
|
|
230
|
+
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
231
|
+
length += value.byteLength;
|
|
232
|
+
if (length > UPDATE_BATCH_MAX_BYTES) throw new Error("updates are too large");
|
|
233
|
+
chunks.push(value);
|
|
234
|
+
}
|
|
235
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function readUpdatesInput(filename) {
|
|
239
|
+
const manifest = await readInput(filename);
|
|
240
|
+
let value;
|
|
241
|
+
try { value = JSON.parse(manifest); } catch { throw new Error("invalid updates JSON"); }
|
|
242
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || !Array.isArray(value.updates)) throw new Error("updates must be an object with an updates array");
|
|
243
|
+
const base = filename === "-" ? process.cwd() : dirname(resolve(filename));
|
|
244
|
+
const updates = [];
|
|
245
|
+
let bytes = 0;
|
|
246
|
+
for (const item of value.updates) {
|
|
247
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error("each update must be an object");
|
|
248
|
+
if (typeof item.target !== "string" || item.target.trim() === "") throw new Error("update target must be a non-empty ID");
|
|
249
|
+
const hasHtml = Object.prototype.hasOwnProperty.call(item, "html");
|
|
250
|
+
const hasFile = Object.prototype.hasOwnProperty.call(item, "file");
|
|
251
|
+
if (hasHtml === hasFile) throw new Error(`update for ${item.target} must have exactly one of html or file`);
|
|
252
|
+
let html;
|
|
253
|
+
if (hasHtml) {
|
|
254
|
+
if (typeof item.html !== "string") throw new Error(`update html for ${item.target} must be text`);
|
|
255
|
+
html = item.html;
|
|
256
|
+
} else {
|
|
257
|
+
if (typeof item.file !== "string" || item.file === "") throw new Error(`update file for ${item.target} must be a path`);
|
|
258
|
+
const file = resolve(base, item.file);
|
|
259
|
+
const info = await stat(file);
|
|
260
|
+
if (!info.isFile()) throw new Error(`update file for ${item.target} must be a regular file`);
|
|
261
|
+
if (info.size > UPDATE_BATCH_MAX_BYTES - bytes) throw new Error("updates are too large");
|
|
262
|
+
html = (await readFile(file)).toString("utf8");
|
|
263
|
+
}
|
|
264
|
+
bytes += Buffer.byteLength(html, "utf8");
|
|
265
|
+
if (bytes > UPDATE_BATCH_MAX_BYTES) throw new Error("updates are too large");
|
|
266
|
+
updates.push({ target: item.target, html });
|
|
267
|
+
}
|
|
268
|
+
return updates;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function updatesHash(updates) {
|
|
272
|
+
return createHash("sha256").update(JSON.stringify(updates)).digest("hex");
|
|
273
|
+
}
|
|
274
|
+
|
|
156
275
|
async function submission(packet, recordInteraction) {
|
|
157
276
|
const request = requestUrl(packet);
|
|
158
277
|
const method = typeof packet.method === "string" ? packet.method.toUpperCase() : "";
|
|
@@ -170,7 +289,7 @@ async function submission(packet, recordInteraction) {
|
|
|
170
289
|
if (typeof value.action !== "string") throw new Error("action is required");
|
|
171
290
|
if (value.trigger !== null && (typeof value.trigger !== "object" || Array.isArray(value.trigger))) throw new Error("trigger must be an object or null");
|
|
172
291
|
if (!value.values || typeof value.values !== "object" || Array.isArray(value.values)) throw new Error("values are required");
|
|
173
|
-
await recordInteraction({ type: "submit", id: value.id, page_event: value.page_event, form_id: value.form_id, action: value.action, trigger: value.trigger, values: value.values });
|
|
292
|
+
await recordInteraction({ type: "submit", id: value.id, page_event: value.page_event, form_id: value.form_id, action: value.action, trigger: value.trigger, values: value.values }, body.byteLength);
|
|
174
293
|
return response(packet, 202);
|
|
175
294
|
}
|
|
176
295
|
|
|
@@ -245,8 +364,9 @@ async function start(directory) {
|
|
|
245
364
|
let pageEvent = 0;
|
|
246
365
|
let eventNumber = 0;
|
|
247
366
|
const currentPageHash = () => pageHash(page);
|
|
248
|
-
const eventLog = [];
|
|
249
367
|
const browserEvents = [];
|
|
368
|
+
const browserEventBytes = [];
|
|
369
|
+
let retainedSubmissionBytes = 0;
|
|
250
370
|
let committedBrowserCursor = 0;
|
|
251
371
|
const seenEvents = new Set();
|
|
252
372
|
const tokens = new Map();
|
|
@@ -264,24 +384,28 @@ async function start(directory) {
|
|
|
264
384
|
let retryUntil = 0;
|
|
265
385
|
let stopped = false;
|
|
266
386
|
let ready = false;
|
|
387
|
+
let stopSession = () => {};
|
|
267
388
|
|
|
268
389
|
const batch = () => {
|
|
269
390
|
const start = committedBrowserCursor;
|
|
270
|
-
const end = browserEvents.length;
|
|
391
|
+
const end = start + browserEvents.length;
|
|
271
392
|
const key = `${pageEvent}:${start}:${end}`;
|
|
272
|
-
|
|
273
|
-
if (
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
393
|
+
let token = pendingTokens.get(key)?.token;
|
|
394
|
+
if (!token) {
|
|
395
|
+
if (tokens.size >= MAX_BATCH_TOKENS) {
|
|
396
|
+
stopSession();
|
|
397
|
+
throw new Error("session batch token limit exceeded");
|
|
398
|
+
}
|
|
399
|
+
token = randomUUID();
|
|
400
|
+
tokens.set(token, { start, end, page_event: pageEvent, status: "pending", key, updates_hash: null });
|
|
401
|
+
pendingTokens.set(key, { token });
|
|
402
|
+
}
|
|
403
|
+
return { ok: true, type: "batch", token, frontier: eventNumber, page_event: pageEvent, page_hash: currentPageHash(), events: browserEvents.slice() };
|
|
280
404
|
};
|
|
281
405
|
|
|
282
406
|
const notifyPullWaiters = () => {
|
|
283
407
|
for (const waiter of [...pullWaiters]) {
|
|
284
|
-
if (browserEvents.length ===
|
|
408
|
+
if (browserEvents.length === 0) continue;
|
|
285
409
|
pullWaiters.delete(waiter);
|
|
286
410
|
clearTimeout(waiter.timer);
|
|
287
411
|
waiter.resolve(batch());
|
|
@@ -289,45 +413,60 @@ async function start(directory) {
|
|
|
289
413
|
};
|
|
290
414
|
|
|
291
415
|
const pull = waitSeconds => {
|
|
292
|
-
if (browserEvents.length >
|
|
416
|
+
if (browserEvents.length > 0 || waitSeconds <= 0) return Promise.resolve(batch());
|
|
293
417
|
return new Promise(resolve => {
|
|
294
418
|
const waiter = { resolve, timer: setTimeout(() => { pullWaiters.delete(waiter); resolve(batch()); }, waitSeconds * 1_000) };
|
|
295
419
|
pullWaiters.add(waiter);
|
|
296
420
|
});
|
|
297
421
|
};
|
|
298
422
|
|
|
299
|
-
const commit = async (token,
|
|
423
|
+
const commit = async (token, requestedUpdates) => {
|
|
300
424
|
const record = tokens.get(token);
|
|
301
425
|
if (!record) return { ok: false, error: "unknown batch token" };
|
|
302
|
-
const
|
|
303
|
-
const
|
|
426
|
+
const updates = validateUpdates(requestedUpdates);
|
|
427
|
+
const requestedUpdatesHash = updatesHash(updates);
|
|
304
428
|
if (record.status !== "pending") {
|
|
305
|
-
if (record.
|
|
306
|
-
return { ok: false, error: "batch was already committed with a different
|
|
429
|
+
if (record.updates_hash === requestedUpdatesHash) return record.result;
|
|
430
|
+
return { ok: false, error: "batch was already committed with a different updates payload" };
|
|
307
431
|
}
|
|
308
|
-
record.has_page = hasPage;
|
|
309
|
-
record.requested_page_hash = requestedPageHash;
|
|
310
432
|
if (record.page_event !== pageEvent || record.start !== committedBrowserCursor) {
|
|
433
|
+
record.updates_hash = requestedUpdatesHash;
|
|
311
434
|
pendingTokens.delete(record.key);
|
|
312
435
|
record.status = "failed";
|
|
313
436
|
record.result = { ok: false, error: "batch is based on an old page or browser cursor", frontier: eventNumber, page_event: pageEvent, page_hash: currentPageHash() };
|
|
314
437
|
return record.result;
|
|
315
438
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
439
|
+
let nextPage = page;
|
|
440
|
+
try { if (updates.length) nextPage = applyUpdates(page, updates); } catch (cause) {
|
|
441
|
+
throw new Error(cause instanceof Error ? cause.message : "invalid page updates");
|
|
442
|
+
}
|
|
443
|
+
const committedCount = record.end - record.start;
|
|
444
|
+
const committedEvents = browserEvents.slice(0, committedCount).map(event => event.id);
|
|
445
|
+
retainedSubmissionBytes -= browserEventBytes.slice(0, committedCount).reduce((total, bytes) => total + bytes, 0);
|
|
446
|
+
const updateEvents = [];
|
|
447
|
+
for (const update of updates) {
|
|
321
448
|
eventNumber += 1;
|
|
322
|
-
|
|
323
|
-
update = { type: "update_ui", event_number: pageEvent, html: page };
|
|
324
|
-
eventLog.push(update);
|
|
449
|
+
updateEvents.push({ type: "update_ui", event_number: eventNumber, target: update.target, html: update.html });
|
|
325
450
|
}
|
|
451
|
+
page = nextPage;
|
|
452
|
+
pageEvent = updateEvents.at(-1)?.event_number ?? pageEvent;
|
|
453
|
+
browserEvents.splice(0, committedCount);
|
|
454
|
+
browserEventBytes.splice(0, committedCount);
|
|
326
455
|
committedBrowserCursor = record.end;
|
|
327
456
|
pendingTokens.delete(record.key);
|
|
328
457
|
record.status = "committed";
|
|
329
|
-
record.
|
|
330
|
-
|
|
458
|
+
record.updates_hash = requestedUpdatesHash;
|
|
459
|
+
record.result = {
|
|
460
|
+
ok: true,
|
|
461
|
+
type: "committed",
|
|
462
|
+
token,
|
|
463
|
+
frontier: eventNumber,
|
|
464
|
+
page_event: pageEvent,
|
|
465
|
+
page_hash: currentPageHash(),
|
|
466
|
+
events: committedEvents,
|
|
467
|
+
updates: updateEvents.map(({ event_number, target }) => ({ event_number, target }))
|
|
468
|
+
};
|
|
469
|
+
for (const update of updateEvents) send(update);
|
|
331
470
|
return record.result;
|
|
332
471
|
};
|
|
333
472
|
|
|
@@ -337,8 +476,7 @@ async function start(directory) {
|
|
|
337
476
|
if (request.type === "show") return { ok: true, type: "page", page_event: pageEvent, page_hash: currentPageHash(), html: page };
|
|
338
477
|
if (request.type === "push") {
|
|
339
478
|
if (typeof request.token !== "string") return { ok: false, error: "batch token is required" };
|
|
340
|
-
|
|
341
|
-
return commit(request.token, request.page);
|
|
479
|
+
return commit(request.token, request.updates === undefined ? [] : request.updates);
|
|
342
480
|
}
|
|
343
481
|
return { ok: false, error: "unknown control request" };
|
|
344
482
|
};
|
|
@@ -351,7 +489,7 @@ async function start(directory) {
|
|
|
351
489
|
let handled = false;
|
|
352
490
|
connection.on("data", async chunk => {
|
|
353
491
|
input += chunk;
|
|
354
|
-
if (input
|
|
492
|
+
if (Buffer.byteLength(input, "utf8") > CONTROL_MAX_BYTES || handled) return;
|
|
355
493
|
const newline = input.indexOf("\n");
|
|
356
494
|
if (newline < 0) return;
|
|
357
495
|
handled = true;
|
|
@@ -374,13 +512,18 @@ async function start(directory) {
|
|
|
374
512
|
});
|
|
375
513
|
}).catch(cause => { throw new Error(`cannot start local control channel: ${cause.message}`); });
|
|
376
514
|
|
|
377
|
-
const recordInteraction = event => mutate(async () => {
|
|
515
|
+
const recordInteraction = (event, bytes) => mutate(async () => {
|
|
378
516
|
if (seenEvents.has(event.id)) return;
|
|
517
|
+
if (seenEvents.size >= MAX_UNIQUE_SUBMISSIONS || retainedSubmissionBytes + bytes > MAX_RETAINED_SUBMISSION_BYTES) {
|
|
518
|
+
stopSession();
|
|
519
|
+
throw new Error("session submission limit exceeded");
|
|
520
|
+
}
|
|
379
521
|
seenEvents.add(event.id);
|
|
522
|
+
retainedSubmissionBytes += bytes;
|
|
380
523
|
eventNumber += 1;
|
|
381
524
|
const numbered = { ...event, event_number: eventNumber };
|
|
382
|
-
eventLog.push(numbered);
|
|
383
525
|
browserEvents.push(numbered);
|
|
526
|
+
browserEventBytes.push(bytes);
|
|
384
527
|
notifyPullWaiters();
|
|
385
528
|
});
|
|
386
529
|
|
|
@@ -396,6 +539,7 @@ async function start(directory) {
|
|
|
396
539
|
await unlink(controlPath(root)).catch(() => {});
|
|
397
540
|
process.exit(code);
|
|
398
541
|
};
|
|
542
|
+
stopSession = () => { void stop(1); };
|
|
399
543
|
process.once("SIGINT", () => void stop(0));
|
|
400
544
|
process.once("SIGTERM", () => void stop(0));
|
|
401
545
|
|
|
@@ -453,7 +597,7 @@ async function start(directory) {
|
|
|
453
597
|
send = () => false;
|
|
454
598
|
socket = undefined;
|
|
455
599
|
if (!credential || !sessionUrl) return void stop(1);
|
|
456
|
-
if (!retryUntil) retryUntil = Date.now() +
|
|
600
|
+
if (!retryUntil) retryUntil = Date.now() + RECONNECT_RETRY_SECONDS * 1_000;
|
|
457
601
|
retry();
|
|
458
602
|
});
|
|
459
603
|
};
|
|
@@ -483,7 +627,7 @@ function validSessionUrl(value) {
|
|
|
483
627
|
}
|
|
484
628
|
|
|
485
629
|
function usage() {
|
|
486
|
-
return "Usage:\n npx letmeknow-cli serve <directory>\n npx letmeknow-cli show <directory>\n npx letmeknow-cli pull <directory> [--wait <seconds>]\n npx letmeknow-cli push <directory> --batch <token> [--
|
|
630
|
+
return "Usage:\n npx letmeknow-cli serve <directory>\n npx letmeknow-cli show <directory>\n npx letmeknow-cli pull <directory> [--wait <seconds>]\n npx letmeknow-cli push <directory> --batch <token> [--updates <file|->]\n";
|
|
487
631
|
}
|
|
488
632
|
|
|
489
633
|
function commandArgs() {
|
|
@@ -496,7 +640,7 @@ function commandArgs() {
|
|
|
496
640
|
help: { type: "boolean", short: "h" },
|
|
497
641
|
wait: { type: "string" },
|
|
498
642
|
batch: { type: "string" },
|
|
499
|
-
|
|
643
|
+
updates: { type: "string" }
|
|
500
644
|
},
|
|
501
645
|
allowPositionals: true,
|
|
502
646
|
strict: true
|
|
@@ -505,14 +649,14 @@ function commandArgs() {
|
|
|
505
649
|
throw new Error(cause instanceof Error ? cause.message : "invalid arguments");
|
|
506
650
|
}
|
|
507
651
|
if (parsed.values.skill || parsed.values.help) {
|
|
508
|
-
if (parsed.positionals.length || parsed.values.wait !== undefined || parsed.values.batch !== undefined || parsed.values.
|
|
652
|
+
if (parsed.positionals.length || parsed.values.wait !== undefined || parsed.values.batch !== undefined || parsed.values.updates !== undefined) throw new Error(usage());
|
|
509
653
|
return { command: parsed.values.skill ? "skill" : "help" };
|
|
510
654
|
}
|
|
511
655
|
const [command, directory, ...extra] = parsed.positionals;
|
|
512
656
|
if (!command || !directory || extra.length) throw new Error(usage());
|
|
513
657
|
if (!["serve", "show", "pull", "push"].includes(command)) throw new Error(usage());
|
|
514
|
-
if ((command === "serve" || command === "show") && (parsed.values.wait !== undefined || parsed.values.batch !== undefined || parsed.values.
|
|
515
|
-
if (command === "pull" && (parsed.values.batch !== undefined || parsed.values.
|
|
658
|
+
if ((command === "serve" || command === "show") && (parsed.values.wait !== undefined || parsed.values.batch !== undefined || parsed.values.updates !== undefined)) throw new Error(usage());
|
|
659
|
+
if (command === "pull" && (parsed.values.batch !== undefined || parsed.values.updates !== undefined)) throw new Error(usage());
|
|
516
660
|
if (command === "push" && parsed.values.wait !== undefined) throw new Error(usage());
|
|
517
661
|
let wait = 0;
|
|
518
662
|
if (parsed.values.wait !== undefined) {
|
|
@@ -520,26 +664,9 @@ function commandArgs() {
|
|
|
520
664
|
if (!Number.isFinite(wait) || wait < 0) throw new Error("--wait must be a non-negative number");
|
|
521
665
|
}
|
|
522
666
|
if (command === "push" && typeof parsed.values.batch !== "string") throw new Error("--batch is required");
|
|
523
|
-
return { command, directory, wait, token: parsed.values.batch,
|
|
667
|
+
return { command, directory, wait, token: parsed.values.batch, updates: parsed.values.updates };
|
|
524
668
|
}
|
|
525
669
|
|
|
526
|
-
async function readPageInput(filename) {
|
|
527
|
-
const chunks = [];
|
|
528
|
-
let length = 0;
|
|
529
|
-
if (filename === "-") {
|
|
530
|
-
for await (const chunk of process.stdin) {
|
|
531
|
-
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
532
|
-
length += value.byteLength;
|
|
533
|
-
if (length > MAX_BODY_BYTES) throw new Error("page is too large");
|
|
534
|
-
chunks.push(value);
|
|
535
|
-
}
|
|
536
|
-
} else {
|
|
537
|
-
const body = await readFile(resolve(filename));
|
|
538
|
-
if (body.byteLength > MAX_BODY_BYTES) throw new Error("page is too large");
|
|
539
|
-
chunks.push(body);
|
|
540
|
-
}
|
|
541
|
-
return Buffer.concat(chunks).toString("utf8");
|
|
542
|
-
}
|
|
543
670
|
|
|
544
671
|
let command;
|
|
545
672
|
try {
|
|
@@ -553,8 +680,7 @@ try {
|
|
|
553
680
|
if (command.command === "pull") packet = { type: "pull", wait_seconds: command.wait };
|
|
554
681
|
else if (command.command === "show") packet = { type: "show" };
|
|
555
682
|
else {
|
|
556
|
-
packet = { type: "push", token: command.token };
|
|
557
|
-
if (command.page !== undefined) packet.page = await readPageInput(command.page);
|
|
683
|
+
packet = { type: "push", token: command.token, updates: command.updates === undefined ? [] : await readUpdatesInput(command.updates) };
|
|
558
684
|
}
|
|
559
685
|
const result = await connectControl(root, packet);
|
|
560
686
|
if (command.command === "show" && result.ok) process.stdout.write(result.html);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "letmeknow-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "A live agent-authored page with structured browser feedback.",
|
|
5
5
|
"files": [
|
|
6
6
|
"bin",
|
|
@@ -24,13 +24,13 @@
|
|
|
24
24
|
"@cloudflare/vitest-plugin": "^1.1.0",
|
|
25
25
|
"@cloudflare/workers-types": "^5.20260825.1",
|
|
26
26
|
"@types/node": "^22.15.17",
|
|
27
|
-
"idiomorph": "0.7.4",
|
|
28
27
|
"typescript": "^5.9.2",
|
|
29
28
|
"vitest": "^4.1.11",
|
|
30
29
|
"wrangler": "^4.126.0",
|
|
31
30
|
"ws": "^8.21.3"
|
|
32
31
|
},
|
|
33
32
|
"dependencies": {
|
|
34
|
-
"mrmime": "^2.0.1"
|
|
33
|
+
"mrmime": "^2.0.1",
|
|
34
|
+
"parse5": "^8.0.1"
|
|
35
35
|
}
|
|
36
36
|
}
|