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 +6 -4
- package/SKILL.md +12 -10
- package/bin/letmeknow.js +210 -273
- 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
|
+
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
|
|
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,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
|
|
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 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
|
|
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
|
|
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 {
|
|
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+":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
|
|
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
|
-
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
|
-
|
|
66
|
-
socket
|
|
67
|
-
|
|
68
|
-
if(
|
|
69
|
-
|
|
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(
|
|
80
|
+
if(message.type==="closed"){terminal=true;try{sessionStorage.removeItem(credentialKey);sessionStorage.removeItem(snapshotKey)}catch{}status(message.message)}
|
|
73
81
|
};
|
|
74
|
-
|
|
75
|
-
|
|
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{
|
|
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(
|
|
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=
|
|
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
|
|
101
|
-
const
|
|
102
|
-
|
|
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
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
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
|
|
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
|
-
});
|
|
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
|
-
|
|
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();
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
144
|
+
function inside(root, target) {
|
|
145
|
+
const path = relative(root, target);
|
|
146
|
+
return path === "" || (path !== ".." && !path.startsWith(".." + sep));
|
|
147
|
+
}
|
|
220
148
|
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
239
|
-
|
|
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
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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(
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
4
|
-
"description": "A live
|
|
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
|
}
|