letmeknow-cli 0.4.9 → 0.4.11
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 +1 -1
- package/SKILL.md +3 -1
- package/bin/letmeknow.js +63 -48
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ The CLI does not receive file commands. The agent reads and writes the directory
|
|
|
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
32
|
|
|
33
|
-
|
|
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.
|
|
34
34
|
|
|
35
35
|
An optional element can display submission status:
|
|
36
36
|
|
package/SKILL.md
CHANGED
|
@@ -33,9 +33,11 @@ Create an ordinary static page in the folder, usually `index.html`, plus any CSS
|
|
|
33
33
|
|
|
34
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
|
+
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
|
+
|
|
36
38
|
The preview is live:
|
|
37
39
|
|
|
38
|
-
- A change to the current HTML route reloads the page
|
|
40
|
+
- A change to the current HTML route reloads the page with its saved state.
|
|
39
41
|
- CSS changes cache-bust matching linked stylesheets without navigating.
|
|
40
42
|
- Changes to a different HTML route do not disturb the current page.
|
|
41
43
|
- Changes to other assets reload the page. Arbitrary JavaScript heap state cannot be preserved.
|
package/bin/letmeknow.js
CHANGED
|
@@ -1,43 +1,33 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { existsSync, readFileSync, statSync,
|
|
3
|
+
import { existsSync, readFileSync, statSync, writeSync } from "node:fs";
|
|
4
4
|
import { readFile, realpath, stat } from "node:fs/promises";
|
|
5
5
|
import { dirname, extname, relative, resolve, sep } from "node:path";
|
|
6
|
+
import { parseArgs } from "node:util";
|
|
7
|
+
import chokidar from "chokidar";
|
|
8
|
+
import ignore from "ignore";
|
|
9
|
+
import { lookup } from "mrmime";
|
|
6
10
|
|
|
7
11
|
const MAX_BODY_BYTES = 1024 * 1024;
|
|
8
12
|
const GRACE_SECONDS = 10 * 60;
|
|
9
13
|
const CONNECTION_TIMEOUT = 10_000;
|
|
10
14
|
const MAX_RETRY_DELAY = 5_000;
|
|
11
15
|
const credentialPattern = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
};
|
|
16
|
+
const ig = ignore().add([".env", ".env.*", ".git", "*.key", "*.pem", "*.p12", "*.sqlite", "*.db"]);
|
|
17
|
+
const privateFilePattern = /\.(?:key|pem|p12|sqlite|db)$/i;
|
|
18
|
+
|
|
19
|
+
function getMimeType(filename) {
|
|
20
|
+
const type = lookup(filename);
|
|
21
|
+
if (!type) return "application/octet-stream";
|
|
22
|
+
return type.startsWith("text/") || type === "application/json" || type === "application/xml" || type === "application/manifest+json"
|
|
23
|
+
? `${type}; charset=utf-8`
|
|
24
|
+
: type;
|
|
25
|
+
}
|
|
36
26
|
const client = String.raw`
|
|
37
27
|
const sessionMatch=location.pathname.match(/^\/s\/[a-f0-9]{20}(?:\/|$)/);
|
|
38
28
|
const sessionBase=sessionMatch?(sessionMatch[0].endsWith("/")?sessionMatch[0]:sessionMatch[0]+"/"):"/";
|
|
39
29
|
const credentialKey="letmeknow-credential:"+location.origin+sessionBase;
|
|
40
|
-
const snapshotKey=credentialKey+":
|
|
30
|
+
const snapshotKey=()=>credentialKey+":state:"+pageIdentity();
|
|
41
31
|
let credential;
|
|
42
32
|
try{credential=sessionStorage.getItem(credentialKey)}catch{}
|
|
43
33
|
let socket;
|
|
@@ -52,19 +42,30 @@ const formIdentity=form=>form?.id||form?.getAttribute("name")||form?.getAttribut
|
|
|
52
42
|
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
43
|
const detailKey=(element,index,all=details())=>{const id=uniqueId(element,all);return id?"id:"+id:"detail:"+index};
|
|
54
44
|
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
|
|
45
|
+
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,options)=>option.selected?[option.value,options.slice(0,optionIndex).filter(candidate=>candidate.value===option.value).length]:null).filter(Boolean):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}};
|
|
56
46
|
const status=message=>{const element=document.querySelector("[data-letmeknow-status]");if(element)element.textContent=message};
|
|
57
47
|
const stripSessionPath=path=>{if(sessionBase==="/")return path;if(path===sessionBase.slice(0,-1))return "/";return path.startsWith(sessionBase)?"/"+path.slice(sessionBase.length):path};
|
|
58
48
|
const routePath=()=>{let path=stripSessionPath(location.pathname);return path.endsWith("/")?path+"index.html":path};
|
|
59
49
|
const pagePath=path=>{path=path.split("?",1)[0];return stripSessionPath(path)||"/"};
|
|
60
|
-
const
|
|
61
|
-
const
|
|
50
|
+
const save=()=>{try{sessionStorage.setItem(snapshotKey(),JSON.stringify(snapshot()))}catch{}};
|
|
51
|
+
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)){const selectedIndexes=new Set(state.selected.filter(Number.isInteger));const selectedValues=new Set(state.selected.filter(Array.isArray).map(entry=>entry.join("\u0000")));for(const [optionIndex,option] of [...control.options].entries()){const occurrence=[...control.options].slice(0,optionIndex).filter(candidate=>candidate.value===option.value).length;option.selected=selectedIndexes.has(optionIndex)||selectedValues.has([option.value,occurrence].join("\u0000"))}}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)};
|
|
52
|
+
const reload=()=>{if(reloadTimer)return;reloadTimer=setTimeout(()=>{save();location.reload()},75)};
|
|
62
53
|
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
54
|
const refreshStylesheet=link=>{const url=new URL(link.href,location.href);url.searchParams.set("_letmeknow",crypto.randomUUID());link.href=url.href};
|
|
64
55
|
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
56
|
const pendingUpdates=new Set();
|
|
66
57
|
const update=path=>{if(typeof path!=="string")return;path=pagePath(path);pendingUpdates.add(path);if(!updateTimer)updateTimer=setTimeout(flushUpdates,75)};
|
|
58
|
+
let saveTimer;
|
|
59
|
+
const scheduleSave=()=>{if(!saveTimer)saveTimer=setTimeout(()=>{saveTimer=undefined;save()},100)};
|
|
60
|
+
addEventListener("input",scheduleSave,true);
|
|
61
|
+
addEventListener("change",scheduleSave,true);
|
|
62
|
+
addEventListener("toggle",scheduleSave,true);
|
|
63
|
+
addEventListener("focusin",scheduleSave,true);
|
|
64
|
+
addEventListener("selectionchange",scheduleSave,true);
|
|
65
|
+
addEventListener("scroll",scheduleSave,{passive:true});
|
|
66
|
+
addEventListener("pagehide",save);
|
|
67
67
|
addEventListener("load",()=>requestAnimationFrame(restore),{once:true});
|
|
68
|
+
document.addEventListener("reset",()=>setTimeout(save));
|
|
68
69
|
const connect=()=>{
|
|
69
70
|
clearTimeout(retryTimer);retryTimer=undefined;
|
|
70
71
|
const url=new URL(sessionBase+"_letmeknow/client",location.href);url.protocol=url.protocol==="https:"?"wss:":"ws:";
|
|
@@ -77,7 +78,7 @@ const connect=()=>{
|
|
|
77
78
|
if(message.type==="challenge"){if(current.readyState===WebSocket.OPEN)try{current.send(JSON.stringify({type:"alive",nonce:message.nonce}))}catch{}return}
|
|
78
79
|
if(message.type==="busy"){status("This session is open elsewhere");retryTimer=setTimeout(connect,message.retry_after*1000);return}
|
|
79
80
|
if(message.type==="file_update"){update(message.path);return}
|
|
80
|
-
if(message.type==="closed"){terminal=true;try{sessionStorage.removeItem(credentialKey)
|
|
81
|
+
if(message.type==="closed"){terminal=true;try{sessionStorage.removeItem(credentialKey)}catch{}status(message.message)}
|
|
81
82
|
};
|
|
82
83
|
current.onclose=()=>{if(socket!==current||terminal)return;if(!retryTimer){status("Reconnecting…");retryTimer=setTimeout(connect,1000)}};
|
|
83
84
|
current.onerror=()=>{};
|
|
@@ -138,7 +139,8 @@ function errorResponse(packet, status, message) {
|
|
|
138
139
|
}
|
|
139
140
|
|
|
140
141
|
function deniedPath(pathname) {
|
|
141
|
-
|
|
142
|
+
const normalized = pathname.replace(/^\/+/, "");
|
|
143
|
+
return normalized !== "" && (ig.ignores(normalized) || pathname.split("/").filter(Boolean).some(part => ig.ignores(part) || privateFilePattern.test(part)));
|
|
142
144
|
}
|
|
143
145
|
|
|
144
146
|
function inside(root, target) {
|
|
@@ -221,7 +223,7 @@ async function staticResponse(root, packet) {
|
|
|
221
223
|
if (body.byteLength > MAX_BODY_BYTES) return errorResponse(packet, 413, "response body is too large");
|
|
222
224
|
if (extname(target).toLowerCase() === ".html") body = htmlWithClient(body);
|
|
223
225
|
if (body.byteLength > MAX_BODY_BYTES) return errorResponse(packet, 413, "response body is too large");
|
|
224
|
-
return response(packet, 200, body, { "Content-Type":
|
|
226
|
+
return response(packet, 200, body, { "Content-Type": getMimeType(target) });
|
|
225
227
|
}
|
|
226
228
|
|
|
227
229
|
async function submission(packet) {
|
|
@@ -257,14 +259,8 @@ async function handleRequest(root, packet) {
|
|
|
257
259
|
return staticResponse(root, packet);
|
|
258
260
|
}
|
|
259
261
|
|
|
260
|
-
function options(
|
|
261
|
-
|
|
262
|
-
for (const argument of args) {
|
|
263
|
-
if (argument.startsWith("-")) throw new Error(`unknown option: ${argument}`);
|
|
264
|
-
if (root !== undefined) throw new Error("only one directory may be provided");
|
|
265
|
-
root = resolve(argument);
|
|
266
|
-
}
|
|
267
|
-
root = root || process.cwd();
|
|
262
|
+
function options(directory) {
|
|
263
|
+
const root = resolve(directory || process.cwd());
|
|
268
264
|
if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error(`directory does not exist: ${root}`);
|
|
269
265
|
return realpath(root).then(root => ({ root }));
|
|
270
266
|
}
|
|
@@ -294,11 +290,12 @@ function validSessionUrl(value) {
|
|
|
294
290
|
return /^\/s\/[a-f0-9]{20}(?:\/|$)/.test(url.pathname);
|
|
295
291
|
}
|
|
296
292
|
|
|
297
|
-
async function start(
|
|
298
|
-
const { root } = await options(
|
|
293
|
+
async function start(directory) {
|
|
294
|
+
const { root } = await options(directory);
|
|
299
295
|
const control = process.env.LETMEKNOW_URL || "https://letmeknow.dev";
|
|
300
296
|
let send = () => false;
|
|
301
|
-
const watcher = watch(root, {
|
|
297
|
+
const watcher = chokidar.watch(root, { ignoreInitial: true });
|
|
298
|
+
watcher.on("all", (_event, filename) => {
|
|
302
299
|
if (!filename) { send({ type: "file_update", path: "/" }); return; }
|
|
303
300
|
const file = resolve(root, String(filename));
|
|
304
301
|
const path = relative(root, file).split(sep).join("/");
|
|
@@ -320,7 +317,7 @@ async function start(args) {
|
|
|
320
317
|
clearTimeout(retryTimer);
|
|
321
318
|
clearTimeout(connectionTimer);
|
|
322
319
|
try { socket?.close(); } catch {}
|
|
323
|
-
watcher.close();
|
|
320
|
+
await watcher.close();
|
|
324
321
|
process.exit(code);
|
|
325
322
|
};
|
|
326
323
|
process.once("SIGINT", () => void stop(0));
|
|
@@ -387,11 +384,29 @@ async function start(args) {
|
|
|
387
384
|
await new Promise(() => {});
|
|
388
385
|
}
|
|
389
386
|
|
|
390
|
-
|
|
391
|
-
|
|
387
|
+
let parsed;
|
|
388
|
+
try {
|
|
389
|
+
parsed = parseArgs({
|
|
390
|
+
args: process.argv.slice(2),
|
|
391
|
+
options: {
|
|
392
|
+
skill: { type: "boolean" },
|
|
393
|
+
help: { type: "boolean", short: "h" }
|
|
394
|
+
},
|
|
395
|
+
allowPositionals: true
|
|
396
|
+
});
|
|
397
|
+
} catch (cause) {
|
|
398
|
+
process.stderr.write(`letmeknow: ${cause instanceof Error ? cause.message : "invalid arguments"}\n`);
|
|
399
|
+
process.exit(1);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (parsed.values.skill) {
|
|
403
|
+
if (parsed.positionals.length > 0) { process.stderr.write("Usage: npx letmeknow-cli --skill\n"); process.exit(1); }
|
|
392
404
|
writeSync(1, readFileSync(new URL("../SKILL.md", import.meta.url)));
|
|
393
|
-
} else if (
|
|
405
|
+
} else if (parsed.values.help) {
|
|
394
406
|
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");
|
|
407
|
+
} else if (parsed.positionals.length > 1) {
|
|
408
|
+
process.stderr.write("letmeknow: only one directory may be provided\n");
|
|
409
|
+
process.exit(1);
|
|
395
410
|
} else {
|
|
396
|
-
try { await start(
|
|
411
|
+
try { await start(parsed.positionals[0]); } catch (cause) { process.stderr.write(`letmeknow: ${cause instanceof Error ? cause.message : "server failed"}\n`); process.exitCode = 1; }
|
|
397
412
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "letmeknow-cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.11",
|
|
4
4
|
"description": "A live static preview with agent-readable form submissions.",
|
|
5
5
|
"files": [
|
|
6
6
|
"bin",
|
|
@@ -27,5 +27,10 @@
|
|
|
27
27
|
"vitest": "^4.1.11",
|
|
28
28
|
"wrangler": "^4.126.0",
|
|
29
29
|
"ws": "^8.21.3"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"chokidar": "^5.0.0",
|
|
33
|
+
"ignore": "^7.0.6",
|
|
34
|
+
"mrmime": "^2.0.1"
|
|
30
35
|
}
|
|
31
36
|
}
|