letmeknow-cli 0.6.1 → 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 -192
- 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,41 +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
|
-
visited.delete(sourceReal);
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
async function snapshotDirectory(root) {
|
|
255
|
-
const snapshot = await mkdtemp(join(tmpdir(), SNAPSHOT_PREFIX));
|
|
256
|
-
try {
|
|
257
|
-
await copyDirectory(root, snapshot, root);
|
|
258
|
-
return snapshot;
|
|
259
|
-
} catch (cause) {
|
|
260
|
-
await rm(snapshot, { recursive: true, force: true });
|
|
261
|
-
throw cause;
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
197
|
function mutateQueue() {
|
|
266
198
|
let chain = Promise.resolve();
|
|
267
199
|
return operation => {
|
|
@@ -303,20 +235,19 @@ function connectControl(root, packet) {
|
|
|
303
235
|
});
|
|
304
236
|
}
|
|
305
237
|
|
|
238
|
+
function pageHash(page) {
|
|
239
|
+
return createHash("sha256").update(page).digest("hex");
|
|
240
|
+
}
|
|
241
|
+
|
|
306
242
|
async function start(directory) {
|
|
307
243
|
const { root } = await options(directory);
|
|
308
|
-
let
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
};
|
|
313
|
-
const socketPath = controlPath(root);
|
|
314
|
-
let publishedRoot = await snapshotDirectory(root);
|
|
315
|
-
let workspaceId = randomUUID();
|
|
316
|
-
let workspaceSequence = 1;
|
|
317
|
-
const workspaceIds = new Set([workspaceId]);
|
|
244
|
+
let page = await readInitialPage(root);
|
|
245
|
+
let pageEvent = 0;
|
|
246
|
+
let eventNumber = 0;
|
|
247
|
+
const currentPageHash = () => pageHash(page);
|
|
318
248
|
const eventLog = [];
|
|
319
|
-
|
|
249
|
+
const browserEvents = [];
|
|
250
|
+
let committedBrowserCursor = 0;
|
|
320
251
|
const seenEvents = new Set();
|
|
321
252
|
const tokens = new Map();
|
|
322
253
|
const pendingTokens = new Map();
|
|
@@ -333,32 +264,24 @@ async function start(directory) {
|
|
|
333
264
|
let retryUntil = 0;
|
|
334
265
|
let stopped = false;
|
|
335
266
|
let ready = false;
|
|
336
|
-
let initialPublished = false;
|
|
337
267
|
|
|
338
268
|
const batch = () => {
|
|
339
|
-
const start =
|
|
340
|
-
const end =
|
|
341
|
-
const key = `${
|
|
269
|
+
const start = committedBrowserCursor;
|
|
270
|
+
const end = browserEvents.length;
|
|
271
|
+
const key = `${pageEvent}:${start}:${end}`;
|
|
342
272
|
const existing = pendingTokens.get(key);
|
|
343
273
|
if (existing) return existing;
|
|
344
274
|
const token = randomUUID();
|
|
345
|
-
const events =
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
based_on: event.based_on ?? null,
|
|
349
|
-
current: workspaceId,
|
|
350
|
-
relationship: event.based_on === workspaceId ? "current" : workspaceIds.has(event.based_on) ? "stale" : "unknown"
|
|
351
|
-
}
|
|
352
|
-
}));
|
|
353
|
-
tokens.set(token, { start, end, parent: workspaceId, status: "pending", key });
|
|
354
|
-
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 };
|
|
355
278
|
pendingTokens.set(key, result);
|
|
356
279
|
return result;
|
|
357
280
|
};
|
|
358
281
|
|
|
359
282
|
const notifyPullWaiters = () => {
|
|
360
283
|
for (const waiter of [...pullWaiters]) {
|
|
361
|
-
if (
|
|
284
|
+
if (browserEvents.length === committedBrowserCursor) continue;
|
|
362
285
|
pullWaiters.delete(waiter);
|
|
363
286
|
clearTimeout(waiter.timer);
|
|
364
287
|
waiter.resolve(batch());
|
|
@@ -366,53 +289,57 @@ async function start(directory) {
|
|
|
366
289
|
};
|
|
367
290
|
|
|
368
291
|
const pull = waitSeconds => {
|
|
369
|
-
if (
|
|
292
|
+
if (browserEvents.length > committedBrowserCursor || waitSeconds <= 0) return Promise.resolve(batch());
|
|
370
293
|
return new Promise(resolve => {
|
|
371
294
|
const waiter = { resolve, timer: setTimeout(() => { pullWaiters.delete(waiter); resolve(batch()); }, waitSeconds * 1_000) };
|
|
372
295
|
pullWaiters.add(waiter);
|
|
373
296
|
});
|
|
374
297
|
};
|
|
375
298
|
|
|
376
|
-
const commit = async (token,
|
|
299
|
+
const commit = async (token, requestedPage) => {
|
|
377
300
|
const record = tokens.get(token);
|
|
378
301
|
if (!record) return { ok: false, error: "unknown batch token" };
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
record.
|
|
383
|
-
|
|
384
|
-
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" };
|
|
385
307
|
}
|
|
386
|
-
|
|
308
|
+
record.has_page = hasPage;
|
|
309
|
+
record.requested_page_hash = requestedPageHash;
|
|
310
|
+
if (record.page_event !== pageEvent || record.start !== committedBrowserCursor) {
|
|
387
311
|
pendingTokens.delete(record.key);
|
|
388
312
|
record.status = "failed";
|
|
389
|
-
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() };
|
|
390
314
|
return record.result;
|
|
391
315
|
}
|
|
392
|
-
if (
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
} else {
|
|
402
|
-
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);
|
|
403
325
|
}
|
|
404
|
-
|
|
326
|
+
committedBrowserCursor = record.end;
|
|
405
327
|
pendingTokens.delete(record.key);
|
|
406
328
|
record.status = "committed";
|
|
407
|
-
|
|
329
|
+
record.result = { ok: true, type: "committed", token, frontier: eventNumber, page_event: pageEvent, page_hash: currentPageHash(), events: committedEvents };
|
|
330
|
+
if (update) send(update);
|
|
408
331
|
return record.result;
|
|
409
332
|
};
|
|
410
333
|
|
|
411
334
|
const dispatchControl = async request => {
|
|
412
335
|
if (!request || typeof request !== "object") return { ok: false, error: "invalid control request" };
|
|
413
336
|
if (request.type === "pull") return pull(Number.isFinite(request.wait_seconds) ? Math.max(0, request.wait_seconds) : 0);
|
|
414
|
-
if (request.type === "
|
|
415
|
-
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
|
+
}
|
|
416
343
|
return { ok: false, error: "unknown control request" };
|
|
417
344
|
};
|
|
418
345
|
|
|
@@ -424,7 +351,7 @@ async function start(directory) {
|
|
|
424
351
|
let handled = false;
|
|
425
352
|
connection.on("data", async chunk => {
|
|
426
353
|
input += chunk;
|
|
427
|
-
if (input.length >
|
|
354
|
+
if (input.length > CONTROL_MAX_BYTES || handled) return;
|
|
428
355
|
const newline = input.indexOf("\n");
|
|
429
356
|
if (newline < 0) return;
|
|
430
357
|
handled = true;
|
|
@@ -440,20 +367,20 @@ async function start(directory) {
|
|
|
440
367
|
});
|
|
441
368
|
await new Promise((resolveListen, reject) => {
|
|
442
369
|
controlServer.once("error", reject);
|
|
443
|
-
controlServer.listen(
|
|
444
|
-
try { await chmod(
|
|
370
|
+
controlServer.listen(controlPath(root), async () => {
|
|
371
|
+
try { await chmod(controlPath(root), 0o600); } catch (cause) { controlServer.close(() => reject(cause)); return; }
|
|
445
372
|
controlServer.off("error", reject);
|
|
446
373
|
resolveListen();
|
|
447
374
|
});
|
|
448
|
-
}).catch(
|
|
449
|
-
await rm(publishedRoot, { recursive: true, force: true });
|
|
450
|
-
throw new Error(`cannot start local control channel: ${cause.message}`);
|
|
451
|
-
});
|
|
375
|
+
}).catch(cause => { throw new Error(`cannot start local control channel: ${cause.message}`); });
|
|
452
376
|
|
|
453
377
|
const recordInteraction = event => mutate(async () => {
|
|
454
378
|
if (seenEvents.has(event.id)) return;
|
|
455
379
|
seenEvents.add(event.id);
|
|
456
|
-
|
|
380
|
+
eventNumber += 1;
|
|
381
|
+
const numbered = { ...event, event_number: eventNumber };
|
|
382
|
+
eventLog.push(numbered);
|
|
383
|
+
browserEvents.push(numbered);
|
|
457
384
|
notifyPullWaiters();
|
|
458
385
|
});
|
|
459
386
|
|
|
@@ -466,11 +393,7 @@ async function start(directory) {
|
|
|
466
393
|
try { socket?.close(); } catch {}
|
|
467
394
|
for (const connection of controlConnections) connection.destroy();
|
|
468
395
|
await new Promise(resolveClose => controlServer.close(() => resolveClose()));
|
|
469
|
-
await unlink(
|
|
470
|
-
await rm(publishedRoot, { recursive: true, force: true });
|
|
471
|
-
if (attachmentInboxPromise) {
|
|
472
|
-
try { await rm(await attachmentInboxPromise, { recursive: true, force: true }); } catch {}
|
|
473
|
-
}
|
|
396
|
+
await unlink(controlPath(root)).catch(() => {});
|
|
474
397
|
process.exit(code);
|
|
475
398
|
};
|
|
476
399
|
process.once("SIGINT", () => void stop(0));
|
|
@@ -479,7 +402,7 @@ async function start(directory) {
|
|
|
479
402
|
const retry = () => {
|
|
480
403
|
if (stopped || Date.now() >= retryUntil) return void stop(1);
|
|
481
404
|
retryTimer = setTimeout(() => { retryTimer = undefined; connect(); }, retryDelay);
|
|
482
|
-
retryDelay = Math.min(retryDelay * 2,
|
|
405
|
+
retryDelay = Math.min(retryDelay * 2, 5_000);
|
|
483
406
|
};
|
|
484
407
|
|
|
485
408
|
const connect = () => {
|
|
@@ -512,15 +435,11 @@ async function start(directory) {
|
|
|
512
435
|
} else if (packet.type === "session") {
|
|
513
436
|
if (!validSessionUrl(packet.url)) return void stop(1);
|
|
514
437
|
sessionUrl = packet.url;
|
|
515
|
-
if (!
|
|
516
|
-
initialPublished = true;
|
|
517
|
-
send({ type: "revision" });
|
|
518
|
-
}
|
|
519
|
-
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`); }
|
|
520
439
|
} else if (packet.type === "http_request") {
|
|
521
|
-
const
|
|
522
|
-
const
|
|
523
|
-
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")));
|
|
524
443
|
} else if (packet.type === "closed") {
|
|
525
444
|
void stop(0);
|
|
526
445
|
} else if (packet.type === "error") {
|
|
@@ -564,7 +483,7 @@ function validSessionUrl(value) {
|
|
|
564
483
|
}
|
|
565
484
|
|
|
566
485
|
function usage() {
|
|
567
|
-
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";
|
|
568
487
|
}
|
|
569
488
|
|
|
570
489
|
function commandArgs() {
|
|
@@ -576,7 +495,8 @@ function commandArgs() {
|
|
|
576
495
|
skill: { type: "boolean" },
|
|
577
496
|
help: { type: "boolean", short: "h" },
|
|
578
497
|
wait: { type: "string" },
|
|
579
|
-
|
|
498
|
+
batch: { type: "string" },
|
|
499
|
+
page: { type: "string" }
|
|
580
500
|
},
|
|
581
501
|
allowPositionals: true,
|
|
582
502
|
strict: true
|
|
@@ -585,22 +505,40 @@ function commandArgs() {
|
|
|
585
505
|
throw new Error(cause instanceof Error ? cause.message : "invalid arguments");
|
|
586
506
|
}
|
|
587
507
|
if (parsed.values.skill || parsed.values.help) {
|
|
588
|
-
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());
|
|
589
509
|
return { command: parsed.values.skill ? "skill" : "help" };
|
|
590
510
|
}
|
|
591
511
|
const [command, directory, ...extra] = parsed.positionals;
|
|
592
512
|
if (!command || !directory || extra.length) throw new Error(usage());
|
|
593
|
-
if (
|
|
594
|
-
if (command === "
|
|
595
|
-
if (
|
|
596
|
-
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());
|
|
597
517
|
let wait = 0;
|
|
598
518
|
if (parsed.values.wait !== undefined) {
|
|
599
519
|
wait = Number(parsed.values.wait);
|
|
600
520
|
if (!Number.isFinite(wait) || wait < 0) throw new Error("--wait must be a non-negative number");
|
|
601
521
|
}
|
|
602
|
-
if (
|
|
603
|
-
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");
|
|
604
542
|
}
|
|
605
543
|
|
|
606
544
|
let command;
|
|
@@ -611,8 +549,16 @@ try {
|
|
|
611
549
|
else if (command.command === "serve") await start(command.directory);
|
|
612
550
|
else {
|
|
613
551
|
const { root } = await options(command.directory);
|
|
614
|
-
|
|
615
|
-
|
|
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`);
|
|
616
562
|
if (!result.ok) process.exitCode = 1;
|
|
617
563
|
}
|
|
618
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",
|