letmeknow-cli 0.4.2 → 0.4.4
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 +32 -131
- package/SKILL.md +46 -118
- package/bin/letmeknow.js +412 -251
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -1,175 +1,76 @@
|
|
|
1
1
|
# LetMeKnow
|
|
2
2
|
|
|
3
|
-
LetMeKnow gives
|
|
3
|
+
LetMeKnow gives an agent-managed folder a public, live Vite preview. The CLI runs Vite in middleware mode and makes only an outbound WebSocket connection to the relay; it does not listen on a network port.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Start
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
Node 22 or newer is required.
|
|
10
|
-
|
|
11
|
-
```bash
|
|
12
|
-
npx letmeknow-cli
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
The deployed service is used by default. Set `LETMEKNOW_URL` for local development:
|
|
7
|
+
Node.js 22.12 or newer is required.
|
|
16
8
|
|
|
17
9
|
```bash
|
|
18
|
-
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
stdin contains one compact JSON command per line. stdout contains one JSON event per line. Diagnostics go to stderr.
|
|
22
|
-
|
|
23
|
-
```bash
|
|
24
|
-
npx letmeknow-cli --skill
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
prints the agent instructions without connecting.
|
|
28
|
-
|
|
29
|
-
## Example
|
|
30
|
-
|
|
31
|
-
Open a session:
|
|
32
|
-
|
|
33
|
-
```json
|
|
34
|
-
{"type":"open","id":"open-1"}
|
|
10
|
+
npx letmeknow-cli ./workspace
|
|
35
11
|
```
|
|
36
12
|
|
|
37
|
-
The CLI
|
|
13
|
+
The CLI prints JSON lines to stdout. The first line contains the public preview URL:
|
|
38
14
|
|
|
39
15
|
```json
|
|
40
|
-
{"type":"
|
|
16
|
+
{"type":"ready","url":"https://0123456789abcdef0123.letmeknow.dev/"}
|
|
41
17
|
```
|
|
42
18
|
|
|
43
|
-
|
|
19
|
+
Open that URL in one browser. The directory defaults to the current working directory. Set `LETMEKNOW_URL` to use another compatible relay:
|
|
44
20
|
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
A connected browser receives the render immediately. The acknowledgement contains its revision:
|
|
50
|
-
|
|
51
|
-
```json
|
|
52
|
-
{"type":"ack","id":"render-1","render_id":"b87438c2-4f1c-44bd-9875-6cc64370b8aa"}
|
|
53
|
-
```
|
|
54
|
-
|
|
55
|
-
Submitting the form produces one normalized action:
|
|
56
|
-
|
|
57
|
-
```json
|
|
58
|
-
{"type":"action","id":"2ee81a6b-1035-40a7-a90d-c1e02f426baa","render_id":"b87438c2-4f1c-44bd-9875-6cc64370b8aa","action_id":"search","form_id":"search","target_id":"results","trigger":{"id":null,"name":null,"value":null},"values":{"customer":"Acme Ltd"}}
|
|
21
|
+
```bash
|
|
22
|
+
LETMEKNOW_URL=https://letmeknow.dev npx letmeknow-cli ./workspace
|
|
59
23
|
```
|
|
60
24
|
|
|
61
|
-
|
|
25
|
+
There are no `--host` or `--port` options because the CLI intentionally has no listening network socket. Diagnostics go to stderr. `--skill` prints the agent instructions without starting a session.
|
|
62
26
|
|
|
63
|
-
|
|
64
|
-
{"type":"response","id":"response-1","request_id":"2ee81a6b-1035-40a7-a90d-c1e02f426baa","body":"<table><tr><th>Invoice</th><th>Amount</th></tr><tr><td>INV-42</td><td>$800</td></tr></table>"}
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
The browser inserts the fragment into `#results`. Without `data-lmk-target`, the response replaces the whole workspace.
|
|
27
|
+
## File workflow
|
|
68
28
|
|
|
69
|
-
|
|
29
|
+
The CLI does not receive file commands. The agent reads and writes the directory directly. Keep a normal Vite entry point such as `index.html`; JavaScript, CSS, images, and other Vite-supported files can be requested through the relay.
|
|
70
30
|
|
|
71
|
-
|
|
31
|
+
When a watched file changes, the browser receives an update. HTML changes replace the current document body without a page reload and preserve form values, focus, selection, and scroll position. CSS links are refreshed without navigating. Changes to a different HTML route do not disturb the current page.
|
|
72
32
|
|
|
73
|
-
|
|
33
|
+
An optional element can display submission status:
|
|
74
34
|
|
|
75
35
|
```html
|
|
76
|
-
<
|
|
77
|
-
<label>Comment<textarea name="comment"></textarea></label>
|
|
78
|
-
<button name="decision" value="approve" formaction="approve">Approve</button>
|
|
79
|
-
<button name="decision" value="reject" formaction="reject">Reject</button>
|
|
80
|
-
</form>
|
|
36
|
+
<p data-letmeknow-status aria-live="polite"></p>
|
|
81
37
|
```
|
|
82
38
|
|
|
83
|
-
|
|
39
|
+
## Form submissions
|
|
84
40
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
A button can invoke an action without a form:
|
|
41
|
+
Forms are submitted without navigation. GET and POST forms are sent through the relay to the CLI, which prints each submission as one JSON line on stdout. The agent can read that line and edit the folder in response.
|
|
88
42
|
|
|
89
43
|
```html
|
|
90
|
-
<
|
|
44
|
+
<form id="decision" action="/decide" method="post">
|
|
45
|
+
<label>Comment <textarea name="comment"></textarea></label>
|
|
46
|
+
<button name="decision" value="approve">Approve</button>
|
|
47
|
+
<button name="decision" value="reject">Reject</button>
|
|
48
|
+
</form>
|
|
91
49
|
```
|
|
92
50
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
### Targeted updates
|
|
96
|
-
|
|
97
|
-
`data-lmk-target` accepts one bare element ID. All updates use `innerHTML`. There are no alternate swap modes or selector targets. The default target is `lmk-view`, the whole workspace.
|
|
98
|
-
|
|
99
|
-
Typing, focusing, expanding `<details>`, validation, scrolling, and other local browser behavior produce no agent events.
|
|
100
|
-
|
|
101
|
-
## CSS
|
|
102
|
-
|
|
103
|
-
`render` accepts page CSS in its `css` field. A `response` may include `css` to replace it; omitting `css` preserves it.
|
|
104
|
-
|
|
105
|
-
Modern CSS is supported, including grid, flexbox, media queries, variables, transitions, and print styles. External stylesheets, `@import`, scripts, inline handlers, and inline `style` attributes are blocked. Local assets work in HTML and CSS. The built-in style is deliberately document-like and avoids decorative cards, gradients, shadows, and rounded controls.
|
|
106
|
-
|
|
107
|
-
## One browser client
|
|
108
|
-
|
|
109
|
-
At most one browser is active for a session. The first browser receives a private credential stored in that tab's `sessionStorage`.
|
|
110
|
-
|
|
111
|
-
- Reloads and reconnects reuse the credential.
|
|
112
|
-
- A competing browser triggers at most one liveness probe every five seconds.
|
|
113
|
-
- If the active browser answers, the claimant sees “This session is open elsewhere.”
|
|
114
|
-
- After a disconnect, the previous credential has a five-second exclusive reconnect period.
|
|
115
|
-
- After five seconds, either the old browser or a new claimant may connect; first connection wins.
|
|
116
|
-
- Laptop sleep does not invalidate the credential.
|
|
117
|
-
|
|
118
|
-
The server sends a connecting browser one canonical snapshot of the current HTML, CSS, render ID, and pending actions. It does not replay user actions. The browser restores same-tab drafts from `sessionStorage`; drafts do not transfer to another browser.
|
|
119
|
-
|
|
120
|
-
The server keeps committed page state and assets for the producer session. A full `render` replaces the page and clears old pending actions. A targeted response updates the canonical page. Browser disconnect alone does not delete state.
|
|
121
|
-
|
|
122
|
-
## Assets
|
|
123
|
-
|
|
124
|
-
`put` stores or replaces passive resources only under `/assets/`:
|
|
51
|
+
Submitting `Approve` prints an event like:
|
|
125
52
|
|
|
126
53
|
```json
|
|
127
|
-
{"type":"
|
|
54
|
+
{"type":"submit","id":"…","method":"POST","action":"/decide","form_id":"decision","trigger":{"id":null,"name":"decision","value":"approve"},"values":{"comment":"Looks good","decision":"approve"}}
|
|
128
55
|
```
|
|
129
56
|
|
|
130
|
-
|
|
57
|
+
Repeated field names become arrays. Native browser validation still runs before a submission is sent. File inputs and cross-origin form actions are not supported. Forms can use a submitter's standard `formaction`, `formmethod`, and `name`/`value` attributes.
|
|
131
58
|
|
|
132
|
-
|
|
133
|
-
<img src="assets/logo.png" alt="Company logo">
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
Assets answer only `GET` and `HEAD`. Each body is limited to 1 MiB. A session accepts 100 assets and 10 MiB decoded asset data. There is no `delete`; assets disappear with the session.
|
|
137
|
-
|
|
138
|
-
## Protocol
|
|
139
|
-
|
|
140
|
-
Commands:
|
|
141
|
-
|
|
142
|
-
- `open`: create the session; it must be first.
|
|
143
|
-
- `render`: replace the committed HTML and CSS and push it to the browser.
|
|
144
|
-
- `put`: store an asset under `/assets/`.
|
|
145
|
-
- `response`: resolve one pending action with HTML and optional CSS.
|
|
146
|
-
- `close`: destroy the session immediately.
|
|
147
|
-
|
|
148
|
-
Events:
|
|
59
|
+
The event ID identifies the submission. It is not a request/response handle: update the files and let the live preview show the result.
|
|
149
60
|
|
|
150
|
-
|
|
151
|
-
- `action`: normalized form or standalone-button action.
|
|
152
|
-
- `ack`: command completion.
|
|
153
|
-
- `error`: invalid command or protocol state.
|
|
154
|
-
- `closing`: explicit session destruction.
|
|
61
|
+
## Security
|
|
155
62
|
|
|
156
|
-
|
|
63
|
+
The preview URL is a bearer capability. The relay receives the served files and submitted values. Do not put secrets in the preview folder or submit credentials unless that is intentional. The folder is trusted executable code from the browser's perspective.
|
|
157
64
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
## Security and lifecycle
|
|
161
|
-
|
|
162
|
-
HTML fragments are sanitized. Scripts, style elements, inline handlers, HTMX attributes, frames, active metadata, external form actions, and invalid LetMeKnow attributes are removed. CSP blocks arbitrary browser connections and external resources.
|
|
163
|
-
|
|
164
|
-
The URL is a bearer secret. Share it only with the intended human. Browser and producer reconnect credentials remain private and never appear in protocol output.
|
|
165
|
-
|
|
166
|
-
If the producer disconnects, the current page remains visible, drafts are preserved, and actions are disabled. The CLI can reconnect for ten minutes. Browser traffic does not extend that grace. `close` or producer-grace expiry deletes page state, credentials, pending actions, and assets.
|
|
65
|
+
The CLI's Vite configuration is disabled and its filesystem access is limited to the selected folder. The CLI itself still requires an outbound network connection to the relay. It does not accept inbound browser connections.
|
|
167
66
|
|
|
168
67
|
## Development
|
|
169
68
|
|
|
170
69
|
```bash
|
|
171
70
|
npm install
|
|
172
|
-
npm run dev
|
|
173
71
|
npm test
|
|
72
|
+
npm run dev
|
|
174
73
|
npm run deploy
|
|
175
74
|
```
|
|
75
|
+
|
|
76
|
+
`npm run dev` and `npm run deploy` operate the Cloudflare relay.
|
package/SKILL.md
CHANGED
|
@@ -1,166 +1,94 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: letmeknow
|
|
3
|
-
description:
|
|
3
|
+
description: Give a human a live public preview of an agent-managed folder and receive browser form submissions as JSON lines.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# LetMeKnow
|
|
7
7
|
|
|
8
|
-
Use LetMeKnow when one human needs a temporary
|
|
8
|
+
Use LetMeKnow when one human needs to inspect or interact with a temporary page, report, dashboard, approval form, quiz, table, or status view. The agent owns the files in a folder. The CLI runs Vite in middleware mode, connects to the public relay over an outbound WebSocket, and reports browser submissions on stdout.
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
The CLI does **not** listen on a local network port.
|
|
11
11
|
|
|
12
12
|
## Start
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
Run the CLI as a long-lived child process with the folder you will edit:
|
|
15
15
|
|
|
16
16
|
```bash
|
|
17
|
-
npx letmeknow-cli
|
|
17
|
+
npx letmeknow-cli ./workspace
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
Node.js 22.12 or newer is required. Read stdout and stderr separately. Stdout is JSONL; the first event is:
|
|
21
21
|
|
|
22
22
|
```json
|
|
23
|
-
{"type":"
|
|
23
|
+
{"type":"ready","url":"https://0123456789abcdef0123.letmeknow.dev/"}
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
Open the public URL for the human. The directory defaults to the current working directory. Set `LETMEKNOW_URL` to use another compatible relay. There are no host or port options: the CLI intentionally has no listening socket.
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
{"type":"session","id":"open-1","url":"https://0123456789abcdef0123.letmeknow.dev/","expires_after_disconnect":600}
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
The URL immediately serves a shell with a waiting message. One browser may use it at a time.
|
|
33
|
-
|
|
34
|
-
## Render HTML and CSS
|
|
35
|
-
|
|
36
|
-
```json
|
|
37
|
-
{"type":"render","id":"render-1","body":"<h1>Search invoices</h1><form id=\"search\" action=\"search\" method=\"post\" data-lmk-target=\"results\"><label>Customer<input name=\"customer\" required></label><button>Search</button></form><section id=\"results\"><p>Enter a customer.</p></section>","css":"#results { margin-top: 2rem; }"}
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
A connected browser receives the render immediately. Wait for its acknowledgement and retain the `render_id`:
|
|
41
|
-
|
|
42
|
-
```json
|
|
43
|
-
{"type":"ack","id":"render-1","render_id":"b87438c2-4f1c-44bd-9875-6cc64370b8aa"}
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
Use semantic HTML: headings, sections, paragraphs, lists, tables, `dl`, forms, labels, controls, buttons, `details`, progress, and images. Do not include `<html>`, `<head>`, `<body>`, `<main id="lmk-view">`, scripts, style elements, inline handlers, inline `style`, iframes, or HTMX attributes.
|
|
28
|
+
Do not send file commands to stdin. Read and write the folder directly. Keep the process running while the human uses the page.
|
|
47
29
|
|
|
48
|
-
|
|
30
|
+
## Build the page
|
|
49
31
|
|
|
50
|
-
|
|
32
|
+
Create an ordinary Vite page in the folder, usually `index.html`, plus any CSS, JavaScript, images, or other assets it needs. Use semantic HTML and accessible labels, headings, sections, tables, and controls.
|
|
51
33
|
|
|
52
|
-
|
|
34
|
+
The relay is live:
|
|
53
35
|
|
|
54
|
-
|
|
36
|
+
- HTML changes update the current document body without a full page reload.
|
|
37
|
+
- Existing form values, focus, text selection, and scroll position are restored after an HTML update.
|
|
38
|
+
- CSS links are refreshed without navigating.
|
|
39
|
+
- Changes to another HTML route do not replace the current route.
|
|
55
40
|
|
|
56
|
-
|
|
41
|
+
An optional status element gives the human feedback after a form submission:
|
|
57
42
|
|
|
58
43
|
```html
|
|
59
|
-
<
|
|
60
|
-
<label>Reason<textarea name="reason" required></textarea></label>
|
|
61
|
-
<button name="decision" value="approve" formaction="approve">Approve</button>
|
|
62
|
-
<button name="decision" value="reject" formaction="reject">Reject</button>
|
|
63
|
-
</form>
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
Rules:
|
|
67
|
-
|
|
68
|
-
- Use `method="post"`.
|
|
69
|
-
- Give each form a stable, unique `id`.
|
|
70
|
-
- Give controls meaningful `name` values.
|
|
71
|
-
- Use an action identifier containing letters, digits, `.`, `_`, `:`, or `-`.
|
|
72
|
-
- A submit button's standard `formaction` may override the form action.
|
|
73
|
-
- Native `required`, input types, ranges, and patterns validate locally.
|
|
74
|
-
|
|
75
|
-
The runtime captures `FormData(form, submitter)` before disabling controls. Selected radios, checked boxes, ordinary controls, and the clicked submit button are included. Repeated names become string arrays. File inputs are rejected.
|
|
76
|
-
|
|
77
|
-
## Standalone actions
|
|
78
|
-
|
|
79
|
-
```html
|
|
80
|
-
<button type="button" data-lmk-action="refresh-status" data-lmk-target="status">
|
|
81
|
-
Refresh
|
|
82
|
-
</button>
|
|
44
|
+
<p data-letmeknow-status aria-live="polite"></p>
|
|
83
45
|
```
|
|
84
46
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
## Whole and partial updates
|
|
47
|
+
## Receive form submissions
|
|
88
48
|
|
|
89
|
-
|
|
49
|
+
GET and POST forms are intercepted before navigation and sent through the relay to the CLI. Read stdout for a `submit` event:
|
|
90
50
|
|
|
91
51
|
```html
|
|
92
|
-
<form id="search" action="search" method="post"
|
|
93
|
-
<input name="query">
|
|
94
|
-
<button>Search</button>
|
|
52
|
+
<form id="search" action="/search" method="post">
|
|
53
|
+
<label>Query <input name="query" required></label>
|
|
54
|
+
<button name="scope" value="all">Search all</button>
|
|
95
55
|
</form>
|
|
96
|
-
<section id="results"></section>
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
The target is one bare element ID without `#`. Every update uses `innerHTML`. A submit button may override its form's target.
|
|
100
|
-
|
|
101
|
-
## Handle actions
|
|
102
|
-
|
|
103
|
-
```json
|
|
104
|
-
{"type":"action","id":"2ee81a6b-1035-40a7-a90d-c1e02f426baa","render_id":"b87438c2-4f1c-44bd-9875-6cc64370b8aa","action_id":"search","form_id":"search","target_id":"results","trigger":{"id":null,"name":null,"value":null},"values":{"query":"quarterly report"}}
|
|
105
|
-
```
|
|
106
|
-
|
|
107
|
-
Use:
|
|
108
|
-
|
|
109
|
-
- `id` to respond to this exact action.
|
|
110
|
-
- `render_id` to identify the page revision that produced it.
|
|
111
|
-
- `action_id` for user intent.
|
|
112
|
-
- `form_id` and `target_id` for context.
|
|
113
|
-
- `trigger` for the clicked button.
|
|
114
|
-
- `values` for the submitted form snapshot.
|
|
115
|
-
|
|
116
|
-
Validate actions and values. Treat values as untrusted and HTML-escape reflected text.
|
|
117
|
-
|
|
118
|
-
Respond with HTML for the target contents:
|
|
119
|
-
|
|
120
|
-
```json
|
|
121
|
-
{"type":"response","id":"response-1","request_id":"2ee81a6b-1035-40a7-a90d-c1e02f426baa","body":"<table><tr><th>Invoice</th><th>Amount</th></tr><tr><td>INV-42</td><td>$800</td></tr></table>"}
|
|
122
56
|
```
|
|
123
57
|
|
|
124
|
-
|
|
58
|
+
The event is:
|
|
125
59
|
|
|
126
60
|
```json
|
|
127
|
-
{"type":"
|
|
61
|
+
{"type":"submit","id":"…","method":"POST","action":"/search","form_id":"search","trigger":{"id":null,"name":"scope","value":"all"},"values":{"query":"quarterly report","scope":"all"}}
|
|
128
62
|
```
|
|
129
63
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
## One-browser behavior
|
|
133
|
-
|
|
134
|
-
The first browser claims the session with a private credential. Reload and laptop wake reconnect automatically. A second browser cannot connect while the first is active.
|
|
135
|
-
|
|
136
|
-
Liveness is checked only when another browser tries to claim, at most once every five seconds. After a browser disconnect, its credential has five seconds of exclusive reconnect priority. After that, the first old or new browser to connect wins.
|
|
137
|
-
|
|
138
|
-
A reconnect receives one canonical snapshot of committed HTML, CSS, render ID, and pending actions. It does not replay old user actions. Same-tab drafts are restored from `sessionStorage`; drafts do not transfer during takeover.
|
|
139
|
-
|
|
140
|
-
## Assets
|
|
64
|
+
Rules:
|
|
141
65
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
66
|
+
- Give interactive forms a stable, meaningful `id`.
|
|
67
|
+
- Give controls meaningful `name` values.
|
|
68
|
+
- Use normal relative or same-origin actions.
|
|
69
|
+
- Use `formaction` and `formmethod` on submitters when different buttons have different intents.
|
|
70
|
+
- Native `required`, input types, ranges, and patterns validate in the browser before the event is sent.
|
|
71
|
+
- Repeated names become string arrays.
|
|
72
|
+
- File inputs and cross-origin actions are not supported.
|
|
145
73
|
|
|
146
|
-
|
|
74
|
+
The event ID identifies the submission. There is no response packet. Validate its values, edit the files, and let the live preview show the result. Do not write JSON commands to stdin.
|
|
147
75
|
|
|
148
|
-
|
|
149
|
-
<img src="assets/logo.png" alt="Company logo">
|
|
150
|
-
```
|
|
76
|
+
## Example response workflow
|
|
151
77
|
|
|
152
|
-
|
|
78
|
+
1. Render the initial state in `index.html`.
|
|
79
|
+
2. Wait for a `submit` event on stdout.
|
|
80
|
+
3. Validate its `values` and `action`.
|
|
81
|
+
4. Rewrite the relevant HTML or data file in the workspace.
|
|
82
|
+
5. The browser updates in place through the relay.
|
|
153
83
|
|
|
154
|
-
|
|
84
|
+
Escape untrusted values before placing them in HTML. Treat browser input as untrusted even though the folder is local to the agent.
|
|
155
85
|
|
|
156
|
-
|
|
157
|
-
{"type":"close","id":"close-1"}
|
|
158
|
-
```
|
|
86
|
+
## Security
|
|
159
87
|
|
|
160
|
-
|
|
88
|
+
The public URL is a bearer capability. The relay receives the served files and submitted values. Do not put secrets in the preview folder or submit credentials unless that is intentional. The folder is trusted executable code from the browser's perspective.
|
|
161
89
|
|
|
162
|
-
|
|
90
|
+
The CLI disables Vite config discovery and limits filesystem access to the selected folder. It makes outbound relay connections only; it does not accept inbound browser connections.
|
|
163
91
|
|
|
164
|
-
|
|
92
|
+
## Stop
|
|
165
93
|
|
|
166
|
-
|
|
94
|
+
Send `SIGINT` or `SIGTERM` to stop the CLI. The relay session expires after producer disconnect. `--skill` prints these instructions without starting a session.
|
package/bin/letmeknow.js
CHANGED
|
@@ -1,299 +1,460 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
3
|
+
import { createServer } from "vite";
|
|
4
|
+
import { existsSync, readFileSync, statSync, writeSync } from "node:fs";
|
|
5
|
+
import { relative, resolve, sep } from "node:path";
|
|
6
|
+
import { Readable, Writable } from "node:stream";
|
|
5
7
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
const connectionAttemptTimeout = 10_000;
|
|
18
|
-
const maxRetryDelay = 5_000;
|
|
19
|
-
const subprotocolTokenPattern = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
8
|
+
const MAX_BODY_BYTES = 1024 * 1024;
|
|
9
|
+
const GRACE_SECONDS = 10 * 60;
|
|
10
|
+
const CONNECTION_TIMEOUT = 10_000;
|
|
11
|
+
const MAX_RETRY_DELAY = 5_000;
|
|
12
|
+
const credentialPattern = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
13
|
+
const clientPath = "/__letmeknow_client.js";
|
|
14
|
+
const clientId = "\0letmeknow-client";
|
|
15
|
+
const client = String.raw`
|
|
16
|
+
const key="letmeknow-client:"+location.host+location.pathname;
|
|
17
|
+
const draftKey="letmeknow-draft:"+location.host+location.pathname;
|
|
18
|
+
let credential=sessionStorage.getItem(key);
|
|
20
19
|
let socket;
|
|
21
|
-
let input;
|
|
22
|
-
let credential;
|
|
23
|
-
let sessionUrl;
|
|
24
20
|
let retryTimer;
|
|
25
|
-
let
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
let
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const queued = [];
|
|
36
|
-
|
|
37
|
-
function endpoint() {
|
|
38
|
-
const url = new URL(control);
|
|
39
|
-
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
40
|
-
url.pathname = "/v1/connect";
|
|
41
|
-
url.search = "";
|
|
42
|
-
url.hash = "";
|
|
43
|
-
if (credential && sessionUrl) {
|
|
44
|
-
const publicUrl = new URL(sessionUrl);
|
|
45
|
-
const hostCode = publicUrl.hostname.match(/^([a-f0-9]{20})\.letmeknow\.dev$/);
|
|
46
|
-
const pathCode = publicUrl.pathname.match(/^\/s\/([a-f0-9]{20})(?:\/|$)/);
|
|
47
|
-
const code = hostCode?.[1] || pathCode?.[1];
|
|
48
|
-
if (code) {
|
|
49
|
-
url.searchParams.set("code", code);
|
|
50
|
-
}
|
|
21
|
+
let terminal=false;
|
|
22
|
+
const stateKey=(control,index)=>control.id?"#"+control.id:(control.form?.id??"")+":"+control.name+":"+control.type+":"+index;
|
|
23
|
+
const controls=root=>[...root.querySelectorAll("input,select,textarea")];
|
|
24
|
+
const snapshot=()=>{
|
|
25
|
+
const state=new Map();
|
|
26
|
+
let activeKey;
|
|
27
|
+
for(const [index,control] of controls(document).entries()){
|
|
28
|
+
const key=stateKey(control,index);
|
|
29
|
+
state.set(key,{value:control.value,checked:control.checked,selected:control instanceof HTMLSelectElement?[...control.options].filter(option=>option.selected).map(option=>option.value):undefined,start:typeof control.selectionStart==="number"?control.selectionStart:undefined,end:typeof control.selectionEnd==="number"?control.selectionEnd:undefined});
|
|
30
|
+
if(control===document.activeElement)activeKey=key;
|
|
51
31
|
}
|
|
52
|
-
return
|
|
53
|
-
}
|
|
32
|
+
return {state,activeKey,x:scrollX,y:scrollY};
|
|
33
|
+
};
|
|
34
|
+
const restore=saved=>{
|
|
35
|
+
let active;
|
|
36
|
+
for(const [index,control] of controls(document).entries()){
|
|
37
|
+
const state=saved.state.get(stateKey(control,index));
|
|
38
|
+
if(!state)continue;
|
|
39
|
+
if(control instanceof HTMLSelectElement&&state.selected)for(const option of control.options)option.selected=state.selected.includes(option.value);
|
|
40
|
+
else if(control.type==="checkbox"||control.type==="radio")control.checked=state.checked;
|
|
41
|
+
else{control.value=state.value;if(typeof state.start==="number"&&typeof control.setSelectionRange==="function")control.setSelectionRange(state.start,state.end)}
|
|
42
|
+
if(stateKey(control,index)===saved.activeKey)active=control;
|
|
43
|
+
}
|
|
44
|
+
active?.focus();
|
|
45
|
+
scrollTo(saved.x,saved.y);
|
|
46
|
+
};
|
|
47
|
+
const status=message=>{const element=document.querySelector("[data-letmeknow-status]");if(element)element.textContent=message};
|
|
48
|
+
const sessionPrefix=location.pathname.match(/^\/s\/[a-f0-9]{20}\//)?.[0];
|
|
49
|
+
const currentPath=()=>{const path=sessionPrefix?location.pathname.slice(sessionPrefix.length-1)||"/":location.pathname;return path.endsWith("/")?path+"index.html":path};
|
|
50
|
+
const refresh=async()=>{
|
|
51
|
+
const saved=snapshot();
|
|
52
|
+
const response=await fetch(location.href,{cache:"no-store",headers:{Accept:"text/html"}});
|
|
53
|
+
if(!response.ok)throw new Error("page refresh failed");
|
|
54
|
+
const next=new DOMParser().parseFromString(await response.text(),"text/html");
|
|
55
|
+
document.title=next.title;
|
|
56
|
+
document.body.replaceChildren(...[...next.body.childNodes].filter(node=>!(node instanceof HTMLScriptElement&&node.hasAttribute("data-letmeknow-client"))));
|
|
57
|
+
const links=[...document.head.querySelectorAll("link[rel=stylesheet]")];
|
|
58
|
+
for(const link of links){const url=new URL(link.href);url.searchParams.set("_letmeknow",crypto.randomUUID());link.href=url}
|
|
59
|
+
restore(saved);
|
|
60
|
+
};
|
|
61
|
+
const update=path=>{if(path===currentPath()||path?.endsWith(".css"))refresh().catch(()=>status("The page could not be refreshed"))};
|
|
62
|
+
const connect=()=>{
|
|
63
|
+
clearTimeout(retryTimer);retryTimer=undefined;
|
|
64
|
+
const url=new URL("_letmeknow/client",location.href);url.protocol=url.protocol==="https:"?"wss:":"ws:";
|
|
65
|
+
socket=credential?new WebSocket(url,credential):new WebSocket(url);
|
|
66
|
+
socket.onmessage=event=>{
|
|
67
|
+
const message=JSON.parse(event.data);
|
|
68
|
+
if(message.type==="credential"){credential=message.credential;sessionStorage.setItem(key,credential);return}
|
|
69
|
+
if(message.type==="challenge"){socket.send(JSON.stringify({type:"alive",nonce:message.nonce}));return}
|
|
70
|
+
if(message.type==="busy"){status("This session is open elsewhere");retryTimer=setTimeout(connect,message.retry_after*1000);return}
|
|
71
|
+
if(message.type==="file_update"){update(message.path);return}
|
|
72
|
+
if(message.type==="closed"){terminal=true;sessionStorage.removeItem(key);sessionStorage.removeItem(draftKey);status(message.message)}
|
|
73
|
+
};
|
|
74
|
+
socket.onclose=()=>{if(!terminal&&!retryTimer){status("Reconnecting…");retryTimer=setTimeout(connect,1000)}};
|
|
75
|
+
socket.onerror=()=>{};
|
|
76
|
+
};
|
|
77
|
+
document.addEventListener("submit",async event=>{
|
|
78
|
+
const form=event.target;
|
|
79
|
+
if(!(form instanceof HTMLFormElement))return;
|
|
80
|
+
event.preventDefault();
|
|
81
|
+
const submitter=event.submitter;
|
|
82
|
+
const method=(submitter?.getAttribute("formmethod")??form.getAttribute("method")??"get").toLowerCase();
|
|
83
|
+
if(method!=="get"&&method!=="post"){status("Only GET and POST forms are supported");return}
|
|
84
|
+
if(!form.checkValidity()){form.reportValidity();return}
|
|
85
|
+
let target;
|
|
86
|
+
try{target=new URL(submitter?.getAttribute("formaction")??form.getAttribute("action")??location.href,location.href)}catch{status("Invalid form action");return}
|
|
87
|
+
if(target.origin!==location.origin){status("Form actions must stay on this site");return}
|
|
88
|
+
if(sessionPrefix&&!target.pathname.startsWith(sessionPrefix))target.pathname=(sessionPrefix+target.pathname.replace(/^\//,""));
|
|
89
|
+
const values=new URLSearchParams();
|
|
90
|
+
for(const [name,value] of new FormData(form,submitter)){if(typeof value!=="string"){status("File inputs are not supported");return}values.append(name,value)}
|
|
91
|
+
const actionPath=sessionPrefix?target.pathname.slice(sessionPrefix.length-1)||"/":target.pathname;
|
|
92
|
+
const metadata={id:crypto.randomUUID(),form_id:form.id||null,action:actionPath+target.search,trigger:{id:submitter?.id||null,name:submitter?.getAttribute("name"),value:submitter?.getAttribute("value")}};
|
|
93
|
+
const headers={"X-LetMeKnow-Submission":"1","X-LetMeKnow-ID":encodeURIComponent(metadata.id),"X-LetMeKnow-Form-ID":encodeURIComponent(metadata.form_id??""),"X-LetMeKnow-Action":encodeURIComponent(metadata.action),"X-LetMeKnow-Trigger-ID":encodeURIComponent(metadata.trigger.id??""),"X-LetMeKnow-Trigger-Name":encodeURIComponent(metadata.trigger.name??""),"X-LetMeKnow-Trigger-Value":encodeURIComponent(metadata.trigger.value??"")};
|
|
94
|
+
if(method==="get")for(const [name,value] of values)target.searchParams.append(name,value);
|
|
95
|
+
try{const response=await fetch(target,{method:method.toUpperCase(),headers,...(method==="post"?{body:values}:{})});if(!response.ok)throw new Error();status("Submitted")}catch{status("The submission failed")}
|
|
96
|
+
});
|
|
97
|
+
connect();
|
|
98
|
+
`;
|
|
54
99
|
|
|
55
|
-
function
|
|
56
|
-
|
|
57
|
-
|
|
100
|
+
function encodedHeader(request, name) {
|
|
101
|
+
const value = request.headers[name];
|
|
102
|
+
if (typeof value !== "string" || value === "") return null;
|
|
103
|
+
try {
|
|
104
|
+
return decodeURIComponent(value);
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
58
108
|
}
|
|
59
109
|
|
|
60
|
-
function
|
|
61
|
-
if (
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
process.exitCode = code;
|
|
110
|
+
function addValue(values, name, value) {
|
|
111
|
+
if (Object.prototype.hasOwnProperty.call(values, name)) {
|
|
112
|
+
values[name] = Array.isArray(values[name]) ? [...values[name], value] : [values[name], value];
|
|
113
|
+
} else {
|
|
114
|
+
values[name] = value;
|
|
115
|
+
}
|
|
67
116
|
}
|
|
68
117
|
|
|
69
|
-
function
|
|
70
|
-
return
|
|
118
|
+
function readBody(request) {
|
|
119
|
+
return new Promise((resolveBody, reject) => {
|
|
120
|
+
const chunks = [];
|
|
121
|
+
let size = 0;
|
|
122
|
+
let tooLarge = false;
|
|
123
|
+
request.on("data", chunk => {
|
|
124
|
+
if (tooLarge) return;
|
|
125
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
126
|
+
size += buffer.byteLength;
|
|
127
|
+
if (size > MAX_BODY_BYTES) {
|
|
128
|
+
tooLarge = true;
|
|
129
|
+
request.resume();
|
|
130
|
+
reject(new Error("submission is too large"));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
chunks.push(buffer);
|
|
134
|
+
});
|
|
135
|
+
request.on("end", () => resolveBody(Buffer.concat(chunks)));
|
|
136
|
+
request.on("error", reject);
|
|
137
|
+
});
|
|
71
138
|
}
|
|
72
139
|
|
|
73
|
-
function
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
140
|
+
async function submission(request, response) {
|
|
141
|
+
const url = new URL(request.url || "/", "http://localhost");
|
|
142
|
+
const method = (request.method || "GET").toUpperCase();
|
|
143
|
+
const values = Object.create(null);
|
|
144
|
+
if (method === "GET") {
|
|
145
|
+
for (const [name, value] of url.searchParams) addValue(values, name, value);
|
|
146
|
+
} else if (method === "POST") {
|
|
147
|
+
const body = await readBody(request);
|
|
148
|
+
const contentType = request.headers["content-type"]?.split(";", 1)[0].trim();
|
|
149
|
+
if (contentType !== "application/x-www-form-urlencoded") throw new Error("unsupported submission encoding");
|
|
150
|
+
for (const [name, value] of new URLSearchParams(body.toString("utf8"))) addValue(values, name, value);
|
|
151
|
+
} else {
|
|
152
|
+
throw new Error("unsupported submission method");
|
|
80
153
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
154
|
+
const event = {
|
|
155
|
+
type: "submit",
|
|
156
|
+
id: encodedHeader(request, "x-letmeknow-id"),
|
|
157
|
+
method,
|
|
158
|
+
action: encodedHeader(request, "x-letmeknow-action") || url.pathname,
|
|
159
|
+
form_id: encodedHeader(request, "x-letmeknow-form-id"),
|
|
160
|
+
trigger: {
|
|
161
|
+
id: encodedHeader(request, "x-letmeknow-trigger-id"),
|
|
162
|
+
name: encodedHeader(request, "x-letmeknow-trigger-name"),
|
|
163
|
+
value: encodedHeader(request, "x-letmeknow-trigger-value")
|
|
164
|
+
},
|
|
165
|
+
values
|
|
166
|
+
};
|
|
167
|
+
process.stdout.write(`${JSON.stringify(event)}\n`);
|
|
168
|
+
response.statusCode = 204;
|
|
169
|
+
response.setHeader("Cache-Control", "no-store");
|
|
170
|
+
response.end();
|
|
84
171
|
}
|
|
85
172
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
173
|
+
class RelayRequest extends Readable {
|
|
174
|
+
constructor(packet) {
|
|
175
|
+
super();
|
|
176
|
+
this.method = packet.method;
|
|
177
|
+
this.url = packet.path;
|
|
178
|
+
this.originalUrl = packet.path;
|
|
179
|
+
this.headers = packet.headers || {};
|
|
180
|
+
this.httpVersion = "1.1";
|
|
181
|
+
this.httpVersionMajor = 1;
|
|
182
|
+
this.httpVersionMinor = 1;
|
|
183
|
+
this.socket = { encrypted: false, remoteAddress: "127.0.0.1" };
|
|
184
|
+
this.body = Buffer.from(packet.body || "", "base64");
|
|
185
|
+
this.sent = false;
|
|
96
186
|
}
|
|
97
|
-
}
|
|
98
187
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
return true;
|
|
105
|
-
} catch {
|
|
106
|
-
try {
|
|
107
|
-
socket.close();
|
|
108
|
-
} catch {
|
|
109
|
-
// The close event still determines whether reconnect is needed.
|
|
110
|
-
}
|
|
111
|
-
return false;
|
|
188
|
+
_read() {
|
|
189
|
+
if (this.sent) return;
|
|
190
|
+
this.sent = true;
|
|
191
|
+
this.push(this.body);
|
|
192
|
+
this.push(null);
|
|
112
193
|
}
|
|
113
194
|
}
|
|
114
195
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
function flush() {
|
|
122
|
-
if (finished) return;
|
|
123
|
-
while (queued.length && !closeCommandSent) {
|
|
124
|
-
if (!send(queued[0])) break;
|
|
125
|
-
queued.shift();
|
|
196
|
+
class RelayResponse extends Writable {
|
|
197
|
+
constructor() {
|
|
198
|
+
super();
|
|
199
|
+
this.statusCode = 200;
|
|
200
|
+
this.headers = new Map();
|
|
201
|
+
this.chunks = [];
|
|
126
202
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
203
|
+
|
|
204
|
+
setHeader(name, value) {
|
|
205
|
+
this.headers.set(name.toLowerCase(), Array.isArray(value) ? value.join(", ") : String(value));
|
|
206
|
+
return this;
|
|
130
207
|
}
|
|
131
|
-
}
|
|
132
208
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
process.stderr.write(`letmeknow: server protocol error: ${message}\n`);
|
|
136
|
-
const current = socket;
|
|
137
|
-
if (current) {
|
|
138
|
-
try {
|
|
139
|
-
current.close(1000, "protocol error");
|
|
140
|
-
} catch {
|
|
141
|
-
// The process still exits below.
|
|
142
|
-
}
|
|
209
|
+
getHeader(name) {
|
|
210
|
+
return this.headers.get(name.toLowerCase());
|
|
143
211
|
}
|
|
144
|
-
finish(1);
|
|
145
|
-
}
|
|
146
212
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if (typeof event.data !== "string") {
|
|
150
|
-
protocolFailure("binary WebSocket frame");
|
|
151
|
-
return;
|
|
213
|
+
getHeaders() {
|
|
214
|
+
return Object.fromEntries(this.headers);
|
|
152
215
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
packet = JSON.parse(text);
|
|
157
|
-
} catch {
|
|
158
|
-
protocolFailure("invalid JSON");
|
|
159
|
-
return;
|
|
216
|
+
|
|
217
|
+
hasHeader(name) {
|
|
218
|
+
return this.headers.has(name.toLowerCase());
|
|
160
219
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
220
|
+
|
|
221
|
+
removeHeader(name) {
|
|
222
|
+
this.headers.delete(name.toLowerCase());
|
|
164
223
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
224
|
+
|
|
225
|
+
writeHead(status, headers) {
|
|
226
|
+
this.statusCode = status;
|
|
227
|
+
if (headers) for (const [name, value] of Object.entries(headers)) this.setHeader(name, value);
|
|
228
|
+
return this;
|
|
168
229
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
return;
|
|
230
|
+
|
|
231
|
+
flushHeaders() {}
|
|
232
|
+
|
|
233
|
+
_write(chunk, _encoding, callback) {
|
|
234
|
+
this.chunks.push(Buffer.from(chunk));
|
|
235
|
+
callback();
|
|
176
236
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
if (typeof packet.expires_after_disconnect !== "number"
|
|
183
|
-
|| !Number.isFinite(packet.expires_after_disconnect)
|
|
184
|
-
|| packet.expires_after_disconnect <= 0) {
|
|
185
|
-
protocolFailure("invalid session expiration");
|
|
186
|
-
return;
|
|
187
|
-
}
|
|
188
|
-
sessionUrl = packet.url;
|
|
189
|
-
retryDelay = 100;
|
|
237
|
+
|
|
238
|
+
body() {
|
|
239
|
+
return Buffer.concat(this.chunks);
|
|
190
240
|
}
|
|
191
|
-
if (packet.type === "closing") explicitSessionClosed = true;
|
|
192
|
-
process.stdout.write(`${text}\n`);
|
|
193
241
|
}
|
|
194
242
|
|
|
195
|
-
function
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
243
|
+
function middlewareResponse(response) {
|
|
244
|
+
const body = response.body();
|
|
245
|
+
if (body.byteLength > MAX_BODY_BYTES) throw new Error("response body is too large");
|
|
246
|
+
return {
|
|
247
|
+
type: "http_response",
|
|
248
|
+
request_id: response.requestId,
|
|
249
|
+
status: response.statusCode,
|
|
250
|
+
headers: response.getHeaders(),
|
|
251
|
+
body: body.toString("base64")
|
|
252
|
+
};
|
|
205
253
|
}
|
|
206
254
|
|
|
207
|
-
function
|
|
208
|
-
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
if (socket !== current || connected || finished) return;
|
|
215
|
-
clearConnectionTimer();
|
|
216
|
-
process.stderr.write("letmeknow: WebSocket connection attempt timed out\n");
|
|
255
|
+
async function handleRequest(server, packet, send) {
|
|
256
|
+
const request = new RelayRequest(packet);
|
|
257
|
+
const response = new RelayResponse();
|
|
258
|
+
response.requestId = packet.request_id;
|
|
259
|
+
await new Promise((resolveRequest, rejectRequest) => {
|
|
260
|
+
response.once("finish", resolveRequest);
|
|
261
|
+
response.once("error", rejectRequest);
|
|
217
262
|
try {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
263
|
+
server.middlewares(request, response, () => {
|
|
264
|
+
if (!response.writableEnded) {
|
|
265
|
+
response.statusCode = 404;
|
|
266
|
+
response.end("Not found");
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
} catch (cause) {
|
|
270
|
+
rejectRequest(cause);
|
|
221
271
|
}
|
|
222
|
-
socket = undefined;
|
|
223
|
-
connected = false;
|
|
224
|
-
if (reconnecting) retry();
|
|
225
|
-
else finish(1);
|
|
226
|
-
}, connectionAttemptTimeout);
|
|
227
|
-
current.addEventListener("message", handleMessage);
|
|
228
|
-
current.addEventListener("error", () => {
|
|
229
|
-
if (!finished) process.stderr.write("letmeknow: WebSocket connection failed; retrying\n");
|
|
230
|
-
});
|
|
231
|
-
current.addEventListener("open", () => {
|
|
232
|
-
if (socket !== current || finished) return;
|
|
233
|
-
clearConnectionTimer();
|
|
234
|
-
connected = true;
|
|
235
|
-
retryDelay = 100;
|
|
236
|
-
if (reconnecting) retryUntil = 0;
|
|
237
|
-
flush();
|
|
238
|
-
});
|
|
239
|
-
current.addEventListener("close", (event) => {
|
|
240
|
-
if (socket !== current) return;
|
|
241
|
-
clearConnectionTimer();
|
|
242
|
-
connected = false;
|
|
243
|
-
if (finished) return;
|
|
244
|
-
socket = undefined;
|
|
245
|
-
if (closeCommandSent || signalRequested || explicitSessionClosed || (stdinClosed && !queued.length)) {
|
|
246
|
-
finish(0);
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
|
-
if (!credential || !sessionUrl) {
|
|
250
|
-
process.stderr.write(`letmeknow: connection closed (${event.code}${event.reason ? `: ${event.reason}` : ""})\n`);
|
|
251
|
-
finish(1);
|
|
252
|
-
return;
|
|
253
|
-
}
|
|
254
|
-
if (!retryUntil) retryUntil = Date.now() + graceSeconds * 1_000;
|
|
255
|
-
retry();
|
|
256
272
|
});
|
|
273
|
+
send(middlewareResponse(response));
|
|
257
274
|
}
|
|
258
275
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
stdinClosed = true;
|
|
266
|
-
if (!socket) {
|
|
267
|
-
if (!queued.length) finish(0);
|
|
268
|
-
return;
|
|
276
|
+
function options(args) {
|
|
277
|
+
let root;
|
|
278
|
+
for (const argument of args) {
|
|
279
|
+
if (argument.startsWith("-")) throw new Error(`unknown option: ${argument}`);
|
|
280
|
+
if (root !== undefined) throw new Error("only one directory may be provided");
|
|
281
|
+
root = resolve(argument);
|
|
269
282
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
283
|
+
root = root || process.cwd();
|
|
284
|
+
if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error(`directory does not exist: ${root}`);
|
|
285
|
+
return { root };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function endpoint(control, credential, sessionUrl) {
|
|
289
|
+
const url = new URL(control);
|
|
290
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
291
|
+
url.pathname = "/v1/connect";
|
|
292
|
+
url.search = "";
|
|
293
|
+
url.hash = "";
|
|
294
|
+
if (credential && sessionUrl) {
|
|
295
|
+
const publicUrl = new URL(sessionUrl);
|
|
296
|
+
const hostCode = publicUrl.hostname.match(/^([a-f0-9]{20})\.letmeknow\.dev$/);
|
|
297
|
+
const pathCode = publicUrl.pathname.match(/^\/s\/([a-f0-9]{20})(?:\/|$)/);
|
|
298
|
+
const code = hostCode?.[1] || pathCode?.[1];
|
|
299
|
+
if (code) url.searchParams.set("code", code);
|
|
281
300
|
}
|
|
282
|
-
|
|
301
|
+
return url;
|
|
302
|
+
}
|
|
283
303
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
304
|
+
function validSessionUrl(value) {
|
|
305
|
+
if (typeof value !== "string") return false;
|
|
306
|
+
let url;
|
|
307
|
+
try {
|
|
308
|
+
url = new URL(value);
|
|
309
|
+
} catch {
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
|
313
|
+
if (/^[a-f0-9]{20}\.letmeknow\.dev$/.test(url.hostname)) return true;
|
|
314
|
+
return /^\/s\/[a-f0-9]{20}(?:\/|$)/.test(url.pathname);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function start(args) {
|
|
318
|
+
const { root } = options(args);
|
|
319
|
+
const control = process.env.LETMEKNOW_URL || "https://letmeknow.dev";
|
|
320
|
+
let send = () => false;
|
|
321
|
+
const vite = await createServer({
|
|
322
|
+
root,
|
|
323
|
+
configFile: false,
|
|
324
|
+
appType: "spa",
|
|
325
|
+
css: { postcss: false },
|
|
326
|
+
logLevel: "silent",
|
|
327
|
+
server: { middlewareMode: true, hmr: false, ws: false, fs: { strict: true, allow: [root], deny: ["**/.env", "**/.env.*", "**/.git/**", "**/*.key", "**/*.pem", "**/*.p12", "**/*.sqlite", "**/*.db"] } },
|
|
328
|
+
plugins: [{
|
|
329
|
+
name: "letmeknow-relay",
|
|
330
|
+
resolveId(id) { return id === clientPath ? clientId : undefined; },
|
|
331
|
+
load(id) { return id === clientId ? client : undefined; },
|
|
332
|
+
configureServer(server) {
|
|
333
|
+
server.middlewares.use((request, response, next) => {
|
|
334
|
+
if (request.headers["x-letmeknow-submission"] !== "1") {
|
|
335
|
+
next();
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
submission(request, response).catch(cause => {
|
|
339
|
+
response.statusCode = cause instanceof Error && cause.message === "submission is too large" ? 413 : 400;
|
|
340
|
+
response.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
341
|
+
response.end(cause instanceof Error ? cause.message : "invalid submission");
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
const update = file => send({ type: "file_update", path: "/" + relative(root, file).split(sep).join("/") });
|
|
345
|
+
server.watcher.on("change", update);
|
|
346
|
+
server.watcher.on("add", update);
|
|
347
|
+
server.watcher.on("unlink", update);
|
|
348
|
+
},
|
|
349
|
+
transformIndexHtml(html) {
|
|
350
|
+
const script = `<script type="module" src="${clientPath}" data-letmeknow-client></script>`;
|
|
351
|
+
return html.includes("</body>") ? html.replace("</body>", `${script}</body>`) : `${html}${script}`;
|
|
293
352
|
}
|
|
294
|
-
}
|
|
295
|
-
finish(0);
|
|
353
|
+
}]
|
|
296
354
|
});
|
|
355
|
+
|
|
356
|
+
let socket;
|
|
357
|
+
let credential;
|
|
358
|
+
let sessionUrl;
|
|
359
|
+
let retryTimer;
|
|
360
|
+
let connectionTimer;
|
|
361
|
+
let retryDelay = 100;
|
|
362
|
+
let retryUntil = 0;
|
|
363
|
+
let stopped = false;
|
|
364
|
+
let ready = false;
|
|
365
|
+
const stop = async code => {
|
|
366
|
+
if (stopped) return;
|
|
367
|
+
stopped = true;
|
|
368
|
+
clearTimeout(retryTimer);
|
|
369
|
+
clearTimeout(connectionTimer);
|
|
370
|
+
try { socket?.close(); } catch {}
|
|
371
|
+
await vite.close();
|
|
372
|
+
process.exit(code);
|
|
373
|
+
};
|
|
374
|
+
process.once("SIGINT", () => void stop(0));
|
|
375
|
+
process.once("SIGTERM", () => void stop(0));
|
|
376
|
+
|
|
377
|
+
const retry = () => {
|
|
378
|
+
if (stopped || Date.now() >= retryUntil) return void stop(1);
|
|
379
|
+
retryTimer = setTimeout(() => {
|
|
380
|
+
retryTimer = undefined;
|
|
381
|
+
connect();
|
|
382
|
+
}, retryDelay);
|
|
383
|
+
retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY);
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
const connect = () => {
|
|
387
|
+
if (stopped) return;
|
|
388
|
+
const reconnecting = Boolean(credential && sessionUrl);
|
|
389
|
+
const current = socket = reconnecting ? new WebSocket(endpoint(control, credential, sessionUrl), credential) : new WebSocket(endpoint(control));
|
|
390
|
+
connectionTimer = setTimeout(() => {
|
|
391
|
+
if (socket !== current || current.readyState === WebSocket.OPEN || stopped) return;
|
|
392
|
+
try { current.close(); } catch {}
|
|
393
|
+
if (reconnecting) retry(); else void stop(1);
|
|
394
|
+
}, CONNECTION_TIMEOUT);
|
|
395
|
+
current.addEventListener("open", () => {
|
|
396
|
+
if (socket !== current || stopped) return;
|
|
397
|
+
clearTimeout(connectionTimer);
|
|
398
|
+
retryDelay = 100;
|
|
399
|
+
if (reconnecting) retryUntil = 0;
|
|
400
|
+
send = packet => {
|
|
401
|
+
if (current.readyState !== WebSocket.OPEN) return false;
|
|
402
|
+
try { current.send(JSON.stringify(packet)); return true; } catch { return false; }
|
|
403
|
+
};
|
|
404
|
+
if (!reconnecting) send({ type: "open" });
|
|
405
|
+
});
|
|
406
|
+
current.addEventListener("message", event => {
|
|
407
|
+
if (typeof event.data !== "string") return;
|
|
408
|
+
let packet;
|
|
409
|
+
try { packet = JSON.parse(event.data); } catch { return; }
|
|
410
|
+
if (packet.type === "credential") {
|
|
411
|
+
if (typeof packet.credential !== "string" || !credentialPattern.test(packet.credential)) return void stop(1);
|
|
412
|
+
credential = packet.credential;
|
|
413
|
+
} else if (packet.type === "session") {
|
|
414
|
+
if (!validSessionUrl(packet.url)) return void stop(1);
|
|
415
|
+
sessionUrl = packet.url;
|
|
416
|
+
retryDelay = 100;
|
|
417
|
+
if (!ready) {
|
|
418
|
+
ready = true;
|
|
419
|
+
process.stdout.write(`${JSON.stringify({ type: "ready", url: sessionUrl })}\n`);
|
|
420
|
+
}
|
|
421
|
+
} else if (packet.type === "http_request") {
|
|
422
|
+
void handleRequest(vite, packet, response => send(response)).catch(() => send({ type: "http_response", request_id: packet.request_id, status: 500, headers: { "Content-Type": "text/plain; charset=utf-8" }, body: Buffer.from("preview request failed").toString("base64") }));
|
|
423
|
+
} else if (packet.type === "closed") {
|
|
424
|
+
void stop(0);
|
|
425
|
+
} else if (packet.type === "error") {
|
|
426
|
+
void stop(1);
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
current.addEventListener("error", () => {});
|
|
430
|
+
current.addEventListener("close", () => {
|
|
431
|
+
if (socket !== current || stopped) return;
|
|
432
|
+
clearTimeout(connectionTimer);
|
|
433
|
+
send = () => false;
|
|
434
|
+
socket = undefined;
|
|
435
|
+
if (!credential || !sessionUrl) return void stop(1);
|
|
436
|
+
if (!retryUntil) retryUntil = Date.now() + GRACE_SECONDS * 1_000;
|
|
437
|
+
retry();
|
|
438
|
+
});
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
connect();
|
|
442
|
+
await new Promise(() => {});
|
|
297
443
|
}
|
|
298
444
|
|
|
299
|
-
|
|
445
|
+
if (process.argv[2] === "--skill") {
|
|
446
|
+
if (process.argv.length !== 3) {
|
|
447
|
+
process.stderr.write("Usage: npx letmeknow-cli --skill\n");
|
|
448
|
+
process.exit(1);
|
|
449
|
+
}
|
|
450
|
+
writeSync(1, readFileSync(new URL("../SKILL.md", import.meta.url)));
|
|
451
|
+
} else if (process.argv.slice(2).includes("--help") || process.argv.slice(2).includes("-h")) {
|
|
452
|
+
process.stdout.write("Usage: npx letmeknow-cli [directory]\n\nServe a folder through the hosted LetMeKnow relay. The CLI does not listen on a network port. Form submissions are JSON lines on stdout.\n");
|
|
453
|
+
} else {
|
|
454
|
+
try {
|
|
455
|
+
await start(process.argv.slice(2));
|
|
456
|
+
} catch (cause) {
|
|
457
|
+
process.stderr.write(`letmeknow: ${cause instanceof Error ? cause.message : "server failed"}\n`);
|
|
458
|
+
process.exitCode = 1;
|
|
459
|
+
}
|
|
460
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "letmeknow-cli",
|
|
3
|
-
"version": "0.4.
|
|
4
|
-
"description": "A
|
|
3
|
+
"version": "0.4.4",
|
|
4
|
+
"description": "A live Vite preview with agent-readable form submissions.",
|
|
5
5
|
"files": [
|
|
6
6
|
"bin",
|
|
7
7
|
"SKILL.md"
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"letmeknow": "bin/letmeknow.js"
|
|
12
12
|
},
|
|
13
13
|
"engines": {
|
|
14
|
-
"node": ">=22"
|
|
14
|
+
"node": ">=22.12.0"
|
|
15
15
|
},
|
|
16
16
|
"scripts": {
|
|
17
17
|
"dev": "wrangler dev",
|
|
@@ -27,5 +27,8 @@
|
|
|
27
27
|
"vitest": "^4.1.11",
|
|
28
28
|
"wrangler": "^4.126.0",
|
|
29
29
|
"ws": "^8.21.3"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"vite": "^7.3.6"
|
|
30
33
|
}
|
|
31
34
|
}
|