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 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
- 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.
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 and restores form values, checked controls, selections, focus, text selection, scroll position, and open `<details>` elements.
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, watch, writeSync } from "node:fs";
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 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
- };
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+":snapshot";
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: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}};
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 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)};
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);sessionStorage.removeItem(snapshotKey)}catch{}status(message.message)}
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
- return pathname.split("/").some(part => part === ".env" || part.startsWith(".env.") || part === ".git" || /\.(?:key|pem|p12|sqlite|db)$/i.test(part));
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": mimeTypes[extname(target).toLowerCase()] || "application/octet-stream" });
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(args) {
261
- let root;
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(args) {
298
- const { root } = await options(args);
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, { recursive: true, encoding: "utf8" }, (_event, filename) => {
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
- if (process.argv[2] === "--skill") {
391
- if (process.argv.length !== 3) { process.stderr.write("Usage: npx letmeknow-cli --skill\n"); process.exit(1); }
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 (process.argv.slice(2).includes("--help") || process.argv.slice(2).includes("-h")) {
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(process.argv.slice(2)); } catch (cause) { process.stderr.write(`letmeknow: ${cause instanceof Error ? cause.message : "server failed"}\n`); process.exitCode = 1; }
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.9",
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
  }