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.
- package/bin/letmeknow.js +49 -45
- 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,
|
|
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]+"/"):"/";
|
|
@@ -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
|
|
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=
|
|
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
|
-
|
|
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":
|
|
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(
|
|
272
|
-
|
|
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(
|
|
309
|
-
const { root } = await options(
|
|
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, {
|
|
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
|
-
|
|
402
|
-
|
|
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 (
|
|
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(
|
|
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.
|
|
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
|
}
|