letmeknow-cli 0.4.10 → 0.5.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.
Files changed (4) hide show
  1. package/README.md +23 -28
  2. package/SKILL.md +24 -49
  3. package/bin/letmeknow.js +147 -174
  4. package/package.json +7 -2
package/README.md CHANGED
@@ -1,46 +1,38 @@
1
1
  # LetMeKnow
2
2
 
3
- LetMeKnow gives an agent-managed folder a public, live static preview and receives browser form submissions. The CLI serves files directly and makes only an outbound WebSocket connection to the relay; it does not listen on a network port.
3
+ LetMeKnow gives an agent a temporary public preview of a dedicated folder and receives structured input from a human. The CLI serves the folder over an outbound connection to the LetMeKnow relay; it 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. Pass the preview directory explicitly:
8
8
 
9
9
  ```bash
10
- npx letmeknow-cli ./workspace
10
+ npx letmeknow-cli ./preview
11
11
  ```
12
12
 
13
- The CLI prints JSON lines to stdout. The first line contains the public preview URL:
13
+ The CLI prints JSON lines to stdout. The first line contains the public URL:
14
14
 
15
15
  ```json
16
16
  {"type":"ready","url":"https://0123456789abcdef0123.letmeknow.dev/"}
17
17
  ```
18
18
 
19
- Open that URL in one browser. The directory defaults to the current working directory. Set `LETMEKNOW_URL` to use another compatible relay:
19
+ Open the URL in one or more browsers. The URL is a bearer capability: anyone who has it can view the preview and submit its forms. Sessions are hosted at `letmeknow.dev` and are temporary. A graceful CLI stop closes the session; an unexpected disconnect can reconnect for up to ten minutes. `--skill` prints instructions for an agent without starting a session. Diagnostics go to stderr.
20
20
 
21
- ```bash
22
- LETMEKNOW_URL=https://letmeknow.dev npx letmeknow-cli ./workspace
23
- ```
21
+ The preview directory is public. Keep secrets and unrelated project files elsewhere. Use a dedicated directory containing only the files intended for the human.
24
22
 
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.
23
+ ## Live workspace
26
24
 
27
- ## File workflow
25
+ Create an ordinary static site in the directory, usually with `index.html`, plus its CSS, JavaScript, images, and other assets. The relay injects the small browser runtime into HTML pages. The CLI serves the workspace files unchanged.
28
26
 
29
- The CLI does not receive file commands. The agent reads and writes the directory directly. Keep an `index.html` at the root, plus ordinary JavaScript, CSS, images, and other static assets.
27
+ Every file in the preview directory is part of the live artifact. A burst of changes is coalesced into one workspace revision. Each connected browser then performs a full-page reload.
30
28
 
31
- The preview serves exact files and `index.html` for directory paths. It supports GET and HEAD, redirects directory paths to a trailing slash, and has no application-shell fallback. HTML responses include the live-preview client inline. Use relative asset URLs so previews also work on path-based session URLs.
29
+ The runtime preserves scroll position and the values of controls with stable, unique `id` attributes, including checked and selected state. Other browser state is not part of the preview contract. Use relative asset URLs and normal links between pages.
32
30
 
33
- The client continuously saves form values, checked controls, selections, focus, text selection, scroll position, and open `<details>` elements for each page in the tab's session storage. It restores them after file-triggered and user-triggered reloads. CSS changes cache-bust matching stylesheets without navigating, while changes to the current HTML route or another asset reload the page. Changes to a different HTML route do not disturb the current page. Arbitrary JavaScript heap state cannot be preserved.
31
+ If a requested page does not exist, the live 404 page remains connected and recovers when the page is created. Connection status pages likewise reconnect and recover when the producer becomes available again.
34
32
 
35
- An optional element can display submission status:
33
+ ## Forms
36
34
 
37
- ```html
38
- <p data-letmeknow-status aria-live="polite"></p>
39
- ```
40
-
41
- ## Form submissions
42
-
43
- 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.
35
+ Use native HTML GET and POST forms with same-origin or relative actions:
44
36
 
45
37
  ```html
46
38
  <form id="decision" action="/decide" method="post">
@@ -50,29 +42,32 @@ Forms are submitted without navigation. GET and POST forms are sent through the
50
42
  </form>
51
43
  ```
52
44
 
53
- Submitting `Approve` prints an event like:
45
+ The runtime sends the submission to the CLI, which prints one `submit` event to stdout:
54
46
 
55
47
  ```json
56
48
  {"type":"submit","id":"…","method":"POST","action":"/decide","form_id":"decision","trigger":{"id":null,"name":"decision","value":"approve"},"values":{"comment":"Looks good","decision":"approve"}}
57
49
  ```
58
50
 
59
- 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.
51
+ Repeated field names become arrays, and native browser validation runs before delivery. POST forms may include file inputs; the total submission is limited to 1 MiB. Uploaded files are stored in a private temporary inbox and the event includes an `attachments` array with each field name, original filename, media type, size, and local path. Attachment paths remain available until the CLI stops and are never published unless the agent deliberately copies them into the preview directory.
52
+
53
+ The event ID identifies the submission; it is not a response handle. Read the event, treat values and attachments as untrusted input, update the workspace, and let the next revision show the result.
60
54
 
61
- The event ID identifies the submission. It is not a request/response handle: update the files and let the live preview show the result.
55
+ Submission feedback is automatic: **Sending…** or **Uploading…**, then **Sent. Waiting for an update…**, or **Couldn’t send. Try again.** Add `[data-letmeknow-status]` where a form or page needs a particular status location. Forms are accepted for asynchronous processing, so the transport response is `202 Accepted`.
62
56
 
63
57
  ## Security
64
58
 
65
- 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.
59
+ The preview URL grants access to the session. The relay receives served files and submitted values. The preview folder is trusted code from the browser's perspective, so do not include secrets or credentials unless that is intentional. The CLI makes outbound relay connections only and accepts no inbound browser connections.
66
60
 
67
- The selected folder is resolved with real paths. Requests that would leave that folder through a symlink are denied. `.env` files, `.git`, private-key files, database files, and their descendants are denied. The CLI itself still requires an outbound network connection to the relay. It does not accept inbound browser connections.
61
+ The CLI resolves requested paths and denies symlinks whose targets leave the selected directory. Sensitive names such as `.env`, `.git`, private keys, and database files are excluded. Processes with write access to the preview directory are trusted publishers and can make data public through it.
68
62
 
69
63
  ## Development
70
64
 
71
65
  ```bash
72
66
  npm install
73
67
  npm test
68
+ npm run test:browser
74
69
  npm run dev
75
70
  npm run deploy
76
71
  ```
77
72
 
78
- `npm run dev` and `npm run deploy` operate the Cloudflare relay.
73
+ `npm run test:browser` requires Firefox and geckodriver. `npm run dev` and `npm run deploy` operate the Cloudflare relay. The stdout contract is JSONL (`ready` and `submit` events); diagnostics belong on stderr.
package/SKILL.md CHANGED
@@ -1,56 +1,39 @@
1
1
  ---
2
2
  name: letmeknow
3
- description: Give a human a live public preview of an agent-managed folder and receive browser form submissions as JSON lines.
3
+ description: Give a human a temporary live 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 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 serves that folder directly, connects to the public relay over an outbound WebSocket, and reports browser submissions on stdout.
9
-
10
- The CLI does **not** listen on a local network port.
8
+ Use LetMeKnow when a human needs to inspect or interact with a temporary page, report, dashboard, approval form, quiz, table, or status view. You own the files in a dedicated preview folder. The CLI serves that folder through the hosted LetMeKnow relay and reports browser submissions on stdout. It does not listen on a local network port.
11
9
 
12
10
  ## Start
13
11
 
14
- Run the CLI as a long-lived child process with the folder you will edit:
12
+ Pass only the files intended for public viewing in an explicit directory:
15
13
 
16
14
  ```bash
17
- npx letmeknow-cli ./workspace
15
+ npx letmeknow-cli ./preview
18
16
  ```
19
17
 
20
- Node.js 22.12 or newer is required. Read stdout and stderr separately. Stdout is JSONL; the first event is:
18
+ Node.js 22.12 or newer is required. Keep the process running while the human uses the page. Read stdout and stderr separately. Stdout is JSONL; the first event is:
21
19
 
22
20
  ```json
23
21
  {"type":"ready","url":"https://0123456789abcdef0123.letmeknow.dev/"}
24
22
  ```
25
23
 
26
- Open that 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
-
28
- Do not send file commands to stdin. Read and write the folder directly. Keep the process running while the human uses the page.
29
-
30
- ## Build the page
24
+ Give the human the URL. It is a bearer capability, so anyone with it can view the preview and submit forms. Multiple browsers may view the session. `--skill` prints these instructions without starting a session.
31
25
 
32
- Create an ordinary static page in the folder, usually `index.html`, plus any CSS, JavaScript, images, or other assets it needs. Use relative asset URLs so path-based preview URLs work. Use semantic HTML and accessible labels, headings, sections, tables, and controls.
26
+ ## Build the preview
33
27
 
34
- The relay serves exact files and directory `index.html` files. It supports GET and HEAD, redirects directory paths to a trailing slash, and does not provide an application-shell fallback. HTML responses include the live-preview client inline.
28
+ Create an ordinary static site in the folder, usually `index.html`, with its CSS, JavaScript, images, and other assets. Use semantic HTML, accessible labels, and relative asset URLs. Use normal links for navigation.
35
29
 
36
- The preview continuously saves each page's form values, checked controls, selections, focus, text selection, scroll position, and open `<details>` elements in the tab's session storage. It restores them after file-triggered and user-triggered reloads.
30
+ The relay injects the browser runtime into HTML. All files in the folder form one live workspace. Changes made in a short burst become one revision, and connected browsers perform a full-page reload. The runtime preserves scroll position and values, checked state, and selected state for controls with stable, unique `id` attributes.
37
31
 
38
- The preview is live:
39
-
40
- - A change to the current HTML route reloads the page with its saved state.
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
-
45
- An optional status element gives the human feedback after a form submission:
46
-
47
- ```html
48
- <p data-letmeknow-status aria-live="polite"></p>
49
- ```
32
+ A missing page displays a live 404 page that can recover when you create the page. Connection status pages reconnect and recover when the producer is available again.
50
33
 
51
34
  ## Receive form submissions
52
35
 
53
- GET and POST forms are intercepted before navigation and sent through the relay to the CLI. Read stdout for a `submit` event:
36
+ Use native GET and POST forms with relative or same-origin actions:
54
37
 
55
38
  ```html
56
39
  <form id="search" action="/search" method="post">
@@ -59,40 +42,32 @@ GET and POST forms are intercepted before navigation and sent through the relay
59
42
  </form>
60
43
  ```
61
44
 
62
- The event is:
45
+ The runtime provides automatic transport feedback: **Sending…** or **Uploading…**, **Sent. Waiting for an update…**, or **Couldn’t send. Try again.** Add `[data-letmeknow-status]` for a custom status location. The transport accepts submissions asynchronously with `202 Accepted`.
46
+
47
+ Read stdout for a `submit` event:
63
48
 
64
49
  ```json
65
50
  {"type":"submit","id":"…","method":"POST","action":"/search","form_id":"search","trigger":{"id":null,"name":"scope","value":"all"},"values":{"query":"quarterly report","scope":"all"}}
66
51
  ```
67
52
 
68
- Rules:
53
+ Give forms stable IDs and controls meaningful `name` values. Native browser validation runs before delivery, and repeated names become arrays. POST forms may include file inputs within the 1 MiB total submission limit. Attachment metadata includes a local temporary path that remains readable until the CLI stops; treat the filename, media type, and contents as untrusted. Attachments are private unless you deliberately copy them into the preview folder.
69
54
 
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.
55
+ The event ID identifies the submission, not a response channel: validate the values and attachments, update the files, and let the next workspace revision show the result. Do not write commands to stdin.
77
56
 
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.
57
+ ## Example workflow
79
58
 
80
- ## Example response workflow
81
-
82
- 1. Render the initial state in `index.html`.
59
+ 1. Build the initial page in `index.html`.
83
60
  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.
61
+ 3. Validate its values and action.
62
+ 4. Rewrite the relevant HTML or data file in the preview folder.
63
+ 5. Let the workspace revision reload the human's page.
87
64
 
88
- Escape untrusted values before placing them in HTML. Treat browser input as untrusted even though the folder is local to the agent.
65
+ Escape untrusted values before placing them in HTML.
89
66
 
90
67
  ## Security
91
68
 
92
- 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.
93
-
94
- The selected folder is resolved with real paths, and requests cannot escape it through symlinks. `.env` files, `.git`, private-key files, and database files are denied. The CLI makes outbound relay connections only; it does not accept inbound browser connections.
69
+ The preview folder is public and is trusted code from the browser's perspective. Keep secrets and unrelated project files elsewhere. The CLI restricts requests to the selected directory and excludes `.env`, `.git`, private-key, and database files. Treat every process with write access to the preview folder as a trusted publisher. The CLI makes outbound relay connections only and accepts no inbound browser connections.
95
70
 
96
71
  ## Stop
97
72
 
98
- Send `SIGINT` or `SIGTERM` to stop the CLI. The relay session expires after producer disconnect. `--skill` prints these instructions without starting a session.
73
+ Send `SIGINT` or `SIGTERM` to close the session. After an unexpected disconnect, the CLI can reconnect to the session for up to ten minutes. Diagnostics go to stderr; stdout remains JSONL.
package/bin/letmeknow.js CHANGED
@@ -1,124 +1,31 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { existsSync, readFileSync, statSync, watch, writeSync } from "node:fs";
4
- import { readFile, realpath, stat } from "node:fs/promises";
5
- import { dirname, extname, relative, resolve, sep } from "node:path";
3
+ import { constants, existsSync, readFileSync, statSync, writeSync } from "node:fs";
4
+ import { mkdtemp, open, realpath, rm, stat, writeFile } from "node:fs/promises";
5
+ import { randomUUID } from "node:crypto";
6
+ import { tmpdir } from "node:os";
7
+ import { dirname, extname, join, relative, resolve, sep } from "node:path";
8
+ import { parseArgs } from "node:util";
9
+ import chokidar from "chokidar";
10
+ import { lookup } from "mrmime";
6
11
 
7
12
  const MAX_BODY_BYTES = 1024 * 1024;
8
13
  const GRACE_SECONDS = 10 * 60;
9
14
  const CONNECTION_TIMEOUT = 10_000;
10
15
  const MAX_RETRY_DELAY = 5_000;
16
+ const REVISION_QUIET_MS = 200;
17
+ const CONTROL_URL = "https://letmeknow.dev";
11
18
  const credentialPattern = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
12
- const mimeTypes = {
13
- ".avif": "image/avif",
14
- ".css": "text/css; charset=utf-8",
15
- ".csv": "text/csv; charset=utf-8",
16
- ".gif": "image/gif",
17
- ".html": "text/html; charset=utf-8",
18
- ".ico": "image/x-icon",
19
- ".jpeg": "image/jpeg",
20
- ".jpg": "image/jpeg",
21
- ".js": "text/javascript; charset=utf-8",
22
- ".json": "application/json; charset=utf-8",
23
- ".map": "application/json; charset=utf-8",
24
- ".mjs": "text/javascript; charset=utf-8",
25
- ".pdf": "application/pdf",
26
- ".png": "image/png",
27
- ".svg": "image/svg+xml",
28
- ".txt": "text/plain; charset=utf-8",
29
- ".wasm": "application/wasm",
30
- ".webmanifest": "application/manifest+json; charset=utf-8",
31
- ".webp": "image/webp",
32
- ".woff": "font/woff",
33
- ".woff2": "font/woff2",
34
- ".xml": "application/xml; charset=utf-8"
35
- };
36
- const client = String.raw`
37
- const sessionMatch=location.pathname.match(/^\/s\/[a-f0-9]{20}(?:\/|$)/);
38
- const sessionBase=sessionMatch?(sessionMatch[0].endsWith("/")?sessionMatch[0]:sessionMatch[0]+"/"):"/";
39
- const credentialKey="letmeknow-credential:"+location.origin+sessionBase;
40
- const snapshotKey=()=>credentialKey+":state:"+pageIdentity();
41
- let credential;
42
- try{credential=sessionStorage.getItem(credentialKey)}catch{}
43
- let socket;
44
- let retryTimer;
45
- let updateTimer;
46
- let reloadTimer;
47
- let terminal=false;
48
- const controls=()=>[...document.querySelectorAll("button,input,select,textarea")];
49
- const details=()=>[...document.querySelectorAll("details")];
50
- const uniqueId=(element,all)=>element.id&&all.filter(candidate=>candidate.id===element.id).length===1?element.id:null;
51
- const formIdentity=form=>form?.id||form?.getAttribute("name")||form?.getAttribute("action")||"document";
52
- 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};
53
- const detailKey=(element,index,all=details())=>{const id=uniqueId(element,all);return id?"id:"+id:"detail:"+index};
54
- const pageIdentity=()=>location.pathname+location.search+location.hash;
55
- 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)=>option.selected?optionIndex:null).filter(optionIndex=>optionIndex!==null):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}};
56
- const status=message=>{const element=document.querySelector("[data-letmeknow-status]");if(element)element.textContent=message};
57
- const stripSessionPath=path=>{if(sessionBase==="/")return path;if(path===sessionBase.slice(0,-1))return "/";return path.startsWith(sessionBase)?"/"+path.slice(sessionBase.length):path};
58
- const routePath=()=>{let path=stripSessionPath(location.pathname);return path.endsWith("/")?path+"index.html":path};
59
- const pagePath=path=>{path=path.split("?",1)[0];return stripSessionPath(path)||"/"};
60
- const save=()=>{try{sessionStorage.setItem(snapshotKey(),JSON.stringify(snapshot()))}catch{}};
61
- 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))for(const [optionIndex,option] of [...control.options].entries())option.selected=state.selected.includes(optionIndex);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)};
62
- const reload=()=>{if(reloadTimer)return;reloadTimer=setTimeout(()=>{save();location.reload()},75)};
63
- 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};
64
- const refreshStylesheet=link=>{const url=new URL(link.href,location.href);url.searchParams.set("_letmeknow",crypto.randomUUID());link.href=url.href};
65
- 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)};
66
- const pendingUpdates=new Set();
67
- const update=path=>{if(typeof path!=="string")return;path=pagePath(path);pendingUpdates.add(path);if(!updateTimer)updateTimer=setTimeout(flushUpdates,75)};
68
- let saveTimer;
69
- const scheduleSave=()=>{if(!saveTimer)saveTimer=setTimeout(()=>{saveTimer=undefined;save()},100)};
70
- addEventListener("input",scheduleSave,true);
71
- addEventListener("change",scheduleSave,true);
72
- addEventListener("toggle",scheduleSave,true);
73
- addEventListener("focusin",scheduleSave,true);
74
- addEventListener("selectionchange",scheduleSave,true);
75
- addEventListener("scroll",scheduleSave,{passive:true});
76
- addEventListener("pagehide",save);
77
- addEventListener("load",()=>requestAnimationFrame(restore),{once:true});
78
- document.addEventListener("reset",()=>setTimeout(save));
79
- const connect=()=>{
80
- clearTimeout(retryTimer);retryTimer=undefined;
81
- const url=new URL(sessionBase+"_letmeknow/client",location.href);url.protocol=url.protocol==="https:"?"wss:":"ws:";
82
- const current=credential?new WebSocket(url,credential):new WebSocket(url);
83
- socket=current;
84
- current.onmessage=event=>{
85
- if(socket!==current||typeof event.data!=="string")return;
86
- let message;try{message=JSON.parse(event.data)}catch{return}
87
- if(message.type==="credential"){credential=message.credential;try{sessionStorage.setItem(credentialKey,credential)}catch{}return}
88
- if(message.type==="challenge"){if(current.readyState===WebSocket.OPEN)try{current.send(JSON.stringify({type:"alive",nonce:message.nonce}))}catch{}return}
89
- if(message.type==="busy"){status("This session is open elsewhere");retryTimer=setTimeout(connect,message.retry_after*1000);return}
90
- if(message.type==="file_update"){update(message.path);return}
91
- if(message.type==="closed"){terminal=true;try{sessionStorage.removeItem(credentialKey)}catch{}status(message.message)}
92
- };
93
- current.onclose=()=>{if(socket!==current||terminal)return;if(!retryTimer){status("Reconnecting…");retryTimer=setTimeout(connect,1000)}};
94
- current.onerror=()=>{};
95
- };
96
- document.addEventListener("submit",async event=>{
97
- const form=event.target;
98
- if(!(form instanceof HTMLFormElement))return;
99
- event.preventDefault();
100
- const submitter=event.submitter;
101
- const method=(submitter?.getAttribute("formmethod")??form.getAttribute("method")??"get").toLowerCase();
102
- if(method!=="get"&&method!=="post"){status("Only GET and POST forms are supported");return}
103
- if(!form.checkValidity()){form.reportValidity();return}
104
- let target;
105
- 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}
106
- if(target.origin!==location.origin){status("Form actions must stay on this site");return}
107
- if(sessionBase!=="/"){
108
- const sessionPath=sessionBase.slice(0,-1);
109
- if(target.pathname===sessionPath)target.pathname=sessionBase;
110
- else if(!target.pathname.startsWith(sessionBase))target.pathname=sessionBase+target.pathname.replace(/^\//,"");
111
- }
112
- const values=new URLSearchParams();
113
- for(const [name,value] of new FormData(form,submitter)){if(typeof value!=="string"){status("File inputs are not supported");return}values.append(name,value)}
114
- const actionPath=sessionBase!=="/"?target.pathname.slice(sessionBase.length-1)||"/":target.pathname;
115
- 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")}};
116
- 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??"")};
117
- if(method==="get")for(const [name,value] of values)target.searchParams.append(name,value);
118
- 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")}
119
- });
120
- connect();
121
- `;
19
+ const privateNames = new Set([".env", ".git", ".ssh", "id_rsa", "id_ed25519", "id_ecdsa", "id_dsa"]);
20
+ const privateFilePattern = /^\.env\.|\.(?:key|pem|p12|ppk|p8|sqlite|sqlite3|db|db3)$|-(?:wal|shm|journal)$/i;
21
+
22
+ function getMimeType(filename) {
23
+ const type = lookup(filename);
24
+ if (!type) return "application/octet-stream";
25
+ return type.startsWith("text/") || type === "application/json" || type === "application/xml" || type === "application/manifest+json"
26
+ ? `${type}; charset=utf-8`
27
+ : type;
28
+ }
122
29
 
123
30
  function header(packet, name) {
124
31
  const entry = Object.entries(packet.headers || {}).find(([key]) => key.toLowerCase() === name);
@@ -149,7 +56,7 @@ function errorResponse(packet, status, message) {
149
56
  }
150
57
 
151
58
  function deniedPath(pathname) {
152
- return pathname.split("/").some(part => part === ".env" || part.startsWith(".env.") || part === ".git" || /\.(?:key|pem|p12|sqlite|db)$/i.test(part));
59
+ return pathname.split("/").filter(Boolean).some(part => privateNames.has(part) || privateFilePattern.test(part));
153
60
  }
154
61
 
155
62
  function inside(root, target) {
@@ -184,13 +91,6 @@ function requestUrl(packet) {
184
91
  return { pathname, encodedPathname: url.pathname, search: url.search };
185
92
  }
186
93
 
187
- function htmlWithClient(body) {
188
- const text = body.toString("utf8");
189
- const script = `<script type="module" data-letmeknow-client>${client}</script>`;
190
- const closingBody = text.search(/<\/body\s*>/i);
191
- return Buffer.from(closingBody < 0 ? text + script : text.slice(0, closingBody) + script + text.slice(closingBody), "utf8");
192
- }
193
-
194
94
  async function staticResponse(root, packet) {
195
95
  const method = typeof packet.method === "string" ? packet.method.toUpperCase() : "";
196
96
  if (method !== "GET" && method !== "HEAD") return errorResponse(packet, 405, "method not allowed");
@@ -211,8 +111,8 @@ async function staticResponse(root, packet) {
211
111
  return errorResponse(packet, 500, "preview request failed");
212
112
  }
213
113
  if (info.isDirectory()) {
214
- if (!request.pathname.endsWith("/")) {
215
- const location = request.encodedPathname + "/" + request.search;
114
+ if (!request.encodedPathname.endsWith("/")) {
115
+ const location = request.encodedPathname.slice(request.encodedPathname.lastIndexOf("/") + 1) + "/" + request.search;
216
116
  return response(packet, 301, Buffer.from(`Redirecting to ${location}`), { Location: location, "Content-Type": "text/plain; charset=utf-8" });
217
117
  }
218
118
  const index = resolve(target, "index.html");
@@ -222,31 +122,73 @@ async function staticResponse(root, packet) {
222
122
  }
223
123
  if (!inside(root, target)) return errorResponse(packet, 403, "forbidden");
224
124
  if (deniedPath("/" + relative(root, target).split(sep).join("/"))) return errorResponse(packet, 403, "forbidden");
125
+ try { info = await stat(target); } catch (cause) {
126
+ if (cause?.code === "ENOENT" || cause?.code === "ENOTDIR") return errorResponse(packet, 404, "not found");
127
+ if (cause?.code === "EACCES" || cause?.code === "EPERM") return errorResponse(packet, 403, "forbidden");
128
+ return errorResponse(packet, 500, "preview request failed");
129
+ }
225
130
  } else if (request.pathname.endsWith("/")) return errorResponse(packet, 404, "not found");
226
- let body;
227
- try { body = await readFile(target); } catch (cause) {
131
+ let file;
132
+ try { file = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW); } catch (cause) {
228
133
  if (cause?.code === "ENOENT" || cause?.code === "ENOTDIR") return errorResponse(packet, 404, "not found");
229
- if (cause?.code === "EACCES" || cause?.code === "EPERM") return errorResponse(packet, 403, "forbidden");
134
+ if (cause?.code === "EACCES" || cause?.code === "EPERM" || cause?.code === "ELOOP") return errorResponse(packet, 403, "forbidden");
230
135
  return errorResponse(packet, 500, "preview request failed");
231
136
  }
232
- if (body.byteLength > MAX_BODY_BYTES) return errorResponse(packet, 413, "response body is too large");
233
- if (extname(target).toLowerCase() === ".html") body = htmlWithClient(body);
234
- if (body.byteLength > MAX_BODY_BYTES) return errorResponse(packet, 413, "response body is too large");
235
- return response(packet, 200, body, { "Content-Type": mimeTypes[extname(target).toLowerCase()] || "application/octet-stream" });
137
+ try {
138
+ info = await file.stat();
139
+ if (!info.isFile()) return errorResponse(packet, 404, "not found");
140
+ if (info.size > MAX_BODY_BYTES) return errorResponse(packet, 413, "response body is too large");
141
+ const body = await file.readFile();
142
+ if (body.byteLength > MAX_BODY_BYTES) return errorResponse(packet, 413, "response body is too large");
143
+ return response(packet, 200, body, { "Content-Type": getMimeType(target) });
144
+ } catch {
145
+ return errorResponse(packet, 500, "preview request failed");
146
+ } finally {
147
+ await file.close();
148
+ }
236
149
  }
237
150
 
238
- async function submission(packet) {
151
+ async function multipartSubmission(body, contentType, getAttachmentInbox) {
152
+ const formData = await new Request("http://letmeknow.local", {
153
+ method: "POST",
154
+ headers: { "Content-Type": contentType },
155
+ body
156
+ }).formData();
157
+ const values = Object.create(null);
158
+ const attachments = [];
159
+ for (const [name, value] of formData) {
160
+ if (typeof value === "string") {
161
+ addValue(values, name, value);
162
+ continue;
163
+ }
164
+ if (value.name === "") continue;
165
+ const bytes = Buffer.from(await value.arrayBuffer());
166
+ const path = join(await getAttachmentInbox(), randomUUID());
167
+ await writeFile(path, bytes, { mode: 0o600, flag: "wx" });
168
+ attachments.push({ field: name, name: value.name, type: value.type, size: bytes.byteLength, path });
169
+ }
170
+ return { values, attachments };
171
+ }
172
+
173
+ async function submission(packet, getAttachmentInbox) {
239
174
  const url = requestUrl(packet);
240
175
  const method = typeof packet.method === "string" ? packet.method.toUpperCase() : "";
241
176
  const values = Object.create(null);
177
+ let attachments;
242
178
  if (method === "GET") {
243
179
  for (const [name, value] of new URLSearchParams(url.search)) addValue(values, name, value);
244
180
  } else if (method === "POST") {
245
181
  const body = Buffer.from(typeof packet.body === "string" ? packet.body : "", "base64");
246
182
  if (body.byteLength > MAX_BODY_BYTES) throw new Error("submission is too large");
247
- const contentType = header(packet, "content-type")?.split(";", 1)[0].trim().toLowerCase();
248
- if (contentType !== "application/x-www-form-urlencoded") throw new Error("unsupported submission encoding");
249
- for (const [name, value] of new URLSearchParams(body.toString("utf8"))) addValue(values, name, value);
183
+ const contentTypeHeader = header(packet, "content-type");
184
+ const contentType = contentTypeHeader?.split(";", 1)[0].trim().toLowerCase();
185
+ if (contentType === "application/x-www-form-urlencoded") {
186
+ for (const [name, value] of new URLSearchParams(body.toString("utf8"))) addValue(values, name, value);
187
+ } else if (contentType === "multipart/form-data" && contentTypeHeader) {
188
+ const parsed = await multipartSubmission(body, contentTypeHeader, getAttachmentInbox);
189
+ Object.assign(values, parsed.values);
190
+ attachments = parsed.attachments;
191
+ } else throw new Error("unsupported submission encoding");
250
192
  } else throw new Error("unsupported submission method");
251
193
  const event = {
252
194
  type: "submit",
@@ -257,41 +199,33 @@ async function submission(packet) {
257
199
  trigger: { id: encodedHeader(packet, "x-letmeknow-trigger-id"), name: encodedHeader(packet, "x-letmeknow-trigger-name"), value: encodedHeader(packet, "x-letmeknow-trigger-value") },
258
200
  values
259
201
  };
202
+ if (attachments?.length) event.attachments = attachments;
260
203
  process.stdout.write(`${JSON.stringify(event)}\n`);
261
- return response(packet, 204);
204
+ return response(packet, 202);
262
205
  }
263
206
 
264
- async function handleRequest(root, packet) {
207
+ async function handleRequest(root, packet, getAttachmentInbox) {
265
208
  if (header(packet, "x-letmeknow-submission") === "1") {
266
- 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"); }
209
+ try { return await submission(packet, getAttachmentInbox); } catch (cause) { return errorResponse(packet, cause?.message === "submission is too large" ? 413 : 400, cause instanceof Error ? cause.message : "invalid submission"); }
267
210
  }
268
211
  return staticResponse(root, packet);
269
212
  }
270
213
 
271
- function options(args) {
272
- let root;
273
- for (const argument of args) {
274
- if (argument.startsWith("-")) throw new Error(`unknown option: ${argument}`);
275
- if (root !== undefined) throw new Error("only one directory may be provided");
276
- root = resolve(argument);
277
- }
278
- root = root || process.cwd();
214
+ function options(directory) {
215
+ const root = resolve(directory);
279
216
  if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error(`directory does not exist: ${root}`);
280
217
  return realpath(root).then(root => ({ root }));
281
218
  }
282
219
 
283
- function endpoint(control, credential, sessionUrl) {
284
- const url = new URL(control);
285
- url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
286
- url.pathname = "/v1/connect";
287
- url.search = "";
288
- url.hash = "";
220
+ function endpoint(credential, sessionUrl) {
221
+ const url = new URL(CONTROL_URL);
222
+ url.protocol = "wss:";
223
+ url.pathname = "/v2/connect";
289
224
  if (credential && sessionUrl) {
290
225
  const publicUrl = new URL(sessionUrl);
291
- const hostCode = publicUrl.hostname.match(/^([a-f0-9]{20})\.letmeknow\.dev$/);
292
- const pathCode = publicUrl.pathname.match(/^\/s\/([a-f0-9]{20})(?:\/|$)/);
293
- const code = hostCode?.[1] || pathCode?.[1];
294
- if (code) url.searchParams.set("code", code);
226
+ const code = publicUrl.hostname.match(/^([a-f0-9]{20})\.letmeknow\.dev$/)?.[1];
227
+ if (!code) throw new Error("invalid session URL");
228
+ url.searchParams.set("code", code);
295
229
  }
296
230
  return url;
297
231
  }
@@ -300,21 +234,37 @@ function validSessionUrl(value) {
300
234
  if (typeof value !== "string") return false;
301
235
  let url;
302
236
  try { url = new URL(value); } catch { return false; }
303
- if (url.protocol !== "http:" && url.protocol !== "https:") return false;
304
- if (/^[a-f0-9]{20}\.letmeknow\.dev$/.test(url.hostname)) return true;
305
- return /^\/s\/[a-f0-9]{20}(?:\/|$)/.test(url.pathname);
237
+ return url.protocol === "https:" && /^[a-f0-9]{20}\.letmeknow\.dev$/.test(url.hostname) && url.pathname === "/" && !url.search && !url.hash;
306
238
  }
307
239
 
308
- async function start(args) {
309
- const { root } = await options(args);
310
- const control = process.env.LETMEKNOW_URL || "https://letmeknow.dev";
240
+ async function start(directory) {
241
+ const { root } = await options(directory);
242
+ let attachmentInboxPromise;
243
+ const getAttachmentInbox = () => {
244
+ attachmentInboxPromise ??= mkdtemp(join(tmpdir(), "letmeknow-attachments-"));
245
+ return attachmentInboxPromise;
246
+ };
311
247
  let send = () => false;
312
- const watcher = watch(root, { recursive: true, encoding: "utf8" }, (_event, filename) => {
313
- if (!filename) { send({ type: "file_update", path: "/" }); return; }
248
+ let revisionTimer;
249
+ const watchedPath = filename => {
314
250
  const file = resolve(root, String(filename));
315
251
  const path = relative(root, file).split(sep).join("/");
316
- if (!path || path.startsWith("../") || path === ".." || deniedPath("/" + path)) return;
317
- send({ type: "file_update", path: "/" + path.split("/").map(encodeURIComponent).join("/") });
252
+ return path && path !== ".." && !path.startsWith("../") && !deniedPath("/" + path);
253
+ };
254
+ const watcher = chokidar.watch(root, {
255
+ ignoreInitial: true,
256
+ ignored: filename => {
257
+ const path = relative(root, resolve(root, String(filename))).split(sep).join("/");
258
+ return path !== "" && (path === ".." || path.startsWith("../") || deniedPath("/" + path));
259
+ }
260
+ });
261
+ const scheduleRevision = () => {
262
+ clearTimeout(revisionTimer);
263
+ revisionTimer = setTimeout(() => { revisionTimer = undefined; send({ type: "revision" }); }, REVISION_QUIET_MS);
264
+ };
265
+ watcher.on("all", (_event, filename) => {
266
+ if (filename && !watchedPath(filename)) return;
267
+ scheduleRevision();
318
268
  });
319
269
  let socket;
320
270
  let credential;
@@ -330,8 +280,13 @@ async function start(args) {
330
280
  stopped = true;
331
281
  clearTimeout(retryTimer);
332
282
  clearTimeout(connectionTimer);
283
+ clearTimeout(revisionTimer);
284
+ send({ type: "close" });
333
285
  try { socket?.close(); } catch {}
334
- watcher.close();
286
+ await watcher.close();
287
+ if (attachmentInboxPromise) {
288
+ try { await rm(await attachmentInboxPromise, { recursive: true, force: true }); } catch {}
289
+ }
335
290
  process.exit(code);
336
291
  };
337
292
  process.once("SIGINT", () => void stop(0));
@@ -346,11 +301,11 @@ async function start(args) {
346
301
  const connect = () => {
347
302
  if (stopped) return;
348
303
  const reconnecting = Boolean(credential && sessionUrl);
349
- const current = socket = reconnecting ? new WebSocket(endpoint(control, credential, sessionUrl), credential) : new WebSocket(endpoint(control));
304
+ const current = socket = reconnecting ? new WebSocket(endpoint(credential, sessionUrl), credential) : new WebSocket(endpoint());
350
305
  connectionTimer = setTimeout(() => {
351
306
  if (socket !== current || current.readyState === WebSocket.OPEN || stopped) return;
352
307
  try { current.close(); } catch {}
353
- if (reconnecting) retry(); else void stop(1);
308
+ if (!reconnecting) void stop(1);
354
309
  }, CONNECTION_TIMEOUT);
355
310
  current.addEventListener("open", () => {
356
311
  if (socket !== current || stopped) return;
@@ -375,7 +330,7 @@ async function start(args) {
375
330
  sessionUrl = packet.url;
376
331
  if (!ready) { ready = true; process.stdout.write(`${JSON.stringify({ type: "ready", url: sessionUrl })}\n`); }
377
332
  } else if (packet.type === "http_request") {
378
- void handleRequest(root, packet).then(result => send(result)).catch(() => send(errorResponse(packet, 500, "preview request failed")));
333
+ void handleRequest(root, packet, getAttachmentInbox).then(result => send(result)).catch(() => send(errorResponse(packet, 500, "preview request failed")));
379
334
  } else if (packet.type === "closed") {
380
335
  void stop(0);
381
336
  } else if (packet.type === "error") {
@@ -398,11 +353,29 @@ async function start(args) {
398
353
  await new Promise(() => {});
399
354
  }
400
355
 
401
- if (process.argv[2] === "--skill") {
402
- if (process.argv.length !== 3) { process.stderr.write("Usage: npx letmeknow-cli --skill\n"); process.exit(1); }
356
+ let parsed;
357
+ try {
358
+ parsed = parseArgs({
359
+ args: process.argv.slice(2),
360
+ options: {
361
+ skill: { type: "boolean" },
362
+ help: { type: "boolean", short: "h" }
363
+ },
364
+ allowPositionals: true
365
+ });
366
+ } catch (cause) {
367
+ process.stderr.write(`letmeknow: ${cause instanceof Error ? cause.message : "invalid arguments"}\n`);
368
+ process.exit(1);
369
+ }
370
+
371
+ if (parsed.values.skill) {
372
+ if (parsed.positionals.length > 0) { process.stderr.write("Usage: npx letmeknow-cli --skill\n"); process.exit(1); }
403
373
  writeSync(1, readFileSync(new URL("../SKILL.md", import.meta.url)));
404
- } else if (process.argv.slice(2).includes("--help") || process.argv.slice(2).includes("-h")) {
405
- 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");
374
+ } else if (parsed.values.help) {
375
+ 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");
376
+ } else if (parsed.positionals.length !== 1) {
377
+ process.stderr.write("letmeknow: exactly one directory must be provided\n");
378
+ process.exit(1);
406
379
  } else {
407
- try { await start(process.argv.slice(2)); } catch (cause) { process.stderr.write(`letmeknow: ${cause instanceof Error ? cause.message : "server failed"}\n`); process.exitCode = 1; }
380
+ try { await start(parsed.positionals[0]); } catch (cause) { process.stderr.write(`letmeknow: ${cause instanceof Error ? cause.message : "server failed"}\n`); process.exitCode = 1; }
408
381
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "letmeknow-cli",
3
- "version": "0.4.10",
3
+ "version": "0.5.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 tests/integration.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",
@@ -27,5 +28,9 @@
27
28
  "vitest": "^4.1.11",
28
29
  "wrangler": "^4.126.0",
29
30
  "ws": "^8.21.3"
31
+ },
32
+ "dependencies": {
33
+ "chokidar": "^5.0.0",
34
+ "mrmime": "^2.0.1"
30
35
  }
31
36
  }