letmeknow-cli 0.4.11 → 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 +112 -143
  4. package/package.json +3 -3
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,20 +1,23 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { existsSync, readFileSync, statSync, 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";
6
8
  import { parseArgs } from "node:util";
7
9
  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;
14
15
  const MAX_RETRY_DELAY = 5_000;
16
+ const REVISION_QUIET_MS = 200;
17
+ const CONTROL_URL = "https://letmeknow.dev";
15
18
  const credentialPattern = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
16
- const ig = ignore().add([".env", ".env.*", ".git", "*.key", "*.pem", "*.p12", "*.sqlite", "*.db"]);
17
- const privateFilePattern = /\.(?:key|pem|p12|sqlite|db)$/i;
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;
18
21
 
19
22
  function getMimeType(filename) {
20
23
  const type = lookup(filename);
@@ -23,92 +26,6 @@ function getMimeType(filename) {
23
26
  ? `${type}; charset=utf-8`
24
27
  : type;
25
28
  }
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
29
 
113
30
  function header(packet, name) {
114
31
  const entry = Object.entries(packet.headers || {}).find(([key]) => key.toLowerCase() === name);
@@ -139,8 +56,7 @@ function errorResponse(packet, status, message) {
139
56
  }
140
57
 
141
58
  function deniedPath(pathname) {
142
- const normalized = pathname.replace(/^\/+/, "");
143
- return normalized !== "" && (ig.ignores(normalized) || pathname.split("/").filter(Boolean).some(part => ig.ignores(part) || privateFilePattern.test(part)));
59
+ return pathname.split("/").filter(Boolean).some(part => privateNames.has(part) || privateFilePattern.test(part));
144
60
  }
145
61
 
146
62
  function inside(root, target) {
@@ -175,13 +91,6 @@ function requestUrl(packet) {
175
91
  return { pathname, encodedPathname: url.pathname, search: url.search };
176
92
  }
177
93
 
178
- function htmlWithClient(body) {
179
- const text = body.toString("utf8");
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
94
  async function staticResponse(root, packet) {
186
95
  const method = typeof packet.method === "string" ? packet.method.toUpperCase() : "";
187
96
  if (method !== "GET" && method !== "HEAD") return errorResponse(packet, 405, "method not allowed");
@@ -202,8 +111,8 @@ async function staticResponse(root, packet) {
202
111
  return errorResponse(packet, 500, "preview request failed");
203
112
  }
204
113
  if (info.isDirectory()) {
205
- if (!request.pathname.endsWith("/")) {
206
- const location = request.encodedPathname + "/" + request.search;
114
+ if (!request.encodedPathname.endsWith("/")) {
115
+ const location = request.encodedPathname.slice(request.encodedPathname.lastIndexOf("/") + 1) + "/" + request.search;
207
116
  return response(packet, 301, Buffer.from(`Redirecting to ${location}`), { Location: location, "Content-Type": "text/plain; charset=utf-8" });
208
117
  }
209
118
  const index = resolve(target, "index.html");
@@ -213,31 +122,73 @@ async function staticResponse(root, packet) {
213
122
  }
214
123
  if (!inside(root, target)) return errorResponse(packet, 403, "forbidden");
215
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
+ }
216
130
  } else if (request.pathname.endsWith("/")) return errorResponse(packet, 404, "not found");
217
- let body;
218
- try { body = await readFile(target); } catch (cause) {
131
+ let file;
132
+ try { file = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW); } catch (cause) {
219
133
  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");
134
+ if (cause?.code === "EACCES" || cause?.code === "EPERM" || cause?.code === "ELOOP") return errorResponse(packet, 403, "forbidden");
135
+ return errorResponse(packet, 500, "preview request failed");
136
+ }
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 {
221
145
  return errorResponse(packet, 500, "preview request failed");
146
+ } finally {
147
+ await file.close();
222
148
  }
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
149
  }
228
150
 
229
- 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) {
230
174
  const url = requestUrl(packet);
231
175
  const method = typeof packet.method === "string" ? packet.method.toUpperCase() : "";
232
176
  const values = Object.create(null);
177
+ let attachments;
233
178
  if (method === "GET") {
234
179
  for (const [name, value] of new URLSearchParams(url.search)) addValue(values, name, value);
235
180
  } else if (method === "POST") {
236
181
  const body = Buffer.from(typeof packet.body === "string" ? packet.body : "", "base64");
237
182
  if (body.byteLength > MAX_BODY_BYTES) throw new Error("submission is too large");
238
- const contentType = header(packet, "content-type")?.split(";", 1)[0].trim().toLowerCase();
239
- if (contentType !== "application/x-www-form-urlencoded") throw new Error("unsupported submission encoding");
240
- 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");
241
192
  } else throw new Error("unsupported submission method");
242
193
  const event = {
243
194
  type: "submit",
@@ -248,35 +199,33 @@ async function submission(packet) {
248
199
  trigger: { id: encodedHeader(packet, "x-letmeknow-trigger-id"), name: encodedHeader(packet, "x-letmeknow-trigger-name"), value: encodedHeader(packet, "x-letmeknow-trigger-value") },
249
200
  values
250
201
  };
202
+ if (attachments?.length) event.attachments = attachments;
251
203
  process.stdout.write(`${JSON.stringify(event)}\n`);
252
- return response(packet, 204);
204
+ return response(packet, 202);
253
205
  }
254
206
 
255
- async function handleRequest(root, packet) {
207
+ async function handleRequest(root, packet, getAttachmentInbox) {
256
208
  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"); }
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"); }
258
210
  }
259
211
  return staticResponse(root, packet);
260
212
  }
261
213
 
262
214
  function options(directory) {
263
- const root = resolve(directory || process.cwd());
215
+ const root = resolve(directory);
264
216
  if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error(`directory does not exist: ${root}`);
265
217
  return realpath(root).then(root => ({ root }));
266
218
  }
267
219
 
268
- function endpoint(control, credential, sessionUrl) {
269
- const url = new URL(control);
270
- url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
271
- url.pathname = "/v1/connect";
272
- url.search = "";
273
- url.hash = "";
220
+ function endpoint(credential, sessionUrl) {
221
+ const url = new URL(CONTROL_URL);
222
+ url.protocol = "wss:";
223
+ url.pathname = "/v2/connect";
274
224
  if (credential && sessionUrl) {
275
225
  const publicUrl = new URL(sessionUrl);
276
- const hostCode = publicUrl.hostname.match(/^([a-f0-9]{20})\.letmeknow\.dev$/);
277
- const pathCode = publicUrl.pathname.match(/^\/s\/([a-f0-9]{20})(?:\/|$)/);
278
- const code = hostCode?.[1] || pathCode?.[1];
279
- 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);
280
229
  }
281
230
  return url;
282
231
  }
@@ -285,22 +234,37 @@ function validSessionUrl(value) {
285
234
  if (typeof value !== "string") return false;
286
235
  let url;
287
236
  try { url = new URL(value); } catch { return false; }
288
- if (url.protocol !== "http:" && url.protocol !== "https:") return false;
289
- if (/^[a-f0-9]{20}\.letmeknow\.dev$/.test(url.hostname)) return true;
290
- 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;
291
238
  }
292
239
 
293
240
  async function start(directory) {
294
241
  const { root } = await options(directory);
295
- const control = process.env.LETMEKNOW_URL || "https://letmeknow.dev";
242
+ let attachmentInboxPromise;
243
+ const getAttachmentInbox = () => {
244
+ attachmentInboxPromise ??= mkdtemp(join(tmpdir(), "letmeknow-attachments-"));
245
+ return attachmentInboxPromise;
246
+ };
296
247
  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; }
248
+ let revisionTimer;
249
+ const watchedPath = filename => {
300
250
  const file = resolve(root, String(filename));
301
251
  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("/") });
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();
304
268
  });
305
269
  let socket;
306
270
  let credential;
@@ -316,8 +280,13 @@ async function start(directory) {
316
280
  stopped = true;
317
281
  clearTimeout(retryTimer);
318
282
  clearTimeout(connectionTimer);
283
+ clearTimeout(revisionTimer);
284
+ send({ type: "close" });
319
285
  try { socket?.close(); } catch {}
320
286
  await watcher.close();
287
+ if (attachmentInboxPromise) {
288
+ try { await rm(await attachmentInboxPromise, { recursive: true, force: true }); } catch {}
289
+ }
321
290
  process.exit(code);
322
291
  };
323
292
  process.once("SIGINT", () => void stop(0));
@@ -332,11 +301,11 @@ async function start(directory) {
332
301
  const connect = () => {
333
302
  if (stopped) return;
334
303
  const reconnecting = Boolean(credential && sessionUrl);
335
- 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());
336
305
  connectionTimer = setTimeout(() => {
337
306
  if (socket !== current || current.readyState === WebSocket.OPEN || stopped) return;
338
307
  try { current.close(); } catch {}
339
- if (reconnecting) retry(); else void stop(1);
308
+ if (!reconnecting) void stop(1);
340
309
  }, CONNECTION_TIMEOUT);
341
310
  current.addEventListener("open", () => {
342
311
  if (socket !== current || stopped) return;
@@ -361,7 +330,7 @@ async function start(directory) {
361
330
  sessionUrl = packet.url;
362
331
  if (!ready) { ready = true; process.stdout.write(`${JSON.stringify({ type: "ready", url: sessionUrl })}\n`); }
363
332
  } else if (packet.type === "http_request") {
364
- 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")));
365
334
  } else if (packet.type === "closed") {
366
335
  void stop(0);
367
336
  } else if (packet.type === "error") {
@@ -403,9 +372,9 @@ if (parsed.values.skill) {
403
372
  if (parsed.positionals.length > 0) { process.stderr.write("Usage: npx letmeknow-cli --skill\n"); process.exit(1); }
404
373
  writeSync(1, readFileSync(new URL("../SKILL.md", import.meta.url)));
405
374
  } else if (parsed.values.help) {
406
- 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");
407
- } else if (parsed.positionals.length > 1) {
408
- process.stderr.write("letmeknow: only one directory may be provided\n");
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");
409
378
  process.exit(1);
410
379
  } else {
411
380
  try { await start(parsed.positionals[0]); } catch (cause) { process.stderr.write(`letmeknow: ${cause instanceof Error ? cause.message : "server failed"}\n`); process.exitCode = 1; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "letmeknow-cli",
3
- "version": "0.4.11",
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",
@@ -30,7 +31,6 @@
30
31
  },
31
32
  "dependencies": {
32
33
  "chokidar": "^5.0.0",
33
- "ignore": "^7.0.6",
34
34
  "mrmime": "^2.0.1"
35
35
  }
36
36
  }