letmeknow-cli 0.6.0 → 0.7.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 +75 -44
- package/SKILL.md +63 -38
- package/bin/letmeknow.js +138 -191
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,76 +1,93 @@
|
|
|
1
1
|
# LetMeKnow
|
|
2
2
|
|
|
3
|
-
LetMeKnow gives an agent a temporary public browser surface and structured human feedback. The agent
|
|
3
|
+
LetMeKnow gives an agent a temporary public browser surface and structured human feedback. The agent authors ordinary HTML and static assets; a running CLI serves them and accepts page updates over a single ordered event stream.
|
|
4
4
|
|
|
5
5
|
## Start a session
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Create a directory containing the public files, including an initial `index.html`, then run:
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
10
|
npx letmeknow-cli serve ./preview
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
The
|
|
13
|
+
`serve` reads `index.html` once as the canonical dynamic page and serves it at `/`. Other files in the directory—such as CSS, JavaScript, images, and data—are served live as static assets. The CLI prints one JSON line containing the public bearer URL:
|
|
14
14
|
|
|
15
15
|
```json
|
|
16
|
-
{"type":"ready","url":"https://0123456789abcdef0123.letmeknow.dev/","
|
|
16
|
+
{"type":"ready","url":"https://0123456789abcdef0123.letmeknow.dev/","page_event":0,"page_hash":"…"}
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
Anyone with the URL can view the
|
|
19
|
+
Give the URL to the human. Anyone with the URL can view the page and submit its forms. Canonical page state and the event stream live in memory while `serve` runs; they do not survive a stopped session. The CLI connects outbound and does not listen on a network port.
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
The agent’s files are never modified by `serve`.
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
## Agent workflow
|
|
24
|
+
|
|
25
|
+
Pull browser events, update the page, and push the resulting page:
|
|
24
26
|
|
|
25
27
|
```bash
|
|
26
|
-
npx letmeknow-cli pull ./preview --wait 30
|
|
27
|
-
|
|
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
|
|
28
32
|
```
|
|
29
33
|
|
|
30
|
-
`pull` returns
|
|
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.
|
|
31
35
|
|
|
32
36
|
```json
|
|
33
|
-
{
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
51
60
|
```
|
|
52
61
|
|
|
53
|
-
|
|
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
|
|
54
65
|
|
|
55
|
-
|
|
66
|
+
`show` writes the canonical dynamic HTML held by `serve` to standard output:
|
|
56
67
|
|
|
57
68
|
```bash
|
|
58
|
-
npx letmeknow-cli
|
|
69
|
+
npx letmeknow-cli show ./preview > current.html
|
|
59
70
|
```
|
|
60
71
|
|
|
61
|
-
|
|
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.
|
|
62
73
|
|
|
63
|
-
The
|
|
74
|
+
## The event stream
|
|
64
75
|
|
|
65
|
-
|
|
76
|
+
Browser submissions and CLI page updates share one ordered, in-memory event stream:
|
|
77
|
+
|
|
78
|
+
```text
|
|
79
|
+
submit browser
|
|
80
|
+
submit browser
|
|
81
|
+
update_ui CLI: complete desired HTML page
|
|
82
|
+
```
|
|
66
83
|
|
|
67
|
-
|
|
84
|
+
Browser submission events are delivered to the agent through `pull`. Page-update events are broadcast to all connected browsers. There is no per-browser audience or dynamic view system in the initial model.
|
|
68
85
|
|
|
69
|
-
|
|
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.
|
|
70
87
|
|
|
71
88
|
## Forms
|
|
72
89
|
|
|
73
|
-
Use
|
|
90
|
+
Use ordinary HTML forms with stable IDs and meaningful field names:
|
|
74
91
|
|
|
75
92
|
```html
|
|
76
93
|
<form id="decision" action="/decide" method="post">
|
|
@@ -80,19 +97,33 @@ Use native same-origin GET or POST forms:
|
|
|
80
97
|
</form>
|
|
81
98
|
```
|
|
82
99
|
|
|
83
|
-
|
|
100
|
+
The runtime intercepts native form submission and turns it into a durable JSON `submit` event. It assigns an opaque UUID, stores the event in the browser’s local outbox before delivery, retries after connection failures, and reuses the UUID on retry. The CLI deduplicates repeated delivery of the same event. Distinct submissions remain distinct, including rapid repeated clicks.
|
|
84
101
|
|
|
85
|
-
|
|
102
|
+
Form values are untrusted input and should be validated by the agent. File uploads are not supported.
|
|
86
103
|
|
|
87
|
-
|
|
104
|
+
## Authoring the dynamic page
|
|
88
105
|
|
|
89
|
-
|
|
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.
|
|
90
107
|
|
|
91
|
-
|
|
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>
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Agent-authored JavaScript should be loaded by the initial page as a static asset and use delegated event listeners. Existing scripts remain active across morphs, but scripts added or changed by a pushed page are not executed in connected browsers; keep script references fixed for the session.
|
|
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.
|
|
92
119
|
|
|
93
|
-
|
|
120
|
+
## Static assets
|
|
121
|
+
|
|
122
|
+
Static assets are read live from the directory, independently of the canonical dynamic page. An agent can change CSS, JavaScript, images, and other assets without a page push. Finish an asset before pushing HTML that references it, write files atomically, and use versioned filenames or cache-busting URLs when cached assets must change with the page.
|
|
123
|
+
|
|
124
|
+
## Security
|
|
94
125
|
|
|
95
|
-
The
|
|
126
|
+
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.
|
|
96
127
|
|
|
97
128
|
## Development
|
|
98
129
|
|
package/SKILL.md
CHANGED
|
@@ -1,55 +1,87 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: letmeknow
|
|
3
|
-
description:
|
|
3
|
+
description: Serve a temporary live HTML page, collect structured human feedback, and push agent-authored page updates.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# LetMeKnow
|
|
7
7
|
|
|
8
|
-
Use LetMeKnow when a human should inspect or interact with an agent-
|
|
8
|
+
Use LetMeKnow when a human should inspect or interact with an agent-authored page, report, dashboard, approval, quiz, table, or prototype.
|
|
9
9
|
|
|
10
10
|
## Start
|
|
11
11
|
|
|
12
|
-
Create a
|
|
12
|
+
Create a directory containing the public files and an initial `index.html`:
|
|
13
13
|
|
|
14
14
|
```bash
|
|
15
15
|
npx letmeknow-cli serve ./preview
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
`serve` reads `index.html` once as the canonical dynamic document and serves it at `/`. It serves the other files in the directory live as static assets. The first stdout JSON line contains the public bearer URL:
|
|
19
19
|
|
|
20
20
|
```json
|
|
21
|
-
{"type":"ready","url":"https://0123456789abcdef0123.letmeknow.dev/","
|
|
21
|
+
{"type":"ready","url":"https://0123456789abcdef0123.letmeknow.dev/","page_event":0,"page_hash":"…"}
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
Give the URL to the human. Anyone with
|
|
24
|
+
Give the URL to the human. Anyone with the URL can view the page and submit its forms. The CLI connects outbound and opens no network port. Canonical page state and events are temporary in-memory session state; they end when `serve` stops. `serve` never modifies agent files.
|
|
25
25
|
|
|
26
26
|
## Agent loop
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
Pull browser events, update the desired page, and push it:
|
|
29
29
|
|
|
30
30
|
```bash
|
|
31
|
-
npx letmeknow-cli pull ./preview --wait 30
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
batch=$(npx letmeknow-cli pull ./preview --wait 30)
|
|
32
|
+
token=$(printf '%s\n' "$batch" | jq -r .token)
|
|
33
|
+
# inspect the events and edit index.html
|
|
34
|
+
npx letmeknow-cli push ./preview --batch "$token" --page index.html
|
|
34
35
|
```
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
Commands:
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
```text
|
|
40
|
+
serve <dir>
|
|
41
|
+
show <dir>
|
|
42
|
+
pull <dir> [--wait seconds]
|
|
43
|
+
push <dir> --batch TOKEN [--page FILE|-]
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`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.
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{"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"}}]}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
An event's `page_event` identifies the page displayed when the browser submitted it. Compare it with the batch's current `page_event` before applying old input to the current page.
|
|
53
|
+
|
|
54
|
+
`push --page FILE` atomically commits the events represented by the token, makes FILE the complete desired dynamic document, appends one page-update event to the global event stream, and broadcasts it to all connected browsers. Browsers morph the page without navigation.
|
|
39
55
|
|
|
40
56
|
```bash
|
|
41
|
-
npx letmeknow-cli
|
|
57
|
+
npx letmeknow-cli push ./preview --batch "$token" --page index.html
|
|
42
58
|
```
|
|
43
59
|
|
|
44
|
-
|
|
60
|
+
A push without `--page` only commits the pulled browser events:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
npx letmeknow-cli push ./preview --batch "$token"
|
|
64
|
+
```
|
|
45
65
|
|
|
46
|
-
|
|
66
|
+
Use `--page -` for standard input:
|
|
47
67
|
|
|
48
|
-
|
|
68
|
+
```bash
|
|
69
|
+
npx letmeknow-cli push ./preview --batch "$token" --page - < updated.html
|
|
70
|
+
```
|
|
49
71
|
|
|
50
|
-
|
|
72
|
+
The page push is all-or-nothing. Invalid input or an invalid token commits nothing. The CLI assigns one global order to each browser submission and each page-update event.
|
|
51
73
|
|
|
52
|
-
|
|
74
|
+
`show` retrieves the canonical dynamic HTML held by `serve` without changing the event stream:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
npx letmeknow-cli show ./preview > current.html
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The public URL is the visual preview. `show` returns canonical HTML, not a browser’s local DOM state such as focus, open disclosures, unsent input, scroll position, or JavaScript state.
|
|
81
|
+
|
|
82
|
+
## Forms
|
|
83
|
+
|
|
84
|
+
Use native forms with stable IDs and meaningful names:
|
|
53
85
|
|
|
54
86
|
```html
|
|
55
87
|
<form id="review" action="/review" method="post">
|
|
@@ -59,31 +91,24 @@ Use native same-origin GET or POST forms:
|
|
|
59
91
|
</form>
|
|
60
92
|
```
|
|
61
93
|
|
|
62
|
-
The browser
|
|
94
|
+
The browser runtime serializes native form submissions as JSON `submit` events. It assigns an opaque ID, stores each event in a local durable outbox before sending it, retries after connection failures, and reuses the ID on retry. The CLI deduplicates repeated delivery. Ten intentional rapid clicks should produce ten distinct events. File uploads are not supported.
|
|
63
95
|
|
|
64
|
-
|
|
96
|
+
Treat pulled values as untrusted input. Validate them and escape them before putting them into HTML.
|
|
65
97
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
"context": {
|
|
74
|
-
"based_on": "…",
|
|
75
|
-
"current": "…",
|
|
76
|
-
"relationship": "current"
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
```
|
|
98
|
+
## Dynamic page rules
|
|
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.
|
|
101
|
+
|
|
102
|
+
Give elements stable unique IDs. Load agent-authored JavaScript from the initial page as a static asset and use delegated listeners. Existing scripts remain active across morphs, but scripts added or changed by a pushed page are not executed in connected browsers; keep script references fixed for the session.
|
|
103
|
+
|
|
104
|
+
The CLI owns 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. Do not have browser JavaScript and pushed HTML otherwise mutate the same state; a later morph may replace browser-created changes.
|
|
80
105
|
|
|
81
|
-
|
|
106
|
+
Page updates are shared with all connected browsers. There is no dynamic view or per-browser update system. Use ordinary static links and files when the application needs more persistent pages.
|
|
82
107
|
|
|
83
|
-
|
|
108
|
+
## Static assets
|
|
84
109
|
|
|
85
|
-
|
|
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 new page.
|
|
86
111
|
|
|
87
112
|
## Stop
|
|
88
113
|
|
|
89
|
-
Send `SIGINT` or `SIGTERM` to `serve`.
|
|
114
|
+
Send `SIGINT` or `SIGTERM` to `serve`. The temporary session ends when the process stops.
|
package/bin/letmeknow.js
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { constants, existsSync, readFileSync, statSync, writeSync } from "node:fs";
|
|
4
|
-
import { chmod,
|
|
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
|
-
import { dirname,
|
|
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
10
|
import { lookup } from "mrmime";
|
|
11
11
|
|
|
12
12
|
const MAX_BODY_BYTES = 1024 * 1024;
|
|
13
|
+
const CONTROL_MAX_BYTES = MAX_BODY_BYTES * 2 + 16 * 1024;
|
|
13
14
|
const GRACE_SECONDS = 10 * 60;
|
|
14
15
|
const CONNECTION_TIMEOUT = 10_000;
|
|
15
16
|
const CONTROL_TIMEOUT = 35_000;
|
|
16
|
-
const MAX_RETRY_DELAY = 5_000;
|
|
17
17
|
const CONTROL_PREFIX = "letmeknow-control-";
|
|
18
|
-
const SNAPSHOT_PREFIX = "letmeknow-snapshot-";
|
|
19
18
|
const CONTROL_URL = "https://letmeknow.dev";
|
|
20
19
|
const credentialPattern = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
21
20
|
const privateNames = new Set([".env", ".git", ".ssh", "id_rsa", "id_ed25519", "id_ecdsa", "id_dsa"]);
|
|
22
21
|
const privateFilePattern = /^\.env\.|\.(?:key|pem|p12|ppk|p8|sqlite|sqlite3|db|db3)$|-(?:wal|shm|journal)$/i;
|
|
22
|
+
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
23
23
|
|
|
24
24
|
function getMimeType(filename) {
|
|
25
25
|
const type = lookup(filename);
|
|
@@ -34,17 +34,6 @@ function header(packet, name) {
|
|
|
34
34
|
return typeof entry?.[1] === "string" && entry[1] !== "" ? entry[1] : null;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
function encodedHeader(packet, name) {
|
|
38
|
-
const value = header(packet, name);
|
|
39
|
-
if (value === null) return null;
|
|
40
|
-
try { return decodeURIComponent(value); } catch { return null; }
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function addValue(values, name, value) {
|
|
44
|
-
if (Object.prototype.hasOwnProperty.call(values, name)) values[name] = Array.isArray(values[name]) ? [...values[name], value] : [values[name], value];
|
|
45
|
-
else values[name] = value;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
37
|
function response(packet, status, body = Buffer.alloc(0), headers = {}) {
|
|
49
38
|
if (body.byteLength > MAX_BODY_BYTES) return response(packet, 413, Buffer.from("response body is too large"), { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" });
|
|
50
39
|
const outputHeaders = { "Cache-Control": "no-store", ...headers };
|
|
@@ -93,12 +82,15 @@ function requestUrl(packet) {
|
|
|
93
82
|
return { pathname, encodedPathname: url.pathname, search: url.search };
|
|
94
83
|
}
|
|
95
84
|
|
|
96
|
-
async function staticResponse(root, packet,
|
|
97
|
-
const published = (status, body = Buffer.alloc(0), headers = {}) => response(packet, status, body,
|
|
85
|
+
async function staticResponse(root, packet, page, pageEvent) {
|
|
86
|
+
const published = (status, body = Buffer.alloc(0), headers = {}) => response(packet, status, body, headers);
|
|
98
87
|
const method = typeof packet.method === "string" ? packet.method.toUpperCase() : "";
|
|
99
88
|
if (method !== "GET" && method !== "HEAD") return errorResponse(packet, 405, "method not allowed");
|
|
100
89
|
let request;
|
|
101
90
|
try { request = requestUrl(packet); } catch { return errorResponse(packet, 400, "bad request"); }
|
|
91
|
+
if (request.pathname === "/" || request.pathname === "/index.html") {
|
|
92
|
+
return published(200, Buffer.from(page), { "Content-Type": "text/html; charset=utf-8", "X-LetMeKnow-Page-Event": String(pageEvent) });
|
|
93
|
+
}
|
|
102
94
|
if (deniedPath(request.pathname)) return errorResponse(packet, 403, "forbidden");
|
|
103
95
|
const candidate = resolve(root, "." + request.pathname);
|
|
104
96
|
if (!inside(root, candidate)) return errorResponse(packet, 403, "forbidden");
|
|
@@ -151,69 +143,44 @@ async function staticResponse(root, packet, workspaceId) {
|
|
|
151
143
|
}
|
|
152
144
|
}
|
|
153
145
|
|
|
154
|
-
async function
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
for (const [name, value] of formData) {
|
|
163
|
-
if (typeof value === "string") {
|
|
164
|
-
addValue(values, name, value);
|
|
165
|
-
continue;
|
|
166
|
-
}
|
|
167
|
-
if (value.name === "") continue;
|
|
168
|
-
const bytes = Buffer.from(await value.arrayBuffer());
|
|
169
|
-
const path = join(await getAttachmentInbox(), randomUUID());
|
|
170
|
-
await writeFile(path, bytes, { mode: 0o600, flag: "wx" });
|
|
171
|
-
attachments.push({ field: name, name: value.name, type: value.type, size: bytes.byteLength, path });
|
|
172
|
-
}
|
|
173
|
-
return { values, attachments };
|
|
146
|
+
async function readInitialPage(root) {
|
|
147
|
+
const candidate = join(root, "index.html");
|
|
148
|
+
const target = await safeRealpath(root, candidate);
|
|
149
|
+
if (target === null || target === undefined || deniedPath("/" + relative(root, target).split(sep).join("/"))) throw new Error("index.html is required");
|
|
150
|
+
const info = await stat(target);
|
|
151
|
+
if (!info.isFile()) throw new Error("index.html must be a file");
|
|
152
|
+
if (info.size > MAX_BODY_BYTES) throw new Error("index.html is too large");
|
|
153
|
+
return (await readFile(target)).toString("utf8");
|
|
174
154
|
}
|
|
175
155
|
|
|
176
|
-
async function submission(packet,
|
|
177
|
-
const
|
|
156
|
+
async function submission(packet, recordInteraction) {
|
|
157
|
+
const request = requestUrl(packet);
|
|
178
158
|
const method = typeof packet.method === "string" ? packet.method.toUpperCase() : "";
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if (
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
} else throw new Error("unsupported submission encoding");
|
|
195
|
-
} else throw new Error("unsupported submission method");
|
|
196
|
-
const event = {
|
|
197
|
-
type: "submit",
|
|
198
|
-
id: encodedHeader(packet, "x-letmeknow-id") || randomUUID(),
|
|
199
|
-
method,
|
|
200
|
-
action: encodedHeader(packet, "x-letmeknow-action") || url.pathname,
|
|
201
|
-
form_id: encodedHeader(packet, "x-letmeknow-form-id"),
|
|
202
|
-
trigger: { id: encodedHeader(packet, "x-letmeknow-trigger-id"), name: encodedHeader(packet, "x-letmeknow-trigger-name"), value: encodedHeader(packet, "x-letmeknow-trigger-value") },
|
|
203
|
-
values
|
|
204
|
-
};
|
|
205
|
-
const basedOn = encodedHeader(packet, "x-letmeknow-based-on");
|
|
206
|
-
if (basedOn !== null) event.based_on = basedOn;
|
|
207
|
-
if (attachments?.length) event.attachments = attachments;
|
|
208
|
-
await recordInteraction(event);
|
|
159
|
+
if (method !== "POST" || request.pathname !== "/_letmeknow/submit") throw new Error("invalid submission endpoint");
|
|
160
|
+
const contentType = header(packet, "content-type")?.split(";", 1)[0].trim().toLowerCase();
|
|
161
|
+
if (contentType !== "application/json") throw new Error("JSON submission is required");
|
|
162
|
+
const body = Buffer.from(typeof packet.body === "string" ? packet.body : "", "base64");
|
|
163
|
+
if (body.byteLength > MAX_BODY_BYTES) throw new Error("submission is too large");
|
|
164
|
+
let value;
|
|
165
|
+
try { value = JSON.parse(body.toString("utf8")); } catch { throw new Error("invalid submission JSON"); }
|
|
166
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("submission must be an object");
|
|
167
|
+
if (typeof value.id !== "string" || !uuidPattern.test(value.id)) throw new Error("submission id must be a UUID");
|
|
168
|
+
if (!Number.isSafeInteger(value.page_event) || value.page_event < 0) throw new Error("page_event must be a non-negative integer");
|
|
169
|
+
if (value.form_id !== null && typeof value.form_id !== "string") throw new Error("form_id must be text or null");
|
|
170
|
+
if (typeof value.action !== "string") throw new Error("action is required");
|
|
171
|
+
if (value.trigger !== null && (typeof value.trigger !== "object" || Array.isArray(value.trigger))) throw new Error("trigger must be an object or null");
|
|
172
|
+
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 });
|
|
209
174
|
return response(packet, 202);
|
|
210
175
|
}
|
|
211
176
|
|
|
212
|
-
async function handleRequest(root,
|
|
213
|
-
|
|
214
|
-
|
|
177
|
+
async function handleRequest(root, page, pageEvent, packet, recordInteraction) {
|
|
178
|
+
let request;
|
|
179
|
+
try { request = requestUrl(packet); } catch { return errorResponse(packet, 400, "bad request"); }
|
|
180
|
+
if (request.pathname === "/_letmeknow/submit") {
|
|
181
|
+
try { return await submission(packet, recordInteraction); } catch (cause) { return errorResponse(packet, cause?.message === "submission is too large" ? 413 : 400, cause instanceof Error ? cause.message : "invalid submission"); }
|
|
215
182
|
}
|
|
216
|
-
return staticResponse(root, packet,
|
|
183
|
+
return staticResponse(root, packet, page, pageEvent);
|
|
217
184
|
}
|
|
218
185
|
|
|
219
186
|
function options(directory) {
|
|
@@ -227,40 +194,6 @@ function controlPath(root) {
|
|
|
227
194
|
return join(tmpdir(), `${CONTROL_PREFIX}${key}.sock`);
|
|
228
195
|
}
|
|
229
196
|
|
|
230
|
-
async function copyDirectory(source, target, root, visited = new Set()) {
|
|
231
|
-
const sourceReal = await realpath(source);
|
|
232
|
-
if (visited.has(sourceReal)) return;
|
|
233
|
-
visited.add(sourceReal);
|
|
234
|
-
await mkdir(target, { recursive: true });
|
|
235
|
-
for (const entry of await readdir(sourceReal, { withFileTypes: true })) {
|
|
236
|
-
const candidate = join(sourceReal, entry.name);
|
|
237
|
-
const pathname = "/" + relative(root, candidate).split(sep).join("/");
|
|
238
|
-
if (deniedPath(pathname)) continue;
|
|
239
|
-
const targetPath = join(target, entry.name);
|
|
240
|
-
const targetReal = await safeRealpath(root, candidate);
|
|
241
|
-
if (targetReal === null) {
|
|
242
|
-
if ((await lstat(candidate)).isSymbolicLink()) await symlink(await readlink(candidate), targetPath);
|
|
243
|
-
continue;
|
|
244
|
-
}
|
|
245
|
-
if (targetReal === undefined) continue;
|
|
246
|
-
if (deniedPath("/" + relative(root, targetReal).split(sep).join("/"))) continue;
|
|
247
|
-
const info = await stat(targetReal);
|
|
248
|
-
if (info.isDirectory()) await copyDirectory(targetReal, targetPath, root, visited);
|
|
249
|
-
else if (info.isFile()) await copyFile(targetReal, targetPath);
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
async function snapshotDirectory(root) {
|
|
254
|
-
const snapshot = await mkdtemp(join(tmpdir(), SNAPSHOT_PREFIX));
|
|
255
|
-
try {
|
|
256
|
-
await copyDirectory(root, snapshot, root);
|
|
257
|
-
return snapshot;
|
|
258
|
-
} catch (cause) {
|
|
259
|
-
await rm(snapshot, { recursive: true, force: true });
|
|
260
|
-
throw cause;
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
197
|
function mutateQueue() {
|
|
265
198
|
let chain = Promise.resolve();
|
|
266
199
|
return operation => {
|
|
@@ -302,20 +235,19 @@ function connectControl(root, packet) {
|
|
|
302
235
|
});
|
|
303
236
|
}
|
|
304
237
|
|
|
238
|
+
function pageHash(page) {
|
|
239
|
+
return createHash("sha256").update(page).digest("hex");
|
|
240
|
+
}
|
|
241
|
+
|
|
305
242
|
async function start(directory) {
|
|
306
243
|
const { root } = await options(directory);
|
|
307
|
-
let
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
};
|
|
312
|
-
const socketPath = controlPath(root);
|
|
313
|
-
let publishedRoot = await snapshotDirectory(root);
|
|
314
|
-
let workspaceId = randomUUID();
|
|
315
|
-
let workspaceSequence = 1;
|
|
316
|
-
const workspaceIds = new Set([workspaceId]);
|
|
244
|
+
let page = await readInitialPage(root);
|
|
245
|
+
let pageEvent = 0;
|
|
246
|
+
let eventNumber = 0;
|
|
247
|
+
const currentPageHash = () => pageHash(page);
|
|
317
248
|
const eventLog = [];
|
|
318
|
-
|
|
249
|
+
const browserEvents = [];
|
|
250
|
+
let committedBrowserCursor = 0;
|
|
319
251
|
const seenEvents = new Set();
|
|
320
252
|
const tokens = new Map();
|
|
321
253
|
const pendingTokens = new Map();
|
|
@@ -332,32 +264,24 @@ async function start(directory) {
|
|
|
332
264
|
let retryUntil = 0;
|
|
333
265
|
let stopped = false;
|
|
334
266
|
let ready = false;
|
|
335
|
-
let initialPublished = false;
|
|
336
267
|
|
|
337
268
|
const batch = () => {
|
|
338
|
-
const start =
|
|
339
|
-
const end =
|
|
340
|
-
const key = `${
|
|
269
|
+
const start = committedBrowserCursor;
|
|
270
|
+
const end = browserEvents.length;
|
|
271
|
+
const key = `${pageEvent}:${start}:${end}`;
|
|
341
272
|
const existing = pendingTokens.get(key);
|
|
342
273
|
if (existing) return existing;
|
|
343
274
|
const token = randomUUID();
|
|
344
|
-
const events =
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
based_on: event.based_on ?? null,
|
|
348
|
-
current: workspaceId,
|
|
349
|
-
relationship: event.based_on === workspaceId ? "current" : workspaceIds.has(event.based_on) ? "stale" : "unknown"
|
|
350
|
-
}
|
|
351
|
-
}));
|
|
352
|
-
tokens.set(token, { start, end, parent: workspaceId, status: "pending", key });
|
|
353
|
-
const result = { ok: true, type: "batch", token, workspace: workspaceId, workspace_sequence: workspaceSequence, frontier: end, events };
|
|
275
|
+
const events = browserEvents.slice(start, end);
|
|
276
|
+
tokens.set(token, { start, end, page_event: pageEvent, status: "pending", key, page_hash: currentPageHash(), has_page: null, requested_page_hash: null });
|
|
277
|
+
const result = { ok: true, type: "batch", token, frontier: eventNumber, page_event: pageEvent, page_hash: currentPageHash(), events };
|
|
354
278
|
pendingTokens.set(key, result);
|
|
355
279
|
return result;
|
|
356
280
|
};
|
|
357
281
|
|
|
358
282
|
const notifyPullWaiters = () => {
|
|
359
283
|
for (const waiter of [...pullWaiters]) {
|
|
360
|
-
if (
|
|
284
|
+
if (browserEvents.length === committedBrowserCursor) continue;
|
|
361
285
|
pullWaiters.delete(waiter);
|
|
362
286
|
clearTimeout(waiter.timer);
|
|
363
287
|
waiter.resolve(batch());
|
|
@@ -365,53 +289,57 @@ async function start(directory) {
|
|
|
365
289
|
};
|
|
366
290
|
|
|
367
291
|
const pull = waitSeconds => {
|
|
368
|
-
if (
|
|
292
|
+
if (browserEvents.length > committedBrowserCursor || waitSeconds <= 0) return Promise.resolve(batch());
|
|
369
293
|
return new Promise(resolve => {
|
|
370
294
|
const waiter = { resolve, timer: setTimeout(() => { pullWaiters.delete(waiter); resolve(batch()); }, waitSeconds * 1_000) };
|
|
371
295
|
pullWaiters.add(waiter);
|
|
372
296
|
});
|
|
373
297
|
};
|
|
374
298
|
|
|
375
|
-
const commit = async (token,
|
|
299
|
+
const commit = async (token, requestedPage) => {
|
|
376
300
|
const record = tokens.get(token);
|
|
377
301
|
if (!record) return { ok: false, error: "unknown batch token" };
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
record.
|
|
382
|
-
|
|
383
|
-
return record.result;
|
|
302
|
+
const hasPage = requestedPage !== undefined;
|
|
303
|
+
const requestedPageHash = hasPage ? pageHash(requestedPage) : null;
|
|
304
|
+
if (record.status !== "pending") {
|
|
305
|
+
if (record.has_page === hasPage && record.requested_page_hash === requestedPageHash) return record.result;
|
|
306
|
+
return { ok: false, error: "batch was already committed with a different page payload" };
|
|
384
307
|
}
|
|
385
|
-
|
|
308
|
+
record.has_page = hasPage;
|
|
309
|
+
record.requested_page_hash = requestedPageHash;
|
|
310
|
+
if (record.page_event !== pageEvent || record.start !== committedBrowserCursor) {
|
|
386
311
|
pendingTokens.delete(record.key);
|
|
387
312
|
record.status = "failed";
|
|
388
|
-
record.result = { ok: false, error: "batch is based on an old
|
|
313
|
+
record.result = { ok: false, error: "batch is based on an old page or browser cursor", frontier: eventNumber, page_event: pageEvent, page_hash: currentPageHash() };
|
|
389
314
|
return record.result;
|
|
390
315
|
}
|
|
391
|
-
if (
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
} else {
|
|
401
|
-
record.result = { ok: true, type: "acknowledged", token, workspace: workspaceId, workspace_sequence: workspaceSequence, frontier: record.end, events: eventLog.slice(record.start, record.end).map(event => event.id) };
|
|
316
|
+
if (hasPage && Buffer.byteLength(requestedPage, "utf8") > MAX_BODY_BYTES) return { ok: false, error: "page is too large" };
|
|
317
|
+
const committedEvents = browserEvents.slice(record.start, record.end).map(event => event.id);
|
|
318
|
+
let update;
|
|
319
|
+
if (hasPage) {
|
|
320
|
+
page = requestedPage;
|
|
321
|
+
eventNumber += 1;
|
|
322
|
+
pageEvent = eventNumber;
|
|
323
|
+
update = { type: "update_ui", event_number: pageEvent, html: page };
|
|
324
|
+
eventLog.push(update);
|
|
402
325
|
}
|
|
403
|
-
|
|
326
|
+
committedBrowserCursor = record.end;
|
|
404
327
|
pendingTokens.delete(record.key);
|
|
405
328
|
record.status = "committed";
|
|
406
|
-
|
|
329
|
+
record.result = { ok: true, type: "committed", token, frontier: eventNumber, page_event: pageEvent, page_hash: currentPageHash(), events: committedEvents };
|
|
330
|
+
if (update) send(update);
|
|
407
331
|
return record.result;
|
|
408
332
|
};
|
|
409
333
|
|
|
410
334
|
const dispatchControl = async request => {
|
|
411
335
|
if (!request || typeof request !== "object") return { ok: false, error: "invalid control request" };
|
|
412
336
|
if (request.type === "pull") return pull(Number.isFinite(request.wait_seconds) ? Math.max(0, request.wait_seconds) : 0);
|
|
413
|
-
if (request.type === "
|
|
414
|
-
if (request.type === "
|
|
337
|
+
if (request.type === "show") return { ok: true, type: "page", page_event: pageEvent, page_hash: currentPageHash(), html: page };
|
|
338
|
+
if (request.type === "push") {
|
|
339
|
+
if (typeof request.token !== "string") return { ok: false, error: "batch token is required" };
|
|
340
|
+
if (request.page !== undefined && typeof request.page !== "string") return { ok: false, error: "page must be text" };
|
|
341
|
+
return commit(request.token, request.page);
|
|
342
|
+
}
|
|
415
343
|
return { ok: false, error: "unknown control request" };
|
|
416
344
|
};
|
|
417
345
|
|
|
@@ -423,7 +351,7 @@ async function start(directory) {
|
|
|
423
351
|
let handled = false;
|
|
424
352
|
connection.on("data", async chunk => {
|
|
425
353
|
input += chunk;
|
|
426
|
-
if (input.length >
|
|
354
|
+
if (input.length > CONTROL_MAX_BYTES || handled) return;
|
|
427
355
|
const newline = input.indexOf("\n");
|
|
428
356
|
if (newline < 0) return;
|
|
429
357
|
handled = true;
|
|
@@ -439,20 +367,20 @@ async function start(directory) {
|
|
|
439
367
|
});
|
|
440
368
|
await new Promise((resolveListen, reject) => {
|
|
441
369
|
controlServer.once("error", reject);
|
|
442
|
-
controlServer.listen(
|
|
443
|
-
try { await chmod(
|
|
370
|
+
controlServer.listen(controlPath(root), async () => {
|
|
371
|
+
try { await chmod(controlPath(root), 0o600); } catch (cause) { controlServer.close(() => reject(cause)); return; }
|
|
444
372
|
controlServer.off("error", reject);
|
|
445
373
|
resolveListen();
|
|
446
374
|
});
|
|
447
|
-
}).catch(
|
|
448
|
-
await rm(publishedRoot, { recursive: true, force: true });
|
|
449
|
-
throw new Error(`cannot start local control channel: ${cause.message}`);
|
|
450
|
-
});
|
|
375
|
+
}).catch(cause => { throw new Error(`cannot start local control channel: ${cause.message}`); });
|
|
451
376
|
|
|
452
377
|
const recordInteraction = event => mutate(async () => {
|
|
453
378
|
if (seenEvents.has(event.id)) return;
|
|
454
379
|
seenEvents.add(event.id);
|
|
455
|
-
|
|
380
|
+
eventNumber += 1;
|
|
381
|
+
const numbered = { ...event, event_number: eventNumber };
|
|
382
|
+
eventLog.push(numbered);
|
|
383
|
+
browserEvents.push(numbered);
|
|
456
384
|
notifyPullWaiters();
|
|
457
385
|
});
|
|
458
386
|
|
|
@@ -465,11 +393,7 @@ async function start(directory) {
|
|
|
465
393
|
try { socket?.close(); } catch {}
|
|
466
394
|
for (const connection of controlConnections) connection.destroy();
|
|
467
395
|
await new Promise(resolveClose => controlServer.close(() => resolveClose()));
|
|
468
|
-
await unlink(
|
|
469
|
-
await rm(publishedRoot, { recursive: true, force: true });
|
|
470
|
-
if (attachmentInboxPromise) {
|
|
471
|
-
try { await rm(await attachmentInboxPromise, { recursive: true, force: true }); } catch {}
|
|
472
|
-
}
|
|
396
|
+
await unlink(controlPath(root)).catch(() => {});
|
|
473
397
|
process.exit(code);
|
|
474
398
|
};
|
|
475
399
|
process.once("SIGINT", () => void stop(0));
|
|
@@ -478,7 +402,7 @@ async function start(directory) {
|
|
|
478
402
|
const retry = () => {
|
|
479
403
|
if (stopped || Date.now() >= retryUntil) return void stop(1);
|
|
480
404
|
retryTimer = setTimeout(() => { retryTimer = undefined; connect(); }, retryDelay);
|
|
481
|
-
retryDelay = Math.min(retryDelay * 2,
|
|
405
|
+
retryDelay = Math.min(retryDelay * 2, 5_000);
|
|
482
406
|
};
|
|
483
407
|
|
|
484
408
|
const connect = () => {
|
|
@@ -511,15 +435,11 @@ async function start(directory) {
|
|
|
511
435
|
} else if (packet.type === "session") {
|
|
512
436
|
if (!validSessionUrl(packet.url)) return void stop(1);
|
|
513
437
|
sessionUrl = packet.url;
|
|
514
|
-
if (!
|
|
515
|
-
initialPublished = true;
|
|
516
|
-
send({ type: "revision" });
|
|
517
|
-
}
|
|
518
|
-
if (!ready) { ready = true; process.stdout.write(`${JSON.stringify({ type: "ready", url: sessionUrl, workspace: workspaceId, workspace_sequence: workspaceSequence })}\n`); }
|
|
438
|
+
if (!ready) { ready = true; process.stdout.write(`${JSON.stringify({ type: "ready", url: sessionUrl, page_event: pageEvent, page_hash: currentPageHash() })}\n`); }
|
|
519
439
|
} else if (packet.type === "http_request") {
|
|
520
|
-
const
|
|
521
|
-
const
|
|
522
|
-
void handleRequest(
|
|
440
|
+
const requestPage = page;
|
|
441
|
+
const requestPageEvent = pageEvent;
|
|
442
|
+
void handleRequest(root, requestPage, requestPageEvent, packet, recordInteraction).then(result => send(result)).catch(() => send(errorResponse(packet, 500, "preview request failed")));
|
|
523
443
|
} else if (packet.type === "closed") {
|
|
524
444
|
void stop(0);
|
|
525
445
|
} else if (packet.type === "error") {
|
|
@@ -563,7 +483,7 @@ function validSessionUrl(value) {
|
|
|
563
483
|
}
|
|
564
484
|
|
|
565
485
|
function usage() {
|
|
566
|
-
return "Usage:\n npx letmeknow-cli serve <directory>\n npx letmeknow-cli pull <directory> [--wait <seconds>]\n npx letmeknow-cli push <directory> --
|
|
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> [--page <file|->]\n";
|
|
567
487
|
}
|
|
568
488
|
|
|
569
489
|
function commandArgs() {
|
|
@@ -575,7 +495,8 @@ function commandArgs() {
|
|
|
575
495
|
skill: { type: "boolean" },
|
|
576
496
|
help: { type: "boolean", short: "h" },
|
|
577
497
|
wait: { type: "string" },
|
|
578
|
-
|
|
498
|
+
batch: { type: "string" },
|
|
499
|
+
page: { type: "string" }
|
|
579
500
|
},
|
|
580
501
|
allowPositionals: true,
|
|
581
502
|
strict: true
|
|
@@ -584,22 +505,40 @@ function commandArgs() {
|
|
|
584
505
|
throw new Error(cause instanceof Error ? cause.message : "invalid arguments");
|
|
585
506
|
}
|
|
586
507
|
if (parsed.values.skill || parsed.values.help) {
|
|
587
|
-
if (parsed.positionals.length || parsed.values.wait !== undefined || parsed.values
|
|
508
|
+
if (parsed.positionals.length || parsed.values.wait !== undefined || parsed.values.batch !== undefined || parsed.values.page !== undefined) throw new Error(usage());
|
|
588
509
|
return { command: parsed.values.skill ? "skill" : "help" };
|
|
589
510
|
}
|
|
590
511
|
const [command, directory, ...extra] = parsed.positionals;
|
|
591
512
|
if (!command || !directory || extra.length) throw new Error(usage());
|
|
592
|
-
if (
|
|
593
|
-
if (command === "
|
|
594
|
-
if (
|
|
595
|
-
if (
|
|
513
|
+
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.page !== undefined)) throw new Error(usage());
|
|
515
|
+
if (command === "pull" && (parsed.values.batch !== undefined || parsed.values.page !== undefined)) throw new Error(usage());
|
|
516
|
+
if (command === "push" && parsed.values.wait !== undefined) throw new Error(usage());
|
|
596
517
|
let wait = 0;
|
|
597
518
|
if (parsed.values.wait !== undefined) {
|
|
598
519
|
wait = Number(parsed.values.wait);
|
|
599
520
|
if (!Number.isFinite(wait) || wait < 0) throw new Error("--wait must be a non-negative number");
|
|
600
521
|
}
|
|
601
|
-
if (
|
|
602
|
-
return { command, directory, wait, token: parsed.values
|
|
522
|
+
if (command === "push" && typeof parsed.values.batch !== "string") throw new Error("--batch is required");
|
|
523
|
+
return { command, directory, wait, token: parsed.values.batch, page: parsed.values.page };
|
|
524
|
+
}
|
|
525
|
+
|
|
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");
|
|
603
542
|
}
|
|
604
543
|
|
|
605
544
|
let command;
|
|
@@ -610,8 +549,16 @@ try {
|
|
|
610
549
|
else if (command.command === "serve") await start(command.directory);
|
|
611
550
|
else {
|
|
612
551
|
const { root } = await options(command.directory);
|
|
613
|
-
|
|
614
|
-
|
|
552
|
+
let packet;
|
|
553
|
+
if (command.command === "pull") packet = { type: "pull", wait_seconds: command.wait };
|
|
554
|
+
else if (command.command === "show") packet = { type: "show" };
|
|
555
|
+
else {
|
|
556
|
+
packet = { type: "push", token: command.token };
|
|
557
|
+
if (command.page !== undefined) packet.page = await readPageInput(command.page);
|
|
558
|
+
}
|
|
559
|
+
const result = await connectControl(root, packet);
|
|
560
|
+
if (command.command === "show" && result.ok) process.stdout.write(result.html);
|
|
561
|
+
else process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
615
562
|
if (!result.ok) process.exitCode = 1;
|
|
616
563
|
}
|
|
617
564
|
} catch (cause) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "letmeknow-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "A live
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "A live agent-authored page with structured browser feedback.",
|
|
5
5
|
"files": [
|
|
6
6
|
"bin",
|
|
7
7
|
"SKILL.md"
|
|
@@ -24,6 +24,7 @@
|
|
|
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",
|
|
27
28
|
"typescript": "^5.9.2",
|
|
28
29
|
"vitest": "^4.1.11",
|
|
29
30
|
"wrangler": "^4.126.0",
|