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 +6 -4
- package/SKILL.md +14 -10
- package/bin/letmeknow.js +221 -280
- package/package.json +2 -5
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# LetMeKnow
|
|
2
2
|
|
|
3
|
-
LetMeKnow gives an agent-managed folder a public, live
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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
|
|
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 {
|
|
4
|
-
import {
|
|
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
|
|
14
|
-
|
|
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
|
|
17
|
-
const
|
|
18
|
-
|
|
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
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
};
|
|
61
|
-
|
|
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
|
-
|
|
66
|
-
socket
|
|
67
|
-
|
|
68
|
-
if(
|
|
69
|
-
|
|
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(
|
|
91
|
+
if(message.type==="closed"){terminal=true;try{sessionStorage.removeItem(credentialKey)}catch{}status(message.message)}
|
|
73
92
|
};
|
|
74
|
-
|
|
75
|
-
|
|
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{
|
|
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(
|
|
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=
|
|
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
|
|
101
|
-
const
|
|
102
|
-
|
|
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
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
155
|
+
function inside(root, target) {
|
|
156
|
+
const path = relative(root, target);
|
|
157
|
+
return path === "" || (path !== ".." && !path.startsWith(".." + sep));
|
|
158
|
+
}
|
|
225
159
|
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
244
|
-
|
|
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
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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(
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
4
|
-
"description": "A live
|
|
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
|
}
|