letmeknow-cli 0.4.7 → 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,187 +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
- 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
- }
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
+ }
221
143
 
222
- hasHeader(name) {
223
- return this.headers.has(name.toLowerCase());
224
- }
144
+ function inside(root, target) {
145
+ const path = relative(root, target);
146
+ return path === "" || (path !== ".." && !path.startsWith(".." + sep));
147
+ }
225
148
 
226
- removeHeader(name) {
227
- 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;
228
163
  }
164
+ }
229
165
 
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
- }
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
+ }
235
175
 
236
- 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
+ }
237
182
 
238
- _write(chunk, _encoding, callback) {
239
- this.chunks.push(Buffer.from(chunk));
240
- 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");
241
201
  }
242
-
243
- body() {
244
- 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");
245
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" });
246
225
  }
247
226
 
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")
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
257
248
  };
249
+ process.stdout.write(`${JSON.stringify(event)}\n`);
250
+ return response(packet, 204);
258
251
  }
259
252
 
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));
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);
281
258
  }
282
259
 
283
260
  function options(args) {
@@ -289,7 +266,7 @@ function options(args) {
289
266
  }
290
267
  root = root || process.cwd();
291
268
  if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error(`directory does not exist: ${root}`);
292
- return { root };
269
+ return realpath(root).then(root => ({ root }));
293
270
  }
294
271
 
295
272
  function endpoint(control, credential, sessionUrl) {
@@ -311,55 +288,23 @@ function endpoint(control, credential, sessionUrl) {
311
288
  function validSessionUrl(value) {
312
289
  if (typeof value !== "string") return false;
313
290
  let url;
314
- try {
315
- url = new URL(value);
316
- } catch {
317
- return false;
318
- }
291
+ try { url = new URL(value); } catch { return false; }
319
292
  if (url.protocol !== "http:" && url.protocol !== "https:") return false;
320
293
  if (/^[a-f0-9]{20}\.letmeknow\.dev$/.test(url.hostname)) return true;
321
294
  return /^\/s\/[a-f0-9]{20}(?:\/|$)/.test(url.pathname);
322
295
  }
323
296
 
324
297
  async function start(args) {
325
- const { root } = options(args);
298
+ const { root } = await options(args);
326
299
  const control = process.env.LETMEKNOW_URL || "https://letmeknow.dev";
327
300
  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
- }]
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("/") });
361
307
  });
362
-
363
308
  let socket;
364
309
  let credential;
365
310
  let sessionUrl;
@@ -375,7 +320,7 @@ async function start(args) {
375
320
  clearTimeout(retryTimer);
376
321
  clearTimeout(connectionTimer);
377
322
  try { socket?.close(); } catch {}
378
- await vite.close();
323
+ watcher.close();
379
324
  process.exit(code);
380
325
  };
381
326
  process.once("SIGINT", () => void stop(0));
@@ -383,10 +328,7 @@ async function start(args) {
383
328
 
384
329
  const retry = () => {
385
330
  if (stopped || Date.now() >= retryUntil) return void stop(1);
386
- retryTimer = setTimeout(() => {
387
- retryTimer = undefined;
388
- connect();
389
- }, retryDelay);
331
+ retryTimer = setTimeout(() => { retryTimer = undefined; connect(); }, retryDelay);
390
332
  retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY);
391
333
  };
392
334
 
@@ -420,13 +362,9 @@ async function start(args) {
420
362
  } else if (packet.type === "session") {
421
363
  if (!validSessionUrl(packet.url)) return void stop(1);
422
364
  sessionUrl = packet.url;
423
- retryDelay = 100;
424
- if (!ready) {
425
- ready = true;
426
- process.stdout.write(`${JSON.stringify({ type: "ready", url: sessionUrl })}\n`);
427
- }
365
+ if (!ready) { ready = true; process.stdout.write(`${JSON.stringify({ type: "ready", url: sessionUrl })}\n`); }
428
366
  } 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") }));
367
+ void handleRequest(root, packet).then(result => send(result)).catch(() => send(errorResponse(packet, 500, "preview request failed")));
430
368
  } else if (packet.type === "closed") {
431
369
  void stop(0);
432
370
  } else if (packet.type === "error") {
@@ -450,18 +388,10 @@ async function start(args) {
450
388
  }
451
389
 
452
390
  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
- }
391
+ if (process.argv.length !== 3) { process.stderr.write("Usage: npx letmeknow-cli --skill\n"); process.exit(1); }
457
392
  writeSync(1, readFileSync(new URL("../SKILL.md", import.meta.url)));
458
393
  } else if (process.argv.slice(2).includes("--help") || process.argv.slice(2).includes("-h")) {
459
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");
460
395
  } 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
- }
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; }
467
397
  }
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.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
  }