letmeknow-cli 0.4.11 → 0.6.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 +58 -29
- package/SKILL.md +47 -56
- package/bin/letmeknow.js +389 -181
- package/package.json +3 -4
package/README.md
CHANGED
|
@@ -1,78 +1,107 @@
|
|
|
1
1
|
# LetMeKnow
|
|
2
2
|
|
|
3
|
-
LetMeKnow gives an agent
|
|
3
|
+
LetMeKnow gives an agent a temporary public browser surface and structured human feedback. The agent edits an ordinary folder, explicitly publishes coherent revisions, and pulls form submissions as JSON. The CLI connects outbound to the hosted relay and does not listen on a network port.
|
|
4
4
|
|
|
5
|
-
## Start
|
|
5
|
+
## Start a session
|
|
6
6
|
|
|
7
|
-
Node.js 22.12 or newer is required.
|
|
7
|
+
Node.js 22.12 or newer is required. Create a dedicated directory containing only public files, then keep the server running:
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
npx letmeknow-cli ./
|
|
10
|
+
npx letmeknow-cli serve ./preview
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
The
|
|
13
|
+
The server prints one JSON line containing the public bearer URL and initial workspace revision:
|
|
14
14
|
|
|
15
15
|
```json
|
|
16
|
-
{"type":"ready","url":"https://0123456789abcdef0123.letmeknow.dev/"}
|
|
16
|
+
{"type":"ready","url":"https://0123456789abcdef0123.letmeknow.dev/","workspace":"…","workspace_sequence":1}
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
Anyone with the URL can view the published workspace and submit its forms. A graceful stop closes the session; an unexpected relay disconnect can reconnect for up to ten minutes. Diagnostics go to stderr.
|
|
20
|
+
|
|
21
|
+
## Publish revisions
|
|
22
|
+
|
|
23
|
+
`serve` snapshots the initial folder. Later filesystem changes remain private until explicitly published:
|
|
20
24
|
|
|
21
25
|
```bash
|
|
22
|
-
|
|
26
|
+
npx letmeknow-cli pull ./preview --wait 30
|
|
27
|
+
npx letmeknow-cli push ./preview --based-on <batch-token>
|
|
23
28
|
```
|
|
24
29
|
|
|
25
|
-
|
|
30
|
+
`pull` returns pending browser interactions, the current workspace, and an opaque batch token:
|
|
26
31
|
|
|
27
|
-
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"ok": true,
|
|
35
|
+
"type": "batch",
|
|
36
|
+
"token": "…",
|
|
37
|
+
"workspace": "…",
|
|
38
|
+
"workspace_sequence": 1,
|
|
39
|
+
"frontier": 1,
|
|
40
|
+
"events": [
|
|
41
|
+
{
|
|
42
|
+
"type": "submit",
|
|
43
|
+
"id": "…",
|
|
44
|
+
"form_id": "decision",
|
|
45
|
+
"values": {"decision":"approve"},
|
|
46
|
+
"based_on": "…",
|
|
47
|
+
"context": {"based_on":"…","current":"…","relationship":"current"}
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
```
|
|
28
52
|
|
|
29
|
-
|
|
53
|
+
A repeated pull returns uncommitted events again. `push` atomically snapshots the folder, commits the batch, and reloads connected browsers once. Events arriving while the agent works remain for the next pull. A push can also publish independent work from an empty batch.
|
|
30
54
|
|
|
31
|
-
|
|
55
|
+
If a batch requires no workspace change, commit it without publishing:
|
|
32
56
|
|
|
33
|
-
|
|
57
|
+
```bash
|
|
58
|
+
npx letmeknow-cli ack ./preview --based-on <batch-token>
|
|
59
|
+
```
|
|
34
60
|
|
|
35
|
-
|
|
61
|
+
`push` and `ack` are idempotent for a token. They fail if another command has moved the workspace or event cursor first.
|
|
36
62
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
63
|
+
The commands communicate with `serve` through a private local Unix socket. `--skill` prints agent instructions without starting a session.
|
|
64
|
+
|
|
65
|
+
## Workspace behavior
|
|
66
|
+
|
|
67
|
+
A published workspace is an immutable temporary snapshot of the selected folder. It may contain HTML, CSS, JavaScript, images, data, and linked pages. The relay injects a small runtime into HTML and serves all files from the same workspace revision.
|
|
40
68
|
|
|
41
|
-
|
|
69
|
+
A successful push sends one revision notification. Browsers reload and preserve scroll position plus the values, checked state, and selected state of controls with stable unique IDs. Missing pages and connection-status pages remain live and recover on a later publication or reconnect.
|
|
42
70
|
|
|
43
|
-
Forms
|
|
71
|
+
## Forms
|
|
72
|
+
|
|
73
|
+
Use native same-origin GET or POST forms:
|
|
44
74
|
|
|
45
75
|
```html
|
|
46
76
|
<form id="decision" action="/decide" method="post">
|
|
47
|
-
<label>Comment <textarea name="comment"></textarea></label>
|
|
77
|
+
<label>Comment <textarea id="comment" name="comment"></textarea></label>
|
|
48
78
|
<button name="decision" value="approve">Approve</button>
|
|
49
79
|
<button name="decision" value="reject">Reject</button>
|
|
50
80
|
</form>
|
|
51
81
|
```
|
|
52
82
|
|
|
53
|
-
|
|
83
|
+
Before delivery, the runtime gives each logical submission an opaque UUID and persists the serialized request in IndexedDB. Network retries and page reloads reuse that UUID. The CLI deduplicates accepted events, so a transport retry does not become another interaction. Distinct intentional submissions receive distinct IDs.
|
|
54
84
|
|
|
55
|
-
|
|
56
|
-
{"type":"submit","id":"…","method":"POST","action":"/decide","form_id":"decision","trigger":{"id":null,"name":"decision","value":"approve"},"values":{"comment":"Looks good","decision":"approve"}}
|
|
57
|
-
```
|
|
85
|
+
The runtime displays **Sending…** or **Uploading…**, followed by **Sent. Waiting for an update…** or an error. Add `[data-letmeknow-status]` to choose the status location. Native validation runs before submission. Repeated field names become arrays.
|
|
58
86
|
|
|
59
|
-
|
|
87
|
+
POST forms may include files within the 1 MiB total request limit. `pull` events contain attachment metadata and private temporary paths. Attachments remain available until `serve` stops and are not public unless deliberately copied into the workspace and pushed.
|
|
60
88
|
|
|
61
|
-
|
|
89
|
+
Every submission records the exact workspace revision shown to the user. Its derived `context.relationship` is `current`, `stale`, or `unknown`, allowing the agent to decide whether to apply, rebase, or reject old feedback.
|
|
62
90
|
|
|
63
91
|
## Security
|
|
64
92
|
|
|
65
|
-
The
|
|
93
|
+
The URL is a bearer capability. The relay receives published files and submitted values. Keep secrets and unrelated files outside the preview directory.
|
|
66
94
|
|
|
67
|
-
The
|
|
95
|
+
The CLI excludes `.env`, `.git`, SSH keys, private-key files, and database files, and prevents symlink escapes. Processes that can write the workspace and invoke `push` are trusted publishers. Browser values, filenames, media types, and attachment contents remain untrusted input.
|
|
68
96
|
|
|
69
97
|
## Development
|
|
70
98
|
|
|
71
99
|
```bash
|
|
72
100
|
npm install
|
|
73
101
|
npm test
|
|
102
|
+
npm run test:browser
|
|
74
103
|
npm run dev
|
|
75
104
|
npm run deploy
|
|
76
105
|
```
|
|
77
106
|
|
|
78
|
-
`npm run dev` and `npm run deploy` operate the Cloudflare relay.
|
|
107
|
+
The browser test requires Firefox and geckodriver. `npm run dev` and `npm run deploy` operate the Cloudflare relay.
|
package/SKILL.md
CHANGED
|
@@ -1,98 +1,89 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: letmeknow
|
|
3
|
-
description:
|
|
3
|
+
description: Publish a temporary browser workspace, pull structured human feedback, and push coherent agent revisions.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# LetMeKnow
|
|
7
7
|
|
|
8
|
-
Use LetMeKnow when
|
|
9
|
-
|
|
10
|
-
The CLI does **not** listen on a local network port.
|
|
8
|
+
Use LetMeKnow when a human should inspect or interact with an agent-managed page, report, dashboard, approval, quiz, table, or prototype.
|
|
11
9
|
|
|
12
10
|
## Start
|
|
13
11
|
|
|
14
|
-
|
|
12
|
+
Create a dedicated directory containing only public files and keep the server running:
|
|
15
13
|
|
|
16
14
|
```bash
|
|
17
|
-
npx letmeknow-cli ./
|
|
15
|
+
npx letmeknow-cli serve ./preview
|
|
18
16
|
```
|
|
19
17
|
|
|
20
|
-
Node.js 22.12 or newer is required.
|
|
18
|
+
Node.js 22.12 or newer is required. The first stdout JSON line contains the bearer URL and initial workspace revision:
|
|
21
19
|
|
|
22
20
|
```json
|
|
23
|
-
{"type":"ready","url":"https://0123456789abcdef0123.letmeknow.dev/"}
|
|
21
|
+
{"type":"ready","url":"https://0123456789abcdef0123.letmeknow.dev/","workspace":"…","workspace_sequence":1}
|
|
24
22
|
```
|
|
25
23
|
|
|
26
|
-
|
|
24
|
+
Give the URL to the human. Anyone with it can view the workspace and submit forms. The CLI connects outbound and opens no network port. Diagnostics go to stderr.
|
|
27
25
|
|
|
28
|
-
|
|
26
|
+
## Agent loop
|
|
29
27
|
|
|
30
|
-
|
|
28
|
+
Filesystem writes are private drafts. Explicitly pull feedback and publish coherent revisions:
|
|
31
29
|
|
|
32
|
-
|
|
30
|
+
```bash
|
|
31
|
+
npx letmeknow-cli pull ./preview --wait 30
|
|
32
|
+
# validate feedback and edit files
|
|
33
|
+
npx letmeknow-cli push ./preview --based-on <batch-token>
|
|
34
|
+
```
|
|
33
35
|
|
|
34
|
-
|
|
36
|
+
`pull` returns pending events, their derived causal context, the current workspace, and an opaque token. Pulling does not consume events; they are returned again after a crash. A successful `push` atomically snapshots the folder, commits that batch, and reloads connected browsers once. Feedback that arrives while you work remains for the next pull.
|
|
35
37
|
|
|
36
|
-
|
|
38
|
+
When a batch needs no visible workspace change:
|
|
37
39
|
|
|
38
|
-
|
|
40
|
+
```bash
|
|
41
|
+
npx letmeknow-cli ack ./preview --based-on <batch-token>
|
|
42
|
+
```
|
|
39
43
|
|
|
40
|
-
|
|
41
|
-
- CSS changes cache-bust matching linked stylesheets without navigating.
|
|
42
|
-
- Changes to a different HTML route do not disturb the current page.
|
|
43
|
-
- Changes to other assets reload the page. Arbitrary JavaScript heap state cannot be preserved.
|
|
44
|
+
Both commands are idempotent for a token. Do not edit while `push` is snapshotting. Do not write commands to the long-running server's stdin.
|
|
44
45
|
|
|
45
|
-
|
|
46
|
+
A push may publish independent work from an empty batch. Use the token returned by an empty `pull`.
|
|
46
47
|
|
|
47
|
-
|
|
48
|
-
<p data-letmeknow-status aria-live="polite"></p>
|
|
49
|
-
```
|
|
48
|
+
## Build the workspace
|
|
50
49
|
|
|
51
|
-
|
|
50
|
+
Use ordinary HTML, CSS, JavaScript, images, and relative links. Give forms stable IDs and controls meaningful names. Give editable controls stable unique IDs so values and scroll position survive published revisions.
|
|
52
51
|
|
|
53
|
-
GET
|
|
52
|
+
Use native same-origin GET or POST forms:
|
|
54
53
|
|
|
55
54
|
```html
|
|
56
|
-
<form id="
|
|
57
|
-
<label>
|
|
58
|
-
<button name="
|
|
55
|
+
<form id="review" action="/review" method="post">
|
|
56
|
+
<label>Comment <textarea id="comment" name="comment"></textarea></label>
|
|
57
|
+
<button name="decision" value="approve">Approve</button>
|
|
58
|
+
<button name="decision" value="reject">Reject</button>
|
|
59
59
|
</form>
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
-
The
|
|
62
|
+
The browser persists each serialized submission before delivery and retries it with the same opaque UUID after network failures or reloads. The CLI deduplicates retries. Distinct intentional submissions remain distinct. Native validation and repeated field names work normally.
|
|
63
|
+
|
|
64
|
+
A pulled event includes the workspace the human saw:
|
|
63
65
|
|
|
64
66
|
```json
|
|
65
|
-
{
|
|
67
|
+
{
|
|
68
|
+
"type": "submit",
|
|
69
|
+
"id": "…",
|
|
70
|
+
"form_id": "review",
|
|
71
|
+
"values": {"comment":"Looks good","decision":"approve"},
|
|
72
|
+
"based_on": "…",
|
|
73
|
+
"context": {
|
|
74
|
+
"based_on": "…",
|
|
75
|
+
"current": "…",
|
|
76
|
+
"relationship": "current"
|
|
77
|
+
}
|
|
78
|
+
}
|
|
66
79
|
```
|
|
67
80
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
- Give interactive forms a stable, meaningful `id`.
|
|
71
|
-
- Give controls meaningful `name` values.
|
|
72
|
-
- Use normal relative or same-origin actions.
|
|
73
|
-
- Use `formaction` and `formmethod` on submitters when different buttons have different intents.
|
|
74
|
-
- Native `required`, input types, ranges, and patterns validate in the browser before the event is sent.
|
|
75
|
-
- Repeated names become string arrays.
|
|
76
|
-
- File inputs and cross-origin actions are not supported.
|
|
77
|
-
|
|
78
|
-
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.
|
|
79
|
-
|
|
80
|
-
## Example response workflow
|
|
81
|
-
|
|
82
|
-
1. Render the initial state in `index.html`.
|
|
83
|
-
2. Wait for a `submit` event on stdout.
|
|
84
|
-
3. Validate its `values` and `action`.
|
|
85
|
-
4. Rewrite the relevant HTML or data file in the workspace.
|
|
86
|
-
5. The agent updates the live preview.
|
|
87
|
-
|
|
88
|
-
Escape untrusted values before placing them in HTML. Treat browser input as untrusted even though the folder is local to the agent.
|
|
89
|
-
|
|
90
|
-
## Security
|
|
81
|
+
Treat `stale` feedback deliberately: apply its intent to current state when safe, or show that the artifact changed and ask the human to review again. Never reconstruct the workspace from stale form values.
|
|
91
82
|
|
|
92
|
-
|
|
83
|
+
POST forms may upload files within the 1 MiB request limit. Attachment events contain private temporary paths valid until `serve` stops. Validate names, media types, sizes, contents, actions, and IDs. Copy only deliberate outputs into the public workspace.
|
|
93
84
|
|
|
94
|
-
|
|
85
|
+
Escape untrusted text before placing it in HTML.
|
|
95
86
|
|
|
96
87
|
## Stop
|
|
97
88
|
|
|
98
|
-
Send `SIGINT` or `SIGTERM` to stop the
|
|
89
|
+
Send `SIGINT` or `SIGTERM` to `serve`. A graceful stop closes the public session and removes temporary snapshots, attachments, and the local control socket. `--skill` prints these instructions.
|
package/bin/letmeknow.js
CHANGED
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { existsSync, readFileSync, statSync, writeSync } from "node:fs";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { constants, existsSync, readFileSync, statSync, writeSync } from "node:fs";
|
|
4
|
+
import { chmod, copyFile, lstat, mkdtemp, mkdir, open, readdir, readlink, realpath, rm, stat, symlink, unlink, writeFile } from "node:fs/promises";
|
|
5
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
6
|
+
import net from "node:net";
|
|
7
|
+
import { dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
6
9
|
import { parseArgs } from "node:util";
|
|
7
|
-
import chokidar from "chokidar";
|
|
8
|
-
import ignore from "ignore";
|
|
9
10
|
import { lookup } from "mrmime";
|
|
10
11
|
|
|
11
12
|
const MAX_BODY_BYTES = 1024 * 1024;
|
|
12
13
|
const GRACE_SECONDS = 10 * 60;
|
|
13
14
|
const CONNECTION_TIMEOUT = 10_000;
|
|
15
|
+
const CONTROL_TIMEOUT = 35_000;
|
|
14
16
|
const MAX_RETRY_DELAY = 5_000;
|
|
17
|
+
const CONTROL_PREFIX = "letmeknow-control-";
|
|
18
|
+
const SNAPSHOT_PREFIX = "letmeknow-snapshot-";
|
|
19
|
+
const CONTROL_URL = "https://letmeknow.dev";
|
|
15
20
|
const credentialPattern = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
16
|
-
const
|
|
17
|
-
const privateFilePattern =
|
|
21
|
+
const privateNames = new Set([".env", ".git", ".ssh", "id_rsa", "id_ed25519", "id_ecdsa", "id_dsa"]);
|
|
22
|
+
const privateFilePattern = /^\.env\.|\.(?:key|pem|p12|ppk|p8|sqlite|sqlite3|db|db3)$|-(?:wal|shm|journal)$/i;
|
|
18
23
|
|
|
19
24
|
function getMimeType(filename) {
|
|
20
25
|
const type = lookup(filename);
|
|
@@ -23,92 +28,6 @@ function getMimeType(filename) {
|
|
|
23
28
|
? `${type}; charset=utf-8`
|
|
24
29
|
: type;
|
|
25
30
|
}
|
|
26
|
-
const client = String.raw`
|
|
27
|
-
const sessionMatch=location.pathname.match(/^\/s\/[a-f0-9]{20}(?:\/|$)/);
|
|
28
|
-
const sessionBase=sessionMatch?(sessionMatch[0].endsWith("/")?sessionMatch[0]:sessionMatch[0]+"/"):"/";
|
|
29
|
-
const credentialKey="letmeknow-credential:"+location.origin+sessionBase;
|
|
30
|
-
const snapshotKey=()=>credentialKey+":state:"+pageIdentity();
|
|
31
|
-
let credential;
|
|
32
|
-
try{credential=sessionStorage.getItem(credentialKey)}catch{}
|
|
33
|
-
let socket;
|
|
34
|
-
let retryTimer;
|
|
35
|
-
let updateTimer;
|
|
36
|
-
let reloadTimer;
|
|
37
|
-
let terminal=false;
|
|
38
|
-
const controls=()=>[...document.querySelectorAll("button,input,select,textarea")];
|
|
39
|
-
const details=()=>[...document.querySelectorAll("details")];
|
|
40
|
-
const uniqueId=(element,all)=>element.id&&all.filter(candidate=>candidate.id===element.id).length===1?element.id:null;
|
|
41
|
-
const formIdentity=form=>form?.id||form?.getAttribute("name")||form?.getAttribute("action")||"document";
|
|
42
|
-
const controlKey=(control,index,all=controls())=>{const id=uniqueId(control,all);if(id)return"id:"+id;const form=control.form;const identity=formIdentity(form)+":"+(control.type||control.localName)+":"+(control.name||"");const occurrence=all.slice(0,index).filter(candidate=>!uniqueId(candidate,all)&&formIdentity(candidate.form)+":"+(candidate.type||candidate.localName)+":"+(candidate.name||"")===identity).length;return"control:"+identity+":"+occurrence};
|
|
43
|
-
const detailKey=(element,index,all=details())=>{const id=uniqueId(element,all);return id?"id:"+id:"detail:"+index};
|
|
44
|
-
const pageIdentity=()=>location.pathname+location.search+location.hash;
|
|
45
|
-
const snapshot=()=>{const all=controls();return{version:1,page:pageIdentity(),controls:all.map((control,index)=>({key:controlKey(control,index,all),value:control.value,checked:control.checked,indeterminate:control.indeterminate,selected:control instanceof HTMLSelectElement?[...control.options].map((option,optionIndex,options)=>option.selected?[option.value,options.slice(0,optionIndex).filter(candidate=>candidate.value===option.value).length]:null).filter(Boolean):undefined,start:typeof control.selectionStart==="number"?control.selectionStart:undefined,end:typeof control.selectionEnd==="number"?control.selectionEnd:undefined,direction:control.selectionDirection||undefined})),active:document.activeElement instanceof Element?controlKey(document.activeElement,all.indexOf(document.activeElement),all):undefined,details:details().map((element,index)=>({key:detailKey(element,index),open:element.open})),x:scrollX,y:scrollY}};
|
|
46
|
-
const status=message=>{const element=document.querySelector("[data-letmeknow-status]");if(element)element.textContent=message};
|
|
47
|
-
const stripSessionPath=path=>{if(sessionBase==="/")return path;if(path===sessionBase.slice(0,-1))return "/";return path.startsWith(sessionBase)?"/"+path.slice(sessionBase.length):path};
|
|
48
|
-
const routePath=()=>{let path=stripSessionPath(location.pathname);return path.endsWith("/")?path+"index.html":path};
|
|
49
|
-
const pagePath=path=>{path=path.split("?",1)[0];return stripSessionPath(path)||"/"};
|
|
50
|
-
const save=()=>{try{sessionStorage.setItem(snapshotKey(),JSON.stringify(snapshot()))}catch{}};
|
|
51
|
-
const restore=()=>{let raw;try{raw=sessionStorage.getItem(snapshotKey())}catch{return}if(!raw)return;let saved;try{saved=JSON.parse(raw)}catch{return}if(saved.version!==1||saved.page!==pageIdentity())return;const all=controls();const savedControls=new Map((Array.isArray(saved.controls)?saved.controls:[]).map(state=>[state.key,state]));let active;for(const [index,control] of all.entries()){const state=savedControls.get(controlKey(control,index,all));if(!state)continue;if(control instanceof HTMLSelectElement&&Array.isArray(state.selected)){const selectedIndexes=new Set(state.selected.filter(Number.isInteger));const selectedValues=new Set(state.selected.filter(Array.isArray).map(entry=>entry.join("\u0000")));for(const [optionIndex,option] of [...control.options].entries()){const occurrence=[...control.options].slice(0,optionIndex).filter(candidate=>candidate.value===option.value).length;option.selected=selectedIndexes.has(optionIndex)||selectedValues.has([option.value,occurrence].join("\u0000"))}}else if(control.type==="checkbox"||control.type==="radio"){control.checked=state.checked;control.indeterminate=state.indeterminate}else{control.value=state.value;if(typeof state.start==="number"&&typeof control.setSelectionRange==="function")control.setSelectionRange(state.start,state.end,state.direction||"none")}if(controlKey(control,index,all)===saved.active)active=control}const savedDetails=new Map((Array.isArray(saved.details)?saved.details:[]).map(state=>[state.key,state]));for(const [index,element] of details().entries()){const state=savedDetails.get(detailKey(element,index));if(state)element.open=state.open}active?.focus({preventScroll:true});scrollTo(saved.x||0,saved.y||0)};
|
|
52
|
-
const reload=()=>{if(reloadTimer)return;reloadTimer=setTimeout(()=>{save();location.reload()},75)};
|
|
53
|
-
const linkedStylesheet=path=>{for(const link of document.querySelectorAll('link[rel~="stylesheet"]')){let url;try{url=new URL(link.href,location.href)}catch{continue}if(url.origin!==location.origin)continue;if(sessionBase!=="/"&&!url.pathname.startsWith(sessionBase))continue;if(pagePath(url.pathname)===path)return link}return null};
|
|
54
|
-
const refreshStylesheet=link=>{const url=new URL(link.href,location.href);url.searchParams.set("_letmeknow",crypto.randomUUID());link.href=url.href};
|
|
55
|
-
const flushUpdates=()=>{updateTimer=undefined;const paths=[...pendingUpdates];pendingUpdates.clear();let shouldReload=false;const styles=[];for(const path of paths){if(/\.html?$/i.test(path)){if(path===routePath())shouldReload=true}else if(/\.css$/i.test(path)){const link=linkedStylesheet(path);if(link)styles.push([path,link]);else shouldReload=true}else shouldReload=true}if(shouldReload){reload();return}for(const [,link] of styles)refreshStylesheet(link)};
|
|
56
|
-
const pendingUpdates=new Set();
|
|
57
|
-
const update=path=>{if(typeof path!=="string")return;path=pagePath(path);pendingUpdates.add(path);if(!updateTimer)updateTimer=setTimeout(flushUpdates,75)};
|
|
58
|
-
let saveTimer;
|
|
59
|
-
const scheduleSave=()=>{if(!saveTimer)saveTimer=setTimeout(()=>{saveTimer=undefined;save()},100)};
|
|
60
|
-
addEventListener("input",scheduleSave,true);
|
|
61
|
-
addEventListener("change",scheduleSave,true);
|
|
62
|
-
addEventListener("toggle",scheduleSave,true);
|
|
63
|
-
addEventListener("focusin",scheduleSave,true);
|
|
64
|
-
addEventListener("selectionchange",scheduleSave,true);
|
|
65
|
-
addEventListener("scroll",scheduleSave,{passive:true});
|
|
66
|
-
addEventListener("pagehide",save);
|
|
67
|
-
addEventListener("load",()=>requestAnimationFrame(restore),{once:true});
|
|
68
|
-
document.addEventListener("reset",()=>setTimeout(save));
|
|
69
|
-
const connect=()=>{
|
|
70
|
-
clearTimeout(retryTimer);retryTimer=undefined;
|
|
71
|
-
const url=new URL(sessionBase+"_letmeknow/client",location.href);url.protocol=url.protocol==="https:"?"wss:":"ws:";
|
|
72
|
-
const current=credential?new WebSocket(url,credential):new WebSocket(url);
|
|
73
|
-
socket=current;
|
|
74
|
-
current.onmessage=event=>{
|
|
75
|
-
if(socket!==current||typeof event.data!=="string")return;
|
|
76
|
-
let message;try{message=JSON.parse(event.data)}catch{return}
|
|
77
|
-
if(message.type==="credential"){credential=message.credential;try{sessionStorage.setItem(credentialKey,credential)}catch{}return}
|
|
78
|
-
if(message.type==="challenge"){if(current.readyState===WebSocket.OPEN)try{current.send(JSON.stringify({type:"alive",nonce:message.nonce}))}catch{}return}
|
|
79
|
-
if(message.type==="busy"){status("This session is open elsewhere");retryTimer=setTimeout(connect,message.retry_after*1000);return}
|
|
80
|
-
if(message.type==="file_update"){update(message.path);return}
|
|
81
|
-
if(message.type==="closed"){terminal=true;try{sessionStorage.removeItem(credentialKey)}catch{}status(message.message)}
|
|
82
|
-
};
|
|
83
|
-
current.onclose=()=>{if(socket!==current||terminal)return;if(!retryTimer){status("Reconnecting…");retryTimer=setTimeout(connect,1000)}};
|
|
84
|
-
current.onerror=()=>{};
|
|
85
|
-
};
|
|
86
|
-
document.addEventListener("submit",async event=>{
|
|
87
|
-
const form=event.target;
|
|
88
|
-
if(!(form instanceof HTMLFormElement))return;
|
|
89
|
-
event.preventDefault();
|
|
90
|
-
const submitter=event.submitter;
|
|
91
|
-
const method=(submitter?.getAttribute("formmethod")??form.getAttribute("method")??"get").toLowerCase();
|
|
92
|
-
if(method!=="get"&&method!=="post"){status("Only GET and POST forms are supported");return}
|
|
93
|
-
if(!form.checkValidity()){form.reportValidity();return}
|
|
94
|
-
let target;
|
|
95
|
-
try{const action=submitter?.getAttribute("formaction")??form.getAttribute("action")??location.href;const base=sessionBase!=="/"&&location.pathname===sessionBase.slice(0,-1)?new URL(sessionBase,location.href):location.href;target=new URL(action,base)}catch{status("Invalid form action");return}
|
|
96
|
-
if(target.origin!==location.origin){status("Form actions must stay on this site");return}
|
|
97
|
-
if(sessionBase!=="/"){
|
|
98
|
-
const sessionPath=sessionBase.slice(0,-1);
|
|
99
|
-
if(target.pathname===sessionPath)target.pathname=sessionBase;
|
|
100
|
-
else if(!target.pathname.startsWith(sessionBase))target.pathname=sessionBase+target.pathname.replace(/^\//,"");
|
|
101
|
-
}
|
|
102
|
-
const values=new URLSearchParams();
|
|
103
|
-
for(const [name,value] of new FormData(form,submitter)){if(typeof value!=="string"){status("File inputs are not supported");return}values.append(name,value)}
|
|
104
|
-
const actionPath=sessionBase!=="/"?target.pathname.slice(sessionBase.length-1)||"/":target.pathname;
|
|
105
|
-
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")}};
|
|
106
|
-
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??"")};
|
|
107
|
-
if(method==="get")for(const [name,value] of values)target.searchParams.append(name,value);
|
|
108
|
-
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")}
|
|
109
|
-
});
|
|
110
|
-
connect();
|
|
111
|
-
`;
|
|
112
31
|
|
|
113
32
|
function header(packet, name) {
|
|
114
33
|
const entry = Object.entries(packet.headers || {}).find(([key]) => key.toLowerCase() === name);
|
|
@@ -139,8 +58,7 @@ function errorResponse(packet, status, message) {
|
|
|
139
58
|
}
|
|
140
59
|
|
|
141
60
|
function deniedPath(pathname) {
|
|
142
|
-
|
|
143
|
-
return normalized !== "" && (ig.ignores(normalized) || pathname.split("/").filter(Boolean).some(part => ig.ignores(part) || privateFilePattern.test(part)));
|
|
61
|
+
return pathname.split("/").filter(Boolean).some(part => privateNames.has(part) || privateFilePattern.test(part));
|
|
144
62
|
}
|
|
145
63
|
|
|
146
64
|
function inside(root, target) {
|
|
@@ -175,14 +93,8 @@ function requestUrl(packet) {
|
|
|
175
93
|
return { pathname, encodedPathname: url.pathname, search: url.search };
|
|
176
94
|
}
|
|
177
95
|
|
|
178
|
-
function
|
|
179
|
-
const
|
|
180
|
-
const script = `<script type="module" data-letmeknow-client>${client}</script>`;
|
|
181
|
-
const closingBody = text.search(/<\/body\s*>/i);
|
|
182
|
-
return Buffer.from(closingBody < 0 ? text + script : text.slice(0, closingBody) + script + text.slice(closingBody), "utf8");
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
async function staticResponse(root, packet) {
|
|
96
|
+
async function staticResponse(root, packet, workspaceId) {
|
|
97
|
+
const published = (status, body = Buffer.alloc(0), headers = {}) => response(packet, status, body, { ...headers, "X-LetMeKnow-Workspace": workspaceId });
|
|
186
98
|
const method = typeof packet.method === "string" ? packet.method.toUpperCase() : "";
|
|
187
99
|
if (method !== "GET" && method !== "HEAD") return errorResponse(packet, 405, "method not allowed");
|
|
188
100
|
let request;
|
|
@@ -202,9 +114,9 @@ async function staticResponse(root, packet) {
|
|
|
202
114
|
return errorResponse(packet, 500, "preview request failed");
|
|
203
115
|
}
|
|
204
116
|
if (info.isDirectory()) {
|
|
205
|
-
if (!request.
|
|
206
|
-
const location = request.encodedPathname + "/" + request.search;
|
|
207
|
-
return
|
|
117
|
+
if (!request.encodedPathname.endsWith("/")) {
|
|
118
|
+
const location = request.encodedPathname.slice(request.encodedPathname.lastIndexOf("/") + 1) + "/" + request.search;
|
|
119
|
+
return published(301, Buffer.from(`Redirecting to ${location}`), { Location: location, "Content-Type": "text/plain; charset=utf-8" });
|
|
208
120
|
}
|
|
209
121
|
const index = resolve(target, "index.html");
|
|
210
122
|
try { target = await realpath(index); } catch (cause) {
|
|
@@ -213,95 +125,204 @@ async function staticResponse(root, packet) {
|
|
|
213
125
|
}
|
|
214
126
|
if (!inside(root, target)) return errorResponse(packet, 403, "forbidden");
|
|
215
127
|
if (deniedPath("/" + relative(root, target).split(sep).join("/"))) return errorResponse(packet, 403, "forbidden");
|
|
128
|
+
try { info = await stat(target); } catch (cause) {
|
|
129
|
+
if (cause?.code === "ENOENT" || cause?.code === "ENOTDIR") return errorResponse(packet, 404, "not found");
|
|
130
|
+
if (cause?.code === "EACCES" || cause?.code === "EPERM") return errorResponse(packet, 403, "forbidden");
|
|
131
|
+
return errorResponse(packet, 500, "preview request failed");
|
|
132
|
+
}
|
|
216
133
|
} else if (request.pathname.endsWith("/")) return errorResponse(packet, 404, "not found");
|
|
217
|
-
let
|
|
218
|
-
try {
|
|
134
|
+
let file;
|
|
135
|
+
try { file = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW); } catch (cause) {
|
|
219
136
|
if (cause?.code === "ENOENT" || cause?.code === "ENOTDIR") return errorResponse(packet, 404, "not found");
|
|
220
|
-
if (cause?.code === "EACCES" || cause?.code === "EPERM") return errorResponse(packet, 403, "forbidden");
|
|
137
|
+
if (cause?.code === "EACCES" || cause?.code === "EPERM" || cause?.code === "ELOOP") return errorResponse(packet, 403, "forbidden");
|
|
138
|
+
return errorResponse(packet, 500, "preview request failed");
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
info = await file.stat();
|
|
142
|
+
if (!info.isFile()) return errorResponse(packet, 404, "not found");
|
|
143
|
+
if (info.size > MAX_BODY_BYTES) return errorResponse(packet, 413, "response body is too large");
|
|
144
|
+
const body = await file.readFile();
|
|
145
|
+
if (body.byteLength > MAX_BODY_BYTES) return errorResponse(packet, 413, "response body is too large");
|
|
146
|
+
return published(200, body, { "Content-Type": getMimeType(target) });
|
|
147
|
+
} catch {
|
|
221
148
|
return errorResponse(packet, 500, "preview request failed");
|
|
149
|
+
} finally {
|
|
150
|
+
await file.close();
|
|
222
151
|
}
|
|
223
|
-
if (body.byteLength > MAX_BODY_BYTES) return errorResponse(packet, 413, "response body is too large");
|
|
224
|
-
if (extname(target).toLowerCase() === ".html") body = htmlWithClient(body);
|
|
225
|
-
if (body.byteLength > MAX_BODY_BYTES) return errorResponse(packet, 413, "response body is too large");
|
|
226
|
-
return response(packet, 200, body, { "Content-Type": getMimeType(target) });
|
|
227
152
|
}
|
|
228
153
|
|
|
229
|
-
async function
|
|
154
|
+
async function multipartSubmission(body, contentType, getAttachmentInbox) {
|
|
155
|
+
const formData = await new Request("http://letmeknow.local", {
|
|
156
|
+
method: "POST",
|
|
157
|
+
headers: { "Content-Type": contentType },
|
|
158
|
+
body
|
|
159
|
+
}).formData();
|
|
160
|
+
const values = Object.create(null);
|
|
161
|
+
const attachments = [];
|
|
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 };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function submission(packet, getAttachmentInbox, recordInteraction) {
|
|
230
177
|
const url = requestUrl(packet);
|
|
231
178
|
const method = typeof packet.method === "string" ? packet.method.toUpperCase() : "";
|
|
232
179
|
const values = Object.create(null);
|
|
180
|
+
let attachments;
|
|
233
181
|
if (method === "GET") {
|
|
234
182
|
for (const [name, value] of new URLSearchParams(url.search)) addValue(values, name, value);
|
|
235
183
|
} else if (method === "POST") {
|
|
236
184
|
const body = Buffer.from(typeof packet.body === "string" ? packet.body : "", "base64");
|
|
237
185
|
if (body.byteLength > MAX_BODY_BYTES) throw new Error("submission is too large");
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
186
|
+
const contentTypeHeader = header(packet, "content-type");
|
|
187
|
+
const contentType = contentTypeHeader?.split(";", 1)[0].trim().toLowerCase();
|
|
188
|
+
if (contentType === "application/x-www-form-urlencoded") {
|
|
189
|
+
for (const [name, value] of new URLSearchParams(body.toString("utf8"))) addValue(values, name, value);
|
|
190
|
+
} else if (contentType === "multipart/form-data" && contentTypeHeader) {
|
|
191
|
+
const parsed = await multipartSubmission(body, contentTypeHeader, getAttachmentInbox);
|
|
192
|
+
Object.assign(values, parsed.values);
|
|
193
|
+
attachments = parsed.attachments;
|
|
194
|
+
} else throw new Error("unsupported submission encoding");
|
|
241
195
|
} else throw new Error("unsupported submission method");
|
|
242
196
|
const event = {
|
|
243
197
|
type: "submit",
|
|
244
|
-
id: encodedHeader(packet, "x-letmeknow-id"),
|
|
198
|
+
id: encodedHeader(packet, "x-letmeknow-id") || randomUUID(),
|
|
245
199
|
method,
|
|
246
200
|
action: encodedHeader(packet, "x-letmeknow-action") || url.pathname,
|
|
247
201
|
form_id: encodedHeader(packet, "x-letmeknow-form-id"),
|
|
248
202
|
trigger: { id: encodedHeader(packet, "x-letmeknow-trigger-id"), name: encodedHeader(packet, "x-letmeknow-trigger-name"), value: encodedHeader(packet, "x-letmeknow-trigger-value") },
|
|
249
203
|
values
|
|
250
204
|
};
|
|
251
|
-
|
|
252
|
-
|
|
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);
|
|
209
|
+
return response(packet, 202);
|
|
253
210
|
}
|
|
254
211
|
|
|
255
|
-
async function handleRequest(root, packet) {
|
|
212
|
+
async function handleRequest(root, workspaceId, packet, getAttachmentInbox, recordInteraction) {
|
|
256
213
|
if (header(packet, "x-letmeknow-submission") === "1") {
|
|
257
|
-
try { return await submission(packet); } catch (cause) { return errorResponse(packet, cause?.message === "submission is too large" ? 413 : 400, cause instanceof Error ? cause.message : "invalid submission"); }
|
|
214
|
+
try { return await submission(packet, getAttachmentInbox, recordInteraction); } catch (cause) { return errorResponse(packet, cause?.message === "submission is too large" ? 413 : 400, cause instanceof Error ? cause.message : "invalid submission"); }
|
|
258
215
|
}
|
|
259
|
-
return staticResponse(root, packet);
|
|
216
|
+
return staticResponse(root, packet, workspaceId);
|
|
260
217
|
}
|
|
261
218
|
|
|
262
219
|
function options(directory) {
|
|
263
|
-
const root = resolve(directory
|
|
220
|
+
const root = resolve(directory);
|
|
264
221
|
if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error(`directory does not exist: ${root}`);
|
|
265
222
|
return realpath(root).then(root => ({ root }));
|
|
266
223
|
}
|
|
267
224
|
|
|
268
|
-
function
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
225
|
+
function controlPath(root) {
|
|
226
|
+
const key = createHash("sha256").update(root).digest("hex").slice(0, 32);
|
|
227
|
+
return join(tmpdir(), `${CONTROL_PREFIX}${key}.sock`);
|
|
228
|
+
}
|
|
229
|
+
|
|
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);
|
|
280
250
|
}
|
|
281
|
-
return url;
|
|
282
251
|
}
|
|
283
252
|
|
|
284
|
-
function
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
253
|
+
async function snapshotDirectory(root) {
|
|
254
|
+
const snapshot = await mkdtemp(join(tmpdir(), SNAPSHOT_PREFIX));
|
|
255
|
+
try {
|
|
256
|
+
await copyDirectory(root, snapshot, root);
|
|
257
|
+
return snapshot;
|
|
258
|
+
} catch (cause) {
|
|
259
|
+
await rm(snapshot, { recursive: true, force: true });
|
|
260
|
+
throw cause;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function mutateQueue() {
|
|
265
|
+
let chain = Promise.resolve();
|
|
266
|
+
return operation => {
|
|
267
|
+
const previous = chain;
|
|
268
|
+
let release;
|
|
269
|
+
chain = new Promise(resolve => { release = resolve; });
|
|
270
|
+
return previous.then(operation).finally(release);
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function connectControl(root, packet) {
|
|
275
|
+
const timeout = Math.max(CONTROL_TIMEOUT, ((packet.wait_seconds || 0) + 5) * 1_000);
|
|
276
|
+
return new Promise((resolve, reject) => {
|
|
277
|
+
const socket = net.createConnection(controlPath(root));
|
|
278
|
+
let output = "";
|
|
279
|
+
let settled = false;
|
|
280
|
+
const timer = setTimeout(() => {
|
|
281
|
+
socket.destroy();
|
|
282
|
+
reject(new Error("control request timed out"));
|
|
283
|
+
}, timeout);
|
|
284
|
+
const finish = (cause, value) => {
|
|
285
|
+
if (settled) return;
|
|
286
|
+
settled = true;
|
|
287
|
+
clearTimeout(timer);
|
|
288
|
+
if (cause) reject(cause);
|
|
289
|
+
else resolve(value);
|
|
290
|
+
};
|
|
291
|
+
socket.setEncoding("utf8");
|
|
292
|
+
socket.on("connect", () => socket.write(JSON.stringify(packet) + "\n"));
|
|
293
|
+
socket.on("data", chunk => {
|
|
294
|
+
output += chunk;
|
|
295
|
+
const newline = output.indexOf("\n");
|
|
296
|
+
if (newline < 0) return;
|
|
297
|
+
try { finish(null, JSON.parse(output.slice(0, newline))); } catch (cause) { finish(cause); }
|
|
298
|
+
socket.destroy();
|
|
299
|
+
});
|
|
300
|
+
socket.on("error", cause => finish(new Error(`serve is not running: ${cause.message}`)));
|
|
301
|
+
socket.on("close", () => { if (!settled) finish(new Error("serve closed the control connection")); });
|
|
302
|
+
});
|
|
291
303
|
}
|
|
292
304
|
|
|
293
305
|
async function start(directory) {
|
|
294
306
|
const { root } = await options(directory);
|
|
295
|
-
|
|
307
|
+
let attachmentInboxPromise;
|
|
308
|
+
const getAttachmentInbox = () => {
|
|
309
|
+
attachmentInboxPromise ??= mkdtemp(join(tmpdir(), "letmeknow-attachments-"));
|
|
310
|
+
return attachmentInboxPromise;
|
|
311
|
+
};
|
|
312
|
+
const socketPath = controlPath(root);
|
|
313
|
+
let publishedRoot = await snapshotDirectory(root);
|
|
314
|
+
let workspaceId = randomUUID();
|
|
315
|
+
let workspaceSequence = 1;
|
|
316
|
+
const workspaceIds = new Set([workspaceId]);
|
|
317
|
+
const eventLog = [];
|
|
318
|
+
let committedCursor = 0;
|
|
319
|
+
const seenEvents = new Set();
|
|
320
|
+
const tokens = new Map();
|
|
321
|
+
const pendingTokens = new Map();
|
|
322
|
+
const pullWaiters = new Set();
|
|
323
|
+
const mutate = mutateQueue();
|
|
324
|
+
let controlServer;
|
|
296
325
|
let send = () => false;
|
|
297
|
-
const watcher = chokidar.watch(root, { ignoreInitial: true });
|
|
298
|
-
watcher.on("all", (_event, filename) => {
|
|
299
|
-
if (!filename) { send({ type: "file_update", path: "/" }); return; }
|
|
300
|
-
const file = resolve(root, String(filename));
|
|
301
|
-
const path = relative(root, file).split(sep).join("/");
|
|
302
|
-
if (!path || path.startsWith("../") || path === ".." || deniedPath("/" + path)) return;
|
|
303
|
-
send({ type: "file_update", path: "/" + path.split("/").map(encodeURIComponent).join("/") });
|
|
304
|
-
});
|
|
305
326
|
let socket;
|
|
306
327
|
let credential;
|
|
307
328
|
let sessionUrl;
|
|
@@ -311,13 +332,144 @@ async function start(directory) {
|
|
|
311
332
|
let retryUntil = 0;
|
|
312
333
|
let stopped = false;
|
|
313
334
|
let ready = false;
|
|
335
|
+
let initialPublished = false;
|
|
336
|
+
|
|
337
|
+
const batch = () => {
|
|
338
|
+
const start = committedCursor;
|
|
339
|
+
const end = eventLog.length;
|
|
340
|
+
const key = `${workspaceId}:${start}:${end}`;
|
|
341
|
+
const existing = pendingTokens.get(key);
|
|
342
|
+
if (existing) return existing;
|
|
343
|
+
const token = randomUUID();
|
|
344
|
+
const events = eventLog.slice(start, end).map(event => ({
|
|
345
|
+
...event,
|
|
346
|
+
context: {
|
|
347
|
+
based_on: event.based_on ?? null,
|
|
348
|
+
current: workspaceId,
|
|
349
|
+
relationship: event.based_on === workspaceId ? "current" : workspaceIds.has(event.based_on) ? "stale" : "unknown"
|
|
350
|
+
}
|
|
351
|
+
}));
|
|
352
|
+
tokens.set(token, { start, end, parent: workspaceId, status: "pending", key });
|
|
353
|
+
const result = { ok: true, type: "batch", token, workspace: workspaceId, workspace_sequence: workspaceSequence, frontier: end, events };
|
|
354
|
+
pendingTokens.set(key, result);
|
|
355
|
+
return result;
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const notifyPullWaiters = () => {
|
|
359
|
+
for (const waiter of [...pullWaiters]) {
|
|
360
|
+
if (eventLog.length === committedCursor) continue;
|
|
361
|
+
pullWaiters.delete(waiter);
|
|
362
|
+
clearTimeout(waiter.timer);
|
|
363
|
+
waiter.resolve(batch());
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const pull = waitSeconds => {
|
|
368
|
+
if (eventLog.length > committedCursor || waitSeconds <= 0) return Promise.resolve(batch());
|
|
369
|
+
return new Promise(resolve => {
|
|
370
|
+
const waiter = { resolve, timer: setTimeout(() => { pullWaiters.delete(waiter); resolve(batch()); }, waitSeconds * 1_000) };
|
|
371
|
+
pullWaiters.add(waiter);
|
|
372
|
+
});
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const commit = async (token, publish) => {
|
|
376
|
+
const record = tokens.get(token);
|
|
377
|
+
if (!record) return { ok: false, error: "unknown batch token" };
|
|
378
|
+
if (record.status !== "pending") return record.result;
|
|
379
|
+
if (record.start < committedCursor && record.end <= committedCursor) {
|
|
380
|
+
pendingTokens.delete(record.key);
|
|
381
|
+
record.status = "committed";
|
|
382
|
+
record.result = { ok: true, type: "already_committed", token, workspace: workspaceId, workspace_sequence: workspaceSequence, frontier: committedCursor };
|
|
383
|
+
return record.result;
|
|
384
|
+
}
|
|
385
|
+
if (record.parent !== workspaceId || record.start !== committedCursor) {
|
|
386
|
+
pendingTokens.delete(record.key);
|
|
387
|
+
record.status = "failed";
|
|
388
|
+
record.result = { ok: false, error: "batch is based on an old workspace or cursor", current_workspace: workspaceId, frontier: committedCursor };
|
|
389
|
+
return record.result;
|
|
390
|
+
}
|
|
391
|
+
if (publish) {
|
|
392
|
+
const nextRoot = await snapshotDirectory(root);
|
|
393
|
+
const previousRoot = publishedRoot;
|
|
394
|
+
publishedRoot = nextRoot;
|
|
395
|
+
workspaceId = randomUUID();
|
|
396
|
+
workspaceIds.add(workspaceId);
|
|
397
|
+
workspaceSequence += 1;
|
|
398
|
+
record.result = { ok: true, type: "published", token, workspace: workspaceId, workspace_sequence: workspaceSequence, parent: record.parent, frontier: record.end, events: eventLog.slice(record.start, record.end).map(event => event.id) };
|
|
399
|
+
void rm(previousRoot, { recursive: true, force: true }).catch(() => {});
|
|
400
|
+
} else {
|
|
401
|
+
record.result = { ok: true, type: "acknowledged", token, workspace: workspaceId, workspace_sequence: workspaceSequence, frontier: record.end, events: eventLog.slice(record.start, record.end).map(event => event.id) };
|
|
402
|
+
}
|
|
403
|
+
committedCursor = record.end;
|
|
404
|
+
pendingTokens.delete(record.key);
|
|
405
|
+
record.status = "committed";
|
|
406
|
+
if (publish) send({ type: "revision" });
|
|
407
|
+
return record.result;
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
const dispatchControl = async request => {
|
|
411
|
+
if (!request || typeof request !== "object") return { ok: false, error: "invalid control request" };
|
|
412
|
+
if (request.type === "pull") return pull(Number.isFinite(request.wait_seconds) ? Math.max(0, request.wait_seconds) : 0);
|
|
413
|
+
if (request.type === "push") return commit(typeof request.token === "string" ? request.token : "", true);
|
|
414
|
+
if (request.type === "ack") return commit(typeof request.token === "string" ? request.token : "", false);
|
|
415
|
+
return { ok: false, error: "unknown control request" };
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const controlConnections = new Set();
|
|
419
|
+
controlServer = net.createServer(connection => {
|
|
420
|
+
controlConnections.add(connection);
|
|
421
|
+
connection.setEncoding("utf8");
|
|
422
|
+
let input = "";
|
|
423
|
+
let handled = false;
|
|
424
|
+
connection.on("data", async chunk => {
|
|
425
|
+
input += chunk;
|
|
426
|
+
if (input.length > MAX_BODY_BYTES || handled) return;
|
|
427
|
+
const newline = input.indexOf("\n");
|
|
428
|
+
if (newline < 0) return;
|
|
429
|
+
handled = true;
|
|
430
|
+
let result;
|
|
431
|
+
try {
|
|
432
|
+
const request = JSON.parse(input.slice(0, newline));
|
|
433
|
+
result = request.type === "pull" ? await dispatchControl(request) : await mutate(() => dispatchControl(request));
|
|
434
|
+
} catch (cause) { result = { ok: false, error: cause instanceof Error ? cause.message : "control request failed" }; }
|
|
435
|
+
connection.end(JSON.stringify(result) + "\n");
|
|
436
|
+
});
|
|
437
|
+
connection.on("close", () => controlConnections.delete(connection));
|
|
438
|
+
connection.on("error", () => controlConnections.delete(connection));
|
|
439
|
+
});
|
|
440
|
+
await new Promise((resolveListen, reject) => {
|
|
441
|
+
controlServer.once("error", reject);
|
|
442
|
+
controlServer.listen(socketPath, async () => {
|
|
443
|
+
try { await chmod(socketPath, 0o600); } catch (cause) { controlServer.close(() => reject(cause)); return; }
|
|
444
|
+
controlServer.off("error", reject);
|
|
445
|
+
resolveListen();
|
|
446
|
+
});
|
|
447
|
+
}).catch(async cause => {
|
|
448
|
+
await rm(publishedRoot, { recursive: true, force: true });
|
|
449
|
+
throw new Error(`cannot start local control channel: ${cause.message}`);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
const recordInteraction = event => mutate(async () => {
|
|
453
|
+
if (seenEvents.has(event.id)) return;
|
|
454
|
+
seenEvents.add(event.id);
|
|
455
|
+
eventLog.push(event);
|
|
456
|
+
notifyPullWaiters();
|
|
457
|
+
});
|
|
458
|
+
|
|
314
459
|
const stop = async code => {
|
|
315
460
|
if (stopped) return;
|
|
316
461
|
stopped = true;
|
|
317
462
|
clearTimeout(retryTimer);
|
|
318
463
|
clearTimeout(connectionTimer);
|
|
464
|
+
send({ type: "close" });
|
|
319
465
|
try { socket?.close(); } catch {}
|
|
320
|
-
|
|
466
|
+
for (const connection of controlConnections) connection.destroy();
|
|
467
|
+
await new Promise(resolveClose => controlServer.close(() => resolveClose()));
|
|
468
|
+
await unlink(socketPath).catch(() => {});
|
|
469
|
+
await rm(publishedRoot, { recursive: true, force: true });
|
|
470
|
+
if (attachmentInboxPromise) {
|
|
471
|
+
try { await rm(await attachmentInboxPromise, { recursive: true, force: true }); } catch {}
|
|
472
|
+
}
|
|
321
473
|
process.exit(code);
|
|
322
474
|
};
|
|
323
475
|
process.once("SIGINT", () => void stop(0));
|
|
@@ -332,11 +484,11 @@ async function start(directory) {
|
|
|
332
484
|
const connect = () => {
|
|
333
485
|
if (stopped) return;
|
|
334
486
|
const reconnecting = Boolean(credential && sessionUrl);
|
|
335
|
-
const current = socket = reconnecting ? new WebSocket(endpoint(
|
|
487
|
+
const current = socket = reconnecting ? new WebSocket(endpoint(credential, sessionUrl), credential) : new WebSocket(endpoint());
|
|
336
488
|
connectionTimer = setTimeout(() => {
|
|
337
489
|
if (socket !== current || current.readyState === WebSocket.OPEN || stopped) return;
|
|
338
490
|
try { current.close(); } catch {}
|
|
339
|
-
if (reconnecting)
|
|
491
|
+
if (!reconnecting) void stop(1);
|
|
340
492
|
}, CONNECTION_TIMEOUT);
|
|
341
493
|
current.addEventListener("open", () => {
|
|
342
494
|
if (socket !== current || stopped) return;
|
|
@@ -359,9 +511,15 @@ async function start(directory) {
|
|
|
359
511
|
} else if (packet.type === "session") {
|
|
360
512
|
if (!validSessionUrl(packet.url)) return void stop(1);
|
|
361
513
|
sessionUrl = packet.url;
|
|
362
|
-
if (!
|
|
514
|
+
if (!initialPublished) {
|
|
515
|
+
initialPublished = true;
|
|
516
|
+
send({ type: "revision" });
|
|
517
|
+
}
|
|
518
|
+
if (!ready) { ready = true; process.stdout.write(`${JSON.stringify({ type: "ready", url: sessionUrl, workspace: workspaceId, workspace_sequence: workspaceSequence })}\n`); }
|
|
363
519
|
} else if (packet.type === "http_request") {
|
|
364
|
-
|
|
520
|
+
const requestRoot = publishedRoot;
|
|
521
|
+
const requestWorkspace = workspaceId;
|
|
522
|
+
void handleRequest(requestRoot, requestWorkspace, packet, getAttachmentInbox, recordInteraction).then(result => send(result)).catch(() => send(errorResponse(packet, 500, "preview request failed")));
|
|
365
523
|
} else if (packet.type === "closed") {
|
|
366
524
|
void stop(0);
|
|
367
525
|
} else if (packet.type === "error") {
|
|
@@ -384,29 +542,79 @@ async function start(directory) {
|
|
|
384
542
|
await new Promise(() => {});
|
|
385
543
|
}
|
|
386
544
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
545
|
+
function endpoint(credential, sessionUrl) {
|
|
546
|
+
const url = new URL(CONTROL_URL);
|
|
547
|
+
url.protocol = "wss:";
|
|
548
|
+
url.pathname = "/v2/connect";
|
|
549
|
+
if (credential && sessionUrl) {
|
|
550
|
+
const publicUrl = new URL(sessionUrl);
|
|
551
|
+
const code = publicUrl.hostname.match(/^([a-f0-9]{20})\.letmeknow\.dev$/)?.[1];
|
|
552
|
+
if (!code) throw new Error("invalid session URL");
|
|
553
|
+
url.searchParams.set("code", code);
|
|
554
|
+
}
|
|
555
|
+
return url;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function validSessionUrl(value) {
|
|
559
|
+
if (typeof value !== "string") return false;
|
|
560
|
+
let url;
|
|
561
|
+
try { url = new URL(value); } catch { return false; }
|
|
562
|
+
return url.protocol === "https:" && /^[a-f0-9]{20}\.letmeknow\.dev$/.test(url.hostname) && url.pathname === "/" && !url.search && !url.hash;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function usage() {
|
|
566
|
+
return "Usage:\n npx letmeknow-cli serve <directory>\n npx letmeknow-cli pull <directory> [--wait <seconds>]\n npx letmeknow-cli push <directory> --based-on <token>\n npx letmeknow-cli ack <directory> --based-on <token>\n";
|
|
400
567
|
}
|
|
401
568
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
569
|
+
function commandArgs() {
|
|
570
|
+
let parsed;
|
|
571
|
+
try {
|
|
572
|
+
parsed = parseArgs({
|
|
573
|
+
args: process.argv.slice(2),
|
|
574
|
+
options: {
|
|
575
|
+
skill: { type: "boolean" },
|
|
576
|
+
help: { type: "boolean", short: "h" },
|
|
577
|
+
wait: { type: "string" },
|
|
578
|
+
"based-on": { type: "string" }
|
|
579
|
+
},
|
|
580
|
+
allowPositionals: true,
|
|
581
|
+
strict: true
|
|
582
|
+
});
|
|
583
|
+
} catch (cause) {
|
|
584
|
+
throw new Error(cause instanceof Error ? cause.message : "invalid arguments");
|
|
585
|
+
}
|
|
586
|
+
if (parsed.values.skill || parsed.values.help) {
|
|
587
|
+
if (parsed.positionals.length || parsed.values.wait !== undefined || parsed.values["based-on"] !== undefined) throw new Error(usage());
|
|
588
|
+
return { command: parsed.values.skill ? "skill" : "help" };
|
|
589
|
+
}
|
|
590
|
+
const [command, directory, ...extra] = parsed.positionals;
|
|
591
|
+
if (!command || !directory || extra.length) throw new Error(usage());
|
|
592
|
+
if (command === "serve" && (parsed.values.wait !== undefined || parsed.values["based-on"] !== undefined)) throw new Error(usage());
|
|
593
|
+
if (command === "pull" && parsed.values["based-on"] !== undefined) throw new Error(usage());
|
|
594
|
+
if ((command === "push" || command === "ack") && parsed.values.wait !== undefined) throw new Error(usage());
|
|
595
|
+
if (!["serve", "pull", "push", "ack"].includes(command)) throw new Error(usage());
|
|
596
|
+
let wait = 0;
|
|
597
|
+
if (parsed.values.wait !== undefined) {
|
|
598
|
+
wait = Number(parsed.values.wait);
|
|
599
|
+
if (!Number.isFinite(wait) || wait < 0) throw new Error("--wait must be a non-negative number");
|
|
600
|
+
}
|
|
601
|
+
if ((command === "push" || command === "ack") && typeof parsed.values["based-on"] !== "string") throw new Error("--based-on is required");
|
|
602
|
+
return { command, directory, wait, token: parsed.values["based-on"] };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
let command;
|
|
606
|
+
try {
|
|
607
|
+
command = commandArgs();
|
|
608
|
+
if (command.command === "skill") writeSync(1, readFileSync(new URL("../SKILL.md", import.meta.url)));
|
|
609
|
+
else if (command.command === "help") process.stdout.write(usage());
|
|
610
|
+
else if (command.command === "serve") await start(command.directory);
|
|
611
|
+
else {
|
|
612
|
+
const { root } = await options(command.directory);
|
|
613
|
+
const result = await connectControl(root, command.command === "pull" ? { type: "pull", wait_seconds: command.wait } : { type: command.command, token: command.token });
|
|
614
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
615
|
+
if (!result.ok) process.exitCode = 1;
|
|
616
|
+
}
|
|
617
|
+
} catch (cause) {
|
|
618
|
+
process.stderr.write(`letmeknow: ${cause instanceof Error ? cause.message : "command failed"}\n`);
|
|
619
|
+
process.exitCode = 1;
|
|
412
620
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "letmeknow-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "A live static preview with agent-readable form submissions.",
|
|
5
5
|
"files": [
|
|
6
6
|
"bin",
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
"dev": "wrangler dev",
|
|
18
18
|
"deploy": "wrangler deploy",
|
|
19
19
|
"check": "tsc --noEmit && tsc -p tsconfig.test.json --noEmit",
|
|
20
|
-
"test": "npm run check && vitest run && node --test tests/cli.test.js
|
|
20
|
+
"test": "npm run check && vitest run && node --test tests/cli.test.js",
|
|
21
|
+
"test:browser": "node --test tests/browser.test.js"
|
|
21
22
|
},
|
|
22
23
|
"devDependencies": {
|
|
23
24
|
"@cloudflare/vitest-plugin": "^1.1.0",
|
|
@@ -29,8 +30,6 @@
|
|
|
29
30
|
"ws": "^8.21.3"
|
|
30
31
|
},
|
|
31
32
|
"dependencies": {
|
|
32
|
-
"chokidar": "^5.0.0",
|
|
33
|
-
"ignore": "^7.0.6",
|
|
34
33
|
"mrmime": "^2.0.1"
|
|
35
34
|
}
|
|
36
35
|
}
|