letmeknow-cli 0.4.10 → 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.
Files changed (2) hide show
  1. package/bin/letmeknow.js +49 -45
  2. package/package.json +6 -1
package/bin/letmeknow.js CHANGED
@@ -1,38 +1,28 @@
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]+"/"):"/";
@@ -52,13 +42,13 @@ 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
50
  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)};
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)};
62
52
  const reload=()=>{if(reloadTimer)return;reloadTimer=setTimeout(()=>{save();location.reload()},75)};
63
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};
64
54
  const refreshStylesheet=link=>{const url=new URL(link.href,location.href);url.searchParams.set("_letmeknow",crypto.randomUUID());link.href=url.href};
@@ -149,7 +139,8 @@ function errorResponse(packet, status, message) {
149
139
  }
150
140
 
151
141
  function deniedPath(pathname) {
152
- 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)));
153
144
  }
154
145
 
155
146
  function inside(root, target) {
@@ -232,7 +223,7 @@ async function staticResponse(root, packet) {
232
223
  if (body.byteLength > MAX_BODY_BYTES) return errorResponse(packet, 413, "response body is too large");
233
224
  if (extname(target).toLowerCase() === ".html") body = htmlWithClient(body);
234
225
  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" });
226
+ return response(packet, 200, body, { "Content-Type": getMimeType(target) });
236
227
  }
237
228
 
238
229
  async function submission(packet) {
@@ -268,14 +259,8 @@ async function handleRequest(root, packet) {
268
259
  return staticResponse(root, packet);
269
260
  }
270
261
 
271
- function options(args) {
272
- let root;
273
- for (const argument of args) {
274
- if (argument.startsWith("-")) throw new Error(`unknown option: ${argument}`);
275
- if (root !== undefined) throw new Error("only one directory may be provided");
276
- root = resolve(argument);
277
- }
278
- root = root || process.cwd();
262
+ function options(directory) {
263
+ const root = resolve(directory || process.cwd());
279
264
  if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error(`directory does not exist: ${root}`);
280
265
  return realpath(root).then(root => ({ root }));
281
266
  }
@@ -305,11 +290,12 @@ function validSessionUrl(value) {
305
290
  return /^\/s\/[a-f0-9]{20}(?:\/|$)/.test(url.pathname);
306
291
  }
307
292
 
308
- async function start(args) {
309
- const { root } = await options(args);
293
+ async function start(directory) {
294
+ const { root } = await options(directory);
310
295
  const control = process.env.LETMEKNOW_URL || "https://letmeknow.dev";
311
296
  let send = () => false;
312
- const watcher = watch(root, { recursive: true, encoding: "utf8" }, (_event, filename) => {
297
+ const watcher = chokidar.watch(root, { ignoreInitial: true });
298
+ watcher.on("all", (_event, filename) => {
313
299
  if (!filename) { send({ type: "file_update", path: "/" }); return; }
314
300
  const file = resolve(root, String(filename));
315
301
  const path = relative(root, file).split(sep).join("/");
@@ -331,7 +317,7 @@ async function start(args) {
331
317
  clearTimeout(retryTimer);
332
318
  clearTimeout(connectionTimer);
333
319
  try { socket?.close(); } catch {}
334
- watcher.close();
320
+ await watcher.close();
335
321
  process.exit(code);
336
322
  };
337
323
  process.once("SIGINT", () => void stop(0));
@@ -398,11 +384,29 @@ async function start(args) {
398
384
  await new Promise(() => {});
399
385
  }
400
386
 
401
- if (process.argv[2] === "--skill") {
402
- 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); }
403
404
  writeSync(1, readFileSync(new URL("../SKILL.md", import.meta.url)));
404
- } else if (process.argv.slice(2).includes("--help") || process.argv.slice(2).includes("-h")) {
405
+ } else if (parsed.values.help) {
405
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);
406
410
  } else {
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; }
411
+ try { await start(parsed.positionals[0]); } catch (cause) { process.stderr.write(`letmeknow: ${cause instanceof Error ? cause.message : "server failed"}\n`); process.exitCode = 1; }
408
412
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "letmeknow-cli",
3
- "version": "0.4.10",
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
  }