letmeknow-cli 0.4.7 → 0.4.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # LetMeKnow
2
2
 
3
- LetMeKnow gives an agent-managed folder a public, live Vite preview. The CLI runs Vite in middleware mode and makes only an outbound WebSocket connection to the relay; it does not listen on a network port.
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.
4
4
 
5
5
  ## Start
6
6
 
@@ -26,9 +26,11 @@ There are no `--host` or `--port` options because the CLI intentionally has no l
26
26
 
27
27
  ## File workflow
28
28
 
29
- The CLI does not receive file commands. The agent reads and writes the directory directly. Keep a normal Vite entry point such as `index.html`; JavaScript, CSS, images, and other Vite-supported files can be requested through the relay.
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.
30
30
 
31
- When a watched file changes, the browser receives an update. HTML changes replace the current document body without a page reload and preserve form values, focus, selection, and scroll position. CSS links are refreshed without navigating. Changes to a different HTML route do not disturb the current page.
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.
32
+
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.
32
34
 
33
35
  An optional element can display submission status:
34
36
 
@@ -62,7 +64,7 @@ The event ID identifies the submission. It is not a request/response handle: upd
62
64
 
63
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.
64
66
 
65
- The CLI's Vite configuration is disabled and its filesystem access is limited to the selected folder. The CLI itself still requires an outbound network connection to the relay. It does not accept inbound browser connections.
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.
66
68
 
67
69
  ## Development
68
70
 
package/SKILL.md CHANGED
@@ -5,7 +5,7 @@ description: Give a human a live public preview of an agent-managed folder and r
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 runs Vite in middleware mode, connects to the public relay over an outbound WebSocket, and reports browser submissions on stdout.
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
9
 
10
10
  The CLI does **not** listen on a local network port.
11
11
 
@@ -23,20 +23,24 @@ Node.js 22.12 or newer is required. Read stdout and stderr separately. Stdout is
23
23
  {"type":"ready","url":"https://0123456789abcdef0123.letmeknow.dev/"}
24
24
  ```
25
25
 
26
- Open the public URL for the human. The directory defaults to the current working directory. Set `LETMEKNOW_URL` to use another compatible relay. There are no host or port options: the CLI intentionally has no listening socket.
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
27
 
28
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
29
 
30
30
  ## Build the page
31
31
 
32
- Create an ordinary Vite page in the folder, usually `index.html`, plus any CSS, JavaScript, images, or other assets it needs. Use semantic HTML and accessible labels, headings, sections, tables, and controls.
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.
33
33
 
34
- The relay is live:
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.
35
35
 
36
- - HTML changes update the current document body without a full page reload.
37
- - Existing form values, focus, text selection, and scroll position are restored after an HTML update.
38
- - CSS links are refreshed without navigating.
39
- - Changes to another HTML route do not replace the current route.
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.
37
+
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.
40
44
 
41
45
  An optional status element gives the human feedback after a form submission:
42
46
 
@@ -79,7 +83,7 @@ The event ID identifies the submission. There is no response packet. Validate it
79
83
  2. Wait for a `submit` event on stdout.
80
84
  3. Validate its `values` and `action`.
81
85
  4. Rewrite the relevant HTML or data file in the workspace.
82
- 5. The browser updates in place through the relay.
86
+ 5. The agent updates the live preview.
83
87
 
84
88
  Escape untrusted values before placing them in HTML. Treat browser input as untrusted even though the folder is local to the agent.
85
89
 
@@ -87,7 +91,7 @@ Escape untrusted values before placing them in HTML. Treat browser input as untr
87
91
 
88
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.
89
93
 
90
- The CLI disables Vite config discovery and limits filesystem access to the selected folder. It makes outbound relay connections only; it does not accept inbound browser connections.
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.
91
95
 
92
96
  ## Stop
93
97
 
package/bin/letmeknow.js CHANGED
@@ -1,78 +1,97 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { createServer } from "vite";
4
- import { existsSync, readFileSync, statSync, writeSync } from "node:fs";
5
- import { relative, resolve, sep } from "node:path";
6
- import { Readable, Writable } from "node:stream";
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";
7
6
 
8
7
  const MAX_BODY_BYTES = 1024 * 1024;
9
8
  const GRACE_SECONDS = 10 * 60;
10
9
  const CONNECTION_TIMEOUT = 10_000;
11
10
  const MAX_RETRY_DELAY = 5_000;
12
11
  const credentialPattern = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
13
- const clientPath = "/__letmeknow_client.js";
14
- const clientId = "\0letmeknow-client";
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
+ };
15
36
  const client = String.raw`
16
- const key="letmeknow-client:"+location.host+location.pathname;
17
- const draftKey="letmeknow-draft:"+location.host+location.pathname;
18
- let credential=sessionStorage.getItem(key);
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{}
19
43
  let socket;
20
44
  let retryTimer;
45
+ let updateTimer;
46
+ let reloadTimer;
21
47
  let terminal=false;
22
- const stateKey=(control,index)=>control.id?"#"+control.id:(control.form?.id??"")+":"+control.name+":"+control.type+":"+index;
23
- const controls=root=>[...root.querySelectorAll("input,select,textarea")];
24
- const snapshot=()=>{
25
- const state=new Map();
26
- let activeKey;
27
- for(const [index,control] of controls(document).entries()){
28
- const key=stateKey(control,index);
29
- state.set(key,{value:control.value,checked:control.checked,selected:control instanceof HTMLSelectElement?[...control.options].filter(option=>option.selected).map(option=>option.value):undefined,start:typeof control.selectionStart==="number"?control.selectionStart:undefined,end:typeof control.selectionEnd==="number"?control.selectionEnd:undefined});
30
- if(control===document.activeElement)activeKey=key;
31
- }
32
- return {state,activeKey,x:scrollX,y:scrollY};
33
- };
34
- const restore=saved=>{
35
- let active;
36
- for(const [index,control] of controls(document).entries()){
37
- const state=saved.state.get(stateKey(control,index));
38
- if(!state)continue;
39
- if(control instanceof HTMLSelectElement&&state.selected)for(const option of control.options)option.selected=state.selected.includes(option.value);
40
- else if(control.type==="checkbox"||control.type==="radio")control.checked=state.checked;
41
- else{control.value=state.value;if(typeof state.start==="number"&&typeof control.setSelectionRange==="function")control.setSelectionRange(state.start,state.end)}
42
- if(stateKey(control,index)===saved.activeKey)active=control;
43
- }
44
- active?.focus();
45
- scrollTo(saved.x,saved.y);
46
- };
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}};
47
56
  const status=message=>{const element=document.querySelector("[data-letmeknow-status]");if(element)element.textContent=message};
48
- const sessionPrefix=location.pathname.match(/^\/s\/[a-f0-9]{20}\//)?.[0];
49
- const currentPath=()=>{const path=sessionPrefix?location.pathname.slice(sessionPrefix.length-1)||"/":location.pathname;return path.endsWith("/")?path+"index.html":path};
50
- const refresh=async()=>{
51
- const saved=snapshot();
52
- const response=await fetch(location.href,{cache:"no-store",headers:{Accept:"text/html"}});
53
- if(!response.ok)throw new Error("page refresh failed");
54
- const next=new DOMParser().parseFromString(await response.text(),"text/html");
55
- document.title=next.title;
56
- document.body.replaceChildren(...[...next.body.childNodes].filter(node=>!(node instanceof HTMLScriptElement&&node.hasAttribute("data-letmeknow-client"))));
57
- const links=[...document.head.querySelectorAll("link[rel=stylesheet]")];
58
- for(const link of links){const url=new URL(link.href);url.searchParams.set("_letmeknow",crypto.randomUUID());link.href=url}
59
- restore(saved);
60
- };
61
- const update=path=>{if(path===currentPath()||path?.endsWith(".css"))refresh().catch(()=>status("The page could not be refreshed"))};
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));
62
79
  const connect=()=>{
63
80
  clearTimeout(retryTimer);retryTimer=undefined;
64
- const url=new URL("_letmeknow/client",location.href);url.protocol=url.protocol==="https:"?"wss:":"ws:";
65
- socket=credential?new WebSocket(url,credential):new WebSocket(url);
66
- socket.onmessage=event=>{
67
- const message=JSON.parse(event.data);
68
- if(message.type==="credential"){credential=message.credential;sessionStorage.setItem(key,credential);return}
69
- if(message.type==="challenge"){socket.send(JSON.stringify({type:"alive",nonce:message.nonce}));return}
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}
70
89
  if(message.type==="busy"){status("This session is open elsewhere");retryTimer=setTimeout(connect,message.retry_after*1000);return}
71
90
  if(message.type==="file_update"){update(message.path);return}
72
- if(message.type==="closed"){terminal=true;sessionStorage.removeItem(key);sessionStorage.removeItem(draftKey);status(message.message)}
91
+ if(message.type==="closed"){terminal=true;try{sessionStorage.removeItem(credentialKey)}catch{}status(message.message)}
73
92
  };
74
- socket.onclose=()=>{if(!terminal&&!retryTimer){status("Reconnecting…");retryTimer=setTimeout(connect,1000)}};
75
- socket.onerror=()=>{};
93
+ current.onclose=()=>{if(socket!==current||terminal)return;if(!retryTimer){status("Reconnecting…");retryTimer=setTimeout(connect,1000)}};
94
+ current.onerror=()=>{};
76
95
  };
77
96
  document.addEventListener("submit",async event=>{
78
97
  const form=event.target;
@@ -83,12 +102,16 @@ document.addEventListener("submit",async event=>{
83
102
  if(method!=="get"&&method!=="post"){status("Only GET and POST forms are supported");return}
84
103
  if(!form.checkValidity()){form.reportValidity();return}
85
104
  let target;
86
- try{target=new URL(submitter?.getAttribute("formaction")??form.getAttribute("action")??location.href,location.href)}catch{status("Invalid form action");return}
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}
87
106
  if(target.origin!==location.origin){status("Form actions must stay on this site");return}
88
- if(sessionPrefix&&!target.pathname.startsWith(sessionPrefix))target.pathname=(sessionPrefix+target.pathname.replace(/^\//,""));
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
+ }
89
112
  const values=new URLSearchParams();
90
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)}
91
- const actionPath=sessionPrefix?target.pathname.slice(sessionPrefix.length-1)||"/":target.pathname;
114
+ const actionPath=sessionBase!=="/"?target.pathname.slice(sessionBase.length-1)||"/":target.pathname;
92
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")}};
93
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??"")};
94
117
  if(method==="get")for(const [name,value] of values)target.searchParams.append(name,value);
@@ -97,187 +120,152 @@ document.addEventListener("submit",async event=>{
97
120
  connect();
98
121
  `;
99
122
 
100
- function encodedHeader(request, name) {
101
- const value = request.headers[name];
102
- if (typeof value !== "string" || value === "") return null;
103
- try {
104
- return decodeURIComponent(value);
105
- } catch {
106
- return null;
107
- }
123
+ function header(packet, name) {
124
+ const entry = Object.entries(packet.headers || {}).find(([key]) => key.toLowerCase() === name);
125
+ return typeof entry?.[1] === "string" && entry[1] !== "" ? entry[1] : null;
108
126
  }
109
127
 
110
- function addValue(values, name, value) {
111
- if (Object.prototype.hasOwnProperty.call(values, name)) {
112
- values[name] = Array.isArray(values[name]) ? [...values[name], value] : [values[name], value];
113
- } else {
114
- values[name] = value;
115
- }
128
+ function encodedHeader(packet, name) {
129
+ const value = header(packet, name);
130
+ if (value === null) return null;
131
+ try { return decodeURIComponent(value); } catch { return null; }
116
132
  }
117
133
 
118
- function readBody(request) {
119
- return new Promise((resolveBody, reject) => {
120
- const chunks = [];
121
- let size = 0;
122
- let tooLarge = false;
123
- request.on("data", chunk => {
124
- if (tooLarge) return;
125
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
126
- size += buffer.byteLength;
127
- if (size > MAX_BODY_BYTES) {
128
- tooLarge = true;
129
- request.resume();
130
- reject(new Error("submission is too large"));
131
- return;
132
- }
133
- chunks.push(buffer);
134
- });
135
- request.on("end", () => resolveBody(Buffer.concat(chunks)));
136
- request.on("error", reject);
137
- });
134
+ function addValue(values, name, value) {
135
+ if (Object.prototype.hasOwnProperty.call(values, name)) values[name] = Array.isArray(values[name]) ? [...values[name], value] : [values[name], value];
136
+ else values[name] = value;
138
137
  }
139
138
 
140
- async function submission(request, response) {
141
- const url = new URL(request.url || "/", "http://localhost");
142
- const method = (request.method || "GET").toUpperCase();
143
- const values = Object.create(null);
144
- if (method === "GET") {
145
- for (const [name, value] of url.searchParams) addValue(values, name, value);
146
- } else if (method === "POST") {
147
- const body = await readBody(request);
148
- const contentType = request.headers["content-type"]?.split(";", 1)[0].trim();
149
- if (contentType !== "application/x-www-form-urlencoded") throw new Error("unsupported submission encoding");
150
- for (const [name, value] of new URLSearchParams(body.toString("utf8"))) addValue(values, name, value);
151
- } else {
152
- throw new Error("unsupported submission method");
153
- }
154
- const event = {
155
- type: "submit",
156
- id: encodedHeader(request, "x-letmeknow-id"),
157
- method,
158
- action: encodedHeader(request, "x-letmeknow-action") || url.pathname,
159
- form_id: encodedHeader(request, "x-letmeknow-form-id"),
160
- trigger: {
161
- id: encodedHeader(request, "x-letmeknow-trigger-id"),
162
- name: encodedHeader(request, "x-letmeknow-trigger-name"),
163
- value: encodedHeader(request, "x-letmeknow-trigger-value")
164
- },
165
- values
166
- };
167
- process.stdout.write(`${JSON.stringify(event)}\n`);
168
- response.statusCode = 204;
169
- response.setHeader("Cache-Control", "no-store");
170
- response.end();
139
+ function response(packet, status, body = Buffer.alloc(0), headers = {}) {
140
+ if (body.byteLength > MAX_BODY_BYTES) return response(packet, 413, Buffer.from("response body is too large"), { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" });
141
+ const outputHeaders = { "Cache-Control": "no-store", ...headers };
142
+ if (!Object.keys(outputHeaders).some(name => name.toLowerCase() === "content-length")) outputHeaders["Content-Length"] = String(body.byteLength);
143
+ const method = typeof packet.method === "string" ? packet.method.toUpperCase() : "";
144
+ return { type: "http_response", request_id: packet.request_id, status, headers: outputHeaders, body: method === "HEAD" ? "" : body.toString("base64") };
171
145
  }
172
146
 
173
- class RelayRequest extends Readable {
174
- constructor(packet) {
175
- super();
176
- this.method = packet.method;
177
- this.url = packet.path;
178
- this.originalUrl = packet.path;
179
- this.headers = { ...(packet.headers || {}), host: "localhost" };
180
- this.httpVersion = "1.1";
181
- this.httpVersionMajor = 1;
182
- this.httpVersionMinor = 1;
183
- this.socket = { encrypted: false, remoteAddress: "127.0.0.1" };
184
- this.body = Buffer.from(packet.body || "", "base64");
185
- this.sent = false;
186
- }
187
-
188
- _read() {
189
- if (this.sent) return;
190
- this.sent = true;
191
- this.push(this.body);
192
- this.push(null);
193
- }
147
+ function errorResponse(packet, status, message) {
148
+ return response(packet, status, Buffer.from(message), { "Content-Type": "text/plain; charset=utf-8" });
194
149
  }
195
150
 
196
- class RelayResponse extends Writable {
197
- constructor() {
198
- super();
199
- this.statusCode = 200;
200
- this.headers = new Map();
201
- this.chunks = [];
202
- }
203
-
204
- setHeader(name, value) {
205
- this.headers.set(name.toLowerCase(), Array.isArray(value) ? value.join(", ") : String(value));
206
- return this;
207
- }
208
-
209
- appendHeader(name, value) {
210
- const current = this.getHeader(name);
211
- return this.setHeader(name, current ? [current, value] : value);
212
- }
213
-
214
- getHeader(name) {
215
- return this.headers.get(name.toLowerCase());
216
- }
217
-
218
- getHeaders() {
219
- return Object.fromEntries(this.headers);
220
- }
151
+ function deniedPath(pathname) {
152
+ return pathname.split("/").some(part => part === ".env" || part.startsWith(".env.") || part === ".git" || /\.(?:key|pem|p12|sqlite|db)$/i.test(part));
153
+ }
221
154
 
222
- hasHeader(name) {
223
- return this.headers.has(name.toLowerCase());
224
- }
155
+ function inside(root, target) {
156
+ const path = relative(root, target);
157
+ return path === "" || (path !== ".." && !path.startsWith(".." + sep));
158
+ }
225
159
 
226
- removeHeader(name) {
227
- this.headers.delete(name.toLowerCase());
160
+ async function safeRealpath(root, candidate) {
161
+ try {
162
+ const target = await realpath(candidate);
163
+ return inside(root, target) ? target : null;
164
+ } catch (cause) {
165
+ if (cause?.code === "EACCES" || cause?.code === "EPERM") return null;
166
+ if (cause?.code === "ENOENT" || cause?.code === "ENOTDIR") {
167
+ try {
168
+ const parent = await realpath(dirname(candidate));
169
+ if (!inside(root, parent)) return null;
170
+ } catch {}
171
+ return undefined;
172
+ }
173
+ throw cause;
228
174
  }
175
+ }
229
176
 
230
- writeHead(status, headers) {
231
- this.statusCode = status;
232
- if (headers) for (const [name, value] of Object.entries(headers)) this.setHeader(name, value);
233
- return this;
234
- }
177
+ function requestUrl(packet) {
178
+ if (typeof packet.path !== "string" || !packet.path.startsWith("/")) throw new Error("invalid request path");
179
+ const url = new URL(packet.path, "http://letmeknow.local");
180
+ if (url.origin !== "http://letmeknow.local") throw new Error("invalid request path");
181
+ let pathname;
182
+ try { pathname = decodeURIComponent(url.pathname); } catch { throw new Error("invalid request path"); }
183
+ if (pathname.includes("\0") || pathname.includes("\\")) throw new Error("invalid request path");
184
+ return { pathname, encodedPathname: url.pathname, search: url.search };
185
+ }
235
186
 
236
- flushHeaders() {}
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
+ }
237
193
 
238
- _write(chunk, _encoding, callback) {
239
- this.chunks.push(Buffer.from(chunk));
240
- callback();
194
+ async function staticResponse(root, packet) {
195
+ const method = typeof packet.method === "string" ? packet.method.toUpperCase() : "";
196
+ if (method !== "GET" && method !== "HEAD") return errorResponse(packet, 405, "method not allowed");
197
+ let request;
198
+ try { request = requestUrl(packet); } catch { return errorResponse(packet, 400, "bad request"); }
199
+ if (deniedPath(request.pathname)) return errorResponse(packet, 403, "forbidden");
200
+ const candidate = resolve(root, "." + request.pathname);
201
+ if (!inside(root, candidate)) return errorResponse(packet, 403, "forbidden");
202
+ let target;
203
+ try { target = await safeRealpath(root, candidate); } catch { return errorResponse(packet, 500, "preview request failed"); }
204
+ if (target === null) return errorResponse(packet, 403, "forbidden");
205
+ if (target === undefined) return errorResponse(packet, 404, "not found");
206
+ if (deniedPath("/" + relative(root, target).split(sep).join("/"))) return errorResponse(packet, 403, "forbidden");
207
+ let info;
208
+ try { info = await stat(target); } catch (cause) {
209
+ if (cause?.code === "ENOENT" || cause?.code === "ENOTDIR") return errorResponse(packet, 404, "not found");
210
+ if (cause?.code === "EACCES" || cause?.code === "EPERM") return errorResponse(packet, 403, "forbidden");
211
+ return errorResponse(packet, 500, "preview request failed");
241
212
  }
242
-
243
- body() {
244
- return Buffer.concat(this.chunks);
213
+ if (info.isDirectory()) {
214
+ if (!request.pathname.endsWith("/")) {
215
+ const location = request.encodedPathname + "/" + request.search;
216
+ return response(packet, 301, Buffer.from(`Redirecting to ${location}`), { Location: location, "Content-Type": "text/plain; charset=utf-8" });
217
+ }
218
+ const index = resolve(target, "index.html");
219
+ try { target = await realpath(index); } catch (cause) {
220
+ if (cause?.code === "ENOENT" || cause?.code === "ENOTDIR") return errorResponse(packet, 404, "not found");
221
+ return errorResponse(packet, 500, "preview request failed");
222
+ }
223
+ if (!inside(root, target)) return errorResponse(packet, 403, "forbidden");
224
+ if (deniedPath("/" + relative(root, target).split(sep).join("/"))) return errorResponse(packet, 403, "forbidden");
225
+ } else if (request.pathname.endsWith("/")) return errorResponse(packet, 404, "not found");
226
+ let body;
227
+ try { body = await readFile(target); } catch (cause) {
228
+ 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");
230
+ return errorResponse(packet, 500, "preview request failed");
245
231
  }
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" });
246
236
  }
247
237
 
248
- function middlewareResponse(response) {
249
- const body = response.body();
250
- if (body.byteLength > MAX_BODY_BYTES) throw new Error("response body is too large");
251
- return {
252
- type: "http_response",
253
- request_id: response.requestId,
254
- status: response.statusCode,
255
- headers: response.getHeaders(),
256
- body: body.toString("base64")
238
+ async function submission(packet) {
239
+ const url = requestUrl(packet);
240
+ const method = typeof packet.method === "string" ? packet.method.toUpperCase() : "";
241
+ const values = Object.create(null);
242
+ if (method === "GET") {
243
+ for (const [name, value] of new URLSearchParams(url.search)) addValue(values, name, value);
244
+ } else if (method === "POST") {
245
+ const body = Buffer.from(typeof packet.body === "string" ? packet.body : "", "base64");
246
+ 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);
250
+ } else throw new Error("unsupported submission method");
251
+ const event = {
252
+ type: "submit",
253
+ id: encodedHeader(packet, "x-letmeknow-id"),
254
+ method,
255
+ action: encodedHeader(packet, "x-letmeknow-action") || url.pathname,
256
+ form_id: encodedHeader(packet, "x-letmeknow-form-id"),
257
+ trigger: { id: encodedHeader(packet, "x-letmeknow-trigger-id"), name: encodedHeader(packet, "x-letmeknow-trigger-name"), value: encodedHeader(packet, "x-letmeknow-trigger-value") },
258
+ values
257
259
  };
260
+ process.stdout.write(`${JSON.stringify(event)}\n`);
261
+ return response(packet, 204);
258
262
  }
259
263
 
260
- async function handleRequest(server, packet, send) {
261
- const request = new RelayRequest(packet);
262
- const response = new RelayResponse();
263
- response.requestId = packet.request_id;
264
- await new Promise((resolveRequest, rejectRequest) => {
265
- response.once("finish", resolveRequest);
266
- response.once("error", rejectRequest);
267
- try {
268
- server.middlewares(request, response, cause => {
269
- if (cause) {
270
- rejectRequest(cause);
271
- } else if (!response.writableEnded) {
272
- response.statusCode = 404;
273
- response.end("Not found");
274
- }
275
- });
276
- } catch (cause) {
277
- rejectRequest(cause);
278
- }
279
- });
280
- send(middlewareResponse(response));
264
+ async function handleRequest(root, packet) {
265
+ 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"); }
267
+ }
268
+ return staticResponse(root, packet);
281
269
  }
282
270
 
283
271
  function options(args) {
@@ -289,7 +277,7 @@ function options(args) {
289
277
  }
290
278
  root = root || process.cwd();
291
279
  if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error(`directory does not exist: ${root}`);
292
- return { root };
280
+ return realpath(root).then(root => ({ root }));
293
281
  }
294
282
 
295
283
  function endpoint(control, credential, sessionUrl) {
@@ -311,55 +299,23 @@ function endpoint(control, credential, sessionUrl) {
311
299
  function validSessionUrl(value) {
312
300
  if (typeof value !== "string") return false;
313
301
  let url;
314
- try {
315
- url = new URL(value);
316
- } catch {
317
- return false;
318
- }
302
+ try { url = new URL(value); } catch { return false; }
319
303
  if (url.protocol !== "http:" && url.protocol !== "https:") return false;
320
304
  if (/^[a-f0-9]{20}\.letmeknow\.dev$/.test(url.hostname)) return true;
321
305
  return /^\/s\/[a-f0-9]{20}(?:\/|$)/.test(url.pathname);
322
306
  }
323
307
 
324
308
  async function start(args) {
325
- const { root } = options(args);
309
+ const { root } = await options(args);
326
310
  const control = process.env.LETMEKNOW_URL || "https://letmeknow.dev";
327
311
  let send = () => false;
328
- const vite = await createServer({
329
- root,
330
- configFile: false,
331
- appType: "spa",
332
- css: { postcss: false },
333
- logLevel: "silent",
334
- server: { middlewareMode: true, hmr: false, ws: false, fs: { strict: true, allow: [root], deny: ["**/.env", "**/.env.*", "**/.git/**", "**/*.key", "**/*.pem", "**/*.p12", "**/*.sqlite", "**/*.db"] } },
335
- plugins: [{
336
- name: "letmeknow-relay",
337
- resolveId(id) { return id === clientPath ? clientId : undefined; },
338
- load(id) { return id === clientId ? client : undefined; },
339
- configureServer(server) {
340
- server.middlewares.use((request, response, next) => {
341
- if (request.headers["x-letmeknow-submission"] !== "1") {
342
- next();
343
- return;
344
- }
345
- submission(request, response).catch(cause => {
346
- response.statusCode = cause instanceof Error && cause.message === "submission is too large" ? 413 : 400;
347
- response.setHeader("Content-Type", "text/plain; charset=utf-8");
348
- response.end(cause instanceof Error ? cause.message : "invalid submission");
349
- });
350
- });
351
- const update = file => send({ type: "file_update", path: "/" + relative(root, file).split(sep).join("/") });
352
- server.watcher.on("change", update);
353
- server.watcher.on("add", update);
354
- server.watcher.on("unlink", update);
355
- },
356
- transformIndexHtml(html) {
357
- const script = `<script type="module" src="${clientPath}" data-letmeknow-client></script>`;
358
- return html.includes("</body>") ? html.replace("</body>", `${script}</body>`) : `${html}${script}`;
359
- }
360
- }]
312
+ const watcher = watch(root, { recursive: true, encoding: "utf8" }, (_event, filename) => {
313
+ if (!filename) { send({ type: "file_update", path: "/" }); return; }
314
+ const file = resolve(root, String(filename));
315
+ 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("/") });
361
318
  });
362
-
363
319
  let socket;
364
320
  let credential;
365
321
  let sessionUrl;
@@ -375,7 +331,7 @@ async function start(args) {
375
331
  clearTimeout(retryTimer);
376
332
  clearTimeout(connectionTimer);
377
333
  try { socket?.close(); } catch {}
378
- await vite.close();
334
+ watcher.close();
379
335
  process.exit(code);
380
336
  };
381
337
  process.once("SIGINT", () => void stop(0));
@@ -383,10 +339,7 @@ async function start(args) {
383
339
 
384
340
  const retry = () => {
385
341
  if (stopped || Date.now() >= retryUntil) return void stop(1);
386
- retryTimer = setTimeout(() => {
387
- retryTimer = undefined;
388
- connect();
389
- }, retryDelay);
342
+ retryTimer = setTimeout(() => { retryTimer = undefined; connect(); }, retryDelay);
390
343
  retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY);
391
344
  };
392
345
 
@@ -420,13 +373,9 @@ async function start(args) {
420
373
  } else if (packet.type === "session") {
421
374
  if (!validSessionUrl(packet.url)) return void stop(1);
422
375
  sessionUrl = packet.url;
423
- retryDelay = 100;
424
- if (!ready) {
425
- ready = true;
426
- process.stdout.write(`${JSON.stringify({ type: "ready", url: sessionUrl })}\n`);
427
- }
376
+ if (!ready) { ready = true; process.stdout.write(`${JSON.stringify({ type: "ready", url: sessionUrl })}\n`); }
428
377
  } else if (packet.type === "http_request") {
429
- void handleRequest(vite, packet, response => send(response)).catch(() => send({ type: "http_response", request_id: packet.request_id, status: 500, headers: { "Content-Type": "text/plain; charset=utf-8" }, body: Buffer.from("preview request failed").toString("base64") }));
378
+ void handleRequest(root, packet).then(result => send(result)).catch(() => send(errorResponse(packet, 500, "preview request failed")));
430
379
  } else if (packet.type === "closed") {
431
380
  void stop(0);
432
381
  } else if (packet.type === "error") {
@@ -450,18 +399,10 @@ async function start(args) {
450
399
  }
451
400
 
452
401
  if (process.argv[2] === "--skill") {
453
- if (process.argv.length !== 3) {
454
- process.stderr.write("Usage: npx letmeknow-cli --skill\n");
455
- process.exit(1);
456
- }
402
+ if (process.argv.length !== 3) { process.stderr.write("Usage: npx letmeknow-cli --skill\n"); process.exit(1); }
457
403
  writeSync(1, readFileSync(new URL("../SKILL.md", import.meta.url)));
458
404
  } else if (process.argv.slice(2).includes("--help") || process.argv.slice(2).includes("-h")) {
459
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");
460
406
  } else {
461
- try {
462
- await start(process.argv.slice(2));
463
- } catch (cause) {
464
- process.stderr.write(`letmeknow: ${cause instanceof Error ? cause.message : "server failed"}\n`);
465
- process.exitCode = 1;
466
- }
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; }
467
408
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "letmeknow-cli",
3
- "version": "0.4.7",
4
- "description": "A live Vite preview with agent-readable form submissions.",
3
+ "version": "0.4.10",
4
+ "description": "A live static preview with agent-readable form submissions.",
5
5
  "files": [
6
6
  "bin",
7
7
  "SKILL.md"
@@ -27,8 +27,5 @@
27
27
  "vitest": "^4.1.11",
28
28
  "wrangler": "^4.126.0",
29
29
  "ws": "^8.21.3"
30
- },
31
- "dependencies": {
32
- "vite": "^7.3.6"
33
30
  }
34
31
  }