letmeknow-cli 0.4.6 → 0.4.9

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
+ When a watched file changes, the browser receives an update. Changes to the current HTML route or to another asset reload the page and preserve form values, checked controls, selections, focus, text selection, scroll position, and open `<details>` elements. CSS changes cache-bust matching stylesheets without navigating. 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,22 @@ 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 is live:
37
+
38
+ - A change to the current HTML route reloads the page and restores form values, checked controls, selections, focus, text selection, scroll position, and open `<details>` elements.
39
+ - CSS changes cache-bust matching linked stylesheets without navigating.
40
+ - Changes to a different HTML route do not disturb the current page.
41
+ - Changes to other assets reload the page. Arbitrary JavaScript heap state cannot be preserved.
40
42
 
41
43
  An optional status element gives the human feedback after a form submission:
42
44
 
@@ -79,7 +81,7 @@ The event ID identifies the submission. There is no response packet. Validate it
79
81
  2. Wait for a `submit` event on stdout.
80
82
  3. Validate its `values` and `action`.
81
83
  4. Rewrite the relevant HTML or data file in the workspace.
82
- 5. The browser updates in place through the relay.
84
+ 5. The agent updates the live preview.
83
85
 
84
86
  Escape untrusted values before placing them in HTML. Treat browser input as untrusted even though the folder is local to the agent.
85
87
 
@@ -87,7 +89,7 @@ Escape untrusted values before placing them in HTML. Treat browser input as untr
87
89
 
88
90
  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
91
 
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.
92
+ 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
93
 
92
94
  ## Stop
93
95
 
package/bin/letmeknow.js CHANGED
@@ -1,78 +1,86 @@
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+":snapshot";
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 restore=()=>{let raw;try{raw=sessionStorage.getItem(snapshotKey)}catch{return}if(!raw)return;try{sessionStorage.removeItem(snapshotKey)}catch{}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)};
61
+ const reload=()=>{if(reloadTimer)return;reloadTimer=setTimeout(()=>{try{sessionStorage.setItem(snapshotKey,JSON.stringify(snapshot()))}catch{}location.reload()},75)};
62
+ 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};
63
+ const refreshStylesheet=link=>{const url=new URL(link.href,location.href);url.searchParams.set("_letmeknow",crypto.randomUUID());link.href=url.href};
64
+ 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)};
65
+ const pendingUpdates=new Set();
66
+ const update=path=>{if(typeof path!=="string")return;path=pagePath(path);pendingUpdates.add(path);if(!updateTimer)updateTimer=setTimeout(flushUpdates,75)};
67
+ addEventListener("load",()=>requestAnimationFrame(restore),{once:true});
62
68
  const connect=()=>{
63
69
  clearTimeout(retryTimer);retryTimer=undefined;
64
- const url=new URL("_letmeknow/client",location.href);url.protocol=url.protocol==="https:"?"wss:":"ws:";
65
- socket=credential?new WebSocket(url,credential):new WebSocket(url);
66
- socket.onmessage=event=>{
67
- const message=JSON.parse(event.data);
68
- if(message.type==="credential"){credential=message.credential;sessionStorage.setItem(key,credential);return}
69
- if(message.type==="challenge"){socket.send(JSON.stringify({type:"alive",nonce:message.nonce}));return}
70
+ const url=new URL(sessionBase+"_letmeknow/client",location.href);url.protocol=url.protocol==="https:"?"wss:":"ws:";
71
+ const current=credential?new WebSocket(url,credential):new WebSocket(url);
72
+ socket=current;
73
+ current.onmessage=event=>{
74
+ if(socket!==current||typeof event.data!=="string")return;
75
+ let message;try{message=JSON.parse(event.data)}catch{return}
76
+ if(message.type==="credential"){credential=message.credential;try{sessionStorage.setItem(credentialKey,credential)}catch{}return}
77
+ if(message.type==="challenge"){if(current.readyState===WebSocket.OPEN)try{current.send(JSON.stringify({type:"alive",nonce:message.nonce}))}catch{}return}
70
78
  if(message.type==="busy"){status("This session is open elsewhere");retryTimer=setTimeout(connect,message.retry_after*1000);return}
71
79
  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)}
80
+ if(message.type==="closed"){terminal=true;try{sessionStorage.removeItem(credentialKey);sessionStorage.removeItem(snapshotKey)}catch{}status(message.message)}
73
81
  };
74
- socket.onclose=()=>{if(!terminal&&!retryTimer){status("Reconnecting…");retryTimer=setTimeout(connect,1000)}};
75
- socket.onerror=()=>{};
82
+ current.onclose=()=>{if(socket!==current||terminal)return;if(!retryTimer){status("Reconnecting…");retryTimer=setTimeout(connect,1000)}};
83
+ current.onerror=()=>{};
76
84
  };
77
85
  document.addEventListener("submit",async event=>{
78
86
  const form=event.target;
@@ -83,12 +91,16 @@ document.addEventListener("submit",async event=>{
83
91
  if(method!=="get"&&method!=="post"){status("Only GET and POST forms are supported");return}
84
92
  if(!form.checkValidity()){form.reportValidity();return}
85
93
  let target;
86
- try{target=new URL(submitter?.getAttribute("formaction")??form.getAttribute("action")??location.href,location.href)}catch{status("Invalid form action");return}
94
+ 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
95
  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(/^\//,""));
96
+ if(sessionBase!=="/"){
97
+ const sessionPath=sessionBase.slice(0,-1);
98
+ if(target.pathname===sessionPath)target.pathname=sessionBase;
99
+ else if(!target.pathname.startsWith(sessionBase))target.pathname=sessionBase+target.pathname.replace(/^\//,"");
100
+ }
89
101
  const values=new URLSearchParams();
90
102
  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;
103
+ const actionPath=sessionBase!=="/"?target.pathname.slice(sessionBase.length-1)||"/":target.pathname;
92
104
  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
105
  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
106
  if(method==="get")for(const [name,value] of values)target.searchParams.append(name,value);
@@ -97,180 +109,152 @@ document.addEventListener("submit",async event=>{
97
109
  connect();
98
110
  `;
99
111
 
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
- }
112
+ function header(packet, name) {
113
+ const entry = Object.entries(packet.headers || {}).find(([key]) => key.toLowerCase() === name);
114
+ return typeof entry?.[1] === "string" && entry[1] !== "" ? entry[1] : null;
108
115
  }
109
116
 
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
- }
117
+ function encodedHeader(packet, name) {
118
+ const value = header(packet, name);
119
+ if (value === null) return null;
120
+ try { return decodeURIComponent(value); } catch { return null; }
116
121
  }
117
122
 
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
- });
123
+ function addValue(values, name, value) {
124
+ if (Object.prototype.hasOwnProperty.call(values, name)) values[name] = Array.isArray(values[name]) ? [...values[name], value] : [values[name], value];
125
+ else values[name] = value;
138
126
  }
139
127
 
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();
128
+ function response(packet, status, body = Buffer.alloc(0), headers = {}) {
129
+ 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" });
130
+ const outputHeaders = { "Cache-Control": "no-store", ...headers };
131
+ if (!Object.keys(outputHeaders).some(name => name.toLowerCase() === "content-length")) outputHeaders["Content-Length"] = String(body.byteLength);
132
+ const method = typeof packet.method === "string" ? packet.method.toUpperCase() : "";
133
+ return { type: "http_response", request_id: packet.request_id, status, headers: outputHeaders, body: method === "HEAD" ? "" : body.toString("base64") };
171
134
  }
172
135
 
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
- }
136
+ function errorResponse(packet, status, message) {
137
+ return response(packet, status, Buffer.from(message), { "Content-Type": "text/plain; charset=utf-8" });
194
138
  }
195
139
 
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
- getHeader(name) {
210
- return this.headers.get(name.toLowerCase());
211
- }
212
-
213
- getHeaders() {
214
- return Object.fromEntries(this.headers);
215
- }
140
+ function deniedPath(pathname) {
141
+ return pathname.split("/").some(part => part === ".env" || part.startsWith(".env.") || part === ".git" || /\.(?:key|pem|p12|sqlite|db)$/i.test(part));
142
+ }
216
143
 
217
- hasHeader(name) {
218
- return this.headers.has(name.toLowerCase());
219
- }
144
+ function inside(root, target) {
145
+ const path = relative(root, target);
146
+ return path === "" || (path !== ".." && !path.startsWith(".." + sep));
147
+ }
220
148
 
221
- removeHeader(name) {
222
- this.headers.delete(name.toLowerCase());
149
+ async function safeRealpath(root, candidate) {
150
+ try {
151
+ const target = await realpath(candidate);
152
+ return inside(root, target) ? target : null;
153
+ } catch (cause) {
154
+ if (cause?.code === "EACCES" || cause?.code === "EPERM") return null;
155
+ if (cause?.code === "ENOENT" || cause?.code === "ENOTDIR") {
156
+ try {
157
+ const parent = await realpath(dirname(candidate));
158
+ if (!inside(root, parent)) return null;
159
+ } catch {}
160
+ return undefined;
161
+ }
162
+ throw cause;
223
163
  }
164
+ }
224
165
 
225
- writeHead(status, headers) {
226
- this.statusCode = status;
227
- if (headers) for (const [name, value] of Object.entries(headers)) this.setHeader(name, value);
228
- return this;
229
- }
166
+ function requestUrl(packet) {
167
+ if (typeof packet.path !== "string" || !packet.path.startsWith("/")) throw new Error("invalid request path");
168
+ const url = new URL(packet.path, "http://letmeknow.local");
169
+ if (url.origin !== "http://letmeknow.local") throw new Error("invalid request path");
170
+ let pathname;
171
+ try { pathname = decodeURIComponent(url.pathname); } catch { throw new Error("invalid request path"); }
172
+ if (pathname.includes("\0") || pathname.includes("\\")) throw new Error("invalid request path");
173
+ return { pathname, encodedPathname: url.pathname, search: url.search };
174
+ }
230
175
 
231
- flushHeaders() {}
176
+ function htmlWithClient(body) {
177
+ const text = body.toString("utf8");
178
+ const script = `<script type="module" data-letmeknow-client>${client}</script>`;
179
+ const closingBody = text.search(/<\/body\s*>/i);
180
+ return Buffer.from(closingBody < 0 ? text + script : text.slice(0, closingBody) + script + text.slice(closingBody), "utf8");
181
+ }
232
182
 
233
- _write(chunk, _encoding, callback) {
234
- this.chunks.push(Buffer.from(chunk));
235
- callback();
183
+ async function staticResponse(root, packet) {
184
+ const method = typeof packet.method === "string" ? packet.method.toUpperCase() : "";
185
+ if (method !== "GET" && method !== "HEAD") return errorResponse(packet, 405, "method not allowed");
186
+ let request;
187
+ try { request = requestUrl(packet); } catch { return errorResponse(packet, 400, "bad request"); }
188
+ if (deniedPath(request.pathname)) return errorResponse(packet, 403, "forbidden");
189
+ const candidate = resolve(root, "." + request.pathname);
190
+ if (!inside(root, candidate)) return errorResponse(packet, 403, "forbidden");
191
+ let target;
192
+ try { target = await safeRealpath(root, candidate); } catch { return errorResponse(packet, 500, "preview request failed"); }
193
+ if (target === null) return errorResponse(packet, 403, "forbidden");
194
+ if (target === undefined) return errorResponse(packet, 404, "not found");
195
+ if (deniedPath("/" + relative(root, target).split(sep).join("/"))) return errorResponse(packet, 403, "forbidden");
196
+ let info;
197
+ try { info = await stat(target); } catch (cause) {
198
+ if (cause?.code === "ENOENT" || cause?.code === "ENOTDIR") return errorResponse(packet, 404, "not found");
199
+ if (cause?.code === "EACCES" || cause?.code === "EPERM") return errorResponse(packet, 403, "forbidden");
200
+ return errorResponse(packet, 500, "preview request failed");
236
201
  }
237
-
238
- body() {
239
- return Buffer.concat(this.chunks);
202
+ if (info.isDirectory()) {
203
+ if (!request.pathname.endsWith("/")) {
204
+ const location = request.encodedPathname + "/" + request.search;
205
+ return response(packet, 301, Buffer.from(`Redirecting to ${location}`), { Location: location, "Content-Type": "text/plain; charset=utf-8" });
206
+ }
207
+ const index = resolve(target, "index.html");
208
+ try { target = await realpath(index); } catch (cause) {
209
+ if (cause?.code === "ENOENT" || cause?.code === "ENOTDIR") return errorResponse(packet, 404, "not found");
210
+ return errorResponse(packet, 500, "preview request failed");
211
+ }
212
+ if (!inside(root, target)) return errorResponse(packet, 403, "forbidden");
213
+ if (deniedPath("/" + relative(root, target).split(sep).join("/"))) return errorResponse(packet, 403, "forbidden");
214
+ } else if (request.pathname.endsWith("/")) return errorResponse(packet, 404, "not found");
215
+ let body;
216
+ try { body = await readFile(target); } catch (cause) {
217
+ if (cause?.code === "ENOENT" || cause?.code === "ENOTDIR") return errorResponse(packet, 404, "not found");
218
+ if (cause?.code === "EACCES" || cause?.code === "EPERM") return errorResponse(packet, 403, "forbidden");
219
+ return errorResponse(packet, 500, "preview request failed");
240
220
  }
221
+ if (body.byteLength > MAX_BODY_BYTES) return errorResponse(packet, 413, "response body is too large");
222
+ if (extname(target).toLowerCase() === ".html") body = htmlWithClient(body);
223
+ if (body.byteLength > MAX_BODY_BYTES) return errorResponse(packet, 413, "response body is too large");
224
+ return response(packet, 200, body, { "Content-Type": mimeTypes[extname(target).toLowerCase()] || "application/octet-stream" });
241
225
  }
242
226
 
243
- function middlewareResponse(response) {
244
- const body = response.body();
245
- if (body.byteLength > MAX_BODY_BYTES) throw new Error("response body is too large");
246
- return {
247
- type: "http_response",
248
- request_id: response.requestId,
249
- status: response.statusCode,
250
- headers: response.getHeaders(),
251
- body: body.toString("base64")
227
+ async function submission(packet) {
228
+ const url = requestUrl(packet);
229
+ const method = typeof packet.method === "string" ? packet.method.toUpperCase() : "";
230
+ const values = Object.create(null);
231
+ if (method === "GET") {
232
+ for (const [name, value] of new URLSearchParams(url.search)) addValue(values, name, value);
233
+ } else if (method === "POST") {
234
+ const body = Buffer.from(typeof packet.body === "string" ? packet.body : "", "base64");
235
+ if (body.byteLength > MAX_BODY_BYTES) throw new Error("submission is too large");
236
+ const contentType = header(packet, "content-type")?.split(";", 1)[0].trim().toLowerCase();
237
+ if (contentType !== "application/x-www-form-urlencoded") throw new Error("unsupported submission encoding");
238
+ for (const [name, value] of new URLSearchParams(body.toString("utf8"))) addValue(values, name, value);
239
+ } else throw new Error("unsupported submission method");
240
+ const event = {
241
+ type: "submit",
242
+ id: encodedHeader(packet, "x-letmeknow-id"),
243
+ method,
244
+ action: encodedHeader(packet, "x-letmeknow-action") || url.pathname,
245
+ form_id: encodedHeader(packet, "x-letmeknow-form-id"),
246
+ trigger: { id: encodedHeader(packet, "x-letmeknow-trigger-id"), name: encodedHeader(packet, "x-letmeknow-trigger-name"), value: encodedHeader(packet, "x-letmeknow-trigger-value") },
247
+ values
252
248
  };
249
+ process.stdout.write(`${JSON.stringify(event)}\n`);
250
+ return response(packet, 204);
253
251
  }
254
252
 
255
- async function handleRequest(server, packet, send) {
256
- const request = new RelayRequest(packet);
257
- const response = new RelayResponse();
258
- response.requestId = packet.request_id;
259
- await new Promise((resolveRequest, rejectRequest) => {
260
- response.once("finish", resolveRequest);
261
- response.once("error", rejectRequest);
262
- try {
263
- server.middlewares(request, response, () => {
264
- if (!response.writableEnded) {
265
- response.statusCode = 404;
266
- response.end("Not found");
267
- }
268
- });
269
- } catch (cause) {
270
- rejectRequest(cause);
271
- }
272
- });
273
- send(middlewareResponse(response));
253
+ async function handleRequest(root, packet) {
254
+ if (header(packet, "x-letmeknow-submission") === "1") {
255
+ 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"); }
256
+ }
257
+ return staticResponse(root, packet);
274
258
  }
275
259
 
276
260
  function options(args) {
@@ -282,7 +266,7 @@ function options(args) {
282
266
  }
283
267
  root = root || process.cwd();
284
268
  if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error(`directory does not exist: ${root}`);
285
- return { root };
269
+ return realpath(root).then(root => ({ root }));
286
270
  }
287
271
 
288
272
  function endpoint(control, credential, sessionUrl) {
@@ -304,55 +288,23 @@ function endpoint(control, credential, sessionUrl) {
304
288
  function validSessionUrl(value) {
305
289
  if (typeof value !== "string") return false;
306
290
  let url;
307
- try {
308
- url = new URL(value);
309
- } catch {
310
- return false;
311
- }
291
+ try { url = new URL(value); } catch { return false; }
312
292
  if (url.protocol !== "http:" && url.protocol !== "https:") return false;
313
293
  if (/^[a-f0-9]{20}\.letmeknow\.dev$/.test(url.hostname)) return true;
314
294
  return /^\/s\/[a-f0-9]{20}(?:\/|$)/.test(url.pathname);
315
295
  }
316
296
 
317
297
  async function start(args) {
318
- const { root } = options(args);
298
+ const { root } = await options(args);
319
299
  const control = process.env.LETMEKNOW_URL || "https://letmeknow.dev";
320
300
  let send = () => false;
321
- const vite = await createServer({
322
- root,
323
- configFile: false,
324
- appType: "spa",
325
- css: { postcss: false },
326
- logLevel: "silent",
327
- server: { middlewareMode: true, hmr: false, ws: false, fs: { strict: true, allow: [root], deny: ["**/.env", "**/.env.*", "**/.git/**", "**/*.key", "**/*.pem", "**/*.p12", "**/*.sqlite", "**/*.db"] } },
328
- plugins: [{
329
- name: "letmeknow-relay",
330
- resolveId(id) { return id === clientPath ? clientId : undefined; },
331
- load(id) { return id === clientId ? client : undefined; },
332
- configureServer(server) {
333
- server.middlewares.use((request, response, next) => {
334
- if (request.headers["x-letmeknow-submission"] !== "1") {
335
- next();
336
- return;
337
- }
338
- submission(request, response).catch(cause => {
339
- response.statusCode = cause instanceof Error && cause.message === "submission is too large" ? 413 : 400;
340
- response.setHeader("Content-Type", "text/plain; charset=utf-8");
341
- response.end(cause instanceof Error ? cause.message : "invalid submission");
342
- });
343
- });
344
- const update = file => send({ type: "file_update", path: "/" + relative(root, file).split(sep).join("/") });
345
- server.watcher.on("change", update);
346
- server.watcher.on("add", update);
347
- server.watcher.on("unlink", update);
348
- },
349
- transformIndexHtml(html) {
350
- const script = `<script type="module" src="${clientPath}" data-letmeknow-client></script>`;
351
- return html.includes("</body>") ? html.replace("</body>", `${script}</body>`) : `${html}${script}`;
352
- }
353
- }]
301
+ const watcher = watch(root, { recursive: true, encoding: "utf8" }, (_event, filename) => {
302
+ if (!filename) { send({ type: "file_update", path: "/" }); return; }
303
+ const file = resolve(root, String(filename));
304
+ const path = relative(root, file).split(sep).join("/");
305
+ if (!path || path.startsWith("../") || path === ".." || deniedPath("/" + path)) return;
306
+ send({ type: "file_update", path: "/" + path.split("/").map(encodeURIComponent).join("/") });
354
307
  });
355
-
356
308
  let socket;
357
309
  let credential;
358
310
  let sessionUrl;
@@ -368,7 +320,7 @@ async function start(args) {
368
320
  clearTimeout(retryTimer);
369
321
  clearTimeout(connectionTimer);
370
322
  try { socket?.close(); } catch {}
371
- await vite.close();
323
+ watcher.close();
372
324
  process.exit(code);
373
325
  };
374
326
  process.once("SIGINT", () => void stop(0));
@@ -376,10 +328,7 @@ async function start(args) {
376
328
 
377
329
  const retry = () => {
378
330
  if (stopped || Date.now() >= retryUntil) return void stop(1);
379
- retryTimer = setTimeout(() => {
380
- retryTimer = undefined;
381
- connect();
382
- }, retryDelay);
331
+ retryTimer = setTimeout(() => { retryTimer = undefined; connect(); }, retryDelay);
383
332
  retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY);
384
333
  };
385
334
 
@@ -413,13 +362,9 @@ async function start(args) {
413
362
  } else if (packet.type === "session") {
414
363
  if (!validSessionUrl(packet.url)) return void stop(1);
415
364
  sessionUrl = packet.url;
416
- retryDelay = 100;
417
- if (!ready) {
418
- ready = true;
419
- process.stdout.write(`${JSON.stringify({ type: "ready", url: sessionUrl })}\n`);
420
- }
365
+ if (!ready) { ready = true; process.stdout.write(`${JSON.stringify({ type: "ready", url: sessionUrl })}\n`); }
421
366
  } else if (packet.type === "http_request") {
422
- void handleRequest(vite, packet, response => send(response)).catch(() => send({ type: "http_response", request_id: packet.request_id, status: 500, headers: { "Content-Type": "text/plain; charset=utf-8" }, body: Buffer.from("preview request failed").toString("base64") }));
367
+ void handleRequest(root, packet).then(result => send(result)).catch(() => send(errorResponse(packet, 500, "preview request failed")));
423
368
  } else if (packet.type === "closed") {
424
369
  void stop(0);
425
370
  } else if (packet.type === "error") {
@@ -443,18 +388,10 @@ async function start(args) {
443
388
  }
444
389
 
445
390
  if (process.argv[2] === "--skill") {
446
- if (process.argv.length !== 3) {
447
- process.stderr.write("Usage: npx letmeknow-cli --skill\n");
448
- process.exit(1);
449
- }
391
+ if (process.argv.length !== 3) { process.stderr.write("Usage: npx letmeknow-cli --skill\n"); process.exit(1); }
450
392
  writeSync(1, readFileSync(new URL("../SKILL.md", import.meta.url)));
451
393
  } else if (process.argv.slice(2).includes("--help") || process.argv.slice(2).includes("-h")) {
452
394
  process.stdout.write("Usage: npx letmeknow-cli [directory]\n\nServe a folder through the hosted LetMeKnow relay. The CLI does not listen on a network port. Form submissions are JSON lines on stdout.\n");
453
395
  } else {
454
- try {
455
- await start(process.argv.slice(2));
456
- } catch (cause) {
457
- process.stderr.write(`letmeknow: ${cause instanceof Error ? cause.message : "server failed"}\n`);
458
- process.exitCode = 1;
459
- }
396
+ try { await start(process.argv.slice(2)); } catch (cause) { process.stderr.write(`letmeknow: ${cause instanceof Error ? cause.message : "server failed"}\n`); process.exitCode = 1; }
460
397
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "letmeknow-cli",
3
- "version": "0.4.6",
4
- "description": "A live Vite preview with agent-readable form submissions.",
3
+ "version": "0.4.9",
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
  }